diff --git a/pdr_backend/pdr_dashboard/assets/styles.css b/pdr_backend/pdr_dashboard/assets/styles.css index 53eadbf10..5bded9f9d 100644 --- a/pdr_backend/pdr_dashboard/assets/styles.css +++ b/pdr_backend/pdr_dashboard/assets/styles.css @@ -77,7 +77,7 @@ button{ gap: 10px; } -.table-title { +.table-title { display: flex; justify-content: space-between; align-items: center; @@ -97,3 +97,21 @@ button{ background-color: #dedede; border-radius: 3px; } + +.show-hide { + display: none; +} + +.tooltip-question-mark { + border-radius: 50%; + width: 14px; + height: 14px; + line-height: 14px; + font-size: 10px; + padding: 0; + text-align: center; + color: white; + background-color: #6c757d !important; + border: 0px none; + margin-left: 5px; +} \ No newline at end of file diff --git a/pdr_backend/pdr_dashboard/dash_components/app_constants.py b/pdr_backend/pdr_dashboard/dash_components/app_constants.py deleted file mode 100644 index 1f9e4f56e..000000000 --- a/pdr_backend/pdr_dashboard/dash_components/app_constants.py +++ /dev/null @@ -1,9 +0,0 @@ -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 7b197846b..b3342918e 100644 --- a/pdr_backend/pdr_dashboard/dash_components/callbacks.py +++ b/pdr_backend/pdr_dashboard/dash_components/callbacks.py @@ -1,13 +1,7 @@ -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, ) @@ -19,6 +13,7 @@ get_payouts_from_db, get_start_date_from_period, select_or_clear_all_by_table, + calculate_tx_gas_fee_cost_in_OCEAN, ) from pdr_backend.pdr_dashboard.dash_components.view_elements import ( get_graph, @@ -29,24 +24,14 @@ # pylint: disable=too-many-statements def get_callbacks(app): - @app.callback( - [ - Output("show-favourite-addresses", "value"), - Output("is-loading", "value"), - ], - Input("is-loading", "value"), - ) - # 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("accuracy_chart", "children"), Output("profit_chart", "children"), + Output("cost_chart", "children"), Output("stake_chart", "children"), Output("accuracy_metric", "children"), Output("profit_metric", "children"), + Output("costs_metric", "children"), Output("stake_metric", "children"), Output("available_data_period_text", "children"), [ @@ -90,13 +75,21 @@ def get_display_data_from_db( app.lake_dir, ) - # get figures - accuracy_fig, profit_fig, stakes_fig, avg_accuracy, total_profit, avg_stake = ( - get_figures_and_metrics( - payouts, - feeds, - predictoors_addrs, + # get fee estimate + fee_cost = ( + calculate_tx_gas_fee_cost_in_OCEAN( + app.web3_pp, feeds[0].contract, app.prices ) + if feeds + else 0.0 + ) + + # get figures + figs_metrics = get_figures_and_metrics( + payouts, + feeds, + predictoors_addrs, + fee_cost, ) # get available period date text @@ -110,50 +103,20 @@ def get_display_data_from_db( ) 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", + get_graph(figs_metrics.fig_accuracy), + get_graph(figs_metrics.fig_profit), + get_graph(figs_metrics.fig_costs), + get_graph(figs_metrics.fig_stakes), + f"{round(figs_metrics.avg_accuracy, 2)}%", + f"{round(figs_metrics.total_profit, 2)} OCEAN", + f"~{round(figs_metrics.total_cost, 2)} OCEAN", + f"{round(figs_metrics.avg_stake, 2)} OCEAN", date_period_text, ) - @app.callback( - Output("feeds_table", "columns"), - Output("feeds_table", "data"), - Input("is-loading", "value"), - ) - # pylint: disable=unused-argument - def create_feeds_table(is_loading): - if not app.feeds_data: - return dash.no_update - - data = copy.deepcopy(app.feeds_data) - for feed in data: - del feed["contract"] - - columns = [{"name": col, "id": col} for col in data[0].keys()] - return columns, data - - @app.callback( - Output("predictoors_table", "columns"), - Output("predictoors_table", "data"), - Input("is-loading", "value"), - ) - # 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 app.predictoors_data[0].keys()] - - return columns, app.predictoors_data - @app.callback( 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"), @@ -192,25 +155,18 @@ def update_predictoors_table_on_search( filtered_data = filter_objects_by_field( filtered_data, "user", search_value, selected_predictoors ) + else: + filtered_data = [p for p in filtered_data if p not in selected_predictoors] - selected_predictoor_indices = [ - i - for i, predictoor in enumerate(filtered_data) - if predictoor in selected_predictoors - ] + filtered_data = selected_predictoors + filtered_data + selected_predictoor_indices = list(range(len(selected_predictoors))) - return ( - filtered_data, - selected_predictoor_indices, - PREDICTOOR_TABLE_COLUMNS, - PREDICTOOR_TABLE_HIDDEN_COLUMNS, - ) + return (filtered_data, selected_predictoor_indices) @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"), @@ -222,7 +178,6 @@ def update_predictoors_table_on_search( ) # pylint: disable=unused-argument def update_feeds_table_on_search( - is_loading, search_value, selected_rows, feeds_table, @@ -236,14 +191,7 @@ def update_feeds_table_on_search( 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 - ) + filtered_data = app.feeds_data # filter feeds by payouts from selected predictoors if predictoor_feeds_only and (len(predictoors_addrs) > 0): @@ -252,18 +200,24 @@ def update_feeds_table_on_search( predictoors_addrs, ) filtered_data = [ - obj for obj in app.feeds_data if obj["contract"] in feed_ids + obj + for obj in filtered_data + if obj["contract"] in feed_ids + if obj not in selected_feeds ] - if ( - app.favourite_addresses - and "is-loading.value" in dash.callback_context.triggered_prop_ids - ): - return filtered_data, list(range(len(filtered_data))) + # filter feeds by pair address + filtered_data = ( + filter_objects_by_field( + app.feeds_data, "pair", search_value, selected_feeds + ) + if search_value + else filtered_data + ) - selected_feed_indices = [ - i for i, feed in enumerate(filtered_data) if feed in selected_feeds - ] + filtered_data = selected_feeds + filtered_data + + selected_feed_indices = list(range(len(selected_feeds))) return filtered_data, selected_feed_indices diff --git a/pdr_backend/pdr_dashboard/dash_components/plots.py b/pdr_backend/pdr_dashboard/dash_components/plots.py index ed67b8025..e5ae01e65 100644 --- a/pdr_backend/pdr_dashboard/dash_components/plots.py +++ b/pdr_backend/pdr_dashboard/dash_components/plots.py @@ -1,15 +1,167 @@ from itertools import product -from typing import List, Optional, Tuple, Union +from typing import List, Optional, Union, NamedTuple import plotly.graph_objects as go from enforce_typing import enforce_types +from statsmodels.stats.proportion import proportion_confint from pdr_backend.cli.arg_feeds import ArgFeeds from pdr_backend.util.time_types import UnixTimeS -@enforce_types -def process_payouts(payouts: List[dict]) -> tuple: +# pylint: disable=too-many-instance-attributes +class FiguresAndMetricsResult: + def __init__(self): + self.avg_accuracy = 0.0 + self.total_profit = 0.0 + self.avg_stake = 0.0 + self.total_cost = 0.0 + + self.accuracy_scatters = [] + self.fig_accuracy = None + + self.profit_scatters = [] + self.fig_profit = None + + self.stakes_scatters = [] + self.fig_stakes = None + + self.costs_scatters = [] + self.fig_costs = None + + def make_figures(self): + fig_config = { + "accuracy_scatters": { + "fallback": [go.Scatter(x=[], y=[], mode="lines", name="accuracy")], + "fig_attr": "fig_accuracy", + "args": { + "title": "Accuracy", + "yaxis_title": "Accuracy(%)", + "yaxis_range": [30, 70], + }, + }, + "profit_scatters": { + "fallback": [go.Scatter(x=[], y=[], mode="lines", name="profit")], + "fig_attr": "fig_profit", + "args": { + "title": "Profit", + "yaxis_title": "Profit(OCEAN)", + "show_legend": False, + }, + }, + "costs_scatters": { + "fallback": [go.Bar(x=[], y=[], name="costs")], + "fig_attr": "fig_costs", + "args": { + "title": "Costs", + "yaxis_title": "Fees(OCEAN)", + "show_legend": False, + "use_default_tick_format": True, + }, + }, + "stakes_scatters": { + "fallback": [go.Histogram(x=[], y=[], name="stakes")], + "fig_attr": "fig_stakes", + "args": { + "title": "Stakes", + "yaxis_title": "Stake(OCEAN)", + "show_legend": False, + }, + }, + } + + for key, value in fig_config.items(): + scatters = getattr(self, key) or value["fallback"] + + fig = create_figure( + scatters, + **value["args"], + ) + setattr(self, value["fig_attr"], fig) + + +class AccInterval(NamedTuple): + acc_l: float + acc_u: float + + +class ProcessedPayouts: + def __init__(self): + self.slot_in_unixts = [] + self.accuracies = [] + self.profits = [] + self.stakes = [] + self.correct_predictions = 0 + self.predictions = 0 + self.acc_intervals = [] + self.tx_cost = 0.0 + self.tx_costs = [] + + def as_accuracy_scatters_bounds(self, short_name, show_confidence_interval: bool): + scatters = [ + go.Scatter( + x=self.slot_in_unixts, + y=self.accuracies, + mode="lines", + name=short_name, + ) + ] + + if show_confidence_interval: + scatters = scatters + [ + go.Scatter( + x=self.slot_in_unixts, + y=[interval.acc_l * 100 for interval in self.acc_intervals], + mode="lines", + name="accuracy_lowerbound", + marker_color="#636EFA", + showlegend=False, + ), + go.Scatter( + x=self.slot_in_unixts, + y=[interval.acc_u * 100 for interval in self.acc_intervals], + mode="lines", + fill="tonexty", + name="accuracy_upperbound", + marker_color="#636EFA", + showlegend=False, + ), + ] + + return scatters + + def as_profit_scatters(self, short_name): + return [ + go.Scatter( + x=self.slot_in_unixts, + y=self.profits, + mode="lines", + name=short_name, + ) + ] + + def as_stakes_scatters(self, short_name): + return [ + go.Histogram( + x=self.slot_in_unixts, + y=self.stakes, + name=short_name, + ) + ] + + def as_costs_scatters(self, label, short_name): + return [ + go.Bar( + x=[label], + y=[self.tx_costs[-1]], + name=short_name, + ) + ] + + +def process_payouts( + payouts: List[dict], tx_fee_cost, calculate_confint: bool = False +) -> ProcessedPayouts: """ Process payouts data for a given predictor and feed. Args: @@ -17,34 +169,39 @@ def process_payouts(payouts: List[dict]) -> tuple: predictor (str): Predictor address. feed (str): Feed contract address. Returns: - tuple: Tuple of slots, accuracies, profits, and stakes. + tuple: Tuple of slots, accuracies, profits, stakes. """ - slots, accuracies, profits, stakes = [], [], [], [] - profit = predictions = correct_predictions = 0 + processed = ProcessedPayouts() + profit = 0.0 for p in payouts: - predictions += 1 - profit_change = max(p["payout"], 0) - p["stake"] + processed.predictions += 1 + processed.tx_cost += tx_fee_cost + profit_change = float(max(p["payout"], 0) - p["stake"]) profit += profit_change - correct_predictions += p["payout"] > 0 - - slots.append(p["slot"]) - 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, - correct_predictions, - predictions, - ) + processed.correct_predictions += p["payout"] > 0 + + if calculate_confint: + acc_l, acc_u = proportion_confint( + count=processed.correct_predictions, nobs=processed.predictions + ) + + processed.acc_intervals.append( + AccInterval( + acc_l, + acc_u, + ) + ) + + processed.slot_in_unixts.append(UnixTimeS(int(p["slot"])).to_milliseconds()) + processed.accuracies.append( + (processed.correct_predictions / processed.predictions) * 100 + ) + processed.profits.append(profit) + processed.stakes.append(p["stake"]) + processed.tx_costs.append(processed.tx_cost) + + return processed @enforce_types @@ -53,6 +210,8 @@ def create_figure( title: str, yaxis_title: str, show_legend: bool = True, + yaxis_range: Union[List, None] = None, + use_default_tick_format: bool = False, ): """ Create a figure with the given data traces. @@ -61,6 +220,8 @@ def create_figure( title (str): Figure title. yaxis_title (str): Y-axis title. show_legend (bool): Show legend. Default is True. + yaxis_range (list, optional): Custom range for the y-axis. + xaxis_tickformat (str, optional): Custom format string for x-axis ticks. Returns: go.Figure: Plotly figure. """ @@ -79,116 +240,101 @@ def create_figure( fig.update_layout( title=title, - yaxis_title=yaxis_title, margin={"l": 20, "r": 0, "t": 50, "b": 0}, showlegend=show_legend, - xaxis_nticks=4, - bargap=0.1, - barmode="stack", legend=legend_config, + barmode="stack", + yaxis={"range": yaxis_range if yaxis_range else None, "title": yaxis_title}, + xaxis=( + { + "type": "date", + "nticks": 5, + "tickformat": "%m-%d %H:%M", + } + if not use_default_tick_format + else {"nticks": 4} + ), ) 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_and_metrics( - payouts: Optional[List], feeds: ArgFeeds, predictoors: List[str] -) -> Tuple[go.Figure, go.Figure, go.Figure, float | None, float | None, float | None]: + payouts: Optional[List], feeds: ArgFeeds, predictors: List[str], fee_cost: float +) -> FiguresAndMetricsResult: """ Get figures for accuracy, profit, and costs. Args: payouts (list): List of payouts data. feeds (list): List of feeds data. - predictoors (list): List of predictoors data. + predictors (list): List of predictors data. Returns: - tuple: Tuple of accuracy, profit, and costs figures, avg accuracy, total profit, avg stake + FiguresAndMetricsResult: 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 + figs_metrics = FiguresAndMetricsResult() + fee_cost = 2 * fee_cost - accuracy_scatters, profit_scatters, stakes_scatters = [], [], [] - avg_accuracy, total_profit, avg_stake = 0.0, 0.0, 0.0 + if not payouts: + figs_metrics.make_figures() + return figs_metrics 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 + prediction_count = 0 + + for predictor, feed in product(predictors, feeds): 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 not slots: + if not filtered_payouts: continue - all_stakes.extend(stakes) - prediction_count += predictions - correct_prediction_count += correct_predictions + show_confidence_interval = len(predictors) == 1 and len(feeds) == 1 - total_profit = (profits[-1] + total_profit) if total_profit else profits[-1] + processed_data = process_payouts( + payouts=filtered_payouts, + tx_fee_cost=fee_cost, + calculate_confint=show_confidence_interval, + ) - short_name = f"{predictor[:5]} - {str(feed)}" - accuracy_scatters.append( - go.Scatter(x=slots, y=accuracies, mode="lines", name=short_name) + all_stakes.extend(processed_data.stakes) + correct_prediction_count += processed_data.correct_predictions + prediction_count += processed_data.predictions + figs_metrics.total_profit += ( + processed_data.profits[-1] if processed_data.profits else 0.0 ) - profit_scatters.append( - go.Scatter(x=slots, y=profits, mode="lines", name=short_name) + figs_metrics.total_cost += ( + processed_data.tx_costs[-1] if processed_data.tx_costs else 0.0 ) - 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 - ) + short_name = f"{predictor[:5]} - {str(feed)}" - if not accuracy_scatters: - accuracy_scatters = _empty_accuracy_scatter() + figs_metrics.accuracy_scatters.extend( + processed_data.as_accuracy_scatters_bounds( + short_name, show_confidence_interval + ) + ) - if not profit_scatters: - profit_scatters = _empty_profit_scatter() + figs_metrics.profit_scatters.extend( + processed_data.as_profit_scatters(short_name) + ) - if not stakes_scatters: - stakes_scatters = _empty_stakes_bar() + figs_metrics.stakes_scatters.extend( + processed_data.as_stakes_scatters(short_name) + ) + + figs_metrics.costs_scatters.extend( + processed_data.as_costs_scatters( + f"{feed.pair.base_str}-{feed.timeframe}-{predictor[:4]}", short_name + ) + ) + + figs_metrics.avg_stake = sum(all_stakes) / len(all_stakes) if all_stakes else 0.0 + figs_metrics.avg_accuracy = ( + (correct_prediction_count / prediction_count) * 100 if prediction_count else 0.0 + ) + figs_metrics.make_figures() - figures = _make_figures((accuracy_scatters, profit_scatters, stakes_scatters)) - return (figures[0], figures[1], figures[2], avg_accuracy, total_profit, avg_stake) + return figs_metrics diff --git a/pdr_backend/pdr_dashboard/dash_components/util.py b/pdr_backend/pdr_dashboard/dash_components/util.py index 2b043655e..bde20f587 100644 --- a/pdr_backend/pdr_dashboard/dash_components/util.py +++ b/pdr_backend/pdr_dashboard/dash_components/util.py @@ -4,10 +4,12 @@ from typing import Union, List, Dict, Any, Optional from enforce_typing import enforce_types import dash +from web3 import Web3 from pdr_backend.lake.duckdb_data_store import DuckDBDataStore from pdr_backend.lake.payout import Payout from pdr_backend.lake.prediction import Prediction +from pdr_backend.util.currency_types import Eth, Wei logger = logging.getLogger("predictoor_dashboard_utils") @@ -59,15 +61,15 @@ def get_user_payouts_stats_from_db(lake_dir: str): return _query_db( lake_dir, f""" - SELECT + 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 + FROM {Payout.get_lake_table_name()} - GROUP BY - "user" + GROUP BY + "user" """, ) @@ -137,7 +139,7 @@ def filter_objects_by_field( return [ obj for obj in objects - if search_string.lower() in obj[field].lower() or obj in previous_objects + if search_string.lower() in obj[field].lower() and obj not in previous_objects ] @@ -207,3 +209,37 @@ def get_date_period_text(payouts: List): - {datetime.fromtimestamp(end_date).strftime('%d-%m-%Y')} """ return date_period_text + + +def calculate_tx_gas_fee_cost_in_OCEAN(web3_pp, feed_contract_addr, prices): + if not prices: + return 0.0 + + web3 = Web3(Web3.HTTPProvider(web3_pp.rpc_url)) + + # generic params + predicted_value = True + stake_amt_wei = Eth(10).to_wei().amt_wei + prediction_ts = Eth(1721723066).to_wei().amt_wei + + # gas price + gas_price = web3.eth.gas_price + + # gas amount + contract = web3.eth.contract( + address=web3.to_checksum_address(feed_contract_addr), + abi=web3_pp.get_contract_abi("ERC20Template3"), + ) + gas_estimate_prediction = contract.functions["submitPredval"]( + predicted_value, stake_amt_wei, prediction_ts + ).estimate_gas({"from": "0xe2DD09d719Da89e5a3D0F2549c7E24566e947260"}) + + # cals tx fee cost + tx_fee_rose_prediction = ( + Wei(gas_estimate_prediction * gas_price).to_eth().amt_eth / 10 + ) + + tx_fee_price_usdt_prediction = tx_fee_rose_prediction * prices["ROSE"] + tx_fee_price_ocean_prediction = tx_fee_price_usdt_prediction / prices["OCEAN"] + + return tx_fee_price_ocean_prediction diff --git a/pdr_backend/pdr_dashboard/dash_components/view_elements.py b/pdr_backend/pdr_dashboard/dash_components/view_elements.py index 9ea69aa9b..70386965c 100644 --- a/pdr_backend/pdr_dashboard/dash_components/view_elements.py +++ b/pdr_backend/pdr_dashboard/dash_components/view_elements.py @@ -1,8 +1,134 @@ import dash_bootstrap_components as dbc from dash import dash_table, dcc, html +from pdr_backend.pdr_dashboard.dash_components.util import ( + get_feed_ids_based_on_predictoors_from_db, +) -def get_input_column(): +def col_to_human(col): + col = col.replace("avg_", "") + col = col.replace("total_", "") + + return col.replace("_", " ").title() + + +# pylint: disable=too-many-return-statements +def get_information_text(tooltip_id: str): + match tooltip_id: + case "tooltip-accuracy_metric": + return """Average accuracy of predictions + within the selected timeframe and for the selected predictoors and feeds.""" + case "tooltip-profit_metric": + return """Total profit generated from predictions + within the selected timeframe and for the selected predictoors and feeds.""" + case "tooltip-costs_metric": + return """Transaction fee costs for predicting and claiming payouts + for each slot individually within the selected timeframe + and for the selected predictoors and feeds.""" + case "tooltip-stake_metric": + return """Average stake placed on each prediction + within the selected timeframe and for the selected predictoors and feeds.""" + case "tooltip-switch-predictoors": + return """Toggle this switch to automatically select predictoors + that are pre-configured in the ppss.yaml settings.""" + case "tooltip-switch-feeds": + return """Toggle this switch to view only the feeds associated with + the selected predictoors.""" + case _: + return "" + + +def get_tooltip_and_button(value_id: str): + return html.Span( + [ + dbc.Button( + "?", id=f"tooltip-target-{value_id}", className="tooltip-question-mark" + ), + dbc.Tooltip( + get_information_text(f"tooltip-{value_id}"), + target=f"tooltip-target-{value_id}", + placement="right", + ), + ] + ) + + +def get_feeds_switch(): + return html.Div( + [ + dbc.Switch( + id="toggle-switch-predictoor-feeds", + label="Predictoor feeds only", + value=True, + ), + get_tooltip_and_button("switch-feeds"), + ], + style={"display": "flex"}, + ) + + +def get_predictoors_switch(selected_items): + return html.Div( + [ + dbc.Switch( + id="show-favourite-addresses", + label="Select configured predictoors", + value=bool(selected_items), + ), + get_tooltip_and_button("switch-predictoors"), + ], + style={"display": "flex"}, + ) + + +def get_feeds_data(app): + data = app.feeds_data + + columns = [{"name": col_to_human(col), "id": col} for col in data[0].keys()] + hidden_columns = ["contract"] + + return (columns, hidden_columns), data + + +def get_predictoors_data(app): + columns = [ + {"name": col_to_human(col), "id": col} for col in app.predictoors_data[0].keys() + ] + hidden_columns = ["user"] + + if app.favourite_addresses: + data = [ + p for p in app.predictoors_data if p["user"] in app.favourite_addresses + ] + [ + p for p in app.predictoors_data if p["user"] not in app.favourite_addresses + ] + else: + data = app.predictoors_data + + return (columns, hidden_columns), data + + +def get_input_column(app): + feed_cols, feed_data = get_feeds_data(app) + predictoor_cols, predictoor_data = get_predictoors_data(app) + + selected_predictoors = list(range(len(app.favourite_addresses))) + + if app.favourite_addresses: + feed_ids = get_feed_ids_based_on_predictoors_from_db( + app.lake_dir, + app.favourite_addresses, + ) + + if feed_ids: + feed_data = [ + feed for feed in app.feeds_data if feed["contract"] in feed_ids + ] + + selected_feeds = list(range(len(feed_ids))) + else: + selected_feeds = [] + return html.Div( [ html.Div( @@ -10,11 +136,10 @@ def get_input_column(): table_id="predictoors_table", table_name="Predictoors", searchable_field="user", - columns=[], - data=None, - default_sorting=[ - {"column_id": "total_profit", "direction": "desc"} - ], + columns=predictoor_cols, + selected_items=selected_predictoors, + data=predictoor_data, + length=len(app.predictoors_data), ), id="predictoors_container", ), @@ -23,13 +148,14 @@ def get_input_column(): table_id="feeds_table", table_name="Feeds", searchable_field="pair", - columns=[], - data=None, - default_sorting=[], + columns=feed_cols, + data=feed_data, + selected_items=selected_feeds, + length=len(app.feeds_data), ), id="feeds_container", style={ - "height": "50%", + "marginTop": "20px", "display": "flex", "flexDirection": "column", "justifyContent": "flex-end", @@ -64,9 +190,10 @@ def get_graphs_column(): 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_metric(label="Avg Accuracy", value="0%", value_id="accuracy_metric"), + get_metric(label="Pred Profit", value="0 OCEAN", value_id="profit_metric"), + get_metric(label="Tx Costs", value="0 OCEAN", value_id="costs_metric"), + get_metric(label="Avg Stake", value="0 OCEAN", value_id="stake_metric"), get_date_period_selection_component(), ], id="metrics_container", @@ -102,7 +229,17 @@ def get_graphs_column_plots_row(): [ html.Div(id="accuracy_chart"), html.Div(id="profit_chart"), - html.Div(id="stake_chart"), + html.Div( + [ + html.Div(id="cost_chart", style={"width": "48%"}), + html.Div(id="stake_chart", style={"width": "48%"}), + ], + style={ + "width": "100%", + "display": "flex", + "justifyContent": "space-between", + }, + ), ], id="plots_container", style={ @@ -118,7 +255,15 @@ def get_metric(label, value, value_id): return html.Div( [ html.Span( - label, + [ + label, + get_tooltip_and_button(value_id), + ], + style={ + "display": "flex", + "justifyContent": "center", + "alignItems": "center", + }, ), html.Span(value, id=value_id, style={"fontWeight": "bold"}), ], @@ -126,7 +271,7 @@ def get_metric(label, value, value_id): ) -def get_layout(): +def get_layout(app): return html.Div( [ dcc.Store(id="user-payout-stats"), @@ -137,40 +282,60 @@ def get_layout(): dcc.Loading( id="loading", type="default", - children=get_main_container(), + children=get_main_container(app), 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(): +def get_main_container(app): return html.Div( - [get_input_column(), get_graphs_column()], + [get_input_column(app), get_graphs_column()], className="main-container", ) -def get_table(table_id, table_name, searchable_field, columns, data, default_sorting): +def get_table( + table_id, + table_name, + searchable_field, + columns, + data, + selected_items=None, + length=0, +): return html.Div( [ html.Div( [ - html.Span(table_name, style={"fontSize": "20px"}), + html.Div( + [ + html.Span( + table_name, style={"fontSize": "20px", "height": "100%"} + ), + html.Span( + id=f"table-rows-count-{table_id}", + children=f"({length})", + style={ + "fontSize": "16px", + "color": "gray", + "hight": "100%", + "marginLeft": "4px", + }, + ), + ], + style={ + "display": "flex", + "justifyContet": "center", + "alignItems": "center", + }, + ), ( - dbc.Switch( - id="toggle-switch-predictoor-feeds", - label="Predictoor feeds only", - value=True, - ) + get_feeds_switch() if table_name == "Feeds" - else dbc.Switch( - id="show-favourite-addresses", - label="Select configured predictoors", - value=True, - ) + else get_predictoors_switch(selected_items=selected_items) ), ], className="table-title", @@ -206,11 +371,11 @@ def get_table(table_id, table_name, searchable_field, columns, data, default_sor ), dash_table.DataTable( id=table_id, - columns=[{"name": col, "id": col, "sortable": True} for col in columns], - sort_by=default_sorting, + columns=columns[0], + hidden_columns=columns[1], data=data, row_selectable="multi", # Can be 'multi' for multiple rows - selected_rows=[], + selected_rows=selected_items if selected_items else [], sort_action="native", # Enables data to be sorted style_cell={"textAlign": "left"}, style_table={ diff --git a/pdr_backend/pdr_dashboard/predictoor_dash.py b/pdr_backend/pdr_dashboard/predictoor_dash.py index 0f1ffae08..990f987c6 100644 --- a/pdr_backend/pdr_dashboard/predictoor_dash.py +++ b/pdr_backend/pdr_dashboard/predictoor_dash.py @@ -1,8 +1,11 @@ import webbrowser +from datetime import datetime import dash_bootstrap_components as dbc from dash import Dash from enforce_typing import enforce_types +from pdr_backend.exchange.fetch_ohlcv import fetch_ohlcv +from pdr_backend.util.time_types import UnixTimeMs from pdr_backend.pdr_dashboard.dash_components.callbacks import ( get_callbacks, @@ -26,7 +29,6 @@ def predictoor_dash(ppss: PPSS, debug_mode: bool): app = Dash(__name__, external_stylesheets=[dbc.themes.BOOTSTRAP]) app.config["suppress_callback_exceptions"] = True - app.layout = get_layout() try: setup_app(app, ppss) @@ -43,11 +45,27 @@ def predictoor_dash(ppss: PPSS, debug_mode: bool): @enforce_types def setup_app(app, ppss: PPSS): + app.web3_pp = ppss.web3_pp 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 + app.layout = get_layout(app) + + # fetch token prices + current_date_ms = UnixTimeMs(int(datetime.now().timestamp()) * 1000 - 300000) + rose_usdt = fetch_ohlcv("binance", "ROSE/USDT", "5m", current_date_ms, 1) + fet_usdt = fetch_ohlcv("binance", "FET/USDT", "5m", current_date_ms, 1) + if rose_usdt and fet_usdt: + app.prices = {"ROSE": rose_usdt[0][1], "OCEAN": fet_usdt[0][1] * 0.433226} + else: + rose_usdt = fetch_ohlcv("binanceus", "ROSE/USDT", "5m", current_date_ms, 1) + fet_usdt = fetch_ohlcv("binanceus", "FET/USDT", "5m", current_date_ms, 1) + if rose_usdt and fet_usdt: + app.prices = {"ROSE": rose_usdt[0][1], "OCEAN": fet_usdt[0][1] * 0.433226} + else: + app.prices = None return app diff --git a/pdr_backend/pdr_dashboard/test/conftest.py b/pdr_backend/pdr_dashboard/test/conftest.py index adfcd7432..93a5f17a1 100644 --- a/pdr_backend/pdr_dashboard/test/conftest.py +++ b/pdr_backend/pdr_dashboard/test/conftest.py @@ -10,9 +10,6 @@ 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, @@ -118,8 +115,6 @@ def setup_app( app.config["suppress_callback_exceptions"] = True app = _add_css(app) - app.layout = get_layout() - setup_app_main(app, ppss) get_callbacks(app) @@ -149,7 +144,6 @@ def setup_app_with_favourite_addresses( app.config["suppress_callback_exceptions"] = True app = _add_css(app) - app.layout = get_layout() setup_app_main(app, ppss) get_callbacks(app) diff --git a/pdr_backend/pdr_dashboard/test/test_callbacks.py b/pdr_backend/pdr_dashboard/test/test_callbacks.py index 2d5fcf2c3..4bb6472a4 100644 --- a/pdr_backend/pdr_dashboard/test/test_callbacks.py +++ b/pdr_backend/pdr_dashboard/test/test_callbacks.py @@ -57,7 +57,7 @@ def _predictoor_count(dash_duo): return predictoors_rows, predictoors_selected_rows -def test_favorite_addresses_search_input(setup_app_with_favourite_addresses, dash_duo): +def test_favourite_addresses_search_input(setup_app_with_favourite_addresses, dash_duo): app = setup_app_with_favourite_addresses start_server_and_wait(dash_duo, app) @@ -77,7 +77,7 @@ def test_favorite_addresses_search_input(setup_app_with_favourite_addresses, das all_feeds_toggle.click() time.sleep(2) f_all, f_sel = _feed_count(dash_duo) - assert len(f_all) == 5 + assert len(f_all) == 6 assert len(f_sel) == 1 # click on fav addr toggle to show all predictoors @@ -121,19 +121,19 @@ def test_timeframe_metrics(setup_app, dash_duo): time.sleep(2) table_profit = dash_duo.find_element( - "#predictoors_table tbody tr:nth-child(3) td:nth-child(3)" + "#predictoors_table tbody tr:nth-child(2) 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)" + "#predictoors_table tbody tr:nth-child(2) 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)" + "#predictoors_table tbody tr:nth-child(2) td:nth-child(5)" ).text metric_stake = dash_duo.find_element("#stake_metric").text assert table_stake + " OCEAN" == metric_stake diff --git a/pdr_backend/pdr_dashboard/test/test_plots.py b/pdr_backend/pdr_dashboard/test/test_plots.py index e4c800ef2..a38dfb562 100644 --- a/pdr_backend/pdr_dashboard/test/test_plots.py +++ b/pdr_backend/pdr_dashboard/test/test_plots.py @@ -25,18 +25,26 @@ def test_process_payouts( ## 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) + tx_fee_cost = 0.2 - assert len(result) == 6 + result = process_payouts( + payouts=filtered_payouts, tx_fee_cost=tx_fee_cost, calculate_confint=True + ) - slots, accuracies, profits, stakes, correct_predictions, predictions = result + slots = result.slot_in_unixts + accuracies = result.accuracies + profits = result.profits + stakes = result.stakes + correct_predictions = result.correct_predictions + predictions = result.predictions + acc_intervals = result.acc_intervals + costs = result.tx_cost assert correct_predictions == 0 + assert costs > 0 assert predictions == 2 assert len(slots) == len(filtered_payouts) - assert slots[0] == UnixTimeS( - filtered_payouts[0]["slot"] - ).to_milliseconds().to_dt().strftime("%m-%d %H:%M") + assert slots[0] == UnixTimeS(filtered_payouts[0]["slot"]).to_milliseconds() ## calculate accuracies test_accuracies = [ @@ -72,6 +80,12 @@ def test_process_payouts( for i, stake in enumerate(stakes): assert stake == test_stakes[i] + assert len(acc_intervals) == len(test_stakes) + + for i, acc_interval in enumerate(acc_intervals): + assert isinstance(acc_interval.acc_l, float) + assert isinstance(acc_interval.acc_u, float) + class MockFigure: def __init__(self, data_traces): @@ -98,13 +112,10 @@ def test_create_figure(): assert isinstance(result, MockFigure) assert result.data_traces == [] assert result.layout == { - "bargap": 0.1, "barmode": "stack", "title": "title", - "yaxis_title": "yaxis_title", "margin": {"l": 20, "r": 0, "t": 50, "b": 0}, "showlegend": True, - "xaxis_nticks": 4, "legend": { "orientation": "h", "yanchor": "bottom", @@ -112,6 +123,12 @@ def test_create_figure(): "xanchor": "right", "x": 1, }, + "yaxis": {"range": None, "title": "yaxis_title"}, + "xaxis": { + "nticks": 5, + "tickformat": "%m-%d %H:%M", + "type": "date", + }, } assert result.update_layout_called == 1 @@ -135,38 +152,56 @@ def test_get_figures_and_metrics( ] ) sample_predictoors = ["0xeb18bad7365a40e36a41fb8734eb0b855d13b74f"] + fee_cost = 0.2 - fig_accuracy, fig_profit, fig_costs, avg_accuracy, total_profit, avg_stake = ( - get_figures_and_metrics(payouts, sample_feeds, sample_predictoors) + figs_metrics = get_figures_and_metrics( + payouts, sample_feeds, sample_predictoors, fee_cost ) + fig_accuracy = figs_metrics.fig_accuracy + fig_profit = figs_metrics.fig_profit + fig_costs = figs_metrics.fig_costs + fig_stakes = figs_metrics.fig_stakes + # Check if figures are instances of MockFigure assert isinstance(fig_accuracy, MockFigure) assert isinstance(fig_profit, MockFigure) assert isinstance(fig_costs, MockFigure) + assert isinstance(fig_stakes, MockFigure) # Check if the figures have the correct layout and data traces assert len(fig_accuracy.data_traces) == 1 assert len(fig_profit.data_traces) == 1 assert len(fig_costs.data_traces) == 1 + assert len(fig_stakes.data_traces) == 1 assert fig_accuracy.layout["title"] == "Accuracy" assert fig_profit.layout["title"] == "Profit" assert fig_costs.layout["title"] == "Costs" + assert fig_stakes.layout["title"] == "Stakes" - assert fig_accuracy.layout["yaxis_title"] == "'%' accuracy over time" - assert fig_profit.layout["yaxis_title"] == "OCEAN profit over time" - assert fig_costs.layout["yaxis_title"] == "Stake (OCEAN) at a time" + assert fig_accuracy.layout["yaxis"]["title"] == "Accuracy(%)" + assert fig_profit.layout["yaxis"]["title"] == "Profit(OCEAN)" + assert fig_costs.layout["yaxis"]["title"] == "Fees(OCEAN)" + assert fig_stakes.layout["yaxis"]["title"] == "Stake(OCEAN)" assert fig_accuracy.update_layout_called == 1 assert fig_profit.update_layout_called == 1 assert fig_costs.update_layout_called == 1 + assert fig_stakes.update_layout_called == 1 # Check metrics + avg_accuracy = figs_metrics.avg_accuracy + total_profit = figs_metrics.total_profit + total_cost = figs_metrics.total_cost + avg_stake = figs_metrics.avg_stake + assert avg_accuracy is not None assert total_profit is not None + assert total_cost is not None assert avg_stake is not None assert isinstance(avg_accuracy, float) assert isinstance(total_profit, float) + assert isinstance(total_cost, 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 83ee78209..2b667200b 100644 --- a/pdr_backend/pdr_dashboard/test/test_util.py +++ b/pdr_backend/pdr_dashboard/test/test_util.py @@ -1,4 +1,4 @@ -from unittest.mock import patch, Mock +from unittest.mock import patch, Mock, MagicMock import dash from enforce_typing import enforce_types @@ -10,6 +10,7 @@ select_or_clear_all_by_table, get_user_payouts_stats_from_db, get_predictoors_data_from_payouts, + calculate_tx_gas_fee_cost_in_OCEAN, ) from pdr_backend.pdr_dashboard.test.resources import ( @@ -203,3 +204,43 @@ def test_get_predictoors_data_from_payouts(): assert test_row["total_profit"] == 0.0 assert test_row["avg_accuracy"] == 100.0 assert test_row["avg_stake"] == 1.99 + + +def test_calculate_tx_gas_fee_cost_in_OCEAN(setup_app): + app = setup_app + assert app.prices is not None + assert app.prices["ROSE"] > 0 + assert app.prices["OCEAN"] > 0 + + # Example feed_contract_addr + feed_contract_addr = "0x1234567890abcdef1234567890abcdef12345678" + + # Example web3_pp mock + web3_pp = MagicMock() + web3_pp.rpc_url = "https://testnet.sapphire.oasis.dev" + web3_pp.get_contract_abi.return_value = [ + { + "constant": False, + "inputs": [ + {"name": "predicted_value", "type": "bool"}, + {"name": "stake_amt_wei", "type": "uint256"}, + {"name": "prediction_ts", "type": "uint256"}, + ], + "name": "submitPredval", + "outputs": [], + "payable": False, + "stateMutability": "nonpayable", + "type": "function", + } + ] + + # If no prices, should return 0 + result = calculate_tx_gas_fee_cost_in_OCEAN(web3_pp, feed_contract_addr, None) + assert isinstance(result, float) + assert result == 0.0 + + # If prices, should return a value > 0 + result = calculate_tx_gas_fee_cost_in_OCEAN(web3_pp, feed_contract_addr, app.prices) + assert isinstance(result, float) + assert result > 0 + assert result < 0.001 diff --git a/pdr_backend/sim/test/conftest.py b/pdr_backend/sim/test/conftest.py index 90996835e..0f19f8a1c 100644 --- a/pdr_backend/sim/test/conftest.py +++ b/pdr_backend/sim/test/conftest.py @@ -16,6 +16,7 @@ def pytest_setup_options(): options = Options() options.add_argument("--headless") options.add_argument("--disable-gpu") + return options diff --git a/pdr_backend/statutil/test/conftest.py b/pdr_backend/statutil/test/conftest.py index 3d286d6e7..86e5fe6ec 100644 --- a/pdr_backend/statutil/test/conftest.py +++ b/pdr_backend/statutil/test/conftest.py @@ -12,4 +12,5 @@ def pytest_setup_options(): options = Options() options.add_argument("--headless") options.add_argument("--disable-gpu") + return options