diff --git a/flumine/execution/transaction.py b/flumine/execution/transaction.py index 59050357..e41d02a3 100644 --- a/flumine/execution/transaction.py +++ b/flumine/execution/transaction.py @@ -1,5 +1,6 @@ import logging from collections import defaultdict +from typing import Optional from ..order.orderpackage import OrderPackageType, BetfairOrderPackage from ..events import events @@ -28,9 +29,27 @@ class Transaction: .. t.cancel_order(order) t.place_order(order) # both executed on transaction __exit__ + + When atomic==True, orders within a transaction will + only be placed if the all pass validation. + For example, supposed we have: + + with market.transaction(atomic=True) as t: + t.place_order(order1) # validation passes + t.place_order(order2) # validation does not pass and raises a ControlError + + Neither order1 nor order2 will be executed, as order2 failed validation. + """ - def __init__(self, market, id_: int, async_place_orders: bool, client): + def __init__( + self, + market, + id_: int, + async_place_orders: bool, + client, + atomic: Optional[bool] = False, + ): self.market = market self._client = client self._id = id_ # unique per market only @@ -40,6 +59,7 @@ def __init__(self, market, id_: int, async_place_orders: bool, client): self._pending_cancel = [] # list of (, None) self._pending_update = [] # list of (, None) self._pending_replace = [] # list of (, market_version) + self.atomic = atomic def place_order( self, @@ -52,6 +72,7 @@ def place_order( if ( execute and not force + and not self.atomic and self._validate_controls(order, OrderPackageType.PLACE) is False ): return False @@ -94,6 +115,7 @@ def cancel_order( ) if ( not force + and not self.atomic and self._validate_controls(order, OrderPackageType.CANCEL) is False ): return False @@ -114,6 +136,7 @@ def update_order( ) if ( not force + and not self.atomic and self._validate_controls(order, OrderPackageType.UPDATE) is False ): return False @@ -134,6 +157,7 @@ def replace_order( ) if ( not force + and not self.atomic and self._validate_controls(order, OrderPackageType.REPLACE) is False ): return False @@ -185,15 +209,18 @@ def execute(self) -> int: def _validate_controls(self, order, package_type: OrderPackageType) -> bool: # return False on violation try: - for control in self.market.flumine.trading_controls: - control(order, package_type) - for control in self._client.trading_controls: - control(order, package_type) + self._do_validate_controls(order, package_type) except ControlError: return False else: return True + def _do_validate_controls(self, order, package_type): + for control in self.market.flumine.trading_controls: + control(order, package_type) + for control in self._client.trading_controls: + control(order, package_type) + def _create_order_package( self, orders: list, package_type: OrderPackageType, async_: bool = False ) -> list: @@ -224,5 +251,52 @@ def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): + if self._pending_orders: + + if self.atomic: + try: + for order in self._pending_place: + self._do_validate_controls(order, OrderPackageType.PLACE) + for order in self._pending_cancel: + self._do_validate_controls(order, OrderPackageType.CANCEL) + for order in self._pending_update: + self._do_validate_controls(order, OrderPackageType.UPDATE) + for order in self._pending_replace: + self._do_validate_controls(order, OrderPackageType.REPLACE) + except ControlError as e: + if logger.isEnabledFor(logging.INFO): + extra = { + "market_id": self.market.market_id, + "transaction_id": self._id, + "client_username": self._client.username, + } + if self._pending_place: + extra["pending_place"] = self._pending_place + if self._pending_update: + extra["pending_update"] = self._pending_update + if self._pending_cancel: + extra["pending_cancel"] = self._pending_cancel + if self._pending_replace: + extra["pending_replace"] = self._pending_replace + logger.info( + "Failed to execute transaction. Validation failed: %s" + % str(e), + extra=extra, + ) + self._clear() + raise self.execute() + + def _clear(self): + for order, _ in self._pending_place: + self._unwind(order) + self._pending_place.clear() + self._pending_update.clear() + self._pending_replace.clear() + self._pending_cancel.clear() + self._pending_orders = False + + def _unwind(self, order): + runner_context = order.trade.strategy.get_runner_context(*order.lookup) + runner_context.live_trades.remove(order.trade.id) diff --git a/flumine/markets/market.py b/flumine/markets/market.py index 6d5951a0..8aea287a 100644 --- a/flumine/markets/market.py +++ b/flumine/markets/market.py @@ -62,7 +62,12 @@ def close_market(self) -> None: extra=self.info, ) - def transaction(self, async_place_orders: bool = None, client=None) -> Transaction: + def transaction( + self, + async_place_orders: bool = None, + client=None, + all_or_nothing: Optional[bool] = False, + ) -> Transaction: if async_place_orders is None: async_place_orders = config.async_place_orders if client is None: @@ -73,6 +78,7 @@ def transaction(self, async_place_orders: bool = None, client=None) -> Transacti id_=self._transaction_id, async_place_orders=async_place_orders, client=client, + atomic=all_or_nothing, ) # order diff --git a/tests/test_markets.py b/tests/test_markets.py index 3d2dc6ae..8da0aa0b 100644 --- a/tests/test_markets.py +++ b/tests/test_markets.py @@ -160,6 +160,7 @@ def test_transaction(self, mock_transaction): id_=self.market._transaction_id, async_place_orders=False, client=self.market.flumine.clients.get_default(), + atomic=False, ) self.assertEqual(transaction, mock_transaction()) @@ -172,6 +173,7 @@ def test_transaction_async(self, mock_transaction): id_=self.market._transaction_id, async_place_orders=True, client=self.market.flumine.clients.get_default(), + atomic=False, ) self.assertEqual(transaction, mock_transaction()) diff --git a/tests/test_transaction.py b/tests/test_transaction.py index 826a7c06..c2ae07a8 100644 --- a/tests/test_transaction.py +++ b/tests/test_transaction.py @@ -61,6 +61,73 @@ def test_place_order( ) mock_order.update_client.assert_called_with(self.transaction._client) + @mock.patch("flumine.execution.transaction.get_market_notes") + @mock.patch( + "flumine.execution.transaction.Transaction._do_validate_controls", + return_value=True, + ) + def test_place_order_with_atomic_success( + self, + mock__do_validate_controls, + mock_get_market_notes, + ): + self.transaction.atomic = True + self.transaction.market.blotter = mock.MagicMock() + self.transaction.market.blotter.has_trade.return_value = False + self.transaction.execute = mock.Mock() + + mock_order1 = mock.Mock(id="123", lookup=(1, 2, 3)) + mock_order1.trade.market_notes = None + + mock_order2 = mock.Mock(id="123", lookup=(1, 2, 3)) + mock_order2.trade.market_notes = None + + with self.transaction: + self.assertTrue(self.transaction.place_order(mock_order1)) + self.assertTrue(self.transaction.place_order(mock_order2)) + self.assertEqual(0, self.transaction.execute.call_count) + + self.assertEqual(1, self.transaction.execute.call_count) + + @mock.patch("flumine.execution.transaction.get_market_notes") + def test_place_order_with_atomic_fail( + self, + mock_get_market_notes, + ): + c = 0 + + def side_effect(*args, **kwargs): + nonlocal c + if c == 0: + c = c + 1 + return + + raise ControlError("Intentional") + + self.transaction._do_validate_controls = mock.Mock(side_effect=side_effect) + + self.transaction.atomic = True + self.transaction.market.blotter = mock.MagicMock() + self.transaction.market.blotter.has_trade.return_value = False + self.transaction.execute = mock.Mock() + + mock_order1 = mock.Mock(id="123", lookup=(1, 2, 3)) + mock_order1.trade.market_notes = None + + mock_order2 = mock.Mock(id="123", lookup=(1, 2, 3)) + mock_order2.trade.market_notes = None + + """ + The second mock order will raise a ControlError + """ + with self.assertRaises(ControlError): + with self.transaction: + self.assertTrue(self.transaction.place_order(mock_order1)) + self.assertTrue(self.transaction.place_order(mock_order2)) + + self.assertEqual(0, self.transaction.execute.call_count) + self.assert_cleared() + @mock.patch("flumine.execution.transaction.get_market_notes") @mock.patch( "flumine.execution.transaction.Transaction._validate_controls", @@ -146,6 +213,49 @@ def test_cancel_order(self, mock__validate_controls): self.transaction._pending_cancel = [(mock_order, None)] self.assertTrue(self.transaction._pending_orders) + @mock.patch("flumine.execution.transaction.get_market_notes") + def test_cancel_order_with_atomic_fail( + self, + mock_get_market_notes, + ): + c = 0 + + def side_effect(*args, **kwargs): + nonlocal c + if c == 0: + c = c + 1 + return + + raise ControlError("Intentional") + + self.transaction._do_validate_controls = mock.Mock(side_effect=side_effect) + + self.transaction.atomic = True + self.transaction.market.blotter = mock.MagicMock() + self.transaction.market.blotter.has_trade.return_value = False + self.transaction.execute = mock.Mock() + + mock_order1 = mock.Mock( + id="123", lookup=(1, 2, 3), client=self.transaction._client + ) + mock_order1.trade.market_notes = None + + mock_order2 = mock.Mock( + id="123", lookup=(1, 2, 3), client=self.transaction._client + ) + mock_order2.trade.market_notes = None + + """ + The second mock order will raise a ControlError + """ + with self.assertRaises(ControlError): + with self.transaction: + self.assertTrue(self.transaction.cancel_order(mock_order1)) + self.assertTrue(self.transaction.cancel_order(mock_order2)) + + self.assertEqual(0, self.transaction.execute.call_count) + self.assert_cleared() + def test_cancel_order_incorrect_client(self): mock_order = mock.Mock(client=123) with self.assertRaises(OrderError): @@ -186,6 +296,57 @@ def test_update_order(self, mock__validate_controls): self.transaction._pending_update = [(mock_order, None)] self.assertTrue(self.transaction._pending_orders) + @mock.patch("flumine.execution.transaction.get_market_notes") + def test_update_order_with_atomic_fail( + self, + mock_get_market_notes, + ): + c = 0 + + def side_effect(*args, **kwargs): + nonlocal c + if c == 0: + c = c + 1 + return + + raise ControlError("Intentional") + + self.transaction._do_validate_controls = mock.Mock(side_effect=side_effect) + + self.transaction.atomic = True + self.transaction.market.blotter = mock.MagicMock() + self.transaction.market.blotter.has_trade.return_value = False + self.transaction.execute = mock.Mock() + + mock_order1 = mock.Mock( + id="123", lookup=(1, 2, 3), client=self.transaction._client + ) + mock_order1.trade.market_notes = None + + mock_order2 = mock.Mock( + id="123", lookup=(1, 2, 3), client=self.transaction._client + ) + mock_order2.trade.market_notes = None + + """ + The second mock order will raise a ControlError + """ + with self.assertRaises(ControlError): + with self.transaction: + self.assertTrue( + self.transaction.update_order( + mock_order1, new_persistence_type="KEEP" + ) + ) + self.assertTrue( + self.transaction.update_order( + mock_order2, new_persistence_type="KEEP" + ) + ) + + self.assertEqual(0, self.transaction.execute.call_count) + self.assert_cleared() + def test_update_order_incorrect_client(self): mock_order = mock.Mock(client=123) with self.assertRaises(OrderError): @@ -228,6 +389,53 @@ def test_replace_order(self, mock__validate_controls): self.transaction._pending_replace = [(mock_order, None)] self.assertTrue(self.transaction._pending_orders) + @mock.patch("flumine.execution.transaction.get_market_notes") + def test_replace_order_with_atomic_fail( + self, + mock_get_market_notes, + ): + c = 0 + + def side_effect(*args, **kwargs): + nonlocal c + if c == 0: + c = c + 1 + return + + raise ControlError("Intentional") + + self.transaction._do_validate_controls = mock.Mock(side_effect=side_effect) + + self.transaction.atomic = True + self.transaction.market.blotter = mock.MagicMock() + self.transaction.market.blotter.has_trade.return_value = False + self.transaction.execute = mock.Mock() + + mock_order1 = mock.Mock( + id="123", lookup=(1, 2, 3), client=self.transaction._client + ) + mock_order1.trade.market_notes = None + + mock_order2 = mock.Mock( + id="123", lookup=(1, 2, 3), client=self.transaction._client + ) + mock_order2.trade.market_notes = None + + """ + The second mock order will raise a ControlError + """ + with self.assertRaises(ControlError): + with self.transaction: + self.assertTrue( + self.transaction.replace_order(mock_order1, new_price=1.01) + ) + self.assertTrue( + self.transaction.replace_order(mock_order2, new_price=1.01) + ) + + self.assertEqual(0, self.transaction.execute.call_count) + self.assert_cleared() + def test_replace_order_incorrect_client(self): mock_order = mock.Mock(client=123) with self.assertRaises(OrderError): @@ -394,3 +602,10 @@ def test_enter_exit(self, mock_execute): self.assertEqual(self.transaction, t) t._pending_orders = True mock_execute.assert_called() + + def assert_cleared(self): + self.assertEqual(0, len(self.transaction._pending_place)) + self.assertEqual(0, len(self.transaction._pending_cancel)) + self.assertEqual(0, len(self.transaction._pending_update)) + self.assertEqual(0, len(self.transaction._pending_replace)) + self.assertFalse(self.transaction._pending_orders)