From b947b928e765345c19da994692f58107905a6d4e Mon Sep 17 00:00:00 2001 From: Jamie Cash Date: Tue, 1 Jun 2021 14:20:57 +0100 Subject: [PATCH] Added diverged symbols view --- mt5_correlation/correlation.py | 28 ++++ mt5_correlation/gui/mdi.py | 16 ++ .../gui/mdi_child_diverged_symbols.py | 138 ++++++++++++++++++ test/test_correlation.py | 36 ++++- 4 files changed, 216 insertions(+), 2 deletions(-) create mode 100644 mt5_correlation/gui/mdi_child_diverged_symbols.py diff --git a/mt5_correlation/correlation.py b/mt5_correlation/correlation.py index 23d9bef..49985d6 100644 --- a/mt5_correlation/correlation.py +++ b/mt5_correlation/correlation.py @@ -161,6 +161,34 @@ class Correlation: return filtered_data + @property + def diverged_symbols(self): + """ + :return: dataframe containing all diverged, diverging or converging symbols and count of number of + divergences for those symbols. + """ + filtered_data = None + + if self.coefficient_data is not None: + # Only rows where we have a divergence + filtered_data = self.coefficient_data \ + .loc[(self.coefficient_data['Status'] == STATUS_DIVERGED) | + (self.coefficient_data['Status'] == STATUS_DIVERGING) | + (self.coefficient_data['Status'] == STATUS_CONVERGING)] + + # We only need the symbols + all_symbols = pd.DataFrame(columns=['Symbol', 'Count'], + data={'Symbol': filtered_data['Symbol 1'].append(filtered_data['Symbol 2']), + 'Count': 1}) + + # Group and count. Reset index so that we have named SYMBOL column. + filtered_data = all_symbols.groupby(by='Symbol').count().reset_index() + + # Sort + filtered_data = filtered_data.sort_values('Count', ascending=False) + + return filtered_data + def load(self, filename): """ Loads calculated coefficients, price data used to calculate them and tick data used during monitoring. diff --git a/mt5_correlation/gui/mdi.py b/mt5_correlation/gui/mdi.py index 6c21809..443a38e 100644 --- a/mt5_correlation/gui/mdi.py +++ b/mt5_correlation/gui/mdi.py @@ -76,6 +76,8 @@ class CorrelationMDIFrame(wx.MDIParentFrame): menu_view = wx.Menu() self.Bind(wx.EVT_MENU, self.__on_view_status, menu_view.Append(wx.ID_ANY, "Status", "View status of correlations.")) + self.Bind(wx.EVT_MENU, self.__on_view_diverged, menu_view.Append(wx.ID_ANY, "Diverged Symbols", + "View diverged symbols.")) self.menubar.Append(menu_view, "&View") # Set menu bar @@ -260,6 +262,20 @@ class CorrelationMDIFrame(wx.MDIParentFrame): else: opened_instance.Raise() + def __on_view_diverged(self, evt): + from mt5_correlation.gui.mdi_child_diverged_symbols import MDIChildDivergedSymbols + + # Only open if not already open. If already open then raise to top. + opened_instance = None + for child in self.GetChildren(): + if isinstance(child, MDIChildDivergedSymbols): + opened_instance = child + + if opened_instance is None: + MDIChildDivergedSymbols(parent=self).Show(True) + else: + opened_instance.Raise() + def __refresh(self): """ Refresh all open child frames diff --git a/mt5_correlation/gui/mdi_child_diverged_symbols.py b/mt5_correlation/gui/mdi_child_diverged_symbols.py new file mode 100644 index 0000000..a4572ce --- /dev/null +++ b/mt5_correlation/gui/mdi_child_diverged_symbols.py @@ -0,0 +1,138 @@ +import logging +import pandas as pd +import wx +import wx.grid + +import mt5_correlation.gui.mdi as mdi + +# Columns for diverged symbols table +COLUMN_INDEX = 0 +COLUMN_SYMBOL = 1 +COLUMN_NUM_DIVERGENCES = 2 + + +class MDIChildDivergedSymbols(mdi.CorrelationMDIChild): + """ + Shows the status of all correlations that are within the monitoring threshold + """ + + # The table and grid containing the symbols that form part of the diverged correlations. Defined at instance level + # to enable refresh. + __table = None + __grid = None + + # Number of rows. Required for and updated by refresh method + __rows = 0 + + __log = None # The logger + + def __init__(self, parent): + # Super + wx.MDIChildFrame.__init__(self, parent=parent, id=wx.ID_ANY, title="Diverged Symbols", + size=wx.Size(width=240, height=-1), style=wx.DEFAULT_FRAME_STYLE) + + # Create logger + self.__log = logging.getLogger(__name__) + + # Panel and sizer for table + panel = wx.Panel(self, wx.ID_ANY) + sizer = wx.BoxSizer(wx.VERTICAL) + panel.SetSizer(sizer) + + # Create the grid. This is a data table using pandas dataframe for underlying data. Add the + # grid to the sizer. + self.__table = _DataTable(columns=self.GetMDIParent().cor.diverged_symbols.columns) + self.__grid = wx.grid.Grid(panel, wx.ID_ANY) + self.__grid.SetTable(self.__table, takeOwnership=True) + self.__grid.EnableEditing(False) + self.__grid.EnableDragRowSize(False) + self.__grid.EnableDragColSize(True) + self.__grid.EnableDragGridSize(True) + self.__grid.SetSelectionMode(wx.grid.Grid.SelectRows) + self.__grid.SetRowLabelSize(0) + self.__grid.SetColSize(COLUMN_INDEX, 0) # Index. Hide + self.__grid.SetColSize(COLUMN_SYMBOL, 100) # Symbol + self.__grid.SetColSize(COLUMN_NUM_DIVERGENCES, 100) # Num divergences + self.__grid.SetMinSize((220, 100)) + sizer.Add(self.__grid, 1, wx.ALL | wx.EXPAND) + + # Refresh to populate + self.refresh() + + def refresh(self): + """ + Refreshes grid. Notifies if rows have been added or deleted. + :return: + """ + self.__log.debug(f"Refreshing grid.") + + # Update data + self.__table.data = self.GetMDIParent().cor.diverged_symbols.copy() + + # Start refresh + self.__grid.BeginBatch() + + # Check if num rows in dataframe has changed, and send appropriate APPEND or DELETE messages + cur_rows = len(self.GetMDIParent().cor.diverged_symbols.index) + if cur_rows < self.__rows: + # Data has been deleted. Send message + msg = wx.grid.GridTableMessage(self.__table, wx.grid.GRIDTABLE_NOTIFY_ROWS_DELETED, + self.__rows - cur_rows, self.__rows - cur_rows) + self.__grid.ProcessTableMessage(msg) + elif cur_rows > self.__rows: + # Data has been added. Send message + msg = wx.grid.GridTableMessage(self.__table, wx.grid.GRIDTABLE_NOTIFY_ROWS_APPENDED, + cur_rows - self.__rows) # how many + self.__grid.ProcessTableMessage(msg) + + self.__grid.EndBatch() + + # Send updated message + msg = wx.grid.GridTableMessage(self.__table, wx.grid.GRIDTABLE_REQUEST_VIEW_GET_VALUES) + self.__grid.ProcessTableMessage(msg) + + # Update row count + self.__rows = cur_rows + + +class _DataTable(wx.grid.GridTableBase): + """ + A data table that holds data in a pandas dataframe. + """ + data = None # The data for this table. A Pandas DataFrame + + def __init__(self, columns): + wx.grid.GridTableBase.__init__(self) + self.headerRows = 1 + self.data = pd.DataFrame(columns=columns) + + def GetNumberRows(self): + return len(self.data) + + def GetNumberCols(self): + return len(self.data.columns) + 1 + + def GetValue(self, row, col): + if row < self.RowsCount and col < self.ColsCount: + return self.data.index[row] if col == 0 else self.data.iloc[row, col - 1] + else: + raise Exception(f"Trying to access row {row} and col {col} which does not exist.") + + def SetValue(self, row, col, value): + self.data.iloc[row, col - 1] = value + + def GetColLabelValue(self, col): + if col == 0: + if self.data.index.name is None: + return 'Index' + else: + return self.data.index.name + return str(self.data.columns[col - 1]) + + def GetTypeName(self, row, col): + return wx.grid.GRID_VALUE_STRING + + def GetAttr(self, row, col, prop): + attr = wx.grid.GridCellAttr() + + return attr diff --git a/test/test_correlation.py b/test/test_correlation.py index a384c33..69bbe93 100644 --- a/test/test_correlation.py +++ b/test/test_correlation.py @@ -1,5 +1,5 @@ import unittest -from unittest.mock import patch +from unittest.mock import patch, PropertyMock import time import mt5_correlation.correlation as correlation import pandas as pd @@ -121,7 +121,7 @@ class TestCorrelation(unittest.TestCase): self.assertEqual(cor.coefficient_data.iloc[1, 2], 1, "The correlation for SYMBOL1:SYMBOL5 should be 1.") self.assertEqual(cor.coefficient_data.iloc[2, 2], 1, "The correlation for SYMBOL4:SYMBOL5 should be 1.") - # Get the price data used to calculate the coefficients fro symbol 1. It should match mock_base_prices. + # Get the price data used to calculate the coefficients for symbol 1. It should match mock_base_prices. price_data = cor.get_price_data('SYMBOL1') self.assertTrue(price_data.equals(self.mock_base_prices), "Price data returned post calculation should match " "mock price data.") @@ -325,6 +325,38 @@ class TestCorrelation(unittest.TestCase): # Cleanup. delete the file os.remove("unittest.cpd") + @patch('mt5_correlation.correlation.Correlation.coefficient_data', new_callable=PropertyMock) + def test_diverged_symbols(self, mock): + """ + Test that diverged_symbols property correctly groups symbols and counts. + :param mock: + :return: + """ + # Correlation class + cor = correlation.Correlation() + + # Mock the correlation data. Symbol 1 has diverged 3 times; symbols 2 has diverged twice; symbol 3 has + # diverged once; symbols 4 has diverged twice and symbol 5 has not diverged at all. Use all diverged status' + # (diverged, diverging & converging). Also add a row for a non diverged pair. + mock.return_value = pd.DataFrame(columns=['Symbol 1', 'Symbol 2', 'Status'], data=[ + ['SYMBOL1', 'SYMBOL2', correlation.STATUS_DIVERGED], + ['SYMBOL1', 'SYMBOL3', correlation.STATUS_DIVERGING], + ['SYMBOL1', 'SYMBOL4', correlation.STATUS_CONVERGING], + ['SYMBOL2', 'SYMBOL4', correlation.STATUS_DIVERGED], + ['SYMBOL2', 'SYMBOL3', correlation.STATUS_CORRELATED]]) + + # Get the diverged_symbols data and check the counts + diverged_symbols = cor.diverged_symbols + self.assertEqual(diverged_symbols.loc[(diverged_symbols['Symbol'] == 'SYMBOL1')]['Count'].iloc[0], 3, + "Symbol 1 has diverged three times.") + self.assertEqual(diverged_symbols.loc[(diverged_symbols['Symbol'] == 'SYMBOL2')]['Count'].iloc[0], 2, + "Symbol 2 has diverged twice.") + self.assertEqual(diverged_symbols.loc[(diverged_symbols['Symbol'] == 'SYMBOL3')]['Count'].iloc[0], 1, + "Symbol 3 has diverged once.") + self.assertEqual(diverged_symbols.loc[(diverged_symbols['Symbol'] == 'SYMBOL4')]['Count'].iloc[0], 2, + "Symbol 4 has diverged twice.") + self.assertFalse('SYMBOL5' in diverged_symbols['Symbol']) + if __name__ == '__main__': unittest.main()