myco-bonding-curve/notebooks/10_cadcad_monte_carlo.ipynb

361 lines
12 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{
"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": [
"# cadCAD Full System Monte Carlo\n",
"\n",
"Runs the full MycoFi system through cadCAD with Monte Carlo sampling to characterise\n",
"the distribution of outcomes under normal growth and stress scenarios.\n",
"\n",
"**Scenarios:**\n",
"- `scenario_normal_growth`: Steady deposits, mild ETH volatility, governance ticking — 3 MC runs over 180 days\n",
"- `scenario_stress_test`: ETH crash at day 30, bank run follows — 5 MC runs over 100 days\n",
"\n",
"**Outputs:**\n",
"- Fan charts (mean ± 1σ) for supply, collateral, and system CR\n",
"- Tranche CR comparison under stress vs normal\n",
"- Summary statistics table"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"import numpy as np\n",
"import pandas as pd\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",
"%matplotlib inline\n",
"plt.rcParams['figure.figsize'] = (14, 5)\n",
"plt.rcParams['figure.dpi'] = 100\n",
"pd.set_option('display.float_format', '{:,.2f}'.format)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 1. Run Normal Growth Scenario\n",
"\n",
"3 Monte Carlo runs, 180 timesteps (one per day). Each run uses the same system configuration\n",
"but different random seeds for deposit arrivals and price movements."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from src.cadcad.config import scenario_normal_growth, scenario_stress_test\n",
"\n",
"print(\"Running normal growth scenario (3 runs × 180 days)...\")\n",
"df_normal = scenario_normal_growth(timesteps=180, runs=3)\n",
"print(f\"Done. DataFrame shape: {df_normal.shape}\")\n",
"print(f\"Columns: {list(df_normal.columns)}\")\n",
"df_normal.head()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 2. Fan Charts — Normal Growth\n",
"\n",
"Plot supply, total collateral, and system collateral ratio across all Monte Carlo runs.\n",
"Shade the 1σ band around the mean to show the spread of outcomes."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"def fan_chart(ax, df, col, runs, color, label, formatter=None):\n",
" \"\"\"Plot a fan chart: individual run traces + mean ± 1σ band.\"\"\"\n",
" # Get all timesteps\n",
" timesteps = sorted(df['timestep'].unique())\n",
"\n",
" # Collect values per timestep across runs\n",
" per_ts = []\n",
" for ts in timesteps:\n",
" # Take the last substep only (final state per timestep)\n",
" sub = df[df['timestep'] == ts]\n",
" if 'substep' in sub.columns:\n",
" sub = sub[sub['substep'] == sub['substep'].max()]\n",
" vals = sub[col].values\n",
" per_ts.append(vals)\n",
"\n",
" per_ts = np.array(per_ts, dtype=float) # shape: (timesteps, runs)\n",
" mean = np.nanmean(per_ts, axis=1)\n",
" std = np.nanstd(per_ts, axis=1)\n",
" t = np.array(timesteps, dtype=float)\n",
"\n",
" # Individual run traces (thin)\n",
" for r in range(per_ts.shape[1]):\n",
" ax.plot(t, per_ts[:, r], color=color, linewidth=0.8, alpha=0.4)\n",
"\n",
" # Mean line\n",
" ax.plot(t, mean, color=color, linewidth=2.5, label=f'{label} (mean)')\n",
"\n",
" # ±1σ band\n",
" ax.fill_between(t, mean - std, mean + std, color=color, alpha=0.15, label=f'{label} ±1σ')\n",
"\n",
" if formatter:\n",
" ax.yaxis.set_major_formatter(formatter)\n",
"\n",
" return mean, std\n",
"\n",
"\n",
"fig, axes = plt.subplots(1, 3, figsize=(20, 6))\n",
"\n",
"runs = sorted(df_normal['run'].unique()) if 'run' in df_normal.columns else [0]\n",
"\n",
"# --- Total supply ---\n",
"fan_chart(axes[0], df_normal, 'total_supply', runs, '#3498db', 'Total Supply',\n",
" mticker.FuncFormatter(lambda x, _: f'${x/1e3:.0f}K'))\n",
"axes[0].set_xlabel('Timestep (day)')\n",
"axes[0].set_ylabel('Total Supply ($)')\n",
"axes[0].set_title('Total Token Supply — Normal Growth')\n",
"axes[0].legend(fontsize=8)\n",
"\n",
"# --- Total collateral ---\n",
"fan_chart(axes[1], df_normal, 'total_collateral_usd', runs, '#2ecc71', 'Total Collateral',\n",
" mticker.FuncFormatter(lambda x, _: f'${x/1e6:.2f}M'))\n",
"axes[1].set_xlabel('Timestep (day)')\n",
"axes[1].set_ylabel('Total Collateral (USD)')\n",
"axes[1].set_title('Total Collateral — Normal Growth')\n",
"axes[1].legend(fontsize=8)\n",
"\n",
"# --- System CR ---\n",
"fan_chart(axes[2], df_normal, 'system_cr', runs, '#e74c3c', 'System CR')\n",
"axes[2].axhline(1.0, color='black', linestyle='--', alpha=0.5, label='CR = 1.0 (par)')\n",
"axes[2].set_xlabel('Timestep (day)')\n",
"axes[2].set_ylabel('Collateral Ratio')\n",
"axes[2].set_title('System Collateral Ratio — Normal Growth')\n",
"axes[2].legend(fontsize=8)\n",
"\n",
"plt.tight_layout()\n",
"plt.show()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 3. Run Stress Test Scenario\n",
"\n",
"5 Monte Carlo runs, 100 timesteps. ETH crashes at day 30, bank run starts at day 31."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"print(\"Running stress test scenario (5 runs × 100 days)...\")\n",
"df_stress = scenario_stress_test(timesteps=100, runs=5)\n",
"print(f\"Done. DataFrame shape: {df_stress.shape}\")\n",
"df_stress.head()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 4. Tranche CRs Under Stress vs Normal\n",
"\n",
"Compare the collateral ratios for each tranche under both scenarios.\n",
"Senior should remain most protected; Junior absorbs the most volatility."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"fig, axes = plt.subplots(1, 3, figsize=(20, 6))\n",
"\n",
"tranche_cols = [\n",
" ('senior_cr', '#2ecc71', 'Senior (myUSD-S)'),\n",
" ('mezzanine_cr', '#f39c12', 'Mezzanine (myUSD-M)'),\n",
" ('junior_cr', '#e74c3c', 'Junior ($MYCO)'),\n",
"]\n",
"\n",
"# Use only the last 100 timesteps of the normal run for comparison\n",
"df_normal_sub = df_normal[df_normal['timestep'] <= 100].copy()\n",
"runs_stress = sorted(df_stress['run'].unique()) if 'run' in df_stress.columns else [0]\n",
"runs_normal = sorted(df_normal_sub['run'].unique()) if 'run' in df_normal_sub.columns else [0]\n",
"\n",
"for ax, (col, color, title) in zip(axes, tranche_cols):\n",
" # Stress scenario fan\n",
" fan_chart(ax, df_stress, col, runs_stress, '#e74c3c', 'Stress')\n",
" # Normal scenario fan (overlaid)\n",
" fan_chart(ax, df_normal_sub, col, runs_normal, '#2ecc71', 'Normal')\n",
"\n",
" ax.axhline(1.0, color='black', linestyle=':', alpha=0.6, label='CR = 1.0')\n",
" ax.axvline(30, color='gray', linestyle='--', alpha=0.5, label='Shock day 30')\n",
" ax.set_xlabel('Timestep (day)')\n",
" ax.set_ylabel('Collateral Ratio')\n",
" ax.set_title(f'{title} CR')\n",
" ax.legend(fontsize=7)\n",
"\n",
"plt.tight_layout()\n",
"plt.show()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 5. Summary Statistics Table\n",
"\n",
"Aggregate key metrics at the final timestep for both scenarios, summarised across all MC runs."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"metrics_of_interest = [\n",
" 'total_supply', 'total_collateral_usd', 'system_cr',\n",
" 'senior_cr', 'mezzanine_cr', 'junior_cr',\n",
" 'total_yield', 'ccip_messages', 'proposals_passed',\n",
"]\n",
"\n",
"def final_timestep_stats(df, label):\n",
" \"\"\"Extract final-timestep rows and compute summary statistics.\"\"\"\n",
" max_ts = df['timestep'].max()\n",
" final = df[df['timestep'] == max_ts]\n",
" if 'substep' in final.columns:\n",
" final = final[final['substep'] == final['substep'].max()]\n",
"\n",
" cols = [c for c in metrics_of_interest if c in final.columns]\n",
" stats = final[cols].agg(['mean', 'std', 'min', 'max']).T\n",
" stats.columns = [f'{label}_mean', f'{label}_std', f'{label}_min', f'{label}_max']\n",
" return stats\n",
"\n",
"normal_stats = final_timestep_stats(df_normal, 'normal')\n",
"stress_stats = final_timestep_stats(df_stress, 'stress')\n",
"\n",
"summary = pd.concat([normal_stats, stress_stats], axis=1)\n",
"print(\"=\" * 80)\n",
"print(\"Final Timestep Summary Statistics\")\n",
"print(\"=\" * 80)\n",
"print(summary.to_string())"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Display as a formatted DataFrame\n",
"display_cols = [\n",
" 'normal_mean', 'normal_std',\n",
" 'stress_mean', 'stress_std',\n",
"]\n",
"display_rows = [\n",
" 'total_supply', 'total_collateral_usd', 'system_cr',\n",
" 'senior_cr', 'mezzanine_cr', 'junior_cr',\n",
" 'total_yield',\n",
"]\n",
"\n",
"display_df = summary[[c for c in display_cols if c in summary.columns]]\n",
"display_df = display_df.loc[[r for r in display_rows if r in display_df.index]]\n",
"\n",
"rename_map = {\n",
" 'total_supply': 'Total Token Supply ($)',\n",
" 'total_collateral_usd': 'Total Collateral (USD)',\n",
" 'system_cr': 'System Collateral Ratio',\n",
" 'senior_cr': 'Senior CR (myUSD-S)',\n",
" 'mezzanine_cr': 'Mezzanine CR (myUSD-M)',\n",
" 'junior_cr': 'Junior CR ($MYCO)',\n",
" 'total_yield': 'Cumulative Yield (USD)',\n",
"}\n",
"display_df.index = [rename_map.get(i, i) for i in display_df.index]\n",
"\n",
"col_rename = {\n",
" 'normal_mean': 'Normal (mean)',\n",
" 'normal_std': 'Normal (±1σ)',\n",
" 'stress_mean': 'Stress (mean)',\n",
" 'stress_std': 'Stress (±1σ)',\n",
"}\n",
"display_df = display_df.rename(columns=col_rename)\n",
"\n",
"display_df"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 6. Normal vs Stress Supply and Collateral Comparison"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"fig, axes = plt.subplots(1, 2, figsize=(16, 6))\n",
"\n",
"# --- Total collateral: normal vs stress ---\n",
"ax = axes[0]\n",
"fan_chart(ax, df_normal_sub, 'total_collateral_usd', runs_normal, '#2ecc71', 'Normal',\n",
" mticker.FuncFormatter(lambda x, _: f'${x/1e6:.2f}M'))\n",
"fan_chart(ax, df_stress, 'total_collateral_usd', runs_stress, '#e74c3c', 'Stress',\n",
" mticker.FuncFormatter(lambda x, _: f'${x/1e6:.2f}M'))\n",
"ax.axvline(30, color='gray', linestyle='--', alpha=0.6, label='Shock day 30')\n",
"ax.set_xlabel('Timestep (day)')\n",
"ax.set_ylabel('Total Collateral (USD)')\n",
"ax.set_title('Total Collateral: Normal vs Stress')\n",
"ax.legend(fontsize=8)\n",
"\n",
"# --- System CR: normal vs stress ---\n",
"ax = axes[1]\n",
"fan_chart(ax, df_normal_sub, 'system_cr', runs_normal, '#2ecc71', 'Normal')\n",
"fan_chart(ax, df_stress, 'system_cr', runs_stress, '#e74c3c', 'Stress')\n",
"ax.axhline(1.0, color='black', linestyle=':', alpha=0.6, label='CR = 1.0 (par)')\n",
"ax.axvline(30, color='gray', linestyle='--', alpha=0.6, label='Shock day 30')\n",
"ax.set_xlabel('Timestep (day)')\n",
"ax.set_ylabel('System Collateral Ratio')\n",
"ax.set_title('System CR: Normal vs Stress')\n",
"ax.legend(fontsize=8)\n",
"\n",
"plt.tight_layout()\n",
"plt.show()"
]
}
]
}