Repository: liampauling/flumine
Pull request #399 (2021-03-22 21:00:58)

Refactoring out the get_worst_possible_profit method
I'd find the separate win/lose exposures useful when controlling risk in my strategy code.


Patch:
diff --git a/HISTORY.rst b/HISTORY.rst
index f598e89..0bfa00f 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -3,14 +3,6 @@
 Release History
 ---------------
 
-1.17.15 (2021-03-25)
-+++++++++++++++++++
-
-**Improvements**
-
-- Blotter exposure refactoring out the get_worst_possible_profit method (@jsphon)
-- Market recorder example updated to use gzip compression
-
 1.17.14 (2021-03-24)
 +++++++++++++++++++
 
diff --git a/examples/strategies/marketrecorder.py b/examples/strategies/marketrecorder.py
index 84f3d23..dbd5b1b 100644
--- a/examples/strategies/marketrecorder.py
+++ b/examples/strategies/marketrecorder.py
@@ -2,7 +2,7 @@ import os
 import json
 import time
 import logging
-import gzip
+import zipfile
 import boto3
 from boto3.s3.transfer import S3Transfer, TransferConfig
 from botocore.exceptions import BotoCoreError
@@ -22,10 +22,7 @@ class MarketRecorder(BaseStrategy):
         self._market_expiration = self.context.get("market_expiration", 3600)  # seconds
         self._remove_file = self.context.get(
             "remove_file", False
-        )  # remove txt file during cleanup
-        self._remove_gz_file = self.context.get(
-            "_remove_gz_file", False
-        )  # remove compressed file during cleanup
+        )  # remove txt/zip file during cleanup
         self._force_update = self.context.get(
             "force_update", True
         )  # update after initial closure
@@ -85,52 +82,50 @@ class MarketRecorder(BaseStrategy):
             )
             return
 
-        # compress file
-        compress_file_dir = self._compress_file(file_dir)
+        # zip file
+        zip_file_dir = self._zip_file(file_dir, market_id)
 
         # core load code
-        self._load(market, compress_file_dir, market_definition)
+        self._load(market, zip_file_dir, market_definition)
 
         # clean up
         self._clean_up()
 
         self._loaded_markets.append(market_id)
 
-    def _compress_file(self, file_dir: str) -> str:
-        """compresses txt file into filename.gz"""
-        compressed_file_dir = "{0}.gz".format(file_dir)
-        with open(file_dir, "rb") as f:
-            with gzip.open(compressed_file_dir, "wb") as compressed_file:
-                compressed_file.writelines(f)
-        return compressed_file_dir
+    def _zip_file(self, file_dir: str, market_id: str) -> str:
+        """zips txt file into filename.zip"""
+        zip_file_directory = os.path.join(
+            self.local_dir, self.recorder_id, "%s.zip" % market_id
+        )
+        with zipfile.ZipFile(zip_file_directory, mode="w") as zf:
+            zf.write(
+                file_dir, os.path.basename(file_dir), compress_type=zipfile.ZIP_DEFLATED
+            )
+        return zip_file_directory
 
-    def _load(self, market, compress_file_dir: str, market_definition: dict) -> None:
+    def _load(self, market, zip_file_dir: str, market_definition: dict) -> None:
         pass
 
     def _clean_up(self) -> None:
-        """If gz > market_expiration old remove
-        gz and txt file
+        """If zip > market_expiration old remove
+        zip and txt file
         """
+        if self._remove_file is False:
+            return  # keep files
         directory = os.path.join(self.local_dir, self.recorder_id)
         for file in os.listdir(directory):
-            if file.endswith(".gz"):
+            if file.endswith(".zip"):
                 file_stats = os.stat(os.path.join(directory, file))
                 seconds_since = time.time() - file_stats.st_mtime
                 if seconds_since > self._market_expiration:
-                    txt_path = os.path.join(directory, file.split(".gz")[0])
-                    gz_path = os.path.join(directory, file)
-                    if self._remove_file:
-                        logger.info(
-                            "Removing: %s, age: %ss"
-                            % (txt_path, round(seconds_since, 2))
-                        )
-                        os.remove(txt_path)
-                    if self._remove_gz_file:
-                        logger.info(
-                            "Removing: %s, age: %ss"
-                            % (gz_path, round(seconds_since, 2))
-                        )
-                        os.remove(gz_path)
+                    logger.info(
+                        "Removing: %s, age: %ss" % (file, round(seconds_since, 2))
+                    )
+                    txt_path = os.path.join(directory, file.split(".zip")[0])
+                    zip_path = os.path.join(directory, file)
+                    os.remove(zip_path)
+                    os.remove(txt_path)
 
     @staticmethod
     def _create_metadata(market_definition: dict) -> dict:
@@ -154,7 +149,7 @@ class S3MarketRecorder(MarketRecorder):
         super().add()
         self.s3.head_bucket(Bucket=self._bucket)  # validate bucket/access
 
-    def _load(self, market, compress_file_dir: str, market_definition: dict) -> None:
+    def _load(self, market, zip_file_dir: str, market_definition: dict) -> None:
         # note this will block the main handler queue during upload
         # todo create background worker instead?
         event_type_id = (
@@ -162,13 +157,13 @@ class S3MarketRecorder(MarketRecorder):
         )
         try:
             self.transfer.upload_file(
-                filename=compress_file_dir,
+                filename=zip_file_dir,
                 bucket=self._bucket,
                 key=os.path.join(
                     self._data_type,
                     "streaming",
                     event_type_id,
-                    os.path.basename(compress_file_dir),
+                    os.path.basename(zip_file_dir),
                 ),
                 extra_args={
                     "Metadata": self._create_metadata(market_definition)
@@ -176,7 +171,7 @@ class S3MarketRecorder(MarketRecorder):
                     else {}
                 },
             )
-            logger.info("%s successfully loaded to s3" % compress_file_dir)
+            logger.info("%s successfully loaded to s3" % zip_file_dir)
         except (BotoCoreError, Exception) as e:
             logger.error("Error loading to s3: %s" % e)
 
@@ -189,7 +184,7 @@ class S3MarketRecorder(MarketRecorder):
                 return
             try:
                 self.s3.put_object(
-                    Body=market.market_catalogue.json(),  # todo compress?
+                    Body=market.market_catalogue.json(),
                     Bucket=self._bucket,
                     Key=os.path.join(
                         "marketdata",
diff --git a/flumine/__version__.py b/flumine/__version__.py
index 883c864..5c00d30 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.15"
+__version__ = "1.17.14"
 __author__ = "Liam Pauling"
 __license__ = "MIT"
diff --git a/flumine/controls/tradingcontrols.py b/flumine/controls/tradingcontrols.py
index b32ebd3..4de13de 100644
--- a/flumine/controls/tradingcontrols.py
+++ b/flumine/controls/tradingcontrols.py
@@ -124,31 +124,46 @@ class StrategyExposure(BaseControl):
             strategy = order.trade.strategy
             if order.order_type.ORDER_TYPE == OrderTypes.LIMIT:
                 if order.side == "BACK":
-                    exposure = order.order_type.size
+                    order_exposure = order.order_type.size
                 else:
-                    exposure = (order.order_type.price - 1) * order.order_type.size
+                    order_exposure = (
+                        order.order_type.price - 1
+                    ) * order.order_type.size
             elif order.order_type.ORDER_TYPE == OrderTypes.LIMIT_ON_CLOSE:
-                exposure = order.order_type.liability  # todo correct?
+                order_exposure = order.order_type.liability  # todo correct?
             elif order.order_type.ORDER_TYPE == OrderTypes.MARKET_ON_CLOSE:
-                exposure = order.order_type.liability
+                order_exposure = order.order_type.liability
             else:
                 return self._on_error(order, "Unknown order_type")
 
             # per order
-            if exposure > strategy.max_order_exposure:
+            if order_exposure > strategy.max_order_exposure:
                 return self._on_error(
                     order,
                     "Order exposure ({0}) is greater than strategy.max_order_exposure ({1})".format(
-                        exposure, strategy.max_order_exposure
+                        order_exposure, strategy.max_order_exposure
                     ),
                 )
 
             # per selection
             market = self.flumine.markets.markets[order.market_id]
-            selection_exposure = market.blotter.selection_exposure(
+            current_exposures = market.blotter.get_exposures(
                 strategy, lookup=order.lookup
             )
-            potential_exposure = selection_exposure + exposure
+            """
+            We use -min(...) in the below, as "worst_possible_profit_on_X" will be negative if the position is
+            at risk of loss, while exposure values are always atleast zero.
+            Exposure refers to the largest potential loss.
+            """
+            if order.side == "BACK":
+                current_selection_exposure = -current_exposures[
+                    "worst_possible_profit_on_lose"
+                ]
+            else:
+                current_selection_exposure = -current_exposures[
+                    "worst_possible_profit_on_win"
+                ]
+            potential_exposure = current_selection_exposure + order_exposure
             if potential_exposure > strategy.max_selection_exposure:
                 return self._on_error(
                     order,
diff --git a/flumine/markets/blotter.py b/flumine/markets/blotter.py
index 7a53873..5e2ea42 100644
--- a/flumine/markets/blotter.py
+++ b/flumine/markets/blotter.py
@@ -1,5 +1,5 @@
 import logging
-from typing import Iterable
+from typing import Iterable, Tuple
 from collections import defaultdict
 
 from ..order.ordertype import OrderTypes
@@ -77,6 +77,15 @@ class Blotter:
             positive = potential loss
             zero = no potential loss
         """
+        exposures = self.get_exposures(strategy=strategy, lookup=lookup)
+        exposure = -min(
+            exposures["worst_possible_profit_on_win"],
+            exposures["worst_possible_profit_on_lose"],
+        )
+        return max(exposure, 0.0)
+
+    def get_exposures(self, strategy, lookup: tuple) -> dict:
+        """Returns strategy/selection exposures as a dict."""
         mb, ml = [], []  # matched bets, (price, size)
         ub, ul = [], []  # unmatched bets, (price, size)
         moc_win_liability = 0.0
@@ -118,8 +127,15 @@ class Blotter:
         worst_possible_profit_on_lose = (
             matched_exposure[1] + unmatched_exposure[1] + moc_lose_liability
         )
-        exposure = -min(worst_possible_profit_on_win, worst_possible_profit_on_lose)
-        return max(exposure, 0.0)
+
+        return {
+            "matched_profit_if_win": matched_exposure[0],
+            "matched_profit_if_lose": matched_exposure[1],
+            "worst_potential_unmatched_profit_if_win": unmatched_exposure[0],
+            "worst_potential_unmatched_profit_if_lose": unmatched_exposure[1],
+            "worst_possible_profit_on_win": worst_possible_profit_on_win,
+            "worst_possible_profit_on_lose": worst_possible_profit_on_lose,
+        }
 
     """ getters / setters """
 


Test patch:
diff --git a/tests/test_blotter.py b/tests/test_blotter.py
index ab42f00..e9f3f26 100644
--- a/tests/test_blotter.py
+++ b/tests/test_blotter.py
@@ -56,6 +56,42 @@ class BlotterTest(unittest.TestCase):
         self.assertEqual(self.blotter.process_cleared_orders(mock_cleared_orders), [])
 
     def test_selection_exposure(self):
+        """
+        Check that selection_exposure returns the absolute worse loss
+        """
+
+        def get_exposures(strategy, lookup):
+            if strategy == "strategy" and lookup == (1, 2, 3):
+                return {
+                    "worst_possible_profit_on_win": -1.0,
+                    "worst_possible_profit_on_lose": -2.0,
+                }
+
+        self.blotter.get_exposures = mock.Mock(side_effect=get_exposures)
+
+        result = self.blotter.selection_exposure("strategy", (1, 2, 3))
+
+        self.assertEqual(2.0, result)
+
+    def test_selection_exposure2(self):
+        """
+        Check that selection_exposure returns zero if there is no risk of loss.
+        """
+
+        def get_exposures(strategy, lookup):
+            if strategy == "strategy" and lookup == (1, 2, 3):
+                return {
+                    "worst_possible_profit_on_win": 0.0,
+                    "worst_possible_profit_on_lose": 1.0,
+                }
+
+        self.blotter.get_exposures = mock.Mock(side_effect=get_exposures)
+
+        result = self.blotter.selection_exposure("strategy", (1, 2, 3))
+
+        self.assertEqual(0.0, result)
+
+    def test_get_exposures(self):
         mock_strategy = mock.Mock()
         mock_trade = mock.Mock(strategy=mock_strategy)
         mock_order = mock.Mock(
@@ -69,11 +105,18 @@ class BlotterTest(unittest.TestCase):
         )
         self.blotter["12345"] = mock_order
         self.assertEqual(
-            self.blotter.selection_exposure(mock_strategy, mock_order.lookup),
-            2.0,
+            self.blotter.get_exposures(mock_strategy, mock_order.lookup),
+            {
+                "matched_profit_if_lose": -2.0,
+                "matched_profit_if_win": 9.2,
+                "worst_possible_profit_on_lose": -2.0,
+                "worst_possible_profit_on_win": 9.2,
+                "worst_potential_unmatched_profit_if_lose": 0.0,
+                "worst_potential_unmatched_profit_if_win": 0.0,
+            },
         )
 
-    def test_selection_exposure_raises_value_error(self):
+    def test_get_exposures_value_error(self):
         mock_strategy = mock.Mock()
         mock_trade = mock.Mock(strategy=mock_strategy)
         mock_order = mock.Mock(
@@ -88,16 +131,15 @@ class BlotterTest(unittest.TestCase):
         self.blotter["12345"] = mock_order
 
         with self.assertRaises(ValueError) as e:
-            self.blotter.selection_exposure(mock_strategy, mock_order.lookup)
+            self.blotter.get_exposures(mock_strategy, mock_order.lookup)
 
         self.assertEqual("Unexpected order type: INVALID", e.exception.args[0])
 
-    def test_selection_exposure_with_price_none(self):
+    def test_get_exposures_with_price_none(self):
         """
-        Check that selection_exposure works if order.order_type.price is None.
+        Check that get_exposures works if order.order_type.price is None.
         If order.order_type.price is None, the controls will flag the order as a violation
         and it won't be set to the exchange, so there won't be any exposure and we can ignore it.
-        :return:
         """
         mock_strategy = mock.Mock()
         mock_trade = mock.Mock(strategy=mock_strategy)
@@ -123,11 +165,18 @@ class BlotterTest(unittest.TestCase):
         self.blotter["12345"] = mock_order1
         self.blotter["23456"] = mock_order2
         self.assertEqual(
-            self.blotter.selection_exposure(mock_strategy, lookup),
-            2.0,
+            self.blotter.get_exposures(mock_strategy, lookup),
+            {
+                "matched_profit_if_lose": -2.0,
+                "matched_profit_if_win": 9.2,
+                "worst_possible_profit_on_lose": -2.0,
+                "worst_possible_profit_on_win": 9.2,
+                "worst_potential_unmatched_profit_if_lose": 0.0,
+                "worst_potential_unmatched_profit_if_win": 0.0,
+            },
         )
 
-    def test_selection_exposure_no_match(self):
+    def test_get_exposures_no_match(self):
         mock_strategy = mock.Mock()
         mock_trade = mock.Mock(strategy=mock_strategy)
         mock_order = mock.Mock(
@@ -141,11 +190,18 @@ class BlotterTest(unittest.TestCase):
         )
         self.blotter["12345"] = mock_order
         self.assertEqual(
-            self.blotter.selection_exposure(mock_strategy, mock_order.lookup),
-            0.0,
+            self.blotter.get_exposures(mock_strategy, mock_order.lookup),
+            {
+                "matched_profit_if_lose": 0.0,
+                "matched_profit_if_win": 0.0,
+                "worst_possible_profit_on_lose": 0.0,
+                "worst_possible_profit_on_win": 0.0,
+                "worst_potential_unmatched_profit_if_lose": 0.0,
+                "worst_potential_unmatched_profit_if_win": 0.0,
+            },
         )
 
-    def test_selection_exposure_from_unmatched_back(self):
+    def test_get_exposures_from_unmatched_back(self):
         mock_strategy = mock.Mock()
         mock_trade = mock.Mock(strategy=mock_strategy)
         mock_order = mock.Mock(
@@ -161,11 +217,18 @@ class BlotterTest(unittest.TestCase):
         # 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(
-            self.blotter.selection_exposure(mock_strategy, mock_order.lookup),
-            4.0,
+            self.blotter.get_exposures(mock_strategy, mock_order.lookup),
+            {
+                "matched_profit_if_lose": -2.0,
+                "matched_profit_if_win": 9.2,
+                "worst_possible_profit_on_lose": -4.0,
+                "worst_possible_profit_on_win": 9.2,
+                "worst_potential_unmatched_profit_if_lose": -2.0,
+                "worst_potential_unmatched_profit_if_win": 0,
+            },
         )
 
-    def test_selection_exposure_from_unmatched_lay(self):
+    def test_get_exposures_from_unmatched_lay(self):
         mock_strategy = mock.Mock()
         mock_trade = mock.Mock(strategy=mock_strategy)
         mock_order = mock.Mock(
@@ -181,11 +244,18 @@ class BlotterTest(unittest.TestCase):
         # 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(
-            self.blotter.selection_exposure(mock_strategy, mock_order.lookup),
-            19.2,
+            self.blotter.get_exposures(mock_strategy, mock_order.lookup),
+            {
+                "matched_profit_if_lose": 2.0,
+                "matched_profit_if_win": -9.2,
+                "worst_possible_profit_on_lose": 2.0,
+                "worst_possible_profit_on_win": -19.2,
+                "worst_potential_unmatched_profit_if_lose": 0,
+                "worst_potential_unmatched_profit_if_win": -10.0,
+            },
         )
 
-    def test_selection_exposure_from_market_on_close_back(self):
+    def test_get_exposures_from_market_on_close_back(self):
         mock_strategy = mock.Mock()
         mock_trade = mock.Mock(strategy=mock_strategy)
         mock_order = mock.Mock(
@@ -196,11 +266,18 @@ class BlotterTest(unittest.TestCase):
         )
         self.blotter["12345"] = mock_order
         self.assertEqual(
-            self.blotter.selection_exposure(mock_strategy, mock_order.lookup),
-            10.0,
+            self.blotter.get_exposures(mock_strategy, mock_order.lookup),
+            {
+                "matched_profit_if_lose": 0.0,
+                "matched_profit_if_win": 0.0,
+                "worst_possible_profit_on_lose": -10.0,
+                "worst_possible_profit_on_win": 0.0,
+                "worst_potential_unmatched_profit_if_lose": 0.0,
+                "worst_potential_unmatched_profit_if_win": 0.0,
+            },
         )
 
-    def test_selection_exposure_from_market_on_close_lay(self):
+    def test_get_exposures_from_market_on_close_lay(self):
         mock_strategy = mock.Mock()
         mock_trade = mock.Mock(strategy=mock_strategy)
         mock_order = mock.Mock(
@@ -211,11 +288,18 @@ class BlotterTest(unittest.TestCase):
         )
         self.blotter["12345"] = mock_order
         self.assertEqual(
-            self.blotter.selection_exposure(mock_strategy, mock_order.lookup),
-            10.0,
+            self.blotter.get_exposures(mock_strategy, mock_order.lookup),
+            {
+                "matched_profit_if_lose": 0.0,
+                "matched_profit_if_win": 0.0,
+                "worst_possible_profit_on_lose": 0.0,
+                "worst_possible_profit_on_win": -10.0,
+                "worst_potential_unmatched_profit_if_lose": 0.0,
+                "worst_potential_unmatched_profit_if_win": 0.0,
+            },
         )
 
-    def test_selection_exposure_from_limit_on_close_lay(self):
+    def test_get_exposures_from_limit_on_close_lay(self):
         mock_strategy = mock.Mock()
         mock_trade = mock.Mock(strategy=mock_strategy)
         mock_order = mock.Mock(
@@ -226,11 +310,18 @@ class BlotterTest(unittest.TestCase):
         )
         self.blotter["12345"] = mock_order
         self.assertEqual(
-            self.blotter.selection_exposure(mock_strategy, mock_order.lookup),
-            10.0,
+            self.blotter.get_exposures(mock_strategy, mock_order.lookup),
+            {
+                "matched_profit_if_lose": 0.0,
+                "matched_profit_if_win": 0.0,
+                "worst_possible_profit_on_lose": 0.0,
+                "worst_possible_profit_on_win": -10.0,
+                "worst_potential_unmatched_profit_if_lose": 0.0,
+                "worst_potential_unmatched_profit_if_win": 0.0,
+            },
         )
 
-    def test_selection_exposure_voided(self):
+    def test_get_exposures_voided(self):
         mock_strategy = mock.Mock()
         mock_trade = mock.Mock(strategy=mock_strategy)
         mock_order = mock.Mock(
@@ -242,8 +333,15 @@ class BlotterTest(unittest.TestCase):
         )
         self.blotter["12345"] = mock_order
         self.assertEqual(
-            self.blotter.selection_exposure(mock_strategy, mock_order.lookup),
-            0,
+            self.blotter.get_exposures(mock_strategy, mock_order.lookup),
+            {
+                "matched_profit_if_lose": 0.0,
+                "matched_profit_if_win": 0.0,
+                "worst_possible_profit_on_lose": 0.0,
+                "worst_possible_profit_on_win": 0.0,
+                "worst_potential_unmatched_profit_if_lose": 0.0,
+                "worst_potential_unmatched_profit_if_win": 0.0,
+            },
         )
 
     def test_complete_order(self):
diff --git a/tests/test_tradingcontrols.py b/tests/test_tradingcontrols.py
index 0690e28..b0157a1 100644
--- a/tests/test_tradingcontrols.py
+++ b/tests/test_tradingcontrols.py
@@ -425,7 +425,9 @@ class TestStrategyExposure(unittest.TestCase):
     @mock.patch("flumine.controls.tradingcontrols.StrategyExposure._on_error")
     def test_validate_selection(self, mock_on_error):
         mock_market = mock.Mock()
-        mock_market.blotter.selection_exposure.return_value = 12.0
+        mock_market.blotter.get_exposures.return_value = {
+            "worst_possible_profit_on_win": -12.0
+        }
         self.mock_flumine.markets.markets = {"1.234": mock_market}
         mock_order = mock.Mock(market_id="1.234", lookup=(1, 2, 3), side="LAY")
         mock_order.trade.strategy.max_order_exposure = 10
@@ -439,3 +441,71 @@ class TestStrategyExposure(unittest.TestCase):
             mock_order,
             "Potential selection exposure (12.12) is greater than strategy.max_selection_exposure (10)",
         )
+
+    @mock.patch("flumine.controls.tradingcontrols.StrategyExposure._on_error")
+    def test_validate_selection2a(self, mock_on_error):
+        """
+        test_validate_selection2a expects an error, as it attempts to place a lay bet with a £36 potential
+        loss, which is more than the £10 max_selection_exposure
+        """
+        mock_market = mock.Mock()
+        mock_market.blotter = Blotter(market_id="1.234")
+        self.mock_flumine.markets.markets = {"1.234": mock_market}
+
+        mock_strategy = mock.Mock()
+        mock_strategy.max_order_exposure = 100
+        mock_strategy.max_selection_exposure = 10
+
+        mock_trade = mock.Mock()
+        mock_trade.strategy = mock_strategy
+
+        mock_order = mock.Mock(market_id="1.234", lookup=(1, 2, 3), side="LAY")
+        mock_order.trade = mock_trade
+        mock_order.order_type.ORDER_TYPE = OrderTypes.LIMIT
+        mock_order.order_type.size = 9.0
+        mock_order.order_type.price = 5.0
+
+        self.trading_control._validate(mock_order, OrderPackageType.PLACE)
+        mock_on_error.assert_called_with(
+            mock_order,
+            "Potential selection exposure (36.00) is greater than strategy.max_selection_exposure (10)",
+        )
+
+    @mock.patch("flumine.controls.tradingcontrols.StrategyExposure._on_error")
+    def test_validate_selection2b(self, mock_on_error):
+        """
+        test_validate_selection2b expects no error.
+        Unlike test_validate_selection2a, the blotter contains an existing order. The order that it attempts
+        to validate hedges the existing order, and reduces the total exposure.
+        """
+        mock_market = mock.Mock()
+        mock_market.blotter = Blotter(market_id="1.234")
+
+        self.mock_flumine.markets.markets = {"1.234": mock_market}
+
+        mock_strategy = mock.Mock()
+        mock_strategy.max_order_exposure = 100
+        mock_strategy.max_selection_exposure = 10
+
+        mock_trade = mock.Mock()
+        mock_trade.strategy = mock_strategy
+
+        existing_matched_order = mock.Mock(
+            market_id="1.234", lookup=(1, 2, 3), side="BACK"
+        )
+        existing_matched_order.trade = mock_trade
+        existing_matched_order.order_type.ORDER_TYPE = OrderTypes.LIMIT
+        existing_matched_order.size_matched = 9.0
+        existing_matched_order.average_price_matched = 6.0
+        existing_matched_order.size_remaining = 0.0
+
+        mock_order = mock.Mock(market_id="1.234", lookup=(1, 2, 3), side="LAY")
+        mock_order.trade = mock_trade
+        mock_order.order_type.ORDER_TYPE = OrderTypes.LIMIT
+        mock_order.order_type.size = 9.0
+        mock_order.order_type.price = 5.0
+
+        mock_market.blotter["existing_order"] = existing_matched_order
+
+        self.trading_control._validate(mock_order, OrderPackageType.PLACE)
+        mock_on_error.assert_not_called()