Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
c451bad
feat(perps): support margin adjustments
SeniorZhai Sep 14, 2026
9debd43
Merge remote-tracking branch 'origin/master' into feat/adjust-perps-m…
SeniorZhai Sep 15, 2026
a6ae4ae
fix(perps): refine margin inputs and adjustment layout
SeniorZhai Sep 15, 2026
391a153
chore: merge upstream updates into margin branch
SeniorZhai Sep 15, 2026
91205e8
feat(wallet): show maximum earn apy
SeniorZhai Sep 15, 2026
3283f0c
chore(perps): merge master into margin branch
SeniorZhai Sep 15, 2026
d3a7288
feat(perps): open margin links over market details
SeniorZhai Sep 15, 2026
d1c4459
fix(analytics): separate perps position and margin events
SeniorZhai Sep 15, 2026
872a624
feat(perps): preview liquidation prices for margin adjustments
SeniorZhai Sep 16, 2026
3738489
chore: fix nullability and deprecated api warnings
SeniorZhai Sep 16, 2026
ad36a4f
chore(perps): merge upstream updates into margin branch
SeniorZhai Sep 16, 2026
eb9a17b
fix(perps): align analytics events and sources
SeniorZhai Sep 16, 2026
f74b31b
fix(l10n): sync perps margin strings
SeniorZhai Sep 16, 2026
8189d5b
fix(perps): fill margin amount from balance
SeniorZhai Sep 16, 2026
072b8c2
fix(perps): restore margin actions and input state
SeniorZhai Sep 17, 2026
81095c9
fix(wallet): align cash and earn account card styles (#6684)
SeniorZhai Sep 17, 2026
a9b3c63
fix(perps): align margin adjustments with ios
SeniorZhai Sep 18, 2026
b4dfafe
chore: merge master into perps margin branch
SeniorZhai Sep 18, 2026
83b0f37
fix(perps): refine margin sheet layout
SeniorZhai Sep 18, 2026
dbe5c61
fix(perps): show side and symbol in activity subtitles
SeniorZhai Sep 18, 2026
f8eb970
fix(perps): align order detail labels and fields
SeniorZhai Sep 18, 2026
7f16915
fix(perps): refine margin previews and slider alignment
SeniorZhai Sep 18, 2026
643448d
fix(perps): enlarge margin amount switch icon
SeniorZhai Sep 18, 2026
f00bfd5
fix(wallet): keep review button blue while loading
SeniorZhai Sep 18, 2026
a80204e
fix(analytics): unify perps adjustment source
SeniorZhai Sep 20, 2026
7d6d1f5
chore: merge master into perps margin branch
SeniorZhai Sep 20, 2026
a2dda51
fix(perps): align margin controls and fill decimal limits
SeniorZhai Sep 21, 2026
f397976
fix(perps): sync minimum margin and position errors
SeniorZhai Sep 21, 2026
20515ea
fix(perps): align margin input with design
SeniorZhai Sep 21, 2026
8617ed4
chore: merge master into margin adjustment
SeniorZhai Sep 21, 2026
969af91
fix(perps): simplify chinese margin action labels
SeniorZhai Sep 21, 2026
1107e47
fix(perps): tighten margin mode toggle spacing
SeniorZhai Sep 21, 2026
c9a155e
fix(perps): distinguish opening and adding limit errors
SeniorZhai Sep 22, 2026
78b28db
chore: merge master into margin adjustment
SeniorZhai Sep 24, 2026
eab58b4
fix(perps): keep liquidation previews stable on refresh
SeniorZhai Sep 24, 2026
b2825ed
fix(perps): correct tp/sl calculations after margin adjustments
SeniorZhai Sep 28, 2026
6e34200
chore: merge master into margin adjustment
SeniorZhai Sep 28, 2026
c4ca755
fix(perps): ignore in-flight orders when computing the sync offset
SeniorZhai Sep 28, 2026
62410d7
fix(perps): correct input validation and refresh feedback
SeniorZhai Sep 28, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
/build
build/
/captures
/docs/
google-services.json
.DS_Store
/.kotlin
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,14 @@ data class IncreaseOrderRequest(
@SerializedName("leader_position_id")
val leaderPositionId: String? = null,
)

data class AdjustMarginRequest(
@SerializedName("type")
val type: String,
@SerializedName("amount")
val amount: String,
@SerializedName("asset_id")
val assetId: String? = null,
@SerializedName("destination")
val destination: String? = null,
)
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ data class PerpsOrder(
companion object {
const val TYPE_OPEN = "open"
const val TYPE_INCREASE = "increase_position"
const val TYPE_INCREASE_MARGIN = "increase_margin"
const val TYPE_DECREASE_MARGIN = "decrease_margin"
const val TYPE_CLOSE = "close"

const val STATUS_PROCESSING = "processing"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import one.mixin.android.api.request.RouteInstrumentRequest
import one.mixin.android.api.request.RoutePriceRequest
import one.mixin.android.api.request.RouteTickerRequest
import one.mixin.android.api.request.RouteTokenRequest
import one.mixin.android.api.request.perps.AdjustMarginRequest
import one.mixin.android.api.request.perps.CloseOrderRequest
import one.mixin.android.api.request.perps.CloseOrderResponse
import one.mixin.android.api.request.perps.IncreaseOrderRequest
Expand Down Expand Up @@ -426,6 +427,7 @@ interface RouteService {
@Query("side") side: String? = null,
@Query("leverage") leverage: Int? = null,
@Query("position_id") positionId: String? = null,
@Query("action") action: String? = null,
): MixinResponse<MarketLiquidationPriceView>

@GET("perps/orders/accepted-assets")
Expand Down Expand Up @@ -462,6 +464,12 @@ interface RouteService {
@Body request: IncreaseOrderRequest,
): MixinResponse<OpenOrderResponse>

@POST("perps/positions/{id}/margin")
suspend fun adjustPerpsMargin(
@Path("id") positionId: String,
@Body request: AdjustMarginRequest,
): MixinResponse<OpenOrderResponse>

@GET("perps/orders")
suspend fun getPerpsOrders(
@Query("offset") offset: String? = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class AssetChangeListConverter {
private val gson = Gson()

@ColumnTypeConverter
fun fromAssetChangeList(value: List<AssetChange>?): String {
fun fromAssetChangeList(value: List<AssetChange>?): String? {
return if (value == null) {
"[]"
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class DepositEntryListConverter {
}

@ColumnTypeConverter
fun converterDate(list: List<OldDepositEntry>?): String =
fun converterDate(list: List<OldDepositEntry>?): String? =
if (list.isNullOrEmpty()) {
""
} else {
Expand Down
10 changes: 5 additions & 5 deletions app/src/main/java/one/mixin/android/db/perps/PerpsOrderDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ interface PerpsOrderDao : BaseDao<PerpsOrder> {
SELECT o.*, m.display_symbol, m.icon_url, m.token_symbol, m.price_scale
FROM perps_orders o
LEFT JOIN markets m ON m.market_id = o.market_id
WHERE o.order_type IN ('open', 'increase_position', 'close')
WHERE o.order_type IN ('open', 'increase_position', 'increase_margin', 'decrease_margin', 'close')
AND o.status != 'processing'
AND (:offset IS NULL OR o.created_at < :offset)
ORDER BY o.created_at DESC
Expand All @@ -31,7 +31,7 @@ interface PerpsOrderDao : BaseDao<PerpsOrder> {
SELECT o.*, m.display_symbol, m.icon_url, m.token_symbol, m.price_scale
FROM perps_orders o
LEFT JOIN markets m ON m.market_id = o.market_id
WHERE o.order_type IN ('open', 'increase_position', 'close')
WHERE o.order_type IN ('open', 'increase_position', 'increase_margin', 'decrease_margin', 'close')
AND o.status != 'processing'
ORDER BY o.created_at DESC
LIMIT :limit
Expand All @@ -42,7 +42,7 @@ interface PerpsOrderDao : BaseDao<PerpsOrder> {
SELECT o.*, m.display_symbol, m.icon_url, m.token_symbol, m.price_scale
FROM perps_orders o
LEFT JOIN markets m ON m.market_id = o.market_id
WHERE o.order_type IN ('open', 'increase_position', 'close')
WHERE o.order_type IN ('open', 'increase_position', 'increase_margin', 'decrease_margin', 'close')
AND o.status != 'processing'
ORDER BY o.created_at DESC
""")
Expand All @@ -52,7 +52,7 @@ interface PerpsOrderDao : BaseDao<PerpsOrder> {
SELECT o.*, m.display_symbol, m.icon_url, m.token_symbol, m.price_scale
FROM perps_orders o
LEFT JOIN markets m ON m.market_id = o.market_id
WHERE o.order_type IN ('open', 'increase_position', 'close')
WHERE o.order_type IN ('open', 'increase_position', 'increase_margin', 'decrease_margin', 'close')
AND o.status != 'processing'
AND o.market_id = :marketId
ORDER BY o.created_at DESC
Expand Down Expand Up @@ -82,7 +82,7 @@ interface PerpsOrderDao : BaseDao<PerpsOrder> {
@Query("DELETE FROM perps_orders")
suspend fun deleteAll()

@Query("SELECT MAX(updated_at) FROM perps_orders")
@Query("SELECT MAX(updated_at) FROM perps_orders WHERE status IN ('filled', 'rejected', 'closed')")
suspend fun getLatestUpdatedAt(): String?

@Query("SELECT SUM(CAST(realized_pnl AS REAL)) FROM perps_orders WHERE order_type = 'close'")
Expand Down
14 changes: 12 additions & 2 deletions app/src/main/java/one/mixin/android/extension/UrlExtension.kt
Original file line number Diff line number Diff line change
Expand Up @@ -396,8 +396,11 @@ internal data class PerpsTradeAction(
val marketId: String?,
val leaderPositionId: String?,
val openPosition: PerpsOpenPositionAction? = null,
val addMargin: PerpsAddMarginAction? = null,
)

internal data class PerpsAddMarginAction(val margin: String?)

internal data class PerpsOpenPositionAction(
val isLong: Boolean?,
val leverage: Int?,
Expand Down Expand Up @@ -426,10 +429,17 @@ internal fun String.toPerpsTradeAction(): PerpsTradeAction? {
val hasLeaderPositionId = query.hasQueryParameter("leader_position")
val leaderPositionId = query.queryParameter("leader_position")
if (hasLeaderPositionId && (leaderPositionId == null || !leaderPositionId.isUUID())) return null
val isAddMarginAction = query.queryParameter("action").equals("add_margin", true)
val marketId = query.queryParameter("market")?.takeIf(String::isNotBlank)
?: return if (!hasLeaderPositionId) PerpsTradeAction(null, null) else null
?: return if (!hasLeaderPositionId && !isAddMarginAction) PerpsTradeAction(null, null) else null
if (!marketId.isUUID()) return null

if (isAddMarginAction) {
val margin = query.queryParameter("margin")?.toPerpsMarginOrNull()
if (query.hasQueryParameter("margin") && margin == null) return null
return PerpsTradeAction(marketId, leaderPositionId, addMargin = PerpsAddMarginAction(margin))
}

val isOpenAction = query.queryParameter("action").equals("open", true)
val openPosition = if (isOpenAction) {
val hasSide = query.hasQueryParameter("side")
Expand Down Expand Up @@ -521,7 +531,7 @@ private suspend fun openLocalPerpsTradeAction(
context: Context,
action: PerpsTradeAction,
): Boolean {
if (action.openPosition != null) return false
if (action.openPosition != null || action.addMargin != null) return false

val marketId = action.marketId
if (marketId == null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1161,11 +1161,13 @@ class LinkBottomSheetDialogFragment : SchemeBottomSheet() {
market.tokenSymbol,
source,
leaderPositionId = action.leaderPositionId,
addMargin = action.addMargin != null,
initialMargin = action.addMargin?.margin,
)
} else {
val (previewMarket, preview) = perpsViewModel.prepareLinkPreview(market, openPosition, action.leaderPositionId, source)
if (preview != PerpsLinkPreview.Input) {
showPerpsLinkPreview(previewMarket, openPosition, action.leaderPositionId, preview)
showPerpsLinkPreview(previewMarket, openPosition, action.leaderPositionId, preview, source)
return
}
PerpsActivity.showOpenPosition(
Expand Down Expand Up @@ -1223,6 +1225,7 @@ class LinkBottomSheetDialogFragment : SchemeBottomSheet() {
action: PerpsOpenPositionAction,
leaderPositionId: String?,
preview: PerpsLinkPreview,
source: String,
) {
lifecycle.withResumed {
val manager = parentFragmentManager
Expand Down Expand Up @@ -1259,6 +1262,7 @@ class LinkBottomSheetDialogFragment : SchemeBottomSheet() {
leverage = action.leverage,
margin = action.margin,
error = getString(R.string.error_already_had_open_position),
source = source,
position = preview.position,
leaderPositionId = leaderPositionId,
liquidationPrice = preview.liquidationPrice,
Expand All @@ -1275,6 +1279,7 @@ class LinkBottomSheetDialogFragment : SchemeBottomSheet() {
leverage = action.leverage,
margin = action.margin,
error = preview.message,
source = source,
liquidationPrice = preview.liquidationPrice,
).showNow(manager, PerpsConfirmBottomSheetDialogFragment.FAILURE_TAG)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ import one.mixin.android.compose.CoilImage
import one.mixin.android.compose.theme.MixinAppTheme
import one.mixin.android.ui.home.inscription.component.AutoSizeText
import one.mixin.android.widget.CoilRoundedHexagonTransformation
import java.math.BigDecimal

internal const val TRADE_INPUT_MAX_DECIMAL_PLACES = 8

Expand All @@ -80,6 +79,8 @@ internal fun isTradeInputDecimalAllowed(
value: String,
maxDecimalPlaces: Int? = TRADE_INPUT_MAX_DECIMAL_PLACES,
): Boolean {
val decimalValue = if (value.startsWith('.')) "0$value" else value
if (value.isNotBlank() && decimalValue.toBigDecimalOrNull() == null) return false
maxDecimalPlaces ?: return true
val decimalIndex = value.indexOf('.')
if (maxDecimalPlaces == 0) return decimalIndex < 0
Expand Down Expand Up @@ -185,11 +186,6 @@ fun InputContent(
return@BasicTextField
}
textFieldValue = it
try {
if (it.text.isBlank()) BigDecimal.ZERO else BigDecimal(it.text)
} catch (e: Exception) {
return@BasicTextField
}
onInputChanged?.invoke(it.text)
},
maxLines = 1,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -592,16 +592,16 @@ class TradeFragment : BaseFragment() {
onShowAllMarkets = { initialCategory, initialSort ->
PerpsMarketListBottomSheetDialogFragment.newInstance(initialCategory, initialSort).show(parentFragmentManager, PerpsMarketListBottomSheetDialogFragment.TAG)
},
onShowAllOpenPositions = {
onShowAllOpenPositions = { source ->
requireActivity().supportFragmentManager.navigateToPerpsRoute(
AllPositionsFragment.newOpenInstance(),
AllPositionsFragment.newOpenInstance(source),
AllPositionsFragment.TAG,
R.id.container,
)
},
onShowAllClosedPositions = {
onShowAllClosedPositions = { source ->
requireActivity().supportFragmentManager.navigateToPerpsRoute(
AllPositionsFragment.newClosedInstance(),
AllPositionsFragment.newClosedInstance(source),
AllPositionsFragment.TAG,
R.id.container,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,8 @@ fun TradePage(
onRecommendedMarketViewAllClick: (SwapRecommendedMarketType, Boolean) -> Unit,
onShowMarketList: (Boolean) -> Unit,
onShowAllMarkets: (String?, MarketSort?) -> Unit,
onShowAllOpenPositions: () -> Unit,
onShowAllClosedPositions: () -> Unit,
onShowAllOpenPositions: (String) -> Unit,
onShowAllClosedPositions: (String) -> Unit,
onOpenPositionClick: (PerpsPositionItem) -> Unit,
onMarketItemClick: (PerpsMarket) -> Unit,
onClosedPositionClick: (PerpsOrderItem) -> Unit,
Expand Down Expand Up @@ -313,7 +313,7 @@ fun TradePage(
if (!isPerpetualOrderBadgeDismissed) {
onDismissPerpetualOrderBadge()
}
onShowAllOpenPositions()
onShowAllOpenPositions(AnalyticsTracker.PerpsSource.PERPS_HOME_MENU)
} else {
onOrderList(currentWalletId, false, spotTypeForPage(pagerState.currentPage))
}
Expand Down Expand Up @@ -385,7 +385,7 @@ fun TradePage(
{
val source = when {
perpetualTabIndex != null && pagerState.currentPage == perpetualTabIndex ->
AnalyticsTracker.CustomerServiceSource.TRADE_PERPS_HOME_MENU
AnalyticsTracker.CustomerServiceSource.PERPS_HOME_MENU
pagerState.currentPage == 1 ->
AnalyticsTracker.CustomerServiceSource.TRADE_ADVANCED_HOME_MENU
else ->
Expand Down
Original file line number Diff line number Diff line change
@@ -1,38 +1,73 @@
package one.mixin.android.ui.home.web3.trade.perps

import android.content.Context
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.google.gson.JsonElement
import kotlinx.coroutines.delay
import one.mixin.android.R
import one.mixin.android.util.ErrorHandler
import java.math.BigDecimal

internal class LiquidationPriceState {
var price by mutableStateOf<String?>(null)
var isLoading by mutableStateOf(false)
private set

suspend fun refresh(request: suspend () -> String?) {
isLoading = price == null
try {
price = request()
} finally {
isLoading = false
}
}
}

internal data class LiquidationPriceLimit(
val maxAmount: String?,
val maxLeverage: Int?,
)
) {
fun errorMessage(context: Context, symbol: String, isAddingPosition: Boolean = false): String =
maxAmount?.toBigDecimalOrNull()?.takeIf { it > BigDecimal.ZERO }?.let {
context.getString(
if (isAddingPosition) R.string.error_perps_position_size_exceeds_leverage_limit_add else R.string.error_perps_position_size_exceeds_leverage_limit_value,
"${it.stripTrailingZeros().toPlainString()} $symbol".trim(),
)
} ?: context.getString(
if (isAddingPosition) R.string.error_perps_position_size_exceeds_leverage_limit_cannot_add else R.string.error_perps_position_size_exceeds_leverage_limit,
)
}

internal sealed interface LiquidationPriceResult {
data class Success(val price: String) : LiquidationPriceResult

data class LimitExceeded(val limit: LiquidationPriceLimit) : LiquidationPriceResult

data class MarginExceeded(val availableMargin: BigDecimal?, val message: String? = null) : LiquidationPriceResult

data object Retry : LiquidationPriceResult

data object Failure : LiquidationPriceResult
data class Failure(val message: String?) : LiquidationPriceResult
}

internal fun liquidationPriceResult(
price: String?,
errorCode: Int?,
limit: LiquidationPriceLimit = LiquidationPriceLimit(null, null),
availableMargin: BigDecimal? = null,
errorMessage: String? = null,
): LiquidationPriceResult {
val validPrice = price?.takeIf { it.isNotBlank() }
return when {
validPrice != null -> LiquidationPriceResult.Success(validPrice)
errorCode == 10653 -> LiquidationPriceResult.MarginExceeded(availableMargin, errorMessage)
errorCode == ErrorHandler.PERPS_POSITION_SIZE_EXCEEDS_LEVERAGE_LIMIT -> {
LiquidationPriceResult.LimitExceeded(limit)
}
errorCode == 500 -> LiquidationPriceResult.Retry
else -> LiquidationPriceResult.Failure
else -> LiquidationPriceResult.Failure(errorMessage)
}
}

Expand Down Expand Up @@ -65,15 +100,24 @@ internal fun shouldRequestLiquidationPrice(
internal suspend fun requestLiquidationPrice(
retryDelayMillis: Long = 1000L,
onLimitExceeded: (LiquidationPriceLimit) -> Unit = {},
onMarginExceeded: ((BigDecimal?) -> Unit)? = null,
onFailure: (String?) -> Unit = {},
request: suspend () -> LiquidationPriceResult,
): String? {
while (true) {
when (val result = request()) {
LiquidationPriceResult.Failure -> return null
is LiquidationPriceResult.Failure -> {
onFailure(result.message)
return null
}
is LiquidationPriceResult.LimitExceeded -> {
onLimitExceeded(result.limit)
return null
}
is LiquidationPriceResult.MarginExceeded -> {
if (onMarginExceeded != null) onMarginExceeded(result.availableMargin) else onFailure(result.message)
return null
}
LiquidationPriceResult.Retry -> delay(retryDelayMillis)
is LiquidationPriceResult.Success -> return result.price
}
Expand Down
Loading
Loading