mirror of
https://github.com/wassname/catalyst.git
synced 2026-07-01 22:55:52 +08:00
Merge pull request #1199 from quantopian/boybands-factor
BollingerBands factor
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
~~~~~~~~~~~~~
|
||||
|
||||
@@ -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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user