diff --git a/scripts/debug_apy.py b/scripts/debug_apy.py index ca94cc697..44002ae58 100644 --- a/scripts/debug_apy.py +++ b/scripts/debug_apy.py @@ -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) @@ -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]]() diff --git a/yearn/apy/__init__.py b/yearn/apy/__init__.py index e696b4e87..005674635 100644 --- a/yearn/apy/__init__.py +++ b/yearn/apy/__init__.py @@ -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 diff --git a/yearn/apy/balancer/simple.py b/yearn/apy/balancer/simple.py new file mode 100644 index 000000000..73d99c31a --- /dev/null +++ b/yearn/apy/balancer/simple.py @@ -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'] diff --git a/yearn/apy/booster.py b/yearn/apy/booster.py new file mode 100644 index 000000000..40c7a1d5b --- /dev/null +++ b/yearn/apy/booster.py @@ -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 diff --git a/yearn/apy/common.py b/yearn/apy/common.py index 1d1e7b27c..374c7c9db 100644 --- a/yearn/apy/common.py +++ b/yearn/apy/common.py @@ -2,10 +2,9 @@ from dataclasses import dataclass from typing import Dict, Optional -from brownie import web3 +from brownie import interface, web3 from yearn.utils import closest_block_after_timestamp -from semantic_version.base import Version SECONDS_PER_YEAR = 31_556_952.0 @@ -85,3 +84,36 @@ def get_samples(now_time: Optional[datetime] = None) -> ApySamples: week_ago = closest_block_after_timestamp((now_time - timedelta(days=7)).timestamp(), True) month_ago = closest_block_after_timestamp((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 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/simple.py b/yearn/apy/curve/simple.py index 6c1095680..da0b42fa8 100644 --- a/yearn/apy/curve/simple.py +++ b/yearn/apy/curve/simple.py @@ -4,17 +4,18 @@ from pprint import pformat from time import time -from brownie import ZERO_ADDRESS, chain, interface, Contract +from brownie import chain from semantic_version import Version -from yearn.apy.common import (SECONDS_PER_YEAR, Apy, ApyError, ApyFees, - ApySamples, SharePricePoint, calculate_roi) -from yearn.apy.curve.rewards import rewards + +from yearn.apy.booster import get_booster_fee, get_booster_reward_apr +from yearn.apy.common import (Apy, ApyError, ApyFees, + ApySamples, calculate_pool_apy) +from yearn.apy.gauge import Gauge from yearn.networks import Network from yearn.prices import magic from yearn.prices.curve import curve -from yearn.utils import contract, get_block_timestamp +from yearn.utils import contract from yearn.debug import Debug -from yearn.typing import Address @dataclass @@ -25,15 +26,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__) @@ -96,22 +88,9 @@ def calculate_simple(vault, gauge: Gauge, samples: ApySamples) -> Apy: crv_price = magic.get_price(curve.crv, block=block) - yearn_voter = addresses[chain.id]['yearn_voter_proxy'] - y_working_balance = gauge.gauge.working_balances(yearn_voter, block_identifier=block) - y_gauge_balance = gauge.gauge.balanceOf(yearn_voter, block_identifier=block) + base_apr = gauge.calculate_base_apr(MAX_BOOST, crv_price, pool_price, base_asset_price) - base_apr = ( - gauge.gauge_inflation_rate - * gauge.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) or 1 - else: - y_boost = MAX_BOOST + y_boost = gauge.calculate_boost(MAX_BOOST, addresses[chain.id]['yearn_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": @@ -123,53 +102,15 @@ def calculate_simple(vault, gauge: Gauge, samples: ApySamples) -> Apy: y_boost = 2.5 crv_debt_ratio = 1 - # TODO: come up with cleaner way to deal with these new gauge rewards - reward_apr = 0 - if hasattr(gauge.gauge, "reward_contract"): - reward_address = gauge.gauge.reward_contract() - if reward_address != ZERO_ADDRESS: - reward_apr = rewards(reward_address, pool_price, base_asset_price, block=block) - elif hasattr(gauge.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 = gauge.gauge.reward_tokens(0) - if gauge_reward_token in [ZERO_ADDRESS]: - logger.warn(f"no reward token for gauge {str(gauge.gauge)}") - else: - reward_data = gauge.gauge.reward_data(gauge_reward_token) - rate = reward_data['rate'] - period_finish = reward_data['period_finish'] - total_supply = gauge.gauge.totalSupply() - token_price = _get_reward_token_price(gauge_reward_token) - current_time = time() if block is None else get_block_timestamp(block) - if period_finish < current_time: - reward_apr = 0 - else: - reward_apr = (SECONDS_PER_YEAR * (rate / 1e18) * token_price) / ((pool_price / 1e18) * (total_supply / 1e18) * base_asset_price) - else: - reward_apr = 0 - - price_per_share = gauge.pool.get_virtual_price - now_price = price_per_share(block_identifier=samples.now) - try: - week_ago_price = price_per_share(block_identifier=samples.week_ago) - except ValueError: - raise ApyError("crv", "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": - pool_apy = 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": - pool_apy = 0 + reward_apr = gauge.calculate_rewards_apr( + pool_price, + base_asset_price, + addresses[chain.id]['kp3r'], + addresses[chain.id]['rkp3r_rewards'], + block + ) - else: - pool_apr = calculate_roi(now_point, week_ago_point) - pool_apy = (((pool_apr / 365) + 1) ** 365) - 1 + _, pool_apy = calculate_pool_apy(vault, gauge.pool.get_virtual_price, samples) # prevent circular import for partners calculations from yearn.v2.vaults import Vault as VaultV2 @@ -336,8 +277,8 @@ def get_detailed_apy_data(self, base_asset_price, pool_price, base_apr) -> Conve cvx_keep_crv = 0 cvx_booster = contract(addresses[chain.id]['convex_booster']) - cvx_fee = self._get_convex_fee(cvx_booster, self.block) - convex_reward_apr = self._get_reward_apr(self._cvx_strategy, cvx_booster, base_asset_price, pool_price, self.block) + cvx_fee = get_booster_fee(cvx_booster, self.block) + convex_reward_apr = get_booster_reward_apr(self._cvx_strategy, cvx_booster, base_asset_price, pool_price, self.block) cvx_boost = self._get_cvx_boost() cvx_printed_as_crv = self._get_cvx_emissions_converted_to_crv() @@ -369,67 +310,9 @@ def _get_cvx_emissions_converted_to_crv(self) -> float: def _get_cvx_boost(self) -> float: """The Curve boost (1-2.5x) being applied to this pool thanks to veCRV locked in Convex's voter proxy.""" - convex_voter = addresses[chain.id]['convex_voter_proxy'] - cvx_working_balance = self.gauge.working_balances(convex_voter, block_identifier=self.block) - cvx_gauge_balance = self.gauge.balanceOf(convex_voter, block_identifier=self.block) - - if cvx_gauge_balance > 0: - return cvx_working_balance / (PER_MAX_BOOST * cvx_gauge_balance) or 1 - else: - return MAX_BOOST - - def _get_reward_apr(self, cvx_strategy, cvx_booster, base_asset_price, pool_price, block=None) -> float: - """The cumulative apr of all extra tokens that are emitted by depositing - to Convex, assuming that they will be sold for profit. - """ - if hasattr(cvx_strategy, "id"): - # Convex hBTC strategy uses id rather than pid - 0x7Ed0d52C5944C7BF92feDC87FEC49D474ee133ce - pid = cvx_strategy.id() - else: - pid = cvx_strategy.pid() - - # pull data from convex's virtual rewards contracts to get bonus rewards - rewards_contract = contract(cvx_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 - - convex_reward_apr = 0 # reset our rewards apr if we're calculating it via convex - - for x in range(rewards_length): - virtual_rewards_pool = contract(rewards_contract.extraRewards(x)) - # do this for all assets, which will duplicate much of the curve info but we don't want to miss anything - if virtual_rewards_pool.periodFinish() > current_time: - reward_token = virtual_rewards_pool.rewardToken() - reward_token_price = _get_reward_token_price(reward_token, block) - - reward_apr = (virtual_rewards_pool.rewardRate() * SECONDS_PER_YEAR * reward_token_price) / (base_asset_price * (pool_price / 1e18) * virtual_rewards_pool.totalSupply()) - convex_reward_apr += reward_apr - - return convex_reward_apr - - def _get_convex_fee(self, cvx_booster, block=None) -> float: - """The fee % that Convex charges on all CRV yield.""" - cvx_lock_incentive = cvx_booster.lockIncentive(block_identifier=block) - cvx_staker_incentive = cvx_booster.stakerIncentive(block_identifier=block) - cvx_earmark_incentive = cvx_booster.earmarkIncentive(block_identifier=block) - cvx_platform_fee = cvx_booster.platformFee(block_identifier=block) - return (cvx_lock_incentive + cvx_staker_incentive + cvx_earmark_incentive + cvx_platform_fee) / 1e4 + return self.gauge.calculate_boost(MAX_BOOST, addresses[chain.id]['convex_voter_proxy'], self.block) @property def _debt_ratio(self) -> float: """The debt ratio of the Convex strategy.""" return self.vault.vault.strategies(self._cvx_strategy)[2] / 1e4 - - -def _get_reward_token_price(reward_token, block=None): - # if the reward token is rKP3R we need to calculate it's price in - # terms of KP3R after the discount - contract_addresses = addresses[chain.id] - if reward_token == contract_addresses['rkp3r_rewards']: - rKP3R_contract = interface.rKP3R(reward_token) - discount = rKP3R_contract.discount(block_identifier=block) - return magic.get_price(contract_addresses['kp3r'], block=block) * (100 - discount) / 100 - else: - return magic.get_price(reward_token, block=block) diff --git a/yearn/apy/gauge.py b/yearn/apy/gauge.py new file mode 100644 index 000000000..3956b73ce --- /dev/null +++ b/yearn/apy/gauge.py @@ -0,0 +1,67 @@ +import logging +from time import time +from dataclasses import dataclass +from brownie import Contract, interface, ZERO_ADDRESS + +from yearn.typing import Address +from yearn.utils import get_block_timestamp +from yearn.apy.common import get_reward_token_price, SECONDS_PER_YEAR +from yearn.apy.curve.rewards import rewards + +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_price_per_share, pool_token_price) -> float: + return ( + self.gauge_inflation_rate + * self.gauge_weight + * (SECONDS_PER_YEAR / self.gauge_working_supply) + * ((1.0 / max_boost) / pool_price_per_share) + * reward_price + ) / pool_token_price + + def calculate_boost(self, max_boost, address, block=None) -> float: + balance = self.gauge.balanceOf(address, block_identifier=block) + working_balance = self.gauge.working_balances(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_price_per_share, 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_price_per_share, 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) + / ((pool_price_per_share / 1e18) * (total_supply / 1e18) * pool_token_price) + ) + + return 0 diff --git a/yearn/gql.py b/yearn/gql.py new file mode 100644 index 000000000..e9036b26b --- /dev/null +++ b/yearn/gql.py @@ -0,0 +1,20 @@ +import logging +import requests + + +logger = logging.getLogger(__name__) + +def gql_post(endpoint, vars, query): + gql = query + for key in vars: + value = vars[key] + if isinstance(value, str): value = '"' + value + '"' + else: value = str(value) + gql = gql.replace("$" + key, value) + + logger.info(gql) + response = requests.post(url=endpoint, json={'query': gql}) + if response.status_code == 200: + return response.json() + else: + raise RuntimeError(f'response.status_code {response.status_code}') diff --git a/yearn/prices/balancer/v1.py b/yearn/prices/balancer/v1.py index 481827cc3..87aef7e84 100644 --- a/yearn/prices/balancer/v1.py +++ b/yearn/prices/balancer/v1.py @@ -4,7 +4,6 @@ from yearn.cache import memory from yearn.multicall2 import fetch_multicall -from yearn.prices import magic from yearn.utils import contract, Singleton from yearn.networks import Network from yearn.typing import Address, Block @@ -38,6 +37,7 @@ def get_tokens(self, token: Address) -> List: @ttl_cache(ttl=600) def get_price(self, token: Address, block: Optional[Block] = None) -> float: + from yearn.prices import magic pool = contract(token) tokens, supply = fetch_multicall([pool, "getCurrentTokens"], [pool, "totalSupply"], block=block) supply = supply / 1e18 diff --git a/yearn/prices/balancer/v2.py b/yearn/prices/balancer/v2.py index 6fffc7ccd..24a9b3786 100644 --- a/yearn/prices/balancer/v2.py +++ b/yearn/prices/balancer/v2.py @@ -4,7 +4,6 @@ from yearn.cache import memory from yearn.multicall2 import fetch_multicall -from yearn.prices import magic from yearn.utils import contract, Singleton from yearn.networks import Network from yearn.typing import Address, Block @@ -40,6 +39,7 @@ def get_tokens(self, token: Address, block: Optional[Block] = None) -> List: @ttl_cache(ttl=600) def get_price(self, token: Address, block: Optional[Block] = None) -> float: + from yearn.prices import magic pool = contract(token) pool_id = pool.getPoolId() vault = contract(pool.getVault()) diff --git a/yearn/prices/curve.py b/yearn/prices/curve.py index dfed6b002..3e1d8d518 100644 --- a/yearn/prices/curve.py +++ b/yearn/prices/curve.py @@ -12,6 +12,7 @@ v1 = 0x0959158b6040D32d04c301A72CBFD6b39E21c9AE v2 = 0xB9fC157394Af804a3578134A6585C0dc9cc990d4 """ +import os import logging import threading import time @@ -30,7 +31,6 @@ from yearn.exceptions import PriceError, UnsupportedNetwork from yearn.multicall2 import fetch_multicall from yearn.networks import Network -from yearn.prices import magic from yearn.typing import Address, AddressOrContract, Block from yearn.utils import Singleton, contract @@ -114,7 +114,11 @@ def __init__(self) -> None: self._done = threading.Event() self._thread = threading.Thread(target=self.watch_events, daemon=True) self._has_exception = False - self._thread.start() + if not os.getenv('DISABLE_CURVE', False): + self._thread.start() + else: + self._done.set() + logger.warn('Curve exporter disabled') @sentry_catch_all def watch_events(self) -> None: @@ -384,6 +388,7 @@ def get_tvl(self, pool: AddressOrContract, block: Optional[Block] = None) -> flo pool = to_address(pool) balances = self.get_balances(pool, block=block) + from yearn.prices import magic return sum( amount * magic.get_price(coin, block=block) for coin, amount in balances.items() @@ -457,6 +462,7 @@ def get_coin_price(self, token: AddressOrContract, block: Optional[Block] = None token_out = contract(coins[token_out_ix]) amount_out = dy / 10 ** token_out.decimals() try: + from yearn.prices import magic return amount_out * magic.get_price(token_out, block = block) except PriceError: return None @@ -512,6 +518,7 @@ def calculate_boost(self, gauge: Contract, addr: Address, block: Optional[Block] } def calculate_apy(self, gauge: Contract, lp_token: AddressOrContract, block: Optional[Block] = None) -> Dict[str,float]: + from yearn.prices import magic crv_price = magic.get_price(self.crv) pool = contract(self.get_pool(lp_token)) results = fetch_multicall( diff --git a/yearn/prices/generic_amm.py b/yearn/prices/generic_amm.py index 4f1cd43b1..276673b69 100644 --- a/yearn/prices/generic_amm.py +++ b/yearn/prices/generic_amm.py @@ -6,7 +6,6 @@ from yearn.exceptions import PriceError from yearn.multicall2 import fetch_multicall -from yearn.prices import magic from yearn.typing import Address, Block from yearn.utils import contract @@ -44,6 +43,7 @@ def get_tvl(self, lp_token_address: Address, block: Optional[Block] = None) -> f prices = [] for token in tokens: try: + from yearn.prices import magic prices.append(magic.get_price(token, block = block)) except PriceError: prices.append(None) diff --git a/yearn/special.py b/yearn/special.py index ae3c5c8fb..735bee999 100644 --- a/yearn/special.py +++ b/yearn/special.py @@ -9,7 +9,6 @@ from yearn.apy.common import Apy, ApyBlocks, ApyFees, ApyPoints, ApySamples from yearn.common import Tvl from yearn.exceptions import PriceError -from yearn.prices import magic from yearn.prices.curve import curve from yearn.utils import Singleton, contract, contract_creation_block @@ -58,6 +57,7 @@ def __init__(self): self.proxy = contract("0xF147b8125d2ef93FB6965Db97D6746952a133934") def describe(self, block=None): + from yearn.prices import magic crv_locked = curve.voting_escrow.balanceOf["address"](self.proxy, block_identifier=block) / 1e18 crv_price = magic.get_price(curve.crv, block=block) return { @@ -67,6 +67,7 @@ def describe(self, block=None): } def total_value_at(self, block=None): + from yearn.prices import magic crv_locked = curve.voting_escrow.balanceOf["address"](self.proxy, block_identifier=block) / 1e18 crv_price = magic.get_price(curve.crv, block=block) return crv_locked * crv_price @@ -76,6 +77,7 @@ def strategies(self): return [] def apy(self, _: ApySamples) -> Apy: + from yearn.prices import magic curve_3_pool = contract("0xbEbc44782C7dB0a1A60Cb6fe97d0b483032FF1C7") curve_reward_distribution = contract("0xA464e6DCda8AC41e03616F95f4BC98a13b8922Dc") curve_voting_escrow = contract("0x5f3b5DfEb7B28CDbD7FAba78963EE202a494e2A2") @@ -106,6 +108,7 @@ def apy(self, _: ApySamples) -> Apy: def tvl(self, block=None) -> Tvl: total_assets = self.vault.totalSupply(block_identifier=block) try: + from yearn.prices import magic price = magic.get_price(self.token, block=block) except PriceError: price = None @@ -122,6 +125,7 @@ def __init__(self): self.token = contract("0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e") def describe(self, block=None): + from yearn.prices import magic yfi_locked = self.token.balanceOf(self.vault, block_identifier=block) / 1e18 yfi_price = magic.get_price(str(self.token), block=block) return { @@ -131,6 +135,7 @@ def describe(self, block=None): } def total_value_at(self, block=None): + from yearn.prices import magic yfi_locked = self.token.balanceOf(self.vault, block_identifier=block) / 1e18 yfi_price = magic.get_price(str(self.token), block=block) return yfi_locked * yfi_price diff --git a/yearn/v2/vaults.py b/yearn/v2/vaults.py index 11c0e02ab..7daa70dec 100644 --- a/yearn/v2/vaults.py +++ b/yearn/v2/vaults.py @@ -15,6 +15,7 @@ from yearn.multicall2 import fetch_multicall from yearn.prices import magic from yearn.prices.curve import curve +from yearn.prices.balancer.balancer import selector as balancer_selector from yearn.special import Ygov from yearn.typing import Address from yearn.utils import safe_views, contract @@ -219,6 +220,8 @@ def describe(self, block=None): def apy(self, samples: ApySamples): if self._needs_curve_simple(): return apy.curve.simple(self, samples) + elif self._needs_balancer_simple(): + return apy.balancer.simple(self, samples) elif Version(self.api_version) >= Version("0.3.2"): return apy.v2.average(self, samples) else: @@ -254,3 +257,15 @@ 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: [], + Network.Fantom: [], + Network.Arbitrum: [] + } + needs_simple = True + if chain.id in exclusions: + needs_simple = self.vault.address not in exclusions[chain.id] + + return needs_simple and balancer_selector.get_balancer_for_pool(self.token.address)