diff --git a/README.md b/README.md index 5eb943d..440e6ee 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,24 @@ def view_retirements( ) -> pd.DataFrame ``` +The `analytics` view provides year-over-year analytics for sinking transactions, aggregating metrics like monthly active users, transaction counts, volume in USD, and carbon sunk, with percentage changes. + +```text +Usage: sc-audit view analytics [OPTIONS] + + View year-over-year analytics for sinking transactions + +Options: + -f, --format [df|csv|json] The output format for this view [default: df] + --help Show this message and exit. +``` + +The Python API is located at `sc_audit.views.stats` and returns the analytics table as a Pandas DataFrame: + +```python +def view_yoy_analytics() -> pd.DataFrame +``` + ### Configuration There isn't much to configure in sc-audit. Several settings can be overriden by environment variables. diff --git a/sc_audit/cli.py b/sc_audit/cli.py index 9c660bd..9ec8cf2 100644 --- a/sc_audit/cli.py +++ b/sc_audit/cli.py @@ -29,6 +29,7 @@ from sc_audit.views.inventory import view_inventory from sc_audit.views.retirement import view_retirements from sc_audit.views.sink_status import view_sinking_txs +from sc_audit.views.stats import view_yoy_analytics from sc_audit.views.utils import format_df @@ -153,6 +154,13 @@ def cli_view_sink_status( ) click.echo(format_df(txdf, format=format)) + +@view.command(name="analytics", params=[view_format]) +def cli_view_yoy_analytics(format: str): + """View year-over-year analytics for sinking transactions""" + df = view_yoy_analytics() + click.echo(format_df(df, format=format)) + # LOADING @cli.group() diff --git a/sc_audit/views/stats.py b/sc_audit/views/stats.py index 6f442c8..68209a9 100644 --- a/sc_audit/views/stats.py +++ b/sc_audit/views/stats.py @@ -1,17 +1,21 @@ """ View Stellarcarbon's asset stats. Optionally, filter by recipient. +Compute year-over-year metrics based on historical sinking transactions. Author: Alex Olieman """ from decimal import Decimal +import numpy as np +import pandas as pd from sqlalchemy import func, select, union_all from sc_audit.db_schema.association import SinkStatus from sc_audit.db_schema.mint import MintedBlock from sc_audit.db_schema.sink import SinkingTx from sc_audit.session_manager import Session +from sc_audit.views.sink_status import view_sinking_txs def get_carbon_stats(recipient: str | None = None) -> dict[str, Decimal]: @@ -49,3 +53,66 @@ def get_carbon_stats(recipient: str | None = None) -> dict[str, Decimal]: stats["carbon_pending"] = c_sunk - c_retired return stats + + +def view_yoy_analytics() -> pd.DataFrame: + df = view_sinking_txs(order='asc') + if df.empty: + return pd.DataFrame() + + # Compute price for USDC transactions + df['price'] = np.where( + df['dest_asset_code'] == 'USDC', + df['dest_asset_amount'].astype(float) / df['carbon_amount'].astype(float), + np.nan + ) + + # Interpolate prices linearly, then extrapolate using forward fill for trailing CARBON transactions + df['price'] = df['price'].interpolate(method='linear').ffill() + + # Convert CARBON denominated amounts to USD using the interpolated price + mask = df['dest_asset_code'] == 'CARBON' + df.loc[mask, 'dest_asset_amount'] = df.loc[mask, 'price'] * df.loc[mask, 'carbon_amount'].astype(float) + + # Ensure all dest_asset_amount are float for consistent summing + df['dest_asset_amount'] = df['dest_asset_amount'].astype(float) + + # Add year and month columns for binning + df['year'] = df['created_at'].dt.year + df['month'] = df['created_at'].dt.month + + # Calculate monthly active users (distinct recipients per month) + mau_df = df.groupby(['year', 'month'])['recipient'].nunique().reset_index() + mau_yearly = ( + mau_df.groupby('year')['recipient'] + .mean() + .reset_index() + .rename(columns={'recipient': 'mau'}) + ) + mau_yearly['mau'] = mau_yearly['mau'].astype(float).round(2) + + # Calculate yearly aggregates + yearly = df.groupby('year').agg( + num_tx=('hash', 'count'), + volume_usd=('dest_asset_amount', 'sum'), + carbon_sunk=('carbon_amount', 'sum') + ).reset_index() + yearly['volume_usd'] = yearly['volume_usd'].astype(float).round(2) + + # Merge MAU with yearly data + result = yearly.merge(mau_yearly, on='year', how='left') + + # Sort by year + result = result.sort_values('year') + + # Calculate year-over-year percentage changes + for col in ['mau', 'num_tx', 'volume_usd', 'carbon_sunk']: + result[f'yoy_{col}'] = (result[col].astype(float).pct_change() * 100).round(2) + + # Reorder columns: year, then each core column followed by its yoy column + result = result[[ + 'year', 'mau', 'yoy_mau', 'num_tx', 'yoy_num_tx', + 'volume_usd', 'yoy_volume_usd', 'carbon_sunk', 'yoy_carbon_sunk', + ]] + + return result