Repository: liampauling/flumine
Pull request #376 (2021-02-23 09:19:15)

Transaction control refactor to correctly validate new limit
- includes failed
- thread safe
- pending execution update


Patch:
diff --git a/HISTORY.rst b/HISTORY.rst
index 2cdc7d3..4d03f3f 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -3,18 +3,6 @@
 Release History
 ---------------
 
-1.17.1 (2021-02-24)
-+++++++++++++++++++
-
-**Improvements**
-
-- Current and total transactions available from client
-- `blotter.strategy_selection_orders` func added (speed improvement on exposure calc)
-
-**Bug Fixes**
-
-- Refactor of client transaction control to correctly apply the 5000 limit
-
 1.17.0 (2021-02-22)
 +++++++++++++++++++
 
diff --git a/flumine/__version__.py b/flumine/__version__.py
index eee2122..9251e59 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.17.1"
+__version__ = "1.17.0"
 __author__ = "Liam Pauling"
 __license__ = "MIT"
diff --git a/flumine/baseflumine.py b/flumine/baseflumine.py
index 19cc1d0..0039efc 100644
--- a/flumine/baseflumine.py
+++ b/flumine/baseflumine.py
@@ -16,7 +16,7 @@ from .markets.middleware import Middleware, SimulatedMiddleware
 from .execution.betfairexecution import BetfairExecution
 from .execution.simulatedexecution import SimulatedExecution
 from .order.process import process_current_orders
-from .controls.clientcontrols import BaseControl, MaxOrderCount
+from .controls.clientcontrols import BaseControl, MaxTransactionCount
 from .controls.tradingcontrols import OrderValidation, StrategyExposure
 from .controls.loggingcontrols import LoggingControl
 from . import config, utils
@@ -69,7 +69,7 @@ class BaseFlumine:
         self.add_trading_control(OrderValidation)
         self.add_trading_control(StrategyExposure)
         # register default client controls (processed in order)
-        self.add_client_control(MaxOrderCount)
+        self.add_client_control(MaxTransactionCount)
 
         # workers
         self._workers = []
diff --git a/flumine/clients/baseclient.py b/flumine/clients/baseclient.py
index efea439..a6d8111 100644
--- a/flumine/clients/baseclient.py
+++ b/flumine/clients/baseclient.py
@@ -42,7 +42,6 @@ class BaseClient:
         self.account_details = None
         self.account_funds = None
         self.commission_paid = 0
-        self.chargeable_transaction_count = 0
 
         self.execution = None  # set during flumine init
         self.trading_controls = []
@@ -65,6 +64,25 @@ class BaseClient:
         elif self.EXCHANGE == ExchangeType.BETFAIR:
             self.execution = flumine.betfair_execution
 
+    def add_transaction(self, count: int, failed: bool = False) -> None:
+        for control in self.trading_controls:
+            if hasattr(control, "add_transaction"):
+                control.add_transaction(count, failed)
+
+    @property
+    def current_transaction_count_total(self) -> Optional[int]:
+        # current hours total transaction count
+        for control in self.trading_controls:
+            if control.NAME == "MAX_TRANSACTION_COUNT":
+                return control.current_transaction_count_total
+
+    @property
+    def transaction_count_total(self) -> Optional[int]:
+        # total transaction count
+        for control in self.trading_controls:
+            if control.NAME == "MAX_TRANSACTION_COUNT":
+                return control.transaction_count_total
+
     @property
     def min_bet_size(self) -> Optional[float]:
         raise NotImplementedError
@@ -83,7 +101,8 @@ class BaseClient:
             "id": self.id,
             "exchange": self.EXCHANGE.value if self.EXCHANGE else None,
             "betting_client": self.betting_client,
-            "chargeable_transaction_count": self.chargeable_transaction_count,
+            "current_transaction_count_total": self.current_transaction_count_total,
+            "transaction_count_total": self.transaction_count_total,
             "trading_controls": self.trading_controls,
             "order_stream": self.order_stream,
             "best_price_execution": self.best_price_execution,
diff --git a/flumine/controls/clientcontrols.py b/flumine/controls/clientcontrols.py
index 70f1e0e..ddaa6e8 100644
--- a/flumine/controls/clientcontrols.py
+++ b/flumine/controls/clientcontrols.py
@@ -1,7 +1,7 @@
 import datetime
 import logging
+import threading
 from typing import Optional
-from betfairlightweight.metadata import transaction_limit
 
 from ..order.orderpackage import BaseOrder, OrderPackageType
 from . import BaseControl
@@ -10,106 +10,104 @@ from ..clients.baseclient import BaseClient
 logger = logging.getLogger(__name__)
 
 
-class MaxOrderCount(BaseControl):
+class MaxTransactionCount(BaseControl):
 
     """
     Counts and limits orders based on max
-    order count.
-    Only prevents placeOrders if limit is
-    reached.
+    transaction count as per:
+        - https://www.betfair.com/aboutUs/Betfair.Charges/#TranCharges2
+        - 5000 transactions per hour
+        - `A ‘transaction’ shall include all bets placed and all failed transactions`
+    Counts are updated after an execution,
+    thread safe due to the execution pool.
     """
 
-    NAME = "MAX_ORDER_COUNT"
+    NAME = "MAX_TRANSACTION_COUNT"
 
     def __init__(self, flumine, client: BaseClient):
-        super(MaxOrderCount, self).__init__(flumine)
+        super(MaxTransactionCount, self).__init__(flumine)
         self.client = client
-        self.total = 0
-        self.place_requests = 0
-        self.cancel_requests = 0
-        self.update_requests = 0
-        self.replace_requests = 0
         self._next_hour = None
+        # this hour
+        self.current_transaction_count = 0
+        self.current_failed_transaction_count = 0
+        # total since start
         self.transaction_count = 0
+        self.failed_transaction_count = 0
+        # thread lock
+        self._lock = threading.Lock()
+
+    def add_transaction(self, count: int, failed: bool = False) -> None:
+        with self._lock:
+            if failed:
+                self.failed_transaction_count += count
+                self.current_failed_transaction_count += count
+            else:
+                self.transaction_count += count
+                self.current_transaction_count += count
 
     def _validate(self, order: BaseOrder, package_type: OrderPackageType) -> None:
-        if self._next_hour is None:
-            self._set_next_hour()
         self._check_hour()
-        self.total += 1
-        if package_type == OrderPackageType.PLACE:
-            self._check_transaction_count(1)
-            self.place_requests += 1
-            if not self.safe:
-                self._on_error(
-                    order,
-                    "Max Order Count has been reached ({0}) for current hour".format(
-                        self.transaction_count
-                    ),
-                )
-        elif package_type == OrderPackageType.CANCEL:
-            self.cancel_requests += 1
-        elif package_type == OrderPackageType.UPDATE:
-            self.update_requests += 1
-        elif package_type == OrderPackageType.REPLACE:
-            self.replace_requests += 1
+        if not self.safe:
+            self._on_error(
+                order,
+                "Max Transaction Count has been reached ({0}) for current hour".format(
+                    self.current_transaction_count_total
+                ),
+            )
 
     def _check_hour(self) -> None:
-        if datetime.datetime.utcnow() > self._next_hour:
+        if self._next_hour is None:
+            self._set_next_hour()
+        elif datetime.datetime.utcnow() > self._next_hour:
             logger.info(
                 "Execution new hour",
                 extra={
-                    "transaction_count": self.transaction_count,
-                    "place_requests": self.place_requests,
-                    "cancel_requests": self.cancel_requests,
-                    "update_requests": self.update_requests,
-                    "replace_requests": self.replace_requests,
+                    "current_transaction_count_total": self.current_transaction_count_total,
+                    "current_transaction_count": self.current_transaction_count,
+                    "current_failed_transaction_count": self.current_failed_transaction_count,
+                    "total_transaction_count": self.transaction_count,
+                    "total_failed_transaction_count": self.failed_transaction_count,
                     "client": self.client,
                 },
             )
             self._set_next_hour()
-            if self.transaction_count > transaction_limit:
-                self.client.chargeable_transaction_count += (
-                    self.transaction_count - transaction_limit
-                )
-            self.transaction_count = 0
-
-    def _check_transaction_count(self, transaction_count: int) -> None:
-        self.transaction_count += transaction_count
-        if self.transaction_limit and self.transaction_count > self.transaction_limit:
-            logger.error(
-                "Transaction limit reached",
-                extra={
-                    "transaction_count": self.transaction_count,
-                    "transaction_limit": self.transaction_limit,
-                    "client": self.client,
-                },
-            )
 
     def _set_next_hour(self) -> None:
         now = datetime.datetime.utcnow()
         self._next_hour = (now + datetime.timedelta(hours=1)).replace(
             minute=0, second=0, microsecond=0
         )
+        self.current_transaction_count = 0
+        self.current_failed_transaction_count = 0
 
     @property
     def safe(self) -> bool:
-        self._check_hour()
         if self.transaction_limit is None:
             return True
-        elif self.transaction_count < self.transaction_limit:
+        elif self.current_transaction_count_total <= self.transaction_limit:
             return True
         else:
             logger.error(
                 "Transaction limit reached",
                 extra={
-                    "transaction_count": self.transaction_count,
+                    "current_transaction_count_total": self.current_transaction_count_total,
+                    "current_transaction_count": self.current_transaction_count,
+                    "current_failed_transaction_count": self.current_failed_transaction_count,
                     "transaction_limit": self.transaction_limit,
                     "client": self.client,
                 },
             )
             return False
 
+    @property
+    def current_transaction_count_total(self) -> int:
+        return self.current_transaction_count + self.current_failed_transaction_count
+
+    @property
+    def transaction_count_total(self) -> int:
+        return self.transaction_count + self.failed_transaction_count
+
     @property
     def transaction_limit(self) -> Optional[int]:
         return self.client.transaction_limit
diff --git a/flumine/execution/betfairexecution.py b/flumine/execution/betfairexecution.py
index c4849e6..c83792c 100644
--- a/flumine/execution/betfairexecution.py
+++ b/flumine/execution/betfairexecution.py
@@ -35,6 +35,9 @@ class BetfairExecution(BaseExecution):
                         # https://docs.developer.betfair.com/display/1smk3cen4v3lu3yomq5qye0ni/Betting+Enums#BettingEnums-ExecutionReportStatus
                         pass
 
+            # update transaction counts
+            order_package.client.add_transaction(len(order_package))
+
     def place(self, order_package: OrderPackageType, session: requests.Session):
         return order_package.client.betting_client.betting.place_orders(
             market_id=order_package.market_id,
@@ -51,6 +54,7 @@ class BetfairExecution(BaseExecution):
     ) -> None:
         response = self._execution_helper(self.cancel, order_package, http_session)
         if response:
+            failed_transaction_count = 0
             order_lookup = {o.bet_id: o for o in order_package}
             for instruction_report in response.cancel_instruction_reports:
                 # get order (can't rely on the order they are returned)
@@ -68,6 +72,7 @@ class BetfairExecution(BaseExecution):
                             order.executable()
                     elif instruction_report.status == "FAILURE":
                         order.executable()
+                        failed_transaction_count += 1
                     elif instruction_report.status == "TIMEOUT":
                         order.executable()
 
@@ -76,6 +81,12 @@ class BetfairExecution(BaseExecution):
                 with order.trade:
                     order.executable()
 
+            # update transaction counts
+            if failed_transaction_count:
+                order_package.client.add_transaction(
+                    failed_transaction_count, failed=True
+                )
+
     def cancel(self, order_package: OrderPackageType, session: requests.Session):
         # temp copy to prevent an empty list of instructions sent
         # this can occur if order is matched during the execution
@@ -96,6 +107,7 @@ class BetfairExecution(BaseExecution):
     ) -> None:
         response = self._execution_helper(self.update, order_package, http_session)
         if response:
+            failed_transaction_count = 0
             for (order, instruction_report) in zip(
                 order_package, response.update_instruction_reports
             ):
@@ -107,9 +119,16 @@ class BetfairExecution(BaseExecution):
                         order.executable()
                     elif instruction_report.status == "FAILURE":
                         order.executable()
+                        failed_transaction_count += 1
                     elif instruction_report.status == "TIMEOUT":
                         order.executable()
 
+            # update transaction counts
+            if failed_transaction_count:
+                order_package.client.add_transaction(
+                    failed_transaction_count, failed=True
+                )
+
     def update(self, order_package: OrderPackageType, session: requests.Session):
         return order_package.client.betting_client.betting.update_orders(
             market_id=order_package.market_id,
@@ -123,6 +142,7 @@ class BetfairExecution(BaseExecution):
     ) -> None:
         response = self._execution_helper(self.replace, order_package, http_session)
         if response:
+            failed_transaction_count = 0
             market = self.flumine.markets.markets[order_package.market_id]
             for (order, instruction_report) in zip(
                 order_package, response.replace_instruction_reports
@@ -144,6 +164,7 @@ class BetfairExecution(BaseExecution):
                         == "FAILURE"
                     ):
                         order.executable()
+                        failed_transaction_count += 1
                     elif (
                         instruction_report.cancel_instruction_reports.status
                         == "TIMEOUT"
@@ -175,6 +196,13 @@ class BetfairExecution(BaseExecution):
                     ):
                         pass  # todo
 
+            # update transaction counts
+            order_package.client.add_transaction(len(order_package))
+            if failed_transaction_count:
+                order_package.client.add_transaction(
+                    failed_transaction_count, failed=True
+                )
+
     def replace(self, order_package: OrderPackageType, session: requests.Session):
         return order_package.client.betting_client.betting.replace_orders(
             market_id=order_package.market_id,
diff --git a/flumine/execution/simulatedexecution.py b/flumine/execution/simulatedexecution.py
index 8c3f75d..4a72267 100644
--- a/flumine/execution/simulatedexecution.py
+++ b/flumine/execution/simulatedexecution.py
@@ -53,11 +53,15 @@ class SimulatedExecution(BaseExecution):
                 elif simulated_response.status == "FAILURE":
                     order.lapsed()
 
+        # update transaction counts
+        order_package.client.add_transaction(len(order_package))
+
     def execute_cancel(
         self, order_package, http_session: Optional[requests.Session]
     ) -> None:
         if order_package.client.paper_trade:
             time.sleep(self.CANCEL_LATENCY)
+        failed_transaction_count = 0
         for order in order_package:
             with order.trade:
                 simulated_response = order.simulated.cancel()
@@ -71,12 +75,18 @@ class SimulatedExecution(BaseExecution):
                         order.executable()
                 elif simulated_response.status == "FAILURE":
                     order.executable()
+                    failed_transaction_count += 1
+
+        # update transaction counts
+        if failed_transaction_count:
+            order_package.client.add_transaction(failed_transaction_count, failed=True)
 
     def execute_update(
         self, order_package, http_session: Optional[requests.Session]
     ) -> None:
         if order_package.client.paper_trade:
             time.sleep(self.UPDATE_LATENCY)
+        failed_transaction_count = 0
         for order, instruction in zip(order_package, order_package.update_instructions):
             with order.trade:
                 simulated_response = order.simulated.update(instruction)
@@ -87,6 +97,11 @@ class SimulatedExecution(BaseExecution):
                     order.executable()
                 elif simulated_response.status == "FAILURE":
                     order.executable()
+                    failed_transaction_count += 1
+
+        # update transaction counts
+        if failed_transaction_count:
+            order_package.client.add_transaction(failed_transaction_count, failed=True)
 
     def execute_replace(
         self, order_package, http_session: Optional[requests.Session]
@@ -95,6 +110,7 @@ class SimulatedExecution(BaseExecution):
             order_package.client.paper_trade
         ):  # todo should the cancel happen without a delay?
             time.sleep(order_package.bet_delay + self.REPLACE_LATENCY)
+        failed_transaction_count = 0
         market = self.flumine.markets.markets[order_package.market_id]
         for order, instruction in zip(
             order_package, order_package.replace_instructions
@@ -106,6 +122,7 @@ class SimulatedExecution(BaseExecution):
                     order.execution_complete()
                 elif cancel_instruction_report.status == "FAILURE":
                     order.executable()  # todo do not carry out replace
+                    failed_transaction_count += 1
                 else:
                     order.lapsed()  # todo do not carry out replace
                 self._order_logger(
@@ -135,3 +152,8 @@ class SimulatedExecution(BaseExecution):
                     replacement_order.executable()
                 elif place_instruction_report.status == "FAILURE":
                     order.executable()
+
+        # update transaction counts
+        order_package.client.add_transaction(len(order_package))
+        if failed_transaction_count:
+            order_package.client.add_transaction(failed_transaction_count, failed=True)


Test patch:
diff --git a/tests/test_clientcontrols.py b/tests/test_clientcontrols.py
index b00bb96..5436cb1 100644
--- a/tests/test_clientcontrols.py
+++ b/tests/test_clientcontrols.py
@@ -4,7 +4,7 @@ from unittest import mock
 
 from flumine.controls.clientcontrols import (
     BaseControl,
-    MaxOrderCount,
+    MaxTransactionCount,
     OrderPackageType,
 )
 from flumine.exceptions import ControlError
@@ -38,82 +38,61 @@ class TestBaseControl(unittest.TestCase):
         order.violation.assert_called_with("Order has violated: None Error: test")
 
 
-class TestMaxOrderCount(unittest.TestCase):
+class TestMaxTransactionCount(unittest.TestCase):
     def setUp(self):
         self.mock_client = mock.Mock()
         self.mock_client.transaction_limit = 1000
         self.mock_client.chargeable_transaction_count = 0
         self.mock_flumine = mock.Mock()
-        self.trading_control = MaxOrderCount(self.mock_flumine, self.mock_client)
+        self.trading_control = MaxTransactionCount(self.mock_flumine, self.mock_client)
 
     def test_init(self):
         self.assertEqual(self.trading_control.client, self.mock_client)
-        self.assertEqual(self.trading_control.NAME, "MAX_ORDER_COUNT")
-        self.assertEqual(self.trading_control.total, 0)
-        self.assertEqual(self.trading_control.place_requests, 0)
-        self.assertEqual(self.trading_control.update_requests, 0)
-        self.assertEqual(self.trading_control.cancel_requests, 0)
-        self.assertEqual(self.trading_control.replace_requests, 0)
+        self.assertEqual(self.trading_control.NAME, "MAX_TRANSACTION_COUNT")
         self.assertIsNone(self.trading_control._next_hour)
+        self.assertEqual(self.trading_control.current_transaction_count, 0)
+        self.assertEqual(self.trading_control.current_failed_transaction_count, 0)
         self.assertEqual(self.trading_control.transaction_count, 0)
-        self.assertEqual(self.trading_control.transaction_limit, 1000)
-
-    @mock.patch("flumine.controls.clientcontrols.MaxOrderCount._set_next_hour")
-    @mock.patch(
-        "flumine.controls.clientcontrols.MaxOrderCount._check_transaction_count"
-    )
-    @mock.patch("flumine.controls.clientcontrols.MaxOrderCount._check_hour")
-    def test_validate_place(
-        self, mock_check_hour, mock__check_transaction_count, mock__set_next_hour
-    ):
+        self.assertEqual(self.trading_control.failed_transaction_count, 0)
+
+    def test_add_transaction(self):
+        self.trading_control.add_transaction(123)
+        self.assertEqual(self.trading_control.transaction_count, 123)
+        self.assertEqual(self.trading_control.current_transaction_count, 123)
+        self.trading_control.add_transaction(123, True)
+        self.assertEqual(self.trading_control.failed_transaction_count, 123)
+        self.assertEqual(self.trading_control.current_failed_transaction_count, 123)
+        self.trading_control.add_transaction(123)
+        self.assertEqual(self.trading_control.transaction_count, 246)
+        self.assertEqual(self.trading_control.current_transaction_count, 246)
+
+    @mock.patch("flumine.controls.clientcontrols.MaxTransactionCount._check_hour")
+    def test_validate(self, mock_check_hour):
         mock_order = mock.Mock()
         self.trading_control._validate(mock_order, OrderPackageType.PLACE)
         mock_check_hour.assert_called()
-        mock__check_transaction_count.assert_called_with(1)
-        mock__set_next_hour.assert_called()
-        self.assertEqual(self.trading_control.place_requests, 1)
 
-    @mock.patch("flumine.controls.clientcontrols.MaxOrderCount._check_hour")
-    def test_validate_cancel(self, mock_check_hour):
-        mock_order = mock.Mock()
-        self.trading_control._validate(mock_order, OrderPackageType.CANCEL)
-        mock_check_hour.assert_called_with()
-        self.assertEqual(self.trading_control.cancel_requests, 1)
-
-    @mock.patch("flumine.controls.clientcontrols.MaxOrderCount._check_hour")
-    def test_validate_update(self, mock_check_hour):
-        mock_order = mock.Mock()
-        self.trading_control._validate(mock_order, OrderPackageType.UPDATE)
-        mock_check_hour.assert_called_with()
-        self.assertEqual(self.trading_control.update_requests, 1)
-
-    @mock.patch("flumine.controls.clientcontrols.MaxOrderCount._check_hour")
-    def test_validate_replace(self, mock_check_hour):
-        mock_order = mock.Mock()
-        self.trading_control._validate(mock_order, OrderPackageType.REPLACE)
-        mock_check_hour.assert_called_with()
-        self.assertEqual(self.trading_control.replace_requests, 1)
-
-    @mock.patch("flumine.controls.clientcontrols.MaxOrderCount._set_next_hour")
+    @mock.patch("flumine.controls.clientcontrols.MaxTransactionCount._set_next_hour")
     def test_check_hour(self, mock_set_next_hour):
         self.trading_control._next_hour = datetime.datetime.utcnow()
         self.trading_control._check_hour()
 
-        self.trading_control.transaction_count = 5069
         now = datetime.datetime.now()
         self.trading_control._next_hour = (now + datetime.timedelta(hours=-1)).replace(
             minute=0, second=0, microsecond=0
         )
         self.trading_control._check_hour()
         mock_set_next_hour.assert_called_with()
-        self.assertEqual(self.trading_control.transaction_count, 0)
-        self.assertEqual(self.mock_client.chargeable_transaction_count, 69)
 
-    def test_check_transaction_count(self):
-        self.trading_control._check_transaction_count(10)
-        self.assertEqual(self.trading_control.transaction_count, 10)
+    @mock.patch("flumine.controls.clientcontrols.MaxTransactionCount._set_next_hour")
+    def test_check_hour_none(self, mock_set_next_hour):
+        self.trading_control._next_hour = None
+        self.trading_control._check_hour()
+        mock_set_next_hour.assert_called_with()
 
     def test_set_next_hour(self):
+        self.trading_control.current_transaction_count = 5069
+        self.trading_control.current_failed_transaction_count = 5069
         self.trading_control._next_hour = None
 
         self.trading_control._set_next_hour()
@@ -122,19 +101,27 @@ class TestMaxOrderCount(unittest.TestCase):
             minute=0, second=0, microsecond=0
         )
         self.assertEqual(self.trading_control._next_hour, now_1)
+        self.assertEqual(self.trading_control.current_transaction_count, 0)
+        self.assertEqual(self.trading_control.current_failed_transaction_count, 0)
 
-    @mock.patch("flumine.controls.clientcontrols.MaxOrderCount._check_hour")
-    def test_safe(self, mock_check_hour):
+    def test_safe(self):
         self.assertTrue(self.trading_control.safe)
-        mock_check_hour.assert_called_with()
 
         self.trading_control.client.transaction_limit = -10
         self.assertFalse(self.trading_control.safe)
-        mock_check_hour.assert_called_with()
 
         self.trading_control.client.transaction_limit = None
         self.assertTrue(self.trading_control.safe)
-        mock_check_hour.assert_called_with()
+
+    def test_current_transaction_count_total(self):
+        self.trading_control.current_transaction_count = 1
+        self.trading_control.current_failed_transaction_count = 2
+        self.assertEqual(self.trading_control.current_transaction_count_total, 3)
+
+    def test_transaction_count_total(self):
+        self.trading_control.transaction_count = 1
+        self.trading_control.failed_transaction_count = 2
+        self.assertEqual(self.trading_control.transaction_count_total, 3)
 
     def test_transaction_limit(self):
         self.assertEqual(
diff --git a/tests/test_clients.py b/tests/test_clients.py
index 5e6cc11..9e33d18 100644
--- a/tests/test_clients.py
+++ b/tests/test_clients.py
@@ -24,7 +24,6 @@ class BaseClientTest(unittest.TestCase):
         self.assertIsNone(self.base_client.account_details)
         self.assertIsNone(self.base_client.account_funds)
         self.assertEqual(self.base_client.commission_paid, 0)
-        self.assertEqual(self.base_client.chargeable_transaction_count, 0)
         self.assertIsNone(self.base_client.execution)
         self.assertEqual(self.base_client.trading_controls, [])
         self.assertTrue(self.base_client.order_stream)
@@ -64,6 +63,28 @@ class BaseClientTest(unittest.TestCase):
         self.base_client.add_execution(mock_flumine)
         self.assertEqual(self.base_client.execution, mock_flumine.simulated_execution)
 
+    def test_add_transaction(self):
+        mock_trading_control = mock.Mock()
+        self.base_client.trading_controls.append(mock_trading_control)
+        self.base_client.add_transaction(123, True)
+        mock_trading_control.add_transaction.assert_called_with(123, True)
+
+    def test_current_transaction_count_total(self):
+        self.assertIsNone(self.base_client.current_transaction_count_total)
+        mock_trading_control = mock.Mock(
+            NAME="MAX_TRANSACTION_COUNT", current_transaction_count_total=123
+        )
+        self.base_client.trading_controls.append(mock_trading_control)
+        self.assertEqual(self.base_client.current_transaction_count_total, 123)
+
+    def test_transaction_count_total(self):
+        self.assertIsNone(self.base_client.transaction_count_total)
+        mock_trading_control = mock.Mock(
+            NAME="MAX_TRANSACTION_COUNT", transaction_count_total=123
+        )
+        self.base_client.trading_controls.append(mock_trading_control)
+        self.assertEqual(self.base_client.transaction_count_total, 123)
+
     def test_min_bet_size(self):
         with self.assertRaises(NotImplementedError):
             assert self.base_client.min_bet_size
diff --git a/tests/test_execution.py b/tests/test_execution.py
index 66ff6d9..278fc8d 100644
--- a/tests/test_execution.py
+++ b/tests/test_execution.py
@@ -213,7 +213,8 @@ class BetfairExecutionTest(unittest.TestCase):
         mock_order = mock.Mock()
         mock_order.trade.__enter__ = mock.Mock()
         mock_order.trade.__exit__ = mock.Mock()
-        mock_order_package = mock.Mock()
+        mock_order_package = mock.MagicMock()
+        mock_order_package.__len__.return_value = 1
         mock_order_package.__iter__ = mock.Mock(return_value=iter([mock_order]))
         mock_order_package.info = {}
         mock_report = mock.Mock()
@@ -231,6 +232,7 @@ class BetfairExecutionTest(unittest.TestCase):
         mock_order.executable.assert_called_with()
         mock_order.trade.__enter__.assert_called_with()
         mock_order.trade.__exit__.assert_called_with(None, None, None)
+        mock_order_package.client.add_transaction.assert_called_with(1)
 
     @mock.patch("flumine.execution.betfairexecution.BetfairExecution._order_logger")
     @mock.patch("flumine.execution.betfairexecution.BetfairExecution.place")
@@ -242,7 +244,8 @@ class BetfairExecutionTest(unittest.TestCase):
         mock_order = mock.Mock()
         mock_order.trade.__enter__ = mock.Mock()
         mock_order.trade.__exit__ = mock.Mock()
-        mock_order_package = mock.Mock()
+        mock_order_package = mock.MagicMock()
+        mock_order_package.__len__.return_value = 1
         mock_order_package.__iter__ = mock.Mock(return_value=iter([mock_order]))
         mock_order_package.info = {}
         mock_report = mock.Mock()
@@ -260,6 +263,7 @@ class BetfairExecutionTest(unittest.TestCase):
         mock_order.lapsed.assert_called_with()
         mock_order.trade.__enter__.assert_called_with()
         mock_order.trade.__exit__.assert_called_with(None, None, None)
+        mock_order_package.client.add_transaction.assert_called_with(1)
 
     @mock.patch("flumine.execution.betfairexecution.BetfairExecution._order_logger")
     @mock.patch("flumine.execution.betfairexecution.BetfairExecution.place")
@@ -271,7 +275,8 @@ class BetfairExecutionTest(unittest.TestCase):
         mock_order = mock.Mock()
         mock_order.trade.__enter__ = mock.Mock()
         mock_order.trade.__exit__ = mock.Mock()
-        mock_order_package = mock.Mock()
+        mock_order_package = mock.MagicMock()
+        mock_order_package.__len__.return_value = 1
         mock_order_package.__iter__ = mock.Mock(return_value=iter([mock_order]))
         mock_order_package.info = {}
         mock_report = mock.Mock()
@@ -289,6 +294,7 @@ class BetfairExecutionTest(unittest.TestCase):
         # mock_order.lapsed.assert_called_with()
         mock_order.trade.__enter__.assert_called_with()
         mock_order.trade.__exit__.assert_called_with(None, None, None)
+        mock_order_package.client.add_transaction.assert_called_with(1)
 
     @mock.patch("flumine.execution.betfairexecution.BetfairExecution.place")
     @mock.patch("flumine.execution.betfairexecution.BetfairExecution._execution_helper")
@@ -406,6 +412,7 @@ class BetfairExecutionTest(unittest.TestCase):
         mock_order.executable.assert_called_with()
         mock_order.trade.__enter__.assert_called_with()
         mock_order.trade.__exit__.assert_called_with(None, None, None)
+        mock_order_package.client.add_transaction.assert_called_with(1, failed=True)
 
     @mock.patch("flumine.execution.betfairexecution.BetfairExecution._order_logger")
     @mock.patch("flumine.execution.betfairexecution.BetfairExecution.cancel")
@@ -551,6 +558,7 @@ class BetfairExecutionTest(unittest.TestCase):
         mock_order.executable.assert_called_with()
         mock_order.trade.__enter__.assert_called_with()
         mock_order.trade.__exit__.assert_called_with(None, None, None)
+        mock_order_package.client.add_transaction.assert_called_with(1, failed=True)
 
     @mock.patch("flumine.execution.betfairexecution.BetfairExecution._order_logger")
     @mock.patch("flumine.execution.betfairexecution.BetfairExecution.update")
@@ -614,7 +622,8 @@ class BetfairExecutionTest(unittest.TestCase):
         mock_order = mock.Mock(market_id="1.23", bet_id=123)
         mock_order.trade.__enter__ = mock.Mock()
         mock_order.trade.__exit__ = mock.Mock()
-        mock_order_package = mock.Mock(market_id="1.23", info={})
+        mock_order_package = mock.MagicMock(market_id="1.23", info={})
+        mock_order_package.__len__.return_value = 1
         mock_order_package.__iter__ = mock.Mock(return_value=iter([mock_order]))
         mock_report = mock.Mock()
         mock_instruction_report = mock.Mock()
@@ -640,6 +649,7 @@ class BetfairExecutionTest(unittest.TestCase):
         )
         mock_order.trade.__enter__.assert_called_with()
         mock_order.trade.__exit__.assert_called_with(None, None, None)
+        mock_order_package.client.add_transaction.assert_called_with(1)
 
     @mock.patch("flumine.execution.betfairexecution.BetfairExecution.replace")
     @mock.patch("flumine.execution.betfairexecution.BetfairExecution._execution_helper")
@@ -820,7 +830,8 @@ class SimulatedExecutionTest(unittest.TestCase):
         mock_order = mock.Mock()
         mock_order.trade.__enter__ = mock.Mock()
         mock_order.trade.__exit__ = mock.Mock()
-        mock_order_package = mock.Mock(market_id="1.23")
+        mock_order_package = mock.MagicMock(market_id="1.23")
+        mock_order_package.__len__.return_value = 1
         mock_order_package.client.paper_trade = False
         mock_order_package.__iter__ = mock.Mock(return_value=iter([mock_order]))
         mock_order_package.place_instructions = [1]
@@ -841,13 +852,15 @@ class SimulatedExecutionTest(unittest.TestCase):
         mock_order.executable.assert_called_with()
         mock_order.trade.__enter__.assert_called_with()
         mock_order.trade.__exit__.assert_called_with(None, None, None)
+        mock_order_package.client.add_transaction.assert_called_with(1)
 
     @mock.patch("flumine.execution.simulatedexecution.SimulatedExecution._order_logger")
     def test_execute_place_failure(self, mock__order_logger):
         mock_order = mock.Mock()
         mock_order.trade.__enter__ = mock.Mock()
         mock_order.trade.__exit__ = mock.Mock()
-        mock_order_package = mock.Mock(market_id="1.23")
+        mock_order_package = mock.MagicMock(market_id="1.23")
+        mock_order_package.__len__.return_value = 1
         mock_order_package.client.paper_trade = False
         mock_order_package.__iter__ = mock.Mock(return_value=iter([mock_order]))
         mock_order_package.place_instructions = [1]
@@ -868,23 +881,27 @@ class SimulatedExecutionTest(unittest.TestCase):
         mock_order.lapsed.assert_called_with()
         mock_order.trade.__enter__.assert_called_with()
         mock_order.trade.__exit__.assert_called_with(None, None, None)
+        mock_order_package.client.add_transaction.assert_called_with(1)
 
     @mock.patch("flumine.execution.simulatedexecution.time")
     def test_execute_place_paper_trade(self, mock_time):
-        mock_order_package = mock.Mock(
+        mock_order_package = mock.MagicMock(
             market_id="1.23", place_instructions=[], bet_delay=1
         )
+        mock_order_package.__len__.return_value = 1
         mock_order_package.__iter__ = mock.Mock(return_value=iter([]))
         mock_order_package.client.paper_trade = True
         self.execution.execute_place(mock_order_package, None)
         mock_time.sleep.assert_called_with(self.execution.PLACE_LATENCY + 1)
+        mock_order_package.client.add_transaction.assert_called_with(1)
 
     @mock.patch("flumine.execution.simulatedexecution.SimulatedExecution._order_logger")
     def test_execute_cancel(self, mock__order_logger):
         mock_order = mock.Mock(size_cancelled=2, size_remaining=0)
         mock_order.trade.__enter__ = mock.Mock()
         mock_order.trade.__exit__ = mock.Mock()
-        mock_order_package = mock.Mock()
+        mock_order_package = mock.MagicMock()
+        mock_order_package.__len__.return_value = 1
         mock_order_package.client.paper_trade = False
         mock_order_package.__iter__ = mock.Mock(return_value=iter([mock_order]))
         mock_sim_resp = mock.Mock()
@@ -904,7 +921,8 @@ class SimulatedExecutionTest(unittest.TestCase):
         mock_order = mock.Mock(size_cancelled=2, size_remaining=2)
         mock_order.trade.__enter__ = mock.Mock()
         mock_order.trade.__exit__ = mock.Mock()
-        mock_order_package = mock.Mock()
+        mock_order_package = mock.MagicMock(market_id="1.23")
+        mock_order_package.__len__.return_value = 1
         mock_order_package.client.paper_trade = False
         mock_order_package.__iter__ = mock.Mock(return_value=iter([mock_order]))
         mock_sim_resp = mock.Mock()
@@ -924,7 +942,8 @@ class SimulatedExecutionTest(unittest.TestCase):
         mock_order = mock.Mock()
         mock_order.trade.__enter__ = mock.Mock()
         mock_order.trade.__exit__ = mock.Mock()
-        mock_order_package = mock.Mock()
+        mock_order_package = mock.MagicMock()
+        mock_order_package.__len__.return_value = 1
         mock_order_package.client.paper_trade = False
         mock_order_package.__iter__ = mock.Mock(return_value=iter([mock_order]))
         mock_sim_resp = mock.Mock()
@@ -938,6 +957,7 @@ class SimulatedExecutionTest(unittest.TestCase):
         mock_order.executable.assert_called_with()
         mock_order.trade.__enter__.assert_called_with()
         mock_order.trade.__exit__.assert_called_with(None, None, None)
+        mock_order_package.client.add_transaction.assert_called_with(1, failed=True)
 
     @mock.patch("flumine.execution.simulatedexecution.time")
     def test_execute_cancel_paper_trade(self, mock_time):
@@ -990,6 +1010,7 @@ class SimulatedExecutionTest(unittest.TestCase):
         mock_order.executable.assert_called_with()
         mock_order.trade.__enter__.assert_called_with()
         mock_order.trade.__exit__.assert_called_with(None, None, None)
+        mock_order_package.client.add_transaction.assert_called_with(1, failed=True)
 
     @mock.patch("flumine.execution.simulatedexecution.time")
     def test_execute_update_paper_trade(self, mock_time):
@@ -1005,7 +1026,8 @@ class SimulatedExecutionTest(unittest.TestCase):
         mock_order.trade.__enter__ = mock.Mock()
         mock_order.trade.__exit__ = mock.Mock()
         mock_order.market_id = "1.234"
-        mock_order_package = mock.Mock(market_id="1.23")
+        mock_order_package = mock.MagicMock(market_id="1.23")
+        mock_order_package.__len__.return_value = 1
         mock_order_package.client.paper_trade = False
         mock_order_package.__iter__ = mock.Mock(return_value=iter([mock_order]))
         mock_order_package.replace_instructions = [{"newPrice": 2.03}]
@@ -1041,13 +1063,15 @@ class SimulatedExecutionTest(unittest.TestCase):
         mock_replacement_order.executable.assert_called_with()
         mock_order.trade.__enter__.assert_called_with()
         mock_order.trade.__exit__.assert_called_with(None, None, None)
+        mock_order_package.client.add_transaction.assert_called_with(1)
 
     @mock.patch("flumine.execution.simulatedexecution.SimulatedExecution._order_logger")
     def test_execute_replace_failure(self, mock__order_logger):
         mock_order = mock.Mock()
         mock_order.trade.__enter__ = mock.Mock()
         mock_order.trade.__exit__ = mock.Mock()
-        mock_order_package = mock.Mock(market_id="1.23")
+        mock_order_package = mock.MagicMock(market_id="1.23")
+        mock_order_package.__len__.return_value = 1
         mock_order_package.client.paper_trade = False
         mock_order_package.__iter__ = mock.Mock(return_value=iter([mock_order]))
         mock_order_package.replace_instructions = [{"newPrice": 2.54}]
@@ -1079,9 +1103,10 @@ class SimulatedExecutionTest(unittest.TestCase):
 
     @mock.patch("flumine.execution.simulatedexecution.time")
     def test_execute_replace_paper_trade(self, mock_time):
-        mock_order_package = mock.Mock(
+        mock_order_package = mock.MagicMock(
             market_id="1.23", replace_instructions=[], bet_delay=1
         )
+        mock_order_package.__len__.return_value = 1
         mock_order_package.__iter__ = mock.Mock(return_value=iter([]))
         mock_order_package.client.paper_trade = True
         self.execution.execute_replace(mock_order_package, None)