ERC-4626: analyse single vault
In this notebook, we examine a single or handful of handpicked ERC-4626 vaults
Vaults are picked manually by (chain, address) list
We analyse the vault performance by its share price, as reported by ERC-4626 smart contract interface.
We do last three months and historical all-time analyses
We look quantitative finance aspects of the vaults like returns, Sharpe and Sortino numbers
Some notes - Because of how vault metrics, share price and such are collected and interpreted, the results in this notebook contain various inaccuracies. - In this notebook, we use terms Net Asset Value (NAV) and Total Value Locked (TVL) interchangeably.
Usage
This is an open source notebook based on open data - You can edit and remix this notebook yourself
To do your own data research:
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.
For any questions, follow and contact Trading Strategy community.
Setup
Set up notebook rendering output mode
Use static image charts so this notebook is readeable on Github / ReadTheDocs
[56]:
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
try:
# Optionally make charts easier to share on Twitter
from tradeexecutor.utils.notebook import set_large_plotly_chart_font
set_large_plotly_chart_font(line_width=5, legend_font_size=16)
pio.templates.default = "custom"
except ImportError:
print("Could not import set_large_plotly_chart_font() from tradeexecutor.utils.notebook and set large output font. ")
pio.templates.default = "plotly_white"
Could not import set_large_plotly_chart_font() from tradeexecutor.utils.notebook and set large output font.
Read previously scanned vault price data
Read the Parquet file produced earlier with price scan
[57]:
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")
sample_vault = next(iter(vault_db.values()))
print("We have vault metadata keys: ", ", ".join(c for c in sample_vault.keys()))
display(pd.Series(sample_vault))
print("We have prices DataFrame columns: ", ", ".join(c for c in prices_df.columns))
print("DataFrame sample:")
display(prices_df.head())
We have 1,960,583 price rows and 8985 vault metadata entries for 17 chains
We have vault metadata keys: Symbol, Name, Address, Denomination, NAV, Protocol, Mgmt fee, Perf fee, Shares, First seen, _detection_data, _denomination_token, _share_token
Symbol fBPT-50STABAL3-50WETH
Name FARM_BPT-50STABAL3-50WETH
Address 0xdfa7578b8187f5dd1d0cb9d86b6dd33625895cf2
Denomination BPT-50STABAL3-50WETH
NAV 0.042399649489411407
Protocol Harvest Finance
Mgmt fee None
Perf fee None
Shares 0.042300245583425379
First seen 2023-08-04 15:37:57
_detection_data ERC4262VaultDetection(chain=8453, address='0xd...
_denomination_token {'name': 'Balancer 50 STABAL3 50 WETH', 'symbo...
_share_token {'name': 'FARM_BPT-50STABAL3-50WETH', 'symbol'...
dtype: object
We have prices DataFrame columns: chain, address, block_number, timestamp, share_price, total_assets, total_supply, performance_fee, management_fee, errors
DataFrame sample:
chain | address | block_number | timestamp | share_price | total_assets | total_supply | performance_fee | management_fee | errors | |
---|---|---|---|---|---|---|---|---|---|---|
0 | 81457 | 0x8e2b50413a53f50e2a059142a9be060294961e40 | 213961 | 2024-02-29 20:15:37 | 1.00 | 0.00 | 0.00 | NaN | NaN | |
1 | 81457 | 0x8e2b50413a53f50e2a059142a9be060294961e40 | 257161 | 2024-03-01 20:15:37 | 1.00 | 1,123.90 | 1,123.90 | NaN | NaN | |
2 | 81457 | 0x2ead9c6c7cab1dd3442714a8a8533078c402135a | 257161 | 2024-03-01 20:15:37 | 1.00 | 3.52 | 3.52 | NaN | NaN | |
3 | 81457 | 0xb9d94a3490ba2482e2d4f21f0e76b92e5661ded8 | 257161 | 2024-03-01 20:15:37 | 1.00 | 0.10 | 0.10 | NaN | NaN | |
4 | 81457 | 0x620aa22aa45f59af91cafbad0ab58181fcdbfb08 | 257161 | 2024-03-01 20:15:37 | 1.00 | 1,253.17 | 1,253.17 | NaN | NaN |
Transform data
Combine columns from metadata database with price series for easier manipulation
[58]:
from eth_defi.chain import get_chain_name
vaults_by_id = {
f"{vault['_detection_data'].chain}-{vault['_detection_data'].address}": vault for vault in vault_db.values()
}
# We use name later as DF index, so we need to make sure they are unique
counter = 1
used_names = set()
for id, vault in vaults_by_id.items():
# 40acres forgot to name their vault
if vault["Name"] == "Vault":
vault["Name"] == "40acres"
if vault["Name"] in used_names:
chain_name = get_chain_name(vault["_detection_data"].chain)
vault["Name"] = f"{vault['Name']} ({chain_name}) #{counter}"
counter += 1
used_names.add(vault["Name"])
print(f"Fixed {counter} duplicate vault names")
# Vaults are identified by their chain and address tuple, make this one human-readable column
# to make DataFrame wrangling easier
prices_df['id'] = prices_df['chain'].astype(str) + '-' + prices_df['address'].astype(str)
prices_df['name'] = prices_df['id'].apply(lambda x: vaults_by_id[x]['Name'] if x in vaults_by_id else None)
prices_df['event_count'] = prices_df['id'].apply(lambda x: vaults_by_id[x]['_detection_data'].deposit_count + vaults_by_id[x]['_detection_data'].redeem_count)
prices_df['protocol'] = prices_df['id'].apply(lambda x: vaults_by_id[x]['Protocol'] if x in vaults_by_id else None)
# 40acres fix - they did not name their vault,
# More about this later
prices_df['name'] = prices_df['name'].fillna("<unknown>")
display(prices_df.head(1))
Fixed 3418 duplicate vault names
chain | address | block_number | timestamp | share_price | total_assets | total_supply | performance_fee | management_fee | errors | id | name | event_count | protocol | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 81457 | 0x8e2b50413a53f50e2a059142a9be060294961e40 | 213961 | 2024-02-29 20:15:37 | 1.00 | 0.00 | 0.00 | NaN | NaN | 81457-0x8e2b50413a53f50e2a059142a9be060294961e40 | Wasabi WETH Vault (Blast) #2722 | 243212 | <unknown ERC-4626> |
Choose vaults to examine
We pick vaults to examine and compare by chain and address tuples
[59]:
from eth_defi.token import is_stablecoin_like
VAULTS = [
# Harvest USDC Autopilot on IPOR on Base
# https://app.ipor.io/fusion/base/0x0d877dc7c8fa3ad980dfdb18b48ec9f8768359c4/settings
(8453, "0x0d877Dc7C8Fa3aD980DfDb18B48eC9F8768359C4".lower()),
# IPOR USDC base
# https://app.ipor.io/fusion/base/0x45aa96f0b3188d47a1dafdbefce1db6b37f58216
(8453, "0x45aa96f0b3188d47a1dafdbefce1db6b37f58216".lower())
]
examind_vaults = [v for v in vault_db.values() if (v["_detection_data"].chain, v["_detection_data"].address) in VAULTS]
print(f"We matched {len(examind_vaults)} examined out of {len(vault_db)} total vaults")
# Build chain-address strings for vaults we are interested in
allowed_vault_ids = set(str(v["_detection_data"].chain) + "-" + v["_detection_data"].address for v in examind_vaults)
# Filter out prices to contain only data for vaults we are interested in
prices_df = prices_df.loc[prices_df["id"].isin(allowed_vault_ids)]
print(f"Filtered out prices have {len(prices_df):,} rows")
assert len(examind_vaults) == len(VAULTS), "Not all vaults matched"
We matched 2 examined out of 8985 total vaults
Filtered out prices have 257 rows
Calculate return series for all vaults
Build daily returns for all vaults we have some good data
[60]:
def calculate_daily_returns(df_work):
"""Calculate daily returns for each vault in isolation"""
# Group by chain and address, then resample and forward fill
df_work = df_work.set_index("timestamp")
result_dfs = []
for (chain_val, addr_val), group in df_work.groupby(['chain', 'address']):
# Resample this group to daily frequency and forward fill
resampled = group.resample('D').ffill()
# Calculate daily returns
resampled['daily_returns'] = resampled['share_price'].pct_change(fill_method=None).fillna(0)
# Add back the groupby keys as they'll be dropped during resampling
resampled['chain'] = chain_val
resampled['address'] = addr_val
result_dfs.append(resampled)
# Concatenate all the processed groups
df_result = pd.concat(result_dfs)
return df_result
raw_returns_df = returns_df = calculate_daily_returns(prices_df)
print("All returns DataFrame sample data:")
display(returns_df.head(2))
All returns DataFrame sample data:
chain | address | block_number | share_price | total_assets | total_supply | performance_fee | management_fee | errors | id | name | event_count | protocol | daily_returns | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
timestamp | ||||||||||||||
2025-03-04 | 8453 | 0x0d877dc7c8fa3ad980dfdb18b48ec9f8768359c4 | 27164615 | 1.00 | 100.97 | 100.97 | 0.02 | 0.00 | 8453-0x0d877dc7c8fa3ad980dfdb18b48ec9f8768359c4 | Autopilot USDC Base | 743 | IPOR | 0.00 | |
2025-03-05 | 8453 | 0x0d877dc7c8fa3ad980dfdb18b48ec9f8768359c4 | 27207815 | 1.00 | 300.97 | 300.97 | 0.02 | 0.00 | 8453-0x0d877dc7c8fa3ad980dfdb18b48ec9f8768359c4 | Autopilot USDC Base | 743 | IPOR | -0.00 |
Clean and filter returns
In returns data we have outliers that are likely not real returns, or one-time events that cannot repeat.
Floating point errors: Share price may jumps wildly when a vault TVL is near zero
Bugs: Vault share price method to estimate returns does not work for a particular airdrop
Airdrops: Vault gets an irregular rewards that will not repeat, and thus are not good to estimate the future performance
We clean returns by doing an assumptions
Daily returns higher than static outlier
Daily TVL max does not make sense
Daily TVL min does not make sense
Daily TVL % below lifetime average TVL
[61]:
# Set threshold we suspect not valid returns for one day
outlier_threshold = 0.50
returns_df = raw_returns_df
high_returns_mask = returns_df['daily_returns'] > outlier_threshold
outlier_returns = returns_df[high_returns_mask]
# Sort by return value (highest first)
outlier_returns = outlier_returns.sort_values(by='daily_returns', ascending=False)
# Display the results
print(f"Found {len(outlier_returns)} outlier returns > {outlier_threshold:%}")
display(outlier_returns[['name', 'id', 'daily_returns', 'share_price', 'total_assets']].head(3))
# Show the distribution of these outliers by vault
outlier_counts = outlier_returns.groupby('name').size().sort_values(ascending=False)
print("\nOutlier distribution by vault:")
display(outlier_counts.head(3))
# Clean up obv too high returns
returns_df.loc[returns_df['daily_returns'] > outlier_threshold, 'daily_returns'] = 0
Found 0 outlier returns > 50.000000%
name | id | daily_returns | share_price | total_assets | |
---|---|---|---|---|---|
timestamp |
Outlier distribution by vault:
Series([], dtype: int64)
TVL-based threshold cleaning of returns
Get rid of potential bad return values when TVL is very low as it may affect the share price based returns calculations
We can also disable or tune the filter
[62]:
# Static threshold for TVL
import warnings
tvl_filtering = False
tvl_threshold_max = 99_000_000_000 # USD 99B
tvl_threshold_min_dynamic = 0.02
tvl_threshold_min = 1_000
if tvl_filtering:
# TVL based cleaning.
# Create a mask based on TVL conditions.
# Clean up returns during low TVL periods
# pd.Timestamp("2024-02-10")
mask = (returns_df['total_assets'] < tvl_threshold_min)
mask |= (returns_df['total_assets'] > tvl_threshold_max)
# Clean up by dynamic TVL threshold filtering
#
# Morpho Steakhouse USDT Compounder by Yearn case, and similars
# https://x.com/moo9000/status/1914746350216077544
# Calculate all-time average of total_assets for each vault
avg_assets_by_vault = returns_df.groupby('id')['total_assets'].mean()
returns_df['avg_assets_by_vault'] = returns_df['id'].map(avg_assets_by_vault)
returns_df['dynamic_tvl_threshold'] = returns_df['id'].map(avg_assets_by_vault) * tvl_threshold_min_dynamic
# Create a mask for rows where total_assets is below the threshold
below_threshold_mask = returns_df['total_assets'] < returns_df['dynamic_tvl_threshold']
mask |= below_threshold_mask
# Count how many data points will be affected
affected_count = below_threshold_mask.sum()
print(f"Setting daily_returns to zero for {affected_count:,} / {len(returns_df):,} data points where total_assets < {tvl_threshold_min_dynamic:.2%} of all-time average TVL")
# We also need to expand the mask,
# so that we zero the returns of the following day
with warnings.catch_warnings():
warnings.simplefilter("ignore", FutureWarning)
mask = mask | mask.groupby(returns_df['id']).shift(1).fillna(False)
# Set daily_returns to zero where the mask is True
returns_df.loc[mask, 'daily_returns'] = 0
returns_df['tvl_filtering_mask'] = mask
Calculate lifetime, annual and 3M returns for each vault
Calculate compounding annual return (CAGR )and lifetime returns for all price series
We also calculate returns for the last three months, reflecting the recent performance
You can benchmark numbers on protocol-maintained frontends, example: Yearn: DAI to yvUSDS Farmer
CAGR, returns, etc. are expressed in raw values: 0.01 means 1% return
Calculate volatility so we can separate actively trading vaults (market making, such) from passive vaults (lending optimisaiton)
To clean the data, we filter out based on - Too high NAV value (broken tokens?) - Too min NAV value (test vaults) - Not enough deposit and redemption activity
Note: These are gross returns. Due to lack of standardisation on vault fee structure, maintaining fee data is mostly manual job.
[63]:
import warnings
import numpy as np
from eth_defi.chain import get_chain_name
three_months_ago = pd.Timestamp.now() - pd.DateOffset(months=3)
def calculate_lifetime_metrics(df):
results = []
for id_val, group in df.groupby('id'):
# Sort by timestamp just to be safe
group = group.sort_index()
name = vaults_by_id[id_val]['Name'] if id_val in vaults_by_id else None
# Calculate lifetime return using cumulative product approach
lifetime_return = (1 + group['daily_returns']).prod() - 1
last_three_months = group['daily_returns'].loc[three_months_ago:]
three_month_returns = (1 + last_three_months).prod() - 1
# Calculate volatility so we can separate actively trading vaults (market making, such) from passive vaults (lending optimisaiton)
three_months_volatility = last_three_months.std()
max_nav = group['total_assets'].max()
current_nav = group['total_assets'].iloc[-1]
chain_id = group['chain'].iloc[-1]
mgmt_fee = group['management_fee'].iloc[-1]
perf_fee = group['performance_fee'].iloc[-1]
event_count = group['event_count'].iloc[-1]
protocol = group['protocol'].iloc[-1]
# Calculate CAGR
# Get the first and last date
start_date = group.index.min()
end_date = group.index.max()
years = (end_date - start_date).days / 365.25
cagr = (1 + lifetime_return) ** (1 / years) - 1 if years > 0 else np.nan
# Calculate 3 months CAGR
# Get the first and last date
start_date = last_three_months.index.min()
end_date = last_three_months.index.max()
three_months_cagr = (1 + three_month_returns) ** (1 / years) - 1 if years > 0 else np.nan
results.append({
'name': name,
'cagr': cagr,
'lifetime_return': lifetime_return,
'three_months_cagr': three_months_cagr,
"three_months_volatility": three_months_volatility,
'denomination': vaults_by_id[id_val]['Denomination'] if id_val in vaults_by_id else None,
'chain': get_chain_name(chain_id),
'peak_nav': max_nav,
'current_nav': current_nav,
'years': years,
"mgmt_fee": mgmt_fee,
"perf_fee": perf_fee,
"event_count": event_count,
"protocol": protocol,
'id': id_val,
'three_months_returns': three_month_returns,
'start_date': start_date,
'end_date': end_date,
})
return pd.DataFrame(results)
# Numpy complains about something
# - invalid value encountered in reduce
# - Boolean Series key will be reindexed to match DataFrame index.
with warnings.catch_warnings():
warnings.simplefilter("ignore", UserWarning)
warnings.simplefilter("ignore", RuntimeWarning)
lifetime_data_df = calculate_lifetime_metrics(returns_df)
lifetime_data_df = lifetime_data_df.sort_values(by='cagr', ascending=False)
lifetime_data_df = lifetime_data_df.set_index('name')
assert not lifetime_data_df.index.duplicated().any(), f"There are duplicate ids in the index: {lifetime_data_df.index}"
# Verify we no longer have duplicates
# display(lifetime_data_df.index)
assert not lifetime_data_df.index.dropna().duplicated().any(), f"There are still duplicate names in the index: {lifetime_data_df.index}"
print("Successfully made all vault names unique by appending chain information")
print(f"Calculated lifetime data for {len(lifetime_data_df):,} vaults")
print("Sample entrys of lifetime data:")
display(lifetime_data_df.tail(4))
Successfully made all vault names unique by appending chain information
Calculated lifetime data for 2 vaults
Sample entrys of lifetime data:
cagr | lifetime_return | three_months_cagr | three_months_volatility | denomination | chain | peak_nav | current_nav | years | mgmt_fee | perf_fee | event_count | protocol | id | three_months_returns | start_date | end_date | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
name | |||||||||||||||||
IPOR USDC Lending Optimizer Base | 0.08 | 0.04 | 0.03 | 0.00 | USDC | Base | 1,631,341.84 | 1,590,966.54 | 0.50 | 0.01 | 0.10 | 627 | IPOR | 8453-0x45aa96f0b3188d47a1dafdbefce1db6b37f58216 | 0.01 | 2025-02-17 | 2025-05-15 |
Autopilot USDC Base | 0.07 | 0.01 | 0.07 | 0.00 | USDC | Base | 5,016,253.76 | 5,016,253.76 | 0.20 | 0.00 | 0.02 | 743 | IPOR | 8453-0x0d877dc7c8fa3ad980dfdb18b48ec9f8768359c4 | 0.01 | 2025-03-04 | 2025-05-15 |
Examine vault data
Examine
Historical performance, including dead ones
Sorted by lifetime returns, the current three month returns displayed as well
Vaults may be dead today with zero NAV/TVL
Reasons why NAV drops to zero may include: hacks, rug pulls, etc.
Share price calculation for low TVL vaults may have various inaccuracies because vaults implement vesting/locking mechanisms which are not visible in the share price
[64]:
def format_lifetime_table(df: pd.DataFrame) -> pd.DataFrame:
"""Format table for human readable output"""
df = df.copy()
df["cagr"] = df["cagr"].apply(lambda x: f"{x:.2%}")
df["lifetime_return"] = df["lifetime_return"].apply(lambda x: f"{x:.2%}")
df["three_months_cagr"] = df["three_months_cagr"].apply(lambda x: f"{x:.2%}")
df["three_months_returns"] = df["three_months_returns"].apply(lambda x: f"{x:.2%}")
df["three_months_volatility"] = df["three_months_volatility"].apply(lambda x: f"{x:.4f}")
df["event_count"] = df["event_count"].apply(lambda x: f"{x:,}")
df["mgmt_fee"] = df["mgmt_fee"].apply(lambda x: f"{x:.2%}" if pd.notna(x) else 'unknown')
df["perf_fee"] = df["perf_fee"].apply(lambda x: f"{x:.2%}" if pd.notna(x) else 'unknown')
df = df.rename(columns={
"cagr": "Annualised return",
"lifetime_return": "Lifetime return",
"three_months_cagr": "Last 3M return",
"three_months_volatility": "Last 3M months volatility",
"three_months_returns": "Last 3M return",
"event_count": "Deposit/redeem count",
"peak_nav": "Peak TVL USD",
"current_nav": "Current TVL USD",
"years": "Age (years)",
"mgmt_fee": "Management fee",
"perf_fee": "Performance fee",
"denomination": "Deposit token",
"chain": "Chain",
"protocol": "Protocol",
"start_date": "First deposit",
"end_date": "Last deposit",
})
return df
nav_threshold = 25_000
lifetime_data_filtered_df = lifetime_data_df[lifetime_data_df['peak_nav'] >= nav_threshold]
positive_returns_df = lifetime_data_filtered_df[lifetime_data_filtered_df['lifetime_return'] > 0]
negative_returns_df = lifetime_data_filtered_df[lifetime_data_filtered_df['lifetime_return'] <= 0]
print(f"All vaults sorted by lifetime returns and having currnet NAV > ${nav_threshold:,} USD")
print(f"Vaults with positive returns {len(positive_returns_df):,} out of {len(lifetime_data_filtered_df)} total vaults")
print(f"Vaults with negative returns {len(negative_returns_df):,} out of {len(lifetime_data_filtered_df)} total vaults")
lifetime_data_filtered_df = lifetime_data_filtered_df.sort_values(by='cagr', ascending=False)
display(format_lifetime_table(lifetime_data_filtered_df.head(50)))
All vaults sorted by lifetime returns and having currnet NAV > $25,000 USD
Vaults with positive returns 2 out of 2 total vaults
Vaults with negative returns 0 out of 2 total vaults
Annualised return | Lifetime return | Last 3M return | Last 3M months volatility | Deposit token | Chain | Peak TVL USD | Current TVL USD | Age (years) | Management fee | Performance fee | Deposit/redeem count | Protocol | id | Last 3M return | First deposit | Last deposit | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
name | |||||||||||||||||
IPOR USDC Lending Optimizer Base | 8.46% | 4.15% | 2.71% | 0.0000 | USDC | Base | 1,631,341.84 | 1,590,966.54 | 0.50 | 1.00% | 10.00% | 627 | IPOR | 8453-0x45aa96f0b3188d47a1dafdbefce1db6b37f58216 | 1.35% | 2025-02-17 | 2025-05-15 |
Autopilot USDC Base | 7.46% | 1.43% | 7.46% | 0.0001 | USDC | Base | 5,016,253.76 | 5,016,253.76 | 0.20 | 0.30% | 2.00% | 743 | IPOR | 8453-0x0d877dc7c8fa3ad980dfdb18b48ec9f8768359c4 | 1.43% | 2025-03-04 | 2025-05-15 |
Vault charts
We plot our annualised 1 month rolling returns on the chart, to see how vaults move in the direction of the markets, or what kind of outliers there are
Below, we plot the same chart again without market-making vaults as their volatility shades other vaults
We plot 1 year period, but we do not have enough history for most vaults for this time window
Market-making vaults vs. lending vaults
You can see how market-making vaults differ from others, as their returns are much more volatile and can be negative (losing money when market is not optimal).
All vaults, including volatile
We clip the monthly rolling returns to 700% to have the chart more readable
[65]:
import plotly.express as px
from plotly.colors import qualitative
benchmark_count: int = 20
def calculate_rolling_returns(
returns_df: pd.DataFrame,
interesting_vaults: pd.Series | None,
window: int = 30, # Bars
period: pd.Timedelta = pd.Timedelta(days=365),
cap: float = None,
clip_down: float=None,
clip_up: float=None,
drop_threshold: float = None,
):
# Limit to benchmarked vaults
if interesting_vaults is not None:
df = returns_df[returns_df["id"].isin(interesting_vaults)]
else:
df = returns_df
df = df.reset_index().sort_values(by=["id", "timestamp"])
# Manually blacklist one vault where we get data until fixed
df = df[df["name"] != "Revert Lend Arbitrum USDC,"]
# Limit chart width
df = df.loc[df["timestamp"] >= (pd.Timestamp.now() - period)]
# Calculate rollling returns
df['rolling_1m_returns'] = df.groupby('id')['daily_returns'].transform(
lambda x: (((1 + x).rolling(window=window).apply(np.prod) - 1) * 100)
)
df['rolling_1m_returns_annualized'] = ((1 + df['rolling_1m_returns']/100) ** 12 - 1) * 100
# When vault launches it has usually near-infinite APY
# Cap it here so charts are readable
if cap is not None:
# Using mask (replaces values WHERE condition is True)
df['rolling_1m_returns_annualized'] = df['rolling_1m_returns_annualized'].mask(
(df['rolling_1m_returns_annualized'] > cap) | (df['rolling_1m_returns_annualized'] < -cap),
np.nan
)
if clip_down is not None:
df['rolling_1m_returns_annualized'] = df['rolling_1m_returns_annualized'].clip(lower=clip_down)
if clip_up is not None:
df['rolling_1m_returns_annualized'] = df['rolling_1m_returns_annualized'].clip(upper=clip_up)
if drop_threshold is not None:
# Step 1: Identify vaults with extreme returns
extreme_return_vaults = returns_df.groupby('name')['daily_returns'].apply(
lambda x: (x > 1000).any()
)
extreme_return_names = extreme_return_vaults[extreme_return_vaults].index.tolist()
print("Removing extreme return vaults: ", extreme_return_names)
# Step 2: Filter the DataFrame to exclude these vaults
df = df[~df['name'].isin(extreme_return_names)]
return df
interesting_vaults = lifetime_data_filtered_df[0:benchmark_count]["id"]
df = rolling_returns_df = all_rolling_returns_df = calculate_rolling_returns(
returns_df,
interesting_vaults=interesting_vaults,
clip_up=700,
)
# Report manual fee data gathering needed
lack_fees_df = df[df["performance_fee"].isna()]
print(f"We lack perf fee data for {len(lack_fees_df['id'].unique()):,} / {len(interesting_vaults)} vaults")
# display(df.head(2))
fig = px.line(df,
x='timestamp',
y='rolling_1m_returns_annualized',
color='name',
title='1-Month Rolling Returns by Vault',
labels={'rolling_1m_returns': '1-Month Rolling Returns (%)',
'timestamp': 'Date',
'name': 'Name'},
hover_data=['id'],
color_discrete_sequence=qualitative.Dark24,
)
fig.update_layout(
xaxis_title='Date',
yaxis_title='1-Month Rolling Returns (%)',
legend_title='Name',
hovermode='closest',
template=pio.templates.default,
)
fig.update_traces(line=dict(width=4))
fig.show()
max_row = df.loc[df['rolling_1m_returns'].idxmax()]
print("Peak returns by")
display(max_row)
We lack perf fee data for 0 / 2 vaults

Peak returns by
timestamp 2025-02-08 00:00:00
chain 8453
address 0x45aa96f0b3188d47a1dafdbefce1db6b37f58216
block_number 26127815
share_price 1.03
total_assets 1,358,606.03
total_supply 1,324,149.14
performance_fee 0.10
management_fee 0.01
errors
id 8453-0x45aa96f0b3188d47a1dafdbefce1db6b37f58216
name IPOR USDC Lending Optimizer Base
event_count 627
protocol IPOR
daily_returns 0.00
rolling_1m_returns 0.98
rolling_1m_returns_annualized 12.41
Name: 160, dtype: object
Performance metrics
Show returns, cumulative share price for each vault
Calculate portfolio performance metrics using QuantStats
[66]:
import plotly.graph_objects as go
import plotly.express as px
from plotly.subplots import make_subplots
def analyse_vault(id: str):
name = vaults_by_id[id]['Name']
vault_df = returns_df.loc[returns_df["id"] == id]
daily_returns = returns_df.loc[returns_df["id"] == id]["daily_returns"]
vault_metadata = vaults_by_id[id]
print(f"Examining vault {name}: {id}, having {len(daily_returns):,} daily returns rows")
nav_series = vault_df["total_assets"]
price_series = vault_df["share_price"]
# Calculate cumulative returns (what $1 would grow to)
cumulative_returns = (1 + daily_returns).cumprod()
# Create figure with secondary y-axis
fig = make_subplots(specs=[[{"secondary_y": True}]])
# Add cumulative returns trace on a separate y-axis (share same axis as share price)
fig.add_trace(
go.Scatter(
x=cumulative_returns.index,
y=cumulative_returns.values,
name="Cumulative returns (cleaned)",
line=dict(color='darkgreen', width=4),
opacity=0.75
),
secondary_y=False,
)
# Add share price trace on primary y-axis
fig.add_trace(
go.Scatter(
x=price_series.index,
y=price_series.values,
name="Share Price",
line=dict(color='green', width=4, dash='dash'),
opacity=0.75
),
secondary_y=False,
)
# Add NAV trace on secondary y-axis
fig.add_trace(
go.Scatter(
x=nav_series.index,
y=nav_series.values,
name="TVL",
line=dict(color='blue', width=4),
opacity=0.75
),
secondary_y=True,
)
# Set titles and labels
fig.update_layout(
title_text=f"{name} - Returns TVL and share price",
hovermode="x unified",
template=pio.templates.default,
showlegend=True,
legend=dict(
orientation="h",
yanchor="bottom",
y=1.02,
xanchor="center",
x=0.5
)
)
# Set y-axes titles
fig.update_yaxes(title_text=f"Share Price ({vault_metadata['Denomination']})", secondary_y=False)
fig.update_yaxes(title_text=f"TVL ({vault_metadata['Denomination']})", secondary_y=True)
# Show chart
fig.show()
# Show portfolio metrics
if quantstats:
with warnings.catch_warnings():
warnings.simplefilter(action='ignore', category=RuntimeWarning)
warnings.simplefilter(action='ignore', category=FutureWarning)
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="simple",
display=False,
internal=True,
)
performance_metrics_df.rename(columns={"Strategy": name}, inplace=True)
display(performance_metrics_df)
for chain, address in VAULTS:
analyse_vault(f"{chain}-{address}")
Examining vault Autopilot USDC Base: 8453-0x0d877dc7c8fa3ad980dfdb18b48ec9f8768359c4, having 73 daily returns rows

Autopilot USDC Base | |
---|---|
Start Period | 2025-03-04 |
End Period | 2025-05-15 |
Risk-Free Rate | 0.0% |
Time in Market | 99.0% |
Cumulative Return | 1.43% |
CAGR﹪ | 5.09% |
Sharpe | 50.02 |
Prob. Sharpe Ratio | 100.0% |
Sortino | 2266.97 |
Sortino/√2 | 1602.99 |
Omega | 726.91 |
Max Drawdown | -0.0% |
Longest DD Days | 1 |
Gain/Pain Ratio | 725.91 |
Gain/Pain (1M) | - |
Payoff Ratio | 20.77 |
Profit Factor | 726.91 |
Common Sense Ratio | 6490.15 |
CPC Index | 14677.6 |
Tail Ratio | 8.93 |
Outlier Win Ratio | 1.66 |
Outlier Loss Ratio | 0.93 |
MTD | 0.34% |
3M | 1.43% |
6M | 1.43% |
YTD | 1.43% |
1Y | 1.43% |
3Y (ann.) | 5.09% |
5Y (ann.) | 5.09% |
10Y (ann.) | 5.09% |
All-time (ann.) | 5.09% |
Avg. Drawdown | -0.0% |
Avg. Drawdown Days | 1 |
Recovery Factor | 1252.41 |
Ulcer Index | 0.0 |
Serenity Index | 216912.29 |
Examining vault IPOR USDC Lending Optimizer Base: 8453-0x45aa96f0b3188d47a1dafdbefce1db6b37f58216, having 184 daily returns rows

IPOR USDC Lending Optimizer Base | |
---|---|
Start Period | 2024-11-13 |
End Period | 2025-05-15 |
Risk-Free Rate | 0.0% |
Time in Market | 100.0% |
Cumulative Return | 4.15% |
CAGR﹪ | 5.76% |
Sharpe | 45.17 |
Prob. Sharpe Ratio | - |
Sortino | - |
Sortino/√2 | - |
Omega | - |
Max Drawdown | % |
Longest DD Days | - |
Gain/Pain Ratio | - |
Gain/Pain (1M) | - |
Payoff Ratio | - |
Profit Factor | - |
Common Sense Ratio | - |
CPC Index | - |
Tail Ratio | 3.18 |
Outlier Win Ratio | 1.92 |
Outlier Loss Ratio | - |
MTD | 0.2% |
3M | 1.39% |
6M | 4.13% |
YTD | 2.67% |
1Y | 4.15% |
3Y (ann.) | 5.76% |
5Y (ann.) | 5.76% |
10Y (ann.) | 5.76% |
All-time (ann.) | 5.76% |
Recovery Factor | - |
Ulcer Index | 0.0 |
Serenity Index | - |
Avg. Drawdown Days | - |