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%}} + + +