432 lines
18 KiB
Plaintext
432 lines
18 KiB
Plaintext
{
|
|
"cells": [
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"# 02 — Conviction Voting Analysis\n",
|
|
"\n",
|
|
"Analyzing the TEC's Disputable Conviction Voting system:\n",
|
|
"- Proposal outcomes and funding distribution\n",
|
|
"- Participation dynamics and voter concentration\n",
|
|
"- Conviction accumulation patterns\n",
|
|
"- Effective governance throughput"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"import pandas as pd\n",
|
|
"import numpy as np\n",
|
|
"import matplotlib.pyplot as plt\n",
|
|
"import matplotlib.dates as mdates\n",
|
|
"import seaborn as sns\n",
|
|
"\n",
|
|
"sns.set_theme(style='whitegrid', palette='deep')\n",
|
|
"plt.rcParams['figure.figsize'] = (14, 6)\n",
|
|
"plt.rcParams['figure.dpi'] = 100\n",
|
|
"\n",
|
|
"DATA = '../data/onchain'\n",
|
|
"\n",
|
|
"proposals = pd.read_csv(f'{DATA}/cv_proposals.csv')\n",
|
|
"stakes = pd.read_csv(f'{DATA}/cv_stakes.csv')\n",
|
|
"supports = pd.read_csv(f'{DATA}/cv_support_updates.csv')\n",
|
|
"\n",
|
|
"print(f'Proposals: {len(proposals)}')\n",
|
|
"print(f'Stake events: {len(stakes)}')\n",
|
|
"print(f'Support updates: {len(supports)}')\n",
|
|
"print(f'\\nProposal status breakdown:')\n",
|
|
"print(proposals['status'].value_counts())"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"## 1. Proposal Funding Distribution\n",
|
|
"\n",
|
|
"How was the common pool allocated? Who were the biggest recipients?"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"funded = proposals[proposals['status'] == 'executed'].copy()\n",
|
|
"funded = funded.sort_values('amount_requested', ascending=True)\n",
|
|
"\n",
|
|
"# Extract short proposal names from link field\n",
|
|
"funded['name'] = funded['link'].str[:50]\n",
|
|
"\n",
|
|
"fig, axes = plt.subplots(1, 2, figsize=(18, 8))\n",
|
|
"\n",
|
|
"# Bar chart of funded amounts\n",
|
|
"ax = axes[0]\n",
|
|
"bars = ax.barh(range(len(funded)), funded['amount_requested'], color='steelblue')\n",
|
|
"ax.set_yticks(range(len(funded)))\n",
|
|
"ax.set_yticklabels(funded['name'], fontsize=7)\n",
|
|
"ax.set_xlabel('TEC Tokens Requested')\n",
|
|
"ax.set_title(f'Funded Proposals ({len(funded)} total, {funded[\"amount_requested\"].sum():,.0f} TEC)')\n",
|
|
"\n",
|
|
"# Top beneficiary addresses (group by beneficiary)\n",
|
|
"ax = axes[1]\n",
|
|
"by_beneficiary = funded.groupby('beneficiary')['amount_requested'].sum().sort_values(ascending=True)\n",
|
|
"# Show short addresses\n",
|
|
"labels = [f'{addr[:8]}...{addr[-4:]}' for addr in by_beneficiary.index]\n",
|
|
"ax.barh(range(len(by_beneficiary)), by_beneficiary.values, color='teal')\n",
|
|
"ax.set_yticks(range(len(by_beneficiary)))\n",
|
|
"ax.set_yticklabels(labels, fontsize=8)\n",
|
|
"ax.set_xlabel('Total TEC Received')\n",
|
|
"ax.set_title(f'Funding by Beneficiary Address ({len(by_beneficiary)} unique)')\n",
|
|
"\n",
|
|
"plt.tight_layout()\n",
|
|
"plt.savefig(f'{DATA}/../snapshots/cv_funding_distribution.png', dpi=150, bbox_inches='tight')\n",
|
|
"plt.show()\n",
|
|
"\n",
|
|
"print(f'\\nTop 5 beneficiaries by total funding:')\n",
|
|
"for addr, amt in by_beneficiary.tail(5).items():\n",
|
|
" props = funded[funded['beneficiary'] == addr]['name'].tolist()\n",
|
|
" print(f' {addr[:16]}... : {amt:>10,.0f} TEC ({len(props)} proposals)')\n",
|
|
" for p in props:\n",
|
|
" print(f' - {p}')"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"## 2. Proposal Success/Failure Patterns"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"# Exclude the Abstain proposal (#1) and zero-amount proposals\n",
|
|
"real_proposals = proposals[(proposals['id'] > 1) & (proposals['amount_requested'] > 0)].copy()\n",
|
|
"\n",
|
|
"fig, axes = plt.subplots(1, 3, figsize=(18, 5))\n",
|
|
"\n",
|
|
"# Status breakdown\n",
|
|
"ax = axes[0]\n",
|
|
"status_counts = real_proposals['status'].value_counts()\n",
|
|
"colors = {'executed': '#2ecc71', 'cancelled': '#e74c3c', 'open': '#3498db'}\n",
|
|
"ax.pie(status_counts.values, labels=status_counts.index, autopct='%1.0f%%',\n",
|
|
" colors=[colors.get(s, 'gray') for s in status_counts.index])\n",
|
|
"ax.set_title(f'Proposal Outcomes (n={len(real_proposals)})')\n",
|
|
"\n",
|
|
"# Amount requested: funded vs cancelled\n",
|
|
"ax = axes[1]\n",
|
|
"for status, color in [('executed', '#2ecc71'), ('cancelled', '#e74c3c')]:\n",
|
|
" subset = real_proposals[real_proposals['status'] == status]\n",
|
|
" ax.hist(subset['amount_requested'], bins=15, alpha=0.6, label=status, color=color)\n",
|
|
"ax.set_xlabel('Amount Requested (TEC)')\n",
|
|
"ax.set_ylabel('Count')\n",
|
|
"ax.set_title('Distribution of Request Sizes')\n",
|
|
"ax.legend()\n",
|
|
"\n",
|
|
"# Cancelled proposals - were they resubmitted?\n",
|
|
"ax = axes[2]\n",
|
|
"cancelled = real_proposals[real_proposals['status'] == 'cancelled']\n",
|
|
"resubmitted = []\n",
|
|
"for _, row in cancelled.iterrows():\n",
|
|
" # Check if same beneficiary has a funded proposal\n",
|
|
" same_benef = funded[funded['beneficiary'] == row['beneficiary']]\n",
|
|
" resubmitted.append(len(same_benef) > 0)\n",
|
|
"resub_counts = pd.Series(resubmitted).value_counts()\n",
|
|
"labels = ['Resubmitted & Funded' if k else 'Not Resubmitted' for k in resub_counts.index]\n",
|
|
"ax.pie(resub_counts.values, labels=labels, autopct='%1.0f%%',\n",
|
|
" colors=['#2ecc71', '#95a5a6'])\n",
|
|
"ax.set_title(f'Cancelled Proposals: Resubmission')\n",
|
|
"\n",
|
|
"plt.tight_layout()\n",
|
|
"plt.savefig(f'{DATA}/../snapshots/cv_proposal_outcomes.png', dpi=150, bbox_inches='tight')\n",
|
|
"plt.show()"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"## 3. Voter Participation & Concentration\n",
|
|
"\n",
|
|
"How concentrated was governance power? Did a small number of whales dominate?"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"# Analyze staker participation\n",
|
|
"staker_stats = stakes.groupby('staker').agg(\n",
|
|
" num_stakes=('proposal_id', 'count'),\n",
|
|
" unique_proposals=('proposal_id', 'nunique'),\n",
|
|
" max_tokens_staked=('tokens_staked', 'max'),\n",
|
|
" total_conviction=('conviction', 'sum'),\n",
|
|
").sort_values('max_tokens_staked', ascending=False)\n",
|
|
"\n",
|
|
"fig, axes = plt.subplots(2, 2, figsize=(16, 10))\n",
|
|
"\n",
|
|
"# Staker activity distribution\n",
|
|
"ax = axes[0, 0]\n",
|
|
"ax.hist(staker_stats['unique_proposals'], bins=range(0, staker_stats['unique_proposals'].max()+2),\n",
|
|
" color='steelblue', edgecolor='white')\n",
|
|
"ax.set_xlabel('Number of Proposals Staked On')\n",
|
|
"ax.set_ylabel('Number of Stakers')\n",
|
|
"ax.set_title(f'Staker Engagement Distribution (n={len(staker_stats)})')\n",
|
|
"\n",
|
|
"# Stake size distribution (log scale)\n",
|
|
"ax = axes[0, 1]\n",
|
|
"max_stakes = staker_stats['max_tokens_staked'].sort_values(ascending=False)\n",
|
|
"ax.bar(range(len(max_stakes)), max_stakes.values, color='teal', width=1.0)\n",
|
|
"ax.set_yscale('log')\n",
|
|
"ax.set_xlabel('Staker Rank')\n",
|
|
"ax.set_ylabel('Max Tokens Staked (log scale)')\n",
|
|
"ax.set_title('Stake Size Distribution (Whale Analysis)')\n",
|
|
"\n",
|
|
"# Cumulative stake concentration (Lorenz-like curve)\n",
|
|
"ax = axes[1, 0]\n",
|
|
"sorted_stakes = max_stakes.sort_values().values\n",
|
|
"cumulative = np.cumsum(sorted_stakes) / sorted_stakes.sum()\n",
|
|
"x = np.arange(len(cumulative)) / len(cumulative)\n",
|
|
"ax.plot(x, cumulative, 'b-', linewidth=2, label='Actual')\n",
|
|
"ax.plot([0, 1], [0, 1], 'k--', alpha=0.3, label='Perfect equality')\n",
|
|
"ax.set_xlabel('Fraction of Stakers')\n",
|
|
"ax.set_ylabel('Fraction of Total Stake')\n",
|
|
"ax.set_title('Stake Concentration (Lorenz Curve)')\n",
|
|
"ax.legend()\n",
|
|
"\n",
|
|
"# Gini coefficient\n",
|
|
"n = len(sorted_stakes)\n",
|
|
"gini = (2 * np.sum((np.arange(1, n+1)) * sorted_stakes) / (n * np.sum(sorted_stakes))) - (n+1)/n\n",
|
|
"ax.text(0.05, 0.9, f'Gini: {gini:.3f}', transform=ax.transAxes, fontsize=12,\n",
|
|
" bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.8))\n",
|
|
"\n",
|
|
"# Top 10 stakers - what % of total conviction\n",
|
|
"ax = axes[1, 1]\n",
|
|
"top10 = staker_stats.head(10)\n",
|
|
"top10_share = top10['max_tokens_staked'].sum() / staker_stats['max_tokens_staked'].sum()\n",
|
|
"labels_short = [f'{addr[:6]}...{addr[-4:]}' for addr in top10.index]\n",
|
|
"ax.barh(range(len(top10)), top10['max_tokens_staked'], color='coral')\n",
|
|
"ax.set_yticks(range(len(top10)))\n",
|
|
"ax.set_yticklabels(labels_short, fontsize=8)\n",
|
|
"ax.set_xlabel('Max Tokens Staked')\n",
|
|
"ax.set_title(f'Top 10 Stakers ({top10_share:.1%} of total stake)')\n",
|
|
"\n",
|
|
"plt.tight_layout()\n",
|
|
"plt.savefig(f'{DATA}/../snapshots/cv_participation.png', dpi=150, bbox_inches='tight')\n",
|
|
"plt.show()\n",
|
|
"\n",
|
|
"print(f'\\nParticipation stats:')\n",
|
|
"print(f' Total unique stakers: {len(staker_stats)}')\n",
|
|
"print(f' Median proposals per staker: {staker_stats[\"unique_proposals\"].median():.0f}')\n",
|
|
"print(f' Mean proposals per staker: {staker_stats[\"unique_proposals\"].mean():.1f}')\n",
|
|
"print(f' Stakers on 1 proposal only: {(staker_stats[\"unique_proposals\"]==1).sum()}')\n",
|
|
"print(f' Stakers on 5+ proposals: {(staker_stats[\"unique_proposals\"]>=5).sum()}')\n",
|
|
"print(f' Gini coefficient: {gini:.3f}')\n",
|
|
"print(f' Top 10 stakers hold {top10_share:.1%} of max stake')"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"## 4. Conviction Dynamics Over Time\n",
|
|
"\n",
|
|
"How did conviction accumulate? Were proposals funded quickly or did they require sustained support?"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"# Track conviction over time for each proposal\n",
|
|
"# Use support updates as the timeline\n",
|
|
"support_by_prop = supports.groupby('proposal_id').agg(\n",
|
|
" num_updates=('block', 'count'),\n",
|
|
" first_block=('block', 'min'),\n",
|
|
" last_block=('block', 'max'),\n",
|
|
")\n",
|
|
"support_by_prop['block_span'] = support_by_prop['last_block'] - support_by_prop['first_block']\n",
|
|
"# Approximate time: Gnosis Chain ~5 sec blocks\n",
|
|
"support_by_prop['days_active'] = support_by_prop['block_span'] * 5 / 86400\n",
|
|
"\n",
|
|
"# Merge with proposal data\n",
|
|
"merged = proposals.merge(support_by_prop, left_on='id', right_index=True, how='left')\n",
|
|
"merged = merged[merged['id'] > 1] # exclude abstain\n",
|
|
"\n",
|
|
"fig, axes = plt.subplots(1, 2, figsize=(16, 6))\n",
|
|
"\n",
|
|
"# Days active by status\n",
|
|
"ax = axes[0]\n",
|
|
"for status, color in [('executed', '#2ecc71'), ('cancelled', '#e74c3c')]:\n",
|
|
" subset = merged[merged['status'] == status]\n",
|
|
" ax.scatter(subset['amount_requested'], subset['days_active'],\n",
|
|
" c=color, s=60, alpha=0.7, label=status, edgecolors='white')\n",
|
|
"ax.set_xlabel('Amount Requested (TEC)')\n",
|
|
"ax.set_ylabel('Days Active (approx)')\n",
|
|
"ax.set_title('Proposal Lifetime vs Request Size')\n",
|
|
"ax.legend()\n",
|
|
"\n",
|
|
"# Number of support updates per proposal\n",
|
|
"ax = axes[1]\n",
|
|
"for status, color in [('executed', '#2ecc71'), ('cancelled', '#e74c3c')]:\n",
|
|
" subset = merged[merged['status'] == status]\n",
|
|
" ax.scatter(subset['amount_requested'], subset['num_updates'],\n",
|
|
" c=color, s=60, alpha=0.7, label=status, edgecolors='white')\n",
|
|
"ax.set_xlabel('Amount Requested (TEC)')\n",
|
|
"ax.set_ylabel('Support Update Events')\n",
|
|
"ax.set_title('Governance Activity per Proposal')\n",
|
|
"ax.legend()\n",
|
|
"\n",
|
|
"plt.tight_layout()\n",
|
|
"plt.savefig(f'{DATA}/../snapshots/cv_dynamics.png', dpi=150, bbox_inches='tight')\n",
|
|
"plt.show()\n",
|
|
"\n",
|
|
"print(f'\\nConviction dynamics:')\n",
|
|
"print(f' Funded proposals - median days active: {merged[merged[\"status\"]==\"executed\"][\"days_active\"].median():.1f}')\n",
|
|
"print(f' Cancelled proposals - median days active: {merged[merged[\"status\"]==\"cancelled\"][\"days_active\"].median():.1f}')"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"## 5. Governance Throughput & Spending Rate"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"# Timeline of funded proposals\n",
|
|
"funded_timeline = proposals[(proposals['status'] == 'executed') & (proposals['amount_requested'] > 0)].copy()\n",
|
|
"funded_timeline = funded_timeline.sort_values('block')\n",
|
|
"funded_timeline['cumulative_funded'] = funded_timeline['amount_requested'].cumsum()\n",
|
|
"\n",
|
|
"# Approximate dates from blocks (block 20086944 ≈ Jan 19, 2022)\n",
|
|
"# Gnosis chain: ~5 sec blocks\n",
|
|
"import datetime\n",
|
|
"genesis_block = 20086944\n",
|
|
"genesis_date = datetime.datetime(2022, 1, 19)\n",
|
|
"funded_timeline['date'] = funded_timeline['block'].apply(\n",
|
|
" lambda b: genesis_date + datetime.timedelta(seconds=(b - genesis_block) * 5)\n",
|
|
")\n",
|
|
"\n",
|
|
"fig, axes = plt.subplots(2, 1, figsize=(16, 10))\n",
|
|
"\n",
|
|
"# Cumulative funding\n",
|
|
"ax = axes[0]\n",
|
|
"ax.step(funded_timeline['date'], funded_timeline['cumulative_funded'],\n",
|
|
" where='post', color='steelblue', linewidth=2)\n",
|
|
"ax.fill_between(funded_timeline['date'], funded_timeline['cumulative_funded'],\n",
|
|
" step='post', alpha=0.2, color='steelblue')\n",
|
|
"ax.set_ylabel('Cumulative TEC Funded')\n",
|
|
"ax.set_title('Conviction Voting: Cumulative Funding Over Time')\n",
|
|
"ax.xaxis.set_major_formatter(mdates.DateFormatter('%b %Y'))\n",
|
|
"ax.xaxis.set_major_locator(mdates.MonthLocator(interval=3))\n",
|
|
"plt.setp(ax.xaxis.get_majorticklabels(), rotation=45)\n",
|
|
"\n",
|
|
"# Individual proposal amounts over time\n",
|
|
"ax = axes[1]\n",
|
|
"colors_map = {'executed': '#2ecc71', 'cancelled': '#e74c3c', 'open': '#3498db'}\n",
|
|
"all_props = proposals[proposals['id'] > 1].copy()\n",
|
|
"all_props['date'] = all_props['block'].apply(\n",
|
|
" lambda b: genesis_date + datetime.timedelta(seconds=(b - genesis_block) * 5)\n",
|
|
")\n",
|
|
"for status in ['executed', 'cancelled']:\n",
|
|
" subset = all_props[all_props['status'] == status]\n",
|
|
" ax.bar(subset['date'], subset['amount_requested'],\n",
|
|
" width=5, color=colors_map[status], alpha=0.7, label=status)\n",
|
|
"ax.set_ylabel('TEC Requested')\n",
|
|
"ax.set_xlabel('Date')\n",
|
|
"ax.set_title('Individual Proposals Over Time')\n",
|
|
"ax.legend()\n",
|
|
"ax.xaxis.set_major_formatter(mdates.DateFormatter('%b %Y'))\n",
|
|
"ax.xaxis.set_major_locator(mdates.MonthLocator(interval=3))\n",
|
|
"plt.setp(ax.xaxis.get_majorticklabels(), rotation=45)\n",
|
|
"\n",
|
|
"plt.tight_layout()\n",
|
|
"plt.savefig(f'{DATA}/../snapshots/cv_throughput.png', dpi=150, bbox_inches='tight')\n",
|
|
"plt.show()\n",
|
|
"\n",
|
|
"# Calculate spending rate\n",
|
|
"date_range = (funded_timeline['date'].max() - funded_timeline['date'].min()).days\n",
|
|
"monthly_rate = funded_timeline['amount_requested'].sum() / (date_range / 30)\n",
|
|
"print(f'\\nSpending analysis:')\n",
|
|
"print(f' Active period: {funded_timeline[\"date\"].min().strftime(\"%b %Y\")} to {funded_timeline[\"date\"].max().strftime(\"%b %Y\")} ({date_range} days)')\n",
|
|
"print(f' Total funded: {funded_timeline[\"amount_requested\"].sum():,.0f} TEC')\n",
|
|
"print(f' Monthly burn rate: ~{monthly_rate:,.0f} TEC/month')\n",
|
|
"print(f' Avg proposal size: {funded_timeline[\"amount_requested\"].mean():,.0f} TEC')\n",
|
|
"print(f' Median proposal size: {funded_timeline[\"amount_requested\"].median():,.0f} TEC')"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"metadata": {},
|
|
"source": [
|
|
"## 6. Key Findings Summary"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"print('=' * 60)\n",
|
|
"print('CONVICTION VOTING — KEY FINDINGS')\n",
|
|
"print('=' * 60)\n",
|
|
"print(f'''\n",
|
|
"SCALE:\n",
|
|
" {len(proposals)} total proposals, {len(funded_timeline)} funded\n",
|
|
" {funded_timeline[\"amount_requested\"].sum():,.0f} TEC disbursed\n",
|
|
" {len(staker_stats)} unique governance participants\n",
|
|
"\n",
|
|
"CONCENTRATION:\n",
|
|
" Gini coefficient: {gini:.3f}\n",
|
|
" Top 10 stakers: {top10_share:.1%} of stake\n",
|
|
" {(staker_stats[\"unique_proposals\"]==1).sum()}/{len(staker_stats)} stakers only voted once\n",
|
|
"\n",
|
|
"OUTCOMES:\n",
|
|
" {len(funded_timeline)}/{len(real_proposals)} proposals funded ({len(funded_timeline)/len(real_proposals):.0%} success rate)\n",
|
|
" {len(cancelled)}/{len(real_proposals)} cancelled ({len(cancelled)/len(real_proposals):.0%})\n",
|
|
"\n",
|
|
"QUESTIONS FOR DEEPER ANALYSIS:\n",
|
|
" 1. Did conviction voting favor incumbents/repeat proposers?\n",
|
|
" 2. How did the 11% spending limit affect large proposals?\n",
|
|
" 3. Were cancelled proposals victims of insufficient participation or active opposition?\n",
|
|
" 4. Did the Abstain proposal (#1) serve its intended signal function?\n",
|
|
" 5. How did staking for CV affect circulating supply and ABC price?\n",
|
|
"''')"
|
|
]
|
|
}
|
|
],
|
|
"metadata": {
|
|
"kernelspec": {
|
|
"display_name": "Python 3",
|
|
"language": "python",
|
|
"name": "python3"
|
|
},
|
|
"language_info": {
|
|
"name": "python",
|
|
"version": "3.11.0"
|
|
}
|
|
},
|
|
"nbformat": 4,
|
|
"nbformat_minor": 4
|
|
}
|