refactor: 重构市场数据接口和移除敏感信息
- 创建 MarketController,将市场相关接口从 AccountController 移出 - 添加 /api/copy-trading/markets/latest-price 接口供前端获取最新价 - 在 PolymarketClobService 中封装获取订单表和最优价的逻辑 - 支持多元市场(二元、三元及以上)的最优价获取 - 市价单价格添加调整系数(买单+0.01,卖单-0.02) - 移除所有测试文件中的真实私钥和API凭证 - 更新前端API服务,添加markets相关接口
This commit is contained in:
@@ -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<OrderResponse>
|
||||
@Body request: NewOrderRequest
|
||||
): Response<NewOrderResponse>
|
||||
|
||||
/**
|
||||
* 批量创建订单
|
||||
@@ -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<String>? = 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<CreateOrderRequest>
|
||||
)
|
||||
@@ -286,3 +345,12 @@ data class ServerTimeResponse(
|
||||
val timestamp: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* 最新价响应(从订单表获取)
|
||||
*/
|
||||
data class LatestPriceResponse(
|
||||
val tokenId: String,
|
||||
val bestBid: String?, // 最高买入价
|
||||
val bestAsk: String? // 最低卖出价
|
||||
)
|
||||
|
||||
|
||||
@@ -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<ApiResponse<MarketPriceResponse>> {
|
||||
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}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ApiResponse<MarketPriceResponse>> {
|
||||
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<ApiResponse<LatestPriceResponse>> {
|
||||
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}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 计算得出)
|
||||
)
|
||||
|
||||
/**
|
||||
* 市场价格响应
|
||||
*/
|
||||
|
||||
@@ -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<String>(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 参数
|
||||
|
||||
@@ -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<String> {
|
||||
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<String> {
|
||||
val outcomeIndex = when (side.uppercase()) {
|
||||
"YES" -> 0
|
||||
"NO" -> 1
|
||||
else -> return Result.failure(IllegalArgumentException("side 必须是 YES 或 NO(仅支持二元市场)"))
|
||||
}
|
||||
return getTokenId(conditionId, outcomeIndex)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户仓位总价值
|
||||
* 通过 Polymarket Data API 查询
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<OrderbookResponse> {
|
||||
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<LatestPriceResponse> {
|
||||
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<OrderResponse> {
|
||||
return Result.failure(UnsupportedOperationException("已废弃,请使用 createSignedOrder 方法"))
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建签名的订单
|
||||
* 注意:此方法需要完整的订单签名逻辑,当前为占位实现
|
||||
* TODO: 实现完整的订单签名逻辑(EIP-712 签名、金额计算等)
|
||||
* 参考: clob-client/src/order-builder/helpers.ts
|
||||
*/
|
||||
suspend fun createSignedOrder(request: NewOrderRequest): Result<NewOrderResponse> {
|
||||
return try {
|
||||
val response = clobApi.createOrder(request)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<OrderbookResponse>
|
||||
```
|
||||
|
||||
### 订单簿响应结构
|
||||
|
||||
```kotlin
|
||||
data class OrderbookResponse(
|
||||
val bids: List<OrderbookEntry>, // 买入订单列表(按价格从高到低排序)
|
||||
val asks: List<OrderbookEntry> // 卖出订单列表(按价格从低到高排序)
|
||||
)
|
||||
|
||||
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()`
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
<div style={{
|
||||
color: pnlNum >= 0 ? '#3f8600' : '#cf1322',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{pnlNum >= 0 ? '+' : ''}{formatNumber(pnl, 2)} USDC
|
||||
</div>
|
||||
baseColumns.push(
|
||||
{
|
||||
title: '盈亏',
|
||||
dataIndex: 'pnl',
|
||||
key: 'pnl',
|
||||
render: (pnl: string, record: AccountPosition) => {
|
||||
const pnlNum = parseFloat(pnl || '0')
|
||||
const percentPnl = parseFloat(record.percentPnl || '0')
|
||||
return (
|
||||
<div>
|
||||
<div style={{
|
||||
color: pnlNum >= 0 ? '#3f8600' : '#cf1322',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{pnlNum >= 0 ? '+' : ''}{formatNumber(pnl, 2)} USDC
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: percentPnl >= 0 ? '#3f8600' : '#cf1322'
|
||||
}}>
|
||||
{formatPercent(record.percentPnl)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
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 (
|
||||
<div>
|
||||
<div style={{
|
||||
color: pnlNum >= 0 ? '#3f8600' : '#cf1322',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{pnlNum >= 0 ? '+' : ''}{formatNumber(realizedPnl, 2)} USDC
|
||||
</div>
|
||||
{record.percentRealizedPnl && (
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: percentPnl >= 0 ? '#3f8600' : '#cf1322'
|
||||
}}>
|
||||
{formatPercent(record.percentPnl)}
|
||||
{formatPercent(record.percentRealizedPnl)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
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
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<div>
|
||||
<div style={{
|
||||
color: pnlNum >= 0 ? '#3f8600' : '#cf1322',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{pnlNum >= 0 ? '+' : ''}{formatNumber(realizedPnl, 2)} USDC
|
||||
</div>
|
||||
{record.percentRealizedPnl && (
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: percentPnl >= 0 ? '#3f8600' : '#cf1322'
|
||||
}}>
|
||||
{formatPercent(record.percentRealizedPnl)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
align: 'right' as const,
|
||||
width: 150
|
||||
}
|
||||
)
|
||||
align: 'right' as const,
|
||||
width: 150
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 只有当前仓位才显示操作列
|
||||
@@ -910,7 +911,7 @@ const PositionList: React.FC = () => {
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '12px', marginBottom: '12px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<h2 style={{ margin: 0 }}>仓位管理</h2>
|
||||
<h2 style={{ margin: 0 }}>仓位管理</h2>
|
||||
{/* WebSocket 连接状态指示器 */}
|
||||
<Tag
|
||||
color={wsConnected ? 'green' : 'orange'}
|
||||
@@ -976,9 +977,9 @@ const PositionList: React.FC = () => {
|
||||
return nameA.localeCompare(nameB, 'zh-CN')
|
||||
})
|
||||
.map(account => ({
|
||||
value: account.id,
|
||||
label: account.accountName || `账户 ${account.id}`
|
||||
}))
|
||||
value: account.id,
|
||||
label: account.accountName || `账户 ${account.id}`
|
||||
}))
|
||||
]}
|
||||
/>
|
||||
<div style={{
|
||||
@@ -988,10 +989,10 @@ const PositionList: React.FC = () => {
|
||||
display: 'inline-flex',
|
||||
gap: '4px'
|
||||
}}>
|
||||
<Radio.Group
|
||||
value={positionFilter}
|
||||
onChange={(e) => setPositionFilter(e.target.value)}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
<Radio.Group
|
||||
value={positionFilter}
|
||||
onChange={(e) => setPositionFilter(e.target.value)}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
style={{ display: 'flex', gap: '4px' }}
|
||||
>
|
||||
<Radio.Button
|
||||
@@ -1027,7 +1028,7 @@ const PositionList: React.FC = () => {
|
||||
{currentCount}
|
||||
</Tag>
|
||||
</span>
|
||||
</Radio.Button>
|
||||
</Radio.Button>
|
||||
<Radio.Button
|
||||
value="historical"
|
||||
style={{
|
||||
@@ -1061,8 +1062,8 @@ const PositionList: React.FC = () => {
|
||||
{historicalCount}
|
||||
</Tag>
|
||||
</span>
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1082,20 +1083,20 @@ const PositionList: React.FC = () => {
|
||||
)}
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<Table
|
||||
dataSource={filteredPositions}
|
||||
columns={columns}
|
||||
rowKey={(record, index) => `${record.accountId}-${record.marketId}-${index}`}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
pageSize: 20,
|
||||
showSizeChanger: !isMobile,
|
||||
showTotal: (total) => `共 ${total} 个仓位${searchKeyword ? `(已过滤)` : ''}`
|
||||
}}
|
||||
scroll={isMobile ? { x: 1500 } : undefined}
|
||||
/>
|
||||
</Card>
|
||||
<Card>
|
||||
<Table
|
||||
dataSource={filteredPositions}
|
||||
columns={columns}
|
||||
rowKey={(record, index) => `${record.accountId}-${record.marketId}-${index}`}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
pageSize: 20,
|
||||
showSizeChanger: !isMobile,
|
||||
showTotal: (total) => `共 ${total} 个仓位${searchKeyword ? `(已过滤)` : ''}`
|
||||
}}
|
||||
scroll={isMobile ? { x: 1500 } : undefined}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 出售模态框 */}
|
||||
|
||||
@@ -105,11 +105,23 @@ export const apiService = {
|
||||
sellPosition: (data: any) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/positions/sell', data),
|
||||
|
||||
},
|
||||
|
||||
/**
|
||||
* 市场数据 API
|
||||
*/
|
||||
markets: {
|
||||
/**
|
||||
* 获取市场价格
|
||||
* 获取市场价格(通过 Gamma API)
|
||||
*/
|
||||
getMarketPrice: (data: any) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/markets/price', data)
|
||||
getMarketPrice: (data: { marketId: string }) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/markets/price', data),
|
||||
|
||||
/**
|
||||
* 获取最新价(从订单表获取,供前端下单时显示)
|
||||
*/
|
||||
getLatestPrice: (data: { tokenId: string }) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/markets/latest-price', data)
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 // 限价订单必需
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user