scale rating by average local liquidity

fixes #51
This commit is contained in:
Carsten Otto
2022-06-09 16:55:48 +02:00
parent eb43aa53d3
commit 47d36d80b4
9 changed files with 116 additions and 15 deletions

View File

@@ -83,4 +83,9 @@ public class BalanceService {
public Optional<Coins> getLocalBalanceMaximum(ChannelId channelId, int days) {
return balancesDao.getLocalBalanceMaximum(channelId, days);
}
@Timed
public Optional<Coins> getLocalBalanceAverage(ChannelId channelId, int days) {
return balancesDao.getLocalBalanceAverage(channelId, days);
}
}

View File

@@ -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));
}

View File

@@ -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));

View File

@@ -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));

View File

@@ -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();
}
}
}

View File

@@ -13,4 +13,6 @@ public interface BalancesDao {
Optional<Coins> getLocalBalanceMinimum(ChannelId channelId, int days);
Optional<Coins> getLocalBalanceMaximum(ChannelId channelId, int days);
Optional<Coins> getLocalBalanceAverage(ChannelId channelId, int days);
}

View File

@@ -53,6 +53,15 @@ class BalancesDaoImpl implements BalancesDao {
);
}
@Override
public Optional<Coins> getLocalBalanceAverage(ChannelId channelId, int days) {
long timestamp = getTimestamp(days);
return balancesRepository.getAverageLocalBalance(
channelId.getShortChannelId(),
timestamp
).map(Coins::ofSatoshis);
}
private Optional<Coins> getLocalBalance(Optional<BalancesJpaDto> balances) {
return balances
.map(BalancesJpaDto::toModel)

View File

@@ -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<BalancesJpaDto, String
Optional<BalancesJpaDto> findTopByChannelIdAndTimestampAfterOrderByLocalBalance(long channelId, long timestamp);
Optional<BalancesJpaDto> findTopByChannelIdAndTimestampAfterOrderByLocalBalanceDesc(long channelId, long timestamp);
@Query("SELECT avg(localBalance) FROM BalancesJpaDto b WHERE b.channelId = ?1 AND b.timestamp > ?2")
Optional<Long> getAverageLocalBalance(long channelId, long timestamp);
}

View File

@@ -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);
}
}
}