diff --git a/indexer/modules/custom/address_index/endpoint/routes.py b/indexer/modules/custom/address_index/endpoint/routes.py index b3be9d03c..35019746a 100644 --- a/indexer/modules/custom/address_index/endpoint/routes.py +++ b/indexer/modules/custom/address_index/endpoint/routes.py @@ -17,8 +17,11 @@ get_address_assets, get_address_base_info, get_address_developer_info, + get_all_udf_dashboards, + get_all_udf_dashboards_data, get_contract_deployed_events, get_contract_deployer_profile, + get_daily_active_address, get_wallet_address_volumes, ) from indexer.modules.custom.address_index.utils.score import calculate_aci_score @@ -128,3 +131,25 @@ class ACIVolumes(Resource): @cache.cached(timeout=360, query_string=True) def get(self, address): address_bytes = bytes.fromhex(address[2:]) + + +@address_profile_namespace.route("/v1/aci/udf_dashboards") +class UDFDashboards(Resource): + # @cache.cached(timeout=360, query_string=True) + def get(self): + return get_all_udf_dashboards() + + +@address_profile_namespace.route("/v1/aci/udf_dashboards_data") +class UDFDashboards(Resource): + @cache.cached(timeout=10, query_string=True) + def get(self): + return get_all_udf_dashboards_data() + + +@address_profile_namespace.route("/v1/aci/daily_active_address") +class DailyAddress(Resource): + @cache.cached(timeout=10, query_string=True) + def get(self): + time_range = flask.request.args.get("time_range", "7d") + return get_daily_active_address(time_range) diff --git a/indexer/modules/custom/address_index/models/dashboard_daily_address.py b/indexer/modules/custom/address_index/models/dashboard_daily_address.py new file mode 100644 index 000000000..7f2c955ed --- /dev/null +++ b/indexer/modules/custom/address_index/models/dashboard_daily_address.py @@ -0,0 +1,14 @@ +from sqlalchemy import Column, Date, PrimaryKeyConstraint +from sqlalchemy.dialects.postgresql import INTEGER + +from common.models import HemeraModel + + +class AFDashboardDailyAddressStats(HemeraModel): + __tablename__ = "af_dashboard_daily_address_stats" + + block_date = Column(Date, nullable=False, primary_key=True) + active_addresses = Column(INTEGER, nullable=False) + new_addresses = Column(INTEGER) + + __table_args__ = (PrimaryKeyConstraint("block_date"),) diff --git a/indexer/modules/custom/address_index/models/distribution_daily_stats.py b/indexer/modules/custom/address_index/models/distribution_daily_stats.py new file mode 100644 index 000000000..fddb94a8b --- /dev/null +++ b/indexer/modules/custom/address_index/models/distribution_daily_stats.py @@ -0,0 +1,16 @@ +from sqlalchemy import VARCHAR, Column, Date, Double, Numeric, PrimaryKeyConstraint + +from common.models import HemeraModel + + +class AFDistributionDailyStats(HemeraModel): + __tablename__ = "af_distribution_daily_stats" + + distribution_name = Column(VARCHAR, nullable=False) + block_date = Column(Date, nullable=False) + x = Column(Numeric, nullable=False) + value = Column(Numeric) + percentage = Column(Double) + total_value = Column(Numeric) + + __table_args__ = (PrimaryKeyConstraint("distribution_name", "block_date", "x"),) diff --git a/indexer/modules/custom/address_index/models/metrics_distribution_daily_stats.py b/indexer/modules/custom/address_index/models/metrics_distribution_daily_stats.py new file mode 100644 index 000000000..42f4171f0 --- /dev/null +++ b/indexer/modules/custom/address_index/models/metrics_distribution_daily_stats.py @@ -0,0 +1,14 @@ +from sqlalchemy import VARCHAR, Column, Date, Double, Numeric, PrimaryKeyConstraint + +from common.models import HemeraModel + + +class AFMetricsDistributionDailyStats(HemeraModel): + __tablename__ = "af_metrics_distribution_daily_stats" + + distribution_name = Column(VARCHAR, nullable=False) + block_date = Column(Date, nullable=False) + avg = Column(Numeric) + stdev = Column(Numeric) + + __table_args__ = (PrimaryKeyConstraint("distribution_name", "block_date"),) diff --git a/indexer/modules/custom/address_index/utils/helpers.py b/indexer/modules/custom/address_index/utils/helpers.py index 9816ac8c9..491b572a1 100644 --- a/indexer/modules/custom/address_index/utils/helpers.py +++ b/indexer/modules/custom/address_index/utils/helpers.py @@ -1,10 +1,10 @@ -import binascii import copy +import math +import re from datetime import date, datetime, timedelta -from select import select -from typing import Any, Dict, List, Optional, Tuple, Union +from typing import Any, Dict, List, Optional, Union -from sqlalchemy import and_, func +from sqlalchemy import and_, distinct, func, or_ from api.app.address.features import register_feature from api.app.address.models import AddressBaseProfile @@ -12,7 +12,7 @@ from api.app.db_service.contracts import get_contracts_by_addresses from api.app.db_service.wallet_addresses import get_token_txn_cnt_by_address from api.app.utils.fill_info import fill_address_display_to_transactions, fill_is_contract_to_transactions -from api.app.utils.format_utils import format_coin_value, format_transaction +from api.app.utils.format_utils import format_coin_value from api.app.utils.token_utils import get_coin_prices, get_latest_coin_prices, get_token_price from api.app.utils.web3_utils import get_balance from common.models import db @@ -36,6 +36,9 @@ from indexer.modules.custom.address_index.models.address_token_holders import AddressTokenHolders from indexer.modules.custom.address_index.models.address_token_transfers import AddressTokenTransfers from indexer.modules.custom.address_index.models.address_transactions import AddressTransactions +from indexer.modules.custom.address_index.models.dashboard_daily_address import AFDashboardDailyAddressStats +from indexer.modules.custom.address_index.models.distribution_daily_stats import AFDistributionDailyStats +from indexer.modules.custom.address_index.models.metrics_distribution_daily_stats import AFMetricsDistributionDailyStats from indexer.modules.custom.address_index.models.token_address_nft_inventories import TokenAddressNftInventories from indexer.modules.custom.address_index.schemas.api import address_base_info_model, filter_and_fill_dict_by_model @@ -1056,3 +1059,305 @@ def parse_address_transactions(transactions: list[AddressTransactions]): ).title() return transaction_list + + +time_ranges = { + "1d": lambda now: now - timedelta(days=1), + "7d": lambda now: now - timedelta(days=7), + "30d": lambda now: now - timedelta(days=30), + "6m": lambda now: now - timedelta(days=180), + "YTD": lambda now: datetime(now.year, 1, 1), + "1y": lambda now: now - timedelta(days=365), +} + + +def get_daily_active_address(time_range: str): + today = date.today() + + start_date = time_ranges[time_range](today) + result = ( + db.session.query(AFDashboardDailyAddressStats) + .filter(AFDashboardDailyAddressStats.block_date <= today, AFDashboardDailyAddressStats.block_date >= start_date) + .order_by(AFDashboardDailyAddressStats.block_date) + .all() + ) + data = [ + { + "block_date": record.block_date.isoformat(), + "active_addresses": record.active_addresses, + "new_addresses": record.new_addresses, + } + for record in result + ] + + return {"time_range": time_range, "data": data} + + +def get_all_udf_dashboards(): + all_udf_dashboards = [] + query = db.session.query(distinct(AFDistributionDailyStats.distribution_name)).order_by( + AFDistributionDailyStats.distribution_name + ) + result = query.all() + + distinct_distribution_names = [row[0] for row in result] + + return distinct_distribution_names + + +def get_all_udf_dashboards_data(): + today = date.today() - timedelta(days=1) + week_ago = today - timedelta(days=7) + month_ago = today - timedelta(days=30) + result = ( + db.session.query(AFDistributionDailyStats) + .filter( + and_( + AFDistributionDailyStats.x != 0.0, + or_( + AFDistributionDailyStats.block_date == today, + AFDistributionDailyStats.block_date == week_ago, + AFDistributionDailyStats.block_date == month_ago, + ), + ) + ) + .order_by( + AFDistributionDailyStats.distribution_name, AFDistributionDailyStats.block_date, AFDistributionDailyStats.x + ) + .all() + ) + + metrics = ( + db.session.query(AFMetricsDistributionDailyStats) + .filter( + or_( + AFMetricsDistributionDailyStats.block_date == today, + AFMetricsDistributionDailyStats.block_date == week_ago, + AFMetricsDistributionDailyStats.block_date == month_ago, + ) + ) + .order_by(AFMetricsDistributionDailyStats.distribution_name, AFMetricsDistributionDailyStats.block_date) + .all() + ) + metrics_dic = {} + for a_metrics in metrics: + distribution_name = a_metrics.distribution_name + if distribution_name not in metrics_dic: + metrics_dic[distribution_name] = {} + metrics_dic[distribution_name][a_metrics.block_date] = a_metrics + + res = {} + all_distribution_names = get_all_udf_dashboards() + for distribution_name in all_distribution_names: + res[distribution_name] = { + "name": distribution_name, + "chart_type": "", + "data": [ + {"type": "as_of_today", "avg": "", "stdev": "", "actual_date": "", "data": []}, + {"type": "as_of_a_week_ago", "avg": "", "stdev": "", "actual_date": "", "data": []}, + {"type": "as_of_a_month_ago", "avg": "", "stdev": "", "actual_date": "", "data": []}, + ], + } + + for row in result: + if ( + row.distribution_name in {"distribution_job_ens_holdings_udf", "distribution_job_ens_resolves_udf"} + and float(row.x) == 100.0 + ): + continue + if row.block_date == today: + target_metrics = metrics_dic.get(row.distribution_name) + if target_metrics: + target_metrics = target_metrics.get(row.block_date) + if target_metrics: + res[row.distribution_name]["data"][0]["avg"] = float(target_metrics.avg) + res[row.distribution_name]["data"][0]["stdev"] = float((target_metrics.stdev)) + res[row.distribution_name]["data"][0]["actual_date"] = today.isoformat() + res[row.distribution_name]["data"][0]["data"].append( + { + "value": float(row.value), + "label": float(row.x), + } + ) + elif row.block_date == week_ago: + target_metrics = metrics_dic.get(row.distribution_name) + if target_metrics: + target_metrics = target_metrics.get(row.block_date) + if target_metrics: + res[row.distribution_name]["data"][1]["avg"] = float(target_metrics.avg) + res[row.distribution_name]["data"][1]["stdev"] = float(target_metrics.stdev) + res[row.distribution_name]["data"][0]["actual_date"] = week_ago.isoformat() + res[row.distribution_name]["data"][1]["data"].append( + { + "value": float(row.value), + "label": float(row.x), + } + ) + elif row.block_date == month_ago: + target_metrics = metrics_dic.get(row.distribution_name) + if target_metrics: + target_metrics = target_metrics.get(row.block_date) + if target_metrics: + res[row.distribution_name]["data"][2]["avg"] = float(target_metrics.avg) + res[row.distribution_name]["data"][2]["stdev"] = float(target_metrics.stdev) + res[row.distribution_name]["data"][0]["actual_date"] = month_ago.isoformat() + res[row.distribution_name]["data"][2]["data"].append( + { + "value": float(row.value), + "label": float(row.x), + } + ) + + # if as_of_week_ago or as_of_month_ago empty, fill them by as_of_today + + for distribution_name, distribution_data in res.items(): + + if not distribution_data["data"][0]["data"]: + actual_date, data = get_best_match_data(distribution_name, today) + distribution_data["data"][0]["data"] = data + res[distribution_name]["data"][0]["actual_date"] = actual_date.isoformat() + avg, stdev = get_distribution_date_metrics(distribution_name, actual_date) + distribution_data["data"][0]["avg"] = avg + distribution_data["data"][0]["stdev"] = stdev + + if not distribution_data["data"][1]["data"]: + actual_date_1, data_1 = get_best_match_data(distribution_name, week_ago) + avg_1, stdev_1 = get_distribution_date_metrics(distribution_name, actual_date_1) + + res[distribution_name]["data"][1]["avg"] = avg_1 + res[distribution_name]["data"][1]["stdev"] = stdev_1 + res[distribution_name]["data"][1]["data"] = data_1 + res[distribution_name]["data"][1]["actual_date"] = actual_date_1.isoformat() + if not distribution_data["data"][2]["data"]: + actual_date_2, data_2 = get_best_match_data(distribution_name, month_ago) + avg_2, stdev_2 = get_distribution_date_metrics(distribution_name, actual_date_2) + + res[distribution_name]["data"][2]["avg"] = avg_2 + res[distribution_name]["data"][2]["stdev"] = stdev_2 + res[distribution_name]["data"][2]["data"] = data_2 + res[distribution_name]["data"][2]["actual_date"] = actual_date_2.isoformat() + chart_type = ( + "log" + if ( + check_logarithmic_pattern(distribution_name, distribution_data["data"][0]["data"]) + or check_logarithmic_pattern(distribution_name, distribution_data["data"][1]["data"]) + or check_logarithmic_pattern(distribution_name, distribution_data["data"][2]["data"]) + ) + else "value" + ) + res[distribution_name]["chart_type"] = chart_type + + return res + + +def get_best_match_data(distribution_name: str, target_date: date): + """ + Find the nearest data for a given distribution and target_date. + """ + # Find the block_date closest to the target_date + closest_block_date = ( + db.session.query(AFDistributionDailyStats.block_date) + .filter(AFDistributionDailyStats.distribution_name == distribution_name) + .order_by( + func.abs( + func.date_part("epoch", AFDistributionDailyStats.block_date) - func.date_part("epoch", target_date) + ) + ) + .limit(1) + .scalar() + ) + + if not closest_block_date: + return [] + + # Query data for the closest block_date + result = ( + db.session.query(AFDistributionDailyStats) + .filter( + and_( + AFDistributionDailyStats.block_date == closest_block_date, + AFDistributionDailyStats.distribution_name == distribution_name, + AFDistributionDailyStats.x != 0.0, + ) + ) + .order_by(AFDistributionDailyStats.x) + .all() + ) + + # Format the result into a list of dictionaries + data = [{"value": float(record.value), "label": float(record.x)} for record in result] + + return closest_block_date, data + + +def get_distribution_exists_data(distribution_name: str): + """ + Find the last existing data for a given distribution, no matter what date it is. + """ + latest_block_date = ( + db.session.query(func.max(AFDistributionDailyStats.block_date)) + .filter( + and_(AFDistributionDailyStats.x != 0.0, AFDistributionDailyStats.distribution_name == distribution_name) + ) + .scalar() + ) + + if not latest_block_date: + return [] + result = ( + db.session.query(AFDistributionDailyStats) + .filter( + and_( + AFDistributionDailyStats.block_date == latest_block_date, + AFDistributionDailyStats.distribution_name == distribution_name, + AFDistributionDailyStats.x != 0.0, + ) + ) + .order_by(AFDistributionDailyStats.x) + .all() + ) + + data = [{"value": float(record.value), "label": float(record.x)} for record in result] + + return latest_block_date, data + + +def get_distribution_date_metrics(distribution_name: str, block_date: date): + row = ( + db.session.query(AFMetricsDistributionDailyStats) + .filter( + AFMetricsDistributionDailyStats.distribution_name == distribution_name, + AFMetricsDistributionDailyStats.block_date == block_date, + ) + .order_by(AFMetricsDistributionDailyStats.distribution_name, AFMetricsDistributionDailyStats.block_date) + .first() + ) + if row: + return float(row.avg), float(row.stdev) + return "", "" + + +def is_logarithmic(labels): + if len(labels) < 3: + return False + differences = [] + for i in range(1, len(labels)): + if labels[i - 1] <= 0 or labels[i] <= 0: + return False + differences.append(math.log(labels[i]) - math.log(labels[i - 1])) + + threshold = 1e-6 + return all(abs(d - differences[0]) < threshold for d in differences) + + +def check_logarithmic_pattern(distribution_name, data): + log_chart_type = { + "distribution_job_eigen_layer_udf", + "distribution_job_aave2_supply_udf", + "distribution_job_aave2_borrow_udf", + "", + } + if distribution_name in log_chart_type: + return "log" + labels = [item["label"] for item in data] + return is_logarithmic(labels)