CryptoInsightsService.kt
package com.distasilucas.cryptobalancetracker.service
import com.distasilucas.cryptobalancetracker.constants.CRYPTOS_BALANCES_INSIGHTS_CACHE
import com.distasilucas.cryptobalancetracker.constants.CRYPTO_INSIGHTS_CACHE
import com.distasilucas.cryptobalancetracker.entity.UserCrypto
import com.distasilucas.cryptobalancetracker.exception.ApiException
import com.distasilucas.cryptobalancetracker.model.SortBy
import com.distasilucas.cryptobalancetracker.model.SortParams
import com.distasilucas.cryptobalancetracker.model.SortType
import com.distasilucas.cryptobalancetracker.model.response.insights.Balances
import com.distasilucas.cryptobalancetracker.model.response.insights.BalancesChartResponse
import com.distasilucas.cryptobalancetracker.model.response.insights.Price
import com.distasilucas.cryptobalancetracker.model.response.insights.PriceChange
import com.distasilucas.cryptobalancetracker.model.response.insights.UserCryptoInsights
import com.distasilucas.cryptobalancetracker.model.response.insights.crypto.CryptoInsightResponse
import com.distasilucas.cryptobalancetracker.model.response.insights.crypto.PageUserCryptosInsightsResponse
import com.distasilucas.cryptobalancetracker.model.response.insights.crypto.PlatformInsight
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.beans.factory.annotation.Value
import org.springframework.cache.annotation.Cacheable
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import java.math.BigDecimal
import kotlin.math.ceil
@Service
data class CryptoInsightsService(
@Value("\${crypto.insights.max-single-items-count}")
private val maxSingleItemsCount: Int,
@Value("\${crypto.insights.elements-page}")
private val cryptosPerPage: Int,
private val platformService: PlatformService,
private val userCryptoService: UserCryptoService,
private val cryptoService: CryptoService,
private val insightsService: InsightsService
) {
private val logger = KotlinLogging.logger { }
@Cacheable(cacheNames = [CRYPTO_INSIGHTS_CACHE], key = "#coingeckoCryptoId")
fun retrieveCryptoInsights(coingeckoCryptoId: String): CryptoInsightResponse {
logger.info { "Retrieving insights for crypto with coingeckoCryptoId $coingeckoCryptoId" }
val userCryptos = userCryptoService.findAllByCoingeckoCryptoId(coingeckoCryptoId)
if (userCryptos.isEmpty()) {
throw ApiException(HttpStatus.NOT_FOUND, "No cryptos with id $coingeckoCryptoId were found")
}
val platformsIds = userCryptos.map { it.platformId }
val platforms = platformService.findAllByIds(platformsIds)
val crypto = cryptoService.retrieveCryptoInfoById(coingeckoCryptoId)
val cryptoInfo = crypto.toCryptoInfo(
price = Price(crypto.lastKnownPrice, crypto.lastKnownPriceInEUR),
priceChange = PriceChange(crypto.changePercentageIn24h, crypto.changePercentageIn7d, crypto.changePercentageIn30d)
)
val platformUserCryptoQuantity = userCryptos.associateBy({ it.platformId }, { it.quantity })
val totalCryptoQuantity = userCryptos.sumOf { it.quantity }
val totalBalances =
insightsService.getTotalBalances(listOf(crypto), mapOf(coingeckoCryptoId to totalCryptoQuantity))
val platformInsights = platforms.map { platform ->
val quantity = platformUserCryptoQuantity[platform.id]
val cryptoTotalBalances = insightsService.getCryptoTotalBalances(crypto, quantity!!)
PlatformInsight(
quantity = quantity.toPlainString(),
balances = cryptoTotalBalances,
percentage = insightsService.calculatePercentage(totalBalances.usd(), cryptoTotalBalances.usd()),
platformName = platform.name
)
}.sortedByDescending { it.percentage }
return CryptoInsightResponse(cryptoInfo, totalBalances, platformInsights)
}
@Cacheable(cacheNames = [CRYPTOS_BALANCES_INSIGHTS_CACHE])
fun retrieveCryptosBalancesInsights(): List<BalancesChartResponse> {
logger.info { "Retrieving all cryptos balances insights" }
val userCryptos = userCryptoService.findAll()
if (userCryptos.isEmpty()) return emptyList()
val userCryptoQuantity = insightsService.getUserCryptoQuantity(userCryptos)
val cryptosIds = userCryptos.map { it.coingeckoCryptoId }.toSet()
val cryptos = cryptoService.findAllByIds(cryptosIds)
val totalBalances = insightsService.getTotalBalances(cryptos, userCryptoQuantity)
val cryptosMap = cryptos.associateBy { it.id }
val cryptosInsights = userCryptoQuantity.map { (coingeckoCryptoId, quantity) ->
val crypto = cryptosMap[coingeckoCryptoId]
val cryptoBalances = insightsService.getCryptoTotalBalances(crypto!!, quantity)
val percentage = insightsService.calculatePercentage(totalBalances.usd(), cryptoBalances.usd())
BalancesChartResponse(crypto.name, cryptoBalances.usd(), percentage)
}.sortedByDescending { it.percentage }
return if (cryptosInsights.size > maxSingleItemsCount) {
getCryptoInsightsWithOthers(totalBalances, cryptosInsights)
} else {
cryptosInsights
}
}
fun retrieveUserCryptosInsights(
page: Int,
sortParams: SortParams = SortParams(SortBy.PERCENTAGE, SortType.DESC)
): PageUserCryptosInsightsResponse {
logger.info { "Retrieving user cryptos insights for page $page" }
// If one of the user cryptos happens to be at the end, and another of the same (i.e: bitcoin), at the start
// using findAllByPage() will display the same crypto twice (in this example), and the idea of this insight
// it's to display total balances and percentage for each individual crypto.
// So I need to calculate everything from all the user cryptos.
// Maybe create a query that returns the coingeckoCryptoId summing all balances for that crypto and
// returning an array of the platforms for that crypto and then paginate the results
// would be a better approach so I don't need to retrieve all.
val userCryptos = userCryptoService.findAll()
if (userCryptos.isEmpty()) {
return PageUserCryptosInsightsResponse.EMPTY
}
val userCryptoQuantity = insightsService.getUserCryptoQuantity(userCryptos)
val cryptosIds = userCryptos.map { it.coingeckoCryptoId }.toSet()
val cryptos = cryptoService.findAllByIds(cryptosIds)
val totalBalances = insightsService.getTotalBalances(cryptos, userCryptoQuantity)
val userCryptosQuantityPlatforms = mapUserCryptosQuantity(userCryptos)
val cryptosMap = cryptos.associateBy { it.id }
val userCryptoInsights = userCryptosQuantityPlatforms.map { (coingeckoCryptoId, cryptoQuantity) ->
val crypto = cryptosMap[coingeckoCryptoId] ?: throw ApiException("User crypto $coingeckoCryptoId not found")
val cryptoTotalBalances = insightsService.getCryptoTotalBalances(crypto, cryptoQuantity)
val price = Price(crypto.lastKnownPrice, crypto.lastKnownPriceInEUR, crypto.lastKnownPriceInBTC)
val priceChange =
PriceChange(crypto.changePercentageIn24h, crypto.changePercentageIn7d, crypto.changePercentageIn30d)
val cryptoInfo = crypto.toCryptoInfo(price, priceChange)
UserCryptoInsights(
cryptoInfo = cryptoInfo,
quantity = cryptoQuantity.toPlainString(),
percentage = insightsService.calculatePercentage(totalBalances.usd(), cryptoTotalBalances.usd()),
balances = cryptoTotalBalances
)
}.sortedWith(sortParams.cryptosInsightsResponseComparator())
val startIndex = page * cryptosPerPage
if (startIndex > userCryptoInsights.size) {
return PageUserCryptosInsightsResponse.EMPTY
}
val totalPages = ceil(userCryptoInsights.size.toDouble() / cryptosPerPage).toInt()
val endIndex = if (isLastPage(page, totalPages)) userCryptoInsights.size else startIndex + cryptosPerPage
val cryptosInsights = userCryptoInsights.subList(startIndex, endIndex)
return PageUserCryptosInsightsResponse(page, totalPages, totalBalances, cryptosInsights)
}
private fun getCryptoInsightsWithOthers(
totalBalances: Balances,
balances: List<BalancesChartResponse>
): List<BalancesChartResponse> {
val topCryptos = balances.take(maxSingleItemsCount)
val others = balances.drop(maxSingleItemsCount)
val othersBalances = others.sumOf { BigDecimal(it.balance) }.toPlainString()
val othersTotalPercentage = insightsService.calculatePercentage(totalBalances.usd(), othersBalances)
val othersCryptoInsights = BalancesChartResponse("Others", othersBalances, othersTotalPercentage)
return topCryptos + othersCryptoInsights
}
private fun mapUserCryptosQuantity(userCryptos: List<UserCrypto>) =
userCryptos.groupBy(UserCrypto::coingeckoCryptoId).mapValues { it.value.sumOf(UserCrypto::quantity) }
private fun isLastPage(page: Int, totalPages: Int) = page + 1 >= totalPages
}