From 1361ee4487883b9c54b3b9cafe6a5f25b9a7397b Mon Sep 17 00:00:00 2001 From: Norbert <37236152+KatunaNorbert@users.noreply.github.com> Date: Mon, 22 Jul 2024 15:17:15 +0300 Subject: [PATCH] Fix #1357: Predictoor dashboard v02 (#1432) * Fix #1376: Change callbacks to not store payout data (#1378) * Fix #1375: Add Buttons and Sorting (#1379) * Fix #1373: Separate some concerns in pdr-dashboard. (#1390) * Fix #1374: Display metrics over multiple feeds and predictoors (#1377) * Fix #1400: Fix selections in feed and predictoor list (#1401) * Fix #1385: Add switch to display selected predictoors feeds only (#1395) * Fix #1402: Allow address config for predictoor dashboard. (#1403) * Fix #1404: Allow date period selection (#1405) * Fix #1384: Improve predictoors table by adding accuracy, stake and profit (#1398) * Fix #1409: Add predictoor and feed startup data instead of loading on a dash component (#1411) * Fix #1412: Update dashboard readme with new instructions (#1414) * Fix #1413: Update and split dash_duo tests (#1417) --- READMEs/predictoor-dashboard.md | 27 +- pdr_backend/cli/arg_feed.py | 3 + pdr_backend/cli/arg_feeds.py | 19 +- pdr_backend/cli/test/test_arg_feed.py | 2 +- pdr_backend/pdr_dashboard/assets/styles.css | 99 +++++ .../dash_components/app_constants.py | 9 + .../dash_components/callbacks.py | 352 ++++++++++++------ .../pdr_dashboard/dash_components/plots.py | 141 +++++-- .../pdr_dashboard/dash_components/util.py | 137 ++++++- .../dash_components/view_elements.py | 195 +++++++--- pdr_backend/pdr_dashboard/predictoor_dash.py | 39 +- pdr_backend/pdr_dashboard/test/conftest.py | 138 ++++++- pdr_backend/pdr_dashboard/test/resources.py | 32 +- .../pdr_dashboard/test/test_callbacks.py | 214 +++++++---- pdr_backend/pdr_dashboard/test/test_plots.py | 40 +- pdr_backend/pdr_dashboard/test/test_util.py | 122 +++++- pdr_backend/ppss/ppss.py | 2 + pdr_backend/ppss/predictoor_ss.py | 6 + ppss.yaml | 2 + 19 files changed, 1241 insertions(+), 338 deletions(-) create mode 100644 pdr_backend/pdr_dashboard/assets/styles.css create mode 100644 pdr_backend/pdr_dashboard/dash_components/app_constants.py diff --git a/READMEs/predictoor-dashboard.md b/READMEs/predictoor-dashboard.md index 9dd891454..c7c6c09e4 100644 --- a/READMEs/predictoor-dashboard.md +++ b/READMEs/predictoor-dashboard.md @@ -20,7 +20,13 @@ pdr lake etl update ./my_ppss.yaml sapphire-mainnet For more information on how the lake works and how to configure it, refer to [`this readme`](./lake-and-etl.md). -2. **Run the dash app from command line** +2. **Configure ppss(Optional)** + +This step is optional but very useful and **highly recommended if you are running a Predictoor bot**. + +By configuring the **ppss -> predictoor_ss -> my_addresses** list and providing one or multiple predictoor addresses, you can easily track those addresses. The app will automatically read the addresses and display all relevant data when it starts. Additionally, if you modify settings and select different Predictoors and feeds, you can easily reset the dashboard to your Predictoor settings. + +3. **Run the dash app from command line** After fetching the chain data locally into the lake, the next step is to read, process, and display the data by running the dashboard with the following command: @@ -28,27 +34,32 @@ After fetching the chain data locally into the lake, the next step is to read, p pdr dashboard ./my_ppss.yaml sapphire-mainnet ``` -This command will open a browser window where you can select predictors and feeds to visualize their performance. +This command will open a browser window where you can select predictoors and feeds to visualize their performance. ## **How to use** -By default, the plots will be empty because no predictors or feeds are selected. +By default, if ppss is not configured with predictoor addresses, the plots will be empty until at least one predictoor and feed are selected. Otherwise you will see stats on the provided predictoor addresses. -After **selecting one or more predictors and feeds combinations** where predictions has been submitted, and the payout method called, the plots will be populated with the relevant data. +After **selecting one or more predictors and feeds combinations** where predictions has been submitted, and the payout method called, the plots and metrics will be populated with the relevant data. -You can then observe how your predictor bots or others have been performing on different feeds and even compare them. +You can then observe how your predictoor bots or others have been performing on different feeds and even compare them. **IMPORTANT: Only predictions data where the payout method has been called are visible.** The displayed charts are: - 1. **Accuracy** - how predictoor accuracy has evolved durring the selected period of time. 2. **Profit** - what is the profit in OCEAN over the period. Here you can clearly see if you are profitable or not. 3. **Costs** - how much predictoor has staked for each epoch. + +To summarize the stats across multiple feeds and Predictoors, follow the **displayed metrics**. These include: **Average Accuracy, Total Profit, and Average Stake**. These metrics make it easy to track overall statistics for the selected Predictoors and users. + + +Furthermore, you can select different periods of time for the data calculation, so you can easily see stats on **last day, last week, or last month**. + ## **How to contribute** -We are constantly looking for ways to improve the Predictoor dashboard to help predictors become more profitable and are open to suggestions and ideas. +We are constantly looking for ways to improve the Predictoor dashboard to help predictoors become more profitable and are open to suggestions and ideas. -If you are a predictor and want to help us improve this tool, [join our Discord channel](https://discord.gg/Tvqd7Z648H) and drop a message, or open a GitHub issue. +If you are a predictoor and want to help us improve this tool, [join our Discord channel](https://discord.gg/Tvqd7Z648H) and drop a message, or open a GitHub issue. diff --git a/pdr_backend/cli/arg_feed.py b/pdr_backend/cli/arg_feed.py index 6717b6c6f..b8b539824 100644 --- a/pdr_backend/cli/arg_feed.py +++ b/pdr_backend/cli/arg_feed.py @@ -24,6 +24,7 @@ def __init__( signal: Union[ArgSignal, str, None] = None, pair: Union[ArgPair, str, None] = None, timeframe: Optional[Union[ArgTimeframe, str]] = None, + contract: Optional[str] = None, ): if signal is not None: self.signal = ArgSignal(signal) if isinstance(signal, str) else signal @@ -44,6 +45,8 @@ def __init__( else: self.timeframe = timeframe + self.contract = contract + def __str__(self): feed_str = f"{self.exchange} {self.pair}" diff --git a/pdr_backend/cli/arg_feeds.py b/pdr_backend/cli/arg_feeds.py index e5abeaf1e..4264de904 100644 --- a/pdr_backend/cli/arg_feeds.py +++ b/pdr_backend/cli/arg_feeds.py @@ -2,7 +2,7 @@ # Copyright 2024 Ocean Protocol Foundation # SPDX-License-Identifier: Apache-2.0 # -from typing import List, Set, Union +from typing import Dict, List, Optional, Set, Union from enforce_typing import enforce_types @@ -77,3 +77,20 @@ def __str__(self) -> str: @enforce_types def to_strs(self) -> List[str]: return _pack_feeds_str(self[:]) + + @staticmethod + def from_table_data(table_data: Optional[List[Dict]]) -> "ArgFeeds": + if not table_data: + return ArgFeeds([]) + + return ArgFeeds( + [ + ArgFeed( + pair=row["pair"], + contract=row["contract"], + exchange=row["source"], + timeframe=row["timeframe"], + ) + for row in table_data + ] + ) diff --git a/pdr_backend/cli/test/test_arg_feed.py b/pdr_backend/cli/test/test_arg_feed.py index 8736999b0..2e8a551eb 100644 --- a/pdr_backend/cli/test/test_arg_feed.py +++ b/pdr_backend/cli/test/test_arg_feed.py @@ -35,7 +35,7 @@ def test_ArgFeed_main_constructor(): # not ok - Type Error tups = [ (), - ("binance", "open", "BTC/USDT", "", ""), + ("binance", "open", "BTC/USDT", "", "", ""), ] for feed_tup in tups: with pytest.raises(TypeError): diff --git a/pdr_backend/pdr_dashboard/assets/styles.css b/pdr_backend/pdr_dashboard/assets/styles.css new file mode 100644 index 000000000..53eadbf10 --- /dev/null +++ b/pdr_backend/pdr_dashboard/assets/styles.css @@ -0,0 +1,99 @@ +html, body, #root { + margin: 0; + height: 100vh; +} + +body{ + padding: 10px; +} + +.main-container { + height: calc( 100vh - 105px ); + width: 100%; + display: flex; + justify-content: space-between; +} + +#page_title { + width: 100%; + text-align: center; + padding-top: 10px; + padding-bottom: 20px; +} + +#error_message { + display: flex; + width: 100%; + justify-content: center; + align-items: center; + text-align: center; +} + +#loading { + height: calc( 100vh - 105px ); + width: 100%; +} + +#predictoors_container .show-hide { + display: none; +} + +/* Hide the default radio circle */ +#date-period-radio-items input[type="radio"] { + display: none; +} + +button{ + background-color: #007BFF !important; + color: white; +} + +/* Style the label as buttons */ +#date-period-radio-items label { + display: inline-block; + padding: 5px 14px; + margin-right: 10px; + background-color: #007BFF; + color: white; + border: 1px solid #007BFF; + border-radius: 4px; + cursor: pointer; +} + +/* Style for selected label */ +#date-period-radio-items label:has(input:checked) { + background-color: #00254d; + border-color: #0056b3; +} + +.form-check-label, .form-switch{ + margin: 0; +} + +.wrap-with-gap { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; +} + +.table-title { + display: flex; + justify-content: space-between; + align-items: center; +} + +.button-select-all { + border: 0; + min-width: 90px; + font-size: 15px; + background-color: #dedede; + border-radius: 3px; +} + +.button-clear-all { + border: 0; + font-size: 15px; + background-color: #dedede; + border-radius: 3px; +} diff --git a/pdr_backend/pdr_dashboard/dash_components/app_constants.py b/pdr_backend/pdr_dashboard/dash_components/app_constants.py new file mode 100644 index 000000000..1f9e4f56e --- /dev/null +++ b/pdr_backend/pdr_dashboard/dash_components/app_constants.py @@ -0,0 +1,9 @@ +PREDICTOOR_TABLE_COLUMNS = [ + {"name": "User Address", "id": "user_address"}, + {"name": "Profit", "id": "total_profit"}, + {"name": "Accuracy", "id": "avg_accuracy"}, + {"name": "Stake", "id": "avg_stake"}, + {"name": "User", "id": "user"}, +] + +PREDICTOOR_TABLE_HIDDEN_COLUMNS = ["user"] diff --git a/pdr_backend/pdr_dashboard/dash_components/callbacks.py b/pdr_backend/pdr_dashboard/dash_components/callbacks.py index 0043bdacc..7b197846b 100644 --- a/pdr_backend/pdr_dashboard/dash_components/callbacks.py +++ b/pdr_backend/pdr_dashboard/dash_components/callbacks.py @@ -1,180 +1,302 @@ -from dash import Input, Output, State +import copy + import dash + +from dash import Input, Output, State + +from pdr_backend.pdr_dashboard.dash_components.app_constants import ( + PREDICTOOR_TABLE_COLUMNS, + PREDICTOOR_TABLE_HIDDEN_COLUMNS, +) +from pdr_backend.pdr_dashboard.dash_components.plots import ( + get_figures_and_metrics, +) + from pdr_backend.pdr_dashboard.dash_components.util import ( - get_feeds_data_from_db, - get_predictoors_data_from_db, - get_payouts_from_db, filter_objects_by_field, + get_date_period_text, + get_feed_ids_based_on_predictoors_from_db, + get_payouts_from_db, + get_start_date_from_period, + select_or_clear_all_by_table, ) from pdr_backend.pdr_dashboard.dash_components.view_elements import ( get_graph, ) -from pdr_backend.pdr_dashboard.dash_components.plots import ( - get_figures, -) + +from pdr_backend.cli.arg_feeds import ArgFeeds # pylint: disable=too-many-statements def get_callbacks(app): @app.callback( - Output("feeds-data", "data"), - Output("predictoors-data", "data"), - Output("error-message", "children"), - Input("data-folder", "data"), + [ + Output("show-favourite-addresses", "value"), + Output("is-loading", "value"), + ], + Input("is-loading", "value"), ) - def get_input_data_from_db(files_dir): - try: - feeds_data = get_feeds_data_from_db(files_dir) - predictoors_data = get_predictoors_data_from_db(files_dir) - return feeds_data, predictoors_data, None - except Exception as e: - return None, None, dash.html.H3(str(e)) + # pylint: disable=unused-argument + def startup(is_loading): + show_favourite_addresses = True if app.favourite_addresses else [] + return show_favourite_addresses, 0 @app.callback( - Output("payouts-data", "data"), + Output("accuracy_chart", "children"), + Output("profit_chart", "children"), + Output("stake_chart", "children"), + Output("accuracy_metric", "children"), + Output("profit_metric", "children"), + Output("stake_metric", "children"), + Output("available_data_period_text", "children"), [ Input("feeds_table", "selected_rows"), Input("predictoors_table", "selected_rows"), - Input("search-input-Feeds", "value"), - Input("search-input-Predictoors", "value"), + Input("feeds_table", "data"), + Input("predictoors_table", "data"), + Input("date-period-radio-items", "value"), ], - State("feeds-data", "data"), - State("predictoors-data", "data"), - State("data-folder", "data"), ) def get_display_data_from_db( feeds_table_selected_rows, predictoors_table_selected_rows, - search_value_feeds, - search_value_predictoors, - feeds_data, - predictoors_data, - lake_dir, + feeds_table, + predictoors_table, + date_period, ): - feeds_addrs = [] - predictoors_addrs = [] - if ( - len(feeds_table_selected_rows) == 0 - or len(predictoors_table_selected_rows) == 0 - ): - return dash.no_update + # feeds_table_selected_rows is a list of ints + # feeds_data is a list of dicts + # get the feeds data for the selected rows + selected_feeds = [feeds_table[i] for i in feeds_table_selected_rows] + feeds = ArgFeeds.from_table_data(selected_feeds) - current_feeds_table_data = update_feeds_table_on_search( - search_value_feeds, feeds_data - ) - for i in feeds_table_selected_rows: - feeds_addrs.append(current_feeds_table_data[i]["contract"]) + selected_predictoors = [ + predictoors_table[i] for i in predictoors_table_selected_rows + ] + predictoors_addrs = [row["user"] for row in selected_predictoors] + + if len(selected_feeds) == 0 or len(selected_predictoors) == 0: + payouts = [] + else: + start_date = ( + get_start_date_from_period(int(date_period)) + if int(date_period) > 0 + else 0 + ) + payouts = get_payouts_from_db( + [row["contract"] for row in selected_feeds], + predictoors_addrs, + start_date, + app.lake_dir, + ) - current_predictoors_table_data = update_predictoors_table_on_search( - search_value_predictoors, predictoors_data + # get figures + accuracy_fig, profit_fig, stakes_fig, avg_accuracy, total_profit, avg_stake = ( + get_figures_and_metrics( + payouts, + feeds, + predictoors_addrs, + ) ) - for i in predictoors_table_selected_rows: - predictoors_addrs.append(current_predictoors_table_data[i]["user"]) - payouts = get_payouts_from_db(feeds_addrs, predictoors_addrs, lake_dir) + # get available period date text + date_period_text = ( + get_date_period_text(payouts) + if ( + int(date_period) == 0 + and (len(selected_feeds) > 0 or len(selected_predictoors) > 0) + ) + else dash.no_update + ) - return payouts + return ( + get_graph(accuracy_fig), + get_graph(profit_fig), + get_graph(stakes_fig), + f"{round(avg_accuracy, 2)}%", + f"{round(total_profit, 2)} OCEAN", + f"{round(avg_stake, 2)} OCEAN", + date_period_text, + ) @app.callback( Output("feeds_table", "columns"), - Input("feeds-data", "data"), + Output("feeds_table", "data"), + Input("is-loading", "value"), ) - def create_feeds_table(feeds_data): - if not feeds_data: + # pylint: disable=unused-argument + def create_feeds_table(is_loading): + if not app.feeds_data: return dash.no_update - for feed in feeds_data: + data = copy.deepcopy(app.feeds_data) + for feed in data: del feed["contract"] - columns = [{"name": col, "id": col} for col in feeds_data[0].keys()] - return columns + columns = [{"name": col, "id": col} for col in data[0].keys()] + return columns, data @app.callback( Output("predictoors_table", "columns"), - Input("predictoors-data", "data"), + Output("predictoors_table", "data"), + Input("is-loading", "value"), ) - def create_predictoors_table(predictoors_data): - if not predictoors_data: + # pylint: disable=unused-argument + def create_predictoors_table(is_loading): + if not app.predictoors_data: return dash.no_update - columns = [{"name": col, "id": col} for col in predictoors_data[0].keys()] - return columns + columns = [{"name": col, "id": col} for col in app.predictoors_data[0].keys()] + + return columns, app.predictoors_data @app.callback( - Output("accuracy_chart", "children"), - Output("profit_chart", "children"), - Output("stake_chart", "children"), - Input("payouts-data", "data"), - Input("feeds_table", "selected_rows"), - Input("predictoors_table", "selected_rows"), - Input("search-input-Feeds", "value"), - Input("search-input-Predictoors", "value"), - State("feeds-data", "data"), - State("predictoors-data", "data"), + Output("predictoors_table", "data", allow_duplicate=True), + Output("predictoors_table", "selected_rows"), + Output("predictoors_table", "columns", allow_duplicate=True), + Output("predictoors_table", "hidden_columns"), + [ + Input("search-input-Predictoors", "value"), + Input("predictoors_table", "selected_rows"), + Input("predictoors_table", "data"), + Input("show-favourite-addresses", "value"), + ], + prevent_initial_call=True, ) - def create_charts( - payouts_data, - feeds_table_selected_rows, - predictoors_table_selected_rows, - search_value_feeds, - search_value_predictoors, - feeds_data, - predictoors_data, + def update_predictoors_table_on_search( + search_value, + selected_rows, + predictoors_table, + show_favourite_addresses, ): - feeds_addrs = [] - predictoors_addrs = [] + selected_predictoors = [predictoors_table[i] for i in selected_rows] + filtered_data = app.predictoors_data - ## calculate selected feeds - current_feeds_table_data = update_feeds_table_on_search( - search_value_feeds, feeds_data - ) - for i in feeds_table_selected_rows: - feeds_addrs.append( - { - "contract": current_feeds_table_data[i]["contract"], - "feed_name": f"{current_feeds_table_data[i]['pair']}-{current_feeds_table_data[i]['timeframe']}", # pylint: disable=line-too-long - } + if "show-favourite-addresses.value" in dash.callback_context.triggered_prop_ids: + custom_predictoors = [ + predictoor + for predictoor in app.predictoors_data + if predictoor["user"] in app.favourite_addresses + ] + + if show_favourite_addresses: + selected_predictoors += custom_predictoors + else: + selected_predictoors = [ + predictoor + for predictoor in selected_predictoors + if predictoor not in custom_predictoors + ] + + if search_value: + # filter predictoors by user address + filtered_data = filter_objects_by_field( + filtered_data, "user", search_value, selected_predictoors ) - ## calculate selected predictoors addrs - current_predictoors_table_data = update_predictoors_table_on_search( - search_value_predictoors, predictoors_data + selected_predictoor_indices = [ + i + for i, predictoor in enumerate(filtered_data) + if predictoor in selected_predictoors + ] + + return ( + filtered_data, + selected_predictoor_indices, + PREDICTOOR_TABLE_COLUMNS, + PREDICTOOR_TABLE_HIDDEN_COLUMNS, ) - for i in predictoors_table_selected_rows: - predictoors_addrs.append(current_predictoors_table_data[i]["user"]) - # get figures - accuracy_fig, profit_fig, stakes_fig = get_figures( - payouts_data, feeds_addrs, predictoors_addrs + @app.callback( + Output("feeds_table", "data", allow_duplicate=True), + Output("feeds_table", "selected_rows"), + [ + Input("is-loading", "value"), + Input("search-input-Feeds", "value"), + Input("feeds_table", "selected_rows"), + Input("feeds_table", "data"), + Input("toggle-switch-predictoor-feeds", "value"), + Input("predictoors_table", "selected_rows"), + ], + State("predictoors_table", "data"), + prevent_initial_call=True, + ) + # pylint: disable=unused-argument + def update_feeds_table_on_search( + is_loading, + search_value, + selected_rows, + feeds_table, + predictoor_feeds_only, + predictoors_table_selected_rows, + predictoors_table, + ): + selected_feeds = [feeds_table[i] for i in selected_rows] + # Extract selected predictoor addresses + predictoors_addrs = [ + predictoors_table[i]["user"] for i in predictoors_table_selected_rows + ] + + # filter feeds by pair address + filtered_data = ( + filter_objects_by_field( + app.feeds_data, "pair", search_value, selected_feeds + ) + if search_value + else app.feeds_data ) - return get_graph(accuracy_fig), get_graph(profit_fig), get_graph(stakes_fig) + # filter feeds by payouts from selected predictoors + if predictoor_feeds_only and (len(predictoors_addrs) > 0): + feed_ids = get_feed_ids_based_on_predictoors_from_db( + app.lake_dir, + predictoors_addrs, + ) + filtered_data = [ + obj for obj in app.feeds_data if obj["contract"] in feed_ids + ] + + if ( + app.favourite_addresses + and "is-loading.value" in dash.callback_context.triggered_prop_ids + ): + return filtered_data, list(range(len(filtered_data))) + + selected_feed_indices = [ + i for i, feed in enumerate(filtered_data) if feed in selected_feeds + ] + + return filtered_data, selected_feed_indices @app.callback( - Output("predictoors_table", "data"), + Output("feeds_table", "selected_rows", allow_duplicate=True), [ - Input("search-input-Predictoors", "value"), - Input("predictoors-data", "data"), + Input("select-all-feeds_table", "n_clicks"), + Input("clear-all-feeds_table", "n_clicks"), ], + State("feeds_table", "data"), + prevent_initial_call=True, ) - def update_predictoors_table_on_search(search_value, predictoors_data): - if not search_value: - return predictoors_data + def select_or_clear_all_feeds(_, __, rows): + """ + Select or clear all rows in the feeds table. + """ - # filter predictoors by user address - filtered_data = filter_objects_by_field(predictoors_data, "user", search_value) - return filtered_data + ctx = dash.callback_context + return select_or_clear_all_by_table(ctx, "feeds_table", rows) @app.callback( - Output("feeds_table", "data"), + Output("predictoors_table", "selected_rows", allow_duplicate=True), [ - Input("search-input-Feeds", "value"), - Input("feeds-data", "data"), + Input("select-all-predictoors_table", "n_clicks"), + Input("clear-all-predictoors_table", "n_clicks"), ], + State("predictoors_table", "data"), + prevent_initial_call=True, ) - def update_feeds_table_on_search(search_value, feeds_data): - if not search_value: - return feeds_data + def select_or_clear_all_predictoors(_, __, rows): + """ + Select or clear all rows in the predictoors table. + """ - # filter feeds by pair address - filtered_data = filter_objects_by_field(feeds_data, "pair", search_value) - return filtered_data + ctx = dash.callback_context + return select_or_clear_all_by_table(ctx, "predictoors_table", rows) diff --git a/pdr_backend/pdr_dashboard/dash_components/plots.py b/pdr_backend/pdr_dashboard/dash_components/plots.py index 798ece1dc..ed67b8025 100644 --- a/pdr_backend/pdr_dashboard/dash_components/plots.py +++ b/pdr_backend/pdr_dashboard/dash_components/plots.py @@ -1,12 +1,15 @@ from itertools import product -from typing import Union, List, Tuple, Optional +from typing import List, Optional, Tuple, Union + import plotly.graph_objects as go from enforce_typing import enforce_types + +from pdr_backend.cli.arg_feeds import ArgFeeds from pdr_backend.util.time_types import UnixTimeS @enforce_types -def process_payouts(payouts: List[dict], predictor: str, feed: str) -> tuple: +def process_payouts(payouts: List[dict]) -> tuple: """ Process payouts data for a given predictor and feed. Args: @@ -20,9 +23,6 @@ def process_payouts(payouts: List[dict], predictor: str, feed: str) -> tuple: profit = predictions = correct_predictions = 0 for p in payouts: - if not (predictor in p["ID"] and feed in p["ID"]): - continue - predictions += 1 profit_change = max(p["payout"], 0) - p["stake"] profit += profit_change @@ -32,10 +32,19 @@ def process_payouts(payouts: List[dict], predictor: str, feed: str) -> tuple: accuracies.append((correct_predictions / predictions) * 100) profits.append(profit) stakes.append(p["stake"]) + slot_in_date_format = [ UnixTimeS(ts).to_milliseconds().to_dt().strftime("%m-%d %H:%M") for ts in slots ] - return slot_in_date_format, accuracies, profits, stakes + + return ( + slot_in_date_format, + accuracies, + profits, + stakes, + correct_predictions, + predictions, + ) @enforce_types @@ -81,10 +90,44 @@ def create_figure( return fig +def _empty_accuracy_scatter() -> List[go.Scatter]: + return [go.Scatter(x=[], y=[], mode="lines", name="accuracy")] + + +def _empty_profit_scatter() -> List[go.Scatter]: + return [go.Scatter(x=[], y=[], mode="lines", name="profit")] + + +def _empty_stakes_bar() -> List[go.Bar]: + return [go.Bar(x=[], y=[], name="stakes", width=5)] + + +def _empty_trio() -> Tuple[go.Figure, go.Figure, go.Figure]: + return _empty_accuracy_scatter(), _empty_profit_scatter(), _empty_stakes_bar() + + +def _make_figures(fig_tup: Tuple) -> Tuple[go.Figure, go.Figure, go.Figure]: + accuracy_scatters, profit_scatters, stakes_scatters = fig_tup + + fig_accuracy = create_figure( + accuracy_scatters, "Accuracy", "'%' accuracy over time" + ) + + fig_profit = create_figure( + profit_scatters, "Profit", "OCEAN profit over time", show_legend=False + ) + + fig_costs = create_figure( + stakes_scatters, "Costs", "Stake (OCEAN) at a time", show_legend=False + ) + + return fig_accuracy, fig_profit, fig_costs + + @enforce_types -def get_figures( - payouts: Optional[List], feeds: List, predictoors: List[str] -) -> Tuple[go.Figure, go.Figure, go.Figure]: +def get_figures_and_metrics( + payouts: Optional[List], feeds: ArgFeeds, predictoors: List[str] +) -> Tuple[go.Figure, go.Figure, go.Figure, float | None, float | None, float | None]: """ Get figures for accuracy, profit, and costs. Args: @@ -92,42 +135,60 @@ def get_figures( feeds (list): List of feeds data. predictoors (list): List of predictoors data. Returns: - tuple: Tuple of accuracy, profit, and costs figures. + tuple: Tuple of accuracy, profit, and costs figures, avg accuracy, total profit, avg stake """ + if not payouts: + figures = _make_figures(_empty_trio()) + return figures[0], figures[1], figures[2], 0.0, 0.0, 0.0 + accuracy_scatters, profit_scatters, stakes_scatters = [], [], [] + avg_accuracy, total_profit, avg_stake = 0.0, 0.0, 0.0 + + all_stakes = [] + prediction_count = 0 + correct_prediction_count = 0 + for predictor, feed in product(predictoors, feeds): + # only filter for this particular predictoor and feed pair + # in order to properly group the data + filtered_payouts = [ + p for p in payouts if predictor in p["ID"] and feed.contract in p["ID"] + ] + + slots, accuracies, profits, stakes, correct_predictions, predictions = ( + process_payouts(filtered_payouts) + ) - if payouts: - for predictor, feed in product(predictoors, feeds): - slots, accuracies, profits, stakes = process_payouts( - payouts, predictor, feed["contract"] - ) - if not slots: - continue - - short_name = f"{predictor[:5]} - {feed['feed_name']}" - accuracy_scatters.append( - go.Scatter(x=slots, y=accuracies, mode="lines", name=short_name) - ) - profit_scatters.append( - go.Scatter(x=slots, y=profits, mode="lines", name=short_name) - ) - stakes_scatters.append(go.Bar(x=slots, y=stakes, name=short_name, width=5)) + if not slots: + continue + + all_stakes.extend(stakes) + prediction_count += predictions + correct_prediction_count += correct_predictions + + total_profit = (profits[-1] + total_profit) if total_profit else profits[-1] + + short_name = f"{predictor[:5]} - {str(feed)}" + accuracy_scatters.append( + go.Scatter(x=slots, y=accuracies, mode="lines", name=short_name) + ) + profit_scatters.append( + go.Scatter(x=slots, y=profits, mode="lines", name=short_name) + ) + stakes_scatters.append(go.Bar(x=slots, y=stakes, name=short_name, width=5)) + + avg_stake = sum(all_stakes) / len(all_stakes) if all_stakes else 0.0 + avg_accuracy = ( + (correct_prediction_count / prediction_count) * 100 if prediction_count else 0.0 + ) if not accuracy_scatters: - accuracy_scatters.append(go.Scatter(x=[], y=[], mode="lines", name="accuracy")) + accuracy_scatters = _empty_accuracy_scatter() + if not profit_scatters: - profit_scatters.append(go.Scatter(x=[], y=[], mode="lines", name="profit")) - if not stakes_scatters: - stakes_scatters.append(go.Bar(x=[], y=[], name="stakes", width=5)) + profit_scatters = _empty_profit_scatter() - fig_accuracy = create_figure( - accuracy_scatters, "Accuracy", "'%' accuracy over time" - ) - fig_profit = create_figure( - profit_scatters, "Profit", "OCEAN profit over time", show_legend=False - ) - fig_costs = create_figure( - stakes_scatters, "Costs", "Stake (OCEAN) at a time", show_legend=False - ) + if not stakes_scatters: + stakes_scatters = _empty_stakes_bar() - return fig_accuracy, fig_profit, fig_costs + figures = _make_figures((accuracy_scatters, profit_scatters, stakes_scatters)) + return (figures[0], figures[1], figures[2], avg_accuracy, total_profit, avg_stake) diff --git a/pdr_backend/pdr_dashboard/dash_components/util.py b/pdr_backend/pdr_dashboard/dash_components/util.py index 0fc0e6fb8..2b043655e 100644 --- a/pdr_backend/pdr_dashboard/dash_components/util.py +++ b/pdr_backend/pdr_dashboard/dash_components/util.py @@ -1,6 +1,9 @@ import logging -from typing import Union, List, Dict, Any + +from datetime import datetime, timedelta +from typing import Union, List, Dict, Any, Optional from enforce_typing import enforce_types +import dash from pdr_backend.lake.duckdb_data_store import DuckDBDataStore from pdr_backend.lake.payout import Payout @@ -51,9 +54,48 @@ def get_predictoors_data_from_db(lake_dir: str): ) +@enforce_types +def get_user_payouts_stats_from_db(lake_dir: str): + return _query_db( + lake_dir, + f""" + SELECT + "user", + SUM(payout - stake) AS total_profit, + SUM(CASE WHEN payout > 0 THEN 1 ELSE 0 END) * 100.0 / COUNT(*) AS avg_accuracy, + AVG(stake) AS avg_stake + FROM + {Payout.get_lake_table_name()} + GROUP BY + "user" + """, + ) + + +def get_feed_ids_based_on_predictoors_from_db( + lake_dir: str, predictoor_addrs: List[str] +): + # Constructing the SQL query + query = f""" + SELECT LIST(LEFT(ID, POSITION('-' IN ID) - 1)) as feed_addrs + FROM {Payout.get_lake_table_name()} + WHERE ID IN ( + SELECT MIN(ID) + FROM {Payout.get_lake_table_name()} + WHERE ( + {" OR ".join([f"ID LIKE '%{item}%'" for item in predictoor_addrs])} + ) + GROUP BY LEFT(ID, POSITION('-' IN ID) - 1) + ); + """ + + # Execute the query + return _query_db(lake_dir, query)[0]["feed_addrs"] + + @enforce_types def get_payouts_from_db( - feed_addrs: List[str], predictoor_addrs: List[str], lake_dir: str + feed_addrs: List[str], predictoor_addrs: List[str], start_date: int, lake_dir: str ) -> List[dict]: """ Get payouts data for the given feed and predictoor addresses. @@ -74,15 +116,94 @@ def get_payouts_from_db( # Adding conditions for the second list query += " OR ".join([f"ID LIKE '%{item}%'" for item in predictoor_addrs]) - query += ");" + query += ")" + if start_date != 0: + query += f"AND (slot >= {start_date})" + query += ";" return _query_db(lake_dir, query) -# Function to filter the list by field containing a given string +@enforce_types def filter_objects_by_field( - objects: List[Dict[str, Any]], field: str, search_string: str + objects: List[Dict[str, Any]], + field: str, + search_string: str, + previous_objects: Optional[List] = None, ) -> List[Dict[str, Any]]: - return list( - filter(lambda obj: search_string.lower() in obj[field].lower(), objects) - ) + if previous_objects is None: + previous_objects = [] + + return [ + obj + for obj in objects + if search_string.lower() in obj[field].lower() or obj in previous_objects + ] + + +@enforce_types +def select_or_clear_all_by_table( + ctx, + table_id: str, + rows: List[Dict[str, Any]], +) -> Union[List[int], dash.no_update]: + """ + Select or unselect all rows in a table. + Args: + ctx (dash.callback_context): Dash callback context. + Returns: + list: List of selected rows or dash.no_update. + """ + if not ctx.triggered: + return dash.no_update + + button_id = ctx.triggered[0]["prop_id"].split(".")[0] + + selected_rows = [] + if button_id == f"select-all-{table_id}": + selected_rows = list(range(len(rows))) + + return selected_rows + + +@enforce_types +def get_predictoors_data_from_payouts( + user_payout_stats: List[Dict[str, Any]], +) -> List[Dict[str, Any]]: + """ + Process the user payouts stats data. + Args: + user_payout_stats (list): List of user payouts stats data. + Returns: + list: List of processed user payouts stats data. + """ + + for data in user_payout_stats: + new_data = { + "user_address": data["user"][:5] + "..." + data["user"][-5:], + "total_profit": round(data["total_profit"], 2), + "avg_accuracy": round(data["avg_accuracy"], 2), + "avg_stake": round(data["avg_stake"], 2), + "user": data["user"], + } + + data.clear() + data.update(new_data) + + return user_payout_stats + + +def get_start_date_from_period(period: int): + return int((datetime.now() - timedelta(days=period)).timestamp()) + + +def get_date_period_text(payouts: List): + if not payouts: + return "there is no data available" + start_date = payouts[0]["slot"] if len(payouts) > 0 else 0 + end_date = payouts[-1]["slot"] if len(payouts) > 0 else 0 + date_period_text = f""" + available {datetime.fromtimestamp(start_date).strftime('%d-%m-%Y')} + - {datetime.fromtimestamp(end_date).strftime('%d-%m-%Y')} + """ + return date_period_text diff --git a/pdr_backend/pdr_dashboard/dash_components/view_elements.py b/pdr_backend/pdr_dashboard/dash_components/view_elements.py index 5a78c166b..9ea69aa9b 100644 --- a/pdr_backend/pdr_dashboard/dash_components/view_elements.py +++ b/pdr_backend/pdr_dashboard/dash_components/view_elements.py @@ -1,5 +1,5 @@ import dash_bootstrap_components as dbc -from dash import dcc, html, dash_table +from dash import dash_table, dcc, html def get_input_column(): @@ -7,148 +7,223 @@ def get_input_column(): [ html.Div( get_table( - table_id="feeds_table", - table_name="Feeds", - searchable_field="pair", + table_id="predictoors_table", + table_name="Predictoors", + searchable_field="user", columns=[], data=None, + default_sorting=[ + {"column_id": "total_profit", "direction": "desc"} + ], ), - id="feeds_container", - style={"height": "50%"}, + id="predictoors_container", ), html.Div( get_table( - table_id="predictoors_table", - table_name="Predictoors", - searchable_field="user", + table_id="feeds_table", + table_name="Feeds", + searchable_field="pair", columns=[], data=None, + default_sorting=[], ), - id="predictoors_container", - style={"height": "50%"}, + id="feeds_container", + style={ + "height": "50%", + "display": "flex", + "flexDirection": "column", + "justifyContent": "flex-end", + }, ), ], style={ "height": "100%", - "width": "20%", - "marginTop": "8px", + "width": "30%", "display": "flex", "flexDirection": "column", - "justifyContent": "space-around", + "justifyContent": "space-between", }, ) def get_graphs_column(): + return html.Div( + [get_graphs_column_metrics_row(), get_graphs_column_plots_row()], + id="graphs_container", + style={ + "height": "100%", + "width": "70%", + "display": "flex", + "flexDirection": "column", + "justifyContent": "start", + "paddingLeft": "50px", + }, + ) + + +def get_graphs_column_metrics_row(): + return html.Div( + [ + get_metric(label="Avg Accuracy", value="50%", value_id="accuracy_metric"), + get_metric(label="Total Profit", value="50%", value_id="profit_metric"), + get_metric(label="Avg Stake", value="50%", value_id="stake_metric"), + get_date_period_selection_component(), + ], + id="metrics_container", + style={ + "height": "12%", + "display": "flex", + "justifyContent": "space-between", + }, + ) + + +def get_date_period_selection_component(): + return html.Div( + [ + dcc.RadioItems( + id="date-period-radio-items", + options=[ + {"label": "1D", "value": "1"}, + {"label": "1W", "value": "7"}, + {"label": "1M", "value": "30"}, + {"label": "ALL", "value": "0"}, + ], + value="0", # default selected value + labelStyle={"display": "inline-block", "margin-right": "10px"}, + ), + html.Span("there is no data available", id="available_data_period_text"), + ] + ) + + +def get_graphs_column_plots_row(): return html.Div( [ html.Div(id="accuracy_chart"), html.Div(id="profit_chart"), html.Div(id="stake_chart"), ], - id="graphs_container", + id="plots_container", style={ - "height": "100%", - "width": "80%", + "height": "88%", "display": "flex", "flexDirection": "column", - "justifyContent": "space-around", - "paddingLeft": "40px", + "justifyContent": "space-between", }, ) +def get_metric(label, value, value_id): + return html.Div( + [ + html.Span( + label, + ), + html.Span(value, id=value_id, style={"fontWeight": "bold"}), + ], + style={"display": "flex", "flexDirection": "column", "font-size": "20px"}, + ) + + def get_layout(): return html.Div( [ - dcc.Store(id="data-folder"), - dcc.Store(id="feeds-data"), - dcc.Store(id="predictoors-data"), - dcc.Store(id="payouts-data"), - dcc.Store(id="predictoors-stake-data"), + dcc.Store(id="user-payout-stats"), html.H1( "Predictoor dashboard", id="page_title", - style={"width": "100%", "textAlign": "center"}, - ), - html.Div( - id="error-message", - style={ - "display": "flex", - "width": "100%", - "justifyContent": "center", - "alignItems": "center", - "textAlign": "center", - }, ), dcc.Loading( id="loading", type="default", children=get_main_container(), - style={ - "height": "100%", - "width": "100%", - }, custom_spinner=html.H2(dbc.Spinner(), style={"height": "100%"}), ), + dcc.Input(id="is-loading", type="hidden", value=1), ], + style={"height": "100%"}, ) def get_main_container(): return html.Div( [get_input_column(), get_graphs_column()], - style={ - "height": "100%", - "width": "100%", - "display": "flex", - "justifyContent": "space-between", - }, + className="main-container", ) -def get_table(table_id, table_name, searchable_field, columns, data): +def get_table(table_id, table_name, searchable_field, columns, data, default_sorting): return html.Div( [ html.Div( [ html.Span(table_name, style={"fontSize": "20px"}), + ( + dbc.Switch( + id="toggle-switch-predictoor-feeds", + label="Predictoor feeds only", + value=True, + ) + if table_name == "Feeds" + else dbc.Switch( + id="show-favourite-addresses", + label="Select configured predictoors", + value=True, + ) + ), + ], + className="table-title", + ), + html.Div( + [ dcc.Input( id=f"search-input-{table_name}", type="text", placeholder=f"Search for {searchable_field}", debounce=True, # Trigger the input event after user stops typing - style={ - "fontSize": "15px", - "height": "100%", - }, + style={"fontSize": "15px", "min-width": "100px"}, + ), + html.Div( + [ + html.Button( + "Select All", + id=f"select-all-{table_id}", + n_clicks=0, + className="button-select-all", + ), + html.Button( + "Clear", + id=f"clear-all-{table_id}", + n_clicks=0, + className="button-clear-all", + ), + ], + className="wrap-with-gap", ), ], - style={ - "display": "flex", - "justifyContent": "space-between", - "alignItems": "center", - }, + className="wrap-with-gap", ), dash_table.DataTable( id=table_id, - columns=[{"name": col, "id": col} for col in columns], + columns=[{"name": col, "id": col, "sortable": True} for col in columns], + sort_by=default_sorting, data=data, row_selectable="multi", # Can be 'multi' for multiple rows selected_rows=[], + sort_action="native", # Enables data to be sorted style_cell={"textAlign": "left"}, style_table={ - "height": "38vh", + "height": "30vh", "width": "100%", "overflow": "auto", - "marginTop": "5px", + "paddingTop": "5px", }, fill_width=True, ), ], - style={"marginBottom": "40px"}, ) def get_graph(figure): - return dcc.Graph(figure=figure, style={"width": "100%", "height": "30vh"}) + return dcc.Graph(figure=figure, style={"width": "100%", "height": "25vh"}) diff --git a/pdr_backend/pdr_dashboard/predictoor_dash.py b/pdr_backend/pdr_dashboard/predictoor_dash.py index c00f88ebc..0f1ffae08 100644 --- a/pdr_backend/pdr_dashboard/predictoor_dash.py +++ b/pdr_backend/pdr_dashboard/predictoor_dash.py @@ -7,22 +7,47 @@ from pdr_backend.pdr_dashboard.dash_components.callbacks import ( get_callbacks, ) +from pdr_backend.pdr_dashboard.dash_components.util import ( + get_feeds_data_from_db, + get_predictoors_data_from_payouts, + get_user_payouts_stats_from_db, +) from pdr_backend.pdr_dashboard.dash_components.view_elements import ( get_layout, ) from pdr_backend.ppss.ppss import PPSS -app = Dash(__name__, external_stylesheets=[dbc.themes.BOOTSTRAP]) -app.config["suppress_callback_exceptions"] = True -app.layout = get_layout() -get_callbacks(app) - @enforce_types def predictoor_dash(ppss: PPSS, debug_mode: bool): port = 8050 if not debug_mode: webbrowser.open(f"http://127.0.0.1:{port}/") - folder = ppss.lake_ss.lake_dir - app.layout.children[0].data = folder + + app = Dash(__name__, external_stylesheets=[dbc.themes.BOOTSTRAP]) + app.config["suppress_callback_exceptions"] = True + app.layout = get_layout() + + try: + setup_app(app, ppss) + except Exception as e: + print( + f"""ERROR: Please make sure there is data in the lake. + Full error: {e}""" + ) + return + + get_callbacks(app) app.run(debug=debug_mode, port=port) + + +@enforce_types +def setup_app(app, ppss: PPSS): + app.lake_dir = ppss.lake_ss.lake_dir + app.feeds_data = get_feeds_data_from_db(ppss.lake_ss.lake_dir) + app.predictoors_data = get_predictoors_data_from_payouts( + get_user_payouts_stats_from_db(ppss.lake_ss.lake_dir) + ) + app.favourite_addresses = ppss.predictoor_ss.my_addresses + + return app diff --git a/pdr_backend/pdr_dashboard/test/conftest.py b/pdr_backend/pdr_dashboard/test/conftest.py index 2c7bcc293..adfcd7432 100644 --- a/pdr_backend/pdr_dashboard/test/conftest.py +++ b/pdr_backend/pdr_dashboard/test/conftest.py @@ -1,13 +1,27 @@ import pytest +import dash_bootstrap_components as dbc -from pdr_backend.lake.prediction import ( - mock_first_predictions, - mock_daily_predictions, +from dash import Dash +from selenium.webdriver.chrome.options import Options + +from pdr_backend.lake.payout import mock_payouts, mock_payouts_related_with_predictions +from pdr_backend.lake.prediction import mock_daily_predictions, mock_first_predictions + +from pdr_backend.pdr_dashboard.dash_components.callbacks import ( + get_callbacks, +) +from pdr_backend.pdr_dashboard.dash_components.view_elements import ( + get_layout, +) +from pdr_backend.pdr_dashboard.test.resources import ( + _prepare_test_db, + _clear_test_db, ) -from pdr_backend.lake.payout import ( - mock_payouts, - mock_payouts_related_with_predictions, +from pdr_backend.pdr_dashboard.predictoor_dash import ( + setup_app as setup_app_main, ) +from pdr_backend.lake.payout import Payout +from pdr_backend.lake.prediction import Prediction @pytest.fixture() @@ -28,3 +42,115 @@ def _sample_payouts(): @pytest.fixture() def _sample_payouts_related_with_predictions(): return mock_payouts_related_with_predictions() + + +# Test for select_or_clear_all_by_table function +@pytest.fixture +def sample_table_rows(): + return [ + {"name": "Alice", "age": 30, "city": "New York"}, + {"name": "Bob", "age": 24, "city": "San Francisco"}, + {"name": "Charlie", "age": 29, "city": "Boston"}, + {"name": "David", "age": 34, "city": "Chicago"}, + {"name": "Eve", "age": 28, "city": "Los Angeles"}, + ] + + +def pytest_setup_options(): + options = Options() + options.add_argument("--headless") + options.add_argument("--disable-gpu") + + return options + + +def _add_css(app): + style_css = "" + # read the styles.css file and add it to the assets folder + # Read the styles.css file + with open("pdr_backend/pdr_dashboard/assets/styles.css", "r") as f: + style_css = f.read() + + # Manually link the CSS file by embedding its contents in a + + + {{%app_entry%}} + + + + """ + + return app + + +@pytest.fixture +def setup_app( + tmpdir, _sample_daily_predictions, _sample_payouts_related_with_predictions +): + _clear_test_db(str(tmpdir)) + + _prepare_test_db( + tmpdir, + _sample_payouts_related_with_predictions, + table_name=Payout.get_lake_table_name(), + ) + + ppss, _ = _prepare_test_db( + tmpdir, + _sample_daily_predictions, + table_name=Prediction.get_lake_table_name(), + ) + + app = Dash(__name__, external_stylesheets=[dbc.themes.BOOTSTRAP]) + app.config["suppress_callback_exceptions"] = True + + app = _add_css(app) + app.layout = get_layout() + + setup_app_main(app, ppss) + get_callbacks(app) + + return app + + +@pytest.fixture +def setup_app_with_favourite_addresses( + tmpdir, _sample_daily_predictions, _sample_payouts_related_with_predictions +): + _clear_test_db(str(tmpdir)) + + _prepare_test_db( + tmpdir, + _sample_payouts_related_with_predictions, + table_name=Payout.get_lake_table_name(), + ) + + ppss, _ = _prepare_test_db( + tmpdir, + _sample_daily_predictions, + table_name=Prediction.get_lake_table_name(), + my_addresses=["0x7149ceca72c61991018ed80788bea3f3f4540c3c"], + ) + + app = Dash(__name__, external_stylesheets=[dbc.themes.BOOTSTRAP]) + app.config["suppress_callback_exceptions"] = True + + app = _add_css(app) + app.layout = get_layout() + setup_app_main(app, ppss) + get_callbacks(app) + + return app diff --git a/pdr_backend/pdr_dashboard/test/resources.py b/pdr_backend/pdr_dashboard/test/resources.py index e0ac5681b..6fdb4375e 100644 --- a/pdr_backend/pdr_dashboard/test/resources.py +++ b/pdr_backend/pdr_dashboard/test/resources.py @@ -1,4 +1,7 @@ +import time + from enforce_typing import enforce_types +from selenium.webdriver.common.keys import Keys from pdr_backend.lake.duckdb_data_store import DuckDBDataStore from pdr_backend.ppss.ppss import mock_ppss @@ -7,11 +10,12 @@ from pdr_backend.lake.payout import Payout -def _prepare_test_db(tmpdir, sample_data, table_name): +def _prepare_test_db(tmpdir, sample_data, table_name, my_addresses=None): ppss = mock_ppss( [{"predict": "binance BTC/USDT c 5m", "train_on": "binance BTC/USDT c 5m"}], "sapphire-mainnet", str(tmpdir), + my_addresses=my_addresses if my_addresses else [], ) db = DuckDBDataStore(str(ppss.lake_ss.lake_dir)) @@ -33,3 +37,29 @@ def _clear_test_db(directory: str): db.drop_table(Payout.get_lake_table_name()) db.drop_table(Prediction.get_lake_table_name()) db.duckdb_conn.close() + + +def _input_action(dash_duo, input_id, table_id, input_value, expected_rows): + """ + Helper function to test the search input in the tables + It sends the input_value to the search input and checks + if the number of rows in the table is as expected + """ + + search_input = dash_duo.find_element(input_id) + search_input.clear() + search_input.send_keys(input_value + Keys.ENTER) + time.sleep(2) + assert len(dash_duo.find_elements(f"{table_id} tbody tr")) == expected_rows + + +def start_server_and_wait(dash_duo, app): + """ + Start the server and wait for the elements to be rendered. + """ + + dash_duo.start_server(app) + dash_duo.wait_for_element("#feeds_table") + dash_duo.wait_for_element("#predictoors_table") + dash_duo.wait_for_element("#feeds_table tbody tr") + dash_duo.wait_for_element("#predictoors_table tbody tr") diff --git a/pdr_backend/pdr_dashboard/test/test_callbacks.py b/pdr_backend/pdr_dashboard/test/test_callbacks.py index 590b4df33..2d5fcf2c3 100644 --- a/pdr_backend/pdr_dashboard/test/test_callbacks.py +++ b/pdr_backend/pdr_dashboard/test/test_callbacks.py @@ -1,106 +1,164 @@ import time -import pytest -from dash import Dash -import dash_bootstrap_components as dbc -from selenium.webdriver.common.keys import Keys - -from pdr_backend.pdr_dashboard.dash_components.callbacks import ( - get_callbacks, -) -from pdr_backend.pdr_dashboard.dash_components.view_elements import ( - get_layout, -) from pdr_backend.pdr_dashboard.test.resources import ( - _prepare_test_db, - _clear_test_db, + _input_action, + start_server_and_wait, ) -from pdr_backend.lake.payout import Payout -from pdr_backend.lake.prediction import Prediction -@pytest.fixture -def _test_analytics_app(): - app = Dash(__name__) - get_callbacks(app) - return app +def test_get_input_data_from_db(setup_app, dash_duo): + app = setup_app + start_server_and_wait(dash_duo, app) -def test_get_input_data_from_db( - tmpdir, - _sample_daily_predictions, - _sample_payouts_related_with_predictions, - dash_duo, -): - _clear_test_db(str(tmpdir)) +def test_feeds_search_input(setup_app, dash_duo): + """ + Test the search input in the "Feeds" table. + The search input is used to filter the feeds by their name. + """ + + app = setup_app + start_server_and_wait(dash_duo, app) + _input_action(dash_duo, "#search-input-Feeds", "#feeds_table", "OCEAN", 1) + _input_action(dash_duo, "#search-input-Feeds", "#feeds_table", "BTC", 2) + _input_action(dash_duo, "#search-input-Feeds", "#feeds_table", "ADA", 2) - _prepare_test_db( - tmpdir, - _sample_payouts_related_with_predictions, - table_name=Payout.get_lake_table_name(), - ) - ppss, _ = _prepare_test_db( - tmpdir, - _sample_daily_predictions, - table_name=Prediction.get_lake_table_name(), +def test_predictoors_search_input(setup_app, dash_duo): + """ + Test the search input in the "Predictoors" table. + The search input is used to filter the predictoors by their name. + """ + + app = setup_app + start_server_and_wait(dash_duo, app) + + _input_action( + dash_duo, "#search-input-Predictoors", "#predictoors_table", "0xaaa", 1 + ) + _input_action( + dash_duo, "#search-input-Predictoors", "#predictoors_table", "0xd2", 2 ) - app = Dash(__name__, external_stylesheets=[dbc.themes.BOOTSTRAP]) - app.config["suppress_callback_exceptions"] = True - app.layout = get_layout() - get_callbacks(app) - app.layout.children[0].data = ppss.lake_ss.lake_dir - dash_duo.start_server(app) - dash_duo.wait_for_element("#feeds_table") - dash_duo.wait_for_element("#predictoors_table") +def _feed_count(dash_duo): + feed_rows = dash_duo.find_elements("#feeds_table tbody tr input") + feed_selected_rows = dash_duo.find_elements("#feeds_table tbody tr input:checked") - dash_duo.wait_for_element("#feeds_table tbody tr") - dash_duo.wait_for_element("#predictoors_table tbody tr") + return feed_rows, feed_selected_rows - # ----TEST SEARCH INPUT WITH "OCEAN" VALUE----# - dash_duo.find_element("#search-input-Feeds").send_keys("OCEAN" + Keys.ENTER) - # wait 5 seconds - time.sleep(2) - assert len(dash_duo.find_elements("#feeds_table tbody tr")) == 1 - # ----TEST SEARCH INPUT WITH "BTC" VALUE----# - # reset the search input - dash_duo.find_element("#search-input-Feeds").clear() - # enter "BTC" phrase to the "search-input-Feeds" input - dash_duo.find_element("#search-input-Feeds").send_keys("BTC" + Keys.ENTER) - time.sleep(2) - # check if the "Feeds" table has only two rows - assert len(dash_duo.find_elements("#feeds_table tbody tr")) == 2 - - # ----TEST SEARCH INPUT WITH "ADA" VALUE----# - # reset the search input - dash_duo.find_element("#search-input-Feeds").clear() - # enter "BTC" phrase to the "search-input-Feeds" input - dash_duo.find_element("#search-input-Feeds").send_keys("ADA" + Keys.ENTER) - time.sleep(2) +def _predictoor_count(dash_duo): + predictoors_rows = dash_duo.find_elements("#predictoors_table tbody tr") + predictoors_selected_rows = dash_duo.find_elements( + "#predictoors_table tbody tr input:checked" + ) + + return predictoors_rows, predictoors_selected_rows + + +def test_favorite_addresses_search_input(setup_app_with_favourite_addresses, dash_duo): + app = setup_app_with_favourite_addresses + start_server_and_wait(dash_duo, app) - # check if the "Feeds" table has only two rows - assert len(dash_duo.find_elements("#feeds_table tbody tr")) == 2 + fav_addr_toggle = dash_duo.find_element("#show-favourite-addresses") + all_feeds_toggle = dash_duo.find_element("#toggle-switch-predictoor-feeds") - # ----TEST PREDICTOOR INPUT WITH "0xaaa" VALUE----# - dash_duo.find_element("#search-input-Predictoors").send_keys("0xaaa" + Keys.ENTER) + # default startup: one predictoor selected out of 6 + # and its single feed selected out of 5 total (ADA/USDT) + p_all, p_sel = _predictoor_count(dash_duo) + assert len(p_all) == 6 + assert len(p_sel) == 1 + f_all, f_sel = _feed_count(dash_duo) + assert len(f_all) == 1 + assert len(f_sel) == 1 + + # click on all feeds toggle to show all feeds + all_feeds_toggle.click() time.sleep(2) - assert len(dash_duo.find_elements("#predictoors_table tbody tr")) == 1 + f_all, f_sel = _feed_count(dash_duo) + assert len(f_all) == 5 + assert len(f_sel) == 1 - # ----TEST PREDICTOOR INPUT WITH "0xd2" VALUE----# - # clear input - dash_duo.find_element("#search-input-Predictoors").clear() - # enter "0xd2" phrase to the "search-input-Predictoors" input - dash_duo.find_element("#search-input-Predictoors").send_keys("0xd2" + Keys.ENTER) + # click on fav addr toggle to show all predictoors + fav_addr_toggle.click() time.sleep(2) + p_all, p_sel = _predictoor_count(dash_duo) + assert len(p_all) == 6 + assert len(p_sel) == 0 + + +def test_checkbox_selection(setup_app, dash_duo): + """ + Test the selection of checkboxes in the "Feeds" and "Predictoors" tables. + """ - # check if the "Predictoors" table has only one row - assert len(dash_duo.find_elements("#predictoors_table tbody tr")) == 2 + app = setup_app + start_server_and_wait(dash_duo, app) # click on the checkbox in the second row of the "Feeds" table dash_duo.find_element("#feeds_table tbody tr:nth-child(2) input").click() # click on the checkbox in the first row of the "Predictoors" table dash_duo.find_element("#predictoors_table tbody tr input").click() + + +def test_timeframe_metrics(setup_app, dash_duo): + """ + Test the metrics that are displayed when a predictoor is selected. + It takes the predictoor row from the table and compares with the top metrics. + + The metrics are: Profit, Accuract, Stake + """ + + app = setup_app + start_server_and_wait(dash_duo, app) + + dash_duo.find_element("#predictoors_table tbody tr:nth-child(3) input").click() + time.sleep(2) + + dash_duo.find_element("#feeds_table tbody tr:nth-child(2) input").click() + time.sleep(2) + + table_profit = dash_duo.find_element( + "#predictoors_table tbody tr:nth-child(3) td:nth-child(3)" + ).text + metric_profit = dash_duo.find_element("#profit_metric").text + assert table_profit + " OCEAN" == metric_profit + + table_accuracy = dash_duo.find_element( + "#predictoors_table tbody tr:nth-child(3) td:nth-child(4)" + ).text + metric_accuracy = dash_duo.find_element("#accuracy_metric").text + assert table_accuracy + ".0%" == metric_accuracy + + table_stake = dash_duo.find_element( + "#predictoors_table tbody tr:nth-child(3) td:nth-child(5)" + ).text + metric_stake = dash_duo.find_element("#stake_metric").text + assert table_stake + " OCEAN" == metric_stake + + +def test_predictoors_feed_only_switch(setup_app, dash_duo): + """ + Test the switch that toggles between showing only the feeds that are + associated with the selected predictoor and all feeds. + """ + + app = setup_app + start_server_and_wait(dash_duo, app) + + dash_duo.find_element("#predictoors_table tbody tr:nth-child(3) input").click() + time.sleep(2) + + feeds_table_len = len(dash_duo.find_elements("#feeds_table tbody tr")) + assert feeds_table_len == 2 + + # Scroll the toggle switch into view and click using JavaScript + toggle_switch = dash_duo.find_element("#toggle-switch-predictoor-feeds") + dash_duo.driver.execute_script("arguments[0].scrollIntoView(true);", toggle_switch) + dash_duo.driver.execute_script("arguments[0].click();", toggle_switch) + time.sleep(2) + + feeds_table_len = len(dash_duo.find_elements("#feeds_table tbody tr")) + assert feeds_table_len == 6 diff --git a/pdr_backend/pdr_dashboard/test/test_plots.py b/pdr_backend/pdr_dashboard/test/test_plots.py index 4ab15174e..e4c800ef2 100644 --- a/pdr_backend/pdr_dashboard/test/test_plots.py +++ b/pdr_backend/pdr_dashboard/test/test_plots.py @@ -5,9 +5,11 @@ from pdr_backend.pdr_dashboard.dash_components.plots import ( process_payouts, create_figure, - get_figures, + get_figures_and_metrics, ) from pdr_backend.util.time_types import UnixTimeS +from pdr_backend.cli.arg_feed import ArgFeed +from pdr_backend.cli.arg_feeds import ArgFeeds @enforce_types @@ -19,16 +21,18 @@ def test_process_payouts( user = "0xeb18bad7365a40e36a41fb8734eb0b855d13b74f" feed = "0x18f54cc21b7a2fdd011bea06bba7801b280e3151" - result = process_payouts(payouts, user, feed) ## filter payouts by user and feed filtered_payouts = [p for p in payouts if user in p["ID"] and feed in p["ID"]] filtered_payouts = sorted(filtered_payouts, key=lambda x: x["slot"]) + result = process_payouts(filtered_payouts) - assert len(result) == 4 + assert len(result) == 6 - slots, accuracies, profits, stakes = result + slots, accuracies, profits, stakes, correct_predictions, predictions = result + assert correct_predictions == 0 + assert predictions == 2 assert len(slots) == len(filtered_payouts) assert slots[0] == UnixTimeS( filtered_payouts[0]["slot"] @@ -115,18 +119,25 @@ def test_create_figure(): @enforce_types @patch("plotly.graph_objects.Figure", new=MockFigure) -def test_get_figures( +def test_get_figures_and_metrics( _sample_payouts, ): ## convert List[Payout] to List[dict] payouts = [p.__dict__ for p in _sample_payouts] - sample_feeds = [ - {"contract": "0x18f54cc21b7a2fdd011bea06bba7801b280e3151", "feed_name": "Feed1"} - ] + sample_feeds = ArgFeeds( + [ + ArgFeed( + contract="b0x18f54cc21b7a2fdd011bea06bba7801b280e315", + pair="BTC/USDT", + exchange="binance", + timeframe="1h", + ), + ] + ) sample_predictoors = ["0xeb18bad7365a40e36a41fb8734eb0b855d13b74f"] - fig_accuracy, fig_profit, fig_costs = get_figures( - payouts, sample_feeds, sample_predictoors + fig_accuracy, fig_profit, fig_costs, avg_accuracy, total_profit, avg_stake = ( + get_figures_and_metrics(payouts, sample_feeds, sample_predictoors) ) # Check if figures are instances of MockFigure @@ -150,3 +161,12 @@ def test_get_figures( assert fig_accuracy.update_layout_called == 1 assert fig_profit.update_layout_called == 1 assert fig_costs.update_layout_called == 1 + + # Check metrics + assert avg_accuracy is not None + assert total_profit is not None + assert avg_stake is not None + + assert isinstance(avg_accuracy, float) + assert isinstance(total_profit, float) + assert isinstance(avg_stake, float) diff --git a/pdr_backend/pdr_dashboard/test/test_util.py b/pdr_backend/pdr_dashboard/test/test_util.py index 7a4665763..83ee78209 100644 --- a/pdr_backend/pdr_dashboard/test/test_util.py +++ b/pdr_backend/pdr_dashboard/test/test_util.py @@ -1,4 +1,5 @@ -from unittest.mock import patch +from unittest.mock import patch, Mock +import dash from enforce_typing import enforce_types from pdr_backend.pdr_dashboard.dash_components.util import ( @@ -6,6 +7,9 @@ get_feeds_data_from_db, get_predictoors_data_from_db, get_payouts_from_db, + select_or_clear_all_by_table, + get_user_payouts_stats_from_db, + get_predictoors_data_from_payouts, ) from pdr_backend.pdr_dashboard.test.resources import ( @@ -73,17 +77,129 @@ def test_get_payouts_from_db( tmpdir, _sample_payouts, table_name=Payout.get_lake_table_name() ) - result = get_payouts_from_db([], [], ppss.lake_ss.lake_dir) + result = get_payouts_from_db([], [], 1704152700, ppss.lake_ss.lake_dir) + assert len(result) == 0 + + result = get_payouts_from_db( + ["0x18f54cc21b7a2fdd011bea06bba7801b280e3151"], + ["0xeb18bad7365a40e36a41fb8734eb0b855d13b74f"], + 1704152700, + ppss.lake_ss.lake_dir, + ) + assert isinstance(result, list) + assert len(result) == 2 + # start date after all payouts should return an empty list + result = get_payouts_from_db( + ["0x18f54cc21b7a2fdd011bea06bba7801b280e3151"], + ["0xeb18bad7365a40e36a41fb8734eb0b855d13b74f"], + 1704154000, + ppss.lake_ss.lake_dir, + ) assert len(result) == 0 + # start date 0 should not filter on start date result = get_payouts_from_db( ["0x18f54cc21b7a2fdd011bea06bba7801b280e3151"], ["0xeb18bad7365a40e36a41fb8734eb0b855d13b74f"], + 0, ppss.lake_ss.lake_dir, ) + assert len(result) == 2 + + _clear_test_db(ppss.lake_ss.lake_dir) + + +def test_select_all(sample_table_rows): + # Mock the dash callback context + mock_ctx = Mock() + mock_ctx.triggered = [{"prop_id": "select-all-example-table.n_clicks"}] + + result = select_or_clear_all_by_table(mock_ctx, "example-table", sample_table_rows) + + assert result == list( + range(len(sample_table_rows)) + ), "The select all function did not return the expected indices." + + +def test_no_trigger(sample_table_rows): + # Mock the dash callback context with no trigger + mock_ctx = Mock() + mock_ctx.triggered = [] + + result = select_or_clear_all_by_table(mock_ctx, "example-table", sample_table_rows) + + assert ( + result == dash.no_update + ), "The function should return no_update when there is no trigger." + + +def test_unrelated_trigger(sample_table_rows): + # Mock the dash callback context with an unrelated trigger + mock_ctx = Mock() + mock_ctx.triggered = [{"prop_id": "some-other-button.n_clicks"}] + + result = select_or_clear_all_by_table(mock_ctx, "example-table", sample_table_rows) + + assert ( + result == [] + ), "The function should return an empty list for unrelated triggers." + + +def test_get_user_payouts_stats_from_db( + tmpdir, + _sample_payouts, +): + ppss, _ = _prepare_test_db( + tmpdir, _sample_payouts, table_name=Payout.get_lake_table_name() + ) + + result = get_user_payouts_stats_from_db(ppss.lake_ss.lake_dir) assert isinstance(result, list) - assert len(result) == 2 + assert len(result) == 5 + + test_row = [ + row + for row in result + if row["user"] == "0x02e9d2eede4c5347e55346860c8a8988117bde9e" + ][0] + + assert test_row["user"] == "0x02e9d2eede4c5347e55346860c8a8988117bde9e" + assert test_row["avg_accuracy"] == 100.0 + assert test_row["avg_stake"] == 1.9908170679122585 _clear_test_db(ppss.lake_ss.lake_dir) + + +def test_get_predictoors_data_from_payouts(): + user_payout_stats = [ + { + "user": "0x02e9d2eede4c5347e55346860c8a8988117bde9e", + "total_profit": 0.0, + "avg_accuracy": 100.0, + "avg_stake": 1.9908170679122585, + }, + { + "user": "0x18f54cc21b7a2fdd011bea06bba7801b280e3151", + "total_profit": 0.0, + "avg_accuracy": 100.0, + "avg_stake": 1.9908170679122585, + }, + ] + + result = get_predictoors_data_from_payouts(user_payout_stats) + + assert isinstance(result, list) + assert len(result) == 2 + + test_row = [ + row + for row in result + if row["user"] == "0x02e9d2eede4c5347e55346860c8a8988117bde9e" + ][0] + + assert test_row["user_address"] == "0x02e...bde9e" + assert test_row["total_profit"] == 0.0 + assert test_row["avg_accuracy"] == 100.0 + assert test_row["avg_stake"] == 1.99 diff --git a/pdr_backend/ppss/ppss.py b/pdr_backend/ppss/ppss.py index 54adc53d3..b628dc5f2 100644 --- a/pdr_backend/ppss/ppss.py +++ b/pdr_backend/ppss/ppss.py @@ -203,6 +203,7 @@ def mock_ppss( st_timestr: Optional[str] = "2023-06-18", fin_timestr: Optional[str] = "2023-06-21", pred_submitter_mgr: Optional[str] = None, + my_addresses: Optional[list] = None, ) -> PPSS: network = network or "development" yaml_str = fast_test_yaml_str(tmpdir) @@ -226,6 +227,7 @@ def mock_ppss( d = predictoor_ss_test_dict( feedset_list=feedset_list, pred_submitter_mgr=pred_submitter_mgr, + my_addresses=my_addresses, ) ppss.predictoor_ss = PredictoorSS(d) diff --git a/pdr_backend/ppss/predictoor_ss.py b/pdr_backend/ppss/predictoor_ss.py index 4b8b1a87e..a313c0f73 100644 --- a/pdr_backend/ppss/predictoor_ss.py +++ b/pdr_backend/ppss/predictoor_ss.py @@ -110,6 +110,10 @@ def payout_batch_size(self) -> int: def min_payout_slots(self) -> int: return self.d["bot_only"].get("min_payout_slots", 0) + @property + def my_addresses(self) -> List[str]: + return self.d.get("my_addresses", []) + # -------------------------------- # setters (add as needed) @enforce_types @@ -195,6 +199,7 @@ def predictoor_ss_test_dict( pred_submitter_mgr="", aimodel_data_ss_dict: Optional[dict] = None, aimodel_ss_dict: Optional[dict] = None, + my_addresses: Optional[List] = None, ) -> dict: """Use this function's return dict 'd' to construct PredictoorSS(d)""" feedset_list = feedset_list or feedset_test_list() @@ -214,5 +219,6 @@ def predictoor_ss_test_dict( }, "aimodel_data_ss": aimodel_data_ss_dict or aimodel_data_ss_test_dict(), "aimodel_ss": aimodel_ss_dict or aimodel_ss_test_dict(), + "my_addresses": my_addresses or [], } return d diff --git a/ppss.yaml b/ppss.yaml index b0c125c17..62a3f01cd 100644 --- a/ppss.yaml +++ b/ppss.yaml @@ -46,6 +46,8 @@ predictoor_ss: train_every_n_epochs: 1 calc_imps: True + my_addresses: [] + exchange_mgr_ss: # used by trader and sim timeout: 30000 ccxt_params: