Repository: liampauling/flumine
Pull request #427 (2021-05-12 17:32:55)

Initial work on dead head profit
Todo
- [x] calculations for four dead heated runners
- [x] refactor profit logic to be more readable


Patch:
diff --git a/HISTORY.rst b/HISTORY.rst
index 6ffa5f6..3d9b43d 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -3,22 +3,6 @@
 Release History
 ---------------
 
-1.18.8 (2021-05-14)
-+++++++++++++++++++
-
-**Improvements**
-
-- Simulation optimisations
-
-**Bug Fixes**
-
-- #173 dead heat profit calculation (@lunswor)
-- listenerKwargs inplay / MoC / SP orders fix (@jsphon)
-
-**Libraries**
-
-- black upgraded to 21.5b1
-
 1.18.7 (2021-05-10)
 +++++++++++++++++++
 
diff --git a/flumine/__version__.py b/flumine/__version__.py
index 2fa773b..21cea79 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.18.8"
+__version__ = "1.18.7"
 __author__ = "Liam Pauling"
 __license__ = "MIT"
diff --git a/flumine/events/events.py b/flumine/events/events.py
index 69a37c6..d5ad22a 100644
--- a/flumine/events/events.py
+++ b/flumine/events/events.py
@@ -29,12 +29,10 @@ class QueueType(Enum):
     LOGGING = "Logging queue"
 
 
-class BaseEvent:
+class BaseEvent:  # todo __slots__?
     EVENT_TYPE = None
     QUEUE_TYPE = None
 
-    __slots__ = ["_time_created", "event", "callback"]
-
     def __init__(self, event):
         self._time_created = datetime.datetime.utcnow()
         self.event = event
diff --git a/flumine/markets/blotter.py b/flumine/markets/blotter.py
index 86647df..12406a1 100644
--- a/flumine/markets/blotter.py
+++ b/flumine/markets/blotter.py
@@ -59,6 +59,9 @@ class Blotter:
         return bool(self._live_orders)
 
     def process_closed_market(self, market_book) -> None:
+        number_of_winners = len(
+            [runner for runner in market_book.runners if runner.status == "WINNER"]
+        )
         for order in self:
             for runner in market_book.runners:
                 if (order.selection_id, order.handicap) == (
@@ -66,6 +69,8 @@ class Blotter:
                     runner.handicap,
                 ):
                     order.runner_status = runner.status
+                    if number_of_winners > market_book.number_of_winners:
+                        order.number_of_dead_heat_winners = number_of_winners
 
     def process_cleared_orders(self, cleared_orders) -> list:
         for cleared_order in cleared_orders.orders:
diff --git a/flumine/markets/middleware.py b/flumine/markets/middleware.py
index 4a14ad1..c8eea79 100644
--- a/flumine/markets/middleware.py
+++ b/flumine/markets/middleware.py
@@ -3,7 +3,7 @@ from collections import defaultdict
 from betfairlightweight.resources.bettingresources import RunnerBook
 
 from ..order.order import OrderStatus, OrderTypes
-from ..utils import wap
+from ..utils import get_price, wap
 
 logger = logging.getLogger(__name__)
 
@@ -134,11 +134,11 @@ class SimulatedMiddleware(Middleware):
 
     @staticmethod
     def _process_simulated_orders(market, market_analytics: dict) -> None:
-        for order in market.blotter._live_orders:
+        for order in market.blotter.live_orders:
             if order.simulated and order.status == OrderStatus.EXECUTABLE:
-                runner_analytics = market_analytics[
+                runner_analytics = market_analytics.get(
                     (order.selection_id, order.handicap)
-                ]
+                )
                 order.simulated(market.market_book, runner_analytics)
 
     @staticmethod
@@ -169,45 +169,36 @@ class RunnerAnalytics:
         if update:
             self.middle = self._calculate_middle(self._runner)  # use last update
             self.matched = self._calculate_matched(runner)
-            _tv = runner.ex.traded_volume
-            if self._traded_volume == _tv:
-                self.traded = {}
-            else:
-                self.traded = self._calculate_traded(_tv)
-            self._traded_volume = _tv
+            self.traded = self._calculate_traded(runner.ex.traded_volume)
+            self._traded_volume = runner.ex.traded_volume
             self._runner = runner
         else:
             self.matched = 0
             self.traded = {}
 
     def _calculate_traded(self, traded_volume: list) -> dict:
-        p_v, traded = self._p_v, {}
-        # create dictionary
-        c_v = {i["price"]: i["size"] for i in traded_volume}
-        # calculate difference
-        for key, value in c_v.items():
-            if key in p_v:
-                new_value = float(value) - float(p_v[key])
-                if new_value > 0:
-                    traded[key] = round(new_value, 2)
-            else:
-                traded[key] = value
-        # cache for next update
-        self._p_v = c_v
-        return traded
+        if self._traded_volume == traded_volume:
+            return {}
+        else:
+            p_v, traded = self._p_v, {}
+            # create dictionary
+            c_v = {i["price"]: i["size"] for i in traded_volume}
+            # calculate difference
+            for key, value in c_v.items():
+                if key in p_v:
+                    new_value = float(value) - float(p_v[key])
+                    if new_value > 0:
+                        traded[key] = round(new_value, 2)
+                else:
+                    traded[key] = value
+            # cache for next update
+            self._p_v = c_v
+            return traded
 
     @staticmethod
     def _calculate_middle(runner: RunnerBook) -> float:
-        _back = runner.ex.available_to_back
-        if _back:
-            back = _back[0]["price"]
-        else:
-            back = 0
-        _lay = runner.ex.available_to_lay
-        if _lay:
-            lay = _lay[0]["price"]
-        else:
-            lay = 1001
+        back = get_price(runner.ex.available_to_back, 0) or 0
+        lay = get_price(runner.ex.available_to_lay, 0) or 1001
         return (float(back) + float(lay)) / 2
 
     def _calculate_matched(self, runner: RunnerBook) -> float:
@@ -215,7 +206,4 @@ class RunnerAnalytics:
         total_matched = (
             runner.total_matched or prev_total_matched
         )  # handles non-runner -> 0
-        if total_matched != prev_total_matched:
-            return round(total_matched - prev_total_matched, 2)
-        else:
-            return 0.0
+        return round(total_matched - prev_total_matched, 2)
diff --git a/flumine/order/order.py b/flumine/order/order.py
index 043a3ed..6725aa1 100644
--- a/flumine/order/order.py
+++ b/flumine/order/order.py
@@ -60,6 +60,7 @@ class BaseOrder:
         self.lookup = self.market_id, self.selection_id, self.handicap
 
         self.runner_status = None  # RunnerBook.status
+        self.number_of_dead_heat_winners = None
         self.status = None
         self.status_log = []
         self.violation_msg = None
diff --git a/flumine/streams/historicalstream.py b/flumine/streams/historicalstream.py
index 04fdaed..0cff5ba 100644
--- a/flumine/streams/historicalstream.py
+++ b/flumine/streams/historicalstream.py
@@ -56,8 +56,8 @@ class FlumineMarketStream(MarketStream):
         for cache in list(self._caches.values()):
             if market_ids and cache.market_id not in market_ids:
                 continue
-            # if market is not open (closed/suspended) send regardless
-            if cache._definition_status == "OPEN":
+            # if market has closed send regardless
+            if cache._definition_status != "CLOSED":
                 if self._listener.inplay:
                     if not cache._definition_in_play:
                         continue


Test patch:
diff --git a/flumine/backtest/simulated.py b/flumine/backtest/simulated.py
index b8a80bf..2caf4a7 100644
--- a/flumine/backtest/simulated.py
+++ b/flumine/backtest/simulated.py
@@ -383,10 +383,24 @@ class Simulated:
     @property
     def profit(self) -> float:
         if self.order.runner_status == "WINNER":
-            if self.side == "BACK":
-                return round((self.average_price_matched - 1) * self.size_matched, 2)
-            else:
-                return round((self.average_price_matched - 1) * -self.size_matched, 2)
+            number_of_dead_heat_winners = self.order.number_of_dead_heat_winners or 1
+            profit = (self.size_matched / number_of_dead_heat_winners) * (
+                self.average_price_matched - 1
+            )
+
+            if number_of_dead_heat_winners == 2:
+                profit = profit - (self.size_matched / number_of_dead_heat_winners)
+            elif number_of_dead_heat_winners > 2:
+                profit = profit - (
+                    self.size_matched
+                    * (number_of_dead_heat_winners - 1)
+                    / number_of_dead_heat_winners
+                )
+
+            if self.side == "LAY":
+                profit = -profit
+
+            return round(profit, 2)
         elif self.order.runner_status == "LOSER":
             if self.side == "BACK":
                 return -self.size_matched
diff --git a/requirements-test.txt b/requirements-test.txt
index ef0c0d2..7e1a600 100644
--- a/requirements-test.txt
+++ b/requirements-test.txt
@@ -1,5 +1,5 @@
 # Tests & Linting
-black==21.5b1
+black==21.5b0
 coverage
 
 # Documentation
diff --git a/tests/test_blotter.py b/tests/test_blotter.py
index 5f5b362..46426a2 100644
--- a/tests/test_blotter.py
+++ b/tests/test_blotter.py
@@ -44,7 +44,7 @@ class BlotterTest(unittest.TestCase):
         self.assertTrue(self.blotter.has_live_orders)
 
     def test_process_closed_market(self):
-        mock_market_book = mock.Mock()
+        mock_market_book = mock.Mock(number_of_winners=1)
         mock_runner = mock.Mock(selection_id=123, handicap=0.0)
         mock_market_book.runners = [mock_runner]
         mock_order = mock.Mock(selection_id=123, handicap=0.0)
diff --git a/tests/test_events.py b/tests/test_events.py
index 86efbd4..2e1cf39 100644
--- a/tests/test_events.py
+++ b/tests/test_events.py
@@ -21,5 +21,6 @@ class BaseEventTest(unittest.TestCase):
         self.assertGreaterEqual(self.base_event.elapsed_seconds, 0)
 
     def test_str(self):
-        self.base_event = events.MarketBookEvent(None)
+        self.base_event.EVENT_TYPE = events.EventType.MARKET_BOOK
+        self.base_event.QUEUE_TYPE = events.QueueType.HANDLER
         self.assertEqual(str(self.base_event), "<MARKET_BOOK [HANDLER]>")
diff --git a/tests/test_middleware.py b/tests/test_middleware.py
index 73806e3..2ae65b2 100644
--- a/tests/test_middleware.py
+++ b/tests/test_middleware.py
@@ -170,17 +170,15 @@ class SimulatedMiddlewareTest(unittest.TestCase):
 
     def test__process_simulated_orders(self):
         mock_market_book = mock.Mock()
+        mock_order = mock.Mock()
+        mock_order.status = OrderStatus.EXECUTABLE
         mock_market = mock.Mock()
-        mock_order = mock.Mock(
-            selection_id=123, handicap=1, status=OrderStatus.EXECUTABLE
-        )
-        mock_order_two = mock.Mock(
-            selection_id=123, handicap=1, status=OrderStatus.PENDING
-        )
-        mock_order_three = mock.Mock(
-            selection_id=123, handicap=1, status=OrderStatus.EXECUTABLE, simulated=False
-        )
-        mock_market.blotter._live_orders = [
+        mock_order_two = mock.Mock()
+        mock_order_two.status = OrderStatus.PENDING
+        mock_order_three = mock.Mock()
+        mock_order_three.status = OrderStatus.EXECUTABLE
+        mock_order_three._simulated = False
+        mock_market.blotter.live_orders = [
             mock_order,
             mock_order_two,
             mock_order_three,
diff --git a/tests/test_simulated.py b/tests/test_simulated.py
index 2ac9b5d..43c8554 100644
--- a/tests/test_simulated.py
+++ b/tests/test_simulated.py
@@ -18,6 +18,7 @@ class SimulatedTest(unittest.TestCase):
             side="BACK",
             order_type=self.mock_order_type,
             trade=mock_trade,
+            number_of_dead_heat_winners=None,
         )
         self.simulated = simulated.Simulated(self.mock_order)
 
@@ -678,6 +679,33 @@ class SimulatedTest(unittest.TestCase):
         self.simulated.order.runner_status = "LOSER"
         self.assertEqual(self.simulated.profit, 2.0)
 
+    def test_profit_dead_heat_back(self):
+        self.simulated.order.number_of_dead_heat_winners = 2
+
+        self.simulated.order.runner_status = "WINNER"
+        self.simulated._update_matched([1234, 4.0, 10.0])
+        self.assertEqual(self.simulated.profit, 10)
+
+        self.simulated.order.runner_status = "LOSER"
+        self.assertEqual(self.simulated.profit, -10)
+
+    def test_profit_dead_heat_lay(self):
+        self.simulated.order.side = "LAY"
+        self.simulated.order.number_of_dead_heat_winners = 2
+
+        self.simulated.order.runner_status = "WINNER"
+        self.simulated._update_matched([1234, 5.0, 20.0])
+        self.assertEqual(self.simulated.profit, -30)
+
+        self.simulated.order.runner_status = "LOSER"
+        self.assertEqual(self.simulated.profit, 20)
+
+    def test_profit_dead_heat_four(self):
+        self.simulated.order.number_of_dead_heat_winners = 4
+        self.simulated.order.runner_status = "WINNER"
+        self.simulated._update_matched([1234, 11.0, 50.0])
+        self.assertEqual(self.simulated.profit, 87.50)
+
     def test_status(self):
         self.mock_order.status.value = "EXECUTION_COMPLETE"
         self.assertEqual(self.simulated.status, "EXECUTION_COMPLETE")
diff --git a/tests/test_streams.py b/tests/test_streams.py
index b84e3af..aff68d8 100644
--- a/tests/test_streams.py
+++ b/tests/test_streams.py
@@ -609,10 +609,10 @@ class TestFlumineMarketStream(unittest.TestCase):
             "1.123": mock.Mock(
                 publish_time=123,
                 market_definition={
+                    "status": "OPEN",
+                    "inPlay": False,
                     "marketTime": 456,
                 },
-                _definition_status="OPEN",
-                _definition_in_play=False,
             )
         }
         self.assertEqual(len(self.stream.snap()), 1)
@@ -620,10 +620,10 @@ class TestFlumineMarketStream(unittest.TestCase):
             "1.123": mock.Mock(
                 publish_time=123,
                 market_definition={
+                    "status": "OPEN",
+                    "inPlay": False,
                     "marketTime": 1234567,
                 },
-                _definition_status="OPEN",
-                _definition_in_play=False,
             )
         }
         self.assertEqual(len(self.stream.snap()), 0)