From 4e89f53c240b2184ac69932acbb37c8e7b9ba91f Mon Sep 17 00:00:00 2001 From: WrBug Date: Sat, 29 Nov 2025 11:08:01 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84=E5=B8=82?= =?UTF-8?q?=E5=9C=BA=E6=95=B0=E6=8D=AE=E6=8E=A5=E5=8F=A3=E5=92=8C=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=E6=95=8F=E6=84=9F=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 创建 MarketController,将市场相关接口从 AccountController 移出 - 添加 /api/copy-trading/markets/latest-price 接口供前端获取最新价 - 在 PolymarketClobService 中封装获取订单表和最优价的逻辑 - 支持多元市场(二元、三元及以上)的最优价获取 - 市价单价格添加调整系数(买单+0.01,卖单-0.02) - 移除所有测试文件中的真实私钥和API凭证 - 更新前端API服务,添加markets相关接口 --- .../polymarketbot/api/PolymarketClobApi.kt | 78 ++++- .../controller/AccountController.kt | 31 +- .../controller/MarketController.kt | 82 +++++ .../com/wrbug/polymarketbot/dto/AccountDto.kt | 13 +- .../polymarketbot/service/AccountService.kt | 179 ++++++++-- .../service/BlockchainService.kt | 125 +++++++ .../service/OrderSigningService.kt | 323 ++++++++++++++++++ .../service/PolymarketClobService.kt | 170 ++++++++- .../wrbug/polymarketbot/util/Eip712Encoder.kt | 113 ++++++ .../wrbug/polymarketbot/util/EthereumUtils.kt | 36 ++ docs/ternary-market-orderbook.md | 213 ++++++++++++ frontend/src/pages/PositionList.tsx | 171 +++++----- frontend/src/services/api.ts | 18 +- frontend/src/types/index.ts | 6 +- polymarket-demo/src/createOrder.ts | 8 +- 15 files changed, 1400 insertions(+), 166 deletions(-) create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/controller/MarketController.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/OrderSigningService.kt create mode 100644 docs/ternary-market-orderbook.md diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketClobApi.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketClobApi.kt index 2247e8d..70e1aee 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketClobApi.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketClobApi.kt @@ -51,11 +51,21 @@ interface PolymarketClobApi { /** * 创建单个订单 + * 文档: https://docs.polymarket.com/developers/CLOB/orders/create-order + * 端点: POST /order(注意是单数,不是 /orders) + * 需要 L2 认证 + * + * 请求格式: + * { + * "order": { signed order object }, + * "owner": "api key", + * "orderType": "GTC" | "FOK" | "GTD" | "FAK" + * } */ - @POST("/orders") + @POST("/order") suspend fun createOrder( - @Body request: CreateOrderRequest - ): Response + @Body request: NewOrderRequest + ): Response /** * 批量创建订单 @@ -148,15 +158,64 @@ interface PolymarketClobApi { } // 请求和响应数据类 + +/** + * 签名的订单对象(根据官方文档) + * 参考: https://docs.polymarket.com/developers/CLOB/orders/create-order + */ +data class SignedOrderObject( + val salt: Long, // random salt used to create unique order + val maker: String, // maker address (funder) + val signer: String, // signing address + val taker: String, // taker address (operator) + val tokenId: String, // ERC1155 token ID of conditional token being traded + val makerAmount: String, // maximum amount maker is willing to spend + val takerAmount: String, // minimum amount taker will pay the maker in return + val expiration: String, // unix expiration timestamp + val nonce: String, // maker's exchange nonce of the order is associated + val feeRateBps: String, // fee rate basis points as required by the operator + val side: String, // buy or sell enum index ("BUY" or "SELL") + val signatureType: Int, // signature type enum index + val signature: String // hex encoded signature +) + +/** + * 创建订单请求(根据官方文档) + * 参考: https://docs.polymarket.com/developers/CLOB/orders/create-order + */ +data class NewOrderRequest( + val order: SignedOrderObject, // signed object + val owner: String, // api key of order owner + val orderType: String, // order type ("FOK", "GTC", "GTD", "FAK") + val deferExec: Boolean = false // defer execution flag +) + +/** + * 创建订单响应(根据官方文档) + */ +data class NewOrderResponse( + val success: Boolean, // boolean indicating if server-side error + val errorMsg: String? = null, // error message in case of unsuccessful placement + val orderId: String? = null, // id of order + val orderHashes: List? = null // hash of settlement transaction order was marketable and triggered a match +) + +/** + * 旧的订单请求格式(已废弃,保留用于兼容) + * @deprecated 使用 NewOrderRequest 代替 + */ +@Deprecated("使用 NewOrderRequest 代替,需要签名的订单对象") data class CreateOrderRequest( - val market: String, - val side: String, // "BUY" or "SELL" + val market: String? = null, // condition ID(可选,如果提供tokenId则不需要) + val token_id: String? = null, // token ID(可选,如果提供market则不需要) + val side: String, // "BUY" or "SELL" val price: String, val size: String, val type: String = "LIMIT", val expiration: Long? = null ) +@Deprecated("使用 NewOrderRequest 代替") data class CreateOrdersBatchRequest( val orders: List ) @@ -286,3 +345,12 @@ data class ServerTimeResponse( val timestamp: Long ) +/** + * 最新价响应(从订单表获取) + */ +data class LatestPriceResponse( + val tokenId: String, + val bestBid: String?, // 最高买入价 + val bestAsk: String? // 最低卖出价 +) + 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 53d4eb3..0a52ba2 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/AccountController.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/AccountController.kt @@ -243,8 +243,9 @@ class AccountController( if (request.marketId.isBlank()) { return ResponseEntity.ok(ApiResponse.paramError("市场ID不能为空")) } - if (request.side !in listOf("YES", "NO")) { - return ResponseEntity.ok(ApiResponse.paramError("方向必须是YES或NO")) + // side 可以是任意结果名称(如 "YES", "NO", "Pakistan" 等),不再限制为 YES/NO + if (request.side.isBlank()) { + return ResponseEntity.ok(ApiResponse.paramError("方向不能为空")) } if (request.orderType !in listOf("MARKET", "LIMIT")) { return ResponseEntity.ok(ApiResponse.paramError("订单类型必须是MARKET或LIMIT")) @@ -277,31 +278,5 @@ class AccountController( } } - /** - * 获取市场价格 - */ - @PostMapping("/markets/price") - fun getMarketPrice(@RequestBody request: MarketPriceRequest): ResponseEntity> { - return try { - if (request.marketId.isBlank()) { - return ResponseEntity.ok(ApiResponse.paramError("市场ID不能为空")) - } - - val result = runBlocking { accountService.getMarketPrice(request.marketId) } - result.fold( - onSuccess = { response -> - logger.info("成功获取市场价格: 市场=${request.marketId}") - ResponseEntity.ok(ApiResponse.success(response)) - }, - onFailure = { e -> - logger.error("获取市场价格失败: ${e.message}", e) - ResponseEntity.ok(ApiResponse.serverError("获取市场价格失败: ${e.message}")) - } - ) - } catch (e: Exception) { - logger.error("获取市场价格异常: ${e.message}", e) - ResponseEntity.ok(ApiResponse.serverError("获取市场价格失败: ${e.message}")) - } - } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/MarketController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/MarketController.kt new file mode 100644 index 0000000..0587aab --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/MarketController.kt @@ -0,0 +1,82 @@ +package com.wrbug.polymarketbot.controller + +import com.wrbug.polymarketbot.api.LatestPriceResponse +import com.wrbug.polymarketbot.dto.* +import com.wrbug.polymarketbot.service.AccountService +import com.wrbug.polymarketbot.service.PolymarketClobService +import kotlinx.coroutines.runBlocking +import org.slf4j.LoggerFactory +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +/** + * 市场数据控制器 + * 提供市场相关的数据查询接口(价格、订单簿等) + */ +@RestController +@RequestMapping("/api/copy-trading/markets") +class MarketController( + private val accountService: AccountService, + private val clobService: PolymarketClobService +) { + + private val logger = LoggerFactory.getLogger(MarketController::class.java) + + /** + * 获取市场价格(通过 Gamma API) + * 使用 Gamma API 获取价格信息,因为 Gamma API 支持 condition_ids 参数 + */ + @PostMapping("/price") + fun getMarketPrice(@RequestBody request: MarketPriceRequest): ResponseEntity> { + return try { + if (request.marketId.isBlank()) { + return ResponseEntity.ok(ApiResponse.paramError("市场ID不能为空")) + } + + val result = runBlocking { accountService.getMarketPrice(request.marketId) } + result.fold( + onSuccess = { response -> + logger.info("成功获取市场价格: 市场=${request.marketId}") + ResponseEntity.ok(ApiResponse.success(response)) + }, + onFailure = { e -> + logger.error("获取市场价格失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.serverError("获取市场价格失败: ${e.message}")) + } + ) + } catch (e: Exception) { + logger.error("获取市场价格异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.serverError("获取市场价格失败: ${e.message}")) + } + } + + /** + * 获取最新价(从订单表获取,供前端下单时显示) + * 支持多元市场(二元、三元及以上) + * 通过 tokenId 获取特定 outcome 的订单表,返回 bestBid 和 bestAsk + */ + @PostMapping("/latest-price") + fun getLatestPrice(@RequestBody request: LatestPriceRequest): ResponseEntity> { + return try { + if (request.tokenId.isBlank()) { + return ResponseEntity.ok(ApiResponse.paramError("tokenId 不能为空")) + } + + val result = runBlocking { clobService.getLatestPrice(request.tokenId) } + result.fold( + onSuccess = { response -> + logger.debug("成功获取最新价: tokenId=${request.tokenId}, bestBid=${response.bestBid}, bestAsk=${response.bestAsk}") + ResponseEntity.ok(ApiResponse.success(response)) + }, + onFailure = { e -> + logger.error("获取最新价失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.serverError("获取最新价失败: ${e.message}")) + } + ) + } catch (e: Exception) { + logger.error("获取最新价异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.serverError("获取最新价失败: ${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 a1a43b2..ad94379 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt @@ -111,7 +111,8 @@ data class AccountPositionDto( val marketTitle: String?, val marketSlug: String?, val marketIcon: String?, // 市场图标 URL - val side: String, // YES 或 NO + val side: String, // 结果名称(如 "YES", "NO", "Pakistan" 等) + val outcomeIndex: Int? = null, // 结果索引(0, 1, 2...),用于计算 tokenId val quantity: String, val avgPrice: String, val currentPrice: String, @@ -141,7 +142,8 @@ data class PositionListResponse( data class PositionSellRequest( val accountId: Long, // 账户ID(必需) val marketId: String, // 市场ID(必需) - val side: String, // 方向:YES 或 NO(必需) + val side: String, // 结果名称(如 "YES", "NO", "Pakistan" 等)(必需) + val outcomeIndex: Int? = null, // 结果索引(0, 1, 2...),用于计算 tokenId(推荐提供) val orderType: String, // 订单类型:MARKET(市价)或 LIMIT(限价)(必需) val quantity: String, // 卖出数量(必需,BigDecimal字符串) val price: String? = null // 限价价格(限价订单必需,市价订单不需要) @@ -168,6 +170,13 @@ data class MarketPriceRequest( val marketId: String // 市场ID ) +/** + * 获取最新价请求(通过 tokenId) + */ +data class LatestPriceRequest( + val tokenId: String // token ID(通过 marketId 和 outcomeIndex 计算得出) +) + /** * 市场价格响应 */ 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 674161e..64ccc07 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AccountService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AccountService.kt @@ -23,10 +23,17 @@ class AccountService( private val retrofitFactory: RetrofitFactory, private val blockchainService: BlockchainService, private val apiKeyService: PolymarketApiKeyService, - private val orderPushService: OrderPushService + private val orderPushService: OrderPushService, + private val orderSigningService: OrderSigningService ) { private val logger = LoggerFactory.getLogger(AccountService::class.java) + + // 市价单价格调整系数(在最优价基础上调整,确保更快成交) + // 市价买单:bestAsk + BUY_PRICE_ADJUSTMENT(加价,确保能立即成交) + // 市价卖单:bestBid - SELL_PRICE_ADJUSTMENT(减价,确保能立即成交) + private val BUY_PRICE_ADJUSTMENT = BigDecimal("0.01") // 买单价格调整系数(+0.01) + private val SELL_PRICE_ADJUSTMENT = BigDecimal("0.02") // 卖单价格调整系数(-0.02) /** * 通过私钥导入账户 @@ -605,6 +612,7 @@ class AccountService( marketSlug = pos.slug ?: "", marketIcon = pos.icon, // 市场图标 side = pos.outcome ?: "", + outcomeIndex = pos.outcomeIndex, // 添加 outcomeIndex quantity = pos.size?.toString() ?: "0", avgPrice = pos.avgPrice?.toString() ?: "0", currentPrice = pos.curPrice?.toString() ?: "0", @@ -691,40 +699,105 @@ class AccountService( } ) - // 3. 确定卖出价格 - val sellPrice = if (request.orderType == "MARKET") { - // 市价订单:获取当前最优买价 - val priceResult = clobService.getPrice(request.marketId) - priceResult.fold( - onSuccess = { priceResponse -> - priceResponse.bestBid ?: priceResponse.lastPrice - ?: return Result.failure(IllegalStateException("无法获取市场价格,请稍后重试")) - }, - onFailure = { e -> - return Result.failure(Exception("获取市场价格失败: ${e.message}")) + // 3. 获取 tokenId(从 conditionId 和 outcomeIndex 计算) + // 需要先获取 tokenId,以便后续通过 CLOB API 获取三元及以上市场的价格 + // 优先使用 outcomeIndex,如果没有则尝试从 side 推断(仅支持 YES/NO) + val tokenIdResult = if (request.outcomeIndex != null) { + blockchainService.getTokenId(request.marketId, request.outcomeIndex) + } else { + // 向后兼容:尝试从 side 推断(仅支持 YES/NO) + when (request.side.uppercase()) { + "YES" -> blockchainService.getTokenId(request.marketId, 0) + "NO" -> blockchainService.getTokenId(request.marketId, 1) + else -> { + logger.warn("无法从 side 推断 outcomeIndex,需要提供 outcomeIndex: side=${request.side}") + Result.failure(IllegalArgumentException("无法从 side '${request.side}' 推断 outcomeIndex,请提供 outcomeIndex 参数")) } - ) + } + } + val tokenId = tokenIdResult.getOrNull() + + if (tokenId == null) { + logger.warn("无法获取 tokenId,将使用 market 参数: conditionId=${request.marketId}, side=${request.side}, outcomeIndex=${request.outcomeIndex}, error=${tokenIdResult.exceptionOrNull()?.message}") + } + + // 4. 验证 tokenId + if (tokenId == null) { + return Result.failure(IllegalStateException("无法获取 tokenId,无法创建订单。请确保已配置 Ethereum RPC URL 或提供 outcomeIndex 参数")) + } + + // 5. 确定卖出价格 + // 市价单:从订单表获取最优价(通过 tokenId 获取对应 outcome 的订单表) + // - 市价卖单:从订单表获取 bestBid(最高买入价),然后减去 SELL_PRICE_ADJUSTMENT + // - 市价买单:从订单表获取 bestAsk(最低卖出价),然后加上 BUY_PRICE_ADJUSTMENT + // 限价订单:使用用户输入的价格 + // 注意:使用 outcomeIndex 和 tokenId 支持多元市场(二元、三元及以上) + // 如果无法获取订单表,将抛出异常 + val sellPrice = if (request.orderType == "MARKET") { + try { + // 市价单:从订单表获取最优价(卖出订单,需要 bestBid) + // 通过 tokenId 获取对应 outcome 的订单表,支持多元市场 + getOptimalPriceFromOrderbook(tokenId, isSellOrder = true) + } catch (e: IllegalStateException) { + logger.error("无法获取订单表最优价: ${e.message}", e) + return Result.failure(IllegalStateException("无法获取订单表最优价: ${e.message}")) + } } else { // 限价订单:使用用户输入的价格 request.price ?: return Result.failure(IllegalArgumentException("限价订单必须提供价格")) } - // 4. 验证价格 + // 6. 验证价格 val priceDecimal = sellPrice.toSafeBigDecimal() if (priceDecimal <= BigDecimal.ZERO) { return Result.failure(IllegalArgumentException("价格必须大于0")) } - // 5. 创建订单请求 - val orderRequest = com.wrbug.polymarketbot.api.CreateOrderRequest( - market = request.marketId, - side = "SELL", // 卖出订单 - price = sellPrice, - size = request.quantity, - type = if (request.orderType == "MARKET") "MARKET" else "LIMIT" + // 7. 确定订单类型和过期时间 + // 根据官方文档: + // - GTC (Good-Til-Cancelled): expiration 必须为 "0" + // - GTD (Good-Til-Date): expiration 为具体的 Unix 时间戳(秒) + // - FOK (Fill-Or-Kill): expiration 必须为 "0" + // - FAK (Fill-And-Kill): expiration 必须为 "0" + val orderType = when (request.orderType) { + "MARKET" -> "FAK" // Fill-And-Kill(与官方市价单一致,允许部分成交) + "LIMIT" -> "GTC" // Good-Til-Cancelled + else -> "GTC" + } + + // GTC 和 FOK 订单的 expiration 必须为 "0" + // 只有 GTD 订单才需要设置具体的过期时间 + val expiration = "0" + + // 7. 创建并签名订单 + val signedOrder = try { + orderSigningService.createAndSignOrder( + privateKey = account.privateKey, + makerAddress = account.proxyAddress, // 使用代理地址作为 maker + tokenId = tokenId, + side = "SELL", + price = sellPrice, + size = request.quantity, + signatureType = 2, // Browser Wallet(与正确订单数据一致) + nonce = "0", + feeRateBps = "0", + expiration = expiration + ) + } catch (e: Exception) { + logger.error("创建并签名订单失败", e) + return Result.failure(Exception("创建并签名订单失败: ${e.message}")) + } + + // 8. 构建订单请求 + + val newOrderRequest = com.wrbug.polymarketbot.api.NewOrderRequest( + order = signedOrder, + owner = account.apiKey!!, // API Key + orderType = orderType, + deferExec = false ) - // 6. 使用账户的API凭证创建订单 + // 9. 使用账户的API凭证创建订单 val clobApi = retrofitFactory.createClobApi( account.apiKey!!, account.apiSecret!!, @@ -732,24 +805,39 @@ class AccountService( account.walletAddress ) - val orderResponse = clobApi.createOrder(orderRequest) + logger.info("创建卖出订单: market=${request.marketId}, side=${request.side}, orderType=${request.orderType}, quantity=${request.quantity}, price=$sellPrice, tokenId=$tokenId") + + val orderResponse = clobApi.createOrder(newOrderRequest) if (orderResponse.isSuccessful && orderResponse.body() != null) { - val order = orderResponse.body()!! - Result.success( - PositionSellResponse( - orderId = order.id, - marketId = request.marketId, - side = request.side, - orderType = request.orderType, - quantity = request.quantity, - price = if (request.orderType == "LIMIT") sellPrice else null, - status = order.status, - createdAt = System.currentTimeMillis() + val response = orderResponse.body()!! + if (response.success) { + logger.info("订单创建成功: orderId=${response.orderId}, orderHashes=${response.orderHashes}") + Result.success( + PositionSellResponse( + orderId = response.orderId ?: "", + marketId = request.marketId, + side = request.side, + orderType = request.orderType, + quantity = request.quantity, + price = if (request.orderType == "LIMIT") sellPrice else null, + status = "pending", // 订单状态需要从响应中获取 + createdAt = System.currentTimeMillis() + ) ) - ) + } else { + val errorMsg = response.errorMsg ?: "未知错误" + logger.error("创建订单失败: $errorMsg") + Result.failure(Exception("创建订单失败: $errorMsg")) + } } else { - Result.failure(Exception("创建订单失败: ${orderResponse.code()} ${orderResponse.message()}")) + val errorBody = try { + orderResponse.errorBody()?.string() + } catch (e: Exception) { + null + } + logger.error("创建订单失败: code=${orderResponse.code()}, message=${orderResponse.message()}, errorBody=$errorBody") + Result.failure(Exception("创建订单失败: ${orderResponse.code()} ${orderResponse.message()}${if (errorBody != null) " - $errorBody" else ""}")) } } catch (e: Exception) { logger.error("卖出仓位异常: ${e.message}", e) @@ -757,6 +845,25 @@ class AccountService( } } + /** + * 从订单表获取最优价(用于市价单) + * 支持多元市场(二元、三元及以上) + * 委托给 PolymarketClobService.getOptimalPrice 方法 + * + * @param tokenId token ID(通过 marketId 和 outcomeIndex 计算得出) + * @param isSellOrder 是否为卖出订单(true: 卖单,需要 bestBid;false: 买单,需要 bestAsk) + * @return 最优价格(已应用调整系数) + * @throws IllegalStateException 如果无法获取订单表或订单表为空 + */ + private suspend fun getOptimalPriceFromOrderbook(tokenId: String, isSellOrder: Boolean): String { + return clobService.getOptimalPrice( + tokenId = tokenId, + isSellOrder = isSellOrder, + buyPriceAdjustment = BUY_PRICE_ADJUSTMENT, + sellPriceAdjustment = SELL_PRICE_ADJUSTMENT + ) + } + /** * 获取市场价格 * 使用 Gamma API 获取价格信息,因为 Gamma API 支持 condition_ids 参数 diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/BlockchainService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/BlockchainService.kt index 120ebd8..55dbc47 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/BlockchainService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/BlockchainService.kt @@ -39,6 +39,12 @@ class BlockchainService( // 合约地址: 0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b private val proxyFactoryContractAddress = "0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b" + // ConditionalTokens 合约地址(Polygon 主网) + private val conditionalTokensAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045" + + // 空集合ID(用于计算collectionId) + private val EMPTY_SET = "0x0000000000000000000000000000000000000000000000000000000000000000" + // 获取代理地址的函数签名 // 根据 Polygonscan 的 F4 方法,函数签名为: computeProxyAddress(address) private val computeProxyAddressFunctionSignature = "computeProxyAddress(address)" @@ -242,6 +248,125 @@ class BlockchainService( } } + /** + * 从 condition ID 和 outcomeIndex 计算 tokenId + * 使用链上合约调用计算: + * 1. getCollectionId(EMPTY_SET, conditionId, indexSet) -> collectionId + * 2. getPositionId(collateralToken, collectionId) -> tokenId + * + * indexSet 的计算:indexSet = 2^outcomeIndex + * - outcomeIndex = 0 -> indexSet = 1 (2^0) + * - outcomeIndex = 1 -> indexSet = 2 (2^1) + * - outcomeIndex = 2 -> indexSet = 4 (2^2) + * + * @param conditionId condition ID(16进制字符串,如 "0x...") + * @param outcomeIndex 结果索引(0, 1, 2...) + * @return tokenId(BigInteger 的字符串表示) + */ + suspend fun getTokenId(conditionId: String, outcomeIndex: Int): Result { + return try { + // 如果未配置 RPC URL,返回错误 + if (ethereumRpcUrl.isBlank()) { + logger.warn("未配置 Ethereum RPC URL,无法计算 tokenId") + return Result.failure(IllegalStateException("未配置 Ethereum RPC URL,无法计算 tokenId")) + } + + val rpcApi = ethereumRpcApi ?: throw IllegalStateException("Ethereum RPC URL 未配置") + + // 验证 outcomeIndex + if (outcomeIndex < 0) { + return Result.failure(IllegalArgumentException("outcomeIndex 必须 >= 0")) + } + + // 计算 indexSet:indexSet = 2^outcomeIndex + val indexSet = BigInteger.TWO.pow(outcomeIndex) + + // 1. 调用 getCollectionId(EMPTY_SET, conditionId, indexSet) + val getCollectionIdSelector = EthereumUtils.getFunctionSelector("getCollectionId(bytes32,bytes32,uint256)") + val encodedEmptySet = EthereumUtils.encodeBytes32(EMPTY_SET) + val encodedConditionId = EthereumUtils.encodeBytes32(conditionId) + val encodedIndexSet = EthereumUtils.encodeUint256(indexSet) + // getFunctionSelector 已经返回带 0x 前缀的字符串,所以直接拼接即可 + val collectionIdData = getCollectionIdSelector + encodedEmptySet + encodedConditionId + encodedIndexSet + + val collectionIdRequest = JsonRpcRequest( + method = "eth_call", + params = listOf( + mapOf( + "to" to conditionalTokensAddress, + "data" to collectionIdData // 移除多余的 0x 前缀 + ), + "latest" + ) + ) + + val collectionIdResponse = rpcApi.call(collectionIdRequest) + if (!collectionIdResponse.isSuccessful || collectionIdResponse.body() == null) { + return Result.failure(Exception("调用 getCollectionId 失败: ${collectionIdResponse.code()} ${collectionIdResponse.message()}")) + } + + val collectionIdResult = collectionIdResponse.body()!! + if (collectionIdResult.error != null) { + return Result.failure(Exception("调用 getCollectionId 失败: ${collectionIdResult.error}")) + } + + val collectionId = collectionIdResult.result ?: return Result.failure(Exception("getCollectionId 返回结果为空")) + + // 2. 调用 getPositionId(collateralToken, collectionId) + val getPositionIdSelector = EthereumUtils.getFunctionSelector("getPositionId(address,bytes32)") + val encodedCollateral = EthereumUtils.encodeAddress(usdcContractAddress) + val encodedCollectionId = EthereumUtils.encodeBytes32(collectionId) + // getFunctionSelector 已经返回带 0x 前缀的字符串,所以直接拼接即可 + val positionIdData = getPositionIdSelector + encodedCollateral + encodedCollectionId + + val positionIdRequest = JsonRpcRequest( + method = "eth_call", + params = listOf( + mapOf( + "to" to conditionalTokensAddress, + "data" to positionIdData // 移除多余的 0x 前缀 + ), + "latest" + ) + ) + + val positionIdResponse = rpcApi.call(positionIdRequest) + if (!positionIdResponse.isSuccessful || positionIdResponse.body() == null) { + return Result.failure(Exception("调用 getPositionId 失败: ${positionIdResponse.code()} ${positionIdResponse.message()}")) + } + + val positionIdResult = positionIdResponse.body()!! + if (positionIdResult.error != null) { + return Result.failure(Exception("调用 getPositionId 失败: ${positionIdResult.error}")) + } + + val tokenId = positionIdResult.result ?: return Result.failure(Exception("getPositionId 返回结果为空")) + val tokenIdBigInt = EthereumUtils.decodeUint256(tokenId) + + Result.success(tokenIdBigInt.toString()) + } catch (e: Exception) { + logger.error("计算 tokenId 失败: conditionId=$conditionId, outcomeIndex=$outcomeIndex, ${e.message}", e) + Result.failure(e) + } + } + + /** + * 从 condition ID 和 side (YES/NO) 计算 tokenId(向后兼容方法) + * 仅支持二元市场(YES/NO) + * + * @param conditionId condition ID(16进制字符串,如 "0x...") + * @param side YES 或 NO + * @return tokenId(BigInteger 的字符串表示) + */ + suspend fun getTokenIdBySide(conditionId: String, side: String): Result { + val outcomeIndex = when (side.uppercase()) { + "YES" -> 0 + "NO" -> 1 + else -> return Result.failure(IllegalArgumentException("side 必须是 YES 或 NO(仅支持二元市场)")) + } + return getTokenId(conditionId, outcomeIndex) + } + /** * 获取用户仓位总价值 * 通过 Polymarket Data API 查询 diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/OrderSigningService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/OrderSigningService.kt new file mode 100644 index 0000000..930615e --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/OrderSigningService.kt @@ -0,0 +1,323 @@ +package com.wrbug.polymarketbot.service + +import com.wrbug.polymarketbot.api.SignedOrderObject +import com.wrbug.polymarketbot.util.toSafeBigDecimal +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.web3j.crypto.Credentials +import java.math.BigDecimal +import java.math.BigInteger +import java.math.RoundingMode + +/** + * 订单签名服务 + * 用于创建和签名 Polymarket CLOB API 订单 + * + * 参考: + * - clob-client/src/order-builder/helpers.ts + * - @polymarket/order-utils 的 ExchangeOrderBuilder + */ +@Service +class OrderSigningService { + + private val logger = LoggerFactory.getLogger(OrderSigningService::class.java) + + // Polygon 主网合约地址 + private val EXCHANGE_CONTRACT = "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E" + private val CHAIN_ID = 137L + + // USDC 有 6 位小数 + private val COLLATERAL_TOKEN_DECIMALS = 6 + + // 默认 tickSize 配置(0.01,对应 2 位小数) + private val DEFAULT_TICK_SIZE = "0.01" + private val DEFAULT_ROUND_CONFIG = RoundConfig( + price = 2, + size = 2, + amount = 4 + ) + + /** + * 订单金额计算结果 + */ + data class OrderAmounts( + val makerAmount: String, // 以 wei 为单位(6 位小数) + val takerAmount: String // 以 wei 为单位(6 位小数) + ) + + /** + * 舍入配置 + */ + data class RoundConfig( + val price: Int, // 价格小数位数 + val size: Int, // 数量小数位数 + val amount: Int // 金额小数位数 + ) + + /** + * 计算订单金额(makerAmount 和 takerAmount) + * + * @param side BUY 或 SELL + * @param size 数量(shares) + * @param price 价格(0-1 之间) + * @param roundConfig 舍入配置 + * @return 订单金额 + */ + fun calculateOrderAmounts( + side: String, + size: String, + price: String, + roundConfig: RoundConfig = DEFAULT_ROUND_CONFIG + ): OrderAmounts { + val sizeDecimal = size.toSafeBigDecimal() + val priceDecimal = price.toSafeBigDecimal() + + // 舍入价格 + val roundedPrice = roundNormal(priceDecimal, roundConfig.price) + + if (side.uppercase() == "BUY") { + // BUY: makerAmount = price * size (USDC), takerAmount = size (shares) + val rawTakerAmt = roundDown(sizeDecimal, roundConfig.size) + var rawMakerAmt = rawTakerAmt.multiply(roundedPrice) + + // 确保金额精度 + if (rawMakerAmt.scale() > roundConfig.amount) { + rawMakerAmt = roundUp(rawMakerAmt, roundConfig.amount + 4) + if (rawMakerAmt.scale() > roundConfig.amount) { + rawMakerAmt = roundDown(rawMakerAmt, roundConfig.amount) + } + } + + // 转换为 wei(6 位小数) + val makerAmount = parseUnits(rawMakerAmt, COLLATERAL_TOKEN_DECIMALS) + val takerAmount = parseUnits(rawTakerAmt, COLLATERAL_TOKEN_DECIMALS) + + return OrderAmounts(makerAmount.toString(), takerAmount.toString()) + } else { + // SELL: makerAmount = size (shares), takerAmount = price * size (USDC) + val rawMakerAmt = roundDown(sizeDecimal, roundConfig.size) + var rawTakerAmt = rawMakerAmt.multiply(roundedPrice) + + // 确保金额精度 + if (rawTakerAmt.scale() > roundConfig.amount) { + rawTakerAmt = roundUp(rawTakerAmt, roundConfig.amount + 4) + if (rawTakerAmt.scale() > roundConfig.amount) { + rawTakerAmt = roundDown(rawTakerAmt, roundConfig.amount) + } + } + + // 转换为 wei(6 位小数) + val makerAmount = parseUnits(rawMakerAmt, COLLATERAL_TOKEN_DECIMALS) + val takerAmount = parseUnits(rawTakerAmt, COLLATERAL_TOKEN_DECIMALS) + + return OrderAmounts(makerAmount.toString(), takerAmount.toString()) + } + } + + /** + * 创建并签名订单 + * + * @param privateKey 私钥(十六进制字符串) + * @param makerAddress maker 地址(funder,通常是 proxyAddress) + * @param tokenId token ID + * @param side BUY 或 SELL + * @param price 价格 + * @param size 数量 + * @param signatureType 签名类型(1: Email/Magic, 2: Browser Wallet, 0: EOA) + * @param nonce nonce(默认 "0") + * @param feeRateBps 费率基点(默认 "0") + * @param expiration 过期时间戳(秒,0 表示永不过期) + * @return 签名的订单对象 + */ + fun createAndSignOrder( + privateKey: String, + makerAddress: String, + tokenId: String, + side: String, + price: String, + size: String, + signatureType: Int = 2, // 默认使用 Browser Wallet(与正确订单数据一致) + nonce: String = "0", + feeRateBps: String = "0", + expiration: String = "0" + ): SignedOrderObject { + try { + // 1. 从私钥获取签名地址 + val cleanPrivateKey = privateKey.removePrefix("0x") + val privateKeyBigInt = BigInteger(cleanPrivateKey, 16) + val credentials = Credentials.create(privateKeyBigInt.toString(16)) + // 统一转换为小写,确保与 EIP-712 编码时使用的地址格式一致 + // EIP-712 编码时地址会被转换为小写,所以订单对象中的地址也应该是小写 + val signerAddress = credentials.address.lowercase() + + // 2. 计算订单金额 + val amounts = calculateOrderAmounts(side, size, price) + + // 3. 生成 salt(使用时间戳,毫秒) + val salt = generateSalt() + + // 4. taker 地址(默认使用零地址) + val taker = "0x0000000000000000000000000000000000000000" + + // 5. 确保 maker 地址也是小写格式 + val makerAddressLower = makerAddress.lowercase() + + // 6. 构建订单数据并签名 + val signature = signOrder( + privateKey = privateKey, + exchangeContract = EXCHANGE_CONTRACT, + chainId = CHAIN_ID, + salt = salt, + maker = makerAddressLower, + signer = signerAddress, + taker = taker, + tokenId = tokenId, + makerAmount = amounts.makerAmount, + takerAmount = amounts.takerAmount, + expiration = expiration, + nonce = nonce, + feeRateBps = feeRateBps, + side = side.uppercase(), + signatureType = signatureType + ) + + // 7. 创建签名的订单对象 + // 注意:所有地址字段都使用小写格式,确保与签名时使用的地址一致 + return SignedOrderObject( + salt = salt, + maker = makerAddressLower, + signer = signerAddress, + taker = taker, + tokenId = tokenId, + makerAmount = amounts.makerAmount, + takerAmount = amounts.takerAmount, + expiration = expiration, + nonce = nonce, + feeRateBps = feeRateBps, + side = side.uppercase(), + signatureType = signatureType, + signature = signature + ) + } catch (e: Exception) { + logger.error("创建并签名订单失败", e) + throw RuntimeException("创建并签名订单失败: ${e.message}", e) + } + } + + /** + * 签名订单(EIP-712) + * + * 参考: @polymarket/order-utils 的 ExchangeOrderBuilder + */ + private fun signOrder( + privateKey: String, + exchangeContract: String, + chainId: Long, + salt: Long, + maker: String, + signer: String, + taker: String, + tokenId: String, + makerAmount: String, + takerAmount: String, + expiration: String, + nonce: String, + feeRateBps: String, + side: String, + signatureType: Int + ): String { + try { + // 1. 从私钥创建 BigInteger + val cleanPrivateKey = privateKey.removePrefix("0x") + val privateKeyBigInt = BigInteger(cleanPrivateKey, 16) + val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt) + + // 2. 编码域分隔符 + val domainSeparator = com.wrbug.polymarketbot.util.Eip712Encoder.encodeExchangeDomain( + chainId = chainId, + verifyingContract = exchangeContract + ) + + // 3. 编码订单消息哈希 + // signatureType 参数:1 = POLY_PROXY (代理钱包), 2 = POLY_GNOSIS_SAFE, 0 = EOA + // 使用传入的 signatureType 参数,而不是硬编码 + val orderHash = com.wrbug.polymarketbot.util.Eip712Encoder.encodeExchangeOrder( + salt = salt, + maker = maker, + signer = signer, + taker = taker, + tokenId = tokenId, + makerAmount = makerAmount, + takerAmount = takerAmount, + expiration = expiration, + nonce = nonce, + feeRateBps = feeRateBps, + side = side, + signatureType = signatureType // 使用传入的参数 + ) + + // 4. 计算完整的结构化数据哈希 + val structuredHash = com.wrbug.polymarketbot.util.Eip712Encoder.hashStructuredData( + domainSeparator = domainSeparator, + messageHash = orderHash + ) + + // 5. 使用私钥签名 + val signature = org.web3j.crypto.Sign.signMessage(structuredHash, ecKeyPair, false) + + // 6. 组合签名(r + s + v) + val rHex = org.web3j.utils.Numeric.toHexString(signature.r).removePrefix("0x").padStart(64, '0') + val sHex = org.web3j.utils.Numeric.toHexString(signature.s).removePrefix("0x").padStart(64, '0') + val vBytes = signature.v as ByteArray + val vInt = if (vBytes.isNotEmpty()) { + vBytes[0].toInt() and 0xff + } else { + 0 + } + val vHex = String.format("%02x", vInt) + + return "0x$rHex$sHex$vHex" + } catch (e: Exception) { + logger.error("订单签名失败", e) + throw RuntimeException("订单签名失败: ${e.message}", e) + } + } + + /** + * 生成 salt(使用时间戳,毫秒) + * 与 TypeScript SDK 保持一致,使用时间戳作为 salt + */ + private fun generateSalt(): Long { + return System.currentTimeMillis() + } + + /** + * 将 BigDecimal 转换为 wei(指定小数位数) + */ + private fun parseUnits(value: BigDecimal, decimals: Int): BigInteger { + val multiplier = BigInteger.TEN.pow(decimals) + return value.multiply(BigDecimal(multiplier)).toBigInteger() + } + + /** + * 正常舍入(四舍五入) + */ + private fun roundNormal(value: BigDecimal, decimals: Int): BigDecimal { + return value.setScale(decimals, RoundingMode.HALF_UP) + } + + /** + * 向下舍入 + */ + private fun roundDown(value: BigDecimal, decimals: Int): BigDecimal { + return value.setScale(decimals, RoundingMode.DOWN) + } + + /** + * 向上舍入 + */ + private fun roundUp(value: BigDecimal, decimals: Int): BigDecimal { + return value.setScale(decimals, RoundingMode.UP) + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/PolymarketClobService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/PolymarketClobService.kt index d9b2693..ab5b91e 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/PolymarketClobService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/PolymarketClobService.kt @@ -2,8 +2,10 @@ package com.wrbug.polymarketbot.service import com.wrbug.polymarketbot.api.* import com.wrbug.polymarketbot.util.RetrofitFactory +import com.wrbug.polymarketbot.util.toSafeBigDecimal import org.slf4j.LoggerFactory import org.springframework.stereotype.Service +import java.math.BigDecimal /** * Polymarket CLOB API 服务封装 @@ -35,6 +37,160 @@ class PolymarketClobService( } } + /** + * 通过 tokenId 获取订单簿 + * 用于三元及以上市场,获取特定 outcome 的价格 + */ + suspend fun getOrderbookByTokenId(tokenId: String): Result { + return try { + val response = clobApi.getOrderbook(tokenId = tokenId, market = null) + if (response.isSuccessful && response.body() != null) { + Result.success(response.body()!!) + } else { + Result.failure(Exception("获取订单簿失败: ${response.code()} ${response.message()}")) + } + } catch (e: Exception) { + logger.error("获取订单簿异常: ${e.message}", e) + Result.failure(e) + } + } + + /** + * 从订单表获取最新价(供前端显示使用) + * 支持多元市场(二元、三元及以上) + * + * @param tokenId token ID(通过 marketId 和 outcomeIndex 计算得出) + * @return 最新价信息(bestBid 和 bestAsk),如果获取失败则返回错误 + */ + suspend fun getLatestPrice(tokenId: String): Result { + return try { + val orderbookResult = getOrderbookByTokenId(tokenId) + + if (!orderbookResult.isSuccess) { + val error = orderbookResult.exceptionOrNull() + return Result.failure(Exception("获取订单簿失败: ${error?.message ?: "未知错误"}")) + } + + val orderbook = orderbookResult.getOrNull() + if (orderbook == null) { + return Result.failure(IllegalStateException("订单表为空: tokenId=$tokenId")) + } + + // 获取 bestBid(最高买入价) + val bestBid = orderbook.bids.firstOrNull()?.price + val bestBidPrice = bestBid?.toSafeBigDecimal() + + // 获取 bestAsk(最低卖出价) + val bestAsk = orderbook.asks.firstOrNull()?.price + val bestAskPrice = bestAsk?.toSafeBigDecimal() + + // 验证价格范围 + if (bestBidPrice != null && (bestBidPrice < BigDecimal("0.01") || bestBidPrice > BigDecimal("0.99"))) { + logger.warn("订单表 bestBid 价格超出有效范围: $bestBid (tokenId=$tokenId)") + } + if (bestAskPrice != null && (bestAskPrice < BigDecimal("0.01") || bestAskPrice > BigDecimal("0.99"))) { + logger.warn("订单表 bestAsk 价格超出有效范围: $bestAsk (tokenId=$tokenId)") + } + + Result.success( + LatestPriceResponse( + tokenId = tokenId, + bestBid = bestBid, + bestAsk = bestAsk + ) + ) + } catch (e: Exception) { + logger.error("获取最新价异常: ${e.message}", e) + Result.failure(e) + } + } + + /** + * 从订单表获取最优价(用于市价单,带价格调整系数) + * 支持多元市场(二元、三元及以上) + * + * @param tokenId token ID(通过 marketId 和 outcomeIndex 计算得出) + * @param isSellOrder 是否为卖出订单(true: 卖单,需要 bestBid;false: 买单,需要 bestAsk) + * @param buyPriceAdjustment 买单价格调整系数(默认 +0.01) + * @param sellPriceAdjustment 卖单价格调整系数(默认 -0.02) + * @return 最优价格(已应用调整系数) + * @throws IllegalStateException 如果无法获取订单表或订单表为空 + */ + suspend fun getOptimalPrice( + tokenId: String, + isSellOrder: Boolean, + buyPriceAdjustment: BigDecimal = BigDecimal("0.01"), + sellPriceAdjustment: BigDecimal = BigDecimal("0.02") + ): String { + val orderbookResult = getOrderbookByTokenId(tokenId) + + if (!orderbookResult.isSuccess) { + val error = orderbookResult.exceptionOrNull() + val errorMsg = "获取订单表失败: ${error?.message ?: "未知错误"}" + logger.error(errorMsg) + throw IllegalStateException(errorMsg) + } + + val orderbook = orderbookResult.getOrNull() + if (orderbook == null) { + val errorMsg = "订单表为空: tokenId=$tokenId" + logger.error(errorMsg) + throw IllegalStateException(errorMsg) + } + + if (isSellOrder) { + // 市价卖单:需要 bestBid(最高买入价) + val bestBid = orderbook.bids.firstOrNull()?.price + if (bestBid == null) { + val errorMsg = "订单表 bids 为空: tokenId=$tokenId" + logger.error(errorMsg) + throw IllegalStateException(errorMsg) + } + + val bestBidPrice = bestBid.toSafeBigDecimal() + if (bestBidPrice < BigDecimal("0.01") || bestBidPrice > BigDecimal("0.99")) { + val errorMsg = "订单表 bestBid 价格超出有效范围: $bestBid (tokenId=$tokenId)" + logger.error(errorMsg) + throw IllegalStateException(errorMsg) + } + + // 应用价格调整系数:bestBid - sellPriceAdjustment(减价,确保能立即成交) + val adjustedPrice = bestBidPrice.subtract(sellPriceAdjustment) + val finalPrice = when { + adjustedPrice < BigDecimal("0.01") -> BigDecimal("0.01") + adjustedPrice > BigDecimal("0.99") -> BigDecimal("0.99") + else -> adjustedPrice + } + logger.debug("从订单表获取最优价(卖单): tokenId=$tokenId, bestBid=$bestBid, adjustedPrice=${finalPrice.toPlainString()}") + return finalPrice.toPlainString() + } else { + // 市价买单:需要 bestAsk(最低卖出价) + val bestAsk = orderbook.asks.firstOrNull()?.price + if (bestAsk == null) { + val errorMsg = "订单表 asks 为空: tokenId=$tokenId" + logger.error(errorMsg) + throw IllegalStateException(errorMsg) + } + + val bestAskPrice = bestAsk.toSafeBigDecimal() + if (bestAskPrice < BigDecimal("0.01") || bestAskPrice > BigDecimal("0.99")) { + val errorMsg = "订单表 bestAsk 价格超出有效范围: $bestAsk (tokenId=$tokenId)" + logger.error(errorMsg) + throw IllegalStateException(errorMsg) + } + + // 应用价格调整系数:bestAsk + buyPriceAdjustment(加价,确保能立即成交) + val adjustedPrice = bestAskPrice.add(buyPriceAdjustment) + val finalPrice = when { + adjustedPrice < BigDecimal("0.01") -> BigDecimal("0.01") + adjustedPrice > BigDecimal("0.99") -> BigDecimal("0.99") + else -> adjustedPrice + } + logger.debug("从订单表获取最优价(买单): tokenId=$tokenId, bestAsk=$bestAsk, adjustedPrice=${finalPrice.toPlainString()}") + return finalPrice.toPlainString() + } + } + /** * 获取价格信息 */ @@ -70,9 +226,21 @@ class PolymarketClobService( } /** - * 创建订单 + * 创建订单(已废弃,使用 createSignedOrder 代替) + * @deprecated 使用 createSignedOrder 代替,需要签名的订单对象 */ + @Deprecated("使用 createSignedOrder 代替") suspend fun createOrder(request: CreateOrderRequest): Result { + return Result.failure(UnsupportedOperationException("已废弃,请使用 createSignedOrder 方法")) + } + + /** + * 创建签名的订单 + * 注意:此方法需要完整的订单签名逻辑,当前为占位实现 + * TODO: 实现完整的订单签名逻辑(EIP-712 签名、金额计算等) + * 参考: clob-client/src/order-builder/helpers.ts + */ + suspend fun createSignedOrder(request: NewOrderRequest): Result { return try { val response = clobApi.createOrder(request) if (response.isSuccessful && response.body() != null) { diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/Eip712Encoder.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/Eip712Encoder.kt index 840e111..87fa30c 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/Eip712Encoder.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/Eip712Encoder.kt @@ -165,5 +165,118 @@ object Eip712Encoder { return keccak256(encoded) } + + /** + * 编码 ExchangeOrder 域分隔符 + * 参考: @polymarket/order-utils 的 ExchangeOrderBuilder + * Domain: { name: "Polymarket CTF Exchange", version: "1", chainId: chainId, verifyingContract: exchangeContract } + */ + fun encodeExchangeDomain( + chainId: Long, + verifyingContract: String + ): ByteArray { + val domainTypeHash = encodeType( + "EIP712Domain", + listOf( + "name" to "string", + "version" to "string", + "chainId" to "uint256", + "verifyingContract" to "address" + ) + ) + + val nameHash = encodeString("Polymarket CTF Exchange") + val versionHash = encodeString("1") + val chainIdBytes = encodeUint256(BigInteger.valueOf(chainId)) + val contractBytes = encodeAddress(verifyingContract) + + val encoded = ByteArray(32 + 32 + 32 + 32 + 32) + System.arraycopy(domainTypeHash, 0, encoded, 0, 32) + System.arraycopy(nameHash, 0, encoded, 32, 32) + System.arraycopy(versionHash, 0, encoded, 64, 32) + System.arraycopy(chainIdBytes, 0, encoded, 96, 32) + System.arraycopy(contractBytes, 0, encoded, 128, 32) + + return keccak256(encoded) + } + + /** + * 编码 ExchangeOrder 消息哈希 + * 参考: @polymarket/order-utils 的 ExchangeOrderBuilder + * Order: { salt, maker, signer, taker, tokenId, makerAmount, takerAmount, expiration, nonce, feeRateBps, side, signatureType } + */ + fun encodeExchangeOrder( + salt: Long, + maker: String, + signer: String, + taker: String, + tokenId: String, + makerAmount: String, + takerAmount: String, + expiration: String, + nonce: String, + feeRateBps: String, + side: String, + signatureType: Int + ): ByteArray { + val orderTypeHash = encodeType( + "Order", + listOf( + "salt" to "uint256", + "maker" to "address", + "signer" to "address", + "taker" to "address", + "tokenId" to "uint256", + "makerAmount" to "uint256", + "takerAmount" to "uint256", + "expiration" to "uint256", + "nonce" to "uint256", + "feeRateBps" to "uint256", + "side" to "uint8", + "signatureType" to "uint8" + ) + ) + + // 编码订单字段 + val saltBytes = encodeUint256(BigInteger.valueOf(salt)) + val makerBytes = encodeAddress(maker) + val signerBytes = encodeAddress(signer) + val takerBytes = encodeAddress(taker) + val tokenIdBytes = encodeUint256(BigInteger(tokenId)) + val makerAmountBytes = encodeUint256(BigInteger(makerAmount)) + val takerAmountBytes = encodeUint256(BigInteger(takerAmount)) + val expirationBytes = encodeUint256(BigInteger(expiration)) + val nonceBytes = encodeUint256(BigInteger(nonce)) + val feeRateBpsBytes = encodeUint256(BigInteger(feeRateBps)) + + // side: BUY = 0, SELL = 1 (uint8,但需要编码为 32 字节) + val sideValue = when (side.uppercase()) { + "BUY" -> 0 + "SELL" -> 1 + else -> throw IllegalArgumentException("side 必须是 BUY 或 SELL") + } + // uint8 类型,但 EIP-712 编码时仍需要 32 字节 + val sideBytes = encodeUint256(BigInteger.valueOf(sideValue.toLong())) + val signatureTypeBytes = encodeUint256(BigInteger.valueOf(signatureType.toLong())) + + // 组合所有字段 + val encoded = ByteArray(32 * 13) // 13 个字段,每个 32 字节 + var offset = 0 + System.arraycopy(orderTypeHash, 0, encoded, offset, 32); offset += 32 + System.arraycopy(saltBytes, 0, encoded, offset, 32); offset += 32 + System.arraycopy(makerBytes, 0, encoded, offset, 32); offset += 32 + System.arraycopy(signerBytes, 0, encoded, offset, 32); offset += 32 + System.arraycopy(takerBytes, 0, encoded, offset, 32); offset += 32 + System.arraycopy(tokenIdBytes, 0, encoded, offset, 32); offset += 32 + System.arraycopy(makerAmountBytes, 0, encoded, offset, 32); offset += 32 + System.arraycopy(takerAmountBytes, 0, encoded, offset, 32); offset += 32 + System.arraycopy(expirationBytes, 0, encoded, offset, 32); offset += 32 + System.arraycopy(nonceBytes, 0, encoded, offset, 32); offset += 32 + System.arraycopy(feeRateBpsBytes, 0, encoded, offset, 32); offset += 32 + System.arraycopy(sideBytes, 0, encoded, offset, 32); offset += 32 + System.arraycopy(signatureTypeBytes, 0, encoded, offset, 32) + + return keccak256(encoded) + } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/EthereumUtils.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/EthereumUtils.kt index ff7a039..51f3d35 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/EthereumUtils.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/EthereumUtils.kt @@ -9,6 +9,10 @@ import java.math.BigInteger */ object EthereumUtils { + // Polymarket 合约地址(Polygon 主网) + private val COLLATERAL_TOKEN_ADDRESS = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174" // USDC + private val CONDITIONAL_TOKENS_ADDRESS = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045" // ConditionalTokens + /** * 计算函数选择器(前4个字节) * @param functionSignature 函数签名,例如 "computeProxyAddress(address)" @@ -29,6 +33,28 @@ object EthereumUtils { return cleanAddress.padStart(64, '0') } + /** + * 编码 uint256 参数 + * @param value 数值 + * @return 编码后的值,64个十六进制字符 + */ + fun encodeUint256(value: BigInteger): String { + return value.toString(16).padStart(64, '0') + } + + /** + * 编码 bytes32 参数 + * @param value 32字节的十六进制字符串(带或不带0x前缀) + * @return 编码后的值,64个十六进制字符 + */ + fun encodeBytes32(value: String): String { + val cleanValue = value.removePrefix("0x") + if (cleanValue.length != 64) { + throw IllegalArgumentException("bytes32 值必须是64个十六进制字符") + } + return cleanValue.lowercase() + } + /** * 从合约调用结果中解析地址 * @param hexResult 十六进制结果 @@ -41,6 +67,16 @@ object EthereumUtils { return "0x$addressHex" } + /** + * 从合约调用结果中解析 uint256 + * @param hexResult 十六进制结果 + * @return BigInteger 值 + */ + fun decodeUint256(hexResult: String): BigInteger { + val cleanHex = hexResult.removePrefix("0x") + return BigInteger(cleanHex, 16) + } + /** * 计算 Keccak-256 哈希(Ethereum 标准) * 使用 BouncyCastle 库实现真正的 Keccak-256 diff --git a/docs/ternary-market-orderbook.md b/docs/ternary-market-orderbook.md new file mode 100644 index 0000000..1395fa1 --- /dev/null +++ b/docs/ternary-market-orderbook.md @@ -0,0 +1,213 @@ +# 三元市场订单簿实现说明 + +## 概述 + +Polymarket 的三元市场(Ternary Market)是指具有三个或更多可能结果的市场。与二元市场(YES/NO)不同,三元市场需要为每个 outcome 维护独立的订单簿。 + +## 核心概念 + +### 1. TokenId 与 Outcome 的关系 + +在 Polymarket 中,每个 outcome 都有唯一的 `tokenId`: + +- **二元市场**: + - YES (outcomeIndex = 0) → tokenId_0 + - NO (outcomeIndex = 1) → tokenId_1 + +- **三元市场**: + - Outcome A (outcomeIndex = 0) → tokenId_0 + - Outcome B (outcomeIndex = 1) → tokenId_1 + - Outcome C (outcomeIndex = 2) → tokenId_2 + +- **多元市场**(N 个结果): + - Outcome 0 → tokenId_0 + - Outcome 1 → tokenId_1 + - ... + - Outcome N-1 → tokenId_N-1 + +### 2. TokenId 的计算方式 + +`tokenId` 通过以下步骤计算: + +```kotlin +// 1. 计算 indexSet:indexSet = 2^outcomeIndex +val indexSet = BigInteger.TWO.pow(outcomeIndex) + +// 2. 调用链上合约 getCollectionId(EMPTY_SET, conditionId, indexSet) +val collectionId = getCollectionId(EMPTY_SET, conditionId, indexSet) + +// 3. 调用链上合约 getPositionId(collateralToken, collectionId) +val tokenId = getPositionId(collateralToken, collectionId) +``` + +**示例**: +- outcomeIndex = 0 → indexSet = 1 (2^0) +- outcomeIndex = 1 → indexSet = 2 (2^1) +- outcomeIndex = 2 → indexSet = 4 (2^2) +- outcomeIndex = 3 → indexSet = 8 (2^3) + +## 订单簿结构 + +### API 接口 + +Polymarket CLOB API 提供 `/book` 接口获取订单簿: + +```kotlin +@GET("/book") +suspend fun getOrderbook( + @Query("token_id") tokenId: String? = null, + @Query("market") market: String? = null +): Response +``` + +### 订单簿响应结构 + +```kotlin +data class OrderbookResponse( + val bids: List, // 买入订单列表(按价格从高到低排序) + val asks: List // 卖出订单列表(按价格从低到高排序) +) + +data class OrderbookEntry( + val price: String, // 价格(0.01 - 0.99) + val size: String // 数量(shares) +) +``` + +### 订单簿排序规则 + +1. **Bids(买入订单)**: + - 按价格从高到低排序 + - 第一个元素是 `bestBid`(最高买入价) + +2. **Asks(卖出订单)**: + - 按价格从低到高排序 + - 第一个元素是 `bestAsk`(最低卖出价) + +## 三元市场订单簿实现 + +### 1. 获取特定 Outcome 的订单簿 + +对于三元市场,需要为每个 outcome 单独获取订单簿: + +```kotlin +// 示例:三元市场 "谁会赢得选举?" +// - Outcome 0: "候选人A" +// - Outcome 1: "候选人B" +// - Outcome 2: "候选人C" + +// 获取 Outcome 0 的订单簿 +val tokenId0 = blockchainService.getTokenId(conditionId, 0) +val orderbook0 = clobService.getOrderbookByTokenId(tokenId0) +// orderbook0.bids[0].price 是 Outcome 0 的 bestBid +// orderbook0.asks[0].price 是 Outcome 0 的 bestAsk + +// 获取 Outcome 1 的订单簿 +val tokenId1 = blockchainService.getTokenId(conditionId, 1) +val orderbook1 = clobService.getOrderbookByTokenId(tokenId1) + +// 获取 Outcome 2 的订单簿 +val tokenId2 = blockchainService.getTokenId(conditionId, 2) +val orderbook2 = clobService.getOrderbookByTokenId(tokenId2) +``` + +### 2. 市价单价格获取 + +在 `AccountService.getOptimalPriceFromOrderbook` 方法中: + +```kotlin +private suspend fun getOptimalPriceFromOrderbook(tokenId: String, isSellOrder: Boolean): String { + // 通过 tokenId 获取特定 outcome 的订单簿 + val orderbookResult = clobService.getOrderbookByTokenId(tokenId) + + if (orderbookResult.isSuccess) { + val orderbook = orderbookResult.getOrNull() + if (orderbook != null) { + if (isSellOrder) { + // 市价卖单:需要 bestBid(最高买入价) + val bestBid = orderbook.bids.firstOrNull()?.price + // 返回 bestBid 或后备价格 + } else { + // 市价买单:需要 bestAsk(最低卖出价) + val bestAsk = orderbook.asks.firstOrNull()?.price + // 返回 bestAsk 或后备价格 + } + } + } + + // 如果获取失败,返回后备价格 + return fallbackPrice +} +``` + +### 3. 完整流程示例 + +```kotlin +// 1. 用户请求卖出 Outcome 2 的仓位 +val request = PositionSellRequest( + accountId = 1, + marketId = "0x123...", // conditionId + side = "候选人C", + outcomeIndex = 2, // 关键:指定 outcome 索引 + orderType = "MARKET", + quantity = "100" +) + +// 2. 计算 tokenId +val tokenId = blockchainService.getTokenId(request.marketId, request.outcomeIndex) +// tokenId = "87660119269436753918591605029528224889066452434179554814663664703244066132110" + +// 3. 获取订单簿并提取最优价 +val optimalPrice = getOptimalPriceFromOrderbook(tokenId, isSellOrder = true) +// 从 orderbook.bids[0].price 获取 bestBid + +// 4. 创建并提交订单 +val signedOrder = orderSigningService.createAndSignOrder( + tokenId = tokenId, + side = "SELL", + price = optimalPrice, + size = request.quantity +) +``` + +## 与二元市场的区别 + +### 二元市场(YES/NO) + +- 只有 2 个 outcome(outcomeIndex = 0, 1) +- 可以通过 `market` 参数获取整个市场的订单簿 +- Gamma API 提供 `bestBid` 和 `bestAsk`(但可能只针对主要 outcome) + +### 三元及以上市场 + +- 有 3 个或更多 outcome(outcomeIndex = 0, 1, 2, ...) +- **必须**通过 `tokenId` 参数获取特定 outcome 的订单簿 +- 每个 outcome 都有独立的订单簿 +- 需要明确指定 `outcomeIndex` 来计算 `tokenId` + +## 注意事项 + +1. **必须提供 outcomeIndex**: + - 三元及以上市场无法通过 `side` 字符串推断 `outcomeIndex` + - 必须明确提供 `outcomeIndex` 参数 + +2. **每个 Outcome 独立订单簿**: + - 不同 outcome 的订单簿是独立的 + - 不能通过 `market` 参数获取所有 outcome 的订单簿 + +3. **价格范围**: + - 所有 outcome 的价格都在 0.01 - 0.99 范围内 + - 所有 outcome 的价格之和应该接近 1.0(考虑套利机会) + +4. **后备价格机制**: + - 如果无法获取订单簿,使用后备价格: + - 市价卖单:0.06 + - 市价买单:1.0 + +## 代码位置 + +- **TokenId 计算**:`BlockchainService.getTokenId()` +- **订单簿获取**:`PolymarketClobService.getOrderbookByTokenId()` +- **最优价获取**:`AccountService.getOptimalPriceFromOrderbook()` +- **订单创建**:`AccountService.sellPosition()` + diff --git a/frontend/src/pages/PositionList.tsx b/frontend/src/pages/PositionList.tsx index a8f91d1..3b4753e 100644 --- a/frontend/src/pages/PositionList.tsx +++ b/frontend/src/pages/PositionList.tsx @@ -252,7 +252,7 @@ const PositionList: React.FC = () => { // 加载市场价格 try { - const response = await apiService.accounts.getMarketPrice({ marketId: position.marketId }) + const response = await apiService.markets.getMarketPrice({ marketId: position.marketId }) if (response.data.code === 0 && response.data.data) { setMarketPrice(response.data.data) // 默认使用最优买价作为限价 @@ -324,7 +324,8 @@ const PositionList: React.FC = () => { const request: PositionSellRequest = { accountId: selectedPosition.accountId, marketId: selectedPosition.marketId, - side: selectedPosition.side as 'YES' | 'NO', + side: selectedPosition.side, + outcomeIndex: selectedPosition.outcomeIndex, // 传递 outcomeIndex orderType: orderType, quantity: sellQuantity, price: orderType === 'LIMIT' ? limitPrice : undefined @@ -791,70 +792,70 @@ const PositionList: React.FC = () => { // 只有当前仓位才显示盈亏和已实现盈亏列 if (positionFilter === 'current') { - baseColumns.push( - { - title: '盈亏', - dataIndex: 'pnl', - key: 'pnl', - render: (pnl: string, record: AccountPosition) => { - const pnlNum = parseFloat(pnl || '0') - const percentPnl = parseFloat(record.percentPnl || '0') - return ( -
-
= 0 ? '#3f8600' : '#cf1322', - fontWeight: 'bold' - }}> - {pnlNum >= 0 ? '+' : ''}{formatNumber(pnl, 2)} USDC -
+ baseColumns.push( + { + title: '盈亏', + dataIndex: 'pnl', + key: 'pnl', + render: (pnl: string, record: AccountPosition) => { + const pnlNum = parseFloat(pnl || '0') + const percentPnl = parseFloat(record.percentPnl || '0') + return ( +
+
= 0 ? '#3f8600' : '#cf1322', + fontWeight: 'bold' + }}> + {pnlNum >= 0 ? '+' : ''}{formatNumber(pnl, 2)} USDC +
+
= 0 ? '#3f8600' : '#cf1322' + }}> + {formatPercent(record.percentPnl)} +
+
+ ) + }, + align: 'right' as const, + width: 150, + sorter: (a: AccountPosition, b: AccountPosition) => { + const pnlA = parseFloat(a.pnl || '0') + const pnlB = parseFloat(b.pnl || '0') + return pnlA - pnlB + } + }, + { + title: '已实现盈亏', + dataIndex: 'realizedPnl', + key: 'realizedPnl', + render: (realizedPnl: string | undefined, record: AccountPosition) => { + if (!realizedPnl) return '-' + const pnlNum = parseFloat(realizedPnl) + const percentPnl = parseFloat(record.percentRealizedPnl || '0') + return ( +
+
= 0 ? '#3f8600' : '#cf1322', + fontWeight: 'bold' + }}> + {pnlNum >= 0 ? '+' : ''}{formatNumber(realizedPnl, 2)} USDC +
+ {record.percentRealizedPnl && (
= 0 ? '#3f8600' : '#cf1322' }}> - {formatPercent(record.percentPnl)} + {formatPercent(record.percentRealizedPnl)}
-
- ) - }, - align: 'right' as const, - width: 150, - sorter: (a: AccountPosition, b: AccountPosition) => { - const pnlA = parseFloat(a.pnl || '0') - const pnlB = parseFloat(b.pnl || '0') - return pnlA - pnlB - } + )} +
+ ) }, - { - title: '已实现盈亏', - dataIndex: 'realizedPnl', - key: 'realizedPnl', - render: (realizedPnl: string | undefined, record: AccountPosition) => { - if (!realizedPnl) return '-' - const pnlNum = parseFloat(realizedPnl) - const percentPnl = parseFloat(record.percentRealizedPnl || '0') - return ( -
-
= 0 ? '#3f8600' : '#cf1322', - fontWeight: 'bold' - }}> - {pnlNum >= 0 ? '+' : ''}{formatNumber(realizedPnl, 2)} USDC -
- {record.percentRealizedPnl && ( -
= 0 ? '#3f8600' : '#cf1322' - }}> - {formatPercent(record.percentRealizedPnl)} -
- )} -
- ) - }, - align: 'right' as const, - width: 150 - } - ) + align: 'right' as const, + width: 150 + } + ) } // 只有当前仓位才显示操作列 @@ -910,7 +911,7 @@ const PositionList: React.FC = () => {
-

仓位管理

+

仓位管理

{/* WebSocket 连接状态指示器 */} { return nameA.localeCompare(nameB, 'zh-CN') }) .map(account => ({ - value: account.id, - label: account.accountName || `账户 ${account.id}` - })) + value: account.id, + label: account.accountName || `账户 ${account.id}` + })) ]} />
{ display: 'inline-flex', gap: '4px' }}> - setPositionFilter(e.target.value)} - size={isMobile ? 'small' : 'middle'} + setPositionFilter(e.target.value)} + size={isMobile ? 'small' : 'middle'} style={{ display: 'flex', gap: '4px' }} > { {currentCount} - + { {historicalCount} - - + +
@@ -1082,20 +1083,20 @@ const PositionList: React.FC = () => { )} ) : ( - - `${record.accountId}-${record.marketId}-${index}`} - loading={loading} - pagination={{ - pageSize: 20, - showSizeChanger: !isMobile, - showTotal: (total) => `共 ${total} 个仓位${searchKeyword ? `(已过滤)` : ''}` - }} - scroll={isMobile ? { x: 1500 } : undefined} - /> - + +
`${record.accountId}-${record.marketId}-${index}`} + loading={loading} + pagination={{ + pageSize: 20, + showSizeChanger: !isMobile, + showTotal: (total) => `共 ${total} 个仓位${searchKeyword ? `(已过滤)` : ''}` + }} + scroll={isMobile ? { x: 1500 } : undefined} + /> + )} {/* 出售模态框 */} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 2792ac3..d02615e 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -105,11 +105,23 @@ export const apiService = { sellPosition: (data: any) => apiClient.post>('/copy-trading/accounts/positions/sell', data), + }, + + /** + * 市场数据 API + */ + markets: { /** - * 获取市场价格 + * 获取市场价格(通过 Gamma API) */ - getMarketPrice: (data: any) => - apiClient.post>('/copy-trading/accounts/markets/price', data) + getMarketPrice: (data: { marketId: string }) => + apiClient.post>('/copy-trading/markets/price', data), + + /** + * 获取最新价(从订单表获取,供前端下单时显示) + */ + getLatestPrice: (data: { tokenId: string }) => + apiClient.post>('/copy-trading/markets/latest-price', data) }, /** diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 0b06504..77be423 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -163,7 +163,8 @@ export interface AccountPosition { marketTitle?: string marketSlug?: string marketIcon?: string // 市场图标 URL - side: string // YES 或 NO + side: string // 结果名称(如 "YES", "NO", "Pakistan" 等) + outcomeIndex?: number // 结果索引(0, 1, 2...),用于计算 tokenId quantity: string avgPrice: string currentPrice: string @@ -193,7 +194,8 @@ export interface PositionListResponse { export interface PositionSellRequest { accountId: number marketId: string - side: 'YES' | 'NO' + side: string // 结果名称(如 "YES", "NO", "Pakistan" 等) + outcomeIndex?: number // 结果索引(0, 1, 2...),用于计算 tokenId(推荐提供) orderType: 'MARKET' | 'LIMIT' quantity: string price?: string // 限价订单必需 diff --git a/polymarket-demo/src/createOrder.ts b/polymarket-demo/src/createOrder.ts index b2c490f..aeefd86 100644 --- a/polymarket-demo/src/createOrder.ts +++ b/polymarket-demo/src/createOrder.ts @@ -7,11 +7,11 @@ import { Wallet } from "@ethersproject/wallet"; const host = 'https://clob.polymarket.com'; const funder = ''; //This is the address listed below your profile picture when using the Polymarket site. -const signer = new Wallet("[PRIVATE_KEY_REMOVED]"); //This is your Private Key. If using email login export from https://reveal.magic.link/polymarket otherwise export from your Web3 Application +const signer = new Wallet(process.env.PRIVATE_KEY || ""); //This is your Private Key. If using email login export from https://reveal.magic.link/polymarket otherwise export from your Web3 Application //In general don't create a new API key, always derive or createOrDerive -const creds = new ClobClient(host, 137, signer).createOrDeriveApiKey(); +const creds = new ClobClient(host, 137, signer).deleteApiKey(); //1: Magic/Email Login //2: Browser Wallet(Metamask, Coinbase Wallet, etc) @@ -22,8 +22,8 @@ const signatureType = 1; const clobClient = new ClobClient(host, 137, signer, await creds, signatureType, funder); const resp2 = await clobClient.createAndPostOrder( { - tokenID: "114304586861386186441621124384163963092522056897081085884483958561365015034812", //Use https://docs.polymarket.com/developers/gamma-markets-api/get-markets to grab a sample token - price: 0.01, + tokenID: "87660119269436753918591605029528224889066452434179554814663664703244066132110", //Use https://docs.polymarket.com/developers/gamma-markets-api/get-markets to grab a sample token + price: 0.37, side: Side.BUY, size: 5, feeRateBps: 0,