ERC-4626: examine vault historical performance
This notebook serves both as a coding tutorial and a useful data analytics tool for ERC-4626 vaults.
In this notebook, we examine one ERC-4626 vault historical performance
We pick one vault by chain id and address and show its metrics
We visualise the performance with Plotly charts and
quantstats
portfolio performance metrics
Usage
Read general instructions how to run the tutorials
See
ERC-4626 scanning all vaults onchain
example in tutorials first how to build a vault database as localvault_db.pickle
file.See
ERC-4626: scanning vaults' historical price and performance
example in tutorials first how to buildvault-prices.parquet
file.
Setup
Set up notebook rendering output mode
Use static image charts so this notebook is readeable on Github / ReadTheDocs
[28]:
import pandas as pd
from plotly.offline import init_notebook_mode
import plotly.io as pio
from eth_defi.vault.base import VaultSpec
pd.options.display.float_format = '{:,.2f}'.format
pd.options.display.max_columns = None
pd.options.display.max_rows = None
# Set up Plotly chart output as SVG
image_format = "png"
width = 1400
height = 800
# https://stackoverflow.com/a/52956402/315168
init_notebook_mode()
# https://stackoverflow.com/a/74609837/315168
assert hasattr(pio, "kaleido"), "Kaleido rendering backend missing. Run 'pip install kaleido' needed for this notebook"
pio.kaleido.scope.default_format = image_format
# https://plotly.com/python/renderers/#overriding-the-default-renderer
pio.renderers.default = image_format
current_renderer = pio.renderers[image_format]
# Have SVGs default pixel with
current_renderer.width = width
current_renderer.height = height
Read scanned vault price data
Read the Parquet file produced earlier with price scan
[29]:
import pickle
from pathlib import Path
output_folder = Path("~/.tradingstrategy/vaults").expanduser()
parquet_file = output_folder / "vault-prices.parquet"
assert parquet_file.exists(), "Run the vault scanner script first"
vault_db = output_folder / "vault-db.pickle"
assert vault_db.exists(), "Run the vault scanner script first"
vault_db = pickle.load(open(vault_db, "rb"))
try:
prices_df = pd.read_parquet(parquet_file)
except Exception as e:
raise RuntimeError(f"Could not read: {parquet_file}: {e}") from e
chains = prices_df["chain"].unique()
print(f"We have {len(prices_df):,} price rows and {len(vault_db)} vault metadata entries for {len(chains)} chains")
display(prices_df.head())
We have 1,433,926 price rows and 7014 vault metadata entries for 10 chains
chain | address | block_number | timestamp | share_price | total_assets | total_supply | performance_fee | management_fee | |
---|---|---|---|---|---|---|---|---|---|
0 | 56 | 0x10c90bfcfb3d2a7ae814da1548ae3a7fc31c35a0 | 18172265 | 2022-05-27 17:25:18 | 1.46 | 6.84 | 10.00 | NaN | NaN |
1 | 56 | 0x10c90bfcfb3d2a7ae814da1548ae3a7fc31c35a0 | 18201065 | 2022-05-28 17:36:35 | 1.46 | 1,032.68 | 1,510.00 | NaN | NaN |
2 | 56 | 0x10c90bfcfb3d2a7ae814da1548ae3a7fc31c35a0 | 18345065 | 2022-06-02 18:23:12 | 1.46 | 23,736.03 | 34,713.44 | NaN | NaN |
3 | 56 | 0x0b96dccbaa03447fd5f5fd733e0ebd10680e84c1 | 18345065 | 2022-06-02 18:23:12 | 0.44 | 194.83 | 84.92 | NaN | NaN |
4 | 56 | 0x10c90bfcfb3d2a7ae814da1548ae3a7fc31c35a0 | 18460265 | 2022-06-06 19:08:56 | 1.46 | 25,803.91 | 37,737.68 | NaN | NaN |
Choose an interesting vault and extract its metadata
We choose one vault what we have seen in the earlier notebooks
[30]:
from eth_defi.chain import get_chain_name
# SteakhouseUSDT
# https://app.morpho.org/ethereum/vault/0xbEef047a543E45807105E51A8BBEFCc5950fcfBa/steakhouse-usdt
chain_id = 1
address = "0xbEef047a543E45807105E51A8BBEFCc5950fcfBa".lower()
vault_spec = VaultSpec(chain_id, address)
vault_metadata = vault_db[vault_spec]
name = vault_metadata["Name"]
meta_df = pd.DataFrame(list(vault_metadata.values()), index=vault_metadata.keys(), columns=["Value"])
chain_name = get_chain_name(vault_spec.chain_id)
print(f"Vault metadata for selected vault: {name} on {chain_name}")
display(meta_df)
Vault metadata for selected vault: Steakhouse USDT on Ethereum
Value | |
---|---|
Symbol | steakUSDT |
Name | Steakhouse USDT |
Address | 0xbeef047a543e45807105e51a8bbefcc5950fcfba |
Denomination | USDT |
NAV | 41888502.211544 |
Protocol | Morpho |
Mgmt fee | 0.00 |
Perf fee | 0.00 |
Shares | 38848612.71044189229694404 |
First seen | 2024-02-06 17:27:35 |
_detection_data | ERC4262VaultDetection(chain=1, address='0xbeef... |
_denomination_token | {'name': 'Tether USD', 'symbol': 'USDT', 'tota... |
_share_token | {'name': 'Steakhouse USDT', 'symbol': 'steakUS... |
Extract price series
Get the historical share price for this vault for all vaults data
[31]:
vault_df = prices_df.loc[
(prices_df["address"] == address) &
(prices_df["chain"] == chain_id)
]
vault_df = vault_df.set_index("timestamp").sort_index()
print(f"Vault price data rows for {chain_id}: {address}, total {len(vault_df)} rows")
display(vault_df.head())
display(vault_df.tail())
assert len(vault_df), f"No rows found for address: {address}"
Vault price data rows for 1: 0xbeef047a543e45807105e51a8bbefcc5950fcfba, total 425 rows
chain | address | block_number | share_price | total_assets | total_supply | performance_fee | management_fee | |
---|---|---|---|---|---|---|---|---|
timestamp | ||||||||
2024-02-06 22:56:35 | 1 | 0xbeef047a543e45807105e51a8bbefcc5950fcfba | 19172299 | 1.00 | 10,000.00 | 10,000.00 | 0.05 | 0.00 |
2024-02-07 23:10:11 | 1 | 0xbeef047a543e45807105e51a8bbefcc5950fcfba | 19179499 | 1.00 | 10,000.00 | 10,000.00 | 0.05 | 0.00 |
2024-02-08 23:26:23 | 1 | 0xbeef047a543e45807105e51a8bbefcc5950fcfba | 19186699 | 1.00 | 10,000.00 | 10,000.00 | 0.05 | 0.00 |
2024-02-09 23:39:35 | 1 | 0xbeef047a543e45807105e51a8bbefcc5950fcfba | 19193899 | 1.00 | 104,000.00 | 104,000.40 | 0.05 | 0.00 |
2024-02-10 23:55:23 | 1 | 0xbeef047a543e45807105e51a8bbefcc5950fcfba | 19201099 | 1.00 | 104,000.00 | 104,001.54 | 0.05 | 0.00 |
chain | address | block_number | share_price | total_assets | total_supply | performance_fee | management_fee | |
---|---|---|---|---|---|---|---|---|
timestamp | ||||||||
2025-04-04 14:57:47 | 1 | 0xbeef047a543e45807105e51a8bbefcc5950fcfba | 22196299 | 1.08 | 40,146,804.75 | 43,273,497.23 | 0.00 | 0.00 |
2025-04-05 15:06:23 | 1 | 0xbeef047a543e45807105e51a8bbefcc5950fcfba | 22203499 | 1.08 | 40,043,884.52 | 43,166,226.04 | 0.00 | 0.00 |
2025-04-06 15:15:11 | 1 | 0xbeef047a543e45807105e51a8bbefcc5950fcfba | 22210699 | 1.08 | 39,549,783.07 | 42,637,131.40 | 0.00 | 0.00 |
2025-04-07 15:25:23 | 1 | 0xbeef047a543e45807105e51a8bbefcc5950fcfba | 22217899 | 1.08 | 42,297,566.71 | 45,603,152.42 | 0.00 | 0.00 |
2025-04-08 15:31:59 | 1 | 0xbeef047a543e45807105e51a8bbefcc5950fcfba | 22225099 | 1.08 | 40,256,837.45 | 43,406,321.00 | 0.00 | 0.00 |
Net asset value
What is the value of the all assets locked in the vault
[32]:
import plotly.express as px
nav_series = vault_df["total_assets"]
# Remove the uninteresting period when NAV is low
min_nav_threshold = 100_000
nav_series = nav_series[nav_series > min_nav_threshold]
fig = px.line(
nav_series,
title=f"Net asset value of {name} vault over time"
)
fig.update_layout(yaxis_title=f"NAV in {vault_metadata['Denomination']}")
fig.update_layout(showlegend=False)
fig.show()

Daily returns
[34]:
# Becauase the original sampling was done by block number,
# not by timestamp, we need to convert the series to daily format
daily_price_series = price_series.resample("D").last()
daily_price_series = daily_price_series.ffill()
print("Daily share token prices are")
display(daily_price_series.head())
Daily share token prices are
timestamp
2024-02-06 1.00
2024-02-07 1.00
2024-02-08 1.00
2024-02-09 1.00
2024-02-10 1.00
Freq: D, Name: share_price, dtype: float64
[35]:
# Convert to returns and plot it out
daily_returns = daily_price_series.pct_change()
# Create symmetric bins for the histogram
max_abs_return = max(abs(daily_returns.min()), abs(daily_returns.max())) # e.g., 2.505
bin_size = 0.5 # Bin width in percentage points
nbins = int(2 * max_abs_return / bin_size) # Ensure symmetry
symmetric_range = [-max_abs_return, max_abs_return]
fig = px.histogram(
daily_returns,
x=daily_returns.values, # Use the values, not the index
title=f"Distribution of Daily Returns of {name} vault",
labels={"x": "Return (%)", "y": "Count"},
nbins=nbins,
range_x=symmetric_range,
)
fig.show()

Performance metrics
Portfolio performance metrics for this vault
[36]:
import warnings
with warnings.catch_warnings(): # DeprecationWarning: Importing display from IPython.core.display is deprecated since IPython 7.14, please import from IPython display
warnings.simplefilter(action='ignore', category=FutureWarning) # yfinance: The default dtype for empty Series will be 'object' instead of 'float64' in a future version. Specify a dtype explicitly to silence this warning.
try:
# quantstats compatibility hack for IPython 8.x, ZMQInteractiveShell error
# https://stackoverflow.com/a/15898875/315168
get_ipython().magic = lambda x: x
import quantstats
except ImportError as e:
quantstats = None
if quantstats:
metrics = quantstats.reports.metrics
performance_metrics_df = metrics(
daily_returns,
benchmark=None,
as_pct=display, # QuantStats codebase is a mess
periods_per_year=365,
mode="full",
display=False,
internal=True,
)
print(f"Portfolio performance metrics for {name} vault")
performance_metrics_df.rename(columns={"Strategy": name}, inplace=True)
display(performance_metrics_df)
else:
print("To show the portfolio performance metrics install quantstats: pip install quantstats")
Portfolio performance metrics for Steakhouse USDT vault
/Users/moo/Library/Caches/pypoetry/virtualenvs/web3-ethereum-defi-YE4GM4ox-py3.11/lib/python3.11/site-packages/scipy/stats/_distn_infrastructure.py:2304: RuntimeWarning:
invalid value encountered in multiply
/Users/moo/Library/Caches/pypoetry/virtualenvs/web3-ethereum-defi-YE4GM4ox-py3.11/lib/python3.11/site-packages/scipy/stats/_distn_infrastructure.py:2305: RuntimeWarning:
invalid value encountered in multiply
Steakhouse USDT | |
---|---|
Start Period | 2024-02-07 |
End Period | 2025-04-08 |
Risk-Free Rate | 0.0% |
Time in Market | 100.0% |
Cumulative Return | 7.82% |
CAGR﹪ | 4.56% |
Sharpe | 37.64 |
Prob. Sharpe Ratio | 100.0% |
Smart Sharpe | 15.87 |
Sortino | - |
Smart Sortino | - |
Sortino/√2 | - |
Smart Sortino/√2 | - |
Omega | - |
Max Drawdown | % |
Longest DD Days | - |
Volatility (ann.) | 0.17% |
Calmar | - |
Skew | 1.14 |
Kurtosis | 2.36 |
Expected Daily | 0.02% |
Expected Monthly | 0.5% |
Expected Yearly | 3.84% |
Kelly Criterion | - |
Risk of Ruin | 0.0% |
Daily Value-at-Risk | -0.0% |
Expected Shortfall (cVaR) | -0.0% |
Max Consecutive Wins | 189 |
Max Consecutive Losses | 0 |
Gain/Pain Ratio | - |
Gain/Pain (1M) | - |
Payoff Ratio | - |
Profit Factor | - |
Common Sense Ratio | - |
CPC Index | - |
Tail Ratio | 7.93 |
Outlier Win Ratio | 2.59 |
Outlier Loss Ratio | - |
MTD | 0.07% |
3M | 1.43% |
6M | 3.42% |
YTD | 1.58% |
1Y | 6.88% |
3Y (ann.) | 4.56% |
5Y (ann.) | 4.56% |
10Y (ann.) | 4.56% |
All-time (ann.) | 4.56% |
Best Day | 0.06% |
Worst Day | 0.0% |
Best Month | 0.82% |
Worst Month | 0.06% |
Best Year | 6.14% |
Worst Year | 1.58% |
Recovery Factor | - |
Ulcer Index | 0.0 |
Serenity Index | - |
Avg. Up Month | 0.5% |
Avg. Down Month | - |
Win Days | 100.0% |
Win Month | 100.0% |
Win Quarter | 100.0% |
Win Year | 100.0% |
Avg. Drawdown Days | - |