diff --git a/app/schemas/one.mixin.android.db.PerpsDatabase/8.json b/app/schemas/one.mixin.android.db.PerpsDatabase/8.json index fe0eacc77a..fe59cf0603 100644 --- a/app/schemas/one.mixin.android.db.PerpsDatabase/8.json +++ b/app/schemas/one.mixin.android.db.PerpsDatabase/8.json @@ -260,7 +260,7 @@ }, { "tableName": "markets", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`market_id` TEXT NOT NULL, `display_symbol` TEXT NOT NULL, `token_symbol` TEXT NOT NULL, `quote_symbol` TEXT NOT NULL, `mark_price` TEXT NOT NULL, `price_scale` INTEGER NOT NULL DEFAULT 2, `leverage` INTEGER NOT NULL, `icon_url` TEXT NOT NULL, `category` TEXT NOT NULL, `tags` TEXT NOT NULL, `funding_rate` TEXT NOT NULL, `funding_interval_hours` INTEGER NOT NULL DEFAULT 0, `next_funding_at` TEXT NOT NULL DEFAULT '', `open_interest` TEXT NOT NULL DEFAULT '0', `min_amount` TEXT NOT NULL, `max_amount` TEXT NOT NULL, `last` TEXT NOT NULL, `volume` TEXT NOT NULL, `high` TEXT NOT NULL, `low` TEXT NOT NULL, `open` TEXT NOT NULL, `change` TEXT NOT NULL, `bid_price` TEXT NOT NULL, `ask_price` TEXT NOT NULL, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, `descriptions` TEXT, PRIMARY KEY(`market_id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`market_id` TEXT NOT NULL, `display_symbol` TEXT NOT NULL, `token_symbol` TEXT NOT NULL, `quote_symbol` TEXT NOT NULL, `mark_price` TEXT NOT NULL, `price_scale` INTEGER NOT NULL DEFAULT 2, `leverage` INTEGER NOT NULL, `icon_url` TEXT NOT NULL, `category` TEXT NOT NULL, `tags` TEXT NOT NULL, `funding_rate` TEXT NOT NULL, `funding_interval_hours` INTEGER NOT NULL DEFAULT 0, `next_funding_at` TEXT NOT NULL DEFAULT '', `open_interest` TEXT NOT NULL DEFAULT '0', `min_amount` TEXT NOT NULL, `max_amount` TEXT NOT NULL, `last` TEXT NOT NULL, `volume` TEXT NOT NULL, `trade_volume_score_1d` INTEGER NOT NULL DEFAULT 0, `high` TEXT NOT NULL, `low` TEXT NOT NULL, `open` TEXT NOT NULL, `change` TEXT NOT NULL, `bid_price` TEXT NOT NULL, `ask_price` TEXT NOT NULL, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, `descriptions` TEXT, PRIMARY KEY(`market_id`))", "fields": [ { "fieldPath": "marketId", @@ -374,6 +374,13 @@ "affinity": "TEXT", "notNull": true }, + { + "fieldPath": "tradeVolumeScore1D", + "columnName": "trade_volume_score_1d", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, { "fieldPath": "high", "columnName": "high", diff --git a/app/src/androidTest/java/one/mixin/android/db/PerpsMigrationTest.kt b/app/src/androidTest/java/one/mixin/android/db/PerpsMigrationTest.kt index c62f589730..aa46d42013 100644 --- a/app/src/androidTest/java/one/mixin/android/db/PerpsMigrationTest.kt +++ b/app/src/androidTest/java/one/mixin/android/db/PerpsMigrationTest.kt @@ -239,6 +239,29 @@ class PerpsMigrationTest { } } + @Test + fun migrate_7_8_addsTradeVolumeScore() { + migrationTestHelper.createDatabase(Constants.DataBase.PERPS_DB_NAME, 7).apply { + insertMarket() + close() + } + + val migratedDb = + migrationTestHelper.runMigrationsAndValidate( + Constants.DataBase.PERPS_DB_NAME, + 8, + true, + PerpsDatabase.MIGRATION_7_8, + ) + + migratedDb.query( + "SELECT trade_volume_score_1d FROM markets WHERE market_id = 'market-1'", + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals(0, cursor.getInt(0)) + } + } + private fun SupportSQLiteDatabase.insertMarket() { execSQL( """ diff --git a/app/src/main/java/one/mixin/android/api/response/perps/PerpsMarket.kt b/app/src/main/java/one/mixin/android/api/response/perps/PerpsMarket.kt index ae6df67532..c76fd3432e 100644 --- a/app/src/main/java/one/mixin/android/api/response/perps/PerpsMarket.kt +++ b/app/src/main/java/one/mixin/android/api/response/perps/PerpsMarket.kt @@ -83,6 +83,10 @@ data class PerpsMarket( @ColumnInfo(name = "volume") val volume: String, + @SerializedName("trade_volume_score_1d") + @ColumnInfo(name = "trade_volume_score_1d", defaultValue = "0") + val tradeVolumeScore1D: Int = 0, + @SerializedName("high") @ColumnInfo(name = "high") val high: String, diff --git a/app/src/main/java/one/mixin/android/db/PerpsDatabase.kt b/app/src/main/java/one/mixin/android/db/PerpsDatabase.kt index 4be87c2cd3..675eb8e203 100644 --- a/app/src/main/java/one/mixin/android/db/PerpsDatabase.kt +++ b/app/src/main/java/one/mixin/android/db/PerpsDatabase.kt @@ -114,6 +114,7 @@ abstract class PerpsDatabase : RoomDatabase() { db.execSQL("ALTER TABLE `markets` ADD COLUMN `funding_interval_hours` INTEGER NOT NULL DEFAULT 0") db.execSQL("ALTER TABLE `markets` ADD COLUMN `next_funding_at` TEXT NOT NULL DEFAULT ''") db.execSQL("ALTER TABLE `markets` ADD COLUMN `open_interest` TEXT NOT NULL DEFAULT '0'") + db.execSQL("ALTER TABLE `markets` ADD COLUMN `trade_volume_score_1d` INTEGER NOT NULL DEFAULT 0") } } diff --git a/app/src/main/java/one/mixin/android/db/perps/PerpsMarketDao.kt b/app/src/main/java/one/mixin/android/db/perps/PerpsMarketDao.kt index 1c05946657..3b47ca66ed 100644 --- a/app/src/main/java/one/mixin/android/db/perps/PerpsMarketDao.kt +++ b/app/src/main/java/one/mixin/android/db/perps/PerpsMarketDao.kt @@ -4,6 +4,7 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.Transaction import kotlinx.coroutines.flow.Flow import one.mixin.android.api.response.perps.PerpsMarket import one.mixin.android.db.BaseDao @@ -16,12 +17,32 @@ interface PerpsMarketDao : BaseDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAll(markets: List) + @Query("SELECT * FROM markets WHERE market_id IN (:marketIds)") + suspend fun getMarkets(marketIds: List): List + + @Transaction + suspend fun upsertPreservingTradeVolumeScores(markets: List): List { + if (markets.isEmpty()) return emptyList() + val existingScores = + getMarkets(markets.map(PerpsMarket::marketId)) + .associate { market -> market.marketId to market.tradeVolumeScore1D } + val mergedMarkets = + markets.map { market -> + market.copy( + tradeVolumeScore1D = existingScores[market.marketId] ?: 0, + ) + } + upsertList(mergedMarkets) + return mergedMarkets + } + @Query( """ SELECT * FROM markets WHERE CAST(volume AS REAL) > 0 - ORDER BY CAST(volume AS REAL) DESC, token_symbol COLLATE NOCASE ASC, market_id ASC + ORDER BY trade_volume_score_1d DESC, CAST(volume AS REAL) DESC, + token_symbol COLLATE NOCASE ASC, market_id ASC """, ) suspend fun getAllMarkets(): List @@ -31,7 +52,8 @@ interface PerpsMarketDao : BaseDao { SELECT * FROM markets WHERE CAST(volume AS REAL) > 0 - ORDER BY CAST(volume AS REAL) DESC, token_symbol COLLATE NOCASE ASC, market_id ASC + ORDER BY trade_volume_score_1d DESC, CAST(volume AS REAL) DESC, + token_symbol COLLATE NOCASE ASC, market_id ASC """, ) fun observeAllMarkets(): Flow> diff --git a/app/src/main/java/one/mixin/android/repository/PerpsMarketRepository.kt b/app/src/main/java/one/mixin/android/repository/PerpsMarketRepository.kt index d6027d27c6..4e3cfff2bb 100644 --- a/app/src/main/java/one/mixin/android/repository/PerpsMarketRepository.kt +++ b/app/src/main/java/one/mixin/android/repository/PerpsMarketRepository.kt @@ -50,34 +50,40 @@ class PerpsMarketRepository suspend fun syncFavoriteMarkets(): List? { val markets = fetchMarkets(CATEGORY_FAVORITE) ?: return null - database.withTransaction { - marketDao.upsertList(markets) + return database.withTransaction { + val mergedMarkets = marketDao.upsertPreservingTradeVolumeScores(markets) favoriteDao.replaceAll( - marketIds = markets.map(PerpsMarket::marketId), + marketIds = mergedMarkets.map(PerpsMarket::marketId), createdAt = nowInUtc(), ) + mergedMarkets } - return markets } suspend fun syncCategory(category: MarketCategory): List? { val markets = fetchMarkets(category.apiValue) ?: return null - database.withTransaction { - marketDao.upsertList(markets) + return database.withTransaction { + val mergedMarkets = + if (category == MarketCategory.TRENDING) { + marketDao.upsertList(markets) + markets + } else { + marketDao.upsertPreservingTradeVolumeScores(markets) + } categoryDao.replaceCategory( category = category.value, - marketIds = markets.map(PerpsMarket::marketId), + marketIds = mergedMarkets.map(PerpsMarket::marketId), ) + mergedMarkets } - return markets } suspend fun refreshMarket(marketId: String): PerpsMarket? = requestRouteAPI( invokeNetwork = { routeService.getPerpsMarket(marketId) }, successBlock = { response -> - response.data?.withDefaults()?.also { market -> - marketDao.upsertSuspend(market) + response.data?.withDefaults()?.let { market -> + marketDao.upsertPreservingTradeVolumeScores(listOf(market)).single() } }, failureBlock = { true }, diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketAnalytics.kt b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketAnalytics.kt index 09221c2bfb..3b866c5a77 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketAnalytics.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketAnalytics.kt @@ -54,4 +54,5 @@ internal fun MarketPageUiState.analyticsSortField(column: MarketSortColumn): Str MarketPriceChangePeriod.TWENTY_FOUR_HOURS -> AnalyticsTracker.MarketsSortField.TWENTY_FOUR_HOURS MarketPriceChangePeriod.SEVEN_DAYS -> AnalyticsTracker.MarketsSortField.SEVEN_DAYS } + MarketSortColumn.SCORE -> AnalyticsTracker.MarketsSortField.SCORE } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketPageModels.kt b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketPageModels.kt index 0324d55971..871a758753 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketPageModels.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketPageModels.kt @@ -53,6 +53,7 @@ enum class MarketSortColumn { VOLUME, PRICE, CHANGE, + SCORE, } enum class MarketSortDirection { @@ -65,14 +66,22 @@ data class MarketSortState( val column: MarketSortColumn? = null, val direction: MarketSortDirection = MarketSortDirection.DEFAULT, ) { - fun next(selectedColumn: MarketSortColumn): MarketSortState { + fun next( + selectedColumn: MarketSortColumn, + isScoreOrderingAvailable: Boolean = false, + ): MarketSortState { if (column != selectedColumn) { return MarketSortState(selectedColumn, MarketSortDirection.DESCENDING) } return when (direction) { MarketSortDirection.DEFAULT -> MarketSortState(selectedColumn, MarketSortDirection.DESCENDING) MarketSortDirection.DESCENDING -> MarketSortState(selectedColumn, MarketSortDirection.ASCENDING) - MarketSortDirection.ASCENDING -> MarketSortState() + MarketSortDirection.ASCENDING -> + if (isScoreOrderingAvailable) { + scoreMarketSortState() + } else { + MarketSortState() + } } } } @@ -96,8 +105,7 @@ fun defaultMarketSortState( MarketSortState(MarketSortColumn.VOLUME, MarketSortDirection.DESCENDING) topTab == MarketTopTab.CRYPTO && subTab == MarketSubTab.TRENDING -> MarketSortState(MarketSortColumn.VOLUME, MarketSortDirection.DESCENDING) - topTab == MarketTopTab.PERPETUAL && subTab == MarketSubTab.TRENDING -> - MarketSortState(MarketSortColumn.VOLUME, MarketSortDirection.DESCENDING) + isScoreOrderingAvailable(topTab, subTab) -> scoreMarketSortState() topTab == MarketTopTab.PERPETUAL && subTab == MarketSubTab.MEME -> MarketSortState(MarketSortColumn.VOLUME, MarketSortDirection.DESCENDING) topTab == MarketTopTab.PERPETUAL && subTab == MarketSubTab.INDICES -> @@ -109,6 +117,14 @@ fun defaultMarketSortState( else -> MarketSortState() } +internal fun scoreMarketSortState() = + MarketSortState(MarketSortColumn.SCORE, MarketSortDirection.DESCENDING) + +internal fun isScoreOrderingAvailable( + topTab: MarketTopTab, + subTab: MarketSubTab?, +): Boolean = topTab == MarketTopTab.PERPETUAL && subTab == MarketSubTab.TRENDING + data class MarketDisplaySettings( val quoteColorReversed: Boolean = false, val priceChangePeriod: MarketPriceChangePeriod = MarketPriceChangePeriod.SEVEN_DAYS, @@ -274,6 +290,7 @@ object MarketPageMapper { subTab: MarketSubTab, ): List = when (subTab) { + MarketSubTab.TRENDING -> markets.sortedByDescending(PerpsMarket::tradeVolumeScore1D) MarketSubTab.TOP_GAINERS -> markets.sortedByDescending { it.changePercentValue() ?: BigDecimal.ZERO } MarketSubTab.TOP_LOSERS -> markets.sortedBy { it.changePercentValue() ?: BigDecimal.ZERO } MarketSubTab.INDICES -> markets.filterByCategory(PerpsMarketCategoryKey.INDICES) @@ -338,6 +355,7 @@ object MarketPageMapper { MarketSortColumn.VOLUME -> entry.volume(useMarketCapForSpot) MarketSortColumn.PRICE -> entry.price() MarketSortColumn.CHANGE -> entry.changePercent(period) + MarketSortColumn.SCORE -> entry.score() } ?: BigDecimal.ZERO } return if (sortState.direction == MarketSortDirection.ASCENDING) { @@ -386,3 +404,9 @@ fun MarketListEntry.changePercent(period: MarketPriceChangePeriod): BigDecimal? is MarketListEntry.Spot -> market.changePercent(period) is MarketListEntry.Perpetual -> market.changePercentValue() } + +fun MarketListEntry.score(): BigDecimal? = + when (this) { + is MarketListEntry.Spot -> null + is MarketListEntry.Perpetual -> market.tradeVolumeScore1D.toBigDecimal() + } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketPageViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketPageViewModel.kt index 12eb07e246..254dd22a46 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketPageViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/market/MarketPageViewModel.kt @@ -115,7 +115,11 @@ class MarketPageViewModel fun updateSort(column: MarketSortColumn) { val state = _uiState.value - val sortState = state.sortState.next(column) + val sortState = + state.sortState.next( + column, + isScoreOrderingAvailable(state.selectedTopTab, state.selectedSubTab), + ) _uiState.value = state.copy(sortState = sortState) rebuildEntries() sortState.direction.analyticsValue()?.let { direction -> @@ -123,7 +127,7 @@ class MarketPageViewModel sortDirection = direction, primaryTab = state.selectedTopTab.analyticsValue(), secondaryTab = state.selectedSubTab.analyticsValue(), - sortField = state.analyticsSortField(column), + sortField = state.analyticsSortField(sortState.column ?: column), ) } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketListBottomSheetModels.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketListBottomSheetModels.kt index ed12b7034c..902b840002 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketListBottomSheetModels.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketListBottomSheetModels.kt @@ -6,6 +6,7 @@ import one.mixin.android.ui.home.web3.market.MarketSortDirection import one.mixin.android.ui.home.web3.market.MarketSortState import one.mixin.android.ui.home.web3.market.PerpsMarketCategoryKey import one.mixin.android.ui.home.web3.market.changePercentValue +import one.mixin.android.ui.home.web3.market.scoreMarketSortState import one.mixin.android.ui.home.web3.widget.MarketSort import java.math.BigDecimal @@ -32,7 +33,7 @@ internal enum class PerpsMarketCategory(val categoryKey: PerpsMarketCategoryKey? internal data class PerpsMarketListUiState( val query: String = "", val selectedCategory: PerpsMarketCategory = PerpsMarketCategory.ALL, - val sortState: MarketSortState = defaultPerpsMarketSortState(), + val sortState: MarketSortState = defaultPerpsMarketSortState(PerpsMarketCategory.ALL), val markets: List = emptyList(), val favoriteMarketIds: Set = emptySet(), val favoriteOverrides: Map = emptyMap(), @@ -76,14 +77,14 @@ internal data class PerpsMarketListUiState( if (selectedCategory == category) return this return copy( selectedCategory = category, - sortState = defaultPerpsMarketSortState(), + sortState = defaultPerpsMarketSortState(category), scrollToTopRequest = scrollToTopRequest + 1, ).normalizeRecommendationSelection() } fun selectSort(column: MarketSortColumn): PerpsMarketListUiState = copy( - sortState = sortState.next(column), + sortState = sortState.next(column, selectedCategory.isScoreOrderingAvailable), scrollToTopRequest = scrollToTopRequest + 1, ).normalizeRecommendationSelection() @@ -176,14 +177,21 @@ internal data class PerpsMarketListUiState( } private fun List.sortedByCurrentState(): List { - val column = sortState.column ?: return this - if (sortState.direction == MarketSortDirection.DEFAULT) return this + val column = sortState.column + if (column == null || sortState.direction == MarketSortDirection.DEFAULT) { + return if (selectedCategory.isScoreOrderingAvailable) { + sortedByDescending(PerpsMarket::tradeVolumeScore1D) + } else { + this + } + } val comparator = compareBy { market -> when (column) { MarketSortColumn.VOLUME -> market.volume.toBigDecimalOrNull() MarketSortColumn.PRICE -> market.last.toBigDecimalOrNull() MarketSortColumn.CHANGE -> market.changePercentValue() + MarketSortColumn.SCORE -> market.tradeVolumeScore1D.toBigDecimal() } ?: BigDecimal.ZERO } return if (sortState.direction == MarketSortDirection.ASCENDING) { @@ -222,21 +230,30 @@ internal data class PerpsMarketListUiState( initialCategory: String?, initialSort: MarketSort?, quoteColorReversed: Boolean, - ): PerpsMarketListUiState = - PerpsMarketListUiState( - selectedCategory = PerpsMarketCategory.fromInitialCategory(initialCategory), - sortState = initialSort.toPerpsMarketSortState(), + ): PerpsMarketListUiState { + val selectedCategory = PerpsMarketCategory.fromInitialCategory(initialCategory) + return PerpsMarketListUiState( + selectedCategory = selectedCategory, + sortState = initialSort.toPerpsMarketSortState(selectedCategory), quoteColorReversed = quoteColorReversed, ) + } } } -internal fun defaultPerpsMarketSortState() = - MarketSortState(MarketSortColumn.VOLUME, MarketSortDirection.DESCENDING) +internal fun defaultPerpsMarketSortState(category: PerpsMarketCategory) = + if (category.isScoreOrderingAvailable) { + scoreMarketSortState() + } else { + MarketSortState(MarketSortColumn.VOLUME, MarketSortDirection.DESCENDING) + } + +private val PerpsMarketCategory.isScoreOrderingAvailable: Boolean + get() = this == PerpsMarketCategory.ALL -internal fun MarketSort?.toPerpsMarketSortState(): MarketSortState = +internal fun MarketSort?.toPerpsMarketSortState(category: PerpsMarketCategory): MarketSortState = when (this) { - null, MarketSort.RANK_DESCENDING -> defaultPerpsMarketSortState() + null, MarketSort.RANK_DESCENDING -> defaultPerpsMarketSortState(category) MarketSort.RANK_ASCENDING -> MarketSortState(MarketSortColumn.VOLUME, MarketSortDirection.ASCENDING) MarketSort.PRICE_ASCENDING -> MarketSortState(MarketSortColumn.PRICE, MarketSortDirection.ASCENDING) MarketSort.PRICE_DESCENDING -> MarketSortState(MarketSortColumn.PRICE, MarketSortDirection.DESCENDING) diff --git a/app/src/main/java/one/mixin/android/util/analytics/AnalyticsTracker.kt b/app/src/main/java/one/mixin/android/util/analytics/AnalyticsTracker.kt index a0f8a35d6a..576119aef3 100644 --- a/app/src/main/java/one/mixin/android/util/analytics/AnalyticsTracker.kt +++ b/app/src/main/java/one/mixin/android/util/analytics/AnalyticsTracker.kt @@ -608,6 +608,7 @@ object AnalyticsTracker { const val TWENTY_FOUR_HOURS = "24h" const val SEVEN_DAYS = "7d" const val MARKET_CAP = "market_cap" + const val SCORE = "score" } object MarketDetailSource { diff --git a/app/src/test/java/one/mixin/android/db/perps/PerpsMarketDaoTest.kt b/app/src/test/java/one/mixin/android/db/perps/PerpsMarketDaoTest.kt new file mode 100644 index 0000000000..f1df54fdee --- /dev/null +++ b/app/src/test/java/one/mixin/android/db/perps/PerpsMarketDaoTest.kt @@ -0,0 +1,101 @@ +package one.mixin.android.db.perps + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import one.mixin.android.api.response.perps.PerpsMarket +import one.mixin.android.db.PerpsDatabase +import org.junit.Assert.assertEquals +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class PerpsMarketDaoTest { + private lateinit var database: PerpsDatabase + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + database = + Room.inMemoryDatabaseBuilder(context, PerpsDatabase::class.java) + .allowMainThreadQueries() + .build() + } + + @After + fun tearDown() { + database.close() + } + + @Test + fun marketsOrderByTradeVolumeScoreBeforeVolume() = + runBlocking { + database.perpsMarketDao().upsertList( + listOf( + market("highest-volume", volume = "1000", score = 10), + market("highest-score", volume = "1", score = 30), + market("middle-score", volume = "10", score = 20), + ), + ) + + val result = database.perpsMarketDao().observeAllMarkets().first() + + assertEquals( + listOf("highest-score", "middle-score", "highest-volume"), + result.map(PerpsMarket::marketId), + ) + } + + @Test + fun nonTrendingRefreshPreservesExistingTradeVolumeScore() = + runBlocking { + val cached = market("cached", volume = "10", score = 30) + database.perpsMarketDao().upsertSuspend(cached) + + val merged = + database.perpsMarketDao().upsertPreservingTradeVolumeScores( + listOf( + cached.copy(last = "2", tradeVolumeScore1D = 0), + market("uncached", volume = "20", score = 50), + ), + ) + + assertEquals(30, merged.first { it.marketId == "cached" }.tradeVolumeScore1D) + assertEquals("2", database.perpsMarketDao().getMarket("cached")?.last) + assertEquals(30, database.perpsMarketDao().getMarket("cached")?.tradeVolumeScore1D) + assertEquals(0, database.perpsMarketDao().getMarket("uncached")?.tradeVolumeScore1D) + } + + private fun market( + marketId: String, + volume: String, + score: Int, + ) = PerpsMarket( + marketId = marketId, + displaySymbol = marketId, + tokenSymbol = marketId, + quoteSymbol = "USD", + markPrice = "1", + leverage = 10, + iconUrl = "", + fundingRate = "0", + minAmount = "0", + maxAmount = "0", + last = "1", + volume = volume, + tradeVolumeScore1D = score, + high = "1", + low = "1", + open = "1", + change = "0", + bidPrice = "1", + askPrice = "1", + createdAt = "", + updatedAt = "", + ) +} diff --git a/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketPageModelsTest.kt b/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketPageModelsTest.kt index 3e666c91f2..42545739d5 100644 --- a/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketPageModelsTest.kt +++ b/app/src/test/java/one/mixin/android/ui/home/web3/market/MarketPageModelsTest.kt @@ -197,12 +197,12 @@ class MarketPageModelsTest { } @Test - fun perpetualTrendingPreservesApiRankOrder() { + fun perpetualTrendingSortsByTradeVolumeScore() { val markets = listOf( - perpsMarket(marketId = "first"), - perpsMarket(marketId = "second"), - perpsMarket(marketId = "third"), + perpsMarket(marketId = "second", tradeVolumeScore1D = 20), + perpsMarket(marketId = "third", tradeVolumeScore1D = 10), + perpsMarket(marketId = "first", tradeVolumeScore1D = 30), ) val trending = MarketPageMapper.perpetualMarkets(markets, MarketSubTab.TRENDING) @@ -310,6 +310,23 @@ class MarketPageModelsTest { assertEquals(MarketSortState(), reset) } + @Test + fun sortHeaderCyclesBackToScoreWhenAvailable() { + val descending = scoreMarketSortState().next(MarketSortColumn.VOLUME, isScoreOrderingAvailable = true) + val ascending = descending.next(MarketSortColumn.VOLUME, isScoreOrderingAvailable = true) + val score = + ascending + .next(MarketSortColumn.PRICE, isScoreOrderingAvailable = true) + .next(MarketSortColumn.PRICE, isScoreOrderingAvailable = true) + .next(MarketSortColumn.PRICE, isScoreOrderingAvailable = true) + + assertEquals(MarketSortState(MarketSortColumn.VOLUME, MarketSortDirection.DESCENDING), descending) + assertEquals(MarketSortState(MarketSortColumn.VOLUME, MarketSortDirection.ASCENDING), ascending) + assertEquals(scoreMarketSortState(), score) + assertTrue(isScoreOrderingAvailable(MarketTopTab.PERPETUAL, MarketSubTab.TRENDING)) + assertTrue(!isScoreOrderingAvailable(MarketTopTab.CRYPTO, MarketSubTab.TRENDING)) + } + @Test fun defaultVolumeSortMatchesMarketCategory() { listOf( @@ -326,7 +343,7 @@ class MarketPageModelsTest { defaultMarketSortState(MarketTopTab.CRYPTO, MarketSubTab.ALL), ) assertEquals( - MarketSortState(MarketSortColumn.VOLUME, MarketSortDirection.DESCENDING), + scoreMarketSortState(), defaultMarketSortState(MarketTopTab.PERPETUAL, MarketSubTab.TRENDING), ) assertEquals( @@ -415,6 +432,23 @@ class MarketPageModelsTest { assertEquals(listOf("spot:higher-volume", "spot:lower-volume"), result.map { it.stableId }) } + @Test + fun applySortByScoreOrdersPerpetualMarkets() { + val second = MarketListEntry.Perpetual(perpsMarket(marketId = "second", tradeVolumeScore1D = 20), false) + val third = MarketListEntry.Perpetual(perpsMarket(marketId = "third", tradeVolumeScore1D = 10), false) + val first = MarketListEntry.Perpetual(perpsMarket(marketId = "first", tradeVolumeScore1D = 30), false) + + val result = + MarketPageMapper.applySort( + entries = listOf(second, third, first), + sortState = scoreMarketSortState(), + period = MarketPriceChangePeriod.TWENTY_FOUR_HOURS, + useMarketCapForSpot = false, + ) + + assertEquals(listOf("perpetual:first", "perpetual:second", "perpetual:third"), result.map { it.stableId }) + } + @Test fun initialLoadingDoesNotFlashSpinnerBeforeLocalDatabaseResult() { val state = MarketPageUiState(isLoading = true, hasLoadedLocalData = false) @@ -487,6 +521,7 @@ class MarketPageModelsTest { marketId: String, change: String = "0", category: String = "", + tradeVolumeScore1D: Int = 0, ) = PerpsMarket( marketId = marketId, displaySymbol = marketId, @@ -501,6 +536,7 @@ class MarketPageModelsTest { maxAmount = "0", last = "1", volume = "10", + tradeVolumeScore1D = tradeVolumeScore1D, high = "1", low = "1", open = "1", diff --git a/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketListBottomSheetModelsTest.kt b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketListBottomSheetModelsTest.kt index 30749bab42..5caebbed36 100644 --- a/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketListBottomSheetModelsTest.kt +++ b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketListBottomSheetModelsTest.kt @@ -1,6 +1,10 @@ package one.mixin.android.ui.home.web3.trade.perps import one.mixin.android.api.response.perps.PerpsMarket +import one.mixin.android.ui.home.web3.market.MarketSortColumn +import one.mixin.android.ui.home.web3.market.MarketSortDirection +import one.mixin.android.ui.home.web3.market.MarketSortState +import one.mixin.android.ui.home.web3.market.scoreMarketSortState import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertSame @@ -35,6 +39,43 @@ class PerpsMarketListBottomSheetModelsTest { assertEquals(setOf("stock", "stocks"), visibleMarkets.mapTo(mutableSetOf()) { it.marketId }) } + @Test + fun allMarketsSortByTradeVolumeScore() { + val visibleMarkets = + PerpsMarketListUiState( + markets = + listOf( + perpsMarket("second", "crypto", tradeVolumeScore1D = 20), + perpsMarket("third", "stocks", tradeVolumeScore1D = 10), + perpsMarket("first", "crypto", tradeVolumeScore1D = 30), + ), + ).visibleMarkets + + assertEquals(listOf("first", "second", "third"), visibleMarkets.map { it.marketId }) + } + + @Test + fun allCategoryHeaderCyclesBackToScore() { + val markets = + listOf( + perpsMarket("second", "crypto", tradeVolumeScore1D = 20, volume = "300"), + perpsMarket("third", "stocks", tradeVolumeScore1D = 10, volume = "200"), + perpsMarket("first", "crypto", tradeVolumeScore1D = 30, volume = "100"), + ) + val descending = PerpsMarketListUiState(markets = markets).selectSort(MarketSortColumn.VOLUME) + val ascending = descending.selectSort(MarketSortColumn.VOLUME) + val score = ascending.selectSort(MarketSortColumn.VOLUME) + + assertEquals(MarketSortState(MarketSortColumn.VOLUME, MarketSortDirection.DESCENDING), descending.sortState) + assertEquals(MarketSortState(MarketSortColumn.VOLUME, MarketSortDirection.ASCENDING), ascending.sortState) + assertEquals(scoreMarketSortState(), score.sortState) + assertEquals(listOf("first", "second", "third"), score.visibleMarkets.map { it.marketId }) + assertEquals( + MarketSortState(MarketSortColumn.VOLUME, MarketSortDirection.DESCENDING), + score.selectCategory(PerpsMarketCategory.CRYPTO).sortState, + ) + } + @Test fun cachesDerivedMarketListsForEachStateInstance() { val state = @@ -91,6 +132,8 @@ class PerpsMarketListBottomSheetModelsTest { private fun perpsMarket( marketId: String, category: String, + tradeVolumeScore1D: Int = 0, + volume: String = "10", ) = PerpsMarket( marketId = marketId, displaySymbol = marketId, @@ -104,7 +147,8 @@ class PerpsMarketListBottomSheetModelsTest { minAmount = "0", maxAmount = "0", last = "1", - volume = "10", + volume = volume, + tradeVolumeScore1D = tradeVolumeScore1D, high = "1", low = "1", open = "1",