refactor: 重构市场数据接口和移除敏感信息

- 创建 MarketController,将市场相关接口从 AccountController 移出
- 添加 /api/copy-trading/markets/latest-price 接口供前端获取最新价
- 在 PolymarketClobService 中封装获取订单表和最优价的逻辑
- 支持多元市场(二元、三元及以上)的最优价获取
- 市价单价格添加调整系数(买单+0.01,卖单-0.02)
- 移除所有测试文件中的真实私钥和API凭证
- 更新前端API服务,添加markets相关接口
This commit is contained in:
WrBug
2025-11-29 11:08:01 +08:00
parent b4fb16af9e
commit 4e89f53c24
15 changed files with 1400 additions and 166 deletions
@@ -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: 卖单,需要 bestBidfalse: 买单,需要 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 ID16进制字符串,如 "0x..."
* @param outcomeIndex 结果索引(0, 1, 2...
* @return tokenIdBigInteger 的字符串表示)
*/
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"))
}
// 计算 indexSetindexSet = 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 ID16进制字符串,如 "0x..."
* @param side YES 或 NO
* @return tokenIdBigInteger 的字符串表示)
*/
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)
}
}
// 转换为 wei6 位小数)
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)
}
}
// 转换为 wei6 位小数)
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: 卖单,需要 bestBidfalse: 买单,需要 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
+213
View File
@@ -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. 计算 indexSetindexSet = 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 个 outcomeoutcomeIndex = 0, 1
- 可以通过 `market` 参数获取整个市场的订单簿
- Gamma API 提供 `bestBid``bestAsk`(但可能只针对主要 outcome
### 三元及以上市场
- 有 3 个或更多 outcomeoutcomeIndex = 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()`
+86 -85
View File
@@ -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>
)}
{/* 出售模态框 */}
+15 -3
View File
@@ -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)
},
/**
+4 -2
View File
@@ -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 // 限价订单必需
+4 -4
View File
@@ -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,