diff --git a/docs/source/appendix.rst b/docs/source/appendix.rst index 800ac8ec..96696bac 100644 --- a/docs/source/appendix.rst +++ b/docs/source/appendix.rst @@ -206,6 +206,9 @@ Pipeline API .. autoclass:: zipline.pipeline.factors.AverageDollarVolume :members: +.. autoclass:: zipline.pipeline.factors.BollingerBands + :members: + .. autoclass:: zipline.pipeline.filters.Filter :members: __and__, __or__ :exclude-members: dtype diff --git a/docs/source/whatsnew/1.0.0.txt b/docs/source/whatsnew/1.0.0.txt index 64e73a74..2aa6de05 100644 --- a/docs/source/whatsnew/1.0.0.txt +++ b/docs/source/whatsnew/1.0.0.txt @@ -127,6 +127,10 @@ Enhancements ``element_of`` is defined for all classifiers. The remaining methods are only defined for strings. (:issue:`1174`) +* Added :class:`~zipline.pipeline.factors.BollingerBands` factor. This factor + implements the Bollinger Bands technical indicator: + https://en.wikipedia.org/wiki/Bollinger_Bands (:issue:`1199`). + Experimental Features ~~~~~~~~~~~~~~~~~~~~~ @@ -165,7 +169,10 @@ None Documentation ~~~~~~~~~~~~~ -None +* Updated documentation for the API methods (:issue:`1188`). + +* Updated release process to mention that docs should be built with python 3 + (:issue:`1188`). Miscellaneous ~~~~~~~~~~~~~ diff --git a/tests/pipeline/test_technical.py b/tests/pipeline/test_technical.py new file mode 100644 index 00000000..313337e4 --- /dev/null +++ b/tests/pipeline/test_technical.py @@ -0,0 +1,140 @@ +import numpy as np +import pandas as pd +import talib + +from zipline.lib.adjusted_array import AdjustedArray +from zipline.pipeline import TermGraph +from zipline.pipeline.data import USEquityPricing +from zipline.pipeline.engine import SimplePipelineEngine +from zipline.pipeline.term import AssetExists +from zipline.pipeline.factors import BollingerBands +from zipline.testing import ExplodingObject, parameter_space +from zipline.testing.fixtures import WithAssetFinder, ZiplineTestCase +from zipline.testing.predicates import assert_equal + + +class WithTechnicalFactor(WithAssetFinder): + """ZiplineTestCase fixture for testing technical factors. + """ + ASSET_FINDER_EQUITY_SIDS = tuple(range(5)) + START_DATE = pd.Timestamp('2014-01-01', tz='utc') + + @classmethod + def init_class_fixtures(cls): + super(WithTechnicalFactor, cls).init_class_fixtures() + cls.ndays = ndays = 24 + cls.nassets = nassets = len(cls.ASSET_FINDER_EQUITY_SIDS) + cls.dates = dates = pd.date_range(cls.START_DATE, periods=ndays) + cls.assets = pd.Index(cls.asset_finder.sids) + cls.engine = SimplePipelineEngine( + lambda column: ExplodingObject(), + dates, + cls.asset_finder, + ) + cls.asset_exists = exists = np.full((ndays, nassets), True, dtype=bool) + cls.asset_exists_masked = masked = exists.copy() + masked[:, -1] = False + + def run_graph(self, graph, initial_workspace, mask_sid): + initial_workspace.setdefault( + AssetExists(), + self.asset_exists_masked if mask_sid else self.asset_exists, + ) + return self.engine.compute_chunk( + graph, + self.dates, + self.assets, + initial_workspace, + ) + + +class BollingerBandsTestCase(WithTechnicalFactor, ZiplineTestCase): + @classmethod + def init_class_fixtures(cls): + super(BollingerBandsTestCase, cls).init_class_fixtures() + cls._closes = closes = ( + np.arange(cls.ndays, dtype=float)[:, np.newaxis] + + np.arange(cls.nassets, dtype=float) * 100 + ) + cls._closes_masked = masked = closes.copy() + masked[:, -1] = np.nan + + def closes(self, masked): + return self._closes_masked if masked else self._closes + + def expected(self, window_length, k, closes): + """Compute the expected data (without adjustments) for the given + window, k, and closes array. + + This uses talib.BBANDS to generate the expected data. + """ + lower_cols = [] + middle_cols = [] + upper_cols = [] + for n in range(self.nassets): + close_col = closes[:, n] + if np.isnan(close_col).all(): + # ta-lib doesn't deal well with all nans. + upper, middle, lower = [np.full(self.ndays, np.nan)] * 3 + else: + upper, middle, lower = talib.BBANDS( + close_col, + window_length, + k, + k, + ) + + upper_cols.append(upper) + middle_cols.append(middle) + lower_cols.append(lower) + + # Stack all of our uppers, middles, lowers into three 2d arrays + # whose columns are the sids. After that, slice off only the + # rows we care about. + where = np.s_[window_length - 1:] + uppers = np.column_stack(upper_cols)[where] + middles = np.column_stack(middle_cols)[where] + lowers = np.column_stack(lower_cols)[where] + return uppers, middles, lowers + + @parameter_space( + window_length={5, 10, 20}, + k={1.5, 2, 2.5}, + mask_sid={True, False}, + ) + def test_bollinger_bands(self, window_length, k, mask_sid): + closes = self.closes(mask_sid) + result = self.run_graph( + TermGraph({ + 'f': BollingerBands( + window_length=window_length, + k=k, + ), + }), + initial_workspace={ + USEquityPricing.close: AdjustedArray( + closes, + np.full_like(closes, True, dtype=bool), + {}, + np.nan, + ), + }, + mask_sid=mask_sid, + )['f'] + + expected_upper, expected_middle, expected_lower = self.expected( + window_length, + k, + closes, + ) + + assert_equal(result.upper, expected_upper) + assert_equal(result.middle, expected_middle) + assert_equal(result.lower, expected_lower) + + def test_bollinger_bands_output_ordering(self): + bbands = BollingerBands(window_length=5, k=2) + lower, middle, upper = bbands + self.assertIs(lower, bbands.lower) + self.assertIs(middle, bbands.middle) + self.assertIs(upper, bbands.upper) diff --git a/tests/pipeline/test_term.py b/tests/pipeline/test_term.py index 4b7bf0de..062bd9e4 100644 --- a/tests/pipeline/test_term.py +++ b/tests/pipeline/test_term.py @@ -27,6 +27,7 @@ from zipline.pipeline.data.testing import TestingDataSet from zipline.pipeline.term import AssetExists, NotSpecified from zipline.pipeline.expression import NUMEXPR_MATH_FUNCS from zipline.testing import parameter_space +from zipline.testing.predicates import assert_equal, assert_raises from zipline.utils.numpy_utils import ( bool_dtype, categorical_dtype, @@ -358,20 +359,20 @@ class ObjectIdentityTestCase(TestCase): method = getattr(f, funcname) self.assertIs(method(), method()) + class SomeFactorParameterized(SomeFactor): + params = ('a', 'b') + def test_parameterized_term(self): - class SomeFactorParameterized(SomeFactor): - params = ('a', 'b') - - f = SomeFactorParameterized(a=1, b=2) + f = self.SomeFactorParameterized(a=1, b=2) self.assertEqual(f.params, {'a': 1, 'b': 2}) - g = SomeFactorParameterized(a=1, b=3) - h = SomeFactorParameterized(a=2, b=2) + g = self.SomeFactorParameterized(a=1, b=3) + h = self.SomeFactorParameterized(a=2, b=2) self.assertDifferentObjects(f, g, h) - f2 = SomeFactorParameterized(a=1, b=2) - f3 = SomeFactorParameterized(b=2, a=1) + f2 = self.SomeFactorParameterized(a=1, b=2) + f3 = self.SomeFactorParameterized(b=2, a=1) self.assertSameObject(f, f2, f3) self.assertEqual(f.params['a'], 1) @@ -379,6 +380,31 @@ class ObjectIdentityTestCase(TestCase): self.assertEqual(f.window_length, SomeFactor.window_length) self.assertEqual(f.inputs, tuple(SomeFactor.inputs)) + def test_parameterized_term_non_hashable_arg(self): + with assert_raises(TypeError) as e: + self.SomeFactorParameterized(a=[], b=1) + assert_equal( + str(e.exception), + "SomeFactorParameterized expected a hashable value for parameter" + " 'a', but got [] instead.", + ) + + with assert_raises(TypeError) as e: + self.SomeFactorParameterized(a=1, b=[]) + assert_equal( + str(e.exception), + "SomeFactorParameterized expected a hashable value for parameter" + " 'b', but got [] instead.", + ) + + with assert_raises(TypeError) as e: + self.SomeFactorParameterized(a=[], b=[]) + assert_equal( + str(e.exception), + "SomeFactorParameterized expected a hashable value for parameter" + " 'a', but got [] instead.", + ) + def test_bad_input(self): class SomeFactor(Factor): diff --git a/tests/utils/test_metautils.py b/tests/utils/test_metautils.py new file mode 100644 index 00000000..ad864d9e --- /dev/null +++ b/tests/utils/test_metautils.py @@ -0,0 +1,91 @@ +from zipline.testing.fixtures import ZiplineTestCase +from zipline.testing.predicates import ( + assert_equal, + assert_is, + assert_is_instance, + assert_is_subclass, + assert_true, +) +from zipline.utils.metautils import compose_types, with_metaclasses + + +class C(object): + @staticmethod + def f(): + return 'C.f' + + def delegate(self): + return 'C.delegate', super(C, self).delegate() + + +class D(object): + @staticmethod + def f(): + return 'D.f' + + @staticmethod + def g(): + return 'D.g' + + def delegate(self): + return 'D.delegate' + + +class ComposeTypesTestCase(ZiplineTestCase): + + def test_identity(self): + assert_is( + compose_types(C), + C, + msg='compose_types of a single class should be identity', + ) + + def test_compose(self): + composed = compose_types(C, D) + + assert_is_subclass(composed, C) + assert_is_subclass(composed, D) + + def test_compose_mro(self): + composed = compose_types(C, D) + + assert_equal(composed.f(), C.f()) + assert_equal(composed.g(), D.g()) + + assert_equal(composed().delegate(), ('C.delegate', 'D.delegate')) + + +class M(type): + def __new__(mcls, name, bases, dict_): + dict_['M'] = True + return super(M, mcls).__new__(mcls, name, bases, dict_) + + +class N(type): + def __new__(mcls, name, bases, dict_): + dict_['N'] = True + return super(N, mcls).__new__(mcls, name, bases, dict_) + + +class WithMetaclassesTestCase(ZiplineTestCase): + def test_with_metaclasses_no_subclasses(self): + class E(with_metaclasses((M, N))): + pass + + assert_true(E.M) + assert_true(E.N) + + assert_is_instance(E, M) + assert_is_instance(E, N) + + def test_with_metaclasses_with_subclasses(self): + class E(with_metaclasses((M, N), C, D)): + pass + + assert_true(E.M) + assert_true(E.N) + + assert_is_instance(E, M) + assert_is_instance(E, N) + assert_is_subclass(E, C) + assert_is_subclass(E, D) diff --git a/zipline/pipeline/factors/__init__.py b/zipline/pipeline/factors/__init__.py index b5c7e2e8..d59de274 100644 --- a/zipline/pipeline/factors/__init__.py +++ b/zipline/pipeline/factors/__init__.py @@ -15,6 +15,7 @@ from .events import ( ) from .technical import ( AverageDollarVolume, + BollingerBands, EWMA, EWMSTD, ExponentialWeightedMovingAverage, @@ -28,6 +29,7 @@ from .technical import ( ) __all__ = [ + 'BollingerBands', 'BusinessDaysSince13DFilingsDate', 'BusinessDaysSinceBuybackAuth', 'BusinessDaysSinceDividendAnnouncement', diff --git a/zipline/pipeline/factors/technical.py b/zipline/pipeline/factors/technical.py index f67471fc..74b02c2b 100644 --- a/zipline/pipeline/factors/technical.py +++ b/zipline/pipeline/factors/technical.py @@ -29,6 +29,7 @@ from zipline.utils.math_utils import ( nanargmax, nanmax, nanmean, + nanstd, nansum, ) from .factor import CustomFactor @@ -396,3 +397,32 @@ class ExponentialWeightedMovingStdDev(_ExponentialWeightedFactor): # Convenience aliases. EWMA = ExponentialWeightedMovingAverage EWMSTD = ExponentialWeightedMovingStdDev + + +class BollingerBands(CustomFactor): + """ + Bollinger Bands technical indicator. + https://en.wikipedia.org/wiki/Bollinger_Bands + + **Default Inputs:** :data:`zipline.pipeline.data.USEquityPricing.close` + + Parameters + ---------- + inputs : length-1 iterable[BoundColumn] + The expression over which to compute bollinger bands. + window_length : int > 0 + Length of the lookback window over which to compute the bollinger + bands. + k : float + The number of standard deviations to add or subtract to create the + upper and lower bands. + """ + params = ('k',) + inputs = (USEquityPricing.close,) + outputs = 'lower', 'middle', 'upper' + + def compute(self, today, assets, out, close, k): + difference = k * nanstd(close, axis=0) + out.middle = middle = nanmean(close, axis=0) + out.upper = middle + difference + out.lower = middle - difference diff --git a/zipline/pipeline/term.py b/zipline/pipeline/term.py index 4b04d465..942c7182 100644 --- a/zipline/pipeline/term.py +++ b/zipline/pipeline/term.py @@ -131,8 +131,7 @@ class Term(with_metaclass(ABCMeta, object)): value = kwargs.pop(key) # Check here that the value is hashable so that we fail here # instead of trying to hash the param values tuple later. - hash(key) - param_values.append(value) + hash(value) except KeyError: raise TypeError( "{typename} expected a keyword parameter {name!r}.".format( @@ -150,6 +149,8 @@ class Term(with_metaclass(ABCMeta, object)): value=value, ) ) + + param_values.append(value) return tuple(zip(cls.params, param_values)) @staticmethod @@ -268,7 +269,7 @@ class Term(with_metaclass(ABCMeta, object)): if hasattr(self, name): raise TypeError( "Parameter {name!r} conflicts with already-present" - "attribute with value {value!r}.".format( + " attribute with value {value!r}.".format( name=name, value=getattr(self, name), ) diff --git a/zipline/testing/fixtures.py b/zipline/testing/fixtures.py index 16931110..7fbd61d0 100644 --- a/zipline/testing/fixtures.py +++ b/zipline/testing/fixtures.py @@ -12,7 +12,6 @@ import numpy as np import pandas as pd import responses - from .core import ( create_daily_bar_data, create_minute_bar_data, @@ -37,7 +36,7 @@ from ..finance.trading import TradingEnvironment from ..utils import tradingcalendar, factory from ..utils.classproperty import classproperty from ..utils.final import FinalMeta, final -from ..utils.metautils import compose_types +from ..utils.metautils import with_metaclasses from .core import tmp_asset_finder, make_simple_equity_info, gen_calendars from zipline.pipeline import Pipeline, SimplePipelineEngine from zipline.pipeline.loaders.testing import make_seeded_random_loader @@ -818,8 +817,8 @@ class WithAdjustmentReader(WithBcolzDailyBarReader): cls.adjustment_reader = SQLiteAdjustmentReader(conn) -class WithPipelineEventDataLoader(with_metaclass( - compose_types(ABCMeta, type(ZiplineTestCase)), WithAssetFinder)): +class WithPipelineEventDataLoader( + with_metaclasses((type(ZiplineTestCase), ABCMeta), WithAssetFinder)): """ ZiplineTestCase mixin providing common test methods/behaviors for event data loaders. diff --git a/zipline/testing/predicates.py b/zipline/testing/predicates.py index 93f3610e..b83bdbf8 100644 --- a/zipline/testing/predicates.py +++ b/zipline/testing/predicates.py @@ -141,6 +141,34 @@ def _fmt_msg(msg): return msg + '\n' +def _safe_cls_name(cls): + try: + return cls.__name__ + except AttributeError: + return repr(cls) + + +def assert_is_subclass(subcls, cls, msg=''): + """Assert that ``subcls`` is a subclass of ``cls``. + + Parameters + ---------- + subcls : type + The type to check. + cls : type + The type to check ``subcls`` against. + msg : str, optional + An extra assertion message to print if this fails. + """ + assert issubclass(subcls, cls), ( + '%s is not a subclass of %s\n%s' % ( + _safe_cls_name(subcls), + _safe_cls_name(cls), + msg, + ) + ) + + @dispatch(object, object) def assert_equal(result, expected, path=(), msg='', **kwargs): """Assert that two objects are equal using the ``==`` operator. diff --git a/zipline/utils/metautils.py b/zipline/utils/metautils.py index fa0dd0ed..a2f86080 100644 --- a/zipline/utils/metautils.py +++ b/zipline/utils/metautils.py @@ -1,7 +1,9 @@ from operator import attrgetter +import six -def compose_types(a, b, *cs): + +def compose_types(a, *cs): """Compose multiple classes together. Parameters @@ -66,9 +68,40 @@ def compose_types(a, b, *cs): Always using ``super()`` to dispatch to your superclass is best practices anyways so most classes should compose without much special considerations. """ - mcls = (a, b) + cs + if not cs: + # if there are no types to compose then just return the single type + return a + + mcls = (a,) + cs return type( 'compose_types(%s)' % ', '.join(map(attrgetter('__name__'), mcls)), mcls, {}, ) + + +def with_metaclasses(metaclasses, *bases): + """Make a class inheriting from ``bases`` whose metaclass inherits from + all of ``metaclasses``. + + Like :func:`six.with_metaclass`, but allows multiple metaclasses. + + Parameters + ---------- + metaclasses : iterable[type] + A tuple of types to use as metaclasses. + *bases : tuple[type] + A tuple of types to use as bases. + + Returns + ------- + base : type + A subtype of ``bases`` whose metaclass is a subtype of ``metaclasses``. + + Notes + ----- + The metaclasses must be written to support cooperative multiple + inheritance. This means that they must delegate all calls to ``super()`` + instead of inlining their super class by name. + """ + return six.with_metaclass(compose_types(*metaclasses), *bases)