Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment on lines +44 to +45

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Label partial close orders as reductions

When quantity is smaller than the open position, the resulting order is still ingested as PerpsOrder.TYPE_CLOSE, while ClosedActivityItem and PositionDetailPage unconditionally 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 👍 / 👎.

)

data class CloseOrderResponse(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()) }
}
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retain cache cleanup for full closes

When an existing full-close caller passes quantity == null, this replaces the previous cache deletion with refreshSinglePosition(). If the detail endpoint returns null or NOT_FOUND after the position has closed—both cases are explicitly ignored by refreshSinglePosition at lines 833–838—the cached row remains in an open state, and the revised order synchronization only invokes the same refresh. Consequently, observeOpenPositions continues showing the closed position until a separate wallet-wide refresh happens; keep the deletion behavior for full closes or remove/close the row when detail confirms it is gone.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the wallet ID when refreshing the position

When the position-detail endpoint returns an empty wallet_id, this new call uses the default null argument, so refreshSinglePosition persists remotePosition.walletId directly and overwrites the cached wallet ID with an empty string. The code already handles this endpoint behavior in loadPositionDetail by falling back to the local wallet ID, and open-position queries filter by the active wallet; consequently, a successfully reduced position can disappear or oscillate in the UI as position and order refreshes race. Pass the known/local wallet ID here or make refreshSinglePosition preserve it when the response value is blank.

Useful? React with 👍 / 👎.

Timber.d("Perps close order submitted: $positionId")
}.onFailure { error ->
Timber.e(error, "Error closing perps order: $positionId")
}
Expand Down Expand Up @@ -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)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) },
)
}
}
Expand All @@ -90,6 +92,7 @@ private fun PerpsAdjustContent(
onAddMargin: () -> Unit,
onReduceMargin: () -> Unit,
onAddPosition: () -> Unit,
onReducePosition: () -> Unit,
) {
Column(
modifier = Modifier
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -194,6 +199,6 @@ private fun AdjustAction(
@Composable
private fun PerpsAdjustPreview() {
MixinAppTheme {
PerpsAdjustContent(onDismiss = {}, onAddMargin = {}, onReduceMargin = {}, onAddPosition = {})
PerpsAdjustContent(onDismiss = {}, onAddMargin = {}, onReduceMargin = {}, onAddPosition = {}, onReducePosition = {})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
Expand Down Expand Up @@ -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" }
Expand All @@ -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")
Expand All @@ -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(
Expand All @@ -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 ->
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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))

Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Dismiss the reduction flow on every successful submission

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 Step.Done without notifying the parent reduction sheet; that sheet remains open and can submit another reduction against the remaining position. Emit the result when submission succeeds, or also propagate it when a completed dialog is dismissed.

Useful? React with 👍 / 👎.

}
onDoneAction?.invoke()
dismiss()
},
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -602,6 +644,7 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen
step = Step.Sending
viewModel.closePerpsOrder(
positionId = positionId,
quantity = reduceQuantity,
onSuccess = {
step = Step.Done
AnalyticsTracker.trackPerpsCloseEnd()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,7 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme
}
}
} else {
PerpsReduceMarginInput(
PerpsReductionInput(
input = reduceInput,
isPercentage = reduceByPercent,
amount = amountValue,
Expand Down Expand Up @@ -646,7 +646,7 @@ private fun PerpsMarginActions(
}

@Composable
private fun PerpsReduceMarginInput(
internal fun PerpsReductionInput(
input: String,
isPercentage: Boolean,
amount: BigDecimal?,
Expand Down Expand Up @@ -904,7 +904,7 @@ private fun PerpsMarginPreviewContent(initialInput: String, initialIsPercentage:
onSubmit = {},
onGuide = {},
) {
PerpsReduceMarginInput(
PerpsReductionInput(
input = displayInput,
isPercentage = isPercentage,
amount = amount,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Track reduction starts before emitting close funnel events

When the user enters the new Reduce Position flow through this branch, no analytics start event is emitted, but the confirmation dialog records trade_perps_close_preview_confirm/cancel and a successful submission records trade_perps_close_end. Each reduction therefore creates orphan close-funnel events without a matching trade_perps_close_start, inflating close conversions and corrupting funnel results; either add a dedicated reduction funnel or emit a matching typed start event here.

Useful? React with 👍 / 👎.

}
}
PerpsAdjustBottomSheetDialogFragment.ACTION_ADD_MARGIN,
PerpsAdjustBottomSheetDialogFragment.ACTION_REDUCE_MARGIN -> {
PerpsMarginBottomSheetDialogFragment.newInstance(
Expand Down
Loading