feat: 实现订单推送服务和 WebSocket 重连机制

- 实现订单推送服务,支持多账户订单实时推送
- 添加 WebSocket 自动重连机制,支持指数退避策略
- 修复订单详情接口 L2 认证问题,通过 PolymarketClobService 获取
- 配置 Gson lenient 模式,支持解析格式不严格的 JSON
- 添加响应日志拦截器,便于调试 API 响应问题
- 使用 Gamma API 获取市场信息,支持通过 condition_ids 查询
- 修复字段映射问题,使用 @SerializedName 替代 @JsonProperty
- 订单推送消息包含订单详情和市场信息
This commit is contained in:
WrBug
2025-11-27 14:51:46 +08:00
parent 47357995af
commit 5b458b9b0c
21 changed files with 1765 additions and 66 deletions
+183
View File
@@ -0,0 +1,183 @@
# 订单推送功能测试指南
## 测试准备
### 1. 确保有测试账户
- 账户必须已导入到系统
- 账户必须有有效的 API Key、Secret 和 Passphrase
- 可以通过 `/api/accounts/import` 接口导入账户
### 2. 启动后端服务
```bash
cd backend
./gradlew bootRun
```
### 3. 检查日志
启动后,应该看到以下日志:
- `订单推送服务已初始化`
- `已为账户 X (账户名) 建立 User Channel 连接并发送订阅消息`
## 测试步骤
### 测试 1: 检查服务初始化
1. 启动后端服务
2. 查看日志,确认 `OrderPushService` 已初始化
3. 确认所有有 API 凭证的账户都已建立连接
**预期结果**:
- 日志显示:`订单推送服务已初始化`
- 对于每个有 API 凭证的账户,日志显示:`已为账户 X 建立 User Channel 连接并发送订阅消息`
### 测试 2: 通过 WebSocket 订阅订单推送
#### 2.1 使用 WebSocket 客户端连接
```bash
# 使用 wscat 或其他 WebSocket 客户端
wscat -c ws://localhost:8000/ws
```
#### 2.2 发送订阅消息
```json
{
"type": 1,
"channel": "order",
"payload": {
"accountId": 1
}
}
```
**预期结果**:
- 收到订阅确认消息:
```json
{
"type": 4,
"channel": "order",
"status": 0,
"message": null
}
```
#### 2.3 等待订单消息
当账户有订单活动时(下单、更新、取消),应该收到推送消息:
```json
{
"type": 3,
"channel": "order",
"payload": {
"accountId": 1,
"accountName": "测试账户",
"order": {
"assetId": "...",
"eventType": "order",
"id": "...",
"market": "...",
"type": "PLACEMENT",
"side": "BUY",
"price": "0.5",
"originalSize": "10",
"sizeMatched": "0",
...
},
"timestamp": 1234567890
},
"timestamp": 1234567890
}
```
### 测试 3: 测试订单类型
#### 3.1 PLACEMENT (下单)
- 在 Polymarket 上创建一个订单
- 应该收到 `type: "PLACEMENT"` 的订单消息
#### 3.2 UPDATE (订单更新)
- 订单部分成交时
- 应该收到 `type: "UPDATE"` 的订单消息,`sizeMatched` 字段会更新
#### 3.3 CANCELLATION (订单取消)
- 取消一个订单
- 应该收到 `type: "CANCELLATION"` 的订单消息
### 测试 4: 多账户支持
1. 导入多个账户(都有 API 凭证)
2. 订阅不同账户的订单推送
3. 验证每个账户的订单消息都能正确推送
### 测试 5: 连接重连
1. 断开网络连接
2. 恢复网络连接
3. 验证连接是否自动重连(需要实现重连逻辑)
### 测试 6: 代理支持
如果配置了代理:
```bash
export ENABLE_PROXY=1
export PROXY_HOST=127.0.0.1
export PROXY_PORT=8888
```
启动服务后,应该看到日志:
- `已配置 WebSocket 代理: 127.0.0.1:8888`
## 常见问题排查
### 问题 1: 连接建立失败
**可能原因**:
- Polymarket RTDS 服务不可用
- 网络连接问题
- 代理配置错误
**排查步骤**:
1. 检查 `polymarket.rtds.ws-url` 配置是否正确
2. 检查网络连接
3. 查看详细错误日志
### 问题 2: 订阅消息发送失败
**可能原因**:
- 连接未完全建立就发送消息
- API 凭证无效
**排查步骤**:
1. 检查日志中的连接状态
2. 验证 API 凭证是否正确
3. 增加连接建立的等待时间
### 问题 3: 收不到订单消息
**可能原因**:
- 账户没有订单活动
- 订阅消息格式错误
- API 凭证权限不足
**排查步骤**:
1. 在 Polymarket 上手动创建一个订单
2. 检查订阅消息格式是否正确
3. 验证 API Key 是否有订单查询权限
### 问题 4: 账户 ID 为 null
**可能原因**:
- 账户未正确保存到数据库
- 账户 ID 未正确传递
**排查步骤**:
1. 检查数据库中的账户记录
2. 验证订阅消息中的 accountId 是否正确
## 日志关键字
搜索以下关键字查看相关日志:
- `订单推送服务已初始化`
- `已为账户 X 建立 User Channel 连接`
- `已发送 User Channel 订阅消息`
- `处理订单消息失败`
- `推送订单消息失败`
## 下一步
测试通过后,可以:
1. 实现前端订阅和 Notification 显示
2. 添加连接重连逻辑
3. 优化错误处理
4. 添加单元测试
@@ -1,5 +1,6 @@
package com.wrbug.polymarketbot.api package com.wrbug.polymarketbot.api
import com.google.gson.annotations.SerializedName
import retrofit2.Response import retrofit2.Response
import retrofit2.http.* import retrofit2.http.*
@@ -59,11 +60,15 @@ interface PolymarketClobApi {
/** /**
* 获取订单信息 * 获取订单信息
* 文档: https://docs.polymarket.com/developers/CLOB/orders/get-order
* 端点: GET /data/order/{order_hash}
* 需要 L2 认证
* 注意:实际返回格式是直接返回 OpenOrder 对象,不是包装在 { "order": ... } 中
*/ */
@GET("/orders/{orderId}") @GET("/data/order/{orderId}")
suspend fun getOrder( suspend fun getOrder(
@Path("orderId") orderId: String @Path("orderId") orderId: String
): Response<OrderResponse> ): Response<OpenOrder>
/** /**
* 获取活跃订单 * 获取活跃订单
@@ -191,6 +196,36 @@ data class OrderResponse(
val createdAt: String // ISO 8601 格式字符串 val createdAt: String // ISO 8601 格式字符串
) )
/**
* OpenOrder 对象(根据实际 API 返回)
* 文档: https://docs.polymarket.com/developers/CLOB/orders/get-order
* 注意:实际返回格式是直接返回订单对象,不是包装在 { "order": ... } 中
*/
data class OpenOrder(
val id: String, // order id
val status: String, // order current status (LIVE, FILLED, CANCELLED, etc.)
val owner: String, // api key
@SerializedName("maker_address")
val makerAddress: String, // maker address (funder)
val market: String, // market id (condition id)
@SerializedName("asset_id")
val assetId: String, // token id
val side: String, // BUY or SELL
@SerializedName("original_size")
val originalSize: String, // original order size at placement
@SerializedName("size_matched")
val sizeMatched: String, // size of order that has been matched/filled
val price: String, // price
val outcome: String, // human readable outcome the order is for
val expiration: String, // unix timestamp when the order expired, 0 if it does not expire
@SerializedName("order_type")
val orderType: String, // order type (GTC, FOK, GTD)
@SerializedName("associate_trades")
val associateTrades: List<String>? = null, // any Trade id the order has been partially included in
@SerializedName("created_at")
val createdAt: Long // unix timestamp when the order was created
)
data class CancelOrderResponse( data class CancelOrderResponse(
val orderId: String, val orderId: String,
val status: String val status: String
@@ -0,0 +1,56 @@
package com.wrbug.polymarketbot.api
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Query
/**
* Polymarket Gamma API 接口定义
* 用于查询市场信息
* Base URL: https://gamma-api.polymarket.com
* 文档: https://docs.polymarket.com/api-reference/markets/list-markets
*/
interface PolymarketGammaApi {
/**
* 根据 condition ID 列表获取市场信息
* 文档: https://docs.polymarket.com/api-reference/markets/list-markets
* @param conditionIds condition ID 数组(16 进制字符串,如 "0x..."
* @param includeTag 是否包含标签信息
* @return 市场信息数组
*/
@GET("/markets")
suspend fun listMarkets(
@Query("condition_ids") conditionIds: List<String>? = null,
@Query("include_tag") includeTag: Boolean? = null
): Response<List<MarketResponse>>
}
/**
* 市场响应(根据 Gamma API 文档)
*/
data class MarketResponse(
val id: String? = null,
val question: String? = null, // 市场名称
val conditionId: String? = null,
val slug: String? = null,
val icon: String? = null,
val image: String? = null,
val description: String? = null,
val category: String? = null,
val active: Boolean? = null,
val closed: Boolean? = null,
val archived: Boolean? = null,
val volume: String? = null,
val liquidity: String? = null,
val endDate: String? = null,
val startDate: String? = null,
val outcomes: String? = null,
val outcomePrices: String? = null,
val volumeNum: Double? = null,
val liquidityNum: Double? = null,
val lastTradePrice: Double? = null,
val bestBid: Double? = null,
val bestAsk: Double? = null
)
@@ -7,7 +7,8 @@ data class AccountImportRequest(
val privateKey: String, // 私钥(前端加密后传输) val privateKey: String, // 私钥(前端加密后传输)
val walletAddress: String, // 钱包地址(前端从私钥推导,用于验证) val walletAddress: String, // 钱包地址(前端从私钥推导,用于验证)
val accountName: String? = null, val accountName: String? = null,
val isDefault: Boolean = false val isDefault: Boolean = false,
val isEnabled: Boolean = true // 是否启用(用于订单推送等功能的开关)
) )
/** /**
@@ -16,7 +17,8 @@ data class AccountImportRequest(
data class AccountUpdateRequest( data class AccountUpdateRequest(
val accountId: Long, val accountId: Long,
val accountName: String? = null, val accountName: String? = null,
val isDefault: Boolean? = null val isDefault: Boolean? = null,
val isEnabled: Boolean? = null // 是否启用(用于订单推送等功能的开关)
) )
/** /**
@@ -55,6 +57,7 @@ data class AccountDto(
val walletAddress: String, val walletAddress: String,
val accountName: String?, val accountName: String?,
val isDefault: Boolean, val isDefault: Boolean,
val isEnabled: Boolean, // 是否启用(用于订单推送等功能的开关)
val apiKeyConfigured: Boolean, // API Key 是否已配置(不返回实际 Key) val apiKeyConfigured: Boolean, // API Key 是否已配置(不返回实际 Key)
val apiSecretConfigured: Boolean, // API Secret 是否已配置 val apiSecretConfigured: Boolean, // API Secret 是否已配置
val apiPassphraseConfigured: Boolean, // API Passphrase 是否已配置 val apiPassphraseConfigured: Boolean, // API Passphrase 是否已配置
@@ -0,0 +1,65 @@
package com.wrbug.polymarketbot.dto
import com.fasterxml.jackson.annotation.JsonProperty
/**
* Polymarket Order Message DTO
* 根据 https://docs.polymarket.com/developers/CLOB/websocket/user-channel#order-message
*/
data class OrderMessageDto(
@JsonProperty("asset_id")
val assetId: String, // asset ID (token ID) of order
@JsonProperty("associate_trades")
val associateTrades: List<String>?, // array of ids referencing trades that the order has been included in
@JsonProperty("event_type")
val eventType: String, // "order"
val id: String, // order id
val market: String, // condition ID of market
@JsonProperty("order_owner")
val orderOwner: String, // owner of order
@JsonProperty("original_size")
val originalSize: String, // original order size
val outcome: String, // outcome
val owner: String, // owner of orders
val price: String, // price of order
val side: String, // BUY/SELL
@JsonProperty("size_matched")
val sizeMatched: String, // size of order that has been matched
val timestamp: String, // time of event
val type: String // PLACEMENT/UPDATE/CANCELLATION
)
/**
* 订单推送消息(统一格式)
*/
data class OrderPushMessage(
val accountId: Long, // 账户 ID
val accountName: String, // 账户名称
val order: OrderMessageDto, // 订单信息(来自 WebSocket
val orderDetail: OrderDetailDto? = null, // 订单详情(通过 API 获取)
val timestamp: Long = System.currentTimeMillis() // 推送时间戳
)
/**
* 订单详情(通过 API 获取)
*/
data class OrderDetailDto(
val id: String, // 订单 ID
val market: String, // 市场 ID (condition ID)
val side: String, // BUY/SELL
val price: String, // 价格
val size: String, // 订单大小
val filled: String, // 已成交数量
val status: String, // 订单状态
val createdAt: String, // 创建时间(ISO 8601 格式)
val marketName: String? = null, // 市场名称(通过 Data API 获取)
val marketSlug: String? = null, // 市场 slug
val marketIcon: String? = null // 市场图标
)
@@ -37,6 +37,9 @@ data class Account(
@Column(name = "is_default", nullable = false) @Column(name = "is_default", nullable = false)
val isDefault: Boolean = false, // 是否默认账户 val isDefault: Boolean = false, // 是否默认账户
@Column(name = "is_enabled", nullable = false)
val isEnabled: Boolean = true, // 是否启用(用于订单推送等功能的开关)
@Column(name = "created_at", nullable = false) @Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(), val createdAt: Long = System.currentTimeMillis(),
@@ -22,7 +22,8 @@ class AccountService(
private val clobService: PolymarketClobService, private val clobService: PolymarketClobService,
private val retrofitFactory: RetrofitFactory, private val retrofitFactory: RetrofitFactory,
private val blockchainService: BlockchainService, private val blockchainService: BlockchainService,
private val apiKeyService: PolymarketApiKeyService private val apiKeyService: PolymarketApiKeyService,
private val orderPushService: OrderPushService
) { ) {
private val logger = LoggerFactory.getLogger(AccountService::class.java) private val logger = LoggerFactory.getLogger(AccountService::class.java)
@@ -112,12 +113,16 @@ class AccountService(
apiPassphrase = apiKeyCreds.passphrase, apiPassphrase = apiKeyCreds.passphrase,
accountName = request.accountName, accountName = request.accountName,
isDefault = request.isDefault, isDefault = request.isDefault,
isEnabled = request.isEnabled,
createdAt = System.currentTimeMillis(), createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis() updatedAt = System.currentTimeMillis()
) )
val saved = accountRepository.save(account) val saved = accountRepository.save(account)
logger.info("成功导入账户: ${saved.id}, ${saved.walletAddress}, 代理地址: ${saved.proxyAddress}") logger.info("成功导入账户: ${saved.id}, ${saved.walletAddress}, 代理地址: ${saved.proxyAddress}, 启用状态: ${saved.isEnabled}")
// 刷新订单推送订阅(如果账户启用且有 API 凭证)
orderPushService.refreshSubscriptions()
Result.success(toDto(saved)) Result.success(toDto(saved))
} catch (e: Exception) { } catch (e: Exception) {
@@ -147,14 +152,21 @@ class AccountService(
} }
} }
// 更新启用状态
val updatedIsEnabled = request.isEnabled ?: account.isEnabled
val updated = account.copy( val updated = account.copy(
accountName = updatedAccountName, accountName = updatedAccountName,
isDefault = updatedIsDefault, isDefault = updatedIsDefault,
isEnabled = updatedIsEnabled,
updatedAt = System.currentTimeMillis() updatedAt = System.currentTimeMillis()
) )
val saved = accountRepository.save(updated) val saved = accountRepository.save(updated)
logger.info("成功更新账户: ${saved.id}") logger.info("成功更新账户: ${saved.id}, 启用状态: ${saved.isEnabled}")
// 刷新订单推送订阅(账户状态变更时)
orderPushService.refreshSubscriptions()
Result.success(toDto(saved)) Result.success(toDto(saved))
} catch (e: Exception) { } catch (e: Exception) {
@@ -194,6 +206,9 @@ class AccountService(
accountRepository.delete(account) accountRepository.delete(account)
logger.info("成功删除账户: $accountId") logger.info("成功删除账户: $accountId")
// 刷新订单推送订阅(账户删除时)
orderPushService.refreshSubscriptions()
Result.success(Unit) Result.success(Unit)
} catch (e: Exception) { } catch (e: Exception) {
logger.error("删除账户失败", e) logger.error("删除账户失败", e)
@@ -370,6 +385,7 @@ class AccountService(
walletAddress = account.walletAddress, walletAddress = account.walletAddress,
accountName = account.accountName, accountName = account.accountName,
isDefault = account.isDefault, isDefault = account.isDefault,
isEnabled = account.isEnabled,
apiKeyConfigured = account.apiKey != null, apiKeyConfigured = account.apiKey != null,
apiSecretConfigured = account.apiSecret != null, apiSecretConfigured = account.apiSecret != null,
apiPassphraseConfigured = account.apiPassphrase != null, apiPassphraseConfigured = account.apiPassphrase != null,
@@ -0,0 +1,458 @@
package com.wrbug.polymarketbot.service
import com.fasterxml.jackson.databind.ObjectMapper
import com.wrbug.polymarketbot.dto.OrderDetailDto
import com.wrbug.polymarketbot.dto.OrderMessageDto
import com.wrbug.polymarketbot.dto.OrderPushMessage
import com.wrbug.polymarketbot.entity.Account
import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.websocket.PolymarketWebSocketClient
import jakarta.annotation.PostConstruct
import jakarta.annotation.PreDestroy
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import java.util.concurrent.ConcurrentHashMap
/**
* 订单推送服务
* 为每个账户建立到 Polymarket User Channel 的连接,接收订单消息并推送给前端
*/
@Service
class OrderPushService(
private val accountRepository: AccountRepository,
private val objectMapper: ObjectMapper,
private val clobService: PolymarketClobService,
private val retrofitFactory: RetrofitFactory // 用于创建 Gamma API 客户端(不需要认证)
) {
private val logger = LoggerFactory.getLogger(OrderPushService::class.java)
@Value("\${polymarket.rtds.ws-url}")
private lateinit var polymarketWsUrl: String
// 存储账户 ID 和对应的 WebSocket 连接
private val accountConnections = ConcurrentHashMap<Long, PolymarketWebSocketClient>()
// 存储账户 ID 和对应的推送回调:accountId -> Set<(OrderPushMessage) -> Unit>
private val accountCallbacks = ConcurrentHashMap<Long, MutableSet<(OrderPushMessage) -> Unit>>()
// 协程作用域
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
/**
* 初始化服务
* 为所有有 API Key 的账户建立连接
*/
@PostConstruct
fun init() {
logger.info("订单推送服务已初始化")
scope.launch {
connectAllAccounts()
}
}
/**
* 清理资源
*/
@PreDestroy
fun destroy() {
logger.info("停止订单推送服务")
accountConnections.values.forEach { client ->
try {
if (client.isConnected()) {
client.closeConnection()
}
} catch (e: Exception) {
logger.error("关闭账户连接失败: ${e.message}", e)
}
}
accountConnections.clear()
accountCallbacks.clear()
scope.cancel()
}
/**
* 为所有有 API Key 且启用的账户建立连接
*/
private suspend fun connectAllAccounts() {
val accounts = accountRepository.findAll()
accounts.forEach { account ->
if (hasApiCredentials(account) && account.isEnabled) {
connectAccount(account)
}
}
}
/**
* 订阅所有启用的账户
*/
fun subscribeAllEnabled(callback: (OrderPushMessage) -> Unit) {
logger.info("订阅所有启用账户的订单推送")
val accounts = accountRepository.findAll()
accounts.forEach { account ->
if (hasApiCredentials(account) && account.isEnabled) {
val accountId = account.id!!
accountCallbacks.getOrPut(accountId) { mutableSetOf() }.add(callback)
// 如果账户连接不存在,建立连接
if (!accountConnections.containsKey(accountId)) {
connectAccount(account)
}
}
}
}
/**
* 取消订阅所有账户
*/
fun unsubscribeAll(callback: (OrderPushMessage) -> Unit) {
logger.info("取消订阅所有账户的订单推送")
accountCallbacks.values.forEach { callbacks ->
callbacks.remove(callback)
}
}
/**
* 重新订阅所有启用的账户(用于账户状态变更时调用)
*/
fun refreshSubscriptions() {
logger.info("刷新所有账户的订阅状态")
val accounts = accountRepository.findAll()
val enabledAccountIds = accounts
.filter { hasApiCredentials(it) && it.isEnabled }
.map { it.id!! }
.toSet()
// 断开已禁用或删除的账户连接
accountConnections.keys.forEach { accountId ->
if (!enabledAccountIds.contains(accountId)) {
disconnectAccount(accountId)
}
}
// 为启用的账户建立连接(如果还没有)
accounts.forEach { account ->
if (hasApiCredentials(account) && account.isEnabled) {
val accountId = account.id!!
if (!accountConnections.containsKey(accountId)) {
connectAccount(account)
}
}
}
}
/**
* 检查账户是否有 API 凭证
*/
private fun hasApiCredentials(account: Account): Boolean {
return account.apiKey != null &&
account.apiSecret != null &&
account.apiPassphrase != null &&
account.apiKey!!.isNotBlank() &&
account.apiSecret!!.isNotBlank() &&
account.apiPassphrase!!.isNotBlank()
}
/**
* 为指定账户建立 User Channel 连接
*/
fun connectAccount(account: Account) {
if (!hasApiCredentials(account)) {
logger.warn("账户 ${account.id} 没有 API 凭证,无法建立连接")
return
}
if (!account.isEnabled) {
logger.debug("账户 ${account.id} 未启用,跳过连接")
return
}
if (accountConnections.containsKey(account.id)) {
logger.debug("账户 ${account.id} 已存在连接,跳过")
return
}
scope.launch {
try {
// 连接到 Polymarket RTDS User Channel
// 根据官方文档:https://docs.polymarket.com/quickstart/websocket/WSS-Quickstart
// URL 格式为: wss://ws-subscriptions-clob.polymarket.com/ws/user
val wsUrl = "$polymarketWsUrl/ws/user"
// 创建客户端,在连接建立后立即发送订阅消息
val client = PolymarketWebSocketClient(
url = wsUrl,
sessionId = "account-${account.id}",
onMessage = { message -> handleMessage(account, message) },
onOpen = {
// 首次连接建立后立即发送订阅消息
val currentClient = accountConnections[account.id!!]
if (currentClient != null) {
try {
sendSubscribeMessage(currentClient, account)
logger.info("已为账户 ${account.id} (${account.accountName ?: account.walletAddress}) 建立 User Channel 连接并发送订阅消息")
} catch (e: Exception) {
logger.error("发送订阅消息失败: account=${account.id}, ${e.message}", e)
// 如果订阅失败,关闭连接(会触发重连)
currentClient.closeConnection()
accountConnections.remove(account.id)
}
} else {
logger.warn("账户 ${account.id} 的连接不存在,无法发送订阅消息")
}
},
onReconnect = {
// 重连后重新发送订阅消息
val currentClient = accountConnections[account.id!!]
if (currentClient != null) {
try {
sendSubscribeMessage(currentClient, account)
logger.info("账户 ${account.id} 重连成功,已重新发送订阅消息")
} catch (e: Exception) {
logger.error("重连后发送订阅消息失败: account=${account.id}, ${e.message}", e)
}
}
}
)
accountConnections[account.id!!] = client
client.connect()
} catch (e: Exception) {
logger.error("为账户 ${account.id} 建立连接失败: ${e.message}", e)
accountConnections.remove(account.id)
}
}
}
/**
* 发送订阅消息到 Polymarket User Channel
* 根据 https://docs.polymarket.com/developers/CLOB/websocket/user-channel
* 参考 clob-client/examples/socketConnection.ts
*
* 订阅消息格式(与 clob-client 保持一致):
* {
* "auth": { "apiKey": "...", "secret": "...", "passphrase": "..." },
* "type": "user",
* "markets": [], // 空数组表示订阅所有市场,也可以指定 condition IDs
* "assets_ids": [],
* "initial_dump": true
* }
*/
private fun sendSubscribeMessage(client: PolymarketWebSocketClient, account: Account) {
try {
val subscribeMessage = mapOf(
"auth" to mapOf(
"apiKey" to account.apiKey,
"secret" to account.apiSecret,
"passphrase" to account.apiPassphrase
),
"type" to "user",
"markets" to emptyList<String>(), // 空数组表示订阅所有市场
"assets_ids" to emptyList<String>(),
"initial_dump" to true
)
val json = objectMapper.writeValueAsString(subscribeMessage)
client.sendMessage(json)
logger.info("已发送 User Channel 订阅消息: account=${account.id}, apiKey=${account.apiKey?.take(10)}...")
} catch (e: Exception) {
logger.error("发送订阅消息失败: account=${account.id}, ${e.message}", e)
}
}
/**
* 处理收到的消息
*/
private fun handleMessage(account: Account, message: String) {
try {
// 处理心跳响应(PONG),直接返回
if (message.trim() == "PONG" || message.trim() == "pong") {
logger.debug("收到 PONG 响应: account=${account.id}")
return
}
// 尝试解析 JSON 消息
val messageMap = objectMapper.readValue(message, Map::class.java) as Map<*, *>
val eventType = messageMap["event_type"] as? String
// 只处理 order 类型的消息
if (eventType == "order") {
val orderMessage = objectMapper.readValue(message, OrderMessageDto::class.java)
// 异步获取订单详情
scope.launch {
val orderDetail = fetchOrderDetail(account, orderMessage.id, orderMessage.market)
val pushMessage = OrderPushMessage(
accountId = account.id!!,
accountName = account.accountName ?: account.walletAddress,
order = orderMessage,
orderDetail = orderDetail
)
// 推送给所有订阅者
accountCallbacks[account.id]?.forEach { callback ->
try {
callback(pushMessage)
} catch (e: Exception) {
logger.error("推送订单消息失败: account=${account.id}, ${e.message}", e)
}
}
}
} else {
// 记录其他类型的消息(用于调试)
logger.debug("收到非订单消息: account=${account.id}, eventType=$eventType")
}
} catch (e: Exception) {
// 如果解析失败,可能是非 JSON 消息(如 PONG),记录为 debug 级别
if (message.trim() == "PONG" || message.trim() == "pong") {
logger.debug("收到 PONG 响应: account=${account.id}")
} else {
logger.error("处理订单消息失败: account=${account.id}, message=${message.take(100)}, ${e.message}", e)
}
}
}
/**
* 获取订单详情
* 通过 PolymarketClobService 获取订单详情
*/
private suspend fun fetchOrderDetail(
account: Account,
orderId: String,
conditionId: String? = null
): OrderDetailDto? {
return try {
// 检查账户是否有 API 凭证
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
logger.debug("账户 ${account.id} 未配置 API 凭证,无法获取订单详情")
return null
}
// 通过 PolymarketClobService 获取订单详情(需要 L2 认证)
val result = clobService.getOrder(
orderId = orderId,
apiKey = account.apiKey!!,
apiSecret = account.apiSecret!!,
apiPassphrase = account.apiPassphrase!!,
walletAddress = account.walletAddress
)
result.fold(
onSuccess = { openOrder ->
// 获取市场信息(通过 Gamma API)
val marketInfo = fetchMarketInfo(conditionId ?: openOrder.market)
// 转换为 DTO
// 注意:createdAt 是 unix timestamp (Long),需要转换为字符串
OrderDetailDto(
id = openOrder.id,
market = openOrder.market,
side = openOrder.side,
price = openOrder.price,
size = openOrder.originalSize, // 使用 original_size
filled = openOrder.sizeMatched, // 使用 size_matched
status = openOrder.status,
createdAt = openOrder.createdAt.toString(), // unix timestamp 转换为字符串
marketName = marketInfo?.question,
marketSlug = marketInfo?.slug,
marketIcon = marketInfo?.icon
)
},
onFailure = { e ->
logger.warn("获取订单详情失败: account=${account.id}, orderId=$orderId, ${e.message}")
null
}
)
} catch (e: Exception) {
logger.error("获取订单详情异常: account=${account.id}, orderId=$orderId, ${e.message}", e)
null
}
}
/**
* 获取市场信息(通过 Gamma API)
* 文档: https://docs.polymarket.com/api-reference/markets/list-markets
*
* 使用 /markets 接口,通过 condition_ids 查询参数获取市场信息
* 订单返回的 market 字段是 16 进制的 condition ID(如 "0x..."
*/
private suspend fun fetchMarketInfo(conditionId: String): com.wrbug.polymarketbot.api.MarketResponse? {
return try {
// 创建 Gamma API 客户端(公开 API,不需要认证)
val gammaApi = retrofitFactory.createGammaApi()
// 调用 Gamma API 获取市场信息
// 使用 /markets 接口,通过 condition_ids 查询参数
val response = gammaApi.listMarkets(
conditionIds = listOf(conditionId),
includeTag = null
)
if (response.isSuccessful && response.body() != null) {
val markets = response.body()!!
if (markets.isNotEmpty()) {
val market = markets.first()
logger.debug("获取市场信息成功: conditionId=$conditionId, question=${market.question}")
return market
} else {
logger.debug("未找到市场信息: conditionId=$conditionId")
return null
}
} else {
logger.debug("获取市场信息失败: conditionId=$conditionId, code=${response.code()}, message=${response.message()}")
null
}
} catch (e: Exception) {
logger.debug("获取市场信息异常: conditionId=$conditionId, ${e.message}")
null
}
}
/**
* 订阅账户的订单推送(保留用于向后兼容)
*/
fun subscribe(accountId: Long, callback: (OrderPushMessage) -> Unit) {
logger.info("订阅账户订单推送: $accountId")
accountCallbacks.getOrPut(accountId) { mutableSetOf() }.add(callback)
// 如果账户连接不存在,尝试建立连接
if (!accountConnections.containsKey(accountId)) {
val account = accountRepository.findById(accountId).orElse(null)
if (account != null && hasApiCredentials(account) && account.isEnabled) {
connectAccount(account)
}
}
}
/**
* 取消订阅账户的订单推送(保留用于向后兼容)
*/
fun unsubscribe(accountId: Long, callback: (OrderPushMessage) -> Unit) {
logger.info("取消订阅账户订单推送: $accountId")
accountCallbacks[accountId]?.remove(callback)
// 如果没有订阅者了,可以考虑关闭连接(但暂时保持连接,以便后续订阅)
}
/**
* 断开指定账户的连接
*/
fun disconnectAccount(accountId: Long) {
logger.info("断开账户连接: $accountId")
val client = accountConnections.remove(accountId)
client?.let {
try {
if (it.isConnected()) {
it.closeConnection()
}
} catch (e: Exception) {
logger.error("关闭账户连接失败: $accountId, ${e.message}", e)
}
}
accountCallbacks.remove(accountId)
}
}
@@ -1,6 +1,7 @@
package com.wrbug.polymarketbot.service package com.wrbug.polymarketbot.service
import com.wrbug.polymarketbot.api.* import com.wrbug.polymarketbot.api.*
import com.wrbug.polymarketbot.util.RetrofitFactory
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@@ -10,7 +11,8 @@ import org.springframework.stereotype.Service
*/ */
@Service @Service
class PolymarketClobService( class PolymarketClobService(
private val clobApi: PolymarketClobApi private val clobApi: PolymarketClobApi, // 用于不需要认证的接口
private val retrofitFactory: RetrofitFactory // 用于创建带认证的客户端
) { ) {
private val logger = LoggerFactory.getLogger(PolymarketClobService::class.java) private val logger = LoggerFactory.getLogger(PolymarketClobService::class.java)
@@ -83,6 +85,45 @@ class PolymarketClobService(
} }
} }
/**
* 获取订单详情(需要 L2 认证)
* 文档: https://docs.polymarket.com/developers/CLOB/orders/get-order
*
* @param orderId 订单 ID
* @param apiKey API Key
* @param apiSecret API Secret
* @param apiPassphrase API Passphrase
* @param walletAddress 钱包地址(用于 POLY_ADDRESS 请求头)
* @return 订单详情
*/
suspend fun getOrder(
orderId: String,
apiKey: String,
apiSecret: String,
apiPassphrase: String,
walletAddress: String
): Result<OpenOrder> {
return try {
// 创建带 L2 认证的 API 客户端
val authenticatedClobApi = retrofitFactory.createClobApi(
apiKey = apiKey,
apiSecret = apiSecret,
apiPassphrase = apiPassphrase,
walletAddress = walletAddress
)
val response = authenticatedClobApi.getOrder(orderId)
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)
}
}
/** /**
* 获取活跃订单 * 获取活跃订单
*/ */
@@ -1,5 +1,6 @@
package com.wrbug.polymarketbot.service package com.wrbug.polymarketbot.service
import com.wrbug.polymarketbot.dto.OrderPushMessage
import com.wrbug.polymarketbot.dto.PositionPushMessage import com.wrbug.polymarketbot.dto.PositionPushMessage
import com.wrbug.polymarketbot.dto.WebSocketMessage as WsMessage import com.wrbug.polymarketbot.dto.WebSocketMessage as WsMessage
import com.wrbug.polymarketbot.dto.WebSocketMessageType import com.wrbug.polymarketbot.dto.WebSocketMessageType
@@ -14,7 +15,8 @@ import java.util.concurrent.ConcurrentHashMap
*/ */
@Service @Service
class WebSocketSubscriptionService( class WebSocketSubscriptionService(
private val positionPushService: PositionPushService private val positionPushService: PositionPushService,
private val orderPushService: OrderPushService
) { ) {
private val logger = LoggerFactory.getLogger(WebSocketSubscriptionService::class.java) private val logger = LoggerFactory.getLogger(WebSocketSubscriptionService::class.java)
@@ -31,6 +33,9 @@ class WebSocketSubscriptionService(
// 存储每个频道的订阅会话数:channel -> Set<sessionId> // 存储每个频道的订阅会话数:channel -> Set<sessionId>
private val channelSubscriptions = ConcurrentHashMap<String, MutableSet<String>>() private val channelSubscriptions = ConcurrentHashMap<String, MutableSet<String>>()
// 存储 order 频道的订阅回调:sessionId -> callback(用于取消订阅)
private val orderChannelCallbacks = ConcurrentHashMap<String, (OrderPushMessage) -> Unit>()
/** /**
* 注册会话 * 注册会话
*/ */
@@ -52,6 +57,9 @@ class WebSocketSubscriptionService(
unsubscribe(sessionId, channel) unsubscribe(sessionId, channel)
} }
// 清理 order 频道的回调
orderChannelCallbacks.remove(sessionId)
sessionCallbacks.remove(sessionId) sessionCallbacks.remove(sessionId)
} }
@@ -92,6 +100,15 @@ class WebSocketSubscriptionService(
} }
} }
} }
"order" -> {
// 订单推送:自动订阅所有启用的账户
val callback: (OrderPushMessage) -> Unit = { message ->
pushData(sessionId, channel, message)
}
orderChannelCallbacks[sessionId] = callback
orderPushService.subscribeAllEnabled(callback)
logger.info("已订阅所有启用账户的订单推送: $sessionId")
}
else -> { else -> {
logger.warn("未知的频道: $channel") logger.warn("未知的频道: $channel")
sendSubscribeAck(sessionId, channel, false, "未知的频道") sendSubscribeAck(sessionId, channel, false, "未知的频道")
@@ -112,6 +129,14 @@ class WebSocketSubscriptionService(
// 取消推送服务的订阅(推送服务内部会处理是否停止轮询) // 取消推送服务的订阅(推送服务内部会处理是否停止轮询)
when (channel) { when (channel) {
"position" -> positionPushService.unsubscribe(sessionId) "position" -> positionPushService.unsubscribe(sessionId)
"order" -> {
// 取消订阅所有账户的订单推送
val callback = orderChannelCallbacks.remove(sessionId)
if (callback != null) {
orderPushService.unsubscribeAll(callback)
logger.debug("已取消订阅订单推送: $sessionId -> $channel")
}
}
} }
} }
@@ -10,6 +10,19 @@ import java.security.cert.X509Certificate
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.net.ssl.* import javax.net.ssl.*
/**
* 获取代理配置用于 WebSocket HTTP 请求
* @return Proxy 对象如果未启用代理则返回 null
*/
fun getProxyConfig(): Proxy? {
if (getEnv("ENABLE_PROXY") != "1") {
return null
}
val host = getEnv("PROXY_HOST").ifEmpty { "127.0.0.1" }
val port = getEnv("PROXY_PORT").toIntOrNull() ?: 8888
return Proxy(Proxy.Type.HTTP, InetSocketAddress(host, port))
}
/** /**
* 创建OkHttpClient客户端 * 创建OkHttpClient客户端
* @return OkHttpClient.Builder * @return OkHttpClient.Builder
@@ -99,13 +99,6 @@ class PolymarketAuthInterceptor(
.header("User-Agent", "@polymarket/clob-client") .header("User-Agent", "@polymarket/clob-client")
.header("Accept", "*/*") .header("Accept", "*/*")
.header("Connection", "keep-alive") .header("Connection", "keep-alive")
.header("Content-Type", "application/json")
.apply {
// GET 请求添加 Accept-Encoding: gzip
if (method == "GET") {
header("Accept-Encoding", "gzip")
}
}
// 如果有请求体,重新设置请求体 // 如果有请求体,重新设置请求体
if (newRequestBody != null) { if (newRequestBody != null) {
@@ -1,11 +1,19 @@
package com.wrbug.polymarketbot.util package com.wrbug.polymarketbot.util
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.wrbug.polymarketbot.api.EthereumRpcApi import com.wrbug.polymarketbot.api.EthereumRpcApi
import com.wrbug.polymarketbot.api.PolymarketClobApi import com.wrbug.polymarketbot.api.PolymarketClobApi
import com.wrbug.polymarketbot.api.PolymarketGammaApi
import okhttp3.Interceptor
import okhttp3.Response
import okio.Buffer
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory
import java.io.IOException
/** /**
* Retrofit 客户端工厂 * Retrofit 客户端工厂
@@ -14,7 +22,9 @@ import retrofit2.converter.gson.GsonConverterFactory
@Component @Component
class RetrofitFactory( class RetrofitFactory(
@Value("\${polymarket.clob.base-url}") @Value("\${polymarket.clob.base-url}")
private val clobBaseUrl: String private val clobBaseUrl: String,
@Value("\${polymarket.gamma.base-url}")
private val gammaBaseUrl: String
) { ) {
/** /**
@@ -33,14 +43,49 @@ class RetrofitFactory(
): PolymarketClobApi { ): PolymarketClobApi {
val authInterceptor = PolymarketAuthInterceptor(apiKey, apiSecret, apiPassphrase, walletAddress) val authInterceptor = PolymarketAuthInterceptor(apiKey, apiSecret, apiPassphrase, walletAddress)
// 添加响应日志拦截器,用于调试 JSON 解析错误
val responseLoggingInterceptor = ResponseLoggingInterceptor()
val okHttpClient = createClient() val okHttpClient = createClient()
.addInterceptor(authInterceptor) .addInterceptor(authInterceptor)
.addInterceptor(responseLoggingInterceptor)
.build() .build()
// 创建 lenient 模式的 Gson,允许解析格式不严格的 JSON
val gson = GsonBuilder()
.setLenient()
.create()
return Retrofit.Builder() return Retrofit.Builder()
.baseUrl(clobBaseUrl) .baseUrl(clobBaseUrl)
.client(okHttpClient) .client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(PolymarketClobApi::class.java)
}
/**
* 创建不带认证的 Polymarket CLOB API 客户端
* 用于不需要认证的查询接口
* @return PolymarketClobApi 客户端
*/
fun createClobApiWithoutAuth(): PolymarketClobApi {
// 添加响应日志拦截器,用于调试 JSON 解析错误
val responseLoggingInterceptor = ResponseLoggingInterceptor()
val okHttpClient = createClient()
.addInterceptor(responseLoggingInterceptor)
.build()
// 创建 lenient 模式的 Gson,允许解析格式不严格的 JSON
val gson = GsonBuilder()
.setLenient()
.create()
return Retrofit.Builder()
.baseUrl(clobBaseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build() .build()
.create(PolymarketClobApi::class.java) .create(PolymarketClobApi::class.java)
} }
@@ -53,12 +98,83 @@ class RetrofitFactory(
fun createEthereumRpcApi(rpcUrl: String): EthereumRpcApi { fun createEthereumRpcApi(rpcUrl: String): EthereumRpcApi {
val okHttpClient = createClient().build() val okHttpClient = createClient().build()
// 创建 lenient 模式的 Gson
val gson = GsonBuilder()
.setLenient()
.create()
return Retrofit.Builder() return Retrofit.Builder()
.baseUrl(rpcUrl) .baseUrl(rpcUrl)
.client(okHttpClient) .client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create(gson))
.build() .build()
.create(EthereumRpcApi::class.java) .create(EthereumRpcApi::class.java)
} }
/**
* 创建 Polymarket Gamma API 客户端
* Gamma API 是公开 API不需要认证
* @return PolymarketGammaApi 客户端
*/
fun createGammaApi(): PolymarketGammaApi {
val baseUrl = if (gammaBaseUrl.endsWith("/")) {
gammaBaseUrl.dropLast(1)
} else {
gammaBaseUrl
}
val okHttpClient = createClient().build()
// 创建 lenient 模式的 Gson
val gson = GsonBuilder()
.setLenient()
.create()
return Retrofit.Builder()
.baseUrl("$baseUrl/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(PolymarketGammaApi::class.java)
}
}
/**
* 响应日志拦截器
* 用于记录 API 响应的原始内容帮助调试 JSON 解析错误
*/
class ResponseLoggingInterceptor : Interceptor {
private val logger = LoggerFactory.getLogger(ResponseLoggingInterceptor::class.java)
@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val response = chain.proceed(request)
// 只在响应不成功或可能有问题时记录响应体
if (response.isSuccessful) {
try {
// 使用 peekBody 读取响应体,避免消费响应流
// 只读取前 2KB,避免内存问题
val responseBody = response.peekBody(2048)
val responseBodyString = responseBody.string()
// 检查是否是有效的 JSON
val isJson = responseBodyString.trim().startsWith("{") ||
responseBodyString.trim().startsWith("[")
if (!isJson || !response.isSuccessful) {
logger.warn(
"API 响应异常: method=${request.method}, url=${request.url}, " +
"code=${response.code}, isJson=$isJson, " +
"responseBody=${responseBodyString.take(500)}"
)
}
} catch (e: Exception) {
logger.debug("读取响应体失败: ${e.message}")
}
}
return response
}
} }
@@ -1,69 +1,269 @@
package com.wrbug.polymarketbot.websocket package com.wrbug.polymarketbot.websocket
import com.fasterxml.jackson.databind.ObjectMapper import com.wrbug.polymarketbot.util.createClient
import org.java_websocket.client.WebSocketClient import com.wrbug.polymarketbot.util.getProxyConfig
import org.java_websocket.handshake.ServerHandshake import kotlinx.coroutines.*
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.ByteString
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.net.URI
/** /**
* Polymarket WebSocket 客户端 * Polymarket WebSocket 客户端使用 OkHttp 实现
* 用于连接到 Polymarket RTDS * 用于连接到 Polymarket RTDS
* 支持代理配置通过环境变量 ENABLE_PROXYPROXY_HOSTPROXY_PORT 控制
*/ */
class PolymarketWebSocketClient( class PolymarketWebSocketClient(
serverUri: URI, private val url: String,
private val objectMapper: ObjectMapper,
private val sessionId: String, private val sessionId: String,
private val onMessage: (String) -> Unit private val onMessage: (String) -> Unit,
) : WebSocketClient(serverUri) { private val onOpen: (() -> Unit)? = null, // 连接建立后的回调,用于发送订阅消息
private val onReconnect: (() -> Unit)? = null // 重连回调,用于重新发送订阅消息
) {
private val logger = LoggerFactory.getLogger(PolymarketWebSocketClient::class.java) private val logger = LoggerFactory.getLogger(PolymarketWebSocketClient::class.java)
override fun onOpen(handshakedata: ServerHandshake?) { private var webSocket: WebSocket? = null
logger.info("已成功连接到 Polymarket RTDS: $sessionId") private var isConnected = false
private var pingJob: Job? = null
private var reconnectJob: Job? = null
private var shouldReconnect = true // 是否应该自动重连
private var reconnectDelay = 3000L // 重连延迟(毫秒),初始 3 秒
private val okHttpClient: OkHttpClient by lazy {
val proxy = getProxyConfig()
val builder = createClient()
// 如果启用了代理,配置代理
if (proxy != null) {
builder.proxy(proxy)
logger.info("已配置 WebSocket 代理: ${proxy.address()}")
}
builder.build()
} }
override fun onMessage(message: String?) { /**
if (message != null) { * 连接 WebSocket
logger.debug("收到 Polymarket 消息: $sessionId, $message") */
onMessage(message) fun connect() {
if (webSocket != null && isConnected) {
logger.debug("WebSocket 已连接: $sessionId")
return
}
try {
val request = Request.Builder()
.url(url)
.build()
webSocket = okHttpClient.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
logger.info("已成功连接到 Polymarket RTDS: $sessionId")
isConnected = true
// 重置重连延迟(连接成功后重置为初始值)
reconnectDelay = 3000L
// 停止重连任务(如果存在)
stopReconnect()
// 连接建立后立即调用回调(用于发送订阅消息)
// 如果是重连,调用 onReconnect;否则调用 onOpen
if (reconnectJob != null) {
// 这是重连,调用 onReconnect
onReconnect?.invoke()
} else {
// 这是首次连接,调用 onOpen
onOpen?.invoke()
}
// 启动 PING 保活机制(每 10 秒发送一次 PING)
startPing()
}
override fun onMessage(webSocket: WebSocket, text: String) {
logger.debug("收到 Polymarket 消息: $sessionId, $text")
onMessage(text)
}
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
logger.debug("收到 Polymarket 二进制消息: $sessionId")
onMessage(bytes.utf8())
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
logger.info("Polymarket 连接正在关闭: $sessionId, code: $code, reason: $reason")
isConnected = false
stopPing()
// 如果不是正常关闭(code != 1000),尝试重连
if (code != 1000 && shouldReconnect) {
scheduleReconnect()
}
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
logger.info("Polymarket 连接已关闭: $sessionId, code: $code, reason: $reason")
isConnected = false
stopPing()
// 如果不是正常关闭(code != 1000),尝试重连
if (code != 1000 && shouldReconnect) {
scheduleReconnect()
}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
logger.error("Polymarket WebSocket 错误: $sessionId, ${t.message}", t)
if (response != null) {
logger.error("响应码: ${response.code}, 响应消息: ${response.message}")
try {
response.body?.let { body ->
val bodyString = body.string()
if (bodyString.isNotEmpty()) {
logger.error("响应体: $bodyString")
}
}
} catch (e: Exception) {
logger.debug("无法读取响应体: ${e.message}")
}
}
isConnected = false
stopPing()
// 连接失败,尝试重连
if (shouldReconnect) {
scheduleReconnect()
}
}
})
logger.info("正在连接到 Polymarket RTDS: $sessionId, URL: $url")
} catch (e: Exception) {
logger.error("创建 WebSocket 连接失败: $sessionId, ${e.message}", e)
throw e
} }
} }
override fun onClose(code: Int, reason: String?, remote: Boolean) { /**
logger.info("Polymarket 连接关闭: $sessionId, code: $code, reason: $reason, remote: $remote") * 启动 PING 保活机制
* 根据官方文档 10 秒发送一次 "PING"
*/
private fun startPing() {
stopPing() // 先停止之前的 PING 任务
pingJob = CoroutineScope(Dispatchers.Default).launch {
while (isActive && isConnected) {
delay(10000) // 10 秒
if (isConnected) {
try {
sendMessage("PING")
logger.debug("已发送 PING: $sessionId")
} catch (e: Exception) {
logger.warn("发送 PING 失败: $sessionId, ${e.message}")
break
}
}
}
}
}
/**
* 停止 PING 保活机制
*/
private fun stopPing() {
pingJob?.cancel()
pingJob = null
}
/**
* 安排重连
* 使用指数退避策略3 -> 6 -> 12 -> 24 -> 最大 60
*/
private fun scheduleReconnect() {
// 如果已经有重连任务在运行,不重复安排
if (reconnectJob != null && reconnectJob!!.isActive) {
return
}
reconnectJob = CoroutineScope(Dispatchers.Default).launch {
try {
delay(reconnectDelay)
// 检查是否应该重连
if (!shouldReconnect) {
logger.info("重连已禁用,停止重连: $sessionId")
return@launch
}
// 如果已经连接,不需要重连
if (isConnected) {
logger.debug("连接已恢复,取消重连: $sessionId")
return@launch
}
logger.info("尝试重连 Polymarket WebSocket: $sessionId, 延迟: ${reconnectDelay}ms")
// 清理旧的连接
webSocket = null
// 重新连接
connect()
// 增加重连延迟(指数退避,最大 60 秒)
reconnectDelay = (reconnectDelay * 2).coerceAtMost(60000L)
} catch (e: Exception) {
logger.error("重连失败: $sessionId, ${e.message}", e)
// 重连失败,继续安排下一次重连
scheduleReconnect()
}
}
}
/**
* 停止重连
*/
private fun stopReconnect() {
reconnectJob?.cancel()
reconnectJob = null
} }
/** /**
* 关闭连接 * 关闭连接
*/ */
fun closeConnection() { fun closeConnection() {
if (isOpen) { try {
try { shouldReconnect = false // 禁用自动重连
closeBlocking() stopReconnect() // 停止重连任务
} catch (e: Exception) { stopPing()
logger.error("关闭连接失败: $sessionId, ${e.message}", e) webSocket?.close(1000, "正常关闭")
} webSocket = null
isConnected = false
logger.info("已关闭 WebSocket 连接: $sessionId")
} catch (e: Exception) {
logger.error("关闭连接失败: $sessionId, ${e.message}", e)
} }
} }
override fun onError(ex: Exception?) {
logger.error("Polymarket WebSocket 错误: $sessionId, ${ex?.message}", ex)
}
/** /**
* 发送消息到 Polymarket * 发送消息到 Polymarket
*/ */
fun sendMessage(message: String) { fun sendMessage(message: String) {
if (isOpen) { val ws = webSocket
if (ws != null && isConnected) {
try { try {
send(message) val sent = ws.send(message)
if (!sent) {
logger.warn("发送消息失败(连接可能已关闭): $sessionId")
throw IllegalStateException("WebSocket 连接已关闭,无法发送消息")
}
} catch (e: Exception) { } catch (e: Exception) {
logger.error("发送消息失败: $sessionId, ${e.message}", e) logger.error("发送消息失败: $sessionId, ${e.message}", e)
throw e throw e
} }
} else { } else {
logger.warn("WebSocket 未连接,无法发送消息: $sessionId") logger.warn("WebSocket 未连接,无法发送消息: $sessionId")
throw IllegalStateException("WebSocket 未连接")
} }
} }
@@ -71,7 +271,7 @@ class PolymarketWebSocketClient(
* 检查连接状态 * 检查连接状态
*/ */
fun isConnected(): Boolean { fun isConnected(): Boolean {
return isOpen return isConnected && webSocket != null
} }
} }
@@ -1,11 +1,9 @@
package com.wrbug.polymarketbot.websocket package com.wrbug.polymarketbot.websocket
import com.fasterxml.jackson.databind.ObjectMapper
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import org.springframework.web.socket.* import org.springframework.web.socket.*
import java.net.URI
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
/** /**
@@ -13,9 +11,7 @@ import java.util.concurrent.ConcurrentHashMap
* 转发前端 WebSocket 连接到 Polymarket RTDS * 转发前端 WebSocket 连接到 Polymarket RTDS
*/ */
@Component @Component
class PolymarketWebSocketHandler( class PolymarketWebSocketHandler : WebSocketHandler {
private val objectMapper: ObjectMapper
) : WebSocketHandler {
private val logger = LoggerFactory.getLogger(PolymarketWebSocketHandler::class.java) private val logger = LoggerFactory.getLogger(PolymarketWebSocketHandler::class.java)
@@ -33,13 +29,13 @@ class PolymarketWebSocketHandler(
try { try {
// 创建到 Polymarket 的 WebSocket 连接 // 创建到 Polymarket 的 WebSocket 连接
val polymarketClient = PolymarketWebSocketClient( val polymarketClient = PolymarketWebSocketClient(
URI(polymarketWsUrl), url = polymarketWsUrl,
objectMapper, sessionId = session.id,
session.id onMessage = { message ->
) { message -> // 当收到 Polymarket 消息时,转发给客户端
// 当收到 Polymarket 消息时,转发给客户端 forwardToClient(session.id, message)
forwardToClient(session.id, message) }
} )
polymarketConnections[session.id] = polymarketClient polymarketConnections[session.id] = polymarketClient
@@ -32,8 +32,9 @@ logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} - %msg%n
# Polymarket API 配置 # Polymarket API 配置
polymarket.clob.base-url=https://clob.polymarket.com polymarket.clob.base-url=https://clob.polymarket.com
polymarket.rtds.ws-url=wss://ws-live-data.polymarket.com polymarket.rtds.ws-url=wss://ws-subscriptions-clob.polymarket.com
polymarket.data-api.base-url=https://data-api.polymarket.com polymarket.data-api.base-url=https://data-api.polymarket.com
polymarket.gamma.base-url=https://gamma-api.polymarket.com
# Ethereum RPC 配置(用于查询链上余额) # Ethereum RPC 配置(用于查询链上余额)
# 可选:如果未配置,将无法查询 USDC 余额,但仍可通过 Subgraph API 查询持仓 # 可选:如果未配置,将无法查询 USDC 余额,但仍可通过 Subgraph API 查询持仓
@@ -0,0 +1,4 @@
-- 添加是否启用字段到账户表
ALTER TABLE copy_trading_accounts
ADD COLUMN is_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用(用于订单推送等功能的开关)' AFTER is_default;
+357
View File
@@ -0,0 +1,357 @@
# 仓位出售订单功能设计文档
## 1. 功能概述
为仓位管理页面添加出售功能,支持用户对当前仓位进行市价或限价卖出操作。
## 2. 后端接口设计
### 2.1 创建卖出订单接口
**接口路径**: `POST /api/copy-trading/positions/sell`
**请求体**:
```kotlin
data class PositionSellRequest(
val accountId: Long, // 账户ID(必需)
val marketId: String, // 市场ID(必需)
val side: String, // 方向:YES 或 NO(必需)
val orderType: String, // 订单类型:MARKET(市价)或 LIMIT(限价)(必需)
val quantity: String, // 卖出数量(必需,BigDecimal字符串)
val price: String? = null // 限价价格(限价订单必需,市价订单不需要)
)
```
**响应体**:
```kotlin
data class PositionSellResponse(
val orderId: String, // 订单ID
val marketId: String, // 市场ID
val side: String, // 方向
val orderType: String, // 订单类型
val quantity: String, // 订单数量
val price: String?, // 订单价格(限价订单)
val status: String, // 订单状态
val createdAt: Long // 创建时间戳
)
```
**业务逻辑**:
1. 验证账户是否存在且已配置API凭证
2. 验证仓位是否存在且数量足够
3. 验证订单参数(数量、价格等)
4. 市价订单:获取当前最优卖价(bestBid)作为价格
5. 限价订单:验证价格是否合理
6. 调用Polymarket CLOB API创建订单
7. 返回订单信息
**错误处理**:
- 账户不存在或未配置API凭证:返回错误码 2001
- 仓位不存在或数量不足:返回错误码 4001
- 价格或数量格式错误:返回错误码 1001
- API调用失败:返回错误码 5001
### 2.2 获取市场当前价格接口(可选,用于显示参考价格)
**接口路径**: `POST /api/copy-trading/markets/price`
**请求体**:
```kotlin
data class MarketPriceRequest(
val marketId: String // 市场ID
)
```
**响应体**:
```kotlin
data class MarketPriceResponse(
val marketId: String,
val lastPrice: String?, // 最新成交价
val bestBid: String?, // 最优买价(用于卖出参考)
val bestAsk: String?, // 最优卖价(用于买入参考)
val midpoint: String? // 中间价
)
```
## 3. 前端交互设计
### 3.1 UI组件设计
#### 3.1.1 出售按钮
- **位置**: 每个仓位卡片/列表项的操作区域
- **样式**:
- 卡片视图:卡片底部或操作区域
- 列表视图:操作列
- **显示条件**: 仅当前仓位显示(历史仓位不显示)
- **按钮文本**: "卖出" 或 "出售"
#### 3.1.2 出售模态框
**布局结构**:
```
┌─────────────────────────────────┐
│ 出售仓位 - [市场标题] │
├─────────────────────────────────┤
│ 账户: [账户名称] │
│ 方向: [YES/NO标签] │
│ 当前持仓: [数量] │
│ 平均价格: [平均买入价格] │
│ 当前价格: [当前市场价格] │
├─────────────────────────────────┤
│ 订单类型: │
│ ○ 市价出售 ○ 限价出售 │
├─────────────────────────────────┤
│ 卖出数量: │
│ [输入框] │
│ [20%] [50%] [80%] [100%] │
├─────────────────────────────────┤
│ 限价价格: (限价时显示) │
│ [输入框] │
│ 参考价格: [当前最优买价] │
├─────────────────────────────────┤
│ 预计平仓收益: │
│ 收益金额: [+/-XXX.XX USDC] │
│ 收益率: [+/-XX.XX%] │
│ (实时计算,根据数量和价格更新) │
├─────────────────────────────────┤
│ [取消] [确认卖出] │
└─────────────────────────────────┘
```
**字段说明**:
1. **订单类型选择**:
- 单选按钮:市价出售 / 限价出售
- 默认:限价出售
- 切换时显示/隐藏限价输入框
2. **卖出数量**:
- 输入框:支持手动输入
- 快捷按钮:20%, 50%, 80%, 100%
- 点击快捷按钮自动填充到输入框
- 验证:不能超过当前持仓数量,不能为0
3. **限价价格**(限价订单时显示):
- 输入框:支持手动输入
- 显示参考价格:当前最优买价(bestBid)
- 验证:价格必须大于0
4. **按钮**:
- 取消:关闭模态框
- 确认卖出:提交订单(加载状态)
### 3.2 交互流程
1. **打开模态框**:
- 点击"卖出"按钮
- 加载市场当前价格(用于显示参考价格)
- 初始化表单(默认限价,数量为空)
2. **选择订单类型**:
- 切换市价/限价
- 市价:隐藏限价输入框
- 限价:显示限价输入框和参考价格
3. **设置数量**:
- 点击快捷按钮(20%, 50%, 80%, 100%
- 自动计算并填充到输入框
- 实时验证数量是否有效
4. **设置限价**(限价订单):
- 手动输入价格
- 显示参考价格提示
- 实时更新平仓收益
5. **查看平仓收益**(实时计算):
- 根据卖出数量和价格实时计算
- 计算公式:
- 收益金额 = (卖出价格 - 平均买入价格) × 卖出数量
- 收益率 = (卖出价格 - 平均买入价格) / 平均买入价格 × 100%
- 市价订单:使用当前最优买价计算
- 限价订单:使用输入的限价计算
- 颜色显示:盈利为绿色,亏损为红色
6. **提交订单**:
- 点击"确认卖出"
- 显示加载状态
- 调用后端接口
- 成功:显示成功提示,关闭模态框,刷新仓位列表
- 失败:显示错误提示
### 3.3 数据验证
**前端验证**:
- 数量:必填,大于0,不超过当前持仓数量
- 限价价格:限价订单必填,大于0
- 账户:必须已配置API凭证
**后端验证**:
- 账户存在且已配置API凭证
- 仓位存在且数量足够
- 价格和数量格式正确
- 市价订单自动获取最优价格
## 4. 类型定义
### 4.1 前端TypeScript类型
```typescript
/**
* 仓位卖出请求
*/
export interface PositionSellRequest {
accountId: number
marketId: string
side: 'YES' | 'NO'
orderType: 'MARKET' | 'LIMIT'
quantity: string
price?: string // 限价订单必需
}
/**
* 仓位卖出响应
*/
export interface PositionSellResponse {
orderId: string
marketId: string
side: string
orderType: string
quantity: string
price?: string
status: string
createdAt: number
}
/**
* 市场价格请求
*/
export interface MarketPriceRequest {
marketId: string
}
/**
* 市场价格响应
*/
export interface MarketPriceResponse {
marketId: string
lastPrice?: string
bestBid?: string
bestAsk?: string
midpoint?: string
}
```
## 5. API服务方法
### 5.1 前端API服务
```typescript
// frontend/src/services/api.ts
export const apiService = {
positions: {
/**
* 卖出仓位
*/
sell: (data: PositionSellRequest) =>
apiClient.post<ApiResponse<PositionSellResponse>>('/copy-trading/positions/sell', data),
/**
* 获取市场价格
*/
getMarketPrice: (data: MarketPriceRequest) =>
apiClient.post<ApiResponse<MarketPriceResponse>>('/copy-trading/markets/price', data)
}
}
```
### 5.2 后端Controller方法
```kotlin
@PostMapping("/positions/sell")
suspend fun sellPosition(@RequestBody request: PositionSellRequest): ResponseEntity<ApiResponse<PositionSellResponse>>
@PostMapping("/markets/price")
suspend fun getMarketPrice(@RequestBody request: MarketPriceRequest): ResponseEntity<ApiResponse<MarketPriceResponse>>
```
## 6. 实现细节
### 6.1 市价订单处理
- 市价订单需要获取当前最优买价(bestBid)作为卖出价格
- 如果无法获取最优买价,使用最新成交价(lastPrice)
- 如果都没有,返回错误提示
### 6.2 数量计算
- 快捷按钮计算:`数量 = 当前持仓数量 × 百分比`
- 保留4位小数(与仓位数量精度一致)
- 验证:不能超过当前持仓数量
### 6.3 平仓收益实时计算
**计算逻辑**:
```typescript
// 获取仓位信息
const avgPrice = parseFloat(position.avgPrice) // 平均买入价格
const quantity = parseFloat(sellQuantity) // 卖出数量
const sellPrice = orderType === 'MARKET'
? parseFloat(marketPrice.bestBid) // 市价:使用最优买价
: parseFloat(limitPrice) // 限价:使用输入价格
// 计算收益
const pnl = (sellPrice - avgPrice) * quantity
const percentPnl = ((sellPrice - avgPrice) / avgPrice) * 100
// 显示格式
const pnlDisplay = `${pnl >= 0 ? '+' : ''}${pnl.toFixed(2)} USDC`
const percentPnlDisplay = `${percentPnl >= 0 ? '+' : ''}${percentPnl.toFixed(2)}%`
```
**更新时机**:
- 数量输入框值变化时
- 限价价格输入框值变化时(限价订单)
- 订单类型切换时(市价/限价)
- 市场价格更新时(市价订单,如果支持实时更新)
**显示样式**:
- 盈利:绿色文字(#52c41a
- 亏损:红色文字(#f5222d
- 字体:加粗显示,突出重要性
### 6.4 错误处理
- 网络错误:显示"网络错误,请重试"
- API错误:显示后端返回的错误信息
- 验证错误:显示具体的验证失败原因
### 6.5 用户体验优化
- 提交订单时禁用按钮,显示加载状态
- 成功后自动刷新仓位列表
- 提供清晰的成功/失败提示
- 模态框支持ESC键关闭
## 7. 安全考虑
1. **权限验证**: 验证账户是否属于当前用户
2. **数量验证**: 确保卖出数量不超过持仓数量
3. **价格验证**: 限价订单验证价格合理性
4. **API凭证**: 确保账户已配置有效的API凭证
## 8. 测试要点
1. 市价订单创建成功
2. 限价订单创建成功
3. 数量快捷按钮功能
4. 数量验证(超过持仓、为0等)
5. 价格验证(限价订单)
6. **平仓收益实时计算**:
- 数量变化时收益更新
- 限价变化时收益更新
- 订单类型切换时收益更新
- 收益金额和收益率计算正确
- 盈利/亏损颜色显示正确
7. 账户未配置API凭证的错误处理
8. 仓位不存在的错误处理
9. 网络错误处理
+88 -2
View File
@@ -1,6 +1,6 @@
import { useEffect } from 'react' import { useEffect, useCallback } from 'react'
import { BrowserRouter, Routes, Route } from 'react-router-dom' import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { ConfigProvider } from 'antd' import { ConfigProvider, notification } from 'antd'
import zhCN from 'antd/locale/zh_CN' import zhCN from 'antd/locale/zh_CN'
import Layout from './components/Layout' import Layout from './components/Layout'
import AccountList from './pages/AccountList' import AccountList from './pages/AccountList'
@@ -13,8 +13,83 @@ import ConfigPage from './pages/ConfigPage'
import PositionList from './pages/PositionList' import PositionList from './pages/PositionList'
import Statistics from './pages/Statistics' import Statistics from './pages/Statistics'
import { wsManager } from './services/websocket' import { wsManager } from './services/websocket'
import type { OrderPushMessage } from './types'
function App() { function App() {
/**
*
*/
const getOrderTypeText = useCallback((type: string): string => {
switch (type) {
case 'PLACEMENT':
return '订单创建'
case 'UPDATE':
return '订单更新'
case 'CANCELLATION':
return '订单取消'
default:
return '订单事件'
}
}, [])
/**
*
*/
const handleOrderPush = useCallback((message: OrderPushMessage) => {
const { accountName, order, orderDetail } = message
// 根据订单类型和操作类型确定通知内容
const orderTypeText = getOrderTypeText(order.type)
const sideText = order.side === 'BUY' ? '买入' : '卖出'
// 如果有市场名称,在标题中显示
const marketName = orderDetail?.marketName || order.market.substring(0, 8) + '...'
const title = `${accountName} - ${orderTypeText}`
// 优先使用订单详情中的数据,如果没有则使用 WebSocket 消息中的数据
const price = orderDetail ? parseFloat(orderDetail.price).toFixed(4) : parseFloat(order.price).toFixed(4)
const size = orderDetail ? parseFloat(orderDetail.size).toFixed(2) : parseFloat(order.original_size).toFixed(2)
const filled = orderDetail ? parseFloat(orderDetail.filled).toFixed(2) : parseFloat(order.size_matched).toFixed(2)
const status = orderDetail?.status || 'UNKNOWN'
// 构建描述信息
let description = `市场: ${marketName}\n${sideText} ${size} @ ${price}`
// 如果有订单详情,显示更详细的信息
if (orderDetail) {
description += `\n状态: ${status}`
if (parseFloat(filled) > 0) {
description += ` | 已成交: ${filled}`
}
const remaining = (parseFloat(size) - parseFloat(filled)).toFixed(2)
if (parseFloat(remaining) > 0) {
description += ` | 剩余: ${remaining}`
}
} else if (order.type === 'UPDATE' && parseFloat(order.size_matched) > 0) {
// 如果没有订单详情,使用 WebSocket 消息中的已成交数量
description += `\n已成交: ${filled}`
}
// 根据订单类型选择通知类型
let notificationType: 'info' | 'success' | 'warning' | 'error' = 'info'
if (order.type === 'PLACEMENT') {
notificationType = 'info'
} else if (order.type === 'UPDATE') {
notificationType = 'success'
} else if (order.type === 'CANCELLATION') {
notificationType = 'warning'
}
// 显示通知
notification[notificationType]({
message: title,
description: description,
placement: 'topRight',
duration: order.type === 'CANCELLATION' ? 3 : 5, // 取消订单通知显示时间短一些
key: `order-${order.id}`, // 使用订单 ID 作为 key,避免重复通知
})
}, [getOrderTypeText])
// 应用启动时立即建立全局 WebSocket 连接 // 应用启动时立即建立全局 WebSocket 连接
useEffect(() => { useEffect(() => {
// 立即建立连接(如果还未连接) // 立即建立连接(如果还未连接)
@@ -26,6 +101,17 @@ function App() {
// WebSocket 连接会在整个应用生命周期中保持,并自动重连 // WebSocket 连接会在整个应用生命周期中保持,并自动重连
}, []) }, [])
// 订阅订单推送并显示全局通知
useEffect(() => {
const unsubscribe = wsManager.subscribe('order', (data: OrderPushMessage) => {
handleOrderPush(data)
})
return () => {
unsubscribe()
}
}, [handleOrderPush])
return ( return (
<ConfigProvider locale={zhCN}> <ConfigProvider locale={zhCN}>
<BrowserRouter> <BrowserRouter>
+2 -2
View File
@@ -1,12 +1,12 @@
import { useEffect, useState, useRef } from 'react' import { useEffect, useState, useRef } from 'react'
import { wsManager, SubscriptionCallback } from '../services/websocket' import { wsManager } from '../services/websocket'
/** /**
* 使 WebSocket * 使 WebSocket
*/ */
export function useWebSocketSubscription<T = any>( export function useWebSocketSubscription<T = any>(
channel: string, channel: string,
callback: SubscriptionCallback, callback: (data: T) => void,
payload?: any payload?: any
): { connected: boolean } { ): { connected: boolean } {
const [connected, setConnected] = useState(wsManager.isConnected()) const [connected, setConnected] = useState(wsManager.isConnected())
+48
View File
@@ -210,3 +210,51 @@ export function getPositionKey(position: AccountPosition): string {
return `${position.accountId}-${position.marketId}-${position.side}` return `${position.accountId}-${position.marketId}-${position.side}`
} }
/**
* Polymarket WebSocket User Channel
*/
export interface OrderMessage {
asset_id: string
associate_trades?: string[]
event_type: string // "order"
id: string // order id
market: string // condition ID of market
order_owner: string // owner of order
original_size: string // original order size
outcome: string // outcome
owner: string // owner of orders
price: string // price of order
side: string // BUY/SELL
size_matched: string // size of order that has been matched
timestamp: string // time of event
type: string // PLACEMENT/UPDATE/CANCELLATION
}
/**
* API
*/
export interface OrderDetail {
id: string // 订单 ID
market: string // 市场 ID (condition ID)
side: string // BUY/SELL
price: string // 价格
size: string // 订单大小
filled: string // 已成交数量
status: string // 订单状态
createdAt: string // 创建时间(ISO 8601 格式)
marketName?: string // 市场名称
marketSlug?: string // 市场 slug
marketIcon?: string // 市场图标
}
/**
*
*/
export interface OrderPushMessage {
accountId: number
accountName: string
order: OrderMessage // 订单信息(来自 WebSocket
orderDetail?: OrderDetail // 订单详情(通过 API 获取)
timestamp?: number // 推送时间戳
}