Repository navigation
feat(perps): support partial position reduction #6725
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
e534988
72d4e9b
198e4de
708bbe2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<Unit> { | ||
| 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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an existing full-close caller passes Useful? React with 👍 / 👎. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the position-detail endpoint returns an empty Useful? React with 👍 / 👎. |
||
| 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) | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<String?>(null) | ||
| private var marketIconUrl by mutableStateOf("") | ||
| private var marketSymbol by mutableStateOf("") | ||
| private var settleAssetSymbol by mutableStateOf("USDT") | ||
|
|
@@ -195,13 +206,18 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen | |
| LaunchedEffect(Unit) { | ||
| latestMarkPrice = markPrice | ||
| latestUnrealizedPnl = unrealizedPnl | ||
| latestQuantity = arguments?.getString(ARGS_QUANTITY).orEmpty() | ||
| latestMargin = margin | ||
| } | ||
|
|
||
| LaunchedEffect(positionId) { | ||
| val localPosition = viewModel.getPositionFromDb(positionId) | ||
| 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) }, | ||
| ) | ||
|
Comment on lines
+531
to
+535
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The reduction result is emitted only from the Done button handler. Since the successful confirmation dialog remains cancelable with the system Back action, a user can dismiss it after Useful? React with 👍 / 👎. |
||
| } | ||
| 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() | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
Comment on lines
+155
to
+158
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the user enters the new Reduce Position flow through this branch, no analytics start event is emitted, but the confirmation dialog records Useful? React with 👍 / 👎. |
||
| } | ||
| } | ||
| PerpsAdjustBottomSheetDialogFragment.ACTION_ADD_MARGIN, | ||
| PerpsAdjustBottomSheetDialogFragment.ACTION_REDUCE_MARGIN -> { | ||
| PerpsMarginBottomSheetDialogFragment.newInstance( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
quantityis smaller than the open position, the resulting order is still ingested asPerpsOrder.TYPE_CLOSE, whileClosedActivityItemandPositionDetailPageunconditionally label every such order as “Closed Long” or “Closed Short.” Consequently, after a successful partial reduction the activity list and order detail claim that the position was closed even though it remains open; carry or derive partial-close metadata and render a reduction-specific label.Useful? React with 👍 / 👎.