diff --git a/tests/pipeline/test_technical.py b/tests/pipeline/test_technical.py index 97734477..bcf233fc 100644 --- a/tests/pipeline/test_technical.py +++ b/tests/pipeline/test_technical.py @@ -13,7 +13,8 @@ from zipline.pipeline.term import AssetExists from zipline.pipeline.factors import ( BollingerBands, Aroon, - FastStochasticOscillator + FastStochasticOscillator, + IchimokuKinkoHyo, ) from zipline.testing import ExplodingObject, parameter_space from zipline.testing.fixtures import WithAssetFinder, ZiplineTestCase @@ -237,3 +238,119 @@ class TestFastStochasticOscillator(WithTechnicalFactor, ZiplineTestCase): ) assert_equal(out, expected_out_k) + + +class IchimokuKinkoHyoTestCase(ZiplineTestCase): + def test_ichimoku_kinko_hyo(self): + window_length = 52 + today = pd.Timestamp('2014', tz='utc') + nassets = 5 + assets = pd.Index(np.arange(nassets)) + days_col = np.arange(window_length)[:, np.newaxis] + highs = np.arange(nassets) + 2 + days_col + closes = np.arange(nassets) + 1 + days_col + lows = np.arange(nassets) + days_col + + tenkan_sen_length = 9 + kijun_sen_length = 26 + chikou_span_length = 26 + ichimoku_kinko_hyo = IchimokuKinkoHyo( + window_length=window_length, + tenkan_sen_length=tenkan_sen_length, + kijun_sen_length=kijun_sen_length, + chikou_span_length=chikou_span_length, + ) + + dtype = [ + ('tenkan_sen', 'f8'), + ('kijun_sen', 'f8'), + ('senkou_span_a', 'f8'), + ('senkou_span_b', 'f8'), + ('chikou_span', 'f8'), + ] + out = np.recarray( + shape=(nassets,), + dtype=dtype, + buf=np.empty(shape=(nassets,), dtype=dtype), + ) + ichimoku_kinko_hyo.compute( + today, + assets, + out, + highs, + lows, + closes, + tenkan_sen_length, + kijun_sen_length, + chikou_span_length, + ) + + expected_tenkan_sen = np.array([ + (53 + 43) / 2, + (54 + 44) / 2, + (55 + 45) / 2, + (56 + 46) / 2, + (57 + 47) / 2, + ]) + expected_kijun_sen = np.array([ + (53 + 26) / 2, + (54 + 27) / 2, + (55 + 28) / 2, + (56 + 29) / 2, + (57 + 30) / 2, + ]) + expected_senkou_span_a = (expected_tenkan_sen + expected_kijun_sen) / 2 + expected_senkou_span_b = np.array([ + (53 + 0) / 2, + (54 + 1) / 2, + (55 + 2) / 2, + (56 + 3) / 2, + (57 + 4) / 2, + ]) + expected_chikou_span = np.array([ + 27.0, + 28.0, + 29.0, + 30.0, + 31.0, + ]) + + assert_equal( + out.tenkan_sen, + expected_tenkan_sen, + msg='tenkan_sen', + ) + assert_equal( + out.kijun_sen, + expected_kijun_sen, + msg='kijun_sen', + ) + assert_equal( + out.senkou_span_a, + expected_senkou_span_a, + msg='senkou_span_a', + ) + assert_equal( + out.senkou_span_b, + expected_senkou_span_b, + msg='senkou_span_b', + ) + assert_equal( + out.chikou_span, + expected_chikou_span, + msg='chikou_span', + ) + + @parameter_space( + arg={'tenkan_sen_length', 'kijun_sen_length', 'chikou_span_length'}, + ) + def test_input_validation(self, arg): + window_length = 52 + + with self.assertRaises(ValueError) as e: + IchimokuKinkoHyo(**{arg: window_length + 1}) + + assert_equal( + str(e.exception), + '%s must be <= the window_length: 53 > 52' % arg, + ) diff --git a/tests/pipeline/test_term.py b/tests/pipeline/test_term.py index eb97964e..0cda474d 100644 --- a/tests/pipeline/test_term.py +++ b/tests/pipeline/test_term.py @@ -5,6 +5,8 @@ from collections import Counter from itertools import product from unittest import TestCase +from toolz import assoc + from zipline.assets import Asset from zipline.errors import ( DTypeNotSpecified, @@ -31,7 +33,12 @@ from zipline.pipeline.factors import RecarrayField from zipline.pipeline.sentinels import NotSpecified from zipline.pipeline.term import AssetExists, Slice from zipline.testing import parameter_space -from zipline.testing.predicates import assert_equal, assert_raises +from zipline.testing.predicates import ( + assert_equal, + assert_raises, + assert_raises_regex, + assert_regex, +) from zipline.utils.numpy_utils import ( bool_dtype, categorical_dtype, @@ -433,10 +440,50 @@ class ObjectIdentityTestCase(TestCase): with assert_raises(TypeError) as e: self.SomeFactorParameterized(a=[], b=[]) - assert_equal( + assert_regex( str(e.exception), - "SomeFactorParameterized expected a hashable value for parameter" - " 'a', but got [] instead.", + r"SomeFactorParameterized expected a hashable value for parameter" + r" '(a|b)', but got \[\] instead\.", + ) + + def test_parameterized_term_default_value(self): + defaults = {'a': 'default for a', 'b': 'default for b'} + + class F(Factor): + params = defaults + + inputs = (SomeDataSet.foo,) + dtype = 'f8' + window_length = 5 + + assert_equal(F().params, defaults) + assert_equal(F(a='new a').params, assoc(defaults, 'a', 'new a')) + assert_equal(F(b='new b').params, assoc(defaults, 'b', 'new b')) + assert_equal( + F(a='new a', b='new b').params, + {'a': 'new a', 'b': 'new b'}, + ) + + def test_parameterized_term_default_value_with_not_specified(self): + defaults = {'a': 'default for a', 'b': NotSpecified} + + class F(Factor): + params = defaults + + inputs = (SomeDataSet.foo,) + dtype = 'f8' + window_length = 5 + + pattern = r"F expected a keyword parameter 'b'\." + with assert_raises_regex(TypeError, pattern): + F() + with assert_raises_regex(TypeError, pattern): + F(a='new a') + + assert_equal(F(b='new b').params, assoc(defaults, 'b', 'new b')) + assert_equal( + F(a='new a', b='new b').params, + {'a': 'new a', 'b': 'new b'}, ) def test_bad_input(self): diff --git a/zipline/assets/symbol_rules.py b/zipline/assets/symbol_rules.py new file mode 100644 index 00000000..6a7fe0fc --- /dev/null +++ b/zipline/assets/symbol_rules.py @@ -0,0 +1,13 @@ +import re + + +_symbol_delimiter_regex = re.compile(r'[./\-_]') + + +def split_nasdaq(symbol): + sym = re.replace(_symbol_delimiter_regex, '', symbol) + return sym[:4], sym[4:] + + +def split_nyse(symbol): + return re.split(_symbol_delimiter_regex, symbol, maxsplit=1) diff --git a/zipline/pipeline/factors/__init__.py b/zipline/pipeline/factors/__init__.py index 481219b3..e32dac95 100644 --- a/zipline/pipeline/factors/__init__.py +++ b/zipline/pipeline/factors/__init__.py @@ -22,6 +22,7 @@ from .technical import ( ExponentialWeightedMovingAverage, ExponentialWeightedMovingStdDev, FastStochasticOscillator, + IchimokuKinkoHyo, MaxDrawdown, Returns, RSI, @@ -43,6 +44,7 @@ __all__ = [ 'ExponentialWeightedMovingStdDev', 'Factor', 'FastStochasticOscillator', + 'IchimokuKinkoHyo', 'Latest', 'MaxDrawdown', 'RecarrayField', diff --git a/zipline/pipeline/factors/technical.py b/zipline/pipeline/factors/technical.py index c7e26efe..8344130a 100644 --- a/zipline/pipeline/factors/technical.py +++ b/zipline/pipeline/factors/technical.py @@ -445,8 +445,7 @@ class BollingerBands(CustomFactor): class Aroon(CustomFactor): """ Aroon technical indicator. - https://www.fidelity.com/learning-center/trading-investing/technical - -analysis/technical-indicator-guide/aroon-indicator + https://www.fidelity.com/learning-center/trading-investing/technical-analysis/technical-indicator-guide/aroon-indicator # noqa **Defaults Inputs:** USEquityPricing.low, USEquityPricing.high @@ -521,3 +520,73 @@ class FastStochasticOscillator(CustomFactor): global_dict={}, out=out, ) + + +class IchimokuKinkoHyo(CustomFactor): + """Compute the various metrics for the Ichimoku Kinko Hyo (Ichimoku Cloud). + http://stockcharts.com/school/doku.php?id=chart_school:technical_indicators:ichimoku_cloud # noqa + + **Default Inputs:** :data:`zipline.pipeline.data.USEquityPricing.high` + :data:`zipline.pipeline.data.USEquityPricing.low` + :data:`zipline.pipeline.data.USEquityPricing.close` + **Default Window Length:** 52 + + Parameters + ---------- + window_length : int > 0 + The length the the window for the senkou span b. + tenkan_sen_length : int >= 0, <= window_length + The length of the window for the tenkan-sen. + kijun_sen_length : int >= 0, <= window_length + The length of the window for the kijou-sen. + chikou_span_length : int >= 0, <= window_length + The lag for the chikou span. + """ + + params = { + 'tenkan_sen_length': 9, + 'kijun_sen_length': 26, + 'chikou_span_length': 26, + } + inputs = USEquityPricing.high, USEquityPricing.close + outputs = ( + 'tenkan_sen', + 'kijun_sen', + 'senkou_span_a', + 'senkou_span_b', + 'chikou_span', + ) + window_length = 52 + + def _validate(self): + super(IchimokuKinkoHyo, self)._validate() + for k, v in self.params.items(): + if v > self.window_length: + raise ValueError( + '%s must be <= the window_length: %s > %s' % ( + k, v, self.window_length, + ), + ) + + def compute(self, + today, + assets, + out, + high, + low, + close, + tenkan_sen_length, + kijun_sen_length, + chikou_span_length): + + out.tenkan_sen = tenkan_sen = ( + high[-tenkan_sen_length:].max(axis=0) + + low[-tenkan_sen_length:].min(axis=0) + ) / 2 + out.kijun_sen = kijun_sen = ( + high[-kijun_sen_length:].max(axis=0) + + low[-kijun_sen_length:].min(axis=0) + ) / 2 + out.senkou_span_a = (tenkan_sen + kijun_sen) / 2 + out.senkou_span_b = (high.max(axis=0) + low.min(axis=0)) / 2 + out.chikou_span = close[chikou_span_length] diff --git a/zipline/pipeline/term.py b/zipline/pipeline/term.py index 563a0c81..4671daed 100644 --- a/zipline/pipeline/term.py +++ b/zipline/pipeline/term.py @@ -3,6 +3,7 @@ Base class for Filters, Factors and Classifiers """ from abc import ABCMeta, abstractproperty from bisect import insort +from collections import Mapping from weakref import WeakValueDictionary from numpy import ( @@ -146,10 +147,16 @@ class Term(with_metaclass(ABCMeta, object)): TypeError Raised if any parameter values are not passed or not hashable. """ + params = cls.params + if not isinstance(params, Mapping): + params = {k: NotSpecified for k in params} param_values = [] - for key in cls.params: + for key, default_value in params.items(): try: - value = kwargs.pop(key) + value = kwargs.pop(key, default_value) + if value is NotSpecified: + raise KeyError(key) + # Check here that the value is hashable so that we fail here # instead of trying to hash the param values tuple later. hash(value) @@ -171,8 +178,8 @@ class Term(with_metaclass(ABCMeta, object)): ) ) - param_values.append(value) - return tuple(zip(cls.params, param_values)) + param_values.append((key, value)) + return tuple(param_values) def __init__(self, *args, **kwargs): """ diff --git a/zipline/testing/predicates.py b/zipline/testing/predicates.py index b25167c3..e2f4078b 100644 --- a/zipline/testing/predicates.py +++ b/zipline/testing/predicates.py @@ -1,6 +1,8 @@ +from contextlib import contextmanager import datetime from functools import partial import inspect +import re from nose.tools import ( # noqa assert_almost_equal, @@ -205,6 +207,47 @@ def assert_is_subclass(subcls, cls, msg=''): ) +def assert_regex(result, expected, msg=''): + """Assert that ``expected`` matches the result. + + Parameters + ---------- + result : str + The string to search. + expected : str or compiled regex + The pattern to search for in ``result``. + msg : str, optional + An extra assertion message to print if this fails. + """ + assert re.search(expected, result), ( + '%s%r not found in %r' % (_fmt_msg(msg), expected, result) + ) + + +@contextmanager +def assert_raises_regex(exc, pattern, msg=''): + """Assert that some exception is raised in a context and that the message + matches some pattern. + + Parameters + ---------- + exc : type or tuple[type] + The exception type or types to expect. + pattern : str or compiled regex + The pattern to search for in the str of the raised exception. + msg : str, optional + An extra assertion message to print if this fails. + """ + try: + yield + except exc as e: + assert re.search(pattern, str(e)), ( + '%s%r not found in %r' % (_fmt_msg(msg), pattern, str(e)) + ) + else: + raise AssertionError('%s%s was not raised' % (_fmt_msg(msg), exc)) + + @dispatch(object, object) def assert_equal(result, expected, path=(), msg='', **kwargs): """Assert that two objects are equal using the ``==`` operator.