Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: calculate simple apy for balancer-aura vaults #486

Closed
wants to merge 16 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion scripts/debug_apy.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import os
import sys
import time
import logging
import traceback

logger = logging.getLogger(__name__)

def main(address):
start = time.perf_counter()
from yearn.v2.vaults import Vault
from yearn.apy.common import get_samples
vault = Vault.from_address(address)
vault.apy(get_samples())
apy = vault.apy(get_samples())
logger.info(f'apy {str(apy)}')
logger.info(f' ⏱️ {time.perf_counter() - start} seconds')

def with_exception_handling():
address = os.getenv("DEBUG_ADDRESS", None)
Expand All @@ -26,3 +31,6 @@ def with_exception_handling():
logger.info("*** Available variables for debugging ***")
available_variables = [ k for k in locals().keys() if '__' not in k and 'pdb' not in k and 'self' != k and 'sys' != k ]
logger.info(available_variables)

if __name__ == '__main__':
globals()[sys.argv[1]]()
1 change: 1 addition & 0 deletions yearn/apy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
from yearn.apy import v2

from yearn.apy.curve import simple as curve
from yearn.apy.balancer import simple as balancer

from yearn.apy.common import ApySamples, Apy, ApyBlocks, ApyError, get_samples, ApyFees, ApyPoints
265 changes: 265 additions & 0 deletions yearn/apy/balancer/simple.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
from datetime import datetime, timedelta
import logging
import os
from pprint import pformat

from brownie import chain, web3
from dataclasses import dataclass
import requests
from semantic_version import Version

from yearn.apy.common import Apy, ApyBlocks, ApyError, ApyFees, ApySamples, SECONDS_PER_YEAR
from yearn.apy.booster import get_booster_fee
from yearn.apy.gauge import Gauge
from yearn.debug import Debug
from yearn.gql import gql_post
from yearn.networks import Network
from yearn.prices import magic
from yearn.utils import closest_block_after_timestamp, contract


logger = logging.getLogger(__name__)

@dataclass
class AuraAprData:
boost: float = 0
bal_apr: float = 0
aura_apr: float = 0
swap_fees_apr: float = 0
bonus_rewards_apr: float = 0
gross_apr: float = 0
net_apr: float = 0
debt_ratio: float = 0

addresses = {
Network.Mainnet: {
'gauge_factory': '0x4E7bBd911cf1EFa442BC1b2e9Ea01ffE785412EC',
'gauge_controller': '0xC128468b7Ce63eA702C1f104D55A2566b13D3ABD',
'voter': '0xc999dE72BFAFB936Cb399B94A8048D24a27eD1Ff',
'bal': '0xba100000625a3754423978a60c9317c58a424e3D',
'aura': '0xC0c293ce456fF0ED870ADd98a0828Dd4d2903DBF',
'booster': '0xA57b8d98dAE62B26Ec3bcC4a365338157060B234',
'booster_voter': '0xaF52695E1bB01A16D33D7194C28C42b10e0Dbec2'
}
}

subgraphs = {
Network.Mainnet: 'https://api.thegraph.com/subgraphs/name/balancer-labs/balancer-v2'
}

MAX_BOOST = 2.5
COMPOUNDING = 52

def is_aura_vault(vault):
return len(vault.strategies) == 1 and 'aura' in vault.strategies[0].name.lower()

def simple(vault, samples: ApySamples) -> Apy:
if chain.id != Network.Mainnet: raise ApyError('bal', 'chain not supported')
if not is_aura_vault(vault): raise ApyError('bal', 'vault not supported')

now = samples.now
pool = contract(vault.token.address)
gauge_factory = contract(addresses[chain.id]['gauge_factory'])
gauge = contract(gauge_factory.getPoolGauge(pool.address))
gauge_inflation_rate = gauge.inflation_rate(block_identifier=now)

gauge_working_supply = gauge.working_supply(block_identifier=now)
if gauge_working_supply == 0:
raise ApyError('bal', 'gauge working supply is zero')

gauge_controller = contract(addresses[chain.id]['gauge_controller'])
gauge_weight = gauge_controller.gauge_relative_weight.call(gauge.address, block_identifier=now)

if os.getenv('DEBUG', None):
logger.info(pformat(Debug().collect_variables(locals())))

return calculate_simple(
vault,
Gauge(pool.address, pool, gauge, gauge_weight, gauge_inflation_rate, gauge_working_supply),
samples
)

def calculate_simple(vault, gauge: Gauge, samples: ApySamples) -> Apy:
if not vault: raise ApyError('bal', 'apy preview not supported')

now = samples.now
pool_token_price = magic.get_price(gauge.lp_token, block=now)
performance_fee, management_fee, keep_bal = get_vault_fees(vault, block=now)

apr_data = get_current_aura_apr(
vault, gauge,
pool_token_price,
block=now
)

gross_apr = apr_data.gross_apr * apr_data.debt_ratio

net_booster_apr = apr_data.net_apr * (1 - performance_fee) - management_fee
net_booster_apy = (1 + (net_booster_apr / COMPOUNDING)) ** COMPOUNDING - 1
net_apy = net_booster_apy

fees = ApyFees(
performance=performance_fee,
management=management_fee,
keep_crv=keep_bal,
cvx_keep_crv=keep_bal
)

if os.getenv('DEBUG', None):
logger.info(pformat(Debug().collect_variables(locals())))

composite = {
"boost": apr_data.boost,
"bal_rewards_apr": apr_data.bal_apr,
"aura_rewards_apr": apr_data.aura_apr,
"swap_fees_apr": apr_data.swap_fees_apr,
"bonus_rewards_apr": apr_data.bonus_rewards_apr,
"aura_gross_apr": apr_data.gross_apr,
"aura_net_apr": apr_data.net_apr,
"booster_net_apr": net_booster_apr,
}

blocks = ApyBlocks(
samples.now,
samples.week_ago,
samples.month_ago,
vault.reports[0].block_number
)

return Apy('aura', gross_apr, net_apy, fees, composite=composite, blocks=blocks)

def get_current_aura_apr(
vault, gauge,
pool_token_price,
block=None
) -> AuraAprData:
"""Calculate the current APR as opposed to projected APR like we do with CRV-CVX"""
strategy = vault.strategies[0].strategy
debt_ratio = get_debt_ratio(vault, strategy)
booster = contract(addresses[chain.id]['booster'])
booster_fee = get_booster_fee(booster, block)
booster_boost = gauge.calculate_boost(MAX_BOOST, addresses[chain.id]['booster_voter'], block)

bal_price = magic.get_price(addresses[chain.id]['bal'], block=block)
aura_price = magic.get_price(addresses[chain.id]['aura'], block=block)

rewards = contract(strategy.rewardsContract())
rewards_tvl = pool_token_price * rewards.totalSupply() / 10**rewards.decimals()

bal_rewards_per_year = (rewards.rewardRate() / 10**rewards.decimals()) * SECONDS_PER_YEAR
bal_rewards_per_year_usd = bal_rewards_per_year * bal_price
bal_rewards_apr = bal_rewards_per_year_usd / rewards_tvl

aura_emission_rate = get_aura_emission_rate(block)
aura_rewards_per_year = bal_rewards_per_year * aura_emission_rate
aura_rewards_per_year_usd = aura_rewards_per_year * aura_price
aura_rewards_apr = aura_rewards_per_year_usd / rewards_tvl

swap_fees_apr = calculate_24hr_swap_fees_apr(gauge.pool, block)
bonus_rewards_apr = get_bonus_rewards_apr(rewards, rewards_tvl)

net_apr = (
bal_rewards_apr
+ aura_rewards_apr
+ swap_fees_apr
+ bonus_rewards_apr
)

gross_apr = (
(bal_rewards_apr / (1 - booster_fee))
+ aura_rewards_apr
+ swap_fees_apr
+ bonus_rewards_apr
)

if os.getenv('DEBUG', None):
logger.info(pformat(Debug().collect_variables(locals())))

return AuraAprData(
booster_boost,
bal_rewards_apr,
aura_rewards_apr,
swap_fees_apr,
bonus_rewards_apr,
gross_apr,
net_apr,
debt_ratio
)

def get_bonus_rewards_apr(rewards, rewards_tvl, block=None):
result = 0
for index in range(rewards.extraRewardsLength(block_identifier=block)):
extra_rewards = contract(rewards.extraRewards(index))
extra_rewards_per_year = (extra_rewards.rewardRate(block_identifier=block) / 10**extra_rewards.decimals()) * SECONDS_PER_YEAR
extra_rewards_per_year_usd = extra_rewards_per_year * magic.get_price(extra_rewards, block=block)
result += extra_rewards_per_year_usd / rewards_tvl
return result

def get_vault_fees(vault, block=None):
if vault:
vault_contract = vault.vault
if len(vault.strategies) > 0 and hasattr(vault.strategies[0].strategy, 'keepBAL'):
keep_bal = vault.strategies[0].strategy.keepBAL(block_identifier=block) / 1e4
else:
keep_bal = 0
performance = vault_contract.performanceFee(block_identifier=block) / 1e4 if hasattr(vault_contract, "performanceFee") else 0
management = vault_contract.managementFee(block_identifier=block) / 1e4 if hasattr(vault_contract, "managementFee") else 0

else:
# used for APY calculation previews
performance = 0.1
management = 0
keep_bal = 0

return performance, management, keep_bal

def get_aura_emission_rate(block=None) -> float:
aura = contract(addresses[chain.id]['aura'])
initial_mint = aura.INIT_MINT_AMOUNT()
supply = aura.totalSupply(block_identifier=block)
max_supply = initial_mint + aura.EMISSIONS_MAX_SUPPLY()

if supply <= max_supply:
total_cliffs = aura.totalCliffs()
minter_minted = get_aura_minter_minted(block)
reduction_per_cliff = aura.reductionPerCliff()
current_cliff = (supply - initial_mint - minter_minted) / reduction_per_cliff
reduction = 2.5 * (total_cliffs - current_cliff) + 700

if os.getenv('DEBUG', None):
logger.info(pformat(Debug().collect_variables(locals())))

return reduction / total_cliffs
else:
if os.getenv('DEBUG', None):
logger.info(pformat(Debug().collect_variables(locals())))

return 0

def get_aura_minter_minted(block=None) -> float:
"""According to Aura's docs you should use the minterMinted field when calculating the
current aura emission rate. The minterMinted field is private in the contract though!?
So get it by storage slot"""
return web3.eth.get_storage_at(addresses[chain.id]['aura'], 7, block_identifier=block)

def get_debt_ratio(vault, strategy) -> float:
return vault.vault.strategies(strategy)[2] / 1e4

def calculate_24hr_swap_fees_apr(pool, block=None):
if not block: block = closest_block_after_timestamp(datetime.today(), True)
yesterday = closest_block_after_timestamp((datetime.today() - timedelta(days=1)).timestamp(), True)
swap_fees_now = get_total_swap_fees(pool.getPoolId(), block)
swap_fees_yesterday = get_total_swap_fees(pool.getPoolId(), yesterday)
swap_fees_delta = float(swap_fees_now['totalSwapFee']) - float(swap_fees_yesterday['totalSwapFee'])
return swap_fees_delta * 365 / float(swap_fees_now['totalLiquidity'])

def get_total_swap_fees(pool_id, block):
swap_fees_gql_vars = { 'pool_id': str(pool_id), 'block': block }
return gql_post(subgraphs[Network.Mainnet], swap_fees_gql_vars, """
query days {
pool(id: $pool_id, block: { number: $block }) {
totalSwapFee,
totalLiquidity
}
}
""")['data']['pool']
52 changes: 52 additions & 0 deletions yearn/apy/booster.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
from time import time

from yearn.apy.common import get_reward_token_price, SECONDS_PER_YEAR
from yearn.utils import contract, get_block_timestamp

def get_booster_fee(booster, block=None) -> float:
"""The fee % that the booster charges on yield."""
lock_incentive = booster.lockIncentive(block_identifier=block)
staker_incentive = booster.stakerIncentive(block_identifier=block)
earmark_incentive = booster.earmarkIncentive(block_identifier=block)
platform_fee = booster.platformFee(block_identifier=block)
return (lock_incentive + staker_incentive + earmark_incentive + platform_fee) / 1e4

def get_booster_reward_apr(
strategy,
booster,
pool_price_per_share,
pool_token_price,
kp3r=None, rkp3r=None,
block=None
) -> float:
"""The cumulative apr of all extra tokens that are emitted by depositing
to the booster, assuming they will be sold for profit.
"""
if hasattr(strategy, "id"):
# Convex hBTC strategy uses id rather than pid - 0x7Ed0d52C5944C7BF92feDC87FEC49D474ee133ce
pid = strategy.id()
else:
pid = strategy.pid()

# get bonus rewards from rewards contract
# even though rewards are in different tokens,
# the pool info field is "crvRewards" for both convex and aura
rewards_contract = contract(booster.poolInfo(pid)['crvRewards'])
rewards_length = rewards_contract.extraRewardsLength()
current_time = time() if block is None else get_block_timestamp(block)
if rewards_length == 0:
return 0

total_apr = 0
for x in range(rewards_length):
virtual_rewards_pool = contract(rewards_contract.extraRewards(x))
if virtual_rewards_pool.periodFinish() > current_time:
reward_token = virtual_rewards_pool.rewardToken()
reward_token_price = get_reward_token_price(reward_token, kp3r, rkp3r, block)
reward_apr = (
(virtual_rewards_pool.rewardRate() * SECONDS_PER_YEAR * reward_token_price)
/ (pool_token_price * (pool_price_per_share / 1e18) * virtual_rewards_pool.totalSupply())
)
total_apr += reward_apr

return total_apr
Loading