diff --git a/tests/test_blotter.py b/tests/test_blotter.py index b47a282a..dbb7856c 100644 --- a/tests/test_blotter.py +++ b/tests/test_blotter.py @@ -1,35 +1,53 @@ -import math +# +# Copyright 2014 Quantopian, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. from nose_parameterized import parameterized from unittest import TestCase -from zipline.finance.blotter import round_for_minimum_price_variation +from zipline.finance.blotter import Blotter +from zipline.finance.execution import ( + LimitOrder, + MarketOrder, + StopLimitOrder, + StopOrder, +) + +from zipline.utils.test_utils import( + setup_logger, + teardown_logger, +) class BlotterTestCase(TestCase): - @parameterized.expand([(0.00, 0.00), - (0.01, 0.01), - (0.0005, 0.00), - (1.006, 1.00), - (1.0095, 1.01), - (1.00949, 1.00), - (1.0005, 1.00)]) - def test_round_for_minimum_price_variation_buy(self, price, expected): - result = round_for_minimum_price_variation(price, is_buy=True) - self.assertEqual(result, expected) - self.assertEqual(math.copysign(1.0, result), - math.copysign(1.0, expected)) + def setUp(self): + setup_logger(self) - @parameterized.expand([(0.00, 0.00), - (0.01, 0.01), - (0.0005, 0.00), - (1.006, 1.01), - (1.0005, 1.00), - (1.00051, 1.01), - (1.0095, 1.01)]) - def test_round_for_minimum_price_variation_sell(self, price, expected): - result = round_for_minimum_price_variation(price, is_buy=False) - self.assertEqual(result, expected) - self.assertEqual(math.copysign(1.0, result), - math.copysign(1.0, expected)) + def tearDown(self): + teardown_logger(self) + + @parameterized.expand([(MarketOrder(), None, None), + (LimitOrder(10), 10, None), + (StopOrder(10), None, 10), + (StopLimitOrder(10, 20), 10, 20)]) + def test_blotter_order_types(self, style_obj, expected_lmt, expected_stp): + + blotter = Blotter() + + blotter.order(24, 100, style_obj) + result = blotter.open_orders[24][0] + + self.assertEqual(result.limit, expected_lmt) + self.assertEqual(result.stop, expected_stp) diff --git a/tests/test_execution_styles.py b/tests/test_execution_styles.py new file mode 100644 index 00000000..8aa5de14 --- /dev/null +++ b/tests/test_execution_styles.py @@ -0,0 +1,135 @@ +# +# Copyright 2014 Quantopian, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from unittest import TestCase + +from six.moves import range + +from nose_parameterized import parameterized + +from zipline.finance.execution import ( + LimitOrder, + MarketOrder, + StopLimitOrder, + StopOrder, +) + +from zipline.utils.test_utils import( + setup_logger, + teardown_logger, +) + + +class ExecutionStyleTestCase(TestCase): + """ + Tests for zipline ExecutionStyle classes. + """ + + epsilon = .000001 + + # Input, expected for buy, expected for sell. + EXPECTED_PRICE_ROUNDING = [ + (0.00, 0.00, 0.00), + (0.0005, 0.00, 0.00), + (1.0005, 1.00, 1.00), # Lowest value to round down on sell. + (1.0005 + epsilon, 1.00, 1.01), + (1.0095 - epsilon, 1.0, 1.01), + (1.0095, 1.01, 1.01), # Highest value to round up on buy. + (0.01, 0.01, 0.01) + ] + + # Test that the same rounding behavior is maintained if we add between 1 + # and 10 to all values, because floating point math is made of lies. + EXPECTED_PRICE_ROUNDING += [ + (x + delta, y + delta, z + delta) + for (x, y, z) in EXPECTED_PRICE_ROUNDING + for delta in range(1, 10) + ] + + INVALID_PRICES = [(-1,), (-1.0,), (0 - epsilon,)] + + def setUp(self): + setup_logger(self) + + def tearDown(self): + teardown_logger(self) + + @parameterized.expand(INVALID_PRICES) + def test_invalid_prices(self, price): + """ + Test that execution styles throw appropriate exceptions upon receipt + of an invalid price field. + """ + with self.assertRaises(ValueError): + LimitOrder(price) + + with self.assertRaises(ValueError): + StopOrder(price) + + for lmt, stp in [(price, 1), (1, price), (price, price)]: + with self.assertRaises(ValueError): + StopLimitOrder(lmt, stp) + + def test_market_order_prices(self): + """ + Basic unit tests for the MarketOrder class. + """ + style = MarketOrder() + + self.assertEqual(style.get_limit_price(True), None) + self.assertEqual(style.get_limit_price(False), None) + + self.assertEqual(style.get_stop_price(True), None) + self.assertEqual(style.get_stop_price(False), None) + + @parameterized.expand(EXPECTED_PRICE_ROUNDING) + def test_limit_order_prices(self, price, expected_buy, expected_sell): + """ + Test price getters for the LimitOrder class. + """ + style = LimitOrder(price) + + self.assertEqual(expected_buy, style.get_limit_price(True)) + self.assertEqual(expected_sell, style.get_limit_price(False)) + + self.assertEqual(None, style.get_stop_price(True)) + self.assertEqual(None, style.get_stop_price(False)) + + @parameterized.expand(EXPECTED_PRICE_ROUNDING) + def test_stop_order_prices(self, price, expected_buy, expected_sell): + """ + Test price getters for StopOrder class. + """ + style = StopOrder(price) + + self.assertEqual(None, style.get_limit_price(True)) + self.assertEqual(None, style.get_limit_price(False)) + + self.assertEqual(expected_buy, style.get_stop_price(True)) + self.assertEqual(expected_sell, style.get_stop_price(False)) + + @parameterized.expand(EXPECTED_PRICE_ROUNDING) + def test_stop_limit_order_prices(self, price, expected_buy, expected_sell): + """ + Test price getters for StopLimitOrder class. + """ + + style = StopLimitOrder(price, price + 1) + + self.assertEqual(expected_buy, style.get_limit_price(True)) + self.assertEqual(expected_sell, style.get_limit_price(False)) + + self.assertEqual(expected_buy + 1, style.get_stop_price(True)) + self.assertEqual(expected_sell + 1, style.get_stop_price(False)) diff --git a/tests/test_finance.py b/tests/test_finance.py index 3a810f88..7eec21b8 100644 --- a/tests/test_finance.py +++ b/tests/test_finance.py @@ -40,6 +40,7 @@ from zipline.finance.blotter import Blotter from zipline.gens.composites import date_sorted_sources from zipline.finance import trading +from zipline.finance.execution import MarketOrder, LimitOrder from zipline.finance.trading import SimulationParameters from zipline.finance.performance import PerformanceTracker @@ -319,7 +320,7 @@ class FinanceTestCase(TestCase): for i in range(order_count): blotter.set_date(order_date) - blotter.order(sid, order_amount * alternator ** i, None, None) + blotter.order(sid, order_amount * alternator ** i, MarketOrder()) order_date = order_date + order_interval # move after market orders to just after market next @@ -400,8 +401,8 @@ class FinanceTestCase(TestCase): # set up two open limit orders with very low limit prices, # one for sid 1 and one for sid 2 - blotter.order(1, 100, 10, None, None) - blotter.order(2, 100, 10, None, None) + blotter.order(1, 100, LimitOrder(10)) + blotter.order(2, 100, LimitOrder(10)) # send in a split for sid 2 split_event = factory.create_split(2, 0.33333, diff --git a/zipline/algorithm.py b/zipline/algorithm.py index aa6d6f7d..ea0165b5 100644 --- a/zipline/algorithm.py +++ b/zipline/algorithm.py @@ -29,30 +29,38 @@ from zipline.errors import ( UnsupportedSlippageModel, OverrideSlippagePostInit, UnsupportedCommissionModel, - OverrideCommissionPostInit + OverrideCommissionPostInit, + UnsupportedOrderParameters +) + +from zipline.finance import trading +from zipline.finance.blotter import Blotter +from zipline.finance.commission import PerShare, PerTrade, PerDollar +from zipline.finance.constants import ANNUALIZER +from zipline.finance.execution import ( + LimitOrder, + MarketOrder, + StopLimitOrder, + StopOrder, ) from zipline.finance.performance import PerformanceTracker -from zipline.sources import DataFrameSource, DataPanelSource -from zipline.utils.factory import create_simulation_parameters -from zipline.utils.api_support import ZiplineAPI, api_method -from zipline.transforms.utils import StatefulTransform from zipline.finance.slippage import ( VolumeShareSlippage, SlippageModel, transact_partial ) -from zipline.finance.commission import PerShare, PerTrade, PerDollar -from zipline.finance.blotter import Blotter -from zipline.finance.constants import ANNUALIZER -from zipline.finance import trading -import zipline.protocol -from zipline.protocol import Event - from zipline.gens.composites import ( date_sorted_sources, sequential_transforms, ) from zipline.gens.tradesimulation import AlgorithmSimulator +from zipline.sources import DataFrameSource, DataPanelSource +from zipline.transforms.utils import StatefulTransform +from zipline.utils.api_support import ZiplineAPI, api_method +from zipline.utils.factory import create_simulation_parameters + +import zipline.protocol +from zipline.protocol import Event from zipline.history import HistorySpec from zipline.history.history_container import HistoryContainer @@ -463,8 +471,70 @@ class TradingAlgorithm(object): self._recorded_vars[name] = value @api_method - def order(self, sid, amount, limit_price=None, stop_price=None): - return self.blotter.order(sid, amount, limit_price, stop_price) + def order(self, sid, amount, + limit_price=None, + stop_price=None, + style=None): + """ + Place an order using the specified parameters. + """ + # Raises a ZiplineError if invalid parameters are detected. + self.validate_order_params(sid, + amount, + limit_price, + stop_price, + style) + + # Convert deprecated limit_price and stop_price parameters to use + # ExecutionStyle objects. + style = self.__convert_order_params_for_blotter(limit_price, + stop_price, + style) + return self.blotter.order(sid, amount, style) + + def validate_order_params(self, + sid, + amount, + limit_price, + stop_price, + style): + """ + Helper method for validating parameters to the order API function. + + Raises an UnsupportedOrderParameters if invalid arguments are found. + """ + if style: + if limit_price: + raise UnsupportedOrderParameters( + msg="Passing both limit_price and style is not supported." + ) + + if stop_price: + raise UnsupportedOrderParameters( + msg="Passing both stop_price and style is not supported." + ) + + @staticmethod + def __convert_order_params_for_blotter(limit_price, stop_price, style): + """ + Helper method for converting deprecated limit_price and stop_price + arguments into ExecutionStyle instances. + + This function assumes that either style == None or (limit_price, + stop_price) == (None, None). + """ + # TODO_SS: DeprecationWarning for usage of limit_price and stop_price. + if style: + assert (limit_price, stop_price) == (None, None) + return style + if limit_price and stop_price: + return StopLimitOrder(limit_price, stop_price) + if limit_price: + return LimitOrder(limit_price) + if stop_price: + return StopOrder(stop_price) + else: + return MarketOrder() @api_method def order_value(self, sid, value, limit_price=None, stop_price=None): diff --git a/zipline/errors.py b/zipline/errors.py index 68e271c8..0fc2d5d1 100644 --- a/zipline/errors.py +++ b/zipline/errors.py @@ -120,3 +120,11 @@ the corresponding order. msg = """ Transaction volume of {txn} exceeds the order volume of {order}. """.strip() + + +class UnsupportedOrderParameters(ZiplineError): + """ + Raised if a set of mutually exclusive parameters are passed to an order + call. + """ + msg = "{msg}" diff --git a/zipline/finance/__init__.py b/zipline/finance/__init__.py index 76da7076..09d512ec 100644 --- a/zipline/finance/__init__.py +++ b/zipline/finance/__init__.py @@ -13,8 +13,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -from . import trading +from . import execution, trading __all__ = [ - 'trading' + 'trading', + 'execution' ] diff --git a/zipline/finance/blotter.py b/zipline/finance/blotter.py index 0345d195..0dc5e54d 100644 --- a/zipline/finance/blotter.py +++ b/zipline/finance/blotter.py @@ -30,7 +30,6 @@ from zipline.finance.slippage import ( check_order_triggers ) from zipline.finance.commission import PerShare -import zipline.utils.math_utils as zp_math log = Logger('Blotter') @@ -43,17 +42,6 @@ ORDER_STATUS = Enum( ) -# On an order to buy, between .05 below to .95 above a penny, use that penny. -# On an order to sell, between .05 above to .95 below a penny, use that penny. -# buy: [.0095, .0195) -> round to .01, sell: (.0005, .0105] -> round to .01 -def round_for_minimum_price_variation(x, is_buy, diff=(0.0095 - .005)): - # relies on rounding half away from zero, unlike numpy's bankers' rounding - rounded = round(x - (diff if is_buy else -diff), 2) - if zp_math.tolerant_equals(rounded, 0.0): - return 0.0 - return rounded - - class Blotter(object): def __init__(self): @@ -86,7 +74,7 @@ class Blotter(object): def set_date(self, dt): self.current_dt = dt - def order(self, sid, amount, limit_price, stop_price, order_id=None): + def order(self, sid, amount, style, order_id=None): # something could be done with amount to further divide # between buy by share count OR buy shares up to a dollar amount @@ -96,11 +84,10 @@ class Blotter(object): amount > 0 :: Buy/Cover amount < 0 :: Sell/Short Market order: order(sid, amount) - Limit order: order(sid, amount, limit_price) - Stop order: order(sid, amount, None, stop_price) - StopLimit order: order(sid, amount, limit_price, stop_price) + Limit order: order(sid, amount, LimitOrder(price)) + Stop order: order(sid, amount, StopOrder(price)) + StopLimit order: order(sid, amount, StopLimitOrder(price)) """ - # This fixes a bug that if amount is e.g. -27.99999 due to # floating point madness we actually want to treat it as -28. def almost_equal_to(a, eps=1e-4): @@ -127,15 +114,13 @@ class Blotter(object): raise OverflowError("Can't order more than %d shares" % self.max_shares) - if limit_price: - limit_price = round_for_minimum_price_variation(limit_price, - amount > 0) + is_buy = (amount > 0) order = Order( dt=self.current_dt, sid=sid, amount=amount, - stop=stop_price, - limit=limit_price, + stop=style.get_stop_price(is_buy), + limit=style.get_limit_price(is_buy), id=order_id ) diff --git a/zipline/finance/execution.py b/zipline/finance/execution.py new file mode 100644 index 00000000..3d9c0516 --- /dev/null +++ b/zipline/finance/execution.py @@ -0,0 +1,151 @@ +# +# Copyright 2014 Quantopian, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import abc + +from sys import float_info + +from six import with_metaclass + +import zipline.utils.math_utils as zp_math + + +def round_for_minimum_price_variation(x, is_buy, + diff=(0.0095 - .005)): + """ + On an order to buy, between .05 below to .95 above a penny, use that penny. + On an order to sell, between .95 below to .05 above a penny, use that + penny. + buy: [.0095, X.0195) -> round to X.01, + sell: (.0005, X.0105] -> round to X.01 + """ + # Subtracting an epsilon from diff to enforce the open-ness of the upper + # bound on buys and the lower bound on sells. Using the actual system + # epsilon doesn't quite get there, so use a slightly less epsilon-ey value. + epsilon = float_info.epsilon * 10 + diff = diff - epsilon + + # relies on rounding half away from zero, unlike numpy's bankers' rounding + rounded = round(x - (diff if is_buy else -diff), 2) + if zp_math.tolerant_equals(rounded, 0.0): + return 0.0 + return rounded + + +class ExecutionStyle(with_metaclass(abc.ABCMeta)): + """ + Abstract base class representing a modification to a standard order. + """ + + @abc.abstractmethod + def get_limit_price(self, is_buy): + """ + Get the limit price for this order. + Returns either None or a numerical value >= 0. + """ + raise NotImplemented + + @abc.abstractmethod + def get_stop_price(self, is_buy): + """ + Get the stop price for this order. + Returns either None or a numerical value >= 0. + """ + raise NotImplemented + + +class MarketOrder(ExecutionStyle): + """ + Class encapsulating an order to be placed at the current market price. + """ + + def __init__(self): + pass + + def get_limit_price(self, _is_buy): + return None + + def get_stop_price(self, _is_buy): + return None + + +class LimitOrder(ExecutionStyle): + """ + Execution style representing an order to be executed at a price equal to or + better than a specified limit price. + """ + def __init__(self, limit_price): + """ + Store the given price. + """ + if limit_price < 0: + raise ValueError("Can't place a limit with a negative price.") + self.limit_price = limit_price + + def get_limit_price(self, is_buy): + return round_for_minimum_price_variation(self.limit_price, is_buy) + + def get_stop_price(self, _is_buy): + return None + + +class StopOrder(ExecutionStyle): + """ + Execution style representing an order to be placed once the market price + reaches a specified stop price. + """ + def __init__(self, stop_price): + """ + Store the given price. + """ + if stop_price < 0: + raise ValueError( + "Can't place a stop order with a negative price." + ) + self.stop_price = stop_price + + def get_limit_price(self, _is_buy): + return None + + def get_stop_price(self, is_buy): + return round_for_minimum_price_variation(self.stop_price, is_buy) + + +class StopLimitOrder(ExecutionStyle): + """ + Execution style representing a limit order to be placed with a specified + limit price once the market reaches a specified stop price. + """ + def __init__(self, limit_price, stop_price): + """ + Store the given prices + """ + if limit_price < 0: + raise ValueError( + "Can't place a limit with a negative price." + ) + if stop_price < 0: + raise ValueError( + "Can't place a stop order with a negative price." + ) + + self.limit_price = limit_price + self.stop_price = stop_price + + def get_limit_price(self, is_buy): + return round_for_minimum_price_variation(self.limit_price, is_buy) + + def get_stop_price(self, is_buy): + return round_for_minimum_price_variation(self.stop_price, is_buy)