Merge pull request #1199 from quantopian/boybands-factor

BollingerBands factor
This commit is contained in:
Joe Jevnik
2016-05-13 15:35:10 -04:00
11 changed files with 378 additions and 18 deletions
+3
View File
@@ -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
+8 -1
View File
@@ -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
~~~~~~~~~~~~~
+140
View File
@@ -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)
+34 -8
View File
@@ -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):
+91
View File
@@ -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)
+2
View File
@@ -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',
+30
View File
@@ -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
+4 -3
View File
@@ -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),
)
+3 -4
View File
@@ -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.
+28
View File
@@ -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.
+35 -2
View File
@@ -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)