diff --git a/nado_protocol/utils/interest.py b/nado_protocol/utils/interest.py index eb25026..33bb27c 100644 --- a/nado_protocol/utils/interest.py +++ b/nado_protocol/utils/interest.py @@ -51,16 +51,49 @@ def calc_borrow_rate_per_second(product: SpotProduct) -> float: return annual_rate / TimeInSeconds.YEAR +def calc_min_deposit_rate_multiplier( + product: SpotProduct, period_in_seconds: int +) -> float: + """ + Compounds the annual min deposit rate over the period, giving the multiplier + `SpotEngine._updateState` applies to both the borrow and the deposit + multiplier: `(1 + min_deposit_rate / seconds_per_year) ** period`. + """ + min_deposit_rate = from_x18(int(product.config.min_deposit_rate_x18)) + return (1 + min_deposit_rate / TimeInSeconds.YEAR) ** period_in_seconds + + +def _calc_borrow_multiplier(product: SpotProduct, period_in_seconds) -> float: + """ + The engine's `borrowRateMultiplier`, which excludes the min deposit rate. + The deposit rate is derived from this figure before the min deposit + multiplier is applied. + """ + return (calc_borrow_rate_per_second(product) + 1) ** period_in_seconds + + def calc_borrow_rate_in_period(product: SpotProduct, period_in_seconds) -> float: - borrow_rate_per_second = calc_borrow_rate_per_second(product) - return (borrow_rate_per_second + 1) ** period_in_seconds - 1 + borrow_multiplier = _calc_borrow_multiplier(product, period_in_seconds) + min_deposit_multiplier = calc_min_deposit_rate_multiplier( + product, period_in_seconds + ) + return borrow_multiplier * min_deposit_multiplier - 1 def calc_deposit_rate_in_period( product: SpotProduct, period_in_seconds: int, interest_fee_fraction: float ) -> float: + min_deposit_multiplier = calc_min_deposit_rate_multiplier( + product, period_in_seconds + ) utilization = calc_utilization_ratio(product) + # Nothing is borrowed, so no interest is paid — but the engine still applies + # the min deposit multiplier, which is the whole point of a rate floor. if utilization == 0: - return 0 - borrow_rate_in_period = calc_borrow_rate_in_period(product, period_in_seconds) - return utilization * borrow_rate_in_period * (1 - interest_fee_fraction) + return min_deposit_multiplier - 1 + realized_deposit_rate = ( + utilization + * (_calc_borrow_multiplier(product, period_in_seconds) - 1) + * (1 - interest_fee_fraction) + ) + return (1 + realized_deposit_rate) * min_deposit_multiplier - 1 diff --git a/tests/utils/test_interest.py b/tests/utils/test_interest.py new file mode 100644 index 0000000..62f3bc7 --- /dev/null +++ b/tests/utils/test_interest.py @@ -0,0 +1,93 @@ +"""Tests that the interest helpers match `SpotEngine._updateState`.""" + +import pytest + +from nado_protocol.engine_client.types.models import ( + SpotProduct, + SpotProductConfig, + SpotProductState, +) +from nado_protocol.utils.interest import ( + calc_borrow_rate_in_period, + calc_borrow_rate_per_second, + calc_deposit_rate_in_period, +) +from nado_protocol.utils.math import to_x18 +from nado_protocol.utils.time import TimeInSeconds + +MIN_DEPOSIT_RATE = 0.05 +INTEREST_FEE_FRACTION = 0.2 + +PERIODS = [ + TimeInSeconds.HOUR, + TimeInSeconds.DAY, + TimeInSeconds.DAY * 30, + TimeInSeconds.YEAR, +] + + +def spot_product(total_borrows: float = 500_000) -> SpotProduct: + config = SpotProductConfig( + token="0x", + interest_inflection_util_x18=str(to_x18(0.8)), + interest_floor_x18=str(to_x18(0.01)), + interest_small_cap_x18=str(to_x18(0.04)), + interest_large_cap_x18=str(to_x18(0.4)), + withdraw_fee_x18=str(to_x18(0)), + min_deposit_rate_x18=str(to_x18(MIN_DEPOSIT_RATE)), + ) + state = SpotProductState( + cumulative_deposits_multiplier_x18=str(to_x18(1)), + cumulative_borrows_multiplier_x18=str(to_x18(1)), + total_deposits_normalized=str(to_x18(1_000_000)), + total_borrows_normalized=str(to_x18(total_borrows)), + ) + return SpotProduct.model_construct(product_id=0, config=config, state=state) + + +def on_chain_borrow_rate(product: SpotProduct, period: int) -> float: + """`borrowRateMultiplier * minDepositRateMultiplier - 1`, as the engine computes it.""" + borrow_rate_per_second = calc_borrow_rate_per_second(product) + min_deposit_rate_per_second = MIN_DEPOSIT_RATE / TimeInSeconds.YEAR + return (1 + borrow_rate_per_second) ** period * ( + 1 + min_deposit_rate_per_second + ) ** period - 1 + + +@pytest.mark.parametrize("period", PERIODS) +def test_borrow_rate_applies_min_deposit_rate(period): + product = spot_product() + assert calc_borrow_rate_in_period(product, period) == pytest.approx( + on_chain_borrow_rate(product, period), rel=1e-12 + ) + + +def test_borrow_rate_stays_under_the_annual_floor_for_an_hour(): + # The min deposit rate was not applied at all, so an hour of borrowing + # reported only the borrow curve. + product = spot_product() + hour = calc_borrow_rate_in_period(product, TimeInSeconds.HOUR) + assert hour > on_chain_borrow_rate(product, TimeInSeconds.HOUR) / 2 + assert hour < MIN_DEPOSIT_RATE / 100 + + +def test_deposit_rate_pays_the_floor_when_nothing_is_borrowed(): + product = spot_product(total_borrows=0) + expected = (1 + MIN_DEPOSIT_RATE / TimeInSeconds.YEAR) ** TimeInSeconds.DAY - 1 + assert calc_deposit_rate_in_period( + product, TimeInSeconds.DAY, INTEREST_FEE_FRACTION + ) == pytest.approx(expected, rel=1e-12) + + +def test_deposit_rate_compounds_the_floor_on_top_of_the_realized_rate(): + product = spot_product() + period = TimeInSeconds.DAY + utilization = 0.5 + borrow_multiplier = (1 + calc_borrow_rate_per_second(product)) ** period + realized = utilization * (borrow_multiplier - 1) * (1 - INTEREST_FEE_FRACTION) + expected = (1 + realized) * ( + 1 + MIN_DEPOSIT_RATE / TimeInSeconds.YEAR + ) ** period - 1 + assert calc_deposit_rate_in_period( + product, period, INTEREST_FEE_FRACTION + ) == pytest.approx(expected, rel=1e-12)