From 0c996b3bed7d6c51d1a52b24238e32b0d19b91f8 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 23 Sep 2025 21:48:54 +0400 Subject: [PATCH] Updated on 2026-08-14 --- .../tangem/domain/models/quote/PriceChange.kt | 17 ++ .../operations/PriceChangeCalculator.kt | 68 ++++++++ .../operations/PriceChangeCalculatorTest.kt | 155 ++++++++++++++++++ 3 files changed, 240 insertions(+) create mode 100644 domain/models/src/main/kotlin/com/tangem/domain/models/quote/PriceChange.kt create mode 100644 domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/PriceChangeCalculator.kt create mode 100644 domain/tokens/src/test/kotlin/com/tangem/domain/tokens/operations/PriceChangeCalculatorTest.kt diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/quote/PriceChange.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/quote/PriceChange.kt new file mode 100644 index 0000000000..b38d5196dd --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/quote/PriceChange.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.models.quote + +import com.tangem.domain.models.StatusSource +import java.math.BigDecimal + +/** + * Represents the price change of a cryptocurrency asset over a specific time period. + * + * @param value The amount of price change. + * @param source The source of the price change information. + * +[REDACTED_AUTHOR] + */ +data class PriceChange( + val value: BigDecimal, + val source: StatusSource, +) \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/PriceChangeCalculator.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/PriceChangeCalculator.kt new file mode 100644 index 0000000000..19c51a1f07 --- /dev/null +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/PriceChangeCalculator.kt @@ -0,0 +1,68 @@ +package com.tangem.domain.tokens.operations + +import arrow.core.toNonEmptyListOrNull +import com.tangem.domain.core.lce.Lce +import com.tangem.domain.core.utils.lceContent +import com.tangem.domain.core.utils.lceLoading +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.quote.PriceChange +import com.tangem.domain.models.tokenlist.TokenList +import com.tangem.utils.extensions.isZero +import com.tangem.utils.extensions.orZero +import java.math.BigDecimal + +/** + * Utility object for calculating the [PriceChange] of a cryptocurrency portfolio. + * + * This object provides methods to calculate the weighted average price change of a list of cryptocurrency statuses + * based on their fiat balance weights. + */ +object PriceChangeCalculator { + + /** + * Calculates the weighted average price change of a list of cryptocurrency statuses. + * + * @param statuses A list of `CryptoCurrencyStatus` objects representing the statuses of cryptocurrencies. + * @return An `Lce` object containing the calculated `PriceChange` or an error/loading state. + * - Returns `Lce.Content` with the calculated `PriceChange` if successful. + * - Returns `Lce.Loading` if the total fiat balance is still loading. + * - Returns `Lce.Error` if the total fiat balance calculation fails. + */ + fun calculate(statuses: List): Lce { + val walletTotalFiatBalance = statuses.toNonEmptyListOrNull() + ?.let(TotalFiatBalanceCalculator::calculate) + ?: TokenList.Empty.totalFiatBalance + + val balance = when (walletTotalFiatBalance) { + is TotalFiatBalance.Loaded -> walletTotalFiatBalance.amount + TotalFiatBalance.Loading -> { + return lceLoading() + } + TotalFiatBalance.Failed -> { + return Lce.Error(error = Unit) + } + } + + if (balance.isZero()) { + return createZeroPriceChange(source = walletTotalFiatBalance.source).lceContent() + } + + val total = statuses.sumOf { + val weight = it.value.fiatAmount.orZero().divide(balance) + val priceChange = it.value.priceChange.orZero() + + weight * priceChange + } + + return PriceChange(value = total, source = walletTotalFiatBalance.source).lceContent() + } + + private fun createZeroPriceChange(source: StatusSource): PriceChange { + return PriceChange( + value = BigDecimal.ZERO.movePointLeft(2), + source = source, + ) + } +} \ No newline at end of file diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/operations/PriceChangeCalculatorTest.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/operations/PriceChangeCalculatorTest.kt new file mode 100644 index 0000000000..7dbbeec80b --- /dev/null +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/operations/PriceChangeCalculatorTest.kt @@ -0,0 +1,155 @@ +package com.tangem.domain.tokens.operations + +import arrow.core.nonEmptyListOf +import com.google.common.truth.Truth +import com.tangem.domain.core.utils.lceContent +import com.tangem.domain.core.utils.lceError +import com.tangem.domain.core.utils.lceLoading +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.NetworkAddress +import com.tangem.domain.models.quote.PriceChange +import com.tangem.domain.tokens.mock.MockTokens +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class PriceChangeCalculatorTest { + + @Test + fun `calculate if statuses is empty list`() { + // Act + val actual = PriceChangeCalculator.calculate(statuses = emptyList()) + + // Assert + val expected = PriceChange( + value = BigDecimal("0.00"), + source = StatusSource.ACTUAL, + ).lceContent() + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `calculate if any status is Loading`() { + // Arrange + val statuses = nonEmptyListOf( + create( + amount = BigDecimal.ZERO, + priceChange = BigDecimal("0.06"), + ), + CryptoCurrencyStatus( + currency = MockTokens.token1, + value = CryptoCurrencyStatus.Loading, + ), + ) + + // Act + val actual = PriceChangeCalculator.calculate(statuses) + + // Assert + val expected = lceLoading() + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `calculate if any status is Unreachable`() { + // Arrange + val statuses = nonEmptyListOf( + create( + amount = BigDecimal.ZERO, + priceChange = BigDecimal("0.06"), + ), + CryptoCurrencyStatus( + currency = MockTokens.token1, + value = CryptoCurrencyStatus.Unreachable(priceChange = null, fiatRate = null, networkAddress = null), + ), + ) + + // Act + val actual = PriceChangeCalculator.calculate(statuses) + + // Assert + val expected = Unit.lceError() + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `calculate if total fiat balance is zero`() { + // Arrange + val statuses = nonEmptyListOf( + create( + amount = BigDecimal.ZERO, + priceChange = BigDecimal("0.06"), + ), + create( + amount = BigDecimal("0.00"), + priceChange = BigDecimal("0.00"), + ), + ) + + // Act + val actual = PriceChangeCalculator.calculate(statuses) + + // Assert + val expected = PriceChange( + value = BigDecimal("0.00"), + source = StatusSource.ACTUAL, + ).lceContent() + Truth.assertThat(actual).isEqualTo(expected) + } + + @Test + fun `calculate if all statuses are loaded`() { + // Arrange + val statuses = nonEmptyListOf( + create( + amount = BigDecimal("5"), // 50% + priceChange = BigDecimal("0.06"), // 6% + ), + create( + amount = BigDecimal("3"), // 30% + priceChange = BigDecimal("-0.02"), // -2% + ), + create( + amount = BigDecimal("2"), // 20% + priceChange = BigDecimal("0.04"), // 4% + ), + ) + + // Act + val actual = PriceChangeCalculator.calculate(statuses) + + // Assert + val expected = PriceChange( + value = BigDecimal("0.032"), // 3.2% + source = StatusSource.ACTUAL, + ).lceContent() + Truth.assertThat(actual).isEqualTo(expected) + } + + private fun create(amount: BigDecimal, priceChange: BigDecimal): CryptoCurrencyStatus { + val value = CryptoCurrencyStatus.Loaded( + amount = amount, + fiatAmount = amount, + fiatRate = BigDecimal.ONE, + priceChange = priceChange, + yieldBalance = null, + yieldSupplyStatus = null, + hasCurrentNetworkTransactions = false, + pendingTransactions = emptySet(), + networkAddress = NetworkAddress.Single( + defaultAddress = NetworkAddress.Address( + value = "0x123", + type = NetworkAddress.Address.Type.Primary, + ), + ), + sources = CryptoCurrencyStatus.Sources(), + ) + + return CryptoCurrencyStatus(currency = MockTokens.token1, value = value) + } +} \ No newline at end of file