diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/AccountController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/AccountController.kt index cbb9d31..9716064 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/AccountController.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/AccountController.kt @@ -3,10 +3,12 @@ package com.wrbug.polymarketbot.controller import com.wrbug.polymarketbot.dto.* import com.wrbug.polymarketbot.enums.ErrorCode import com.wrbug.polymarketbot.service.AccountService +import com.wrbug.polymarketbot.util.toSafeBigDecimal import kotlinx.coroutines.runBlocking import org.slf4j.LoggerFactory import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* +import java.math.BigDecimal /** * 账户管理控制器 @@ -16,9 +18,9 @@ import org.springframework.web.bind.annotation.* class AccountController( private val accountService: AccountService ) { - + private val logger = LoggerFactory.getLogger(AccountController::class.java) - + /** * 通过私钥导入账户 */ @@ -32,7 +34,7 @@ class AccountController( if (request.walletAddress.isBlank()) { return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_WALLET_ADDRESS_EMPTY)) } - + val result = accountService.importAccount(request) result.fold( onSuccess = { account -> @@ -41,7 +43,13 @@ class AccountController( onFailure = { e -> logger.error("导入账户失败: ${e.message}", e) when (e) { - is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, e.message)) + is IllegalArgumentException -> ResponseEntity.ok( + ApiResponse.error( + ErrorCode.PARAM_ERROR, + e.message + ) + ) + else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ACCOUNT_IMPORT_FAILED, e.message)) } } @@ -51,7 +59,7 @@ class AccountController( ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ACCOUNT_IMPORT_FAILED, e.message)) } } - + /** * 更新账户信息 */ @@ -66,7 +74,13 @@ class AccountController( onFailure = { e -> logger.error("更新账户失败: ${e.message}", e) when (e) { - is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, e.message)) + is IllegalArgumentException -> ResponseEntity.ok( + ApiResponse.error( + ErrorCode.PARAM_ERROR, + e.message + ) + ) + else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ACCOUNT_UPDATE_FAILED, e.message)) } } @@ -76,7 +90,7 @@ class AccountController( ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ACCOUNT_UPDATE_FAILED, e.message)) } } - + /** * 删除账户 */ @@ -91,8 +105,20 @@ class AccountController( onFailure = { e -> logger.error("删除账户失败: ${e.message}", e) when (e) { - is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, e.message)) - is IllegalStateException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.BUSINESS_ERROR, e.message)) + is IllegalArgumentException -> ResponseEntity.ok( + ApiResponse.error( + ErrorCode.PARAM_ERROR, + e.message + ) + ) + + is IllegalStateException -> ResponseEntity.ok( + ApiResponse.error( + ErrorCode.BUSINESS_ERROR, + e.message + ) + ) + else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ACCOUNT_DELETE_FAILED, e.message)) } } @@ -102,7 +128,7 @@ class AccountController( ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ACCOUNT_DELETE_FAILED, e.message)) } } - + /** * 查询账户列表 */ @@ -124,7 +150,7 @@ class AccountController( ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ACCOUNT_LIST_FETCH_FAILED, e.message)) } } - + /** * 查询账户详情 */ @@ -139,8 +165,19 @@ class AccountController( onFailure = { e -> logger.error("查询账户详情失败: ${e.message}", e) when (e) { - is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, e.message)) - else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ACCOUNT_DETAIL_FETCH_FAILED, e.message)) + is IllegalArgumentException -> ResponseEntity.ok( + ApiResponse.error( + ErrorCode.PARAM_ERROR, + e.message + ) + ) + + else -> ResponseEntity.ok( + ApiResponse.error( + ErrorCode.SERVER_ACCOUNT_DETAIL_FETCH_FAILED, + e.message + ) + ) } } ) @@ -149,7 +186,7 @@ class AccountController( ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ACCOUNT_DETAIL_FETCH_FAILED, e.message)) } } - + /** * 查询账户余额 */ @@ -164,8 +201,19 @@ class AccountController( onFailure = { e -> logger.error("查询账户余额失败: ${e.message}", e) when (e) { - is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, e.message)) - else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ACCOUNT_BALANCE_FETCH_FAILED, e.message)) + is IllegalArgumentException -> ResponseEntity.ok( + ApiResponse.error( + ErrorCode.PARAM_ERROR, + e.message + ) + ) + + else -> ResponseEntity.ok( + ApiResponse.error( + ErrorCode.SERVER_ACCOUNT_BALANCE_FETCH_FAILED, + e.message + ) + ) } } ) @@ -174,32 +222,7 @@ class AccountController( ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ACCOUNT_BALANCE_FETCH_FAILED, e.message)) } } - - /** - * 设置默认账户 - */ - @PostMapping("/set-default") - fun setDefaultAccount(@RequestBody request: SetDefaultAccountRequest): ResponseEntity> { - return try { - val result = accountService.setDefaultAccount(request.accountId) - result.fold( - onSuccess = { - ResponseEntity.ok(ApiResponse.success(Unit)) - }, - onFailure = { e -> - logger.error("设置默认账户失败: ${e.message}", e) - when (e) { - is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, e.message)) - else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ACCOUNT_DEFAULT_SET_FAILED, e.message)) - } - } - ) - } catch (e: Exception) { - logger.error("设置默认账户异常: ${e.message}", e) - ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ACCOUNT_DEFAULT_SET_FAILED, e.message)) - } - } - + /** * 查询所有账户的仓位列表 */ @@ -221,7 +244,7 @@ class AccountController( ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ACCOUNT_POSITIONS_FETCH_FAILED, e.message)) } } - + /** * 卖出仓位 */ @@ -242,13 +265,25 @@ class AccountController( if (request.orderType !in listOf("MARKET", "LIMIT")) { return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ORDER_TYPE_MUST_BE_MARKET_OR_LIMIT)) } - if (request.quantity.isBlank()) { + // 如果传了 percent,不需要校验 quantity;如果没传 percent,必须提供 quantity + if (request.percent.isNullOrBlank() && request.quantity.isNullOrBlank()) { return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_QUANTITY_EMPTY)) } + // 如果传了 percent,验证百分比值必须在 0-100 之间(支持小数) + if (!request.percent.isNullOrBlank()) { + try { + val percent = request.percent.toSafeBigDecimal() + if (percent <= BigDecimal.ZERO || percent > BigDecimal.valueOf(100)) { + return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, "卖出百分比必须在 0-100 之间")) + } + } catch (e: Exception) { + return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, "卖出百分比格式不正确: ${e.message}")) + } + } if (request.orderType == "LIMIT" && (request.price == null || request.price.isBlank())) { return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_PRICE_EMPTY)) } - + val result = runBlocking { accountService.sellPosition(request) } result.fold( onSuccess = { response -> @@ -257,9 +292,26 @@ class AccountController( onFailure = { e -> logger.error("创建卖出订单失败: ${e.message}", e) when (e) { - is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, e.message)) - is IllegalStateException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.BUSINESS_ERROR, e.message)) - else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ACCOUNT_ORDER_CREATE_FAILED, e.message)) + is IllegalArgumentException -> ResponseEntity.ok( + ApiResponse.error( + ErrorCode.PARAM_ERROR, + e.message + ) + ) + + is IllegalStateException -> ResponseEntity.ok( + ApiResponse.error( + ErrorCode.BUSINESS_ERROR, + e.message + ) + ) + + else -> ResponseEntity.ok( + ApiResponse.error( + ErrorCode.SERVER_ACCOUNT_ORDER_CREATE_FAILED, + e.message + ) + ) } } ) @@ -268,7 +320,7 @@ class AccountController( ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ACCOUNT_ORDER_CREATE_FAILED, e.message)) } } - + /** * 获取可赎回仓位统计 */ @@ -283,8 +335,19 @@ class AccountController( onFailure = { e -> logger.error("获取可赎回仓位统计失败: ${e.message}", e) when (e) { - is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, e.message)) - else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "获取可赎回仓位统计失败: ${e.message}")) + is IllegalArgumentException -> ResponseEntity.ok( + ApiResponse.error( + ErrorCode.PARAM_ERROR, + e.message + ) + ) + + else -> ResponseEntity.ok( + ApiResponse.error( + ErrorCode.SERVER_ERROR, + "获取可赎回仓位统计失败: ${e.message}" + ) + ) } } ) @@ -293,7 +356,7 @@ class AccountController( ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "获取可赎回仓位统计失败: ${e.message}")) } } - + /** * 赎回仓位 */ @@ -304,7 +367,7 @@ class AccountController( if (request.positions.isEmpty()) { return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_REDEEM_POSITIONS_EMPTY)) } - + // 验证每个仓位项 for (item in request.positions) { if (item.accountId <= 0) { @@ -317,7 +380,7 @@ class AccountController( return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_INDEX_SETS_INVALID)) } } - + val result = runBlocking { accountService.redeemPositions(request) } result.fold( onSuccess = { response -> @@ -326,9 +389,26 @@ class AccountController( onFailure = { e -> logger.error("赎回仓位失败: ${e.message}", e) when (e) { - is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, e.message)) - is IllegalStateException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.BUSINESS_ERROR, e.message)) - else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ACCOUNT_REDEEM_POSITIONS_FAILED, e.message)) + is IllegalArgumentException -> ResponseEntity.ok( + ApiResponse.error( + ErrorCode.PARAM_ERROR, + e.message + ) + ) + + is IllegalStateException -> ResponseEntity.ok( + ApiResponse.error( + ErrorCode.BUSINESS_ERROR, + e.message + ) + ) + + else -> ResponseEntity.ok( + ApiResponse.error( + ErrorCode.SERVER_ACCOUNT_REDEEM_POSITIONS_FAILED, + e.message + ) + ) } } ) @@ -337,6 +417,6 @@ class AccountController( ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ACCOUNT_REDEEM_POSITIONS_FAILED, e.message)) } } - + } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt index 279718c..b1d9e2f 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt @@ -7,7 +7,6 @@ data class AccountImportRequest( val privateKey: String, // 私钥(前端加密后传输) val walletAddress: String, // 钱包地址(前端从私钥推导,用于验证) val accountName: String? = null, - val isDefault: Boolean = false, val isEnabled: Boolean = true // 是否启用(用于订单推送等功能的开关) ) @@ -17,7 +16,6 @@ data class AccountImportRequest( data class AccountUpdateRequest( val accountId: Long, val accountName: String? = null, - val isDefault: Boolean? = null, val isEnabled: Boolean? = null // 是否启用(用于订单推送等功能的开关) ) @@ -32,21 +30,14 @@ data class AccountDeleteRequest( * 账户详情请求 */ data class AccountDetailRequest( - val accountId: Long? = null // 不提供则返回默认账户 + val accountId: Long? = null // 账户ID(必需) ) /** * 账户余额请求 */ data class AccountBalanceRequest( - val accountId: Long? = null // 不提供则查询默认账户 -) - -/** - * 设置默认账户请求 - */ -data class SetDefaultAccountRequest( - val accountId: Long + val accountId: Long? = null // 账户ID(必需) ) /** @@ -57,7 +48,6 @@ data class AccountDto( val walletAddress: String, val proxyAddress: String, // Polymarket 代理钱包地址 val accountName: String?, - val isDefault: Boolean, val isEnabled: Boolean, // 是否启用(用于订单推送等功能的开关) val apiKeyConfigured: Boolean, // API Key 是否已配置(不返回实际 Key) val apiSecretConfigured: Boolean, // API Secret 是否已配置 @@ -114,7 +104,8 @@ data class AccountPositionDto( val marketIcon: String?, // 市场图标 URL val side: String, // 结果名称(如 "YES", "NO", "Pakistan" 等) val outcomeIndex: Int? = null, // 结果索引(0, 1, 2...),用于计算 tokenId - val quantity: String, + val quantity: String, // 显示用的数量(可能被截位) + val originalQuantity: String? = null, // 原始数量(保留完整精度,用于100%出售) val avgPrice: String, val currentPrice: String, val currentValue: String, @@ -146,7 +137,8 @@ data class PositionSellRequest( val side: String, // 结果名称(如 "YES", "NO", "Pakistan" 等)(必需) val outcomeIndex: Int? = null, // 结果索引(0, 1, 2...),用于计算 tokenId(推荐提供) val orderType: String, // 订单类型:MARKET(市价)或 LIMIT(限价)(必需) - val quantity: String, // 卖出数量(必需,BigDecimal字符串) + val quantity: String? = null, // 卖出数量(可选,BigDecimal字符串,手动输入时使用) + val percent: String? = null, // 卖出百分比(可选,BigDecimal字符串,支持小数,0-100之间,选择百分比按钮时使用) val price: String? = null // 限价价格(限价订单必需,市价订单不需要) ) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AccountService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AccountService.kt index 3b1b96e..a5f1ded 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AccountService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AccountService.kt @@ -83,15 +83,7 @@ class AccountService( } } - // 5. 如果设置为默认账户,取消其他账户的默认状态 - if (request.isDefault) { - accountRepository.findByIsDefaultTrue()?.let { defaultAccount -> - val updated = defaultAccount.copy(isDefault = false, updatedAt = System.currentTimeMillis()) - accountRepository.save(updated) - } - } - - // 6. 获取代理地址(必须成功,否则导入失败) + // 5. 获取代理地址(必须成功,否则导入失败) val proxyAddress = runBlocking { val proxyResult = blockchainService.getProxyAddress(request.walletAddress) if (proxyResult.isSuccess) { @@ -123,7 +115,7 @@ class AccountService( apiSecret = encryptedApiSecret, // 存储加密后的 API Secret apiPassphrase = encryptedApiPassphrase, // 存储加密后的 API Passphrase accountName = request.accountName, - isDefault = request.isDefault, + isDefault = false, // 不再支持默认账户 isEnabled = request.isEnabled, createdAt = System.currentTimeMillis(), updatedAt = System.currentTimeMillis() @@ -153,21 +145,12 @@ class AccountService( // 更新账户名称 val updatedAccountName = request.accountName ?: account.accountName - // 如果设置为默认账户,取消其他账户的默认状态 - val updatedIsDefault = request.isDefault ?: account.isDefault - if (updatedIsDefault && !account.isDefault) { - accountRepository.findByIsDefaultTrue()?.let { defaultAccount -> - val updated = defaultAccount.copy(isDefault = false, updatedAt = System.currentTimeMillis()) - accountRepository.save(updated) - } - } - // 更新启用状态 val updatedIsEnabled = request.isEnabled ?: account.isEnabled val updated = account.copy( accountName = updatedAccountName, - isDefault = updatedIsDefault, + isDefault = account.isDefault, // 保持原值,不再支持修改 isEnabled = updatedIsEnabled, updatedAt = System.currentTimeMillis() ) @@ -196,22 +179,6 @@ class AccountService( // 注意:不再检查活跃订单,允许用户删除有活跃订单的账户 // 前端会显示确认提示框,由用户决定是否删除 - // 如果删除的是默认账户,需要先设置其他账户为默认 - if (account.isDefault) { - val otherAccounts = accountRepository.findAllByOrderByCreatedAtAsc() - .filter { it.id != accountId } - - if (otherAccounts.isNotEmpty()) { - val newDefault = otherAccounts.first().copy( - isDefault = true, - updatedAt = System.currentTimeMillis() - ) - accountRepository.save(newDefault) - } else { - return Result.failure(IllegalStateException("不能删除最后一个账户")) - } - } - accountRepository.delete(account) // 刷新订单推送订阅(账户删除时) @@ -249,13 +216,12 @@ class AccountService( */ fun getAccountDetail(accountId: Long?): Result { return try { - val account = if (accountId != null) { - accountRepository.findById(accountId).orElse(null) - } else { - accountRepository.findByIsDefaultTrue() + if (accountId == null) { + return Result.failure(IllegalArgumentException("账户ID不能为空")) } - - account ?: return Result.failure(IllegalArgumentException("账户不存在")) + + val account = accountRepository.findById(accountId).orElse(null) + ?: return Result.failure(IllegalArgumentException("账户不存在")) Result.success(toDto(account)) } catch (e: Exception) { @@ -270,13 +236,12 @@ class AccountService( */ fun getAccountBalance(accountId: Long?): Result { return try { - val account = if (accountId != null) { - accountRepository.findById(accountId).orElse(null) - } else { - accountRepository.findByIsDefaultTrue() + if (accountId == null) { + return Result.failure(IllegalArgumentException("账户ID不能为空")) } - - account ?: return Result.failure(IllegalArgumentException("账户不存在")) + + val account = accountRepository.findById(accountId).orElse(null) + ?: return Result.failure(IllegalArgumentException("账户不存在")) // 检查代理地址是否存在 if (account.proxyAddress.isBlank()) { @@ -352,34 +317,6 @@ class AccountService( } } - /** - * 设置默认账户 - */ - @Transactional - fun setDefaultAccount(accountId: Long): Result { - return try { - val account = accountRepository.findById(accountId) - .orElse(null) ?: return Result.failure(IllegalArgumentException("账户不存在")) - - // 取消其他账户的默认状态 - accountRepository.findByIsDefaultTrue()?.let { defaultAccount -> - if (defaultAccount.id != account.id) { - val updated = defaultAccount.copy(isDefault = false, updatedAt = System.currentTimeMillis()) - accountRepository.save(updated) - } - } - - // 设置当前账户为默认 - val updated = account.copy(isDefault = true, updatedAt = System.currentTimeMillis()) - accountRepository.save(updated) - - Result.success(Unit) - } catch (e: Exception) { - logger.error("设置默认账户失败", e) - Result.failure(e) - } - } - /** * 转换为 DTO * 包含交易统计数据(总订单数、总盈亏、活跃订单数、已完成订单数、持仓数量) @@ -392,7 +329,6 @@ class AccountService( walletAddress = account.walletAddress, proxyAddress = account.proxyAddress, accountName = account.accountName, - isDefault = account.isDefault, isEnabled = account.isEnabled, apiKeyConfigured = account.apiKey != null, apiSecretConfigured = account.apiSecret != null, @@ -643,6 +579,16 @@ class AccountService( // 使用 eq 方法判断值是否等于 0 val isCurrent = !currentValue.eq(BigDecimal.ZERO) && !curPrice.eq(BigDecimal.ZERO) + // 将 Double 转换为精确的 BigDecimal,保留完整精度 + val sizeDecimal = pos.size?.let { + BigDecimal.valueOf(it) // 使用 BigDecimal.valueOf 保留 Double 的完整精度 + } ?: BigDecimal.ZERO + + // 显示用的数量(保留4位小数,用于显示) + val displayQuantity = sizeDecimal.setScale(4, java.math.RoundingMode.DOWN).toPlainString() + // 原始数量(保留完整精度,用于100%出售) + val originalQuantity = sizeDecimal.toPlainString() + val positionDto = AccountPositionDto( accountId = account.id!!, accountName = account.accountName, @@ -654,7 +600,8 @@ class AccountService( marketIcon = pos.icon, // 市场图标 side = pos.outcome ?: "", outcomeIndex = pos.outcomeIndex, // 添加 outcomeIndex - quantity = pos.size?.toString() ?: "0", + quantity = displayQuantity, // 显示用的数量 + originalQuantity = originalQuantity, // 原始数量(完整精度) avgPrice = pos.avgPrice?.toString() ?: "0", currentPrice = pos.curPrice?.toString() ?: "0", currentValue = pos.currentValue?.toString() ?: "0", @@ -710,9 +657,33 @@ class AccountService( return Result.failure(IllegalStateException("账户未配置API凭证,无法创建订单")) } - // 2. 验证仓位是否存在且数量足够 + // 2. 验证参数:percent 和 quantity 至少提供一个 + if (request.percent.isNullOrBlank() && request.quantity.isNullOrBlank()) { + return Result.failure(IllegalArgumentException("必须提供卖出数量(quantity)或卖出百分比(percent)")) + } + + if (!request.percent.isNullOrBlank() && !request.quantity.isNullOrBlank()) { + return Result.failure(IllegalArgumentException("不能同时提供卖出数量(quantity)和卖出百分比(percent)")) + } + + // 验证百分比值(如果提供了) + val percentDecimal = if (!request.percent.isNullOrBlank()) { + try { + val percent = request.percent!!.toSafeBigDecimal() + if (percent <= BigDecimal.ZERO || percent > BigDecimal.valueOf(100)) { + return Result.failure(IllegalArgumentException("卖出百分比必须在 0-100 之间")) + } + percent + } catch (e: Exception) { + return Result.failure(IllegalArgumentException("卖出百分比格式不正确: ${e.message}")) + } + } else { + null + } + + // 3. 验证仓位是否存在并获取原始数量 val positionsResult = getAllPositions() - positionsResult.fold( + val (position, originalQuantity) = positionsResult.fold( onSuccess = { positionListResponse -> val position = positionListResponse.currentPositions.find { it.accountId == request.accountId && @@ -724,23 +695,49 @@ class AccountService( return Result.failure(IllegalArgumentException("仓位不存在")) } - val positionQuantity = position.quantity.toSafeBigDecimal() - val sellQuantity = request.quantity.toSafeBigDecimal() - - if (sellQuantity <= BigDecimal.ZERO) { - return Result.failure(IllegalArgumentException("卖出数量必须大于0")) - } - - if (sellQuantity > positionQuantity) { - return Result.failure(IllegalArgumentException("卖出数量不能超过持仓数量")) + // 获取原始数量:如果有 originalQuantity 使用它,否则从 API 重新获取 + val originalQty = if (position.originalQuantity != null) { + position.originalQuantity.toSafeBigDecimal() + } else { + // 如果没有 originalQuantity,从区块链服务重新获取原始数据 + val blockchainPositionsResult = blockchainService.getPositions(account.proxyAddress) + if (blockchainPositionsResult.isSuccess) { + val blockchainPos = blockchainPositionsResult.getOrNull()?.find { + it.conditionId == request.marketId && it.outcome == request.side + } + blockchainPos?.size?.let { BigDecimal.valueOf(it) } ?: position.quantity.toSafeBigDecimal() + } else { + position.quantity.toSafeBigDecimal() + } } + + Pair(position, originalQty) }, onFailure = { e -> return Result.failure(Exception("查询仓位失败: ${e.message}")) } - ) + ) ?: return Result.failure(IllegalArgumentException("仓位不存在")) - // 3. 获取 tokenId(从 conditionId 和 outcomeIndex 计算) + // 4. 计算实际卖出数量 + val sellQuantity = if (percentDecimal != null) { + // 使用百分比计算:原始数量 * 百分比 / 100 + originalQuantity.multiply(percentDecimal) + .divide(BigDecimal.valueOf(100), 8, java.math.RoundingMode.DOWN) + } else { + // 使用手动输入的数量 + request.quantity!!.toSafeBigDecimal() + } + + // 5. 验证卖出数量 + if (sellQuantity <= BigDecimal.ZERO) { + return Result.failure(IllegalArgumentException("卖出数量必须大于0")) + } + + if (sellQuantity > originalQuantity) { + return Result.failure(IllegalArgumentException("卖出数量不能超过持仓数量")) + } + + // 6. 获取 tokenId(从 conditionId 和 outcomeIndex 计算) // 需要先获取 tokenId,以便后续通过 CLOB API 获取三元及以上市场的价格 // 优先使用 outcomeIndex,如果没有则尝试从 side 推断(仅支持 YES/NO) val tokenIdResult = if (request.outcomeIndex != null) { @@ -762,12 +759,12 @@ class AccountService( logger.warn("无法获取 tokenId,将使用 market 参数: conditionId=${request.marketId}, side=${request.side}, outcomeIndex=${request.outcomeIndex}, error=${tokenIdResult.exceptionOrNull()?.message}") } - // 4. 验证 tokenId + // 7. 验证 tokenId if (tokenId == null) { return Result.failure(IllegalStateException("无法获取 tokenId,无法创建订单。请确保已配置 Ethereum RPC URL 或提供 outcomeIndex 参数")) } - // 5. 确定卖出价格 + // 8. 确定卖出价格 // 市价单:从订单表获取最优价(通过 tokenId 获取对应 outcome 的订单表) // - 市价卖单:从订单表获取 bestBid(最高买入价),然后减去 SELL_PRICE_ADJUSTMENT // - 市价买单:从订单表获取 bestAsk(最低卖出价),然后加上 BUY_PRICE_ADJUSTMENT @@ -788,13 +785,13 @@ class AccountService( request.price ?: return Result.failure(IllegalArgumentException("限价订单必须提供价格")) } - // 6. 验证价格 + // 9. 验证价格 val priceDecimal = sellPrice.toSafeBigDecimal() if (priceDecimal <= BigDecimal.ZERO) { return Result.failure(IllegalArgumentException("价格必须大于0")) } - // 7. 确定订单类型和过期时间 + // 10. 确定订单类型和过期时间 // 根据官方文档: // - GTC (Good-Til-Cancelled): expiration 必须为 "0" // - GTD (Good-Til-Date): expiration 为具体的 Unix 时间戳(秒) @@ -813,7 +810,7 @@ class AccountService( // 7. 解密私钥 val decryptedPrivateKey = decryptPrivateKey(account) - // 8. 创建并签名订单 + // 11. 创建并签名订单(使用计算后的卖出数量) val signedOrder = try { orderSigningService.createAndSignOrder( privateKey = decryptedPrivateKey, @@ -821,7 +818,7 @@ class AccountService( tokenId = tokenId, side = "SELL", price = sellPrice, - size = request.quantity, + size = sellQuantity.toPlainString(), // 使用计算后的卖出数量 signatureType = 2, // Browser Wallet(与正确订单数据一致) nonce = "0", feeRateBps = "0", @@ -832,7 +829,7 @@ class AccountService( return Result.failure(Exception("创建并签名订单失败: ${e.message}")) } - // 8. 构建订单请求 + // 12. 构建订单请求 val newOrderRequest = com.wrbug.polymarketbot.api.NewOrderRequest( order = signedOrder, @@ -841,7 +838,7 @@ class AccountService( deferExec = false ) - // 9. 解密 API 凭证并使用账户的API凭证创建订单 + // 13. 解密 API 凭证并使用账户的API凭证创建订单 val apiSecret = try { decryptApiSecret(account) } catch (e: Exception) { @@ -874,7 +871,7 @@ class AccountService( marketId = request.marketId, side = request.side, orderType = request.orderType, - quantity = request.quantity, + quantity = sellQuantity.toPlainString(), // 使用计算后的卖出数量 price = if (request.orderType == "LIMIT") sellPrice else null, status = "pending", // 订单状态需要从响应中获取 createdAt = System.currentTimeMillis() diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/OrderSigningService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/OrderSigningService.kt index 3eb49dc..aeeedcb 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/OrderSigningService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/OrderSigningService.kt @@ -102,15 +102,18 @@ class OrderSigningService { } else { // SELL: makerAmount = size (shares), takerAmount = price * size (USDC) // makerAmount 是 shares 数量,最多 4 位小数 - // takerAmount 是 USDC 金额,最多 2 位小数 + // takerAmount 是 USDC 金额,需要精确计算,不进行舍入(保留足够精度以转换为 wei) val rawMakerAmt = roundDown(sizeDecimal, roundConfig.size) - var rawTakerAmt = rawMakerAmt.multiply(roundedPrice) + // takerAmount = price * size,使用精确计算,不进行舍入 + // 直接使用精确计算结果转换为 wei(6 位小数),让 parseUnits 处理精度 + val rawTakerAmt = rawMakerAmt.multiply(roundedPrice) // 确保 makerAmount 精度(shares,最多 4 位小数) val finalMakerAmt = roundDown(rawMakerAmt, TAKER_AMOUNT_DECIMALS) - // 确保 takerAmount 精度(USDC,最多 2 位小数) - rawTakerAmt = roundDown(rawTakerAmt, MAKER_AMOUNT_DECIMALS) + // takerAmount 不进行舍入,直接使用精确计算结果转换为 wei + // 这样可以保留足够的精度,避免精度丢失导致的错误 + // parseUnits 会将 BigDecimal 转换为 wei(6 位小数),自动处理精度 // 转换为 wei(6 位小数) val makerAmount = parseUnits(finalMakerAmt, COLLATERAL_TOKEN_DECIMALS) @@ -319,10 +322,13 @@ class OrderSigningService { /** * 将 BigDecimal 转换为 wei(指定小数位数) + * 使用精确计算,不进行舍入,直接截断到指定小数位数 */ private fun parseUnits(value: BigDecimal, decimals: Int): BigInteger { + // 先设置精度到指定小数位数(向下截断,不四舍五入) + val scaledValue = value.setScale(decimals, RoundingMode.DOWN) val multiplier = BigInteger.TEN.pow(decimals) - return value.multiply(BigDecimal(multiplier)).toBigInteger() + return scaledValue.multiply(BigDecimal(multiplier)).toBigInteger() } /** diff --git a/frontend/src/pages/AccountDetail.tsx b/frontend/src/pages/AccountDetail.tsx index 9e90877..4efecce 100644 --- a/frontend/src/pages/AccountDetail.tsx +++ b/frontend/src/pages/AccountDetail.tsx @@ -74,7 +74,6 @@ const AccountDetail: React.FC = () => { const updateData: any = { accountId: account.id, accountName: values.accountName || undefined, - isDefault: values.isDefault || false } // 只有非空字符串才更新 API 凭证 @@ -163,8 +162,7 @@ const AccountDetail: React.FC = () => { accountName: account.accountName || '', apiKey: '', // 不显示实际值,留空表示不修改 apiSecret: '', // 不显示实际值,留空表示不修改 - apiPassphrase: '', // 不显示实际值,留空表示不修改 - isDefault: account.isDefault || false + apiPassphrase: '' // 不显示实际值,留空表示不修改 }) }} size={isMobile ? 'middle' : 'large'} @@ -203,11 +201,6 @@ const AccountDetail: React.FC = () => { {account.walletAddress} - - - {account.isDefault ? '是' : '否'} - - {balanceLoading ? ( @@ -378,13 +371,6 @@ const AccountDetail: React.FC = () => { - - 设为默认账户 - - + + + 忘记密码?重置密码 + + diff --git a/frontend/src/pages/PositionList.tsx b/frontend/src/pages/PositionList.tsx index 8a2e6fd..d1db94c 100644 --- a/frontend/src/pages/PositionList.tsx +++ b/frontend/src/pages/PositionList.tsx @@ -30,6 +30,7 @@ const PositionList: React.FC = () => { const [orderType, setOrderType] = useState<'MARKET' | 'LIMIT'>('LIMIT') const [sellQuantity, setSellQuantity] = useState('') const [limitPrice, setLimitPrice] = useState('') + const [selectedPercent, setSelectedPercent] = useState(null) // 记录选择的百分比(字符串格式) const [form] = Form.useForm() const [submitting, setSubmitting] = useState(false) const [wsConnected, setWsConnected] = useState(false) @@ -361,6 +362,7 @@ const PositionList: React.FC = () => { setOrderType('LIMIT') setSellQuantity('') setLimitPrice('') + setSelectedPercent(null) // 重置百分比选择 form.resetFields() // 加载市场价格 @@ -382,6 +384,9 @@ const PositionList: React.FC = () => { // 处理数量快捷按钮 const handleQuantityQuickSelect = (percent: number) => { if (!selectedPosition) return + // 记录选择的百分比(转为字符串,避免精度问题) + setSelectedPercent(percent.toString()) + // 计算显示用的数量(用于预览,使用显示数量即可) const quantity = parseFloat(selectedPosition.quantity) const sellQty = (quantity * percent / 100).toFixed(4) setSellQuantity(sellQty) @@ -440,7 +445,12 @@ const PositionList: React.FC = () => { side: selectedPosition.side, outcomeIndex: selectedPosition.outcomeIndex, // 传递 outcomeIndex orderType: orderType, - quantity: sellQuantity, + // 如果选择了百分比,只传递百分比,不传 quantity + // 如果手动输入,只传递 quantity,不传 percent + ...(selectedPercent != null + ? { percent: selectedPercent } + : { quantity: sellQuantity } + ), price: orderType === 'LIMIT' ? limitPrice : undefined } @@ -452,6 +462,7 @@ const PositionList: React.FC = () => { // 重置表单 setSellQuantity('') setLimitPrice('') + setSelectedPercent(null) // 重置百分比选择 form.resetFields() // 仓位列表会通过WebSocket自动更新 } else { @@ -1371,6 +1382,8 @@ const PositionList: React.FC = () => { onChange={(e) => { const newQuantity = e.target.value setSellQuantity(newQuantity) + // 用户手动输入时,清除百分比选择 + setSelectedPercent(null) if (newQuantity) { const price = getCurrentSellPrice() calculatePnl(newQuantity, price) diff --git a/frontend/src/pages/ResetPassword.tsx b/frontend/src/pages/ResetPassword.tsx index fc72b3e..5c390c5 100644 --- a/frontend/src/pages/ResetPassword.tsx +++ b/frontend/src/pages/ResetPassword.tsx @@ -1,5 +1,4 @@ import { useState } from 'react' -import { useNavigate } from 'react-router-dom' import { Card, Form, Input, Button, message, Typography, Alert, Progress } from 'antd' import { LockOutlined, KeyOutlined, UserOutlined } from '@ant-design/icons' import { apiService } from '../services/api' @@ -52,7 +51,6 @@ const getPasswordStrengthInfo = (strength: number): { text: string; color: strin } const ResetPassword: React.FC = () => { - const navigate = useNavigate() const isMobile = useMediaQuery({ maxWidth: 768 }) const [loading, setLoading] = useState(false) const [passwordStrength, setPasswordStrength] = useState(0) @@ -77,11 +75,11 @@ const ResetPassword: React.FC = () => { newPassword: values.newPassword }) if (response.data.code === 0) { - message.success('密码重置成功,请登录') - // 延迟跳转到登录页,让用户看到成功提示 + message.success('密码重置成功', 1) + // 使用 window.location.href 强制跳转到登录页,确保跳转成功 setTimeout(() => { - navigate('/login', { replace: true }) - }, 1000) + window.location.href = '/login' + }, 500) } else { message.error(response.data.msg || '密码重置失败') } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index f7daf9f..1d8a178 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -184,12 +184,6 @@ export const apiService = { balance: (data: { accountId?: number }) => apiClient.post>('/copy-trading/accounts/balance', data), - /** - * 设置默认账户 - */ - setDefault: (data: { accountId: number }) => - apiClient.post>('/copy-trading/accounts/set-default', data), - /** * 查询所有账户的仓位列表 */ diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 72d02ce..5ed39fb 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -15,7 +15,6 @@ export interface Account { walletAddress: string proxyAddress: string // Polymarket 代理钱包地址 accountName?: string - isDefault: boolean isEnabled?: boolean // 是否启用 apiKeyConfigured: boolean apiSecretConfigured: boolean @@ -43,7 +42,6 @@ export interface AccountImportRequest { privateKey: string walletAddress: string accountName?: string - isDefault?: boolean } /** @@ -52,7 +50,6 @@ export interface AccountImportRequest { export interface AccountUpdateRequest { accountId: number accountName?: string - isDefault?: boolean } /** @@ -287,7 +284,8 @@ export interface AccountPosition { marketIcon?: string // 市场图标 URL side: string // 结果名称(如 "YES", "NO", "Pakistan" 等) outcomeIndex?: number // 结果索引(0, 1, 2...),用于计算 tokenId - quantity: string + quantity: string // 显示用的数量(可能被截位) + originalQuantity?: string // 原始数量(保留完整精度,用于100%出售) avgPrice: string currentPrice: string currentValue: string @@ -319,7 +317,8 @@ export interface PositionSellRequest { side: string // 结果名称(如 "YES", "NO", "Pakistan" 等) outcomeIndex?: number // 结果索引(0, 1, 2...),用于计算 tokenId(推荐提供) orderType: 'MARKET' | 'LIMIT' - quantity: string + quantity?: string // 卖出数量(可选,手动输入时使用) + percent?: string // 卖出百分比(可选,BigDecimal字符串,支持小数,0-100之间,选择百分比按钮时使用) price?: string // 限价订单必需 }