From e534988f15f41a3f003d4091caa3deeee9abcfe1 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 8 Oct 2026 14:26:58 +0800 Subject: [PATCH 1/9] feat(perps): support partial position reduction --- .../api/request/perps/OpenOrderRequest.kt | 4 +- .../web3/trade/perps/PerpetualViewModel.kt | 36 +-- .../PerpsAdjustBottomSheetDialogFragment.kt | 7 +- .../PerpsCloseBottomSheetDialogFragment.kt | 55 ++++- .../PerpsMarginBottomSheetDialogFragment.kt | 6 +- .../web3/trade/perps/PerpsMarketDetailPage.kt | 6 + .../PerpsReduceBottomSheetDialogFragment.kt | 227 ++++++++++++++++++ .../web3/trade/perps/PerpsReduceQuantity.kt | 32 +++ .../trade/perps/PositionDetailFragment.kt | 7 +- app/src/main/res/values-zh-rCN/strings.xml | 6 + app/src/main/res/values-zh-rTW/strings.xml | 6 + app/src/main/res/values/strings.xml | 6 + .../web3/trade/perps/PerpsDialogResultTest.kt | 1 + .../trade/perps/PerpsReduceQuantityTest.kt | 75 ++++++ 14 files changed, 447 insertions(+), 27 deletions(-) create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsReduceBottomSheetDialogFragment.kt create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsReduceQuantity.kt create mode 100644 app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsReduceQuantityTest.kt diff --git a/app/src/main/java/one/mixin/android/api/request/perps/OpenOrderRequest.kt b/app/src/main/java/one/mixin/android/api/request/perps/OpenOrderRequest.kt index 486c9c6322..f7ca9ac2fa 100644 --- a/app/src/main/java/one/mixin/android/api/request/perps/OpenOrderRequest.kt +++ b/app/src/main/java/one/mixin/android/api/request/perps/OpenOrderRequest.kt @@ -40,7 +40,9 @@ data class OpenOrderResponse( data class CloseOrderRequest( @SerializedName("position_id") - val positionId: String + val positionId: String, + @SerializedName("quantity") + val quantity: String? = null, ) data class CloseOrderResponse( diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualViewModel.kt index b157fa7163..c86f0430e8 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualViewModel.kt @@ -1015,10 +1015,11 @@ class PerpetualViewModel @Inject constructor( fun closePerpsOrder( positionId: String, onSuccess: () -> Unit, - onError: (String) -> Unit + onError: (String) -> Unit, + quantity: String? = null, ) { viewModelScope.launch { - closePerpsOrder(positionId) + closePerpsOrder(positionId, quantity) .onSuccess { onSuccess() } .onFailure { onError(it.message.orEmpty()) } } @@ -1049,20 +1050,27 @@ class PerpetualViewModel @Inject constructor( private suspend fun closePerpsOrder( positionId: String, + quantity: String? = null, ): Result { return runCatching { val response = withContext(Dispatchers.IO) { - routeService.closePerpsOrder(CloseOrderRequest(positionId = positionId)) + if (quantity != null) { + val positionResponse = routeService.getPerpsPosition(positionId) + check(positionResponse.isSuccess) { positionResponse.errorDescription.orEmpty() } + val position = positionResponse.data + require(position?.state == PerpsPosition.STATE_OPEN && perpsReduceQuantity(quantity, position.quantity) != null) { + MixinApplication.appContext.getString(R.string.perps_reduce_quantity_changed) + } + } + routeService.closePerpsOrder(CloseOrderRequest(positionId = positionId, quantity = quantity)) } if (!response.isSuccess) { error("Failed to close perps order: ${response.errorDescription}") } - withContext(Dispatchers.IO) { - perpsPositionDao.deleteById(positionId) - } - Timber.d("Perps order closed: $positionId") + refreshSinglePosition(positionId) + Timber.d("Perps close order submitted: $positionId") }.onFailure { error -> Timber.e(error, "Error closing perps order: $positionId") } @@ -1207,14 +1215,12 @@ class PerpetualViewModel @Inject constructor( perpsOrderDao.insertAll(orders) - // Sync position status if it's a close order - orders.filter { it.orderType == PerpsOrder.TYPE_CLOSE && it.status == PerpsOrder.STATUS_FILLED } - .forEach { closeOrder -> - perpsPositionDao.updateStatus( - closeOrder.positionId, - "closed", - closeOrder.updatedAt, - ) + orders.filter { it.orderType == PerpsOrder.TYPE_CLOSE } + .distinctBy { it.positionId } + .forEach { order -> + if (perpsPositionDao.getPosition(order.positionId)?.state in listOf(PerpsPosition.STATE_OPEN, PerpsPosition.STATE_OPENING, PerpsPosition.STATE_ADDING)) { + refreshSinglePosition(order.positionId) + } } } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsAdjustBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsAdjustBottomSheetDialogFragment.kt index 3168496519..cf61ebef70 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsAdjustBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsAdjustBottomSheetDialogFragment.kt @@ -49,6 +49,7 @@ class PerpsAdjustBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme const val ACTION_ADD_MARGIN = "add_margin" const val ACTION_REDUCE_MARGIN = "reduce_margin" const val ACTION_ADD_POSITION = "add_position" + const val ACTION_REDUCE_POSITION = "reduce_position" private const val ARGS_POSITION_ID = "args_position_id" fun newInstance(positionId: String) = PerpsAdjustBottomSheetDialogFragment().withArgs { @@ -71,6 +72,7 @@ class PerpsAdjustBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme onAddMargin = { selectAction(ACTION_ADD_MARGIN) }, onReduceMargin = { selectAction(ACTION_REDUCE_MARGIN) }, onAddPosition = { selectAction(ACTION_ADD_POSITION) }, + onReducePosition = { selectAction(ACTION_REDUCE_POSITION) }, ) } } @@ -90,6 +92,7 @@ private fun PerpsAdjustContent( onAddMargin: () -> Unit, onReduceMargin: () -> Unit, onAddPosition: () -> Unit, + onReducePosition: () -> Unit, ) { Column( modifier = Modifier @@ -131,6 +134,8 @@ private fun PerpsAdjustContent( SectionTitle(R.string.perps_position) Spacer(Modifier.height(10.dp)) AdjustAction(R.string.perps_add_to_position, R.string.perps_add_position_description, R.drawable.ic_perps_add_position, onAddPosition) + Spacer(Modifier.height(8.dp)) + AdjustAction(R.string.perps_reduce_position, R.string.perps_reduce_position_description, R.drawable.ic_perps_reduce_position, onReducePosition) Spacer(Modifier.height(30.dp)) Column( modifier = Modifier.padding(horizontal = 12.dp), @@ -194,6 +199,6 @@ private fun AdjustAction( @Composable private fun PerpsAdjustPreview() { MixinAppTheme { - PerpsAdjustContent(onDismiss = {}, onAddMargin = {}, onReduceMargin = {}, onAddPosition = {}) + PerpsAdjustContent(onDismiss = {}, onAddMargin = {}, onReduceMargin = {}, onAddPosition = {}, onReducePosition = {}) } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt index a7953fbbd3..d2c52eab14 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt @@ -84,10 +84,13 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen companion object { const val TAG = "PerpsCloseBottomSheetDialogFragment" const val RESULT_MARGIN_CONFIRMED = "perps_margin_confirmed" + const val RESULT_POSITION_REDUCED = "perps_position_reduced" const val RESULT_POSITION_ID = "position_id" const val RESULT_AMOUNT = "amount" private const val ARGS_POSITION_ID = "args_position_id" private const val ARGS_REDUCE_MARGIN_AMOUNT = "args_reduce_margin_amount" + private const val ARGS_REDUCE_QUANTITY = "args_reduce_quantity" + private const val ARGS_QUANTITY = "args_quantity" private const val ARGS_SIDE = "args_side" private const val ARGS_MARGIN = "args_margin" private const val ARGS_LEVERAGE = "args_leverage" @@ -100,10 +103,13 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen fun newInstance( position: PerpsPosition, reduceMarginAmount: String? = null, + reduceQuantity: String? = null, ): PerpsCloseBottomSheetDialogFragment { return PerpsCloseBottomSheetDialogFragment().withArgs { putString(ARGS_POSITION_ID, position.positionId) putString(ARGS_REDUCE_MARGIN_AMOUNT, reduceMarginAmount) + putString(ARGS_REDUCE_QUANTITY, reduceQuantity) + putString(ARGS_QUANTITY, position.quantity) putString(ARGS_SIDE, position.side) putString(ARGS_MARGIN, position.margin) putInt(ARGS_LEVERAGE, position.leverage) @@ -157,6 +163,8 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen } private val reduceMarginAmount by lazy { arguments?.getString(ARGS_REDUCE_MARGIN_AMOUNT) } private val isReduceMargin get() = reduceMarginAmount != null + private val reduceQuantity by lazy { arguments?.getString(ARGS_REDUCE_QUANTITY) } + private val isReducePosition get() = reduceQuantity != null private val isLong by lazy { requireNotNull(requireArguments().getString(ARGS_SIDE)) { "side is null" } @@ -180,6 +188,9 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen private var latestMarkPrice by mutableStateOf("") private var latestUnrealizedPnl by mutableStateOf("") + private var latestQuantity by mutableStateOf("") + private var latestMargin by mutableStateOf("") + private var latestLiquidationPrice by mutableStateOf(null) private var marketIconUrl by mutableStateOf("") private var marketSymbol by mutableStateOf("") private var settleAssetSymbol by mutableStateOf("USDT") @@ -195,6 +206,8 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen LaunchedEffect(Unit) { latestMarkPrice = markPrice latestUnrealizedPnl = unrealizedPnl + latestQuantity = arguments?.getString(ARGS_QUANTITY).orEmpty() + latestMargin = margin } LaunchedEffect(positionId) { @@ -202,6 +215,9 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen localPosition?.let { position -> latestMarkPrice = position.markPrice ?: latestMarkPrice latestUnrealizedPnl = position.unrealizedPnl ?: latestUnrealizedPnl + latestQuantity = position.quantity + latestMargin = position.margin ?: latestMargin + latestLiquidationPrice = position.liquidationPrice marketIconUrl = position.iconUrl.orEmpty() marketSymbol = position.displaySymbol ?: position.tokenSymbol.orEmpty() refreshAssetAndSender( @@ -220,6 +236,9 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen onSuccess = { position -> latestMarkPrice = position.markPrice latestUnrealizedPnl = position.unrealizedPnl + latestQuantity = position.quantity + latestMargin = position.margin + latestLiquidationPrice = position.liquidationPrice lifecycleScope.launch { viewModel.getMarketFromDb(position.marketId)?.let { market -> @@ -321,8 +340,8 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen Text( text = stringResource( id = when (step) { - Step.Pending -> if (isReduceMargin) R.string.perps_confirm_reduce_margin_title else R.string.confirm_closing_position - Step.Done -> if (isReduceMargin) R.string.perps_margin_submitted else R.string.Position_Closed + Step.Pending -> if (isReduceMargin) R.string.perps_confirm_reduce_margin_title else if (isReducePosition) R.string.perps_confirm_reduce_position else R.string.confirm_closing_position + Step.Done -> if (isReduceMargin) R.string.perps_margin_submitted else R.string.perps_close_submitted Step.Error -> if (isReduceMargin) R.string.perps_reducing_margin_failed else if (isLong) R.string.Closed_Long_Failed else R.string.Closed_Short_Failed Step.Sending -> if (isReduceMargin) R.string.perps_reducing_margin else R.string.Sending } @@ -363,7 +382,8 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen Box(modifier = Modifier.height(20.dp)) val pnl = try { - BigDecimal(latestUnrealizedPnl) + if (isReducePosition) perpsReductionValue(latestUnrealizedPnl, reduceQuantity.orEmpty(), latestQuantity) ?: BigDecimal.ZERO + else BigDecimal(latestUnrealizedPnl) } catch (e: Exception) { BigDecimal.ZERO } @@ -376,15 +396,16 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen } val estimatedReceive = if (isReduceMargin) reduceMarginAmount?.toBigDecimalOrNull() ?: BigDecimal.ZERO else try { - val margin = BigDecimal(margin) + val margin = BigDecimal(latestMargin) val unrealizedPnl = BigDecimal(latestUnrealizedPnl) - margin + unrealizedPnl + val total = margin + unrealizedPnl + (if (isReducePosition) perpsReductionValue(total.toPlainString(), reduceQuantity.orEmpty(), latestQuantity) ?: BigDecimal.ZERO else total).max(BigDecimal.ZERO) } catch (e: Exception) { BigDecimal.ZERO } val pnlPercent = try { - val marginValue = BigDecimal(margin) + val marginValue = BigDecimal(latestMargin) if (marginValue <= BigDecimal.ZERO) { BigDecimal.ZERO } else { @@ -425,6 +446,10 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen ) } Box(modifier = Modifier.height(20.dp)) + if (isReducePosition) { + PerpsAddInfoRow(stringResource(R.string.Quantity), reduceQuantity.orEmpty()) + Spacer(Modifier.height(16.dp)) + } settleAssetItem?.let { asset -> Text( text = stringResource(R.string.Estimated_Receive).uppercase(), @@ -474,6 +499,12 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen ) } } + if (isReducePosition) { + Spacer(Modifier.height(16.dp)) + PerpsAddInfoRow(stringResource(R.string.Liquidation_Price), latestLiquidationPrice ?: "-") + Spacer(Modifier.height(8.dp)) + Text(stringResource(R.string.perps_reduction_estimate_note), color = MixinAppTheme.colors.textAssist, fontSize = 12.sp) + } } Box(modifier = Modifier.height(20.dp)) @@ -497,6 +528,12 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen ) { MixinButton( onClick = { + if (isReducePosition) { + parentFragmentManager.setFragmentResult( + RESULT_POSITION_REDUCED, + Bundle().apply { putString(RESULT_POSITION_ID, positionId) }, + ) + } onDoneAction?.invoke() dismiss() }, @@ -565,6 +602,11 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen private fun showVerifyPinThenClose() { if (childFragmentManager.findFragmentByTag(VerifyBottomSheetDialogFragment.TAG) != null) return + if (isReducePosition && perpsReduceQuantity(reduceQuantity.orEmpty(), latestQuantity) == null) { + errorInfo = getString(R.string.perps_reduce_quantity_changed) + step = Step.Error + return + } if (isReduceMargin && marginAdjustmentAmount(reduceMarginAmount.orEmpty()) == null) { errorInfo = getString(R.string.Data_error) step = Step.Error @@ -602,6 +644,7 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen step = Step.Sending viewModel.closePerpsOrder( positionId = positionId, + quantity = reduceQuantity, onSuccess = { step = Step.Done AnalyticsTracker.trackPerpsCloseEnd() diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt index 6b0c9daca6..e74f0b8b39 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt @@ -453,7 +453,7 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme } } } else { - PerpsReduceMarginInput( + PerpsReductionInput( input = reduceInput, isPercentage = reduceByPercent, amount = amountValue, @@ -646,7 +646,7 @@ private fun PerpsMarginActions( } @Composable -private fun PerpsReduceMarginInput( +internal fun PerpsReductionInput( input: String, isPercentage: Boolean, amount: BigDecimal?, @@ -904,7 +904,7 @@ private fun PerpsMarginPreviewContent(initialInput: String, initialIsPercentage: onSubmit = {}, onGuide = {}, ) { - PerpsReduceMarginInput( + PerpsReductionInput( input = displayInput, isPercentage = isPercentage, amount = amount, diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt index 9d58ff1754..906f404de1 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt @@ -152,6 +152,12 @@ fun PerpsMarketDetailPage( val position = currentPosition if (activity != null && position?.state == PerpsPosition.STATE_OPEN && !isAddingProcessing) { when (action) { + PerpsAdjustBottomSheetDialogFragment.ACTION_REDUCE_POSITION -> { + if (activity.supportFragmentManager.findFragmentByTag(PerpsReduceBottomSheetDialogFragment.TAG) == null) { + PerpsReduceBottomSheetDialogFragment.newInstance(position) + .show(activity.supportFragmentManager, PerpsReduceBottomSheetDialogFragment.TAG) + } + } PerpsAdjustBottomSheetDialogFragment.ACTION_ADD_MARGIN, PerpsAdjustBottomSheetDialogFragment.ACTION_REDUCE_MARGIN -> { PerpsMarginBottomSheetDialogFragment.newInstance( diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsReduceBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsReduceBottomSheetDialogFragment.kt new file mode 100644 index 0000000000..7fabbcf7c1 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsReduceBottomSheetDialogFragment.kt @@ -0,0 +1,227 @@ +package one.mixin.android.ui.home.web3.trade.perps + +import android.os.Bundle +import android.view.View +import android.view.WindowManager +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.repeatOnLifecycle +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import one.mixin.android.Constants +import one.mixin.android.R +import one.mixin.android.api.response.perps.PerpsPosition +import one.mixin.android.api.response.perps.PerpsPositionItem +import one.mixin.android.api.response.perps.toPosition +import one.mixin.android.compose.CoilImage +import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.extension.defaultSharedPreferences +import one.mixin.android.extension.getParcelableCompat +import one.mixin.android.extension.getSafeAreaInsetsTop +import one.mixin.android.extension.screenHeight +import one.mixin.android.extension.withArgs +import one.mixin.android.ui.common.MixinComposeBottomSheetDialogFragment +import one.mixin.android.widget.components.MixinButton +import java.math.BigDecimal +import java.math.RoundingMode + +@AndroidEntryPoint +class PerpsReduceBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragment() { + companion object { + const val TAG = "PerpsReduceBottomSheetDialogFragment" + private const val ARGS_POSITION = "args_position" + + fun newInstance(position: PerpsPositionItem) = PerpsReduceBottomSheetDialogFragment().withArgs { + putParcelable(ARGS_POSITION, position) + } + } + + private val viewModel by viewModels() + private val initialPosition by lazy { + requireNotNull(requireArguments().getParcelableCompat(ARGS_POSITION, PerpsPositionItem::class.java)) + } + + override fun getTheme() = R.style.AppTheme_Dialog + + override fun getBottomSheetHeight(view: View): Int = requireContext().screenHeight() - view.getSafeAreaInsetsTop() + + override fun onStart() { + super.onStart() + dialog?.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + childFragmentManager.setFragmentResultListener(PerpsCloseBottomSheetDialogFragment.RESULT_POSITION_REDUCED, this) { _, result -> + if (result.getString(PerpsCloseBottomSheetDialogFragment.RESULT_POSITION_ID) == initialPosition.positionId) dismiss() + } + } + + @Composable + override fun ComposeContent() { + val observedPosition by remember(initialPosition.positionId) { + viewModel.observePosition(initialPosition.positionId) + }.collectAsStateWithLifecycle(initialValue = initialPosition) + val position = observedPosition ?: initialPosition + var input by rememberSaveable { mutableStateOf("") } + var isPercentage by rememberSaveable { mutableStateOf(true) } + val keyboard = LocalSoftwareKeyboardController.current + LaunchedEffect(initialPosition.positionId) { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { + while (isActive) { + viewModel.refreshSinglePosition(initialPosition.positionId, initialPosition.walletId) + delay(10_000) + } + } + } + val current = position.quantity.toBigDecimalOrNull()?.abs() ?: BigDecimal.ZERO + val price = position.markPrice?.toBigDecimalOrNull()?.takeIf { it > BigDecimal.ZERO } + val size = price?.multiply(current) + val inputValue = input.toBigDecimalOrNull() ?: BigDecimal.ZERO + val quantity = perpsReductionQuantity(position.quantity, position.markPrice, input, isPercentage) + val amount = if (isPercentage) size?.multiply(inputValue)?.movePointLeft(2) else inputValue + val percentage = if (isPercentage) inputValue else size?.takeIf { it > BigDecimal.ZERO }?.let { + inputValue.multiply(BigDecimal(100)).divide(it, 8, RoundingMode.DOWN) + } + val error = when { + observedPosition?.state != PerpsPosition.STATE_OPEN -> stringResource(R.string.perps_reduce_quantity_changed) + (isPercentage && inputValue > BigDecimal(100)) || (!isPercentage && size != null && inputValue > size) -> + stringResource(R.string.max_removable, if (isPercentage) "100%" else formatPerpsMarginAmount(size)) + inputValue > BigDecimal.ZERO && quantity == null -> stringResource(R.string.perps_reduce_invalid_quantity) + else -> null + } + val canSubmit = quantity != null && error == null + val pnl = perpsReductionValue(position.unrealizedPnl, quantity?.toPlainString().orEmpty(), position.quantity) ?: BigDecimal.ZERO + val margin = position.margin?.toBigDecimalOrNull()?.takeIf { it > BigDecimal.ZERO } + val roe = margin?.let { position.unrealizedPnl?.toBigDecimalOrNull()?.multiply(BigDecimal(100))?.divide(it, 8, RoundingMode.HALF_UP) } + val remaining = quantity?.let { current - it } + val quoteColorReversed = requireContext().defaultSharedPreferences.getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) + + MixinAppTheme { + Column(Modifier.fillMaxSize().background(MixinAppTheme.colors.background).imePadding()) { + Column(Modifier.weight(1f).verticalScroll(rememberScrollState()).padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + CoilImage(position.iconUrl, modifier = Modifier.size(30.dp).clip(CircleShape), placeholder = R.drawable.ic_avatar_place_holder) + Spacer(Modifier.width(10.dp)) + Column(Modifier.weight(1f)) { + Text( + stringResource(R.string.perps_reduce_position_title, stringResource(if (position.side.equals("long", true)) R.string.Long else R.string.Short), position.tokenSymbol.orEmpty()), + color = MixinAppTheme.colors.textPrimary, + fontSize = 16.sp, + fontWeight = FontWeight.W600, + ) + Spacer(Modifier.height(2.dp)) + Text( + stringResource(R.string.auto_close_subtitle_after_open, formatPerpsPrice(position.entryPrice, position.priceScale), formatPerpsPrice(position.markPrice ?: position.entryPrice, position.priceScale)), + color = MixinAppTheme.colors.textAssist, + fontSize = 12.sp, + ) + } + IconButton(onClick = { dismiss() }) { + Icon(painterResource(R.drawable.ic_circle_close), stringResource(R.string.close), tint = Color.Unspecified, modifier = Modifier.size(26.dp)) + } + } + Spacer(Modifier.height(16.dp)) + PerpsReductionInput( + input = input, + isPercentage = isPercentage, + amount = amount, + percentage = percentage, + margin = size, + errorText = error, + enabled = observedPosition?.state == PerpsPosition.STATE_OPEN, + onInputChanged = { input = it }, + onToggleMode = { + input = (if (isPercentage) amount else percentage)?.let { formatMarginAdjustmentInput(it, !isPercentage) }.orEmpty() + isPercentage = !isPercentage + }, + ) + Spacer(Modifier.height(18.dp)) + Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + val risingColor = if (quoteColorReversed) MixinAppTheme.colors.walletRed else MixinAppTheme.colors.walletGreen + val fallingColor = if (quoteColorReversed) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed + PerpsAddInfoRow( + title = stringResource(R.string.perps_estimated_pnl), + value = formatPerpsSignedRawUsdDecimal(pnl) + if (quantity != null && roe != null) " (${formatPerpsSignedPercent(roe, withSign = false)})" else "", + valueColor = if (pnl.signum() == 0) MixinAppTheme.colors.textAssist else if (pnl.signum() > 0) risingColor else fallingColor, + ) + fun sizeText(value: BigDecimal): String = "${formatPerpsQuantity(value)} ${position.tokenSymbol.orEmpty()} (${formatPerpsMarginAmount(price?.multiply(value))})" + PerpsAddInfoRow( + title = stringResource(R.string.Size), + value = if (remaining == null) sizeText(current) else "${sizeText(current)} → ${sizeText(remaining)}", + singleLine = false, + ) + PerpsAddInfoRow( + title = stringResource(R.string.Liquidation_Price), + value = position.liquidationPrice?.let { formatPerpsPrice(it, position.priceScale) } ?: "-", + ) + } + } + Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp).padding(top = 12.dp, bottom = 20.dp), horizontalArrangement = Arrangement.spacedBy(16.dp)) { + MixinButton( + onClick = { dismiss() }, + modifier = Modifier.weight(1f).height(48.dp), + backgroundColor = MixinAppTheme.colors.backgroundGrayLight, + shape = RoundedCornerShape(32.dp), + contentPadding = PaddingValues(vertical = 12.dp), + ) { + Text(stringResource(R.string.Cancel), color = MixinAppTheme.colors.textPrimary, fontSize = 16.sp) + } + MixinButton( + onClick = { + if (quantity != null && canSubmit && childFragmentManager.findFragmentByTag(PerpsCloseBottomSheetDialogFragment.TAG) == null) { + keyboard?.hide() + PerpsCloseBottomSheetDialogFragment.newInstance(position.toPosition(), reduceQuantity = quantity.stripTrailingZeros().toPlainString()) + .show(childFragmentManager, PerpsCloseBottomSheetDialogFragment.TAG) + } + }, + enabled = canSubmit, + modifier = Modifier.weight(1f).height(48.dp), + backgroundColor = if (canSubmit) MixinAppTheme.colors.accent else MixinAppTheme.colors.backgroundGrayLight, + shape = RoundedCornerShape(32.dp), + contentPadding = PaddingValues(vertical = 12.dp), + ) { + Text(stringResource(R.string.perps_reduce_action), color = if (canSubmit) Color.White else MixinAppTheme.colors.textAssist, fontSize = 16.sp) + } + } + } + } + } + + override fun showError(error: String) = Unit +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsReduceQuantity.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsReduceQuantity.kt new file mode 100644 index 0000000000..ef99888c5b --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsReduceQuantity.kt @@ -0,0 +1,32 @@ +package one.mixin.android.ui.home.web3.trade.perps + +import java.math.BigDecimal +import java.math.RoundingMode + +internal fun perpsReduceQuantity(input: String, currentQuantity: String): BigDecimal? { + if (input.length > 40 || input.any { it !in '0'..'9' && it != '.' }) return null + val quantity = input.toBigDecimalOrNull() ?: return null + val current = currentQuantity.toBigDecimalOrNull()?.abs() ?: return null + return quantity.takeIf { it > BigDecimal.ZERO && it <= current } +} + +internal fun perpsReductionQuantity(currentQuantity: String, markPrice: String?, input: String, isPercentage: Boolean): BigDecimal? { + val current = currentQuantity.toBigDecimalOrNull()?.abs()?.takeIf { it > BigDecimal.ZERO } ?: return null + val value = marginAdjustmentAmount(input) ?: return null + val quantity = if (isPercentage) { + if ('.' in input || value > BigDecimal(100)) return null + current.multiply(value).movePointLeft(2) + } else { + val price = markPrice?.toBigDecimalOrNull()?.takeIf { it > BigDecimal.ZERO } ?: return null + if (value > current.multiply(price)) return null + // ponytail: Until quantity_scale is provided, keep eight decimals and let the server validate the exchange step. + value.divide(price, maxOf(8, current.scale()), RoundingMode.DOWN) + } + return perpsReduceQuantity(quantity.toPlainString(), currentQuantity) +} + +internal fun perpsReductionValue(value: String?, quantity: String, currentQuantity: String): BigDecimal? { + val total = value?.toBigDecimalOrNull() ?: return null + val reduction = perpsReduceQuantity(quantity, currentQuantity) ?: return null + return total.multiply(reduction).divide(BigDecimal(currentQuantity).abs(), 16, RoundingMode.DOWN) +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailFragment.kt index fc7911ff6e..ef22d3f913 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailFragment.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.launch import one.mixin.android.Constants import one.mixin.android.api.response.perps.PerpsOrder import one.mixin.android.api.response.perps.PerpsOrderItem +import one.mixin.android.api.response.perps.PerpsPosition import one.mixin.android.api.response.perps.PerpsPositionItem import one.mixin.android.api.response.perps.toPosition import one.mixin.android.compose.theme.MixinAppTheme @@ -188,12 +189,16 @@ class PositionDetailFragment : BaseFragment() { }, onShare = { lifecycleScope.launch { + val active = cachedPosition.value ?: viewModel.getPositionFromDb(closeOrder.positionId) + if (active?.state in listOf(PerpsPosition.STATE_OPEN, PerpsPosition.STATE_OPENING, PerpsPosition.STATE_ADDING)) { + sharePosition(requireNotNull(active)) + return@launch + } val closed = viewModel.getCloseOrderFromDb(closeOrder.positionId) if (closed != null && closed.orderType == PerpsOrder.TYPE_CLOSE) { sharePosition(closed, closed.leverage) return@launch } - val active = cachedPosition.value ?: viewModel.getPositionFromDb(closeOrder.positionId) if (active != null) { sharePosition(active) } diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 2452a2832d..67dc7df17a 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2650,4 +2650,10 @@ 减少保证金 已实现盈亏 未实现盈亏 + 确认减仓 + 平仓订单已提交 + 持仓已变化,请返回核对数量后重试。 + 请输入大于零且不超过当前仓位的金额。 + 预计盈亏 + 预估不含手续费,实际到账以成交和结算为准。此处显示当前强平价格。 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index fa0869138f..4d0abbca05 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -2702,4 +2702,10 @@ 減少保證金 已實現盈虧 未實現盈虧 + 確認減倉 + 平倉訂單已提交 + 持倉已變化,請返回核對數量後重試。 + 請輸入大於零且不超過當前倉位的金額。 + 預計盈虧 + 預估不含手續費,實際到帳以成交和結算為準。此處顯示當前強平價格。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5bf8c9d1a0..453188e4aa 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2732,4 +2732,10 @@ Reduced Margin Realized PnL Unrealized PnL + Confirm Reducing Position + Close order submitted + The position has changed. Please return and check the quantity before trying again. + Enter a positive amount within the current position size. + Estimated PnL + Estimates exclude fees. Final proceeds depend on execution and settlement. The liquidation price shown is the current price. diff --git a/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsDialogResultTest.kt b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsDialogResultTest.kt index a9f58b7278..05fe548857 100644 --- a/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsDialogResultTest.kt +++ b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsDialogResultTest.kt @@ -28,6 +28,7 @@ class PerpsDialogResultTest { PerpsAdjustBottomSheetDialogFragment.ACTION_ADD_MARGIN, PerpsAdjustBottomSheetDialogFragment.ACTION_REDUCE_MARGIN, PerpsAdjustBottomSheetDialogFragment.ACTION_ADD_POSITION, + PerpsAdjustBottomSheetDialogFragment.ACTION_REDUCE_POSITION, )) { assertRestoredResult( original = PerpsAdjustBottomSheetDialogFragment.newInstance("position"), diff --git a/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsReduceQuantityTest.kt b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsReduceQuantityTest.kt new file mode 100644 index 0000000000..df89d5f36b --- /dev/null +++ b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsReduceQuantityTest.kt @@ -0,0 +1,75 @@ +package one.mixin.android.ui.home.web3.trade.perps + +import com.google.gson.Gson +import one.mixin.android.api.request.perps.CloseOrderRequest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.math.BigDecimal + +class PerpsReduceQuantityTest { + @Test + fun percentageReducesAbsoluteQuantityWithoutUsingMarginOrLeverage() { + assertDecimal("0.25", perpsReductionQuantity("1", "88.23", "25", true)) + assertDecimal("0.25", perpsReductionQuantity("-1", "88.23", "25", true)) + assertDecimal("0.2", perpsReductionQuantity("0.8", "88.23", "25", true)) + assertDecimal("0.0000000025", perpsReductionQuantity("0.00000001", "1", "25", true)) + assertDecimal("1.00000001", perpsReductionQuantity("1.00000001", "88.23", "100", true)) + assertNull(perpsReductionQuantity("1", "88.23", "101", true)) + assertNull(perpsReductionQuantity("1", "88.23", "25.5", true)) + } + + @Test + fun dollarInputUsesPositionValueAndNeverRoundsAboveTheBudget() { + assertDecimal("0.2", perpsReductionQuantity("1", "100", "20", false)) + val quantity = requireNotNull(perpsReductionQuantity("10", "3", "1", false)) + assertDecimal("0.33333333", quantity) + assertTrue(quantity.multiply(BigDecimal("3")) <= BigDecimal.ONE) + assertNull(perpsReductionQuantity("1", "100", "100.01", false)) + assertNull(perpsReductionQuantity("1", "0", "1", false)) + assertNull(perpsReductionQuantity("1", null, "1", false)) + } + + @Test + fun quantityIsRevalidatedAgainstChangedPositionAndNeverTurnsIntoFullClose() { + assertDecimal("0.5", perpsReduceQuantity("0.5", "1")) + assertNull(perpsReduceQuantity("0.5", "0.4")) + assertNull(perpsReduceQuantity("0.5", "0")) + assertDecimal("0.5", perpsReduceQuantity("0.5", "-0.5")) + for (input in listOf("", ".", "-1", "+1", "0", "1e2", "NaN", "1.2.3", "9".repeat(41))) { + assertNull(perpsReduceQuantity(input, "100")) + assertNull(perpsReductionQuantity("100", "1", input, true)) + assertNull(perpsReductionQuantity("100", "1", input, false)) + } + } + + @Test + fun estimatesOnlyTheReducedShareAndPreservesPnlSign() { + assertDecimal("5", perpsReductionValue("20", "0.25", "1")) + assertDecimal("-5", perpsReductionValue("-20", "0.25", "-1")) + assertDecimal("30", perpsReductionValue("120", "0.25", "1")) + assertDecimal("120", perpsReductionValue("120", "1", "1")) + assertNull(perpsReductionValue("120", "1", "0.5")) + assertNull(perpsReductionValue("120", "1", "0")) + assertNull(perpsReductionValue(null, "1", "1")) + } + + @Test + fun requestSerializesExplicitQuantityAndOmitsItForExistingFullClose() { + val gson = Gson() + val partial = gson.toJsonTree(CloseOrderRequest(positionId = "position", quantity = "0.00000001")).asJsonObject + assertEquals("position", partial["position_id"].asString) + assertEquals("0.00000001", partial["quantity"].asString) + assertFalse(partial.has("price")) + assertEquals(2, partial.size()) + val full = gson.toJsonTree(CloseOrderRequest("position")).asJsonObject + assertFalse(full.has("quantity")) + assertEquals(1, full.size()) + } + + private fun assertDecimal(expected: String, actual: BigDecimal?) { + assertEquals(expected, actual?.stripTrailingZeros()?.toPlainString()) + } +} From 72d4e9b1d6230577882adb749bdb14ffb2c97327 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 8 Oct 2026 14:32:50 +0800 Subject: [PATCH 2/9] fix(perps): complete reduction translations --- app/src/main/res/values-es/strings.xml | 6 ++++++ app/src/main/res/values-in/strings.xml | 6 ++++++ app/src/main/res/values-ja/strings.xml | 6 ++++++ app/src/main/res/values-ko/strings.xml | 6 ++++++ app/src/main/res/values-ms/strings.xml | 6 ++++++ app/src/main/res/values-ru/strings.xml | 6 ++++++ 6 files changed, 36 insertions(+) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 894188b059..6644b46953 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1224,4 +1224,10 @@ ERROR 10655: No se puede ampliar la posición. ERROR 10655: El importe de apertura supera el límite máximo de %1$s. Reduzca el importe o el apalancamiento. Cantidad máxima %1$s %2$s + Confirmar reducción de posición + Orden de cierre enviada + La posición ha cambiado. Vuelve y comprueba la cantidad antes de intentarlo de nuevo. + Introduce un importe mayor que cero que no supere el valor de la posición actual. + PnL estimado + Las estimaciones no incluyen comisiones. El importe final recibido depende de la ejecución y la liquidación. El precio de liquidación mostrado es el actual. diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index df71665616..7ea3f6d3e5 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -782,4 +782,10 @@ KESALAHAN 10655: Tidak dapat menambah posisi. KESALAHAN 10655: Jumlah pembukaan posisi melebihi batas maksimum %1$s. Kurangi jumlah atau leverage. Jumlah maksimum %1$s %2$s + Konfirmasi Pengurangan Posisi + Order penutupan telah diajukan + Posisi telah berubah. Kembali dan periksa jumlahnya sebelum mencoba lagi. + Masukkan nominal lebih besar dari nol yang tidak melebihi nilai posisi saat ini. + Estimasi PnL + Estimasi tidak termasuk biaya. Jumlah akhir yang diterima bergantung pada eksekusi dan penyelesaian. Harga likuidasi yang ditampilkan adalah harga saat ini. diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 8dbec6191b..6f964c2815 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1170,4 +1170,10 @@ エラー 10655:建玉を追加できません。 エラー 10655:新規建玉の金額が上限の%1$sを超えています。金額またはレバレッジを下げてください。 最大金額 %1$s %2$s + ポジション縮小の確認 + 決済注文を送信しました + ポジションが変更されました。前の画面に戻り、数量を確認してから再試行してください。 + 現在のポジション金額以下の、ゼロより大きい金額を入力してください。 + 予想損益 + 概算には手数料が含まれていません。実際の受取額は約定と決済の結果によって決まります。表示されている清算価格は現在の価格です。 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index b066b20c69..cf70fa8cb6 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -2701,4 +2701,10 @@ 증거금 감소 실현 손익 미실현 손익 + 포지션 축소 확인 + 포지션 종료 주문이 제출되었습니다 + 포지션이 변경되었습니다. 이전 화면으로 돌아가 수량을 확인한 후 다시 시도하세요. + 0보다 크고 현재 포지션 규모를 초과하지 않는 금액을 입력하세요. + 예상 손익 + 예상 금액에는 수수료가 포함되지 않습니다. 실제 수령액은 체결 및 정산 결과에 따라 달라집니다. 표시된 청산 가격은 현재 가격입니다. diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 21f7cadfd4..20cf77676d 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -847,4 +847,10 @@ RALAT 10655: Posisi tidak dapat ditambah. RALAT 10655: Amaun pembukaan posisi melebihi had maksimum %1$s. Kurangkan amaun atau leveraj. Jumlah maksimum %1$s %2$s + Sahkan Pengurangan Posisi + Pesanan penutupan dihantar + Posisi telah berubah. Kembali dan semak kuantiti sebelum mencuba lagi. + Masukkan amaun lebih besar daripada sifar yang tidak melebihi nilai posisi semasa. + Anggaran PnL + Anggaran tidak termasuk yuran. Amaun akhir yang diterima bergantung pada pelaksanaan dan penyelesaian. Harga pembubaran yang dipaparkan ialah harga semasa. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 6e5c1b3336..dc30908563 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1165,4 +1165,10 @@ ОШИБКА 10655: Невозможно увеличить позицию. ОШИБКА 10655: Сумма открытия позиции превышает максимальный лимит %1$s. Уменьшите сумму или кредитное плечо. Максимальная сумма %1$s %2$s + Подтвердить уменьшение позиции + Ордер на закрытие отправлен + Позиция изменилась. Вернитесь и проверьте количество перед повторной попыткой. + Введите сумму больше нуля, не превышающую текущую стоимость позиции. + Ожидаемая прибыль/убыток + Оценка не включает комиссии. Итоговая сумма зависит от исполнения ордера и расчётов. Показана текущая цена ликвидации. From 198e4de513abcab8af3214d03bcebffdc46b9573 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 8 Oct 2026 15:53:13 +0800 Subject: [PATCH 3/9] fix(perps): simplify pnl labels and closed order display --- .../android/ui/home/web3/trade/perps/PositionDetailPage.kt | 3 +-- app/src/main/res/values-zh-rCN/strings.xml | 6 +++--- app/src/main/res/values-zh-rTW/strings.xml | 6 +++--- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailPage.kt index f916e85a5d..2b0e829d65 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailPage.kt @@ -438,7 +438,6 @@ fun PositionDetailPage( val quantity = closeOrder.quantity.toBigDecimalOrNull() ?: BigDecimal.ZERO val absQuantity = quantity.abs() val effectiveLeverage = leverage ?: closeOrder.leverage - val roe = (closeOrder.roe.toBigDecimalOrNull() ?: BigDecimal.ZERO).multiply(BigDecimal(100)) val fee = closeOrder.feeAmount.toBigDecimalOrNull()?.abs() ?: BigDecimal.ZERO fun formatFiat(value: BigDecimal): String { @@ -599,7 +598,7 @@ fun PositionDetailPage( PositionDetailItem( label = stringResource(R.string.perps_realized_pnl).uppercase(), - value = "${formatSignedFiat(pnl)} (${formatSignedPercent(roe)})", + value = formatSignedFiat(pnl), valueColor = pnlColor, ) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 67dc7df17a..826ce5e609 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2344,7 +2344,7 @@ 开仓、加仓和平仓均会产生手续费,费用按仓位价值计算,而不是按保证金计算。 仓位越大,手续费越高 仓位越小,手续费越低 - 已实现盈亏已扣除相关手续费 + 净盈亏已扣除相关手续费 强平价格 止盈条件 止损条件 @@ -2648,8 +2648,8 @@ 加仓 增加保证金 减少保证金 - 已实现盈亏 - 未实现盈亏 + 净盈亏 + 盈亏 确认减仓 平仓订单已提交 持仓已变化,请返回核对数量后重试。 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 4d0abbca05..072a58234b 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -2458,7 +2458,7 @@ 開倉、加倉和平倉均會產生手續費,費用按倉位價值計算,而不是按保證金計算。 倉位越大,手續費越高 倉位越小,手續費越低 - 已實現盈虧已扣除相關手續費 + 淨盈虧已扣除相關手續費 強平價格 價格達到觸發價時自動平倉,及時鎖定利潤 價格跌至觸發價時自動平倉,幫助控制虧損 @@ -2700,8 +2700,8 @@ 加倉 增加保證金 減少保證金 - 已實現盈虧 - 未實現盈虧 + 淨盈虧 + 盈虧 確認減倉 平倉訂單已提交 持倉已變化,請返回核對數量後重試。 From ec6c3725e45d58fe2643b731c432dcfa674a55f9 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Fri, 9 Oct 2026 13:33:33 +0800 Subject: [PATCH 4/9] feat(perps): show estimated close fees in previews --- .../11.json | 544 ++++++++++++++++++ .../11.json | 544 ++++++++++++++++++ .../android/api/response/perps/PerpsExt.kt | 1 + .../api/response/perps/PerpsPosition.kt | 3 + .../api/response/perps/PerpsPositionItem.kt | 3 + .../one/mixin/android/db/PerpsDatabase.kt | 13 +- ...erpsBatchCloseBottomSheetDialogFragment.kt | 32 +- .../PerpsCloseBottomSheetDialogFragment.kt | 77 +-- .../web3/trade/perps/PerpsMarginAmount.kt | 20 + .../perps/PerpsPositionShareBottomFragment.kt | 8 +- app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-in/strings.xml | 1 + app/src/main/res/values-ja/strings.xml | 1 + app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values-ms/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values-zh-rCN/strings.xml | 1 + app/src/main/res/values-zh-rTW/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../db/Room3DatabaseCompatibilityTest.kt | 24 +- .../web3/trade/perps/PerpsMarginAmountTest.kt | 24 + 21 files changed, 1217 insertions(+), 85 deletions(-) create mode 100644 app/schemas/googlePlay/one.mixin.android.db.PerpsDatabase/11.json create mode 100644 app/schemas/otherChannel/one.mixin.android.db.PerpsDatabase/11.json diff --git a/app/schemas/googlePlay/one.mixin.android.db.PerpsDatabase/11.json b/app/schemas/googlePlay/one.mixin.android.db.PerpsDatabase/11.json new file mode 100644 index 0000000000..8042da6c35 --- /dev/null +++ b/app/schemas/googlePlay/one.mixin.android.db.PerpsDatabase/11.json @@ -0,0 +1,544 @@ +{ + "formatVersion": 1, + "database": { + "version": 11, + "identityHash": "a5347b1bc8a8c877a494ce1279947eb8", + "entities": [ + { + "tableName": "positions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`position_id` TEXT NOT NULL, `market_id` TEXT NOT NULL, `side` TEXT NOT NULL, `quantity` TEXT NOT NULL, `entry_price` TEXT NOT NULL, `margin` TEXT NOT NULL, `leverage` INTEGER NOT NULL, `state` TEXT NOT NULL, `mark_price` TEXT NOT NULL, `unrealized_pnl` TEXT NOT NULL, `roe` TEXT NOT NULL, `settle_asset_id` TEXT NOT NULL, `open_pay_amount` TEXT NOT NULL, `open_pay_asset_id` TEXT NOT NULL, `take_profit_price` TEXT, `stop_loss_price` TEXT, `liquidation_price` TEXT, `bot_id` TEXT NOT NULL, `wallet_id` TEXT NOT NULL, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, `estimated_close_fee` TEXT DEFAULT '', PRIMARY KEY(`position_id`))", + "fields": [ + { + "fieldPath": "positionId", + "columnName": "position_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "marketId", + "columnName": "market_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "side", + "columnName": "side", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quantity", + "columnName": "quantity", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "entryPrice", + "columnName": "entry_price", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "margin", + "columnName": "margin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "leverage", + "columnName": "leverage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "markPrice", + "columnName": "mark_price", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unrealizedPnl", + "columnName": "unrealized_pnl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "roe", + "columnName": "roe", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "settleAssetId", + "columnName": "settle_asset_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "openPayAmount", + "columnName": "open_pay_amount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "openPayAssetId", + "columnName": "open_pay_asset_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "takeProfitPrice", + "columnName": "take_profit_price", + "affinity": "TEXT" + }, + { + "fieldPath": "stopLossPrice", + "columnName": "stop_loss_price", + "affinity": "TEXT" + }, + { + "fieldPath": "liquidationPrice", + "columnName": "liquidation_price", + "affinity": "TEXT" + }, + { + "fieldPath": "botId", + "columnName": "bot_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "wallet_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "estimatedCloseFee", + "columnName": "estimated_close_fee", + "affinity": "TEXT", + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "position_id" + ] + } + }, + { + "tableName": "perps_orders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`order_id` TEXT NOT NULL, `position_id` TEXT NOT NULL, `market_id` TEXT NOT NULL, `side` TEXT NOT NULL, `order_type` TEXT NOT NULL, `status` TEXT NOT NULL, `leverage` INTEGER NOT NULL, `quantity` TEXT NOT NULL, `pay_amount` TEXT NOT NULL, `fee_amount` TEXT NOT NULL DEFAULT '0', `entry_price` TEXT NOT NULL, `close_price` TEXT NOT NULL, `realized_pnl` TEXT NOT NULL, `roe` TEXT NOT NULL, `net_realized_pnl` TEXT NOT NULL DEFAULT '', `net_roe` TEXT NOT NULL DEFAULT '', `profit_share_amount` TEXT NOT NULL DEFAULT '', `close_reason` TEXT, `trigger_price` TEXT, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, PRIMARY KEY(`order_id`))", + "fields": [ + { + "fieldPath": "orderId", + "columnName": "order_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "positionId", + "columnName": "position_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "marketId", + "columnName": "market_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "side", + "columnName": "side", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderType", + "columnName": "order_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "leverage", + "columnName": "leverage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "quantity", + "columnName": "quantity", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payAmount", + "columnName": "pay_amount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "feeAmount", + "columnName": "fee_amount", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'0'" + }, + { + "fieldPath": "entryPrice", + "columnName": "entry_price", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "closePrice", + "columnName": "close_price", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "realizedPnl", + "columnName": "realized_pnl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "roe", + "columnName": "roe", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "netRealizedPnl", + "columnName": "net_realized_pnl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "netRoe", + "columnName": "net_roe", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "profitShareAmount", + "columnName": "profit_share_amount", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "closeReason", + "columnName": "close_reason", + "affinity": "TEXT" + }, + { + "fieldPath": "triggerPrice", + "columnName": "trigger_price", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "order_id" + ] + } + }, + { + "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, `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", + "columnName": "market_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displaySymbol", + "columnName": "display_symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tokenSymbol", + "columnName": "token_symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quoteSymbol", + "columnName": "quote_symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "markPrice", + "columnName": "mark_price", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "priceScale", + "columnName": "price_scale", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "2" + }, + { + "fieldPath": "leverage", + "columnName": "leverage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fundingRate", + "columnName": "funding_rate", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fundingIntervalHours", + "columnName": "funding_interval_hours", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "nextFundingAt", + "columnName": "next_funding_at", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "openInterest", + "columnName": "open_interest", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'0'" + }, + { + "fieldPath": "minAmount", + "columnName": "min_amount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "maxAmount", + "columnName": "max_amount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "last", + "columnName": "last", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volume", + "columnName": "volume", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tradeVolumeScore1D", + "columnName": "trade_volume_score_1d", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "high", + "columnName": "high", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "low", + "columnName": "low", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "open", + "columnName": "open", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "change", + "columnName": "change", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bidPrice", + "columnName": "bid_price", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "askPrice", + "columnName": "ask_price", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "descriptions", + "columnName": "descriptions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "market_id" + ] + } + }, + { + "tableName": "favorites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`market_id` TEXT NOT NULL, `is_favored` INTEGER NOT NULL, `created_at` TEXT NOT NULL, PRIMARY KEY(`market_id`))", + "fields": [ + { + "fieldPath": "marketId", + "columnName": "market_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isFavored", + "columnName": "is_favored", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "market_id" + ] + } + }, + { + "tableName": "market_categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`market_id` TEXT NOT NULL, `category` INTEGER NOT NULL, PRIMARY KEY(`market_id`, `category`))", + "fields": [ + { + "fieldPath": "marketId", + "columnName": "market_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "market_id", + "category" + ] + }, + "indices": [ + { + "name": "index_market_categories_category", + "unique": false, + "columnNames": [ + "category" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_market_categories_category` ON `${TABLE_NAME}` (`category`)" + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a5347b1bc8a8c877a494ce1279947eb8')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/otherChannel/one.mixin.android.db.PerpsDatabase/11.json b/app/schemas/otherChannel/one.mixin.android.db.PerpsDatabase/11.json new file mode 100644 index 0000000000..8042da6c35 --- /dev/null +++ b/app/schemas/otherChannel/one.mixin.android.db.PerpsDatabase/11.json @@ -0,0 +1,544 @@ +{ + "formatVersion": 1, + "database": { + "version": 11, + "identityHash": "a5347b1bc8a8c877a494ce1279947eb8", + "entities": [ + { + "tableName": "positions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`position_id` TEXT NOT NULL, `market_id` TEXT NOT NULL, `side` TEXT NOT NULL, `quantity` TEXT NOT NULL, `entry_price` TEXT NOT NULL, `margin` TEXT NOT NULL, `leverage` INTEGER NOT NULL, `state` TEXT NOT NULL, `mark_price` TEXT NOT NULL, `unrealized_pnl` TEXT NOT NULL, `roe` TEXT NOT NULL, `settle_asset_id` TEXT NOT NULL, `open_pay_amount` TEXT NOT NULL, `open_pay_asset_id` TEXT NOT NULL, `take_profit_price` TEXT, `stop_loss_price` TEXT, `liquidation_price` TEXT, `bot_id` TEXT NOT NULL, `wallet_id` TEXT NOT NULL, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, `estimated_close_fee` TEXT DEFAULT '', PRIMARY KEY(`position_id`))", + "fields": [ + { + "fieldPath": "positionId", + "columnName": "position_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "marketId", + "columnName": "market_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "side", + "columnName": "side", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quantity", + "columnName": "quantity", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "entryPrice", + "columnName": "entry_price", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "margin", + "columnName": "margin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "leverage", + "columnName": "leverage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "markPrice", + "columnName": "mark_price", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unrealizedPnl", + "columnName": "unrealized_pnl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "roe", + "columnName": "roe", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "settleAssetId", + "columnName": "settle_asset_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "openPayAmount", + "columnName": "open_pay_amount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "openPayAssetId", + "columnName": "open_pay_asset_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "takeProfitPrice", + "columnName": "take_profit_price", + "affinity": "TEXT" + }, + { + "fieldPath": "stopLossPrice", + "columnName": "stop_loss_price", + "affinity": "TEXT" + }, + { + "fieldPath": "liquidationPrice", + "columnName": "liquidation_price", + "affinity": "TEXT" + }, + { + "fieldPath": "botId", + "columnName": "bot_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "wallet_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "estimatedCloseFee", + "columnName": "estimated_close_fee", + "affinity": "TEXT", + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "position_id" + ] + } + }, + { + "tableName": "perps_orders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`order_id` TEXT NOT NULL, `position_id` TEXT NOT NULL, `market_id` TEXT NOT NULL, `side` TEXT NOT NULL, `order_type` TEXT NOT NULL, `status` TEXT NOT NULL, `leverage` INTEGER NOT NULL, `quantity` TEXT NOT NULL, `pay_amount` TEXT NOT NULL, `fee_amount` TEXT NOT NULL DEFAULT '0', `entry_price` TEXT NOT NULL, `close_price` TEXT NOT NULL, `realized_pnl` TEXT NOT NULL, `roe` TEXT NOT NULL, `net_realized_pnl` TEXT NOT NULL DEFAULT '', `net_roe` TEXT NOT NULL DEFAULT '', `profit_share_amount` TEXT NOT NULL DEFAULT '', `close_reason` TEXT, `trigger_price` TEXT, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, PRIMARY KEY(`order_id`))", + "fields": [ + { + "fieldPath": "orderId", + "columnName": "order_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "positionId", + "columnName": "position_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "marketId", + "columnName": "market_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "side", + "columnName": "side", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderType", + "columnName": "order_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "leverage", + "columnName": "leverage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "quantity", + "columnName": "quantity", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payAmount", + "columnName": "pay_amount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "feeAmount", + "columnName": "fee_amount", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'0'" + }, + { + "fieldPath": "entryPrice", + "columnName": "entry_price", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "closePrice", + "columnName": "close_price", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "realizedPnl", + "columnName": "realized_pnl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "roe", + "columnName": "roe", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "netRealizedPnl", + "columnName": "net_realized_pnl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "netRoe", + "columnName": "net_roe", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "profitShareAmount", + "columnName": "profit_share_amount", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "closeReason", + "columnName": "close_reason", + "affinity": "TEXT" + }, + { + "fieldPath": "triggerPrice", + "columnName": "trigger_price", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "order_id" + ] + } + }, + { + "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, `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", + "columnName": "market_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displaySymbol", + "columnName": "display_symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tokenSymbol", + "columnName": "token_symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quoteSymbol", + "columnName": "quote_symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "markPrice", + "columnName": "mark_price", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "priceScale", + "columnName": "price_scale", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "2" + }, + { + "fieldPath": "leverage", + "columnName": "leverage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fundingRate", + "columnName": "funding_rate", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fundingIntervalHours", + "columnName": "funding_interval_hours", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "nextFundingAt", + "columnName": "next_funding_at", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "openInterest", + "columnName": "open_interest", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'0'" + }, + { + "fieldPath": "minAmount", + "columnName": "min_amount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "maxAmount", + "columnName": "max_amount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "last", + "columnName": "last", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volume", + "columnName": "volume", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tradeVolumeScore1D", + "columnName": "trade_volume_score_1d", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "high", + "columnName": "high", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "low", + "columnName": "low", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "open", + "columnName": "open", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "change", + "columnName": "change", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bidPrice", + "columnName": "bid_price", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "askPrice", + "columnName": "ask_price", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "descriptions", + "columnName": "descriptions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "market_id" + ] + } + }, + { + "tableName": "favorites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`market_id` TEXT NOT NULL, `is_favored` INTEGER NOT NULL, `created_at` TEXT NOT NULL, PRIMARY KEY(`market_id`))", + "fields": [ + { + "fieldPath": "marketId", + "columnName": "market_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isFavored", + "columnName": "is_favored", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "market_id" + ] + } + }, + { + "tableName": "market_categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`market_id` TEXT NOT NULL, `category` INTEGER NOT NULL, PRIMARY KEY(`market_id`, `category`))", + "fields": [ + { + "fieldPath": "marketId", + "columnName": "market_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "market_id", + "category" + ] + }, + "indices": [ + { + "name": "index_market_categories_category", + "unique": false, + "columnNames": [ + "category" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_market_categories_category` ON `${TABLE_NAME}` (`category`)" + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a5347b1bc8a8c877a494ce1279947eb8')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/one/mixin/android/api/response/perps/PerpsExt.kt b/app/src/main/java/one/mixin/android/api/response/perps/PerpsExt.kt index 08a26a011f..2969b760df 100644 --- a/app/src/main/java/one/mixin/android/api/response/perps/PerpsExt.kt +++ b/app/src/main/java/one/mixin/android/api/response/perps/PerpsExt.kt @@ -27,6 +27,7 @@ fun PerpsPositionItem.toPosition(): PerpsPosition { state = state ?: "open", markPrice = markPrice ?: "0", unrealizedPnl = unrealizedPnl ?: "0", + estimatedCloseFee = estimatedCloseFee, roe = roe ?: "0", walletId = walletId ?: "", createdAt = createdAt ?: "", diff --git a/app/src/main/java/one/mixin/android/api/response/perps/PerpsPosition.kt b/app/src/main/java/one/mixin/android/api/response/perps/PerpsPosition.kt index bb37914db4..695a1667e8 100644 --- a/app/src/main/java/one/mixin/android/api/response/perps/PerpsPosition.kt +++ b/app/src/main/java/one/mixin/android/api/response/perps/PerpsPosition.kt @@ -74,6 +74,9 @@ data class PerpsPosition( @SerializedName("updated_at") @ColumnInfo(name = "updated_at") val updatedAt: String, + @SerializedName("estimated_close_fee") + @ColumnInfo(name = "estimated_close_fee", defaultValue = "''") + val estimatedCloseFee: String? = "", ) : Parcelable { companion object { const val STATE_OPEN = "open" diff --git a/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionItem.kt b/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionItem.kt index 9c437786ea..74fe3d76b8 100644 --- a/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionItem.kt +++ b/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionItem.kt @@ -84,4 +84,7 @@ data class PerpsPositionItem( @ColumnInfo(name = "price_scale") @SerializedName("priceScale") val priceScale: Int = 2, + @SerializedName("estimated_close_fee") + @ColumnInfo(name = "estimated_close_fee", defaultValue = "''") + val estimatedCloseFee: String? = "", ) : Parcelable 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 f56e6c4c83..7a873327a4 100644 --- a/app/src/main/java/one/mixin/android/db/PerpsDatabase.kt +++ b/app/src/main/java/one/mixin/android/db/PerpsDatabase.kt @@ -35,7 +35,7 @@ import kotlin.math.min PerpsFavorite::class, PerpsMarketCategoryRelation::class, ], - version = 10, + version = 11, ) @DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class) abstract class PerpsDatabase : RoomDatabase() { @@ -133,6 +133,13 @@ abstract class PerpsDatabase : RoomDatabase() { } } + val MIGRATION_10_11 = + object : Migration(10, 11) { + override suspend fun migrate(connection: SQLiteConnection) { + connection.execSQL("ALTER TABLE `positions` ADD COLUMN `estimated_close_fee` TEXT DEFAULT ''") + } + } + @Suppress("DEPRECATION") fun getDatabase( context: Context, @@ -151,7 +158,7 @@ abstract class PerpsDatabase : RoomDatabase() { context, PerpsDatabase::class.java, File(dir, Constants.DataBase.PERPS_DB_NAME).absolutePath, - ).setDriver(ReportingAndroidSQLiteDriver("Perps", 10)) + ).setDriver(ReportingAndroidSQLiteDriver("Perps", 11)) .addCallback( object : Callback() { override suspend fun onOpen(connection: SQLiteConnection) { @@ -159,7 +166,7 @@ abstract class PerpsDatabase : RoomDatabase() { connection.execSQL("PRAGMA synchronous = NORMAL") } }, - ).addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10) + ).addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11) .fallbackToDestructiveMigration() .enableMultiInstanceInvalidation() .setQueryCoroutineContext( diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsBatchCloseBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsBatchCloseBottomSheetDialogFragment.kt index f93fe11d33..95f43e9fc4 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsBatchCloseBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsBatchCloseBottomSheetDialogFragment.kt @@ -149,10 +149,8 @@ class PerpsBatchCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFr } val displayPositions = if (step == Step.Done) positions else remainingPositions - val totalMargin = displayPositions.sumOf { it.margin.toBigDecimalOrZero() } - val totalPnl = displayPositions.sumOf { it.unrealizedPnl.toBigDecimalOrZero() } - val estimatedReceive = (totalMargin + totalPnl).max(BigDecimal.ZERO) - val pnlPercent = calculatePnlPercent(totalPnl, totalMargin) + val estimatedReceive = estimatedPerpsBatchCloseReturn(displayPositions) + val estimatedFee = estimatedPerpsBatchCloseFee(displayPositions) val settleAssetSymbol = settleAsset?.symbol ?: "USDT" MixinAppTheme { @@ -223,8 +221,7 @@ class PerpsBatchCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFr asset = settleAsset, assetSymbol = settleAssetSymbol, estimatedReceive = estimatedReceive, - totalPnl = totalPnl, - pnlPercent = pnlPercent, + estimatedFee = estimatedFee, ) Spacer(modifier = Modifier.height(20.dp)) } @@ -533,9 +530,8 @@ private fun BatchClosePositionItem(position: PerpsPositionItem) { private fun BatchCloseSummary( asset: TokenItem?, assetSymbol: String, - estimatedReceive: BigDecimal, - totalPnl: BigDecimal, - pnlPercent: BigDecimal, + estimatedReceive: BigDecimal?, + estimatedFee: BigDecimal?, ) { Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp)) { Text( @@ -557,7 +553,7 @@ private fun BatchCloseSummary( Spacer(modifier = Modifier.width(6.dp)) } Text( - text = "+${formatBatchAmount(estimatedReceive)} $assetSymbol", + text = "${estimatedReceive?.let { "+${formatBatchAmount(it)}" } ?: "--"} $assetSymbol", color = MixinAppTheme.colors.textPrimary, fontSize = 16.sp, fontWeight = FontWeight.W500, @@ -571,24 +567,12 @@ private fun BatchCloseSummary( } Spacer(modifier = Modifier.height(4.dp)) Text( - text = "${stringResource(R.string.PnL)}: ${formatSignedBatchAmount(totalPnl)} $assetSymbol (${formatPerpsSignedPercent(pnlPercent)})", - color = if (totalPnl < BigDecimal.ZERO) MixinAppTheme.colors.walletRed else MixinAppTheme.colors.walletGreen, + text = "${stringResource(R.string.Estimated_Fee)}: ${estimatedFee?.let(::formatBatchAmount) ?: "--"} $assetSymbol", + color = MixinAppTheme.colors.textAssist, fontSize = 14.sp, ) } } -private fun String?.toBigDecimalOrZero(): BigDecimal = this?.toBigDecimalOrNull() ?: BigDecimal.ZERO - -private fun calculatePnlPercent(pnl: BigDecimal, margin: BigDecimal): BigDecimal { - if (margin <= BigDecimal.ZERO) return BigDecimal.ZERO - return pnl - .divide(margin, 8, RoundingMode.HALF_UP) - .multiply(BigDecimal(100)) -} - private fun formatBatchAmount(value: BigDecimal): String = value.setScale(8, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString() - -private fun formatSignedBatchAmount(value: BigDecimal): String = - if (value > BigDecimal.ZERO) "+${formatBatchAmount(value)}" else formatBatchAmount(value) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt index d2c52eab14..32cff4e3fd 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt @@ -30,14 +30,13 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle @@ -49,14 +48,12 @@ import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch -import one.mixin.android.Constants import one.mixin.android.R import one.mixin.android.api.response.perps.PerpsPosition import one.mixin.android.compose.CoilImage import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.extension.booleanFromAttribute import one.mixin.android.extension.composeDp -import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.extension.getSafeAreaInsetsTop import one.mixin.android.extension.isNightMode import one.mixin.android.extension.screenHeight @@ -76,7 +73,6 @@ import one.mixin.android.vo.safe.TokenItem import one.mixin.android.widget.components.MixinButton import timber.log.Timber import java.math.BigDecimal -import java.math.RoundingMode @AndroidEntryPoint class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragment() { @@ -97,6 +93,7 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen private const val ARGS_ENTRY_PRICE = "args_entry_price" private const val ARGS_MARK_PRICE = "args_mark_price" private const val ARGS_UNREALIZED_PNL = "args_unrealized_pnl" + private const val ARGS_ESTIMATED_CLOSE_FEE = "args_estimated_close_fee" private const val ARGS_ROE = "args_roe" private const val ARGS_WALLET_NAME = "args_wallet_name" @@ -117,6 +114,7 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen putString(ARGS_MARK_PRICE, position.markPrice) putString(ARGS_UNREALIZED_PNL, position.unrealizedPnl) putString(ARGS_ROE, position.roe) + putString(ARGS_ESTIMATED_CLOSE_FEE, position.estimatedCloseFee) } } } @@ -191,6 +189,7 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen private var latestQuantity by mutableStateOf("") private var latestMargin by mutableStateOf("") private var latestLiquidationPrice by mutableStateOf(null) + private var estimatedCloseFee by mutableStateOf("") private var marketIconUrl by mutableStateOf("") private var marketSymbol by mutableStateOf("") private var settleAssetSymbol by mutableStateOf("USDT") @@ -199,15 +198,13 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen @Composable override fun ComposeContent() { - val context = LocalContext.current - val quoteColorReversed = context.defaultSharedPreferences - .getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) LaunchedEffect(Unit) { latestMarkPrice = markPrice latestUnrealizedPnl = unrealizedPnl latestQuantity = arguments?.getString(ARGS_QUANTITY).orEmpty() latestMargin = margin + estimatedCloseFee = arguments?.getString(ARGS_ESTIMATED_CLOSE_FEE).orEmpty() } LaunchedEffect(positionId) { @@ -218,6 +215,7 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen latestQuantity = position.quantity latestMargin = position.margin ?: latestMargin latestLiquidationPrice = position.liquidationPrice + estimatedCloseFee = position.estimatedCloseFee marketIconUrl = position.iconUrl.orEmpty() marketSymbol = position.displaySymbol ?: position.tokenSymbol.orEmpty() refreshAssetAndSender( @@ -239,6 +237,7 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen latestQuantity = position.quantity latestMargin = position.margin latestLiquidationPrice = position.liquidationPrice + estimatedCloseFee = position.estimatedCloseFee lifecycleScope.launch { viewModel.getMarketFromDb(position.marketId)?.let { market -> @@ -381,40 +380,19 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen ) Box(modifier = Modifier.height(20.dp)) - val pnl = try { - if (isReducePosition) perpsReductionValue(latestUnrealizedPnl, reduceQuantity.orEmpty(), latestQuantity) ?: BigDecimal.ZERO - else BigDecimal(latestUnrealizedPnl) - } catch (e: Exception) { - BigDecimal.ZERO - } - val risingColor = if (quoteColorReversed) MixinAppTheme.colors.walletRed else MixinAppTheme.colors.walletGreen - val fallingColor = if (quoteColorReversed) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed - val pnlColor = if (pnl >= BigDecimal.ZERO) { - risingColor + val closeReturn = estimatedPerpsCloseReturn(latestMargin, latestUnrealizedPnl, estimatedCloseFee) + val estimatedReceive = if (isReduceMargin) { + reduceMarginAmount?.toBigDecimalOrNull() ?: BigDecimal.ZERO + } else if (isReducePosition) { + perpsReductionValue(closeReturn?.toPlainString(), reduceQuantity.orEmpty(), latestQuantity) } else { - fallingColor - } - - val estimatedReceive = if (isReduceMargin) reduceMarginAmount?.toBigDecimalOrNull() ?: BigDecimal.ZERO else try { - val margin = BigDecimal(latestMargin) - val unrealizedPnl = BigDecimal(latestUnrealizedPnl) - val total = margin + unrealizedPnl - (if (isReducePosition) perpsReductionValue(total.toPlainString(), reduceQuantity.orEmpty(), latestQuantity) ?: BigDecimal.ZERO else total).max(BigDecimal.ZERO) - } catch (e: Exception) { - BigDecimal.ZERO + closeReturn } - val pnlPercent = try { - val marginValue = BigDecimal(latestMargin) - if (marginValue <= BigDecimal.ZERO) { - BigDecimal.ZERO - } else { - BigDecimal(latestUnrealizedPnl) - .divide(marginValue, 8, RoundingMode.HALF_UP) - .multiply(BigDecimal(100)) - } - } catch (e: Exception) { - BigDecimal.ZERO + val displayCloseFee = if (isReducePosition) { + perpsReductionValue(estimatedCloseFee, reduceQuantity.orEmpty(), latestQuantity) + } else { + estimatedCloseFee?.toBigDecimalOrNull() } Column( @@ -468,7 +446,9 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen ) Spacer(modifier = Modifier.width(6.dp)) Text( - text = if (isReduceMargin) { + text = if (estimatedReceive == null) { + "-- ${asset.symbol}" + } else if (isReduceMargin) { "+${formatPerpsQuantity(estimatedReceive)} ${asset.symbol}" } else "${String.format("%.8f", estimatedReceive)} ${asset.symbol}", color = MixinAppTheme.colors.textPrimary, @@ -486,18 +466,11 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen if (!isReduceMargin) { Spacer(modifier = Modifier.height(4.dp)) - Row { - Text( - text = "${stringResource(R.string.PnL)}: ", - color = MixinAppTheme.colors.textAssist, - fontSize = 14.sp - ) - Text( - text = "${formatPerpsSignedRawUsdDecimal(pnl)} (${formatPerpsSignedPercent(pnlPercent, withSign = false)})", - color = pnlColor, - fontSize = 14.sp - ) - } + Text( + text = "${stringResource(R.string.Estimated_Fee)}: ${displayCloseFee?.let(::formatPerpsQuantity) ?: "--"} $settleAssetSymbol", + color = MixinAppTheme.colors.textAssist, + fontSize = 14.sp, + ) } if (isReducePosition) { Spacer(Modifier.height(16.dp)) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmount.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmount.kt index 716466117d..ebac63dddb 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmount.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmount.kt @@ -1,6 +1,7 @@ package one.mixin.android.ui.home.web3.trade.perps import com.google.gson.JsonElement +import one.mixin.android.api.response.perps.PerpsPositionItem import one.mixin.android.extension.numberFormat8 import one.mixin.android.ui.home.web3.trade.TRADE_INPUT_MAX_DECIMAL_PLACES import one.mixin.android.vo.safe.TokenItem @@ -83,3 +84,22 @@ internal fun marginReductionPercentage(currentMargin: String?, amount: BigDecima val margin = currentMargin?.toBigDecimalOrNull()?.takeIf { it > BigDecimal.ZERO } ?: return null return amount?.multiply(BigDecimal(100))?.divide(margin, TRADE_INPUT_MAX_DECIMAL_PLACES, RoundingMode.DOWN) } + +internal fun estimatedPerpsCloseReturn(margin: String?, unrealizedPnl: String?, estimatedCloseFee: String?): BigDecimal? { + val marginValue = margin?.toBigDecimalOrNull() ?: return null + val pnl = unrealizedPnl?.toBigDecimalOrNull() ?: return null + val fee = estimatedCloseFee?.toBigDecimalOrNull() ?: return null + return (marginValue + pnl - fee).max(BigDecimal.ZERO) +} + +internal fun estimatedPerpsBatchCloseReturn(positions: List): BigDecimal? { + return positions.fold(BigDecimal.ZERO) { total, position -> + total + (estimatedPerpsCloseReturn(position.margin, position.unrealizedPnl, position.estimatedCloseFee) ?: return null) + } +} + +internal fun estimatedPerpsBatchCloseFee(positions: List): BigDecimal? { + return positions.fold(BigDecimal.ZERO) { total, position -> + total + (position.estimatedCloseFee?.toBigDecimalOrNull() ?: return null) + } +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsPositionShareBottomFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsPositionShareBottomFragment.kt index 1670403ca8..5f91413c50 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsPositionShareBottomFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsPositionShareBottomFragment.kt @@ -81,7 +81,6 @@ class PerpsPositionShareBottomFragment : MixinComposeBottomSheetDialogFragment() private const val ARGS_CLOSE_LEVERAGE = "args_close_leverage" private const val SHARE_QR_URL = "https://mixin.one/mm" private const val SHARE_CARD_COVER_URL = "https://dl.mixinpay.com/perps-share-card.png" - private val MIN_DISPLAY_PNL_PERCENT = BigDecimal("-100") fun newInstance(position: PerpsPositionItem) = PerpsPositionShareBottomFragment().withArgs { putParcelable(ARGS_POSITION, position) @@ -534,13 +533,12 @@ class PerpsPositionShareBottomFragment : MixinComposeBottomSheetDialogFragment() } private fun formatSignedPercent(value: BigDecimal): String { - val displayValue = value.max(MIN_DISPLAY_PNL_PERCENT) val sign = when { - displayValue > BigDecimal.ZERO -> "+" - displayValue < BigDecimal.ZERO -> "-" + value > BigDecimal.ZERO -> "+" + value < BigDecimal.ZERO -> "-" else -> "" } - val scaled = displayValue.abs().setScale(2, RoundingMode.FLOOR) + val scaled = value.abs().setScale(2, RoundingMode.FLOOR) val number = if (scaled.compareTo(BigDecimal.ZERO) == 0) "0.0" else scaled.stripTrailingZeros().toPlainString() return "$sign$number%" } diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 6644b46953..8de088b875 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1230,4 +1230,5 @@ Introduce un importe mayor que cero que no supere el valor de la posición actual. PnL estimado Las estimaciones no incluyen comisiones. El importe final recibido depende de la ejecución y la liquidación. El precio de liquidación mostrado es el actual. + Comisión estimada diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 7ea3f6d3e5..46af1e20b3 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -788,4 +788,5 @@ Masukkan nominal lebih besar dari nol yang tidak melebihi nilai posisi saat ini. Estimasi PnL Estimasi tidak termasuk biaya. Jumlah akhir yang diterima bergantung pada eksekusi dan penyelesaian. Harga likuidasi yang ditampilkan adalah harga saat ini. + Estimasi Biaya diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 6f964c2815..cf8e676f2f 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1176,4 +1176,5 @@ 現在のポジション金額以下の、ゼロより大きい金額を入力してください。 予想損益 概算には手数料が含まれていません。実際の受取額は約定と決済の結果によって決まります。表示されている清算価格は現在の価格です。 + 推定手数料 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index cf70fa8cb6..6d7281da1b 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -2707,4 +2707,5 @@ 0보다 크고 현재 포지션 규모를 초과하지 않는 금액을 입력하세요. 예상 손익 예상 금액에는 수수료가 포함되지 않습니다. 실제 수령액은 체결 및 정산 결과에 따라 달라집니다. 표시된 청산 가격은 현재 가격입니다. + 예상 수수료 diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 20cf77676d..7bd2fcbaf5 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -853,4 +853,5 @@ Masukkan amaun lebih besar daripada sifar yang tidak melebihi nilai posisi semasa. Anggaran PnL Anggaran tidak termasuk yuran. Amaun akhir yang diterima bergantung pada pelaksanaan dan penyelesaian. Harga pembubaran yang dipaparkan ialah harga semasa. + Anggaran Yuran diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index dc30908563..ece76931d2 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1171,4 +1171,5 @@ Введите сумму больше нуля, не превышающую текущую стоимость позиции. Ожидаемая прибыль/убыток Оценка не включает комиссии. Итоговая сумма зависит от исполнения ордера и расчётов. Показана текущая цена ликвидации. + Расчётная комиссия diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 826ce5e609..7cf1851daa 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2656,4 +2656,5 @@ 请输入大于零且不超过当前仓位的金额。 预计盈亏 预估不含手续费,实际到账以成交和结算为准。此处显示当前强平价格。 + 预估手续费 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 072a58234b..ee026d7edc 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -2708,4 +2708,5 @@ 請輸入大於零且不超過當前倉位的金額。 預計盈虧 預估不含手續費,實際到帳以成交和結算為準。此處顯示當前強平價格。 + 預估手續費 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 453188e4aa..3b44e6955a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2738,4 +2738,5 @@ Enter a positive amount within the current position size. Estimated PnL Estimates exclude fees. Final proceeds depend on execution and settlement. The liquidation price shown is the current price. + Estimated Fee diff --git a/app/src/test/java/one/mixin/android/db/Room3DatabaseCompatibilityTest.kt b/app/src/test/java/one/mixin/android/db/Room3DatabaseCompatibilityTest.kt index 5bbee80b9b..497181bc3d 100644 --- a/app/src/test/java/one/mixin/android/db/Room3DatabaseCompatibilityTest.kt +++ b/app/src/test/java/one/mixin/android/db/Room3DatabaseCompatibilityTest.kt @@ -74,7 +74,29 @@ class Room3DatabaseCompatibilityTest { "SELECT realized_pnl || ':' || roe || ':' || COALESCE(net_realized_pnl, 'missing') || ':' || COALESCE(net_roe, 'missing') || ':' || COALESCE(profit_share_amount, 'missing') FROM perps_orders", "10:0.1:::", schemaVersion = 9, - migrations = arrayOf(PerpsDatabase.MIGRATION_9_10), + migrations = arrayOf(PerpsDatabase.MIGRATION_9_10, PerpsDatabase.MIGRATION_10_11), + ) + } + + @Test + fun perpsCloseFeeMigrationPreservesPositionsWithUnknownFee() { + verifyLegacyDatabase( + PerpsDatabase::class.java, + """ + INSERT INTO positions ( + position_id, market_id, side, quantity, entry_price, margin, leverage, state, + mark_price, unrealized_pnl, roe, settle_asset_id, open_pay_amount, open_pay_asset_id, + bot_id, wallet_id, created_at, updated_at + ) VALUES ( + 'position', 'market', 'long', '1', '100', '10', 10, 'open', + '102', '2', '0.2', 'asset', '10', 'asset', 'bot', 'wallet', + '2026-10-09T00:00:00Z', '2026-10-09T00:00:00Z' + ) + """.trimIndent(), + "SELECT margin || ':' || unrealized_pnl || ':' || estimated_close_fee FROM positions", + "10:2:", + schemaVersion = 10, + migrations = arrayOf(PerpsDatabase.MIGRATION_10_11), ) } diff --git a/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmountTest.kt b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmountTest.kt index de7654497e..d7e1a977d8 100644 --- a/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmountTest.kt +++ b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmountTest.kt @@ -1,6 +1,7 @@ package one.mixin.android.ui.home.web3.trade.perps import com.google.gson.JsonParser +import one.mixin.android.api.response.perps.PerpsPositionItem import java.math.BigDecimal import kotlin.test.Test import kotlin.test.assertEquals @@ -9,6 +10,29 @@ import kotlin.test.assertNull import kotlin.test.assertTrue class PerpsMarginAmountTest { + @Test + fun batchCloseSumsEachReturnAfterFeesAndKeepsUnknownTotalsUnknown() { + val first = PerpsPositionItem("p1", "market", "long", "1", "100", 10, margin = "10", unrealizedPnl = "2", estimatedCloseFee = "0.5") + val second = first.copy(positionId = "p2", unrealizedPnl = "-12", estimatedCloseFee = "1") + assertEquals(BigDecimal("11.5"), estimatedPerpsBatchCloseReturn(listOf(first, second))) + assertEquals(BigDecimal("1.5"), estimatedPerpsBatchCloseFee(listOf(first, second))) + val unknown = second.copy(estimatedCloseFee = "") + assertNull(estimatedPerpsBatchCloseReturn(listOf(first, unknown))) + assertNull(estimatedPerpsBatchCloseFee(listOf(first, unknown))) + assertEquals(BigDecimal("5.75"), perpsReductionValue(estimatedPerpsCloseReturn("10", "2", "0.5")?.toPlainString(), "0.5", "1")?.stripTrailingZeros()) + assertEquals(BigDecimal("0.25"), perpsReductionValue("0.5", "0.5", "1")?.stripTrailingZeros()) + } + + @Test + fun closeReturnDeductsEstimatedFeeBeforeProfitSharing() { + assertEquals(BigDecimal("11.5"), estimatedPerpsCloseReturn("10", "2", "0.5")) + assertEquals(BigDecimal.ZERO, estimatedPerpsCloseReturn("10", "-12", "0.5")) + assertEquals(BigDecimal("12"), estimatedPerpsCloseReturn("10", "2", "0")) + for (fee in listOf(null, "", "invalid")) { + assertNull(estimatedPerpsCloseReturn("10", "2", fee)) + } + } + @Test fun fractionalPercentageLimitCanBeFilledAsDollars() { val maximum = BigDecimal("0.056") From be9c162a6dcff1094fd752b784f500edf602c053 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Fri, 9 Oct 2026 13:53:25 +0800 Subject: [PATCH 5/9] fix(perps): refine estimated fee preview layout --- ...erpsBatchCloseBottomSheetDialogFragment.kt | 14 +++--- .../PerpsCloseBottomSheetDialogFragment.kt | 25 +++------- .../web3/trade/perps/PerpsEstimatedFee.kt | 50 +++++++++++++++++++ 3 files changed, 64 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsEstimatedFee.kt diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsBatchCloseBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsBatchCloseBottomSheetDialogFragment.kt index 95f43e9fc4..135f729bc8 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsBatchCloseBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsBatchCloseBottomSheetDialogFragment.kt @@ -222,6 +222,11 @@ class PerpsBatchCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFr assetSymbol = settleAssetSymbol, estimatedReceive = estimatedReceive, estimatedFee = estimatedFee, + onFeeTipClick = { + PerpetualGuideBottomSheetDialogFragment.newInstance( + PerpetualGuideBottomSheetDialogFragment.TAB_TRADING_FEE, + ).show(parentFragmentManager, PerpetualGuideBottomSheetDialogFragment.TAG) + }, ) Spacer(modifier = Modifier.height(20.dp)) } @@ -532,6 +537,7 @@ private fun BatchCloseSummary( assetSymbol: String, estimatedReceive: BigDecimal?, estimatedFee: BigDecimal?, + onFeeTipClick: () -> Unit, ) { Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp)) { Text( @@ -565,12 +571,8 @@ private fun BatchCloseSummary( fontSize = 14.sp, ) } - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "${stringResource(R.string.Estimated_Fee)}: ${estimatedFee?.let(::formatBatchAmount) ?: "--"} $assetSymbol", - color = MixinAppTheme.colors.textAssist, - fontSize = 14.sp, - ) + Spacer(modifier = Modifier.height(20.dp)) + PerpsEstimatedFee(estimatedFee, onFeeTipClick) } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt index 32cff4e3fd..460962b400 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt @@ -188,7 +188,6 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen private var latestUnrealizedPnl by mutableStateOf("") private var latestQuantity by mutableStateOf("") private var latestMargin by mutableStateOf("") - private var latestLiquidationPrice by mutableStateOf(null) private var estimatedCloseFee by mutableStateOf("") private var marketIconUrl by mutableStateOf("") private var marketSymbol by mutableStateOf("") @@ -214,7 +213,6 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen latestUnrealizedPnl = position.unrealizedPnl ?: latestUnrealizedPnl latestQuantity = position.quantity latestMargin = position.margin ?: latestMargin - latestLiquidationPrice = position.liquidationPrice estimatedCloseFee = position.estimatedCloseFee marketIconUrl = position.iconUrl.orEmpty() marketSymbol = position.displaySymbol ?: position.tokenSymbol.orEmpty() @@ -236,7 +234,6 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen latestUnrealizedPnl = position.unrealizedPnl latestQuantity = position.quantity latestMargin = position.margin - latestLiquidationPrice = position.liquidationPrice estimatedCloseFee = position.estimatedCloseFee lifecycleScope.launch { @@ -424,10 +421,6 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen ) } Box(modifier = Modifier.height(20.dp)) - if (isReducePosition) { - PerpsAddInfoRow(stringResource(R.string.Quantity), reduceQuantity.orEmpty()) - Spacer(Modifier.height(16.dp)) - } settleAssetItem?.let { asset -> Text( text = stringResource(R.string.Estimated_Receive).uppercase(), @@ -465,18 +458,12 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen } if (!isReduceMargin) { - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "${stringResource(R.string.Estimated_Fee)}: ${displayCloseFee?.let(::formatPerpsQuantity) ?: "--"} $settleAssetSymbol", - color = MixinAppTheme.colors.textAssist, - fontSize = 14.sp, - ) - } - if (isReducePosition) { - Spacer(Modifier.height(16.dp)) - PerpsAddInfoRow(stringResource(R.string.Liquidation_Price), latestLiquidationPrice ?: "-") - Spacer(Modifier.height(8.dp)) - Text(stringResource(R.string.perps_reduction_estimate_note), color = MixinAppTheme.colors.textAssist, fontSize = 12.sp) + Spacer(modifier = Modifier.height(20.dp)) + PerpsEstimatedFee(displayCloseFee) { + PerpetualGuideBottomSheetDialogFragment.newInstance( + PerpetualGuideBottomSheetDialogFragment.TAB_TRADING_FEE, + ).show(parentFragmentManager, PerpetualGuideBottomSheetDialogFragment.TAG) + } } } Box(modifier = Modifier.height(20.dp)) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsEstimatedFee.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsEstimatedFee.kt new file mode 100644 index 0000000000..e83dbfa310 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsEstimatedFee.kt @@ -0,0 +1,50 @@ +package one.mixin.android.ui.home.web3.trade.perps + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import one.mixin.android.R +import one.mixin.android.compose.theme.MixinAppTheme +import java.math.BigDecimal + +@Composable +internal fun PerpsEstimatedFee(fee: BigDecimal?, onTipClick: () -> Unit) { + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.Estimated_Fee).uppercase(), + color = MixinAppTheme.colors.textRemarks, + fontSize = 14.sp, + ) + Spacer(Modifier.width(6.dp)) + Icon( + painter = painterResource(R.drawable.ic_tip), + contentDescription = stringResource(R.string.Estimated_Fee), + tint = MixinAppTheme.colors.textAssist, + modifier = Modifier.size(16.dp).clickable(role = Role.Button, onClick = onTipClick), + ) + } + Spacer(Modifier.height(8.dp)) + Text( + text = fee?.let(::formatPerpsRawUsdDecimal) ?: "--", + color = MixinAppTheme.colors.textPrimary, + fontSize = 18.sp, + ) + } +} From 9745e78899b4f8c818513a8495808983ac3d04ad Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Fri, 9 Oct 2026 14:27:44 +0800 Subject: [PATCH 6/9] fix(perps): display fees without signs --- .../android/ui/home/web3/trade/perps/PositionDetailPage.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailPage.kt index 25f3d6bd9c..977d410e0f 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailPage.kt @@ -606,7 +606,7 @@ fun PositionDetailPage( PositionDetailItem( label = stringResource(R.string.Fee).uppercase(), - value = formatPerpsSignedExactUsdDecimal(fee.negate()), + value = formatPerpsExactUsdDecimal(fee), onTipClick = onFeeTipClick, ) } @@ -869,7 +869,7 @@ fun OpenedOrderDetailPage( PositionDetailItem( label = stringResource(R.string.Fee).uppercase(), - value = formatPerpsSignedExactUsdDecimal(fee.negate()), + value = formatPerpsExactUsdDecimal(fee), onTipClick = onFeeTipClick, ) } From 6ef8fcb86498b2e3eba15a5485d22fbc927fb22a Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Fri, 9 Oct 2026 17:18:35 +0800 Subject: [PATCH 7/9] feat(perps): remember market detail price selection --- .../web3/trade/perps/PerpsMarketDetailPage.kt | 87 +++++++++++++++---- app/src/main/res/values-zh-rCN/strings.xml | 1 + app/src/main/res/values-zh-rTW/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 4 files changed, 75 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt index 906f404de1..6248f3729a 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt @@ -1,5 +1,6 @@ package one.mixin.android.ui.home.web3.trade.perps +import android.content.SharedPreferences import android.text.Layout import android.util.TypedValue import androidx.compose.foundation.Image @@ -25,6 +26,8 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.Text @@ -75,6 +78,7 @@ import one.mixin.android.api.response.perps.toPosition import one.mixin.android.compose.CoilImage import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.extension.defaultSharedPreferences +import one.mixin.android.extension.putBoolean import one.mixin.android.extension.putInt import one.mixin.android.extension.numberFormatCompact import one.mixin.android.extension.openUrl @@ -98,6 +102,7 @@ import java.util.Locale private const val CLOSED_POSITION_PREVIEW_LIMIT = 100 private const val MARKET_REFRESH_INTERVAL_MS = 30_000L +private const val PREF_MARKET_DETAIL_MARK_PRICE = "perps_market_detail_mark_price" private const val PREF_MARKET_DETAIL_TIME_FRAME = "perps_market_detail_time_frame" @Composable @@ -363,8 +368,6 @@ fun PerpsMarketDetailPage( MarketDetailCard( market = market!!, marketId = marketId, - displaySymbol = displaySymbol, - tokenSymbol = tokenSymbol, selectedTimeFrame = selectedTimeFrame, timeFrameValues = timeFrameValues, timeFrameLabels = timeFrameLabels, @@ -995,8 +998,6 @@ private fun formatFundingRate(fundingRate: String): String { private fun MarketDetailCard( market: PerpsMarket, marketId: String, - displaySymbol: String, - tokenSymbol: String, selectedTimeFrame: Int, timeFrameValues: List, timeFrameLabels: List, @@ -1012,12 +1013,22 @@ private fun MarketDetailCard( val isPositive = changePercent >= BigDecimal.ZERO val changeColor = if (isPositive) risingColor else fallingColor val changeText = formatPerpsSignedPercent(changePercent) - val displayTokenSymbol = tokenSymbol - .takeIf { it.isNotBlank() } - ?: market.tokenSymbol.takeIf { it.isNotBlank() } - ?: displaySymbol - - val displayPrice = market.last + val preferences = remember(context) { context.defaultSharedPreferences } + var showMarkPrice by remember(preferences) { + mutableStateOf(preferences.getBoolean(PREF_MARKET_DETAIL_MARK_PRICE, false)) + } + var priceMenuExpanded by remember { mutableStateOf(false) } + DisposableEffect(preferences) { + val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key == PREF_MARKET_DETAIL_MARK_PRICE) { + showMarkPrice = preferences.getBoolean(PREF_MARKET_DETAIL_MARK_PRICE, false) + } + } + preferences.registerOnSharedPreferenceChangeListener(listener) + showMarkPrice = preferences.getBoolean(PREF_MARKET_DETAIL_MARK_PRICE, false) + onDispose { preferences.unregisterOnSharedPreferenceChangeListener(listener) } + } + val displayPrice = if (showMarkPrice) market.markPrice else market.last Column(modifier = Modifier.fillMaxWidth()) { Row( @@ -1026,11 +1037,57 @@ private fun MarketDetailCard( verticalAlignment = Alignment.CenterVertically ) { Column(modifier = Modifier.weight(1f)) { - Text( - text = displayTokenSymbol, - fontSize = 14.sp, - color = MixinAppTheme.colors.textPrimary - ) + Box { + Row( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .clickable { priceMenuExpanded = true } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(if (showMarkPrice) R.string.Mark_Price else R.string.Last_Price), + fontSize = 14.sp, + color = MixinAppTheme.colors.textPrimary, + ) + Spacer(modifier = Modifier.width(4.dp)) + Icon( + painter = painterResource(R.drawable.ic_type_down), + contentDescription = null, + tint = MixinAppTheme.colors.iconGray, + modifier = Modifier.size(16.dp), + ) + } + DropdownMenu( + expanded = priceMenuExpanded, + onDismissRequest = { priceMenuExpanded = false }, + modifier = Modifier.background(MixinAppTheme.colors.background), + ) { + listOf(false, true).forEach { markPrice -> + DropdownMenuItem(onClick = { + showMarkPrice = markPrice + preferences.putBoolean(PREF_MARKET_DETAIL_MARK_PRICE, markPrice) + priceMenuExpanded = false + }) { + if (showMarkPrice == markPrice) { + Icon( + painter = painterResource(R.drawable.ic_check_black_24dp), + contentDescription = null, + tint = MixinAppTheme.colors.textPrimary, + modifier = Modifier.size(20.dp), + ) + } else { + Spacer(modifier = Modifier.size(20.dp)) + } + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(if (markPrice) R.string.Mark_Price else R.string.Last_Price), + color = MixinAppTheme.colors.textPrimary, + ) + } + } + } + } Spacer(modifier = Modifier.height(7.dp)) Text( text = "$PERPS_USD_SYMBOL$displayPrice", diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 7cf1851daa..9d75174343 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2391,6 +2391,7 @@ 方向 开仓价格 触发价格 + 最新价格 价格 最新价格 平仓价格 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index ee026d7edc..bfd60c96c9 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -2482,6 +2482,7 @@ 方向 開倉價格 觸發價格 + 最新價格 標記價格 最新價格 平倉價格 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3b44e6955a..018861f545 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2472,6 +2472,7 @@ Direction Entry Price Trigger Price + Last Price Mark Price Current Price Close Price From aad6a48ad712fcb7d0376b90215da6142282fc0d Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Sat, 10 Oct 2026 16:26:25 +0800 Subject: [PATCH 8/9] feat(perps): refine close previews and reduction precision --- .../11.json | 13 ++++-- .../11.json | 13 ++++-- .../android/api/response/perps/PerpsMarket.kt | 4 ++ .../one/mixin/android/db/PerpsDatabase.kt | 1 + ...erpsBatchCloseBottomSheetDialogFragment.kt | 6 +++ .../PerpsCloseBottomSheetDialogFragment.kt | 17 ++++--- .../trade/perps/PerpsEstimatedRealizedPnl.kt | 44 +++++++++++++++++++ .../web3/trade/perps/PerpsMarginAmount.kt | 21 +++++++++ .../web3/trade/perps/PerpsMarketDetailPage.kt | 8 ++-- .../PerpsReduceBottomSheetDialogFragment.kt | 14 +++--- .../web3/trade/perps/PerpsReduceQuantity.kt | 8 ++-- app/src/main/res/values-es/strings.xml | 3 ++ app/src/main/res/values-in/strings.xml | 3 ++ app/src/main/res/values-ja/strings.xml | 3 ++ app/src/main/res/values-ko/strings.xml | 3 ++ app/src/main/res/values-ms/strings.xml | 3 ++ app/src/main/res/values-ru/strings.xml | 3 ++ app/src/main/res/values-zh-rCN/strings.xml | 4 +- app/src/main/res/values-zh-rTW/strings.xml | 4 +- app/src/main/res/values/strings.xml | 4 +- .../web3/trade/perps/PerpsMarginAmountTest.kt | 21 +++++++++ .../trade/perps/PerpsReduceQuantityTest.kt | 39 ++++++++++------ 22 files changed, 195 insertions(+), 44 deletions(-) create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsEstimatedRealizedPnl.kt diff --git a/app/schemas/googlePlay/one.mixin.android.db.PerpsDatabase/11.json b/app/schemas/googlePlay/one.mixin.android.db.PerpsDatabase/11.json index 8042da6c35..ce14a964aa 100644 --- a/app/schemas/googlePlay/one.mixin.android.db.PerpsDatabase/11.json +++ b/app/schemas/googlePlay/one.mixin.android.db.PerpsDatabase/11.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 11, - "identityHash": "a5347b1bc8a8c877a494ce1279947eb8", + "identityHash": "7e04252bdf212439a82b2dc96ee221ea", "entities": [ { "tableName": "positions", @@ -287,7 +287,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, `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`))", + "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, `quantity_scale` INTEGER NOT NULL DEFAULT 0, `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", @@ -326,6 +326,13 @@ "notNull": true, "defaultValue": "2" }, + { + "fieldPath": "quantityScale", + "columnName": "quantity_scale", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, { "fieldPath": "leverage", "columnName": "leverage", @@ -538,7 +545,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a5347b1bc8a8c877a494ce1279947eb8')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7e04252bdf212439a82b2dc96ee221ea')" ] } } \ No newline at end of file diff --git a/app/schemas/otherChannel/one.mixin.android.db.PerpsDatabase/11.json b/app/schemas/otherChannel/one.mixin.android.db.PerpsDatabase/11.json index 8042da6c35..ce14a964aa 100644 --- a/app/schemas/otherChannel/one.mixin.android.db.PerpsDatabase/11.json +++ b/app/schemas/otherChannel/one.mixin.android.db.PerpsDatabase/11.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 11, - "identityHash": "a5347b1bc8a8c877a494ce1279947eb8", + "identityHash": "7e04252bdf212439a82b2dc96ee221ea", "entities": [ { "tableName": "positions", @@ -287,7 +287,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, `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`))", + "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, `quantity_scale` INTEGER NOT NULL DEFAULT 0, `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", @@ -326,6 +326,13 @@ "notNull": true, "defaultValue": "2" }, + { + "fieldPath": "quantityScale", + "columnName": "quantity_scale", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, { "fieldPath": "leverage", "columnName": "leverage", @@ -538,7 +545,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a5347b1bc8a8c877a494ce1279947eb8')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7e04252bdf212439a82b2dc96ee221ea')" ] } } \ No newline at end of file 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 df241cf3ec..72f7878647 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 @@ -35,6 +35,10 @@ data class PerpsMarket( @ColumnInfo(name = "price_scale", defaultValue = "2") val priceScale: Int = 2, + @SerializedName("quantity_scale") + @ColumnInfo(name = "quantity_scale", defaultValue = "0") + val quantityScale: Int = 0, + @SerializedName("leverage") @ColumnInfo(name = "leverage") val leverage: Int, 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 7a873327a4..55ba8bc5da 100644 --- a/app/src/main/java/one/mixin/android/db/PerpsDatabase.kt +++ b/app/src/main/java/one/mixin/android/db/PerpsDatabase.kt @@ -137,6 +137,7 @@ abstract class PerpsDatabase : RoomDatabase() { object : Migration(10, 11) { override suspend fun migrate(connection: SQLiteConnection) { connection.execSQL("ALTER TABLE `positions` ADD COLUMN `estimated_close_fee` TEXT DEFAULT ''") + connection.execSQL("ALTER TABLE `markets` ADD COLUMN `quantity_scale` INTEGER NOT NULL DEFAULT 0") } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsBatchCloseBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsBatchCloseBottomSheetDialogFragment.kt index 135f729bc8..1a1db50a27 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsBatchCloseBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsBatchCloseBottomSheetDialogFragment.kt @@ -222,6 +222,8 @@ class PerpsBatchCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFr assetSymbol = settleAssetSymbol, estimatedReceive = estimatedReceive, estimatedFee = estimatedFee, + estimatedPnl = estimatedPerpsBatchClosePnl(displayPositions), + margin = estimatedPerpsBatchCloseMargin(displayPositions), onFeeTipClick = { PerpetualGuideBottomSheetDialogFragment.newInstance( PerpetualGuideBottomSheetDialogFragment.TAB_TRADING_FEE, @@ -537,6 +539,8 @@ private fun BatchCloseSummary( assetSymbol: String, estimatedReceive: BigDecimal?, estimatedFee: BigDecimal?, + estimatedPnl: BigDecimal?, + margin: BigDecimal?, onFeeTipClick: () -> Unit, ) { Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp)) { @@ -572,6 +576,8 @@ private fun BatchCloseSummary( ) } Spacer(modifier = Modifier.height(20.dp)) + PerpsEstimatedRealizedPnl(estimatedPnl, margin) + Spacer(modifier = Modifier.height(20.dp)) PerpsEstimatedFee(estimatedFee, onFeeTipClick) } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt index 460962b400..233b19c8ed 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt @@ -80,7 +80,6 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen companion object { const val TAG = "PerpsCloseBottomSheetDialogFragment" const val RESULT_MARGIN_CONFIRMED = "perps_margin_confirmed" - const val RESULT_POSITION_REDUCED = "perps_position_reduced" const val RESULT_POSITION_ID = "position_id" const val RESULT_AMOUNT = "amount" private const val ARGS_POSITION_ID = "args_position_id" @@ -392,6 +391,14 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen estimatedCloseFee?.toBigDecimalOrNull() } + val closePnl = estimatedPerpsClosePnl(latestUnrealizedPnl, estimatedCloseFee) + val displayPnl = if (isReducePosition) { + perpsReductionValue(closePnl?.toPlainString(), reduceQuantity.orEmpty(), latestQuantity) + } else closePnl + val displayMargin = if (isReducePosition) { + perpsReductionValue(latestMargin, reduceQuantity.orEmpty(), latestQuantity) + } else latestMargin.toBigDecimalOrNull() + Column( modifier = Modifier .fillMaxWidth() @@ -458,6 +465,8 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen } if (!isReduceMargin) { + Spacer(modifier = Modifier.height(20.dp)) + PerpsEstimatedRealizedPnl(displayPnl, displayMargin) Spacer(modifier = Modifier.height(20.dp)) PerpsEstimatedFee(displayCloseFee) { PerpetualGuideBottomSheetDialogFragment.newInstance( @@ -488,12 +497,6 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen ) { MixinButton( onClick = { - if (isReducePosition) { - parentFragmentManager.setFragmentResult( - RESULT_POSITION_REDUCED, - Bundle().apply { putString(RESULT_POSITION_ID, positionId) }, - ) - } onDoneAction?.invoke() dismiss() }, diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsEstimatedRealizedPnl.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsEstimatedRealizedPnl.kt new file mode 100644 index 0000000000..6167dd1019 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsEstimatedRealizedPnl.kt @@ -0,0 +1,44 @@ +package one.mixin.android.ui.home.web3.trade.perps + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import one.mixin.android.Constants +import one.mixin.android.R +import one.mixin.android.extension.defaultSharedPreferences +import one.mixin.android.compose.theme.MixinAppTheme +import java.math.BigDecimal + +@Composable +internal fun PerpsEstimatedRealizedPnl(pnl: BigDecimal?, margin: BigDecimal?) { + val quoteColorReversed = LocalContext.current.defaultSharedPreferences.getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) + val risingColor = if (quoteColorReversed) MixinAppTheme.colors.walletRed else MixinAppTheme.colors.walletGreen + val fallingColor = if (quoteColorReversed) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed + val percent = estimatedPerpsClosePnlPercent(pnl, margin) + Column { + Text( + text = stringResource(R.string.perps_estimated_realized_pnl).uppercase(), + color = MixinAppTheme.colors.textRemarks, + fontSize = 14.sp, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = if (pnl == null) "--" else { + "${formatPerpsSignedUsdDecimal(pnl)} (${percent?.let { formatPerpsSignedPercent(it, withSign = false) } ?: "--"})" + }, + color = when { + pnl == null || pnl.signum() == 0 -> MixinAppTheme.colors.textPrimary + pnl.signum() > 0 -> risingColor + else -> fallingColor + }, + fontSize = 18.sp, + ) + } +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmount.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmount.kt index ebac63dddb..3da7c006e6 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmount.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmount.kt @@ -103,3 +103,24 @@ internal fun estimatedPerpsBatchCloseFee(positions: List): Bi total + (position.estimatedCloseFee?.toBigDecimalOrNull() ?: return null) } } + +internal fun estimatedPerpsClosePnl(unrealizedPnl: String?, estimatedCloseFee: String?): BigDecimal? { + val pnl = unrealizedPnl?.toBigDecimalOrNull() ?: return null + val fee = estimatedCloseFee?.toBigDecimalOrNull() ?: return null + return pnl - fee +} + +internal fun estimatedPerpsClosePnlPercent(pnl: BigDecimal?, margin: BigDecimal?): BigDecimal? { + if (pnl == null || margin == null || margin <= BigDecimal.ZERO) return null + return pnl.multiply(BigDecimal(100)).divide(margin, 8, RoundingMode.HALF_UP) +} + +internal fun estimatedPerpsBatchClosePnl(positions: List): BigDecimal? = + positions.fold(BigDecimal.ZERO) { total, position -> + total + (estimatedPerpsClosePnl(position.unrealizedPnl, position.estimatedCloseFee) ?: return null) + } + +internal fun estimatedPerpsBatchCloseMargin(positions: List): BigDecimal? = + positions.fold(BigDecimal.ZERO) { total, position -> + total + (position.margin?.toBigDecimalOrNull() ?: return null) + } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt index 6248f3729a..1445b7ef53 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt @@ -1046,9 +1046,9 @@ private fun MarketDetailCard( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = stringResource(if (showMarkPrice) R.string.Mark_Price else R.string.Last_Price), + text = stringResource(if (showMarkPrice) R.string.perps_mark_price else R.string.perps_last_price), fontSize = 14.sp, - color = MixinAppTheme.colors.textPrimary, + color = MixinAppTheme.colors.textAssist, ) Spacer(modifier = Modifier.width(4.dp)) Icon( @@ -1081,7 +1081,7 @@ private fun MarketDetailCard( } Spacer(modifier = Modifier.width(8.dp)) Text( - text = stringResource(if (markPrice) R.string.Mark_Price else R.string.Last_Price), + text = stringResource(if (markPrice) R.string.perps_mark_price else R.string.perps_last_price), color = MixinAppTheme.colors.textPrimary, ) } @@ -1090,7 +1090,7 @@ private fun MarketDetailCard( } Spacer(modifier = Modifier.height(7.dp)) Text( - text = "$PERPS_USD_SYMBOL$displayPrice", + text = formatPerpsPrice(displayPrice, market.priceScale), fontSize = 22.sp, fontWeight = FontWeight.W500, color = MixinAppTheme.colors.textPrimary diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsReduceBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsReduceBottomSheetDialogFragment.kt index 7fabbcf7c1..ba1d6a3f26 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsReduceBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsReduceBottomSheetDialogFragment.kt @@ -49,6 +49,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import one.mixin.android.Constants import one.mixin.android.R +import one.mixin.android.api.response.perps.PerpsMarket import one.mixin.android.api.response.perps.PerpsPosition import one.mixin.android.api.response.perps.PerpsPositionItem import one.mixin.android.api.response.perps.toPosition @@ -87,9 +88,6 @@ class PerpsReduceBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme override fun onStart() { super.onStart() dialog?.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) - childFragmentManager.setFragmentResultListener(PerpsCloseBottomSheetDialogFragment.RESULT_POSITION_REDUCED, this) { _, result -> - if (result.getString(PerpsCloseBottomSheetDialogFragment.RESULT_POSITION_ID) == initialPosition.positionId) dismiss() - } } @Composable @@ -98,13 +96,16 @@ class PerpsReduceBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme viewModel.observePosition(initialPosition.positionId) }.collectAsStateWithLifecycle(initialValue = initialPosition) val position = observedPosition ?: initialPosition + var market by remember(position.marketId) { mutableStateOf(null) } var input by rememberSaveable { mutableStateOf("") } var isPercentage by rememberSaveable { mutableStateOf(true) } val keyboard = LocalSoftwareKeyboardController.current LaunchedEffect(initialPosition.positionId) { + market = viewModel.getMarketFromDb(initialPosition.marketId) viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { while (isActive) { viewModel.refreshSinglePosition(initialPosition.positionId, initialPosition.walletId) + viewModel.loadMarketDetail(initialPosition.marketId, onSuccess = { market = it }, onError = {}) delay(10_000) } } @@ -113,7 +114,7 @@ class PerpsReduceBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme val price = position.markPrice?.toBigDecimalOrNull()?.takeIf { it > BigDecimal.ZERO } val size = price?.multiply(current) val inputValue = input.toBigDecimalOrNull() ?: BigDecimal.ZERO - val quantity = perpsReductionQuantity(position.quantity, position.markPrice, input, isPercentage) + val quantity = perpsReductionQuantity(position.quantity, position.markPrice, input, isPercentage, market?.quantityScale ?: 0) val amount = if (isPercentage) size?.multiply(inputValue)?.movePointLeft(2) else inputValue val percentage = if (isPercentage) inputValue else size?.takeIf { it > BigDecimal.ZERO }?.let { inputValue.multiply(BigDecimal(100)).divide(it, 8, RoundingMode.DOWN) @@ -204,10 +205,11 @@ class PerpsReduceBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme } MixinButton( onClick = { - if (quantity != null && canSubmit && childFragmentManager.findFragmentByTag(PerpsCloseBottomSheetDialogFragment.TAG) == null) { + if (quantity != null && canSubmit && parentFragmentManager.findFragmentByTag(PerpsCloseBottomSheetDialogFragment.TAG) == null) { keyboard?.hide() PerpsCloseBottomSheetDialogFragment.newInstance(position.toPosition(), reduceQuantity = quantity.stripTrailingZeros().toPlainString()) - .show(childFragmentManager, PerpsCloseBottomSheetDialogFragment.TAG) + .show(parentFragmentManager, PerpsCloseBottomSheetDialogFragment.TAG) + dismiss() } }, enabled = canSubmit, diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsReduceQuantity.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsReduceQuantity.kt index ef99888c5b..56878496ad 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsReduceQuantity.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsReduceQuantity.kt @@ -10,17 +10,17 @@ internal fun perpsReduceQuantity(input: String, currentQuantity: String): BigDec return quantity.takeIf { it > BigDecimal.ZERO && it <= current } } -internal fun perpsReductionQuantity(currentQuantity: String, markPrice: String?, input: String, isPercentage: Boolean): BigDecimal? { +internal fun perpsReductionQuantity(currentQuantity: String, markPrice: String?, input: String, isPercentage: Boolean, quantityScale: Int): BigDecimal? { + if (quantityScale < 0) return null val current = currentQuantity.toBigDecimalOrNull()?.abs()?.takeIf { it > BigDecimal.ZERO } ?: return null val value = marginAdjustmentAmount(input) ?: return null val quantity = if (isPercentage) { if ('.' in input || value > BigDecimal(100)) return null - current.multiply(value).movePointLeft(2) + current.multiply(value).movePointLeft(2).setScale(quantityScale, RoundingMode.DOWN) } else { val price = markPrice?.toBigDecimalOrNull()?.takeIf { it > BigDecimal.ZERO } ?: return null if (value > current.multiply(price)) return null - // ponytail: Until quantity_scale is provided, keep eight decimals and let the server validate the exchange step. - value.divide(price, maxOf(8, current.scale()), RoundingMode.DOWN) + value.divide(price, quantityScale, RoundingMode.DOWN) } return perpsReduceQuantity(quantity.toPlainString(), currentQuantity) } diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 8de088b875..c5474f00c2 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1231,4 +1231,7 @@ PnL estimado Las estimaciones no incluyen comisiones. El importe final recibido depende de la ejecución y la liquidación. El precio de liquidación mostrado es el actual. Comisión estimada + PnL realizado estimado + Último precio + Precio de marca diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 46af1e20b3..b28a5153a9 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -789,4 +789,7 @@ Estimasi PnL Estimasi tidak termasuk biaya. Jumlah akhir yang diterima bergantung pada eksekusi dan penyelesaian. Harga likuidasi yang ditampilkan adalah harga saat ini. Estimasi Biaya + Estimasi PnL Terealisasi + Harga Terakhir + Harga Mark diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index cf8e676f2f..45adcca552 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1177,4 +1177,7 @@ 予想損益 概算には手数料が含まれていません。実際の受取額は約定と決済の結果によって決まります。表示されている清算価格は現在の価格です。 推定手数料 + 予想実現損益 + 最終取引価格 + マーク価格 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 6d7281da1b..d47922e838 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -2708,4 +2708,7 @@ 예상 손익 예상 금액에는 수수료가 포함되지 않습니다. 실제 수령액은 체결 및 정산 결과에 따라 달라집니다. 표시된 청산 가격은 현재 가격입니다. 예상 수수료 + 예상 실현 손익 + 최근 체결 가격 + 마크 가격 diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 7bd2fcbaf5..0ffd616eb3 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -854,4 +854,7 @@ Anggaran PnL Anggaran tidak termasuk yuran. Amaun akhir yang diterima bergantung pada pelaksanaan dan penyelesaian. Harga pembubaran yang dipaparkan ialah harga semasa. Anggaran Yuran + Anggaran PnL Direalisasikan + Harga Terakhir + Harga Tanda diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index ece76931d2..0390426b6f 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1172,4 +1172,7 @@ Ожидаемая прибыль/убыток Оценка не включает комиссии. Итоговая сумма зависит от исполнения ордера и расчётов. Показана текущая цена ликвидации. Расчётная комиссия + Ожидаемая реализованная прибыль/убыток + Последняя цена + Цена маркировки diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 9d75174343..0146d7d0ec 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2391,7 +2391,6 @@ 方向 开仓价格 触发价格 - 最新价格 价格 最新价格 平仓价格 @@ -2658,4 +2657,7 @@ 预计盈亏 预估不含手续费,实际到账以成交和结算为准。此处显示当前强平价格。 预估手续费 + 预计净盈亏 + 最新价格 + 标记价格 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index bfd60c96c9..5d0564d8f6 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -2482,7 +2482,6 @@ 方向 開倉價格 觸發價格 - 最新價格 標記價格 最新價格 平倉價格 @@ -2710,4 +2709,7 @@ 預計盈虧 預估不含手續費,實際到帳以成交和結算為準。此處顯示當前強平價格。 預估手續費 + 預計淨盈虧 + 最新價格 + 標記價格 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 018861f545..79d365218b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2472,7 +2472,6 @@ Direction Entry Price Trigger Price - Last Price Mark Price Current Price Close Price @@ -2740,4 +2739,7 @@ Estimated PnL Estimates exclude fees. Final proceeds depend on execution and settlement. The liquidation price shown is the current price. Estimated Fee + Estimated Realized PnL + Last Price + Mark Price diff --git a/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmountTest.kt b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmountTest.kt index d7e1a977d8..77a2813f99 100644 --- a/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmountTest.kt +++ b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmountTest.kt @@ -10,6 +10,27 @@ import kotlin.test.assertNull import kotlin.test.assertTrue class PerpsMarginAmountTest { + @Test + fun closePnlDeductsFeesAndUsesClosedMarginForPercentage() { + val pnl = estimatedPerpsClosePnl("2", "0.5")!! + assertEquals(BigDecimal("1.5"), pnl) + assertEquals(BigDecimal("15"), estimatedPerpsClosePnlPercent(pnl, BigDecimal.TEN)?.stripTrailingZeros()) + assertEquals(BigDecimal("-0.3"), estimatedPerpsClosePnl("0.2", "0.5")) + assertNull(estimatedPerpsClosePnl("2", null)) + assertNull(estimatedPerpsClosePnlPercent(pnl, BigDecimal.ZERO)) + assertNull(estimatedPerpsClosePnlPercent(null, BigDecimal.TEN)) + val partialPnl = perpsReductionValue(pnl.toPlainString(), "0.5", "1") + val partialMargin = perpsReductionValue("10", "0.5", "1") + assertEquals(BigDecimal("0.75"), partialPnl?.stripTrailingZeros()) + assertEquals(BigDecimal("15"), estimatedPerpsClosePnlPercent(partialPnl, partialMargin)?.stripTrailingZeros()) + val first = PerpsPositionItem("p1", "market", "long", "1", "100", 10, margin = "10", unrealizedPnl = "2", estimatedCloseFee = "0.5") + val positions = listOf(first, first.copy(positionId = "p2", margin = "20", unrealizedPnl = "-1", estimatedCloseFee = "0.2")) + assertEquals(BigDecimal("0.3"), estimatedPerpsBatchClosePnl(positions)) + assertEquals(BigDecimal("1"), estimatedPerpsClosePnlPercent(estimatedPerpsBatchClosePnl(positions), estimatedPerpsBatchCloseMargin(positions))?.stripTrailingZeros()) + assertNull(estimatedPerpsBatchClosePnl(listOf(first.copy(estimatedCloseFee = null)))) + assertNull(estimatedPerpsBatchCloseMargin(listOf(first.copy(margin = "invalid")))) + } + @Test fun batchCloseSumsEachReturnAfterFeesAndKeepsUnknownTotalsUnknown() { val first = PerpsPositionItem("p1", "market", "long", "1", "100", 10, margin = "10", unrealizedPnl = "2", estimatedCloseFee = "0.5") diff --git a/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsReduceQuantityTest.kt b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsReduceQuantityTest.kt index df89d5f36b..c95cd4e7df 100644 --- a/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsReduceQuantityTest.kt +++ b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsReduceQuantityTest.kt @@ -12,24 +12,35 @@ import java.math.BigDecimal class PerpsReduceQuantityTest { @Test fun percentageReducesAbsoluteQuantityWithoutUsingMarginOrLeverage() { - assertDecimal("0.25", perpsReductionQuantity("1", "88.23", "25", true)) - assertDecimal("0.25", perpsReductionQuantity("-1", "88.23", "25", true)) - assertDecimal("0.2", perpsReductionQuantity("0.8", "88.23", "25", true)) - assertDecimal("0.0000000025", perpsReductionQuantity("0.00000001", "1", "25", true)) - assertDecimal("1.00000001", perpsReductionQuantity("1.00000001", "88.23", "100", true)) - assertNull(perpsReductionQuantity("1", "88.23", "101", true)) - assertNull(perpsReductionQuantity("1", "88.23", "25.5", true)) + assertDecimal("0.25", perpsReductionQuantity("1", "88.23", "25", true, 8)) + assertDecimal("0.25", perpsReductionQuantity("-1", "88.23", "25", true, 8)) + assertDecimal("0.2", perpsReductionQuantity("0.8", "88.23", "25", true, 8)) + assertNull(perpsReductionQuantity("0.00000001", "1", "25", true, 8)) + assertDecimal("1.00000001", perpsReductionQuantity("1.00000001", "88.23", "100", true, 8)) + assertNull(perpsReductionQuantity("1", "88.23", "101", true, 8)) + assertNull(perpsReductionQuantity("1", "88.23", "25.5", true, 8)) } @Test fun dollarInputUsesPositionValueAndNeverRoundsAboveTheBudget() { - assertDecimal("0.2", perpsReductionQuantity("1", "100", "20", false)) - val quantity = requireNotNull(perpsReductionQuantity("10", "3", "1", false)) + assertDecimal("0.2", perpsReductionQuantity("1", "100", "20", false, 8)) + val quantity = requireNotNull(perpsReductionQuantity("10", "3", "1", false, 8)) assertDecimal("0.33333333", quantity) assertTrue(quantity.multiply(BigDecimal("3")) <= BigDecimal.ONE) - assertNull(perpsReductionQuantity("1", "100", "100.01", false)) - assertNull(perpsReductionQuantity("1", "0", "1", false)) - assertNull(perpsReductionQuantity("1", null, "1", false)) + assertNull(perpsReductionQuantity("1", "100", "100.01", false, 8)) + assertNull(perpsReductionQuantity("1", "0", "1", false, 8)) + assertNull(perpsReductionQuantity("1", null, "1", false, 8)) + } + + @Test + fun reductionsRoundDownToMarketQuantityScale() { + assertDecimal("0.002", perpsReductionQuantity("0.011", "100", "25", true, 3)) + assertDecimal("0.003", perpsReductionQuantity("1", "300", "1", false, 3)) + assertDecimal("0.011", perpsReductionQuantity("0.011", "100", "100", true, 3)) + assertDecimal("2", perpsReductionQuantity("11", "100", "25", true, 0)) + assertNull(perpsReductionQuantity("0.001", "100", "25", true, 3)) + assertNull(perpsReductionQuantity("1", "300", "0.1", false, 3)) + assertNull(perpsReductionQuantity("1", "100", "25", true, -1)) } @Test @@ -40,8 +51,8 @@ class PerpsReduceQuantityTest { assertDecimal("0.5", perpsReduceQuantity("0.5", "-0.5")) for (input in listOf("", ".", "-1", "+1", "0", "1e2", "NaN", "1.2.3", "9".repeat(41))) { assertNull(perpsReduceQuantity(input, "100")) - assertNull(perpsReductionQuantity("100", "1", input, true)) - assertNull(perpsReductionQuantity("100", "1", input, false)) + assertNull(perpsReductionQuantity("100", "1", input, true, 8)) + assertNull(perpsReductionQuantity("100", "1", input, false, 8)) } } From def4d8f2f72afe647b34a5c519e2090905316a8b Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Mon, 12 Oct 2026 22:43:46 +0800 Subject: [PATCH 9/9] fix(perps): address reduction review findings --- .../android/db/perps/PerpsPositionDao.kt | 2 +- .../web3/trade/perps/PerpetualViewModel.kt | 15 +++++--- .../PerpsCloseBottomSheetDialogFragment.kt | 6 +++- .../trade/perps/PerpsEstimatedRealizedPnl.kt | 2 +- .../PerpsMarginBottomSheetDialogFragment.kt | 14 ++++---- .../web3/trade/perps/PerpsMarketDetailPage.kt | 1 + .../PerpsReduceBottomSheetDialogFragment.kt | 35 ++++++++++--------- .../web3/trade/perps/PerpsReduceQuantity.kt | 6 ++-- .../util/analytics/AnalyticsTracker.kt | 1 + app/src/main/res/values-es/strings.xml | 1 - app/src/main/res/values-in/strings.xml | 1 - app/src/main/res/values-ja/strings.xml | 1 - app/src/main/res/values-ko/strings.xml | 1 - app/src/main/res/values-ms/strings.xml | 1 - app/src/main/res/values-ru/strings.xml | 1 - app/src/main/res/values-zh-rCN/strings.xml | 1 - app/src/main/res/values-zh-rTW/strings.xml | 1 - app/src/main/res/values/strings.xml | 1 - .../android/db/perps/PerpsPositionDaoTest.kt | 10 ++++++ .../web3/trade/perps/PerpsMarginAmountTest.kt | 10 ++++++ .../trade/perps/PerpsReduceQuantityTest.kt | 20 ++++++++++- 21 files changed, 88 insertions(+), 43 deletions(-) diff --git a/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt index 330fee4e19..5e01468fee 100644 --- a/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt +++ b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt @@ -30,7 +30,7 @@ interface PerpsPositionDao : BaseDao { val updatedAt = current.updatedAt?.let(OffsetDateTime::parse) if (updatedAt != null && OffsetDateTime.parse(entity.updatedAt).isBefore(updatedAt)) return } - insert(entity) + insert(if (entity.walletId.isBlank()) entity.copy(walletId = current?.walletId.orEmpty()) else entity) } @Query(""" diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualViewModel.kt index 547033d646..a366c4ecc6 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualViewModel.kt @@ -801,6 +801,9 @@ class PerpetualViewModel @Inject constructor( fun refreshSinglePosition(positionId: String, walletId: String? = null) { viewModelScope.launch { try { + val knownWalletId = withContext(Dispatchers.IO) { + walletId?.takeIf { it.isNotBlank() } ?: perpsPositionDao.getPosition(positionId)?.walletId?.takeIf { it.isNotBlank() } + } val response = withContext(Dispatchers.IO) { routeService.getPerpsPosition(positionId) } @@ -811,15 +814,15 @@ class PerpetualViewModel @Inject constructor( if (remotePosition != null) { perpsPositionDao.upsertSuspend( remotePosition.copy( - walletId = walletId ?: remotePosition.walletId + walletId = knownWalletId ?: remotePosition.walletId ) ) } else { - Timber.d("Skip deleting local position when remote detail is null: $positionId") + knownWalletId?.takeIf { it.isNotBlank() }?.let(::refreshPositions) } } } else if (response.errorCode == ErrorHandler.NOT_FOUND) { - Timber.d("Skip deleting local position on NOT_FOUND during refresh: $positionId") + knownWalletId?.takeIf { it.isNotBlank() }?.let(::refreshPositions) } else { Timber.e("Failed to refresh position detail: ${response.errorDescription}") } @@ -1001,9 +1004,10 @@ class PerpetualViewModel @Inject constructor( onSuccess: () -> Unit, onError: (String) -> Unit, quantity: String? = null, + allowFullClose: Boolean = false, ) { viewModelScope.launch { - closePerpsOrder(positionId, quantity) + closePerpsOrder(positionId, quantity, allowFullClose) .onSuccess { onSuccess() } .onFailure { onError(it.message.orEmpty()) } } @@ -1035,6 +1039,7 @@ class PerpetualViewModel @Inject constructor( private suspend fun closePerpsOrder( positionId: String, quantity: String? = null, + allowFullClose: Boolean = false, ): Result { return runCatching { val response = withContext(Dispatchers.IO) { @@ -1042,7 +1047,7 @@ class PerpetualViewModel @Inject constructor( val positionResponse = routeService.getPerpsPosition(positionId) check(positionResponse.isSuccess) { positionResponse.errorDescription.orEmpty() } val position = positionResponse.data - require(position?.state == PerpsPosition.STATE_OPEN && perpsReduceQuantity(quantity, position.quantity) != null) { + require(position?.state == PerpsPosition.STATE_OPEN && perpsReduceQuantity(quantity, position.quantity, allowFullClose) != null) { MixinApplication.appContext.getString(R.string.perps_reduce_quantity_changed) } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt index 233b19c8ed..7a24dd19c3 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt @@ -162,6 +162,9 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen private val isReduceMargin get() = reduceMarginAmount != null private val reduceQuantity by lazy { arguments?.getString(ARGS_REDUCE_QUANTITY) } private val isReducePosition get() = reduceQuantity != null + private val allowFullClose get() = reduceQuantity?.toBigDecimalOrNull()?.compareTo( + arguments?.getString(ARGS_QUANTITY)?.toBigDecimalOrNull()?.abs() ?: BigDecimal.ZERO, + ) == 0 private val isLong by lazy { requireNotNull(requireArguments().getString(ARGS_SIDE)) { "side is null" } @@ -565,7 +568,7 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen private fun showVerifyPinThenClose() { if (childFragmentManager.findFragmentByTag(VerifyBottomSheetDialogFragment.TAG) != null) return - if (isReducePosition && perpsReduceQuantity(reduceQuantity.orEmpty(), latestQuantity) == null) { + if (isReducePosition && perpsReduceQuantity(reduceQuantity.orEmpty(), latestQuantity, allowFullClose) == null) { errorInfo = getString(R.string.perps_reduce_quantity_changed) step = Step.Error return @@ -608,6 +611,7 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen viewModel.closePerpsOrder( positionId = positionId, quantity = reduceQuantity, + allowFullClose = allowFullClose, onSuccess = { step = Step.Done AnalyticsTracker.trackPerpsCloseEnd() diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsEstimatedRealizedPnl.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsEstimatedRealizedPnl.kt index 6167dd1019..5350ad9508 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsEstimatedRealizedPnl.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsEstimatedRealizedPnl.kt @@ -31,7 +31,7 @@ internal fun PerpsEstimatedRealizedPnl(pnl: BigDecimal?, margin: BigDecimal?) { Spacer(Modifier.height(8.dp)) Text( text = if (pnl == null) "--" else { - "${formatPerpsSignedUsdDecimal(pnl)} (${percent?.let { formatPerpsSignedPercent(it, withSign = false) } ?: "--"})" + "${formatPerpsSignedUsdDecimal(pnl)} (${percent?.let { formatPerpsSignedPercent(it) } ?: "--"})" }, color = when { pnl == null || pnl.signum() == 0 -> MixinAppTheme.colors.textPrimary diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt index e74f0b8b39..5ca08acf4a 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt @@ -657,6 +657,7 @@ internal fun PerpsReductionInput( onInputChanged: (String) -> Unit, onToggleMode: () -> Unit, onApplyReductionLimit: (() -> Unit)? = null, + decimalPlaces: Int = if (isPercentage) 0 else 2, ) { var textFieldValue by remember(isPercentage) { mutableStateOf(TextFieldValue(input, TextRange(input.length))) } LaunchedEffect(input) { @@ -688,9 +689,10 @@ internal fun PerpsReductionInput( focusRequester.requestFocus() keyboardController?.show() } + fun formatInput(value: BigDecimal) = value.setScale(decimalPlaces, RoundingMode.DOWN).stripTrailingZeros().toPlainString() fun selectPercentage(value: BigDecimal) { val selected = if (isPercentage) value else reduceMarginAmount(margin?.toPlainString(), value.toPlainString(), true) ?: BigDecimal.ZERO - updateInput(formatMarginAdjustmentInput(selected, isPercentage)) + updateInput(formatInput(selected)) } Column( modifier = Modifier @@ -702,7 +704,7 @@ internal fun PerpsReductionInput( ) { Row(horizontalArrangement = Arrangement.spacedBy(32.dp), verticalAlignment = Alignment.CenterVertically) { IconButton( - onClick = { updateInput(formatMarginAdjustmentInput((inputValue - BigDecimal.ONE).max(BigDecimal.ZERO), isPercentage)) }, + onClick = { updateInput(formatInput((inputValue - BigDecimal.ONE).max(BigDecimal.ZERO))) }, enabled = enabled && inputValue > BigDecimal.ZERO, ) { Icon(painterResource(R.drawable.ic_perps_minus), stringResource(R.string.perps_reduce_action), tint = Color.Unspecified, modifier = Modifier.size(16.dp)) @@ -713,8 +715,8 @@ internal fun PerpsReductionInput( value = textFieldValue, onValueChange = { value -> val text = value.text - if (text.length <= 40 && text.all { it in '0'..'9' || (!isPercentage && it == '.') } && text.count { it == '.' } <= 1 && - text.substringAfter('.', "").length <= 2) { + if (text.length <= 40 && text.all { it in '0'..'9' || (decimalPlaces > 0 && it == '.') } && text.count { it == '.' } <= 1 && + text.substringAfter('.', "").length <= decimalPlaces) { textFieldValue = value if (text != input) onInputChanged(text) } @@ -723,7 +725,7 @@ internal fun PerpsReductionInput( singleLine = true, modifier = Modifier.widthIn(min = 20.dp, max = 160.dp).width(inputWidth).focusRequester(focusRequester), textStyle = inputTextStyle, - keyboardOptions = KeyboardOptions(keyboardType = if (isPercentage) KeyboardType.Number else KeyboardType.Decimal), + keyboardOptions = KeyboardOptions(keyboardType = if (decimalPlaces == 0) KeyboardType.Number else KeyboardType.Decimal), cursorBrush = SolidColor(MixinAppTheme.colors.accent), decorationBox = { field -> Box(contentAlignment = Alignment.Center) { @@ -735,7 +737,7 @@ internal fun PerpsReductionInput( if (isPercentage) Text("%", style = inputTextStyle) } IconButton( - onClick = { updateInput(formatMarginAdjustmentInput((inputValue + BigDecimal.ONE).min(maximum), isPercentage)) }, + onClick = { updateInput(formatInput((inputValue + BigDecimal.ONE).min(maximum))) }, enabled = enabled && inputValue < maximum, ) { Icon(painterResource(R.drawable.ic_perps_add), stringResource(R.string.Add), tint = Color.Unspecified, modifier = Modifier.size(16.dp)) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt index e1fef76d31..dc021eb0ac 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt @@ -159,6 +159,7 @@ fun PerpsMarketDetailPage( when (action) { PerpsAdjustBottomSheetDialogFragment.ACTION_REDUCE_POSITION -> { if (activity.supportFragmentManager.findFragmentByTag(PerpsReduceBottomSheetDialogFragment.TAG) == null) { + AnalyticsTracker.trackPerpsCloseStart(AnalyticsTracker.PerpsCloseType.REDUCTION) PerpsReduceBottomSheetDialogFragment.newInstance(position) .show(activity.supportFragmentManager, PerpsReduceBottomSheetDialogFragment.TAG) } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsReduceBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsReduceBottomSheetDialogFragment.kt index ba1d6a3f26..e7f95a7160 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsReduceBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsReduceBottomSheetDialogFragment.kt @@ -97,11 +97,11 @@ class PerpsReduceBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme }.collectAsStateWithLifecycle(initialValue = initialPosition) val position = observedPosition ?: initialPosition var market by remember(position.marketId) { mutableStateOf(null) } - var input by rememberSaveable { mutableStateOf("") } + var enteredInput by rememberSaveable { mutableStateOf("") } + var enteredAsPercentage by rememberSaveable { mutableStateOf(true) } var isPercentage by rememberSaveable { mutableStateOf(true) } val keyboard = LocalSoftwareKeyboardController.current LaunchedEffect(initialPosition.positionId) { - market = viewModel.getMarketFromDb(initialPosition.marketId) viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { while (isActive) { viewModel.refreshSinglePosition(initialPosition.positionId, initialPosition.walletId) @@ -113,23 +113,26 @@ class PerpsReduceBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme val current = position.quantity.toBigDecimalOrNull()?.abs() ?: BigDecimal.ZERO val price = position.markPrice?.toBigDecimalOrNull()?.takeIf { it > BigDecimal.ZERO } val size = price?.multiply(current) - val inputValue = input.toBigDecimalOrNull() ?: BigDecimal.ZERO - val quantity = perpsReductionQuantity(position.quantity, position.markPrice, input, isPercentage, market?.quantityScale ?: 0) - val amount = if (isPercentage) size?.multiply(inputValue)?.movePointLeft(2) else inputValue - val percentage = if (isPercentage) inputValue else size?.takeIf { it > BigDecimal.ZERO }?.let { + val inputValue = enteredInput.toBigDecimalOrNull() ?: BigDecimal.ZERO + val quantity = perpsReductionQuantity(position.quantity, position.markPrice, enteredInput, enteredAsPercentage, market?.quantityScale ?: -1) + val amount = if (enteredAsPercentage) size?.multiply(inputValue)?.movePointLeft(2) else inputValue + val percentage = if (enteredAsPercentage) inputValue else size?.takeIf { it > BigDecimal.ZERO }?.let { inputValue.multiply(BigDecimal(100)).divide(it, 8, RoundingMode.DOWN) } + val input = if (isPercentage == enteredAsPercentage) enteredInput else { + (if (isPercentage) percentage else amount)?.stripTrailingZeros()?.toPlainString().orEmpty() + } val error = when { observedPosition?.state != PerpsPosition.STATE_OPEN -> stringResource(R.string.perps_reduce_quantity_changed) - (isPercentage && inputValue > BigDecimal(100)) || (!isPercentage && size != null && inputValue > size) -> - stringResource(R.string.max_removable, if (isPercentage) "100%" else formatPerpsMarginAmount(size)) - inputValue > BigDecimal.ZERO && quantity == null -> stringResource(R.string.perps_reduce_invalid_quantity) + (enteredAsPercentage && inputValue > BigDecimal(100)) || (!enteredAsPercentage && size != null && inputValue > size) -> + stringResource(R.string.max_removable, if (enteredAsPercentage) "100%" else formatPerpsMarginAmount(size)) + market != null && inputValue > BigDecimal.ZERO && quantity == null -> stringResource(R.string.perps_reduce_invalid_quantity) else -> null } val canSubmit = quantity != null && error == null - val pnl = perpsReductionValue(position.unrealizedPnl, quantity?.toPlainString().orEmpty(), position.quantity) ?: BigDecimal.ZERO - val margin = position.margin?.toBigDecimalOrNull()?.takeIf { it > BigDecimal.ZERO } - val roe = margin?.let { position.unrealizedPnl?.toBigDecimalOrNull()?.multiply(BigDecimal(100))?.divide(it, 8, RoundingMode.HALF_UP) } + val pnl = perpsReductionValue(estimatedPerpsClosePnl(position.unrealizedPnl, position.estimatedCloseFee)?.toPlainString(), quantity?.toPlainString().orEmpty(), position.quantity) + val margin = perpsReductionValue(position.margin, quantity?.toPlainString().orEmpty(), position.quantity) + val roe = estimatedPerpsClosePnlPercent(pnl, margin) val remaining = quantity?.let { current - it } val quoteColorReversed = requireContext().defaultSharedPreferences.getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) @@ -166,9 +169,9 @@ class PerpsReduceBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme margin = size, errorText = error, enabled = observedPosition?.state == PerpsPosition.STATE_OPEN, - onInputChanged = { input = it }, + onInputChanged = { enteredInput = it; enteredAsPercentage = isPercentage }, + decimalPlaces = 8, onToggleMode = { - input = (if (isPercentage) amount else percentage)?.let { formatMarginAdjustmentInput(it, !isPercentage) }.orEmpty() isPercentage = !isPercentage }, ) @@ -178,8 +181,8 @@ class PerpsReduceBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme val fallingColor = if (quoteColorReversed) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed PerpsAddInfoRow( title = stringResource(R.string.perps_estimated_pnl), - value = formatPerpsSignedRawUsdDecimal(pnl) + if (quantity != null && roe != null) " (${formatPerpsSignedPercent(roe, withSign = false)})" else "", - valueColor = if (pnl.signum() == 0) MixinAppTheme.colors.textAssist else if (pnl.signum() > 0) risingColor else fallingColor, + value = if (pnl == null) "--" else formatPerpsSignedRawUsdDecimal(pnl) + if (roe != null) " (${formatPerpsSignedPercent(roe)})" else "", + valueColor = if (pnl == null || pnl.signum() == 0) MixinAppTheme.colors.textAssist else if (pnl.signum() > 0) risingColor else fallingColor, ) fun sizeText(value: BigDecimal): String = "${formatPerpsQuantity(value)} ${position.tokenSymbol.orEmpty()} (${formatPerpsMarginAmount(price?.multiply(value))})" PerpsAddInfoRow( diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsReduceQuantity.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsReduceQuantity.kt index 56878496ad..d51b625c73 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsReduceQuantity.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsReduceQuantity.kt @@ -3,11 +3,11 @@ package one.mixin.android.ui.home.web3.trade.perps import java.math.BigDecimal import java.math.RoundingMode -internal fun perpsReduceQuantity(input: String, currentQuantity: String): BigDecimal? { +internal fun perpsReduceQuantity(input: String, currentQuantity: String, allowFullClose: Boolean = true): BigDecimal? { if (input.length > 40 || input.any { it !in '0'..'9' && it != '.' }) return null val quantity = input.toBigDecimalOrNull() ?: return null val current = currentQuantity.toBigDecimalOrNull()?.abs() ?: return null - return quantity.takeIf { it > BigDecimal.ZERO && it <= current } + return quantity.takeIf { it > BigDecimal.ZERO && it <= current && (allowFullClose || it < current) } } internal fun perpsReductionQuantity(currentQuantity: String, markPrice: String?, input: String, isPercentage: Boolean, quantityScale: Int): BigDecimal? { @@ -15,7 +15,7 @@ internal fun perpsReductionQuantity(currentQuantity: String, markPrice: String?, val current = currentQuantity.toBigDecimalOrNull()?.abs()?.takeIf { it > BigDecimal.ZERO } ?: return null val value = marginAdjustmentAmount(input) ?: return null val quantity = if (isPercentage) { - if ('.' in input || value > BigDecimal(100)) return null + if (value > BigDecimal(100)) return null current.multiply(value).movePointLeft(2).setScale(quantityScale, RoundingMode.DOWN) } else { val price = markPrice?.toBigDecimalOrNull()?.takeIf { it > BigDecimal.ZERO } ?: return null 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 8c8a99b53e..bc0cda62cf 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 @@ -864,6 +864,7 @@ object AnalyticsTracker { } object PerpsCloseType { + const val REDUCTION = "reduction" const val SINGLE = "single" const val MULTIPLE = "multiple" } diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index e5d2320f56..e4a46dbd9f 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1229,7 +1229,6 @@ La posición ha cambiado. Vuelve y comprueba la cantidad antes de intentarlo de nuevo. Introduce un importe mayor que cero que no supere el valor de la posición actual. PnL estimado - Las estimaciones no incluyen comisiones. El importe final recibido depende de la ejecución y la liquidación. El precio de liquidación mostrado es el actual. Comisión estimada PnL realizado estimado Último precio diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 466c75afa6..8a0d6f9f37 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -787,7 +787,6 @@ Posisi telah berubah. Kembali dan periksa jumlahnya sebelum mencoba lagi. Masukkan nominal lebih besar dari nol yang tidak melebihi nilai posisi saat ini. Estimasi PnL - Estimasi tidak termasuk biaya. Jumlah akhir yang diterima bergantung pada eksekusi dan penyelesaian. Harga likuidasi yang ditampilkan adalah harga saat ini. Estimasi Biaya Estimasi PnL Terealisasi Harga Terakhir diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index e75e40355b..f8889af7bc 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1175,7 +1175,6 @@ ポジションが変更されました。前の画面に戻り、数量を確認してから再試行してください。 現在のポジション金額以下の、ゼロより大きい金額を入力してください。 予想損益 - 概算には手数料が含まれていません。実際の受取額は約定と決済の結果によって決まります。表示されている清算価格は現在の価格です。 推定手数料 予想実現損益 最終取引価格 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index f5740fac03..d51b4df2a8 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -2706,7 +2706,6 @@ 포지션이 변경되었습니다. 이전 화면으로 돌아가 수량을 확인한 후 다시 시도하세요. 0보다 크고 현재 포지션 규모를 초과하지 않는 금액을 입력하세요. 예상 손익 - 예상 금액에는 수수료가 포함되지 않습니다. 실제 수령액은 체결 및 정산 결과에 따라 달라집니다. 표시된 청산 가격은 현재 가격입니다. 예상 수수료 예상 실현 손익 최근 체결 가격 diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index c11a474051..6ae08c7620 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -852,7 +852,6 @@ Posisi telah berubah. Kembali dan semak kuantiti sebelum mencuba lagi. Masukkan amaun lebih besar daripada sifar yang tidak melebihi nilai posisi semasa. Anggaran PnL - Anggaran tidak termasuk yuran. Amaun akhir yang diterima bergantung pada pelaksanaan dan penyelesaian. Harga pembubaran yang dipaparkan ialah harga semasa. Anggaran Yuran Anggaran PnL Direalisasikan Harga Terakhir diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index f9d1aaf1fd..52c77cd545 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1170,7 +1170,6 @@ Позиция изменилась. Вернитесь и проверьте количество перед повторной попыткой. Введите сумму больше нуля, не превышающую текущую стоимость позиции. Ожидаемая прибыль/убыток - Оценка не включает комиссии. Итоговая сумма зависит от исполнения ордера и расчётов. Показана текущая цена ликвидации. Расчётная комиссия Ожидаемая реализованная прибыль/убыток Последняя цена diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 90d594598b..add57aec95 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2656,7 +2656,6 @@ 持仓已变化,请返回核对数量后重试。 请输入大于零且不超过当前仓位的金额。 预计盈亏 - 预估不含手续费,实际到账以成交和结算为准。此处显示当前强平价格。 预估手续费 预计净盈亏 最新价格 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index bef08f0362..b6635c9840 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -2707,7 +2707,6 @@ 持倉已變化,請返回核對數量後重試。 請輸入大於零且不超過當前倉位的金額。 預計盈虧 - 預估不含手續費,實際到帳以成交和結算為準。此處顯示當前強平價格。 預估手續費 預計淨盈虧 最新價格 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a86976dc36..e23511a813 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2738,7 +2738,6 @@ The position has changed. Please return and check the quantity before trying again. Enter a positive amount within the current position size. Estimated PnL - Estimates exclude fees. Final proceeds depend on execution and settlement. The liquidation price shown is the current price. Estimated Fee Estimated Realized PnL Last Price diff --git a/app/src/test/java/one/mixin/android/db/perps/PerpsPositionDaoTest.kt b/app/src/test/java/one/mixin/android/db/perps/PerpsPositionDaoTest.kt index 1707d11564..d079f1ba3b 100644 --- a/app/src/test/java/one/mixin/android/db/perps/PerpsPositionDaoTest.kt +++ b/app/src/test/java/one/mixin/android/db/perps/PerpsPositionDaoTest.kt @@ -70,6 +70,16 @@ class PerpsPositionDaoTest { } } + @Test + fun detailWithoutWalletIdKeepsTheReducedPositionInItsWallet() = runBlocking { + val dao = database.perpsPositionDao() + val initial = position() + dao.upsertSuspend(initial) + dao.upsertSuspend(initial.copy(walletId = "", quantity = "0.5", updatedAt = "2026-10-07T00:00:01Z")) + assertEquals("0.5", dao.getOpenPositions("wallet").single().quantity) + assertEquals("wallet", dao.getPosition(initial.positionId)?.walletId) + } + private fun position() = PerpsPosition( positionId = "position", marketId = "market", diff --git a/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmountTest.kt b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmountTest.kt index 77a2813f99..620fbf5a3c 100644 --- a/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmountTest.kt +++ b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmountTest.kt @@ -31,6 +31,16 @@ class PerpsMarginAmountTest { assertNull(estimatedPerpsBatchCloseMargin(listOf(first.copy(margin = "invalid")))) } + @Test + fun reductionFeesCanTurnGrossProfitIntoANetLossWithANegativePercentage() { + val pnl = perpsReductionValue(estimatedPerpsClosePnl("0.2", "0.5")?.toPlainString(), "0.5", "1") + val margin = perpsReductionValue("10", "0.5", "1") + assertEquals(BigDecimal("-0.15"), pnl?.stripTrailingZeros()) + val percent = requireNotNull(estimatedPerpsClosePnlPercent(pnl, margin)) + assertEquals("-3%", formatPerpsSignedPercent(percent)) + assertNull(perpsReductionValue(estimatedPerpsClosePnl("0.2", null)?.toPlainString(), "0.5", "1")) + } + @Test fun batchCloseSumsEachReturnAfterFeesAndKeepsUnknownTotalsUnknown() { val first = PerpsPositionItem("p1", "market", "long", "1", "100", 10, margin = "10", unrealizedPnl = "2", estimatedCloseFee = "0.5") diff --git a/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsReduceQuantityTest.kt b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsReduceQuantityTest.kt index c95cd4e7df..677bb2a26c 100644 --- a/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsReduceQuantityTest.kt +++ b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsReduceQuantityTest.kt @@ -18,7 +18,7 @@ class PerpsReduceQuantityTest { assertNull(perpsReductionQuantity("0.00000001", "1", "25", true, 8)) assertDecimal("1.00000001", perpsReductionQuantity("1.00000001", "88.23", "100", true, 8)) assertNull(perpsReductionQuantity("1", "88.23", "101", true, 8)) - assertNull(perpsReductionQuantity("1", "88.23", "25.5", true, 8)) + assertDecimal("0.255", perpsReductionQuantity("1", "88.23", "25.5", true, 8)) } @Test @@ -56,6 +56,24 @@ class PerpsReduceQuantityTest { } } + @Test + fun aPartialReductionCannotBecomeAFullCloseAfterThePositionShrinks() { + assertDecimal("0.5", perpsReduceQuantity("0.5", "1", allowFullClose = false)) + assertNull(perpsReduceQuantity("0.5", "0.5", allowFullClose = false)) + assertNull(perpsReduceQuantity("0.5", "-0.5", allowFullClose = false)) + assertDecimal("0.5", perpsReduceQuantity("0.50", "-0.5", allowFullClose = true)) + } + + @Test + fun fractionalInputsPreserveDollarAndSmallPositionAmounts() { + assertDecimal("1.999", perpsReductionQuantity("2", "100", "199.90", false, 3)) + assertDecimal("1.999", perpsReductionQuantity("2", "100", "99.95", true, 3)) + assertDecimal("0.005", perpsReductionQuantity("0.01", "1", "50", true, 3)) + assertDecimal("0.005", perpsReductionQuantity("0.01", "1", "0.005", false, 3)) + assertNull(perpsReductionQuantity("10.5", "1", "50", true, -1)) + assertDecimal("5.25", perpsReductionQuantity("10.5", "1", "50", true, 2)) + } + @Test fun estimatesOnlyTheReducedShareAndPreservesPnlSign() { assertDecimal("5", perpsReductionValue("20", "0.25", "1"))