Skip to content

Commit

Permalink
feat: auraBAL apy
Browse files Browse the repository at this point in the history
  • Loading branch information
BobTheBuidler committed Jul 21, 2023
1 parent 9150a5b commit 1f782fa
Show file tree
Hide file tree
Showing 9 changed files with 504 additions and 23 deletions.
10 changes: 9 additions & 1 deletion scripts/debug_apy.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import os
import sys
import time
import logging
import os
import traceback
Expand All @@ -8,12 +11,14 @@

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

def with_exception_handling():
address = os.getenv("DEBUG_ADDRESS", None)
Expand All @@ -31,3 +36,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
@@ -1,4 +1,5 @@
from yearn.apy import v1, v2, velo
from yearn.apy.balancer import simple as balancer
from yearn.apy.common import (Apy, ApyBlocks, ApyError, ApyFees, ApyPoints,
ApySamples, get_samples)
from yearn.apy.curve import simple as curve
303 changes: 303 additions & 0 deletions yearn/apy/balancer/simple.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,303 @@
import logging
import os
from dataclasses import dataclass
from datetime import datetime, timedelta
from decimal import Decimal
from functools import lru_cache
from pprint import pformat
from typing import TYPE_CHECKING, Dict

from brownie import chain, web3
from y import Contract
from y.datatypes import Address
from y.prices.dex.balancer.v2 import BalancerV2Pool, balancer

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

if TYPE_CHECKING:
from yearn.v2.vaults import Vault

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',
}
}

MAX_BOOST = 2.5
COMPOUNDING = 52

_get_pool = lru_cache(balancer.vaults[0].contract.getPool)
get_pool = lambda poolId: BalancerV2Pool(_get_pool(poolId.hex())[0])

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

def get_gauge(token) -> Contract:
return get_all_gauges()[token]

_ignore_gauges = ["SingleRecipientGauge", "ArbitrumRootGauge", "GnosisRootGauge", "OptimismRootGauge", "PolygonRootGauge", "PolygonZkEVMRootGauge"]

@lru_cache
def get_all_gauges() -> Dict[Address, Contract]:
gauge_controller = contract(addresses[chain.id]['gauge_controller'])
gauges = [
gauge for i in range(gauge_controller.n_gauges())
if (gauge:=contract(gauge_controller.gauges(i)) )._name not in _ignore_gauges
]
for gauge in gauges:
if not hasattr(gauge, 'lp_token'):
logger.warning(f'gauge {gauge} has no `lp_token` method')
gauges.remove(gauge)
return {gauge.lp_token(): gauge for gauge in gauges}

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)

try:
gauge = get_gauge(vault.token.address)
except KeyError as e:
raise ApyError('bal', 'gauge factory indicates no gauge exists') from e

try:
gauge_inflation_rate = gauge.inflation_rate(block_identifier=now)
except AttributeError as e:
raise ApyError('bal', f'gauge {gauge} {str(e)[str(e).find("object"):]}') from e

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 = float(Decimal(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,
}

try: # maybe this last arg should just be optional?
blocks = ApyBlocks(
samples.now,
samples.week_ago,
samples.month_ago,
vault.reports[0].block_number
)
except IndexError:
blocks = None

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))
reward_token = extra_rewards
if hasattr(extra_rewards, 'rewardToken'):
reward_token = contract(extra_rewards.rewardToken())

extra_rewards_per_year = (extra_rewards.rewardRate(block_identifier=block) / 10**reward_token.decimals()) * SECONDS_PER_YEAR
extra_rewards_per_year_usd = extra_rewards_per_year * magic.get_price(reward_token, 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"""

# convert HexBytes to int
hb = web3.eth.get_storage_at(addresses[chain.id]['aura'], 7, block_identifier=block)
return int(hb.hex(), 16)

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

def calculate_24hr_swap_fees_apr(pool: Contract, 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)
pool = BalancerV2Pool(pool)
swap_fees_now = get_total_swap_fees(pool.id, block)
swap_fees_yesterday = get_total_swap_fees(pool.id, yesterday)
swap_fees_delta = float(swap_fees_now) - float(swap_fees_yesterday)
pool_tvl = pool.get_tvl(block=block)
return swap_fees_delta * 365 / float(pool_tvl)

def get_total_swap_fees(pool_id: bytes, block: int) -> int:
pool = get_pool(pool_id)
return pool.contract.getRate(block_identifier=block) / 10 ** 18

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

0 comments on commit 1f782fa

Please sign in to comment.