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 local vault_db.pickle file.

  • See ERC-4626: scanning vaults' historical price and performance example in tutorials first how to build vault-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
../_images/tutorials_erc-4626-single-vault_22_1.png
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
../_images/tutorials_erc-4626-single-vault_24_1.png
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
../_images/tutorials_erc-4626-single-vault_24_4.png
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 -