diff --git a/scripts/debug_apy.py b/scripts/debug_apy.py index 7531feb41..1381673cb 100644 --- a/scripts/debug_apy.py +++ b/scripts/debug_apy.py @@ -1,3 +1,6 @@ +import os +import sys +import time import logging import os import traceback @@ -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) @@ -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]]() diff --git a/yearn/apy/__init__.py b/yearn/apy/__init__.py index 47b08bc7f..5907adc1b 100644 --- a/yearn/apy/__init__.py +++ b/yearn/apy/__init__.py @@ -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 diff --git a/yearn/apy/balancer/simple.py b/yearn/apy/balancer/simple.py new file mode 100644 index 000000000..93296a5f3 --- /dev/null +++ b/yearn/apy/balancer/simple.py @@ -0,0 +1,417 @@ +import asyncio +import logging +import os +from dataclasses import dataclass +from datetime import datetime, timedelta +from decimal import Decimal +from pprint import pformat +from typing import TYPE_CHECKING, Dict + +from async_lru import alru_cache +from brownie import chain +from y import ERC20, Contract, Network, magic +from y.datatypes import Address +from y.prices.dex.balancer.v2 import BalancerV2Pool, balancer +from y.time import closest_block_after_timestamp_async +from y.utils.dank_mids import dank_w3 + +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 + +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 + total_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 + +@alru_cache +async def get_pool(poolId: bytes) -> BalancerV2Pool: + return BalancerV2Pool((await balancer.vaults[0].contract.getPool.coroutine(poolId.hex()))[0]) + +def is_aura_vault(vault: "Vault") -> bool: + return len(vault.strategies) == 1 and 'aura' in vault.strategies[0].name.lower() + +async def get_gauge(token) -> Contract: + gauges = await get_all_gauges() + return gauges[token] + +_ignore_gauges = ["SingleRecipientGauge", "ArbitrumRootGauge", "GnosisRootGauge", "OptimismRootGauge", "PolygonRootGauge", "PolygonZkEVMRootGauge"] + +@alru_cache +async def get_all_gauges() -> Dict[Address, Contract]: + gauge_controller = await Contract.coroutine(addresses[chain.id]['gauge_controller']) + num_gauges = await gauge_controller.n_gauges.coroutine() + gauges = await asyncio.gather(*[gauge_controller.gauges.coroutine(i) for i in range(num_gauges)]) + gauges = await asyncio.gather(*[Contract.coroutine(gauge) for gauge in gauges]) + gauges = [gauge for gauge in gauges if gauge._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} + +async 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 = await Contract.coroutine(vault.token.address) + + try: + gauge = await get_gauge(vault.token.address) + except KeyError as e: + raise ApyError('bal', 'gauge factory indicates no gauge exists') from e + + try: + gauge_inflation_rate = await gauge.inflation_rate.coroutine(block_identifier=now) + except AttributeError as e: + raise ApyError('bal', f'gauge {gauge} {str(e)[str(e).find("object"):]}') from e + + gauge_working_supply = await gauge.working_supply.coroutine(block_identifier=now) + if gauge_working_supply == 0: + raise ApyError('bal', 'gauge working supply is zero') + + gauge_controller = await Contract.coroutine(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()))) + + # would probably be best to calculate both current and future expected APR. one comes from Aura gauge, the other comes from balancer gauge + return await calculate_simple( + vault, + Gauge(pool.address, pool, gauge, gauge_weight, gauge_inflation_rate, gauge_working_supply), + samples + ) + +async 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, (performance_fee, management_fee, keep_bal, keep_aura), bal_price = await asyncio.gather( + magic.get_price(gauge.lp_token, block=now, sync=False), + get_vault_fees(vault, block=now), + magic.get_price(addresses[chain.id]['bal'], block=now, sync=False), + ) + + # this is pulled directly from aura's pool deposit contract (ERC-4626) + current_aura_apr = await get_current_aura_apr( + vault, gauge, + pool_token_price, + block=now + ) + + # this is pulled from the balancer gauge + future_aura_apr = await get_future_aura_apr( + vault, gauge, + pool_token_price, + block=now + ) + + # once we have balancer strategies, add a line here to pull base_apr from gauge + + # make sure none of our APRs are super high + if future_aura_apr.bal_apr > 1000 or future_aura_apr.aura_apr > 1000 or current_aura_apr.bal_apr > 1000 or current_aura_apr.aura_apr > 1000: + raise ApyError('aura', f'apr data too big {apr_data}') + + # we want to calculate current/future APR, as well as current APY + current_gross_apr = current_aura_apr.gross_apr * current_aura_apr.debt_ratio + future_gross_apr = future_aura_apr.gross_apr * future_aura_apr.debt_ratio + gross_apr = current_gross_apr + + current_net_apr = current_gross_apr * (1 - performance_fee) - management_fee + future_net_apr = future_gross_apr * (1 - performance_fee) - management_fee + current_net_apy = float(Decimal(1 + (current_net_apr / COMPOUNDING)) ** COMPOUNDING - 1) + net_apy = current_net_apy + + fees = ApyFees( + performance=performance_fee, + management=management_fee, + keep_crv=keep_bal, + cvx_keep_crv=keep_aura + ) + + if os.getenv('DEBUG', None): + logger.info(pformat(Debug().collect_variables(locals()))) + + composite = { + "boost": current_aura_apr.boost, + "current_bal_rewards_apr": current_aura_apr.bal_apr, + "current_aura_rewards_apr": current_aura_apr.aura_apr, + "swap_fees_and_yield_apr": current_aura_apr.swap_fees_apr, + "bonus_rewards_apr": current_aura_apr.bonus_rewards_apr, + "future_bal_rewards_apr": future_aura_apr.bal_apr, + "future_aura_rewards_apr": future_aura_apr.aura_apr, + "current_gross_apr": current_gross_apr, + "future_gross_apr": future_gross_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) + +async def get_current_aura_apr( + vault, + gauge, + pool_token_price, + block=None +) -> AuraAprData: + """Calculate the current APR using our Aura staking pool""" + strategy = vault.strategies[0].strategy + debt_ratio, aura_boost = await asyncio.gather( + get_debt_ratio(vault, strategy), + gauge.calculate_boost(MAX_BOOST, addresses[chain.id]['booster_voter'], block), + ) + + bal_price, aura_price, rewards = await asyncio.gather( + magic.get_price(addresses[chain.id]['bal'], block=block, sync=False), + magic.get_price(addresses[chain.id]['aura'], block=block, sync=False), + strategy.rewardsContract.coroutine(), + ) + + # this gives us the total supply of LP tokens deposited via aura + rewards, aura_rewards_total_supply = await asyncio.gather( + Contract.coroutine(rewards), + ERC20(rewards, asynchronous=True).total_supply_readable(), + ) + + # total TVL deposited via aura + aura_rewards_tvl = pool_token_price * aura_rewards_total_supply + if not aura_rewards_tvl: + raise ApyError('bal', 'rewards tvl is 0') + + # pull our rewardRate, which is bal emissions per second (from aura, post-fee) + reward_rate, scale = await asyncio.gather(rewards.rewardRate.coroutine(), ERC20(rewards, asynchronous=True).scale) + logger.info(f'strategy: {strategy} rewards: {rewards} reward rate: {reward_rate} scale: {scale}') + bal_rewards_per_year = (reward_rate / scale) * SECONDS_PER_YEAR + bal_rewards_per_year_usd = bal_rewards_per_year * bal_price + bal_rewards_apr = bal_rewards_per_year_usd / aura_rewards_tvl + + # based on our BAL emissions, calculate how much AURA we will receive. also pull bonus rewards and swap fees. + aura_emission_rate, swap_fees_apr, bonus_rewards_apr = await asyncio.gather( + get_aura_emission_rate(block), + calculate_24hr_swap_fees_apr(gauge.pool, block), + get_bonus_rewards_apr(rewards, aura_rewards_tvl), + ) + + 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 / aura_rewards_tvl + + total_apr = ( + bal_rewards_apr + + aura_rewards_apr + + swap_fees_apr + + bonus_rewards_apr + ) + + if os.getenv('DEBUG', None): + logger.info(pformat(Debug().collect_variables(locals()))) + + return AuraAprData( + aura_boost, + bal_rewards_apr, + aura_rewards_apr, + swap_fees_apr, + bonus_rewards_apr, + total_apr, + debt_ratio + ) + +async def get_future_aura_apr( + vault, + gauge, + pool_token_price, + block=None +) -> AuraAprData: + """Calculate our future Aura APR (end of epoch target) using the Balancer gauge""" + strategy = vault.strategies[0].strategy + debt_ratio, booster, aura_boost = await asyncio.gather( + get_debt_ratio(vault, strategy), + Contract.coroutine(addresses[chain.id]['booster']), + gauge.calculate_boost(MAX_BOOST, addresses[chain.id]['booster_voter'], block), + ) + booster_fee = get_booster_fee(booster, block) # these are the fees aura charges on BAL emissions collected + + bal_price, aura_price, rewards = await asyncio.gather( + magic.get_price(addresses[chain.id]['bal'], block=block, sync=False), + magic.get_price(addresses[chain.id]['aura'], block=block, sync=False), + strategy.rewardsContract.coroutine(), + ) + rewards, aura_rewards_total_supply = await asyncio.gather( + Contract.coroutine(rewards), + ERC20(rewards, asynchronous=True).total_supply_readable(), + ) + + # technically we could pull the bonus rewards from the gauge here, but not really worth the trouble + aura_rewards_tvl = pool_token_price * aura_rewards_total_supply + aura_emission_rate, swap_fees_apr, bonus_rewards_apr = await asyncio.gather( + get_aura_emission_rate(block), + calculate_24hr_swap_fees_apr(gauge.pool, block), + get_bonus_rewards_apr(rewards, aura_rewards_tvl), + ) + + # calculate our base balancer gauge APR. once we have a balancer-only strategy, use this same code there for base APR+boost. + base_apr = gauge.calculate_base_apr(MAX_BOOST, bal_price, pool_token_price) + aura_bal_apr = base_apr * aura_boost + bal_rewards_apr = aura_bal_apr / (1 - booster_fee) + aura_rewards_apr = bal_rewards_apr * (aura_price / bal_price) * aura_emission_rate + + if not aura_rewards_total_supply: + raise ApyError('bal', 'rewards tvl is 0') + + total_apr = ( + bal_rewards_apr + + aura_rewards_apr + + swap_fees_apr + + bonus_rewards_apr + ) + + if os.getenv('DEBUG', None): + logger.info(pformat(Debug().collect_variables(locals()))) + + return AuraAprData( + aura_boost, + bal_rewards_apr, + aura_rewards_apr, + swap_fees_apr, + bonus_rewards_apr, + total_apr, + debt_ratio + ) + +async def get_bonus_rewards_apr(rewards, rewards_tvl, block=None): + result = 0 + for index in range(rewards.extraRewardsLength(block_identifier=block)): + extra_rewards = await Contract.coroutine(await rewards.extraRewards.coroutine(index)) + reward_token = extra_rewards + if hasattr(extra_rewards, 'rewardToken'): + reward_token = await Contract.coroutine(await extra_rewards.rewardToken.coroutine()) + + extra_reward_rate, reward_token_scale, reward_token_price = await asyncio.gather( + extra_rewards.rewardRate.coroutine(block_identifier=block), + ERC20(reward_token, asynchronous=True).scale, + magic.get_price(reward_token, block=block, sync=False), + ) + extra_rewards_per_year = (extra_reward_rate / reward_token_scale) * SECONDS_PER_YEAR + extra_rewards_per_year_usd = extra_rewards_per_year * reward_token_price + result += extra_rewards_per_year_usd / rewards_tvl + return result + +async def get_vault_fees(vault, block=None): + if vault: + vault_contract = vault.vault + if len(vault.strategies) > 0 and hasattr(vault.strategies[0].strategy, 'localKeepCRV'): + keep_bal = await vault.strategies[0].strategy.localKeepCRV.coroutine(block_identifier=block) / 1e4 + else: + keep_bal = 0 + if len(vault.strategies) > 0 and hasattr(vault.strategies[0].strategy, 'localKeepCVX'): + keep_aura = await vault.strategies[0].strategy.localKeepCVX.coroutine(block_identifier=block) / 1e4 + else: + keep_aura = 0 + performance = await vault_contract.performanceFee.coroutine(block_identifier=block) / 1e4 if hasattr(vault_contract, "performanceFee") else 0 + management = await vault_contract.managementFee.coroutine(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 + keep_aura = 0 + + return performance, management, keep_bal, keep_aura + +async def get_aura_emission_rate(block=None) -> float: + aura = await Contract.coroutine(addresses[chain.id]['aura']) + initial_mint, supply, max_supply = await asyncio.gather( + aura.INIT_MINT_AMOUNT.coroutine(), + aura.totalSupply.coroutine(block_identifier=block), + aura.EMISSIONS_MAX_SUPPLY.coroutine(), + ) + max_supply += initial_mint + + if supply <= max_supply: + total_cliffs, reduction_per_cliff, minter_minted = await asyncio.gather( + aura.totalCliffs.coroutine(block_identifier=block), + aura.reductionPerCliff.coroutine(block_identifier=block), + get_aura_minter_minted(block), + ) + 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 + +async 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 = await dank_w3.eth.get_storage_at(addresses[chain.id]['aura'], 7, block_identifier=block) + return int(hb.hex(), 16) + +async def get_debt_ratio(vault, strategy) -> float: + info = await vault.vault.strategies.coroutine(strategy) + return info[2] / 1e4 + +async def calculate_24hr_swap_fees_apr(pool: Contract, block=None): + if not block: block = await closest_block_after_timestamp_async(datetime.today(), True) + yesterday = await closest_block_after_timestamp_async((datetime.today() - timedelta(days=1)), True) + pool = BalancerV2Pool(pool, asynchronous=True) + swap_fees_now, swap_fees_yesterday, pool_tvl = await asyncio.gather( + get_total_swap_fees(await pool.id, block), + get_total_swap_fees(await pool.id, yesterday), + pool.get_tvl(block=block), + ) + swap_fees_delta = float(swap_fees_now) - float(swap_fees_yesterday) + return swap_fees_delta * 365 / float(pool_tvl) + +async def get_total_swap_fees(pool_id: bytes, block: int) -> int: + pool = await get_pool(pool_id) + return await pool.contract.getRate.coroutine(block_identifier=block) / 10 ** 18 + \ No newline at end of file diff --git a/yearn/apy/booster.py b/yearn/apy/booster.py new file mode 100644 index 000000000..5a811852a --- /dev/null +++ b/yearn/apy/booster.py @@ -0,0 +1,55 @@ +from time import time + +from y.time import get_block_timestamp + +from yearn.apy.common import SECONDS_PER_YEAR, get_reward_token_price +from yearn.utils import contract + + +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 diff --git a/yearn/apy/common.py b/yearn/apy/common.py index 705052124..6302cc586 100644 --- a/yearn/apy/common.py +++ b/yearn/apy/common.py @@ -1,9 +1,8 @@ from dataclasses import dataclass from datetime import datetime, timedelta -from typing import Dict, Optional +from typing import Dict, Optional, Tuple -from brownie import web3 -from semantic_version.base import Version +from brownie import interface, web3 from y.time import closest_block_after_timestamp SECONDS_PER_YEAR = 31_556_952.0 @@ -87,3 +86,37 @@ def get_samples(now_time: Optional[datetime] = None) -> ApySamples: week_ago = closest_block_after_timestamp(int((now_time - timedelta(days=7)).timestamp()), True) month_ago = closest_block_after_timestamp(int((now_time - timedelta(days=31)).timestamp()), True) return ApySamples(now, week_ago, month_ago) + +def get_reward_token_price(reward_token, kp3r=None, rkp3r=None, block=None): + from yearn.prices import magic + + # if the reward token is rKP3R we need to calculate it's price in + # terms of KP3R after the discount + if str(reward_token) == rkp3r: + rKP3R_contract = interface.rKP3R(reward_token) + discount = rKP3R_contract.discount(block_identifier=block) + return magic.get_price(kp3r, block=block) * (100 - discount) / 100 + else: + return magic.get_price(reward_token, block=block) + +def calculate_pool_apy(vault, price_per_share_function, samples) -> Tuple[float, float]: + now_price = price_per_share_function(block_identifier=samples.now) + try: + week_ago_price = price_per_share_function(block_identifier=samples.week_ago) + except ValueError: + raise ApyError("common", "insufficient data") + + now_point = SharePricePoint(samples.now, now_price) + week_ago_point = SharePricePoint(samples.week_ago, week_ago_price) + + # FIXME: crvANKR's pool apy going crazy + if vault and vault.vault.address == "0xE625F5923303f1CE7A43ACFEFd11fd12f30DbcA4": + return 0, 0 + + # Curve USDT Pool yVault apr is way too high which fails the apy calculations with a OverflowError + elif vault and vault.vault.address == "0x28a5b95C101df3Ded0C0d9074DB80C438774B6a9": + return 0, 0 + + else: + pool_apr = calculate_roi(now_point, week_ago_point) + return pool_apr, (((pool_apr / 365) + 1) ** 365) - 1 diff --git a/yearn/apy/curve/rewards.py b/yearn/apy/curve/rewards.py index 877abf151..1149a7a5e 100644 --- a/yearn/apy/curve/rewards.py +++ b/yearn/apy/curve/rewards.py @@ -10,15 +10,15 @@ from yearn.apy.common import SECONDS_PER_YEAR -async def rewards(address: str, pool_price: int, base_asset_price: int, block: Optional[int]=None) -> float: +async def rewards(address: str, base_asset_price: int, block: Optional[int]=None) -> float: staking_rewards = await Contract.coroutine(address) if hasattr(staking_rewards, "periodFinish"): - return await staking(staking_rewards, pool_price, base_asset_price, block=block) + return await staking(staking_rewards, base_asset_price, block=block) else: - return await multi(address, pool_price, base_asset_price, block=block) + return await multi(address, base_asset_price, block=block) -async def staking(staking_rewards: Contract, pool_price: int, base_asset_price: int, block: Optional[int]=None) -> float: +async def staking(staking_rewards: Contract, base_asset_price: int, block: Optional[int]=None) -> float: end = await staking_rewards.periodFinish.coroutine(block_identifier=block) current_time = time() if block is None else await get_block_timestamp_async(block) @@ -42,9 +42,7 @@ async def staking(staking_rewards: Contract, pool_price: int, base_asset_price: if token and rate: # Single reward token token_price = await magic.get_price(token, block=block, sync=False) - return (SECONDS_PER_YEAR * (rate / 1e18) * token_price) / ( - (pool_price / 1e18) * (total_supply / 1e18) * base_asset_price - ) + return (SECONDS_PER_YEAR * (rate / 1e18) * token_price) / ((total_supply / 1e18) * base_asset_price) else: # Multiple reward tokens queue = 0 @@ -63,7 +61,7 @@ async def staking(staking_rewards: Contract, pool_price: int, base_asset_price: token = None rate = data.rewardRate / 1e18 if data else 0 token_price = await magic.get_price(token, block=block, sync=False) or 0 - apr += SECONDS_PER_YEAR * rate * token_price / ((pool_price / 1e18) * (total_supply / 1e18) * token_price) + apr += SECONDS_PER_YEAR * rate * token_price / ((total_supply / 1e18) * token_price) queue += 1 try: token = await staking_rewards.rewardTokens.coroutine(queue, block_identifier=block) @@ -72,7 +70,7 @@ async def staking(staking_rewards: Contract, pool_price: int, base_asset_price: return apr -async def multi(address: str, pool_price: int, base_asset_price: int, block: Optional[int]=None) -> float: +async def multi(address: str, base_asset_price: int, block: Optional[int]=None) -> float: multi_rewards = await Contract.coroutine(address) total_supply = await multi_rewards.totalSupply.coroutine(block_identifier=block) if hasattr(multi_rewards, "totalSupply") else 0 @@ -91,7 +89,7 @@ async def multi(address: str, pool_price: int, base_asset_price: int, block: Opt if data.periodFinish >= time(): rate = data.rewardRate / 1e18 if data else 0 token_price = await magic.get_price(token, block=block, sync=False) or 0 - apr += SECONDS_PER_YEAR * rate * token_price / ((pool_price / 1e18) * (total_supply / 1e18) * token_price) + apr += SECONDS_PER_YEAR * rate * token_price / ((total_supply / 1e18) * token_price) queue += 1 try: token = await multi_rewards.rewardTokens.coroutine(queue, block_identifier=block) diff --git a/yearn/apy/curve/simple.py b/yearn/apy/curve/simple.py index 5cf94960f..8d76455f7 100644 --- a/yearn/apy/curve/simple.py +++ b/yearn/apy/curve/simple.py @@ -4,9 +4,8 @@ import os from dataclasses import dataclass from decimal import Decimal -from pprint import pformat from functools import lru_cache - +from pprint import pformat from time import time import requests @@ -25,10 +24,10 @@ ApyError, ApyFees, ApySamples, SharePricePoint, calculate_roi) from yearn.apy.curve.rewards import rewards +from yearn.apy.gauge import Gauge from yearn.apy.staking_rewards import get_staking_rewards_apr from yearn.debug import Debug from yearn.prices.curve import curve, curve_contracts -from yearn.typing import Address from yearn.utils import contract @@ -40,15 +39,6 @@ class ConvexDetailedApyData: cvx_debt_ratio: float = 0 convex_reward_apr: float = 0 -@dataclass -class Gauge: - lp_token: Address - pool: Contract - gauge: Contract - gauge_weight: int - gauge_inflation_rate: int - gauge_working_supply: int - logger = logging.getLogger(__name__) @@ -151,11 +141,9 @@ async def calculate_simple(vault, gauge: Gauge, samples: ApySamples) -> Apy: if not base_asset_price: raise ValueError(f"Error! Could not find price for {gauge.lp_token} at block {block}") - crv_price, pool_price = await asyncio.gather( - magic.get_price(curve.crv, block=block, sync=False), - gauge.pool.get_virtual_price.coroutine(block_identifier=block) + crv_price = await asyncio.gather( + magic.get_price(curve.crv, block=block, sync=False) ) - gauge_weight = gauge.gauge_weight if chain.id == Network.Mainnet: yearn_voter = addresses[chain.id]['yearn_voter_proxy'] @@ -166,29 +154,15 @@ async def calculate_simple(vault, gauge: Gauge, samples: ApySamples) -> Apy: else: y_working_balance = 0 y_gauge_balance = 0 + reward_token = await gauge.gauge.reward_tokens.coroutine(0) + reward_data = await gauge.gauge.reward_data.coroutine(reward_token) - if await gauge.gauge.reward_count.coroutine() == 0: - gauge_weight = 1 - pool_price = 1 - else: - reward_token = await gauge.gauge.reward_tokens.coroutine(0) - reward_data = await gauge.gauge.reward_data.coroutine(reward_token) - if time() > reward_data['period_finish']: - gauge_weight = 1 - pool_price = 1 - - base_apr = ( - gauge.gauge_inflation_rate - * gauge_weight - * (SECONDS_PER_YEAR / gauge.gauge_working_supply) - * (PER_MAX_BOOST / pool_price) - * crv_price - ) / base_asset_price - - if y_gauge_balance > 0: - y_boost = y_working_balance / (PER_MAX_BOOST * y_gauge_balance) - else: - y_boost = BOOST[chain.id] + base_apr = gauge.calculate_base_apr(BOOST[chain.id], float(crv_price), base_asset_price) + if base_apr > 1000: + raise ApyError('crv', f'base apr too high: {base_apr}', f'MAX BOOST: {MAX_BOOST} crv price: {crv_price} base asset price: {base_asset_price}') + + voter_proxy = addresses[chain.id]['yearn_voter_proxy'] if chain.id in addresses else None + y_boost = await gauge.calculate_boost(BOOST[chain.id], voter_proxy, block) # FIXME: The HBTC v1 vault is currently still earning yield, but it is no longer boosted. if vault and vault.vault.address == "0x46AFc2dfBd1ea0c0760CAD8262A5838e803A37e5": @@ -323,16 +297,21 @@ async def calculate_simple(vault, gauge: Gauge, samples: ApySamples) -> Apy: crv_debt_ratio = 1 crv_apr = base_apr * y_boost + reward_apr + if crv_apr > 1000: + raise ApyError('crv', f'crv apr too high: {crv_apr}', f'base apr: {base_apr} yboost: {y_boost} reward apr: {reward_apr}') + crv_apr_minus_keep_crv = base_apr * y_boost * (1 - crv_keep_crv) gross_apr = (1 + (crv_apr * crv_debt_ratio + cvx_apy_data.cvx_apr * cvx_apy_data.cvx_debt_ratio)) * (1 + pool_apy) - 1 + if gross_apr > 1000: + raise ApyError('crv', f'gross apr too high: {gross_apr}', f'crv apr: {crv_apr} crv debt ratio: {crv_debt_ratio} cvx apr: {cvx_apy_data.cvx_apr} cvx debt ratio: {cvx_apy_data.cvx_debt_ratio} pool apy: {pool_apy}') cvx_net_apr = (cvx_apy_data.cvx_apr_minus_keep_crv + cvx_apy_data.convex_reward_apr) * (1 - performance) - management cvx_net_farmed_apy = (1 + (cvx_net_apr / COMPOUNDING)) ** COMPOUNDING - 1 cvx_net_apy = ((1 + cvx_net_farmed_apy) * (1 + pool_apy)) - 1 crv_net_apr = (crv_apr_minus_keep_crv + reward_apr) * (1 - performance) - management - crv_net_farmed_apy = (1 + (crv_net_apr / COMPOUNDING)) ** COMPOUNDING - 1 + crv_net_farmed_apy = float(Decimal(1 + (crv_net_apr / COMPOUNDING)) ** COMPOUNDING - 1) crv_net_apy = ((1 + crv_net_farmed_apy) * (1 + pool_apy)) - 1 net_apy = crv_net_apy * crv_debt_ratio + cvx_net_apy * cvx_apy_data.cvx_debt_ratio diff --git a/yearn/apy/gauge.py b/yearn/apy/gauge.py new file mode 100644 index 000000000..175bd88e5 --- /dev/null +++ b/yearn/apy/gauge.py @@ -0,0 +1,82 @@ +import asyncio +import logging +from dataclasses import dataclass +from time import time + +from brownie import ZERO_ADDRESS +from y import Contract +from y.time import get_block_timestamp + +from yearn.apy.common import SECONDS_PER_YEAR, get_reward_token_price +from yearn.apy.curve.rewards import rewards +from yearn.typing import Address + +logger = logging.getLogger(__name__) + + +@dataclass +class Gauge: + lp_token: Address + pool: Contract + gauge: Contract + gauge_weight: int + gauge_inflation_rate: int + gauge_working_supply: int + + def calculate_base_apr(self, max_boost, reward_price, pool_token_price) -> float: + logger.info(f'max boost: {max_boost}') + logger.info(f'reward price: {reward_price}') + logger.info(f'pool token price: {pool_token_price}') + logger.info(f'inflation rate: {self.gauge_inflation_rate}') + logger.info(f'weight: {self.gauge_weight}') + logger.info(f'working supply: {self.gauge_working_supply}') + + return ( + self.gauge_inflation_rate + * self.gauge_weight + * (SECONDS_PER_YEAR / self.gauge_working_supply) + * (1.0 / max_boost) + * reward_price + ) / pool_token_price + + async def calculate_boost(self, max_boost, address, block=None) -> float: + if address is None: + balance, working_balance = 0, 0 + else: + balance, working_balance = await asyncio.gather( + self.gauge.balanceOf.coroutine(address, block_identifier=block), + self.gauge.working_balances.coroutine(address, block_identifier=block), + ) + if balance > 0: + return working_balance / ((1.0 / max_boost) * balance) or 1 + else: + return max_boost + + def calculate_rewards_apr(self, pool_token_price, kp3r=None, rkp3r=None, block=None) -> float: + if hasattr(self.gauge, "reward_contract"): + reward_address = self.gauge.reward_contract() + if reward_address != ZERO_ADDRESS: + return rewards(reward_address, pool_token_price, block=block) + + elif hasattr(self.gauge, "reward_data"): # this is how new gauges, starting with MIM, show rewards + # get our token + # TODO: consider adding for loop with [gauge.reward_tokens(i) for i in range(gauge.reward_count())] for multiple rewards tokens + gauge_reward_token = self.gauge.reward_tokens(0) + if gauge_reward_token in [ZERO_ADDRESS]: + logger.warn(f"no reward token for gauge {str(self.gauge)}") + else: + reward_data = self.gauge.reward_data(gauge_reward_token) + rate = reward_data['rate'] + period_finish = reward_data['period_finish'] + total_supply = self.gauge.totalSupply() + token_price = get_reward_token_price(gauge_reward_token, kp3r, rkp3r) + current_time = time() if block is None else get_block_timestamp(block) + if period_finish < current_time: + return 0 + else: + return ( + (SECONDS_PER_YEAR * (rate / 1e18) * token_price) + / ((total_supply / 1e18) * pool_token_price) + ) + + return 0 diff --git a/yearn/prices/curve.py b/yearn/prices/curve.py index cb597f24f..a6af2de02 100644 --- a/yearn/prices/curve.py +++ b/yearn/prices/curve.py @@ -20,21 +20,20 @@ from enum import IntEnum from typing import Dict, List, Optional -from brownie import ZERO_ADDRESS, Contract, chain, convert, interface +from brownie import ZERO_ADDRESS, chain, convert, interface from brownie.convert import to_address from brownie.convert.datatypes import EthAddress from cachetools.func import lru_cache, ttl_cache +from y import Contract, Network, magic from y.constants import EEE_ADDRESS from y.exceptions import NodeNotSynced, PriceError -from y.networks import Network -from y.prices import magic from yearn.decorators import sentry_catch_all, wait_or_exit_after from yearn.events import decode_logs, get_logs_asap from yearn.exceptions import UnsupportedNetwork from yearn.multicall2 import fetch_multicall, fetch_multicall_async from yearn.typing import Address, AddressOrContract, Block -from yearn.utils import Singleton, contract, get_event_loop +from yearn.utils import Singleton, contract logger = logging.getLogger(__name__) @@ -552,8 +551,8 @@ async def calculate_boost(self, gauge: Contract, addr: Address, block: Optional[ } async def calculate_apy(self, gauge: Contract, lp_token: AddressOrContract, block: Optional[Block] = None) -> Dict[str,float]: - pool = contract(self.get_pool(lp_token)) - results = fetch_multicall_async( + pool = await Contract.coroutine(self.get_pool(lp_token)) + results = await fetch_multicall_async( [gauge, "working_supply"], [self.gauge_controller, "gauge_relative_weight", gauge], [gauge, "inflation_rate"], diff --git a/yearn/v2/vaults.py b/yearn/v2/vaults.py index 930af17d6..fff8d3a0f 100644 --- a/yearn/v2/vaults.py +++ b/yearn/v2/vaults.py @@ -13,15 +13,13 @@ from semantic_version.base import Version from y import ERC20, Contract, Network, magic from y.exceptions import NodeNotSynced, PriceError, yPriceMagicError -from y.networks import Network -from y.prices import magic from y.utils.events import get_logs_asap from yearn.common import Tvl from yearn.decorators import sentry_catch_all, wait_or_exit_after from yearn.events import decode_logs, get_logs_asap from yearn.multicall2 import fetch_multicall_async -from yearn.prices.curve import curve +from yearn.prices.balancer import balancer from yearn.special import Ygov from yearn.typing import Address from yearn.utils import run_in_thread, safe_views @@ -273,6 +271,8 @@ async def apy(self, samples: "ApySamples"): return await apy.curve.simple(self, samples) elif pool := await apy.velo.get_staking_pool(self.token.address): return await apy.velo.staking(self, pool, samples) + elif self._needs_balancer_simple(): + return await apy.balancer.simple(self, samples) elif Version(self.api_version) >= Version("0.3.2"): return await apy.v2.average(self, samples) else: @@ -299,6 +299,7 @@ async def tvl(self, block=None): @cached_property def _needs_curve_simple(self): + from yearn.prices.curve import curve # some curve vaults which should not be calculated with curve logic curve_simple_excludes = { Network.Arbitrum: [ @@ -310,3 +311,9 @@ def _needs_curve_simple(self): needs_simple = self.vault.address not in curve_simple_excludes[chain.id] return needs_simple and curve and curve.get_pool(self.token.address) + + def _needs_balancer_simple(self): + exclusions = { + Network.Mainnet: [], + }.get(chain.id, []) + return self.vault.address not in exclusions and balancer.selector.get_balancer_for_pool(self.token.address)