diff --git a/backend/src/main/java/de/cotto/lndmanagej/service/BalanceService.java b/backend/src/main/java/de/cotto/lndmanagej/service/BalanceService.java index 4814163e..189bbe24 100644 --- a/backend/src/main/java/de/cotto/lndmanagej/service/BalanceService.java +++ b/backend/src/main/java/de/cotto/lndmanagej/service/BalanceService.java @@ -83,4 +83,9 @@ public class BalanceService { public Optional getLocalBalanceMaximum(ChannelId channelId, int days) { return balancesDao.getLocalBalanceMaximum(channelId, days); } + + @Timed + public Optional getLocalBalanceAverage(ChannelId channelId, int days) { + return balancesDao.getLocalBalanceAverage(channelId, days); + } } diff --git a/backend/src/main/java/de/cotto/lndmanagej/service/RatingService.java b/backend/src/main/java/de/cotto/lndmanagej/service/RatingService.java index 660a966d..dfb60d4c 100644 --- a/backend/src/main/java/de/cotto/lndmanagej/service/RatingService.java +++ b/backend/src/main/java/de/cotto/lndmanagej/service/RatingService.java @@ -3,6 +3,7 @@ package de.cotto.lndmanagej.service; import de.cotto.lndmanagej.configuration.ConfigurationService; import de.cotto.lndmanagej.model.Channel; import de.cotto.lndmanagej.model.ChannelId; +import de.cotto.lndmanagej.model.Coins; import de.cotto.lndmanagej.model.FeeReport; import de.cotto.lndmanagej.model.LocalOpenChannel; import de.cotto.lndmanagej.model.Pubkey; @@ -29,6 +30,7 @@ public class RatingService { private final RebalanceService rebalanceService; private final PolicyService policyService; private final ConfigurationService configurationService; + private final BalanceService balanceService; public RatingService( ChannelService channelService, @@ -36,7 +38,8 @@ public class RatingService { FeeService feeService, RebalanceService rebalanceService, PolicyService policyService, - ConfigurationService configurationService + ConfigurationService configurationService, + BalanceService balanceService ) { this.channelService = channelService; this.ownNodeService = ownNodeService; @@ -44,6 +47,7 @@ public class RatingService { this.rebalanceService = rebalanceService; this.policyService = policyService; this.configurationService = configurationService; + this.balanceService = balanceService; } public Rating getRatingForPeer(Pubkey peer) { @@ -70,13 +74,15 @@ public class RatingService { long feeRate = policyService.getMinimumFeeRateTo(localOpenChannel.getRemotePubkey()).orElse(0L); long localAvailableMilliSat = localOpenChannel.getBalanceInformation().localAvailable().milliSatoshis(); double millionSat = 1.0 * localAvailableMilliSat / 1_000 / 1_000_000; + long averageSat = balanceService.getLocalBalanceAverage(channelId, (int) durationForAnalysis.toDays()) + .orElse(Coins.NONE).satoshis(); long rating = feeReport.earned().milliSatoshis(); rating += feeReport.sourced().milliSatoshis(); rating += rebalanceReport.supportAsSourceAmount().milliSatoshis() / 10_000; rating += rebalanceReport.supportAsTargetAmount().milliSatoshis() / 10_000; rating += (long) (1.0 * feeRate * millionSat / 10); - double scaledByLiquidity = rating / Math.max(1, millionSat); + double scaledByLiquidity = 1.0 * rating * 1_000_000 / averageSat; double scaledByDays = scaledByLiquidity / durationForAnalysis.toDays(); return Optional.of(new Rating((long) scaledByDays)); } diff --git a/backend/src/test/java/de/cotto/lndmanagej/service/BalanceServiceTest.java b/backend/src/test/java/de/cotto/lndmanagej/service/BalanceServiceTest.java index 7a665335..8ee9f86e 100644 --- a/backend/src/test/java/de/cotto/lndmanagej/service/BalanceServiceTest.java +++ b/backend/src/test/java/de/cotto/lndmanagej/service/BalanceServiceTest.java @@ -134,6 +134,19 @@ class BalanceServiceTest { assertThat(balanceService.getLocalBalanceMaximum(CHANNEL_ID, days)).contains(coins); } + @Test + void getLocalBalanceAverage_empty() { + assertThat(balanceService.getLocalBalanceAverage(CHANNEL_ID, 14)).isEmpty(); + } + + @Test + void getLocalBalanceAverage() { + int days = 14; + Coins coins = Coins.ofSatoshis(456); + when(balancesDao.getLocalBalanceAverage(CHANNEL_ID, days)).thenReturn(Optional.of(coins)); + assertThat(balanceService.getLocalBalanceAverage(CHANNEL_ID, days)).contains(coins); + } + private void mockChannels() { when(grpcChannels.getChannel(CHANNEL_ID)).thenReturn(Optional.of(LOCAL_OPEN_CHANNEL)); when(grpcChannels.getChannel(CHANNEL_ID_2)).thenReturn(Optional.of(LOCAL_OPEN_CHANNEL_2)); diff --git a/backend/src/test/java/de/cotto/lndmanagej/service/RatingServiceTest.java b/backend/src/test/java/de/cotto/lndmanagej/service/RatingServiceTest.java index 0f889bf2..6da7d750 100644 --- a/backend/src/test/java/de/cotto/lndmanagej/service/RatingServiceTest.java +++ b/backend/src/test/java/de/cotto/lndmanagej/service/RatingServiceTest.java @@ -35,8 +35,10 @@ import static de.cotto.lndmanagej.model.OpenInitiator.LOCAL; import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY; import static de.cotto.lndmanagej.model.PubkeyFixtures.PUBKEY_2; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assumptions.assumeThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -68,6 +70,9 @@ class RatingServiceTest { @Mock private ConfigurationService configurationService; + @Mock + private BalanceService balanceService; + @BeforeEach void setUp() { int daysAhead = LOCAL_OPEN_CHANNEL_2.getId().getBlockHeight() + 100 * 24 * 60 / 10; @@ -75,6 +80,8 @@ class RatingServiceTest { lenient().when(feeService.getFeeReportForChannel(any(), any())).thenReturn(FeeReport.EMPTY); lenient().when(rebalanceService.getReportForChannel(any(), any())).thenReturn(RebalanceReport.EMPTY); lenient().when(configurationService.getIntegerValue(any())).thenReturn(Optional.empty()); + lenient().when(balanceService.getLocalBalanceAverage(any(), anyInt())) + .thenReturn(Optional.of(Coins.ofSatoshis(1_000_000))); } @Test @@ -208,27 +215,30 @@ class RatingServiceTest { long maxEarnings = (long) (1.0 * feeRate * balanceMilliSat / 1_000 / 1_000_000.0); assumeThat(maxEarnings).isGreaterThanOrEqualTo(10 * ANALYSIS_DAYS); assertThat(ratingService.getRatingForChannel(CHANNEL_ID)) - .contains(new Rating((maxEarnings / 10 / ANALYSIS_DAYS) / 2)); + .contains(new Rating(maxEarnings / 10 / ANALYSIS_DAYS)); } @Test - void divided_by_million_sats_local() { - Coins localAvailable = Coins.ofSatoshis(2_500_000); + void divided_by_average_million_sats_local() { + Coins localAvailableAverage = Coins.ofSatoshis(2_500_000); long expected = (long) ((1 + 100_000) / 2.5); - assertScaledRating(localAvailable, expected); + assertScaledRating(localAvailableAverage, expected); } @Test - void not_divided_if_zero_balance() { - Coins localAvailable = Coins.NONE; - long expected = 100_000; - assertScaledRating(localAvailable, expected); + void zero_balance_on_average() { + when(balanceService.getLocalBalanceAverage(CHANNEL_ID, ANALYSIS_DAYS)) + .thenReturn(Optional.of(Coins.NONE)); + when(channelService.getOpenChannel(CHANNEL_ID)).thenReturn(Optional.of(LOCAL_OPEN_CHANNEL)); + when(feeService.getFeeReportForChannel(CHANNEL_ID, DEFAULT_DURATION_FOR_ANALYSIS)) + .thenReturn(new FeeReport(Coins.ofMilliSatoshis(100_000 * ANALYSIS_DAYS), Coins.NONE)); + assertThatCode(() -> ratingService.getRatingForChannel(CHANNEL_ID)).doesNotThrowAnyException(); } @Test - void not_divided_if_local_balance_below_one_million() { - Coins localAvailable = Coins.ofSatoshis(999_999); - long expected = 100_000; + void divided_by_average_million_sats_local_if_less_than_one_million_sat() { + Coins localAvailable = Coins.ofSatoshis(500_000); + long expected = 100_000 * 2; assertScaledRating(localAvailable, expected); } @@ -247,6 +257,8 @@ class RatingServiceTest { private void assertScaledRating(Coins localAvailable, long expectedRating) { LocalOpenChannel localOpenChannel = getLocalOpenChannel(localAvailable); when(channelService.getOpenChannel(CHANNEL_ID)).thenReturn(Optional.of(localOpenChannel)); + when(balanceService.getLocalBalanceAverage(CHANNEL_ID, ANALYSIS_DAYS)) + .thenReturn(Optional.of(localAvailable)); when(feeService.getFeeReportForChannel(CHANNEL_ID, DEFAULT_DURATION_FOR_ANALYSIS)) .thenReturn(new FeeReport(Coins.ofMilliSatoshis(100_000 * ANALYSIS_DAYS), Coins.NONE)); assertThat(ratingService.getRatingForChannel(CHANNEL_ID)).contains(new Rating(expectedRating)); diff --git a/balances/src/integrationTest/java/de/cotto/lndmanagej/balances/persistence/BalancesRepositoryIT.java b/balances/src/integrationTest/java/de/cotto/lndmanagej/balances/persistence/BalancesRepositoryIT.java index fff85bac..8f05290b 100644 --- a/balances/src/integrationTest/java/de/cotto/lndmanagej/balances/persistence/BalancesRepositoryIT.java +++ b/balances/src/integrationTest/java/de/cotto/lndmanagej/balances/persistence/BalancesRepositoryIT.java @@ -147,7 +147,38 @@ class BalancesRepositoryIT { .contains(balancesLessThanMaximum.balanceInformation().localBalance()); } + @Test + void average_empty() { + assertThat(repository.getAverageLocalBalance( + CHANNEL_ID.getShortChannelId(), + 14 + )).isEmpty(); + } + + @Test + void average() { + assumeThat(localBalance(BALANCES_OLD) > localBalance(BALANCES)).isTrue(); + repository.save(BalancesJpaDto.fromModel(BALANCES)); + repository.save(BalancesJpaDto.fromModel(BALANCES_OLD)); + long sum = BALANCES.balanceInformation().localBalance().satoshis() + + BALANCES_OLD.balanceInformation().localBalance().satoshis(); + assertThat(repository.getAverageLocalBalance(CHANNEL_ID.getShortChannelId(), 14)).contains(sum / 2); + } + + @Test + void average_too_old() { + LocalDateTime now = TIMESTAMP; + Balances balanceYoungEnough = createBalances(Coins.ofSatoshis(123_456), now); + Balances balancesMaximumButOld = createBalances(Coins.ofSatoshis(1), now.minusDays(1)); + long timestamp = balancesMaximumButOld.timestamp().toEpochSecond(ZoneOffset.UTC); + + repository.save(BalancesJpaDto.fromModel(balanceYoungEnough)); + repository.save(BalancesJpaDto.fromModel(balancesMaximumButOld)); + assertThat(repository.getAverageLocalBalance(CHANNEL_ID.getShortChannelId(), timestamp)) + .contains(balanceYoungEnough.balanceInformation().localBalance().satoshis()); + } + private long localBalance(Balances balances) { return balances.balanceInformation().localBalance().milliSatoshis(); } -} \ No newline at end of file +} diff --git a/balances/src/main/java/de/cotto/lndmanagej/balances/BalancesDao.java b/balances/src/main/java/de/cotto/lndmanagej/balances/BalancesDao.java index 2d339c4c..7041ee7b 100644 --- a/balances/src/main/java/de/cotto/lndmanagej/balances/BalancesDao.java +++ b/balances/src/main/java/de/cotto/lndmanagej/balances/BalancesDao.java @@ -13,4 +13,6 @@ public interface BalancesDao { Optional getLocalBalanceMinimum(ChannelId channelId, int days); Optional getLocalBalanceMaximum(ChannelId channelId, int days); + + Optional getLocalBalanceAverage(ChannelId channelId, int days); } diff --git a/balances/src/main/java/de/cotto/lndmanagej/balances/persistence/BalancesDaoImpl.java b/balances/src/main/java/de/cotto/lndmanagej/balances/persistence/BalancesDaoImpl.java index b7397b57..7e09ee26 100644 --- a/balances/src/main/java/de/cotto/lndmanagej/balances/persistence/BalancesDaoImpl.java +++ b/balances/src/main/java/de/cotto/lndmanagej/balances/persistence/BalancesDaoImpl.java @@ -53,6 +53,15 @@ class BalancesDaoImpl implements BalancesDao { ); } + @Override + public Optional getLocalBalanceAverage(ChannelId channelId, int days) { + long timestamp = getTimestamp(days); + return balancesRepository.getAverageLocalBalance( + channelId.getShortChannelId(), + timestamp + ).map(Coins::ofSatoshis); + } + private Optional getLocalBalance(Optional balances) { return balances .map(BalancesJpaDto::toModel) diff --git a/balances/src/main/java/de/cotto/lndmanagej/balances/persistence/BalancesRepository.java b/balances/src/main/java/de/cotto/lndmanagej/balances/persistence/BalancesRepository.java index 25ac4b95..891799c7 100644 --- a/balances/src/main/java/de/cotto/lndmanagej/balances/persistence/BalancesRepository.java +++ b/balances/src/main/java/de/cotto/lndmanagej/balances/persistence/BalancesRepository.java @@ -1,6 +1,7 @@ package de.cotto.lndmanagej.balances.persistence; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import java.util.Optional; @@ -10,4 +11,7 @@ public interface BalancesRepository extends JpaRepository findTopByChannelIdAndTimestampAfterOrderByLocalBalance(long channelId, long timestamp); Optional findTopByChannelIdAndTimestampAfterOrderByLocalBalanceDesc(long channelId, long timestamp); + + @Query("SELECT avg(localBalance) FROM BalancesJpaDto b WHERE b.channelId = ?1 AND b.timestamp > ?2") + Optional getAverageLocalBalance(long channelId, long timestamp); } diff --git a/balances/src/test/java/de/cotto/lndmanagej/balances/persistence/BalancesDaoImplTest.java b/balances/src/test/java/de/cotto/lndmanagej/balances/persistence/BalancesDaoImplTest.java index a9b8cc33..c622f9f0 100644 --- a/balances/src/test/java/de/cotto/lndmanagej/balances/persistence/BalancesDaoImplTest.java +++ b/balances/src/test/java/de/cotto/lndmanagej/balances/persistence/BalancesDaoImplTest.java @@ -115,9 +115,28 @@ class BalancesDaoImplTest { ); } + @Test + void getLocalBalanceAverage_empty() { + assertThat(dao.getLocalBalanceAverage(CHANNEL_ID, 1)).isEmpty(); + } + + @Test + void getLocalBalanceAverage() { + int days = 14; + Coins averageLocalBalance = Coins.ofSatoshis(456); + when(balancesRepository.getAverageLocalBalance(eq(CHANNEL_ID.getShortChannelId()), anyLong())) + .thenReturn(Optional.of(averageLocalBalance.satoshis())); + assertThat(dao.getLocalBalanceAverage(CHANNEL_ID, days)).contains(averageLocalBalance); + + long expectedTimestamp = ZonedDateTime.now(ZoneOffset.UTC).minusDays(days).toEpochSecond(); + verify(balancesRepository).getAverageLocalBalance(anyLong(), + longThat(timestamp -> timestamp > 0.95 * expectedTimestamp && timestamp < 1.05 * expectedTimestamp) + ); + } + private Balances getWithLocalBalance(Coins localBalance) { BalanceInformation balanceInformation = new BalanceInformation(localBalance, LOCAL_RESERVE, REMOTE_BALANCE, REMOTE_RESERVE); return new Balances(TIMESTAMP, CHANNEL_ID, balanceInformation); } -} \ No newline at end of file +}