From dd97292a946210ec8345d395ee9b2f628ef0553c Mon Sep 17 00:00:00 2001 From: John Ricklefs Date: Mon, 4 Aug 2014 11:40:29 -0400 Subject: [PATCH] TST: Add tests for behavior of rejected/held orders. Also made a tweak to the handling of Order.status for when a held order is filled (partial or full). --- tests/test_blotter.py | 120 ++++++++++++++++++++++++++++++++++++- zipline/finance/blotter.py | 15 ++++- 2 files changed, 133 insertions(+), 2 deletions(-) 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 e5321aa8..3003b076 100644 --- a/zipline/finance/blotter.py +++ b/zipline/finance/blotter.py @@ -140,6 +140,12 @@ class Blotter(object): 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 @@ -157,7 +163,12 @@ class Blotter(object): # along with newly placed orders. self.new_orders.append(cur_order) - def hold(self, order_id, reason): + 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 @@ -317,6 +328,8 @@ class Order(object): def status(self): 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