Repository: liampauling/flumine
Pull request #373 (2021-02-05 09:25:48)

Task/303 transaction


Patch:
diff --git a/HISTORY.rst b/HISTORY.rst
index 7b52ef9..d319062 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -3,6 +3,23 @@
 Release History
 ---------------
 
+1.16.3 (2021-02-08)
++++++++++++++++++++
+
+**Bug Fixes**
+
+- Minor fix when combining data and market stream strategies
+
+1.16.2 (2021-02-05)
++++++++++++++++++++
+
+**Improvements**
+
+- Blotter strategy orders added for faster lookup
+- Strategy name hash cached
+- Minor selection_exposure optimisations
+- Simulated optimisations
+
 1.16.1 (2021-01-28)
 +++++++++++++++++++
 
diff --git a/flumine/__version__.py b/flumine/__version__.py
index 12d917b..31ba5d7 100644
--- a/flumine/__version__.py
+++ b/flumine/__version__.py
@@ -1,6 +1,6 @@
 __title__ = "flumine"
 __description__ = "Betfair trading framework"
 __url__ = "https://github.com/liampauling/flumine"
-__version__ = "1.16.1"
+__version__ = "1.16.3"
 __author__ = "Liam Pauling"
 __license__ = "MIT"
diff --git a/flumine/execution/transaction.py b/flumine/execution/transaction.py
new file mode 100644
index 0000000..e366467
--- /dev/null
+++ b/flumine/execution/transaction.py
@@ -0,0 +1,172 @@
+import logging
+from collections import defaultdict
+
+from ..order.orderpackage import OrderPackageType, BetfairOrderPackage
+from ..events import events
+from ..exceptions import ControlError
+
+logger = logging.getLogger(__name__)
+
+
+class Transaction:
+    """
+    Process place, cancel, update and replace requests.
+
+    Default behaviour is to execute immediately however
+    when it is used as a context manager requests can
+    be batched, for example:
+
+        with market.transaction as t:
+            market.place_order(order)  # executed immediately in separate transaction
+            t.place_order(order)  # executed on transaction __exit__
+
+        with market.transaction as t:
+            t.place_order(order)
+            ..
+            t.execute()  # above order executed
+            ..
+            t.cancel_order(order)
+            t.place_order(order)  # both executed on transaction __exit__
+    """
+
+    def __init__(self, market):
+        self.market = market
+        self._pending_place = []  # list of (<Order>, market_version)
+        self._pending_cancel = []  # list of <Order>
+        self._pending_update = []  # list of <Order>
+        self._pending_replace = []  # list of (<Order>, market_version)
+
+    def place_order(
+        self, order, market_version: int = None, execute: bool = None
+    ) -> bool:
+        # validate controls
+        validated = self._validate_controls(order, OrderPackageType.PLACE)
+        if not validated:
+            return False
+        # place
+        order.place(self.market.market_book.publish_time)
+        if order.id not in self.market.blotter:
+            self.market.blotter[order.id] = order
+            if order.trade.market_notes is None:
+                order.trade.update_market_notes(self.market)
+            self.market.flumine.log_control(
+                events.TradeEvent(order.trade)
+            )  # todo dupes?
+        else:
+            return True  # retry attempt so ignore?
+        if execute:  # handles replaceOrder
+            self._pending_place.append((order, market_version))
+            return True
+        else:
+            return True
+
+    def cancel_order(self, order, size_reduction: float = None) -> bool:
+        # validate controls
+        validated = self._validate_controls(order, OrderPackageType.CANCEL)
+        if not validated:
+            return False
+        # cancel
+        order.cancel(size_reduction)
+        self._pending_cancel.append((order, None))
+        return True
+
+    def update_order(self, order, new_persistence_type: str) -> bool:
+        # validate controls
+        validated = self._validate_controls(order, OrderPackageType.UPDATE)
+        if not validated:
+            return False
+        # update
+        order.update(new_persistence_type)
+        self._pending_update.append((order, None))
+        return True
+
+    def replace_order(
+        self, order, new_price: float, market_version: int = None
+    ) -> bool:
+        # validate controls
+        validated = self._validate_controls(order, OrderPackageType.REPLACE)
+        if not validated:
+            return False
+        # replace
+        order.replace(new_price)
+        self._pending_replace.append((order, market_version))
+        return True
+
+    def execute(self) -> int:
+        packages = []
+        if self._pending_place:
+            packages += self._create_order_package(
+                self._pending_place,
+                OrderPackageType.PLACE,
+            )
+            self._pending_place.clear()
+        if self._pending_cancel:
+            packages += self._create_order_package(
+                self._pending_cancel,
+                OrderPackageType.CANCEL,
+            )
+            self._pending_cancel.clear()
+        if self._pending_update:
+            packages += self._create_order_package(
+                self._pending_update,
+                OrderPackageType.UPDATE,
+            )
+            self._pending_update.clear()
+        if self._pending_replace:
+            packages += self._create_order_package(
+                self._pending_replace,
+                OrderPackageType.REPLACE,
+            )
+            self._pending_replace.clear()
+
+        if packages:
+            for package in packages:
+                self.market.flumine.process_order_package(package)
+            logger.info(
+                "%s order packages executed" % len(packages),
+                extra={
+                    "market_id": self.market.market_id,
+                    "order_packages": [o.info for o in packages],
+                },
+            )
+        return len(packages)
+
+    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.market.flumine.client.trading_controls:
+                control(order, package_type)
+        except ControlError:
+            return False
+        else:
+            return True
+
+    def _create_order_package(
+        self, orders: list, package_type: OrderPackageType
+    ) -> list[BetfairOrderPackage]:
+        # group orders by marketVersion
+        orders_grouped = defaultdict(list)
+        for o in orders:
+            orders_grouped[o[1]].append(o[0])
+        # create packages
+        packages = []
+        for market_version, package_orders in orders_grouped.items():
+            packages.append(
+                BetfairOrderPackage(
+                    client=self.market.flumine.client,
+                    market_id=self.market.market_id,
+                    orders=package_orders,
+                    package_type=package_type,
+                    bet_delay=self.market.market_book.bet_delay,
+                    market_version=market_version,
+                )
+            )
+        return packages
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        self.execute()
diff --git a/flumine/markets/blotter.py b/flumine/markets/blotter.py
index 697e36a..784dc2d 100644
--- a/flumine/markets/blotter.py
+++ b/flumine/markets/blotter.py
@@ -1,5 +1,6 @@
 import logging
 from typing import Iterable
+from collections import defaultdict
 
 from ..order.ordertype import OrderTypes
 from ..utils import (
@@ -15,14 +16,31 @@ IMPLIED_COMMISSION_RATE = 0.03
 
 
 class Blotter:
+
+    """
+    Simple and fast class to hold all orders for
+    a particular market.
+
+    `customer_order_ref` used as the key and various
+    caches available for faster access.
+
+        blotter["abc"] = <Order>  # set
+        "abc" in blotter  # contains
+        orders = [o for o in blotter]  # iter
+        order = blotter["abc"]  # get
+    """
+
     def __init__(self, market_id: str):
         self.market_id = market_id
         self._orders = {}  # {Order.id: Order}
         self._live_orders = []  # cached list of live orders
+        self._strategy_orders = defaultdict(
+            list
+        )  # cache list per strategy (faster lookup)
 
     def strategy_orders(self, strategy) -> list:
         """Returns all orders related to a strategy."""
-        return [order for order in self if order.trade.strategy == strategy]
+        return self._strategy_orders[strategy]
 
     @property
     def live_orders(self):
@@ -57,22 +75,24 @@ class Blotter:
         ub, ul = [], []  # unmatched bets, (price, size)
         moc_win_liability = 0.0
         moc_lose_liability = 0.0
-        for order in self:
-            if order.trade.strategy == strategy and order.lookup == lookup:
+        for order in self.strategy_orders(strategy):
+            if order.lookup == lookup:
                 if order.status == OrderStatus.VIOLATION:
                     continue
 
                 if order.order_type.ORDER_TYPE == OrderTypes.LIMIT:
-                    if order.size_matched:
+                    _size_matched = order.size_matched  # cache
+                    if _size_matched:
                         if order.side == "BACK":
-                            mb.append((order.average_price_matched, order.size_matched))
+                            mb.append((order.average_price_matched, _size_matched))
                         else:
-                            ml.append((order.average_price_matched, order.size_matched))
-                    if order.order_type.price and order.size_remaining:
+                            ml.append((order.average_price_matched, _size_matched))
+                    _size_remaining = order.size_remaining  # cache
+                    if order.order_type.price and _size_remaining:
                         if order.side == "BACK":
-                            ub.append((order.order_type.price, order.size_remaining))
+                            ub.append((order.order_type.price, _size_remaining))
                         else:
-                            ul.append((order.order_type.price, order.size_remaining))
+                            ul.append((order.order_type.price, _size_remaining))
                 elif order.order_type.ORDER_TYPE in (
                     OrderTypes.LIMIT_ON_CLOSE,
                     OrderTypes.MARKET_ON_CLOSE,
@@ -111,6 +131,7 @@ class Blotter:
     def __setitem__(self, customer_order_ref: str, order) -> None:
         self._orders[customer_order_ref] = order
         self._live_orders.append(order)
+        self._strategy_orders[order.trade.strategy].append(order)
 
     def __getitem__(self, customer_order_ref: str):
         return self._orders[customer_order_ref]
diff --git a/flumine/markets/market.py b/flumine/markets/market.py
index 2878936..1ade5da 100644
--- a/flumine/markets/market.py
+++ b/flumine/markets/market.py
@@ -5,14 +5,19 @@ from collections import defaultdict
 from betfairlightweight.resources.bettingresources import MarketBook, MarketCatalogue
 
 from .blotter import Blotter
-from ..events import events
-from ..order.orderpackage import BetfairOrderPackage, OrderPackageType
-from ..exceptions import ControlError
+from ..execution.transaction import Transaction
 
 logger = logging.getLogger(__name__)
 
 
 class Market:
+    """
+    Market data structure to hold latest marketBook,
+    marketCatalogue and various properties. Also
+    allows order placement through the Transaction
+    class.
+    """
+
     def __init__(
         self,
         flumine,
@@ -31,7 +36,7 @@ class Market:
         self.blotter = Blotter(market_id)
 
     def __call__(self, market_book: MarketBook):
-        if market_book.version != self.market_book.version:
+        if self.market_book and market_book.version != self.market_book.version:
             self.update_market_catalogue = True
         self.market_book = market_book
 
@@ -50,6 +55,9 @@ class Market:
             extra=self.info,
         )
 
+    def transaction(self) -> Transaction:
+        return Transaction(self)
+
     # order
     def place_order(
         self,
@@ -57,92 +65,22 @@ class Market:
         market_version: int = None,
         execute: bool = True,
     ) -> bool:
-        # validate controls
-        validated = self._validate_controls(order, OrderPackageType.PLACE)
-        if not validated:
-            return False
-        # place
-        order.place(self.market_book.publish_time)
-        if order.id not in self.blotter:
-            self.blotter[order.id] = order
-            if order.trade.market_notes is None:
-                order.trade.update_market_notes(self)
-            self.flumine.log_control(events.TradeEvent(order.trade))  # todo dupes?
-        else:
-            return True  # retry attempt so ignore?
-        if execute:  # handles replaceOrder
-            # todo market.transaction() / marketVersion
-            order_package = self._create_order_package(
-                [order], OrderPackageType.PLACE, market_version
-            )
-            self.flumine.process_order_package(order_package)
-            return True
-        else:
-            return True
+        with self.transaction() as t:
+            return t.place_order(order, market_version, execute)
 
     def cancel_order(self, order, size_reduction: float = None) -> bool:
-        # validate controls
-        validated = self._validate_controls(order, OrderPackageType.CANCEL)
-        if not validated:
-            return False
-        # cancel
-        order.cancel(size_reduction)
-        # todo market.transaction()
-        order_package = self._create_order_package([order], OrderPackageType.CANCEL)
-        self.flumine.process_order_package(order_package)
-        return True
+        with self.transaction() as t:
+            return t.cancel_order(order, size_reduction)
 
     def update_order(self, order, new_persistence_type: str) -> bool:
-        # validate controls
-        validated = self._validate_controls(order, OrderPackageType.UPDATE)
-        if not validated:
-            return False
-        # update
-        order.update(new_persistence_type)
-        # todo market.transaction()
-        order_package = self._create_order_package([order], OrderPackageType.UPDATE)
-        self.flumine.process_order_package(order_package)
-        return True
+        with self.transaction() as t:
+            return t.update_order(order, new_persistence_type)
 
     def replace_order(
         self, order, new_price: float, market_version: int = None
     ) -> bool:
-        # validate controls
-        validated = self._validate_controls(order, OrderPackageType.REPLACE)
-        if not validated:
-            return False
-        # replace
-        order.replace(new_price)
-        # todo market.transaction()
-        order_package = self._create_order_package(
-            [order], OrderPackageType.REPLACE, market_version
-        )
-        self.flumine.process_order_package(order_package)
-        return True
-
-    def _validate_controls(self, order, package_type: OrderPackageType) -> bool:
-        # return False on violation
-        try:
-            for control in self.flumine.trading_controls:
-                control(order, package_type)
-            for control in self.flumine.client.trading_controls:
-                control(order, package_type)
-        except ControlError:
-            return False
-        else:
-            return True
-
-    def _create_order_package(
-        self, orders: list, package_type: OrderPackageType, market_version: int = None
-    ):
-        return BetfairOrderPackage(
-            client=self.flumine.client,
-            market_id=self.market_id,
-            orders=orders,
-            package_type=package_type,
-            bet_delay=self.market_book.bet_delay,
-            market_version=market_version,
-        )
+        with self.transaction() as t:
+            return t.replace_order(order, new_price, market_version)
 
     @property
     def event(self) -> dict:
diff --git a/flumine/utils.py b/flumine/utils.py
index b117bb5..e5f9705 100644
--- a/flumine/utils.py
+++ b/flumine/utils.py
@@ -43,31 +43,6 @@ def chunks(l: list, n: int) -> list:
         yield l[i : i + n]
 
 
-def batch_orders(orders: list) -> dict:  # todo remove?
-    # batch by market_version -> batch
-    _return_orders = defaultdict(list)  # market_version: [[<Order>, ], .. ]
-    a = _split_orders_by(orders, "market_version")
-    for market_version, a_orders in a.items():
-        b = _split_orders_by(a_orders, "batch", True)
-        for batch, b_orders in b.items():
-            _temp = []
-            for order, _ in b_orders:
-                if batch is False:
-                    _return_orders[market_version].append([order])
-                else:
-                    _temp.append(order)
-            if _temp:
-                _return_orders[market_version].append(_temp)
-    return _return_orders
-
-
-def _split_orders_by(orders: list, by: str, default=None) -> dict:  # todo remove?
-    res = defaultdict(list)
-    for order, request_data in orders:
-        res[request_data.get(by, default)].append((order, request_data))
-    return res
-
-
 def create_cheap_hash(txt: str, length: int = 15) -> str:
     # This is just a hash for debugging purposes.
     #    It does not need to be unique, just fast and short.


Test patch:
diff --git a/flumine/backtest/backtest.py b/flumine/backtest/backtest.py
index b8ae5ca..67ced44 100644
--- a/flumine/backtest/backtest.py
+++ b/flumine/backtest/backtest.py
@@ -7,6 +7,7 @@ from .. import config, utils
 from ..clients import ExchangeType
 from ..exceptions import RunError
 from ..order.orderpackage import OrderPackageType
+from ..order.trade import TradeStatus
 from ..order import process
 
 logger = logging.getLogger(__name__)
@@ -104,7 +105,7 @@ class FlumineBacktest(BaseFlumine):
         blotter = market.blotter
         for order in blotter.live_orders:
             process.process_current_order(order)
-            if order.trade.status.value == "Complete":
+            if order.trade.status == TradeStatus.COMPLETE:
                 blotter.complete_order(order)
         for strategy in self.strategies:
             strategy_orders = blotter.strategy_orders(strategy)
diff --git a/flumine/backtest/simulated.py b/flumine/backtest/simulated.py
index 50a7814..bfdb31d 100644
--- a/flumine/backtest/simulated.py
+++ b/flumine/backtest/simulated.py
@@ -35,12 +35,12 @@ class Simulated:
 
     def __call__(self, market_book: MarketBook, runner_analytics) -> None:
         # simulates order matching
-        runner = self._get_runner(market_book)
         if (
             self._bsp_reconciled is False
             and market_book.bsp_reconciled
             and self.take_sp
         ):
+            runner = self._get_runner(market_book)
             self._process_sp(market_book.publish_time_epoch, runner)
 
         elif (
diff --git a/tests/test_blotter.py b/tests/test_blotter.py
index 68e76a3..fee889f 100644
--- a/tests/test_blotter.py
+++ b/tests/test_blotter.py
@@ -18,7 +18,7 @@ class BlotterTest(unittest.TestCase):
     def test_strategy_orders(self):
         mock_order = mock.Mock()
         mock_order.trade.strategy = 69
-        self.blotter._orders = {"12345": mock_order}
+        self.blotter["12345"] = mock_order
         self.assertEqual(self.blotter.strategy_orders(12), [])
         self.assertEqual(self.blotter.strategy_orders(69), [mock_order])
 
@@ -58,7 +58,7 @@ class BlotterTest(unittest.TestCase):
             size_remaining=0.0,
             order_type=LimitOrder(price=5.6, size=2.0),
         )
-        self.blotter._orders = {"12345": mock_order}
+        self.blotter["12345"] = mock_order
         self.assertEqual(
             self.blotter.selection_exposure(mock_strategy, mock_order.lookup),
             2.0,
@@ -76,7 +76,7 @@ class BlotterTest(unittest.TestCase):
             size_remaining=0.0,
             order_type=mock.Mock(ORDER_TYPE="INVALID"),
         )
-        self.blotter._orders = {"12345": mock_order}
+        self.blotter["12345"] = mock_order
 
         with self.assertRaises(ValueError) as e:
             self.blotter.selection_exposure(mock_strategy, mock_order.lookup)
@@ -111,7 +111,8 @@ class BlotterTest(unittest.TestCase):
             size_remaining=2.0,
             order_type=LimitOrder(price=None, size=2.0),
         )
-        self.blotter._orders = {"12345": mock_order1, "23456": mock_order2}
+        self.blotter["12345"] = mock_order1
+        self.blotter["23456"] = mock_order2
         self.assertEqual(
             self.blotter.selection_exposure(mock_strategy, lookup),
             2.0,
@@ -129,7 +130,7 @@ class BlotterTest(unittest.TestCase):
             size_remaining=0.0,
             order_type=LimitOrder(price=5.6, size=2.0),
         )
-        self.blotter._orders = {"12345": mock_order}
+        self.blotter["12345"] = mock_order
         self.assertEqual(
             self.blotter.selection_exposure(mock_strategy, mock_order.lookup),
             0.0,
@@ -147,7 +148,7 @@ class BlotterTest(unittest.TestCase):
             size_remaining=2.0,
             order_type=LimitOrder(price=6, size=4.0),
         )
-        self.blotter._orders = {"12345": mock_order}
+        self.blotter["12345"] = mock_order
         # On the win side, we have 2.0 * (5.6-1.0) = 9.2
         # On the lose side, we have -2.0-2.0=-4.0
         self.assertEqual(
@@ -167,7 +168,7 @@ class BlotterTest(unittest.TestCase):
             size_remaining=2.0,
             order_type=LimitOrder(price=6, size=4.0),
         )
-        self.blotter._orders = {"12345": mock_order}
+        self.blotter["12345"] = mock_order
         # On the win side, we have -2.0 * (5.6-1.0) -2.0 * (6.0-1.0) = -19.2
         # On the lose side, we have 2.0 from size_matched
         self.assertEqual(
@@ -184,7 +185,7 @@ class BlotterTest(unittest.TestCase):
             side="BACK",
             order_type=MarketOnCloseOrder(liability=10.0),
         )
-        self.blotter._orders = {"12345": mock_order}
+        self.blotter["12345"] = mock_order
         self.assertEqual(
             self.blotter.selection_exposure(mock_strategy, mock_order.lookup),
             10.0,
@@ -199,7 +200,7 @@ class BlotterTest(unittest.TestCase):
             side="LAY",
             order_type=MarketOnCloseOrder(liability=10.0),
         )
-        self.blotter._orders = {"12345": mock_order}
+        self.blotter["12345"] = mock_order
         self.assertEqual(
             self.blotter.selection_exposure(mock_strategy, mock_order.lookup),
             10.0,
@@ -214,7 +215,7 @@ class BlotterTest(unittest.TestCase):
             side="LAY",
             order_type=LimitOnCloseOrder(price=1.01, liability=10.0),
         )
-        self.blotter._orders = {"12345": mock_order}
+        self.blotter["12345"] = mock_order
         self.assertEqual(
             self.blotter.selection_exposure(mock_strategy, mock_order.lookup),
             10.0,
@@ -230,7 +231,7 @@ class BlotterTest(unittest.TestCase):
             order_type=LimitOrder(price=5, size=10.0),
             status=OrderStatus.VIOLATION,
         )
-        self.blotter._orders = {"12345": mock_order}
+        self.blotter["12345"] = mock_order
         self.assertEqual(
             self.blotter.selection_exposure(mock_strategy, mock_order.lookup),
             0,
@@ -246,9 +247,13 @@ class BlotterTest(unittest.TestCase):
         self.assertNotIn("321", self.blotter)
 
     def test__setitem(self):
-        self.blotter["123"] = "test"
-        self.assertEqual(self.blotter._orders, {"123": "test"})
-        self.assertEqual(self.blotter._live_orders, ["test"])
+        mock_order = mock.Mock()
+        self.blotter["123"] = mock_order
+        self.assertEqual(self.blotter._orders, {"123": mock_order})
+        self.assertEqual(self.blotter._live_orders, [mock_order])
+        self.assertEqual(
+            self.blotter._strategy_orders, {mock_order.trade.strategy: [mock_order]}
+        )
 
     def test__getitem(self):
         self.blotter._orders = {"12345": "test", "54321": "test2"}
diff --git a/tests/test_fluminebacktest.py b/tests/test_fluminebacktest.py
index 8805309..dde3cda 100644
--- a/tests/test_fluminebacktest.py
+++ b/tests/test_fluminebacktest.py
@@ -6,6 +6,7 @@ from flumine.clients import ExchangeType
 from flumine.order.orderpackage import OrderPackageType
 from flumine import config
 from flumine.exceptions import RunError
+from flumine.order.trade import TradeStatus
 
 
 class FlumineBacktestTest(unittest.TestCase):
@@ -75,7 +76,7 @@ class FlumineBacktestTest(unittest.TestCase):
     def test__process_backtest_orders(self, mock_process_current_order):
         mock_market = mock.Mock(context={})
         mock_order = mock.Mock()
-        mock_order.trade.status.value = "Complete"
+        mock_order.trade.status = TradeStatus.COMPLETE
         mock_market.blotter.live_orders = [mock_order]
         self.flumine._process_backtest_orders(mock_market)
         mock_process_current_order.assert_called_with(mock_order)
diff --git a/tests/test_markets.py b/tests/test_markets.py
index 5fda2d0..ae2ed43 100644
--- a/tests/test_markets.py
+++ b/tests/test_markets.py
@@ -3,7 +3,7 @@ import datetime
 from unittest import mock
 
 from flumine.markets.markets import Markets
-from flumine.markets.market import Market, OrderPackageType, ControlError
+from flumine.markets.market import Market
 
 
 class MarketsTest(unittest.TestCase):
@@ -130,149 +130,33 @@ class MarketTest(unittest.TestCase):
         self.assertTrue(self.market.closed)
         self.assertIsNotNone(self.market.date_time_closed)
 
-    @mock.patch("flumine.markets.market.Market._validate_controls", return_value=True)
-    @mock.patch("flumine.markets.market.Market._create_order_package")
-    @mock.patch("flumine.markets.market.events")
-    def test_place_order(
-        self, mock_events, mock__create_order_package, mock__validate_controls
-    ):
-        mock_order = mock.Mock(id="123")
-        mock_order.trade.market_notes = None
-        self.assertTrue(self.market.place_order(mock_order))
-        mock_order.place.assert_called_with(self.market.market_book.publish_time)
-        self.mock_flumine.log_control.assert_called_with(mock_events.TradeEvent())
-        mock_order.trade.update_market_notes.assert_called_with(self.market)
-        mock__create_order_package.assert_called_with(
-            [mock_order], OrderPackageType.PLACE, None
-        )
-        self.mock_flumine.process_order_package.assert_called_with(
-            mock__create_order_package()
-        )
-        mock__validate_controls.assert_called_with(mock_order, OrderPackageType.PLACE)
-
-    @mock.patch("flumine.markets.market.Market._validate_controls", return_value=True)
-    @mock.patch("flumine.markets.market.Market._create_order_package")
-    @mock.patch("flumine.markets.market.events")
-    def test_place_order_not_executed(
-        self, mock_events, mock__create_order_package, mock__validate_controls
-    ):
-        mock_order = mock.Mock(id="123")
-        self.assertTrue(self.market.place_order(mock_order, execute=False))
-        mock_order.place.assert_called_with(self.market.market_book.publish_time)
-        self.mock_flumine.log_control.assert_called_with(mock_events.TradeEvent())
-        mock__create_order_package.assert_not_called()
-
-    @mock.patch("flumine.markets.market.Market._validate_controls", return_value=True)
-    @mock.patch("flumine.markets.market.Market._create_order_package")
-    def test_place_order_retry(
-        self, mock__create_order_package, mock__validate_controls
-    ):
-        mock_order = mock.Mock()
-        self.market.blotter._orders = {mock_order.id: mock_order}
-        self.assertTrue(self.market.place_order(mock_order))
-        mock__create_order_package.assert_not_called()
-
-    @mock.patch("flumine.markets.market.Market._validate_controls", return_value=False)
-    def test_place_order_violation(self, mock__validate_controls):
-        mock_order = mock.Mock()
-        self.assertFalse(self.market.place_order(mock_order))
-        mock__validate_controls.assert_called_with(mock_order, OrderPackageType.PLACE)
-
-    @mock.patch("flumine.markets.market.Market._validate_controls", return_value=True)
-    @mock.patch("flumine.markets.market.Market._create_order_package")
-    def test_cancel_order(self, mock__create_order_package, mock__validate_controls):
+    @mock.patch("flumine.markets.market.Market.transaction")
+    def test_place_order(self, mock_transaction):
+        mock_transaction.return_value.__enter__.return_value = mock_transaction
         mock_order = mock.Mock()
-        self.assertTrue(self.market.cancel_order(mock_order, 0.01))
-        mock_order.cancel.assert_called_with(0.01)
-        mock__create_order_package.assert_called_with(
-            [mock_order], OrderPackageType.CANCEL
-        )
-        self.mock_flumine.process_order_package.assert_called_with(
-            mock__create_order_package()
-        )
-        mock__validate_controls.assert_called_with(mock_order, OrderPackageType.CANCEL)
+        self.assertTrue(self.market.place_order(mock_order, 2, False))
+        mock_transaction.place_order.assert_called_with(mock_order, 2, False)
 
-    @mock.patch("flumine.markets.market.Market._validate_controls", return_value=False)
-    def test_cancel_order_violation(self, mock__validate_controls):
+    @mock.patch("flumine.markets.market.Market.transaction")
+    def test_cancel_order(self, mock_transaction):
+        mock_transaction.return_value.__enter__.return_value = mock_transaction
         mock_order = mock.Mock()
-        self.assertFalse(self.market.cancel_order(mock_order))
-        mock__validate_controls.assert_called_with(mock_order, OrderPackageType.CANCEL)
+        self.assertTrue(self.market.cancel_order(mock_order, 2.02))
+        mock_transaction.cancel_order.assert_called_with(mock_order, 2.02)
 
-    @mock.patch("flumine.markets.market.Market._validate_controls", return_value=True)
-    @mock.patch("flumine.markets.market.Market._create_order_package")
-    def test_update_order(self, mock__create_order_package, mock__validate_controls):
+    @mock.patch("flumine.markets.market.Market.transaction")
+    def test_update_order(self, mock_transaction):
+        mock_transaction.return_value.__enter__.return_value = mock_transaction
         mock_order = mock.Mock()
-        self.assertTrue(self.market.update_order(mock_order, "PERSIST"))
-        mock_order.update.assert_called_with("PERSIST")
-        mock__create_order_package.assert_called_with(
-            [mock_order], OrderPackageType.UPDATE
-        )
-        self.mock_flumine.process_order_package.assert_called_with(
-            mock__create_order_package()
-        )
-        mock__validate_controls.assert_called_with(mock_order, OrderPackageType.UPDATE)
+        self.assertTrue(self.market.update_order(mock_order, "test"))
+        mock_transaction.update_order.assert_called_with(mock_order, "test")
 
-    @mock.patch("flumine.markets.market.Market._validate_controls", return_value=False)
-    def test_update_order_violation(self, mock__validate_controls):
+    @mock.patch("flumine.markets.market.Market.transaction")
+    def test_replace_order(self, mock_transaction):
+        mock_transaction.return_value.__enter__.return_value = mock_transaction
         mock_order = mock.Mock()
-        self.assertFalse(self.market.update_order(mock_order, "test"))
-        mock__validate_controls.assert_called_with(mock_order, OrderPackageType.UPDATE)
-
-    @mock.patch("flumine.markets.market.Market._validate_controls", return_value=True)
-    @mock.patch("flumine.markets.market.Market._create_order_package")
-    def test_replace_order(self, mock__create_order_package, mock__validate_controls):
-        mock_order = mock.Mock()
-        self.assertTrue(self.market.replace_order(mock_order, 1.01, 321))
-        mock_order.replace.assert_called_with(1.01)
-        mock__create_order_package.assert_called_with(
-            [mock_order], OrderPackageType.REPLACE, 321
-        )
-        self.mock_flumine.process_order_package.assert_called_with(
-            mock__create_order_package()
-        )
-        mock__validate_controls.assert_called_with(mock_order, OrderPackageType.REPLACE)
-
-    @mock.patch("flumine.markets.market.Market._validate_controls", return_value=False)
-    def test_replace_order_violation(self, mock__validate_controls):
-        mock_order = mock.Mock()
-        self.assertFalse(self.market.replace_order(mock_order, 2.02))
-        mock__validate_controls.assert_called_with(mock_order, OrderPackageType.REPLACE)
-
-    def test__validate_controls(self):
-        mock_trading_control = mock.Mock()
-        mock_client_control = mock.Mock()
-        self.market.flumine.trading_controls = [mock_trading_control]
-        self.market.flumine.client.trading_controls = [mock_client_control]
-        mock_order = mock.Mock()
-        mock_package_type = mock.Mock()
-        self.assertTrue(self.market._validate_controls(mock_order, mock_package_type))
-        mock_trading_control.assert_called_with(mock_order, mock_package_type)
-        mock_client_control.assert_called_with(mock_order, mock_package_type)
-
-    def test__validate_controls_violation(self):
-        mock_trading_control = mock.Mock()
-        mock_trading_control.side_effect = ControlError("test")
-        mock_client_control = mock.Mock()
-        self.market.flumine.trading_controls = [mock_trading_control]
-        self.market.flumine.client.trading_controls = [mock_client_control]
-        mock_order = mock.Mock()
-        mock_package_type = mock.Mock()
-        self.assertFalse(self.market._validate_controls(mock_order, mock_package_type))
-        mock_trading_control.assert_called_with(mock_order, mock_package_type)
-        mock_client_control.assert_not_called()
-
-    @mock.patch("flumine.markets.market.BetfairOrderPackage")
-    def test__create_order_package(self, mock_betfair_order_package):
-        package = self.market._create_order_package([1, 2], OrderPackageType.PLACE, 123)
-        mock_betfair_order_package.assert_called_with(
-            client=self.market.flumine.client,
-            market_id=self.market.market_id,
-            orders=[1, 2],
-            package_type=OrderPackageType.PLACE,
-            bet_delay=self.market.market_book.bet_delay,
-            market_version=123,
-        )
-        self.assertEqual(package, mock_betfair_order_package())
+        self.assertTrue(self.market.replace_order(mock_order, 2, False))
+        mock_transaction.replace_order.assert_called_with(mock_order, 2, False)
 
     def test_event(self):
         mock_market_catalogue = mock.Mock()
diff --git a/tests/test_tradingcontrols.py b/tests/test_tradingcontrols.py
index 2d2f144..934ff3c 100644
--- a/tests/test_tradingcontrols.py
+++ b/tests/test_tradingcontrols.py
@@ -385,7 +385,8 @@ class TestStrategyExposure(unittest.TestCase):
         order2.average_price_matched = 0.0
         order2.size_matched = 0
 
-        self.market.blotter._orders = {"order1": order1, "order2": order2}
+        self.market.blotter["order1"] = order1
+        self.market.blotter["order2"] = order2
 
         self.trading_control._validate(order1, OrderPackageType.PLACE)
         self.assertEqual(1, mock_on_error.call_count)
diff --git a/tests/test_transaction.py b/tests/test_transaction.py
new file mode 100644
index 0000000..7e3287f
--- /dev/null
+++ b/tests/test_transaction.py
@@ -0,0 +1,228 @@
+import unittest
+from unittest import mock
+from unittest.mock import call
+
+from flumine.execution.transaction import Transaction, OrderPackageType, ControlError
+
+
+class TransactionTest(unittest.TestCase):
+    def setUp(self) -> None:
+        mock_blotter = {}
+        self.mock_market = mock.Mock(blotter=mock_blotter)
+        self.transaction = Transaction(self.mock_market)
+
+    def test_init(self):
+        self.assertEqual(self.transaction.market, self.mock_market)
+        self.assertEqual(self.transaction._pending_place, [])
+        self.assertEqual(self.transaction._pending_cancel, [])
+        self.assertEqual(self.transaction._pending_update, [])
+        self.assertEqual(self.transaction._pending_replace, [])
+
+    @mock.patch(
+        "flumine.execution.transaction.Transaction._validate_controls",
+        return_value=True,
+    )
+    @mock.patch("flumine.execution.transaction.events")
+    def test_place_order(self, mock_events, mock__validate_controls):
+        mock_order = mock.Mock(id="123")
+        mock_order.trade.market_notes = None
+        self.assertTrue(self.transaction.place_order(mock_order))
+        mock_order.place.assert_called_with(
+            self.transaction.market.market_book.publish_time
+        )
+        self.transaction.market.flumine.log_control.assert_called_with(
+            mock_events.TradeEvent()
+        )
+        mock_order.trade.update_market_notes.assert_called_with(self.mock_market)
+        mock__validate_controls.assert_called_with(mock_order, OrderPackageType.PLACE)
+        mock__validate_controls.assert_called_with(mock_order, OrderPackageType.PLACE)
+        self.transaction._pending_place = [(mock_order, None)]
+
+    @mock.patch(
+        "flumine.execution.transaction.Transaction._validate_controls",
+        return_value=True,
+    )
+    @mock.patch("flumine.execution.transaction.events")
+    def test_place_order_not_executed(self, mock_events, mock__validate_controls):
+        mock_order = mock.Mock(id="123")
+        self.assertTrue(self.transaction.place_order(mock_order, execute=False))
+        mock_order.place.assert_called_with(
+            self.transaction.market.market_book.publish_time
+        )
+        self.transaction.market.flumine.log_control.assert_called_with(
+            mock_events.TradeEvent()
+        )
+        mock__validate_controls.assert_called_with(mock_order, OrderPackageType.PLACE)
+        self.transaction._pending_place = []
+
+    @mock.patch(
+        "flumine.execution.transaction.Transaction._validate_controls",
+        return_value=True,
+    )
+    def test_place_order_retry(self, mock__validate_controls):
+        mock_order = mock.Mock()
+        self.transaction.market.blotter = {mock_order.id: mock_order}
+        self.assertTrue(self.transaction.place_order(mock_order))
+        mock__validate_controls.assert_called_with(mock_order, OrderPackageType.PLACE)
+        self.transaction._pending_place = []
+
+    @mock.patch(
+        "flumine.execution.transaction.Transaction._validate_controls",
+        return_value=False,
+    )
+    def test_place_order_violation(self, mock__validate_controls):
+        mock_order = mock.Mock()
+        self.assertFalse(self.transaction.place_order(mock_order))
+        mock__validate_controls.assert_called_with(mock_order, OrderPackageType.PLACE)
+        self.transaction._pending_place = []
+
+    @mock.patch(
+        "flumine.execution.transaction.Transaction._validate_controls",
+        return_value=True,
+    )
+    def test_cancel_order(self, mock__validate_controls):
+        mock_order = mock.Mock()
+        self.assertTrue(self.transaction.cancel_order(mock_order, 0.01))
+        mock_order.cancel.assert_called_with(0.01)
+        mock__validate_controls.assert_called_with(mock_order, OrderPackageType.CANCEL)
+        self.transaction._pending_cancel = [(mock_order,)]
+
+    @mock.patch(
+        "flumine.execution.transaction.Transaction._validate_controls",
+        return_value=False,
+    )
+    def test_cancel_order_violation(self, mock__validate_controls):
+        mock_order = mock.Mock()
+        self.assertFalse(self.transaction.cancel_order(mock_order))
+        mock__validate_controls.assert_called_with(mock_order, OrderPackageType.CANCEL)
+        self.transaction._pending_cancel = []
+
+    @mock.patch(
+        "flumine.execution.transaction.Transaction._validate_controls",
+        return_value=True,
+    )
+    def test_update_order(self, mock__validate_controls):
+        mock_order = mock.Mock()
+        self.assertTrue(self.transaction.update_order(mock_order, "PERSIST"))
+        mock_order.update.assert_called_with("PERSIST")
+        mock__validate_controls.assert_called_with(mock_order, OrderPackageType.UPDATE)
+        self.transaction._pending_update = [(mock_order,)]
+
+    @mock.patch(
+        "flumine.execution.transaction.Transaction._validate_controls",
+        return_value=False,
+    )
+    def test_update_order_violation(self, mock__validate_controls):
+        mock_order = mock.Mock()
+        self.assertFalse(self.transaction.update_order(mock_order, "test"))
+        mock__validate_controls.assert_called_with(mock_order, OrderPackageType.UPDATE)
+        self.transaction._pending_update = []
+
+    @mock.patch(
+        "flumine.execution.transaction.Transaction._validate_controls",
+        return_value=True,
+    )
+    def test_replace_order(self, mock__validate_controls):
+        mock_order = mock.Mock()
+        self.assertTrue(self.transaction.replace_order(mock_order, 1.01, 321))
+        mock_order.replace.assert_called_with(1.01)
+        mock__validate_controls.assert_called_with(mock_order, OrderPackageType.REPLACE)
+        self.transaction._pending_replace = [(mock_order,)]
+
+    @mock.patch(
+        "flumine.execution.transaction.Transaction._validate_controls",
+        return_value=False,
+    )
+    def test_replace_order_violation(self, mock__validate_controls):
+        mock_order = mock.Mock()
+        self.assertFalse(self.transaction.replace_order(mock_order, 2.02))
+        mock__validate_controls.assert_called_with(mock_order, OrderPackageType.REPLACE)
+        self.transaction._pending_replace = []
+
+    @mock.patch("flumine.execution.transaction.Transaction._create_order_package")
+    def test_execute(self, mock__create_order_package):
+        mock_package = mock.Mock()
+        mock__create_order_package.return_value = [mock_package]
+        self.assertEqual(self.transaction.execute(), 0)
+        mock_order = mock.Mock()
+        self.transaction._pending_place = [(mock_order, 1234)]
+        self.transaction._pending_cancel = [(mock_order,)]
+        self.transaction._pending_update = [(mock_order,)]
+        self.transaction._pending_replace = [(mock_order, 1234)]
+        self.assertEqual(self.transaction.execute(), 4)
+        mock__create_order_package.assert_has_calls(
+            [
+                call([], OrderPackageType.PLACE),
+                call([], OrderPackageType.CANCEL),
+                call([], OrderPackageType.UPDATE),
+                call([], OrderPackageType.REPLACE),
+            ]
+        )
+        self.transaction.market.flumine.process_order_package.assert_has_calls(
+            [
+                call(mock__create_order_package()[0]),
+                call(mock__create_order_package()[0]),
+                call(mock__create_order_package()[0]),
+                call(mock__create_order_package()[0]),
+            ]
+        )
+
+    def test__validate_controls(self):
+        mock_trading_control = mock.Mock()
+        mock_client_control = mock.Mock()
+        self.transaction.market.flumine.trading_controls = [mock_trading_control]
+        self.transaction.market.flumine.client.trading_controls = [mock_client_control]
+        mock_order = mock.Mock()
+        mock_package_type = mock.Mock()
+        self.assertTrue(
+            self.transaction._validate_controls(mock_order, mock_package_type)
+        )
+        mock_trading_control.assert_called_with(mock_order, mock_package_type)
+        mock_client_control.assert_called_with(mock_order, mock_package_type)
+
+    def test__validate_controls_violation(self):
+        mock_trading_control = mock.Mock()
+        mock_trading_control.side_effect = ControlError("test")
+        mock_client_control = mock.Mock()
+        self.transaction.market.flumine.trading_controls = [mock_trading_control]
+        self.transaction.market.flumine.client.trading_controls = [mock_client_control]
+        mock_order = mock.Mock()
+        mock_package_type = mock.Mock()
+        self.assertFalse(
+            self.transaction._validate_controls(mock_order, mock_package_type)
+        )
+        mock_trading_control.assert_called_with(mock_order, mock_package_type)
+        mock_client_control.assert_not_called()
+
+    @mock.patch("flumine.execution.transaction.BetfairOrderPackage")
+    def test__create_order_package(self, mock_betfair_order_package):
+        packages = self.transaction._create_order_package(
+            [(1, None), (2, None), (3, 123), (4, 123)], OrderPackageType.PLACE
+        )
+        mock_betfair_order_package.assert_has_calls(
+            [
+                call(
+                    client=self.transaction.market.flumine.client,
+                    market_id=self.transaction.market.market_id,
+                    orders=[1, 2],
+                    package_type=OrderPackageType.PLACE,
+                    bet_delay=self.transaction.market.market_book.bet_delay,
+                    market_version=None,
+                ),
+                call(
+                    client=self.transaction.market.flumine.client,
+                    market_id=self.transaction.market.market_id,
+                    orders=[3, 4],
+                    package_type=OrderPackageType.PLACE,
+                    bet_delay=self.transaction.market.market_book.bet_delay,
+                    market_version=123,
+                ),
+            ]
+        )
+        self.assertEqual(len(packages), 2)
+
+    @mock.patch("flumine.execution.transaction.Transaction.execute")
+    def test_enter_exit(self, mock_execute):
+        with self.transaction as t:
+            self.assertEqual(self.transaction, t)
+        mock_execute.assert_called()
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 6804594..610a30a 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -18,31 +18,6 @@ class UtilsTest(unittest.TestCase):
     def test_chunks(self):
         self.assertEqual([i for i in utils.chunks([1, 2, 3], 1)], [[1], [2], [3]])
 
-    def test_batch_orders(self):
-        order_one = (mock.Mock(), {})
-        order_two = (mock.Mock(), {"market_version": 123})
-        order_three = (mock.Mock(), {"batch": False})
-        order_four = (mock.Mock(), {"market_version": 123, "batch": True})
-        self.assertEqual(
-            utils.batch_orders([order_one, order_two, order_three, order_four]),
-            {
-                None: [[order_one[0]], [order_three[0]]],
-                123: [[order_two[0], order_four[0]]],
-            },
-        )
-
-    def test__split_orders_by(self):
-        order_one = (mock.Mock(), {})
-        order_four = (mock.Mock(), {"market_version": 123, "batch": True})
-        order_eight = (mock.Mock(), {"market_version": 456, "batch": True})
-        order_ten = (mock.Mock(), {"market_version": 456, "batch": True})
-        self.assertEqual(
-            utils._split_orders_by(
-                [order_one, order_four, order_eight, order_ten], "market_version", 321
-            ),
-            {321: [order_one], 123: [order_four], 456: [order_eight, order_ten]},
-        )
-
     def test_create_cheap_hash(self):
         self.assertEqual(
             utils.create_cheap_hash("test"),