diff --git a/CHANGELOG.md b/CHANGELOG.md index 52a5eb6f..d51381d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# 0.20.16 + +- Add `LendingMetricUniverse.estimate_accrued_interest` so we can estimate the position cost and interest profit + in backtesting + # 0.20.15 - Update candle mappings so volume doesn't get set to None diff --git a/tests/test_lending.py b/tests/test_lending.py index aed22a2e..d91cf290 100644 --- a/tests/test_lending.py +++ b/tests/test_lending.py @@ -2,6 +2,7 @@ """Client lending dataset download and integrity tests""" import logging +from _decimal import Decimal from pathlib import Path import pandas as pd @@ -12,7 +13,8 @@ filter_for_base_tokens from tradingstrategy.timebucket import TimeBucket from tradingstrategy.client import Client -from tradingstrategy.lending import LendingCandleType, LendingReserveUniverse, LendingProtocolType, UnknownLendingReserve, LendingReserve, LendingCandleUniverse +from tradingstrategy.lending import LendingCandleType, LendingReserveUniverse, LendingProtocolType, UnknownLendingReserve, LendingReserve, \ + LendingCandleUniverse, NoLendingData from tradingstrategy.utils.time import ZERO_TIMEDELTA logger = logging.getLogger(__name__) @@ -306,3 +308,66 @@ def test_get_single_rate(persistent_test_client: Client): assert lag == ZERO_TIMEDELTA +def test_get_estimate_interest(persistent_test_client: Client): + """Estimate how much profit we make with USDC credit. + + Estimate borrow cost and supply profit for a year. + """ + + client = persistent_test_client + lending_reserves = client.fetch_lending_reserve_universe() + + usdc_desc = (ChainId.polygon, LendingProtocolType.aave_v3, "USDC") + + lending_reserves = lending_reserves.limit([usdc_desc]) + + lending_candle_type_map = client.fetch_lending_candles_for_universe( + lending_reserves, + TimeBucket.d1, + start_time=pd.Timestamp("2022-09-01"), + end_time=pd.Timestamp("2023-09-01"), + ) + lending_candles = LendingCandleUniverse(lending_candle_type_map, lending_reserves) + + # Estimate borrow cost + borrow_interest_multiplier = lending_candles.variable_borrow_apr.estimate_accrued_interest( + usdc_desc, + start=pd.Timestamp("2022-09-01"), + end=pd.Timestamp("2023-09-01"), + ) + assert borrow_interest_multiplier == pytest.approx(Decimal('1.028597760665127969909038441')) + + # Estimate borrow cost + supply_interest_multiplier = lending_candles.supply_apr.estimate_accrued_interest( + usdc_desc, + start=pd.Timestamp("2022-09-01"), + end=pd.Timestamp("2023-09-01"), + ) + assert supply_interest_multiplier == pytest.approx(Decimal('1.017786465640168974688961612')) + + +def test_estimate_interest_bad_timeframe(persistent_test_client: Client): + """Try estimate interest when we have no data.""" + + client = persistent_test_client + lending_reserves = client.fetch_lending_reserve_universe() + + usdc_desc = (ChainId.polygon, LendingProtocolType.aave_v3, "USDC") + + lending_reserves = lending_reserves.limit([usdc_desc]) + + lending_candle_type_map = client.fetch_lending_candles_for_universe( + lending_reserves, + TimeBucket.d1, + start_time=pd.Timestamp("2022-09-01"), + end_time=pd.Timestamp("2023-09-01"), + ) + lending_candles = LendingCandleUniverse(lending_candle_type_map, lending_reserves) + + # Ask for non-existing period + with pytest.raises(NoLendingData): + lending_candles.variable_borrow_apr.estimate_accrued_interest( + usdc_desc, + start=pd.Timestamp("2020-01-01"), + end=pd.Timestamp("2020-01-02"), + ) diff --git a/tradingstrategy/lending.py b/tradingstrategy/lending.py index ec56352f..5f5d7350 100644 --- a/tradingstrategy/lending.py +++ b/tradingstrategy/lending.py @@ -6,6 +6,7 @@ See :py:class:`LendingReserveUniverse` on how to load the data. """ import warnings +from _decimal import Decimal from enum import Enum import datetime @@ -21,6 +22,8 @@ from tradingstrategy.types import UNIXTimestamp, PrimaryKey, TokenSymbol, Slug, NonChecksummedAddress, URL from tradingstrategy.utils.groupeduniverse import PairGroupedUniverse +from eth_defi.aave_v3.rates import SECONDS_PER_YEAR + class LendingProtocolType(str, Enum): """Supported lending protocols.""" @@ -38,6 +41,12 @@ class UnknownLendingReserve(Exception): """Does not know about this lending reserve.""" +class NoLendingData(Exception): + """Lending data missing for asked period. + + Reserve was likely not active yet. + """ + @dataclass_json @dataclass @@ -142,7 +151,7 @@ def __hash__(self): return hash((self.chain_id, self.protocol_slug, self.asset_address)) def __repr__(self): - return f"" + return f"" def get_asset(self) -> Token: """Return description for the underlying asset.""" @@ -613,6 +622,95 @@ def get_single_rate( link=link, ) + def estimate_accrued_interest( + self, + reserve: LendingReserveDescription | LendingReserve, + start: datetime.datetime | pd.Timestamp, + end: datetime.datetime | pd.Timestamp, + ) -> Decimal: + """Estimate how much credit or debt interest we would gain on Aave at a given period. + + Example: + + .. code-block: + + lending_reserves = client.fetch_lending_reserve_universe() + + usdc_desc = (ChainId.polygon, LendingProtocolType.aave_v3, "USDC") + + lending_reserves = lending_reserves.limit([usdc_desc]) + + lending_candle_type_map = client.fetch_lending_candles_for_universe( + lending_reserves, + TimeBucket.d1, + start_time=pd.Timestamp("2022-09-01"), + end_time=pd.Timestamp("2023-09-01"), + ) + lending_candles = LendingCandleUniverse(lending_candle_type_map, lending_reserves) + + # Estimate borrow cost + borrow_interest_multiplier = lending_candles.variable_borrow_apr.estimate_accrued_interest( + usdc_desc, + start=pd.Timestamp("2022-09-01"), + end=pd.Timestamp("2023-09-01"), + ) + assert borrow_interest_multiplier == pytest.approx(Decimal('1.028597760665127969909038441')) + + # Estimate borrow cost + supply_interest_multiplier = lending_candles.supply_apr.estimate_accrued_interest( + usdc_desc, + start=pd.Timestamp("2022-09-01"), + end=pd.Timestamp("2023-09-01"), + ) + assert supply_interest_multiplier == pytest.approx(Decimal('1.017786465640168974688961612')) + + :param reserve: + Asset we are interested in. + + :param start: + Start of the period + + :param end: + End of the period + + :return: + Interest multiplier. + + Multiply the starting balance with this number to get the interest applied balance at ``end``. + + 1.0 = no interest. + """ + + if isinstance(start, datetime.datetime): + start = pd.Timestamp(start) + + if isinstance(end, datetime.datetime): + end = pd.Timestamp(end) + + assert isinstance(start, pd.Timestamp), f"Not a timestamp: {start}" + assert isinstance(end, pd.Timestamp), f"Not a timestamp: {end}" + + assert start <= end + + df = self.get_rates_by_reserve(reserve) + + # TODO: Can we use index here to speed up? + candles = df[(df["timestamp"] >= start) & (df["timestamp"] <= end)] + + if len(candles) == 0: + raise NoLendingData(f"No lending data for {reserve}, {start} - {end}") + + # get average APR from high and low + candles["avg"] = candles[["high", "low"]].mean(axis=1) + avg_apr = Decimal(candles["avg"].mean() / 100) + + duration = Decimal((end - start).total_seconds()) + accrued_interest_estimation = (1 + 1 * avg_apr) * duration / SECONDS_PER_YEAR # Use Aave v3 seconds per year + + assert accrued_interest_estimation >= 1, f"Aave interest cannot be negative: {accrued_interest_estimation}" + + return accrued_interest_estimation + @dataclass class LendingCandleUniverse: @@ -706,4 +804,6 @@ def lending_reserves(self) -> LendingReserveUniverse: if metric: return metric.reserves - raise AssertionError("Empty LendingCandlesUniverse") \ No newline at end of file + raise AssertionError("Empty LendingCandlesUniverse") + +