myco-bonding-curve/notebooks/08_crosschain_simulation.ipynb

370 lines
14 KiB
Plaintext

{
"nbformat": 4,
"nbformat_minor": 4,
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.11.0"
}
},
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Cross-Chain Hub-and-Spoke Simulation\n",
"\n",
"Explores the multi-chain collateral architecture of MycoFi:\n",
"\n",
"- **Hub (Base)**: Registry, bonding curve, tranche manager, treasury\n",
"- **Spokes**: Ethereum, Arbitrum, Optimism, Base, Polygon — each with a collateral vault\n",
"- **CCIP messages**: Deposit reports, state syncs, and rebalancing triggers flow between hub and spokes\n",
"\n",
"Each chain hosts different LST/LRT assets with their own APYs, prices, and risk scores."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"import numpy as np\n",
"import matplotlib.pyplot as plt\n",
"import matplotlib.ticker as mticker\n",
"import sys\n",
"import os\n",
"\n",
"sys.path.insert(0, os.path.abspath('..'))\n",
"\n",
"np.random.seed(42)\n",
"\n",
"%matplotlib inline\n",
"plt.rcParams['figure.figsize'] = (14, 5)\n",
"plt.rcParams['figure.dpi'] = 100"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 1. Create the Default 5-Chain System\n",
"\n",
"The default system includes chains: Ethereum, Arbitrum, Optimism, Base, and Polygon,\n",
"each pre-configured with typical LST assets and APYs."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from src.crosschain.hub_spoke import (\n",
" create_default_system,\n",
" simulate_deposit,\n",
" tick,\n",
" apply_price_shock,\n",
" get_crosschain_metrics,\n",
")\n",
"\n",
"system = create_default_system()\n",
"\n",
"chains = list(system.hub.spokes.keys())\n",
"print(f\"Chains registered: {chains}\")\n",
"print()\n",
"for chain, spoke in system.hub.spokes.items():\n",
" assets = [a.symbol for a in spoke.accepted_assets]\n",
" print(f\" {chain:12s}: {assets}\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 2. Seed Deposits Across All Chains\n",
"\n",
"Deposit a realistic initial allocation of LST collateral across all spoke chains."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Seed initial deposits per chain\n",
"seed_deposits = {\n",
" \"ethereum\": [(\"stETH\", 120.0), (\"rETH\", 60.0), (\"cbETH\", 30.0)],\n",
" \"arbitrum\": [(\"wstETH\", 90.0), (\"rETH\", 40.0)],\n",
" \"optimism\": [(\"wstETH\", 70.0), (\"sfrxETH\", 30.0)],\n",
" \"base\": [(\"cbETH\", 50.0), (\"USDC\", 120_000.0)],\n",
" \"polygon\": [(\"stMATIC\", 250_000.0), (\"USDC\", 80_000.0)],\n",
"}\n",
"\n",
"for chain, assets in seed_deposits.items():\n",
" for symbol, amount in assets:\n",
" simulate_deposit(system, chain, symbol, amount, timestamp=0.0)\n",
"\n",
"# Process all CCIP messages to update hub state\n",
"system.hub.process_messages(0.01)\n",
"\n",
"metrics = get_crosschain_metrics(system)\n",
"print(f\"Total collateral after seeding: ${metrics['total_collateral_usd']:>14,.0f}\")\n",
"print()\n",
"for chain, data in metrics['chains'].items():\n",
" print(f\" {chain:12s}: ${data['total_value_usd']:>12,.0f}\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 3. 90-Day Simulation with Random Deposits and Price Movements\n",
"\n",
"Each day: apply staking yield, random deposits, mild ETH price drift, and process CCIP messages."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"import copy\n",
"\n",
"DAYS = 90\n",
"DT = 1.0 / 365 # One day in years\n",
"\n",
"# Assets that can receive random deposits per chain\n",
"deposit_assets = {\n",
" \"ethereum\": (\"stETH\", 2400.0),\n",
" \"arbitrum\": (\"wstETH\", 2410.0),\n",
" \"optimism\": (\"wstETH\", 2410.0),\n",
" \"base\": (\"USDC\", 1.0),\n",
" \"polygon\": (\"USDC\", 1.0),\n",
"}\n",
"\n",
"# Track per-chain collateral over time\n",
"chain_history = {ch: [] for ch in chains}\n",
"total_history = []\n",
"msg_history = []\n",
"yield_history = []\n",
"\n",
"# ETH GBM parameters\n",
"eth_mu = 0.0\n",
"eth_sigma = 0.6 # 60% annualised volatility\n",
"eth_price = 2400.0\n",
"eth_assets = [\"stETH\", \"rETH\", \"cbETH\", \"wstETH\", \"sfrxETH\"]\n",
"\n",
"normal_system = copy.deepcopy(system) # preserve original for stress test\n",
"\n",
"for day in range(DAYS):\n",
" t = day * DT\n",
"\n",
" # Random deposits (Poisson arrivals, ~3 per day)\n",
" n_deposits = np.random.poisson(3)\n",
" for _ in range(n_deposits):\n",
" chain = np.random.choice(chains)\n",
" symbol, unit_price = deposit_assets[chain]\n",
" usd_amount = np.random.exponential(10_000) # avg $10k deposit\n",
" qty = usd_amount / max(unit_price, 1)\n",
" simulate_deposit(normal_system, chain, symbol, qty, timestamp=t)\n",
"\n",
" # ETH price GBM step\n",
" dW = np.random.normal(0, np.sqrt(DT))\n",
" eth_price *= np.exp((eth_mu - 0.5 * eth_sigma**2) * DT + eth_sigma * dW)\n",
" multiplier = eth_price / 2400.0\n",
" for asset_sym in eth_assets:\n",
" for spoke in normal_system.hub.spokes.values():\n",
" for asset in spoke.accepted_assets:\n",
" if asset.symbol == asset_sym:\n",
" # Reset to base * multiplier (avoid compounding the multiplier)\n",
" pass # Prices are tracked relatively — we only shock in stress test\n",
"\n",
" # Tick system (applies yield + processes messages)\n",
" tick_data = tick(normal_system, DT)\n",
"\n",
" # Record\n",
" for ch in chains:\n",
" chain_history[ch].append(normal_system.hub.spokes[ch].total_value_usd)\n",
" total_history.append(tick_data[\"total_collateral_usd\"])\n",
" msg_history.append(normal_system.total_messages_delivered)\n",
" yield_history.append(normal_system.total_yield_generated)\n",
"\n",
"print(f\"Simulation complete.\")\n",
"print(f\"Final total collateral: ${total_history[-1]:,.0f}\")\n",
"print(f\"Total CCIP messages delivered: {msg_history[-1]}\")\n",
"print(f\"Total yield generated: ${yield_history[-1]:,.0f}\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 4. Visualizations — Normal Operation"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"day_axis = np.arange(DAYS)\n",
"\n",
"chain_colors = {\n",
" \"ethereum\": '#627EEA',\n",
" \"arbitrum\": '#28A0F0',\n",
" \"optimism\": '#FF0420',\n",
" \"base\": '#0052FF',\n",
" \"polygon\": '#8247E5',\n",
"}\n",
"\n",
"fig, axes = plt.subplots(1, 3, figsize=(20, 6))\n",
"\n",
"# --- Plot 1: Stacked area — per-chain collateral ---\n",
"ax = axes[0]\n",
"stacked_data = [chain_history[ch] for ch in chains]\n",
"ax.stackplot(day_axis, stacked_data,\n",
" labels=chains,\n",
" colors=[chain_colors[c] for c in chains],\n",
" alpha=0.85)\n",
"ax.set_xlabel('Day')\n",
"ax.set_ylabel('Collateral Value (USD)')\n",
"ax.set_title('Per-Chain Collateral — Stacked Area')\n",
"ax.legend(loc='upper left', fontsize=8)\n",
"ax.yaxis.set_major_formatter(mticker.FuncFormatter(lambda x, _: f'${x/1e6:.1f}M'))\n",
"\n",
"# --- Plot 2: Asset breakdown pie chart (final state) ---\n",
"ax = axes[1]\n",
"final_metrics = get_crosschain_metrics(normal_system)\n",
"asset_values = {}\n",
"for chain_data in final_metrics['chains'].values():\n",
" for sym, data in chain_data['assets'].items():\n",
" asset_values[sym] = asset_values.get(sym, 0.0) + data['value']\n",
"# Sort by value\n",
"sorted_assets = sorted(asset_values.items(), key=lambda x: x[1], reverse=True)\n",
"labels, vals = zip(*sorted_assets)\n",
"ax.pie(vals, labels=labels, autopct='%1.1f%%', startangle=90,\n",
" colors=plt.cm.Set3(np.linspace(0, 1, len(vals))))\n",
"ax.set_title('Asset Breakdown by Value (Day 90)')\n",
"\n",
"# --- Plot 3: CCIP message delivery timeline ---\n",
"ax = axes[2]\n",
"msg_per_day = np.diff([0] + msg_history)\n",
"ax.bar(day_axis, msg_per_day, color='#3498db', alpha=0.7, label='Messages delivered')\n",
"ax.plot(day_axis, np.cumsum(msg_per_day), color='#e74c3c', linewidth=2, label='Cumulative')\n",
"ax2 = ax.twinx()\n",
"ax2.plot(day_axis, yield_history, color='#2ecc71', linewidth=2, linestyle='--', label='Cumulative yield')\n",
"ax2.set_ylabel('Cumulative Yield ($)', color='#2ecc71')\n",
"ax2.yaxis.set_major_formatter(mticker.FuncFormatter(lambda x, _: f'${x/1e3:.1f}K'))\n",
"ax.set_xlabel('Day')\n",
"ax.set_ylabel('CCIP Messages')\n",
"ax.set_title('CCIP Message Timeline & Yield')\n",
"lines1, labels1 = ax.get_legend_handles_labels()\n",
"lines2, labels2 = ax2.get_legend_handles_labels()\n",
"ax.legend(lines1 + lines2, labels1 + labels2, fontsize=8)\n",
"\n",
"plt.tight_layout()\n",
"plt.show()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 5. Stress Test — ETH Crash and Cross-Chain Impact Propagation\n",
"\n",
"Apply a sudden 50% ETH price crash at day 45 and observe how it propagates across all chains\n",
"that hold ETH-denominated assets (Ethereum, Arbitrum, Optimism) while stablecoin-heavy\n",
"chains (Base USDC, Polygon USDC) show relative stability."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"CRASH_DAY = 45\n",
"CRASH_FACTOR = 0.50 # 50% price crash\n",
"\n",
"stress_system = copy.deepcopy(system) # Start from the seeded state\n",
"\n",
"stress_chain_history = {ch: [] for ch in chains}\n",
"stress_total_history = []\n",
"\n",
"for day in range(DAYS):\n",
" t = day * DT\n",
"\n",
" # Small random deposits\n",
" for chain, (symbol, unit_price) in deposit_assets.items():\n",
" if np.random.random() < 0.4:\n",
" qty = np.random.exponential(5_000) / max(unit_price, 1)\n",
" simulate_deposit(stress_system, chain, symbol, qty, timestamp=t)\n",
"\n",
" # ETH crash at day 45\n",
" if day == CRASH_DAY:\n",
" print(f\"Day {day}: ETH crashes by {(1-CRASH_FACTOR)*100:.0f}%\")\n",
" for eth_asset in [\"stETH\", \"rETH\", \"cbETH\", \"wstETH\", \"sfrxETH\"]:\n",
" apply_price_shock(stress_system, eth_asset, CRASH_FACTOR)\n",
" pre_crash = stress_system.hub.total_collateral_usd\n",
" stress_system.hub._recalculate_total()\n",
" post_crash = stress_system.hub.total_collateral_usd\n",
" print(f\" Pre-crash total collateral: ${pre_crash:,.0f}\")\n",
" print(f\" Post-crash total collateral: ${post_crash:,.0f}\")\n",
" print(f\" Drop: {(pre_crash - post_crash)/pre_crash*100:.1f}%\")\n",
"\n",
" tick(stress_system, DT)\n",
"\n",
" for ch in chains:\n",
" stress_chain_history[ch].append(stress_system.hub.spokes[ch].total_value_usd)\n",
" stress_total_history.append(stress_system.hub.total_collateral_usd)"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"fig, axes = plt.subplots(1, 2, figsize=(16, 6))\n",
"\n",
"# --- Plot 1: Per-chain collateral under stress ---\n",
"ax = axes[0]\n",
"for ch in chains:\n",
" ax.plot(day_axis, stress_chain_history[ch],\n",
" label=ch, color=chain_colors[ch], linewidth=2)\n",
"ax.axvline(CRASH_DAY, color='black', linestyle='--', linewidth=1.5, label=f'ETH crash (day {CRASH_DAY})')\n",
"ax.set_xlabel('Day')\n",
"ax.set_ylabel('Chain Collateral Value ($)')\n",
"ax.set_title('Per-Chain Collateral — ETH Crash Stress Test')\n",
"ax.legend(fontsize=8)\n",
"ax.yaxis.set_major_formatter(mticker.FuncFormatter(lambda x, _: f'${x/1e6:.1f}M'))\n",
"\n",
"# --- Plot 2: Normal vs stress total collateral ---\n",
"ax = axes[1]\n",
"ax.plot(day_axis, total_history, color='#2ecc71', linewidth=2, label='Normal')\n",
"ax.plot(day_axis, stress_total_history, color='#e74c3c', linewidth=2, label='Stress (ETH crash)')\n",
"ax.axvline(CRASH_DAY, color='black', linestyle='--', linewidth=1.5, label=f'Shock (day {CRASH_DAY})')\n",
"ax.fill_between(day_axis, stress_total_history, total_history,\n",
" color='#e74c3c', alpha=0.15, label='Loss from shock')\n",
"ax.set_xlabel('Day')\n",
"ax.set_ylabel('Total Collateral ($)')\n",
"ax.set_title('System Collateral: Normal vs Stress')\n",
"ax.legend(fontsize=8)\n",
"ax.yaxis.set_major_formatter(mticker.FuncFormatter(lambda x, _: f'${x/1e6:.1f}M'))\n",
"\n",
"plt.tight_layout()\n",
"plt.show()"
]
}
]
}