diff --git a/tests/test_blotter.py b/tests/test_blotter.py index dbb7856c..cc3223dc 100644 --- a/tests/test_blotter.py +++ b/tests/test_blotter.py @@ -13,16 +13,18 @@ # See the License for the specific language governing permissions and # limitations under the License. +import datetime from nose_parameterized import parameterized from unittest import TestCase -from zipline.finance.blotter import Blotter +from zipline.finance.blotter import Blotter, ORDER_STATUS from zipline.finance.execution import ( LimitOrder, MarketOrder, StopLimitOrder, StopOrder, ) +from zipline.sources.test_source import create_trade from zipline.utils.test_utils import( setup_logger, @@ -51,3 +53,119 @@ class BlotterTestCase(TestCase): self.assertEqual(result.limit, expected_lmt) self.assertEqual(result.stop, expected_stp) + + def test_order_rejection(self): + blotter = Blotter() + # Reject a nonexistent order -> no order appears in new_order, + # no exceptions raised out + blotter.reject(56) + self.assertEqual(blotter.new_orders, []) + + # Basic tests of open order behavior + open_order_id = blotter.order(24, 100, MarketOrder()) + second_order_id = blotter.order(24, 50, MarketOrder()) + self.assertEqual(len(blotter.open_orders[24]), 2) + open_order = blotter.open_orders[24][0] + self.assertEqual(open_order.status, ORDER_STATUS.OPEN) + self.assertEqual(open_order.id, open_order_id) + self.assertIn(open_order, blotter.new_orders) + + # Reject that order immediately (same bar, i.e. still in new_orders) + blotter.reject(open_order_id) + self.assertEqual(len(blotter.new_orders), 2) + self.assertEqual(len(blotter.open_orders[24]), 1) + still_open_order = blotter.new_orders[0] + self.assertEqual(still_open_order.id, second_order_id) + self.assertEqual(still_open_order.status, ORDER_STATUS.OPEN) + rejected_order = blotter.new_orders[1] + self.assertEqual(rejected_order.status, ORDER_STATUS.REJECTED) + self.assertEqual(rejected_order.reason, '') + + # Do it again, but reject it at a later time (after tradesimulation + # pulls it from new_orders) + blotter = Blotter() + new_open_id = blotter.order(24, 10, MarketOrder()) + new_open_order = blotter.open_orders[24][0] + self.assertEqual(new_open_id, new_open_order.id) + # Pretend that the trade simulation did this. + blotter.new_orders = [] + + rejection_reason = "Not enough cash on hand." + blotter.reject(new_open_id, reason=rejection_reason) + rejected_order = blotter.new_orders[0] + self.assertEqual(rejected_order.id, new_open_id) + self.assertEqual(rejected_order.status, ORDER_STATUS.REJECTED) + self.assertEqual(rejected_order.reason, rejection_reason) + + # You can't reject a filled order. + blotter = Blotter() # Reset for paranoia + blotter.current_dt = datetime.datetime.now() + filled_id = blotter.order(24, 100, MarketOrder()) + aapl_trade = create_trade(24, 50.0, 400, datetime.datetime.now()) + filled_order = None + for txn, updated_order in blotter.process_trade(aapl_trade): + filled_order = updated_order + + self.assertEqual(filled_order.id, filled_id) + self.assertIn(filled_order, blotter.new_orders) + self.assertEqual(filled_order.status, ORDER_STATUS.FILLED) + self.assertNotIn(filled_order, blotter.open_orders[24]) + + blotter.reject(filled_id) + updated_order = blotter.orders[filled_id] + self.assertEqual(updated_order.status, ORDER_STATUS.FILLED) + + def test_order_hold(self): + """ + Held orders act almost identically to open orders, except for the + status indication. When a fill happens, the order should switch + status to OPEN/FILLED as necessary + """ + blotter = Blotter() + # Nothing happens on held of a non-existent order + blotter.hold(56) + self.assertEqual(blotter.new_orders, []) + + open_id = blotter.order(24, 100, MarketOrder()) + open_order = blotter.open_orders[24][0] + self.assertEqual(open_order.id, open_id) + + blotter.hold(open_id) + self.assertEqual(len(blotter.new_orders), 1) + self.assertEqual(len(blotter.open_orders[24]), 1) + held_order = blotter.new_orders[0] + self.assertEqual(held_order.status, ORDER_STATUS.HELD) + self.assertEqual(held_order.reason, '') + + blotter.cancel(held_order.id) + self.assertEqual(len(blotter.new_orders), 1) + self.assertEqual(len(blotter.open_orders[24]), 0) + cancelled_order = blotter.new_orders[0] + self.assertEqual(cancelled_order.id, held_order.id) + self.assertEqual(cancelled_order.status, ORDER_STATUS.CANCELLED) + + for trade_amt in (100, 400): + # Verify that incoming fills will change the order status. + order_size = 100 + expected_filled = trade_amt * 0.25 + expected_open = order_size - expected_filled + expected_status = ORDER_STATUS.OPEN if expected_open else \ + ORDER_STATUS.FILLED + + blotter = Blotter() + blotter.current_dt = datetime.datetime.now() + open_id = blotter.order(24, order_size, MarketOrder()) + open_order = blotter.open_orders[24][0] + self.assertEqual(open_id, open_order.id) + blotter.hold(open_id) + held_order = blotter.new_orders[0] + + aapl_trade = create_trade(24, 50.0, trade_amt, + datetime.datetime.now()) + filled_order = None + for txn, updated_order in blotter.process_trade(aapl_trade): + filled_order = updated_order + self.assertEqual(filled_order.id, held_order.id) + self.assertEqual(filled_order.status, expected_status) + self.assertEqual(filled_order.filled, expected_filled) + self.assertEqual(filled_order.open_amount, expected_open) diff --git a/zipline/finance/blotter.py b/zipline/finance/blotter.py index 3415c466..94253344 100644 --- a/zipline/finance/blotter.py +++ b/zipline/finance/blotter.py @@ -38,7 +38,9 @@ from zipline.utils.protocol_utils import Enum ORDER_STATUS = Enum( 'OPEN', 'FILLED', - 'CANCELLED' + 'CANCELLED', + 'REJECTED', + 'HELD', ) @@ -84,9 +86,10 @@ class Blotter(object): amount > 0 :: Buy/Cover amount < 0 :: Sell/Short Market order: order(sid, amount) - Limit order: order(sid, amount, LimitOrder(price)) - Stop order: order(sid, amount, StopOrder(price)) - StopLimit order: order(sid, amount, StopLimitOrder(price)) + Limit order: order(sid, amount, style=LimitOrder(limit_price)) + Stop order: order(sid, amount, style=StopOrder(stop_price)) + StopLimit order: order(sid, amount, style=StopLimitOrder(limit_price, + stop_price)) """ if amount == 0: # Don't bother placing orders for 0 shares. @@ -118,6 +121,7 @@ class Blotter(object): return cur_order = self.orders[order_id] + if cur_order.open: order_list = self.open_orders[cur_order.sid] if cur_order in order_list: @@ -131,6 +135,49 @@ class Blotter(object): # along with newly placed orders. self.new_orders.append(cur_order) + def reject(self, order_id, reason=''): + """ + Mark the given order as 'rejected', which is functionally similar to + cancelled. The distinction is that rejections are involuntary (and + usually include a message from a broker indicating why the order was + rejected) while cancels are typically user-driven. + """ + if order_id not in self.orders: + return + + cur_order = self.orders[order_id] + + order_list = self.open_orders[cur_order.sid] + if cur_order in order_list: + order_list.remove(cur_order) + + if cur_order in self.new_orders: + self.new_orders.remove(cur_order) + cur_order.reject(reason=reason) + cur_order.dt = self.current_dt + # we want this order's new status to be relayed out + # along with newly placed orders. + self.new_orders.append(cur_order) + + def hold(self, order_id, reason=''): + """ + Mark the order with order_id as 'held'. Held is functionally similar + to 'open'. When a fill (full or partial) arrives, the status + will automatically change back to open/filled as necessary. + """ + if order_id not in self.orders: + return + + cur_order = self.orders[order_id] + if cur_order.open: + if cur_order in self.new_orders: + self.new_orders.remove(cur_order) + cur_order.hold(reason=reason) + cur_order.dt = self.current_dt + # we want this order's new status to be relayed out + # along with newly placed orders. + self.new_orders.append(cur_order) + def process_split(self, split_event): if split_event.sid not in self.open_orders: return @@ -208,12 +255,13 @@ class Order(object): # get a string representation of the uuid. self.id = id or self.make_id() self.dt = dt + self.reason = None self.created = dt self.sid = sid self.amount = amount self.filled = filled self.commission = commission - self._cancelled = False + self._status = ORDER_STATUS.OPEN self.stop = stop self.limit = limit self.stop_reached = False @@ -226,7 +274,7 @@ class Order(object): def to_dict(self): py = copy(self.__dict__) - for field in ['type', 'direction', '_cancelled']: + for field in ['type', 'direction', '_status']: del py[field] py['status'] = self.status return py @@ -274,18 +322,31 @@ class Order(object): @property def status(self): - if self._cancelled: - return ORDER_STATUS.CANCELLED + if not self.open_amount: + return ORDER_STATUS.FILLED + elif self._status == ORDER_STATUS.HELD and self.filled: + return ORDER_STATUS.OPEN + else: + return self._status - return ORDER_STATUS.FILLED \ - if not self.open_amount else ORDER_STATUS.OPEN + @status.setter + def status(self, status): + self._status = status def cancel(self): - self._cancelled = True + self.status = ORDER_STATUS.CANCELLED + + def reject(self, reason=''): + self.status = ORDER_STATUS.REJECTED + self.reason = reason + + def hold(self, reason=''): + self.status = ORDER_STATUS.HELD + self.reason = reason @property def open(self): - return self.status == ORDER_STATUS.OPEN + return self.status in [ORDER_STATUS.OPEN, ORDER_STATUS.HELD] @property def triggered(self):