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
import com.google.gson.annotations.SerializedName
import retrofit2.Response
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(
@Path("orderId") orderId: String
): Response<OrderResponse>
): Response<OpenOrder>
/**
* 获取活跃订单
@@ -191,6 +196,36 @@ data class OrderResponse(
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(
val orderId: 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 walletAddress: String, // 钱包地址(前端从私钥推导,用于验证)
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(
val accountId: Long,
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 accountName: String?,
val isDefault: Boolean,
val isEnabled: Boolean, // 是否启用(用于订单推送等功能的开关)
val apiKeyConfigured: Boolean, // API Key 是否已配置(不返回实际 Key)
val apiSecretConfigured: Boolean, // API Secret 是否已配置
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)
val isDefault: Boolean = false, // 是否默认账户
@Column(name = "is_enabled", nullable = false)
val isEnabled: Boolean = true, // 是否启用(用于订单推送等功能的开关)
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@@ -22,7 +22,8 @@ class AccountService(
private val clobService: PolymarketClobService,
private val retrofitFactory: RetrofitFactory,
private val blockchainService: BlockchainService,
private val apiKeyService: PolymarketApiKeyService
private val apiKeyService: PolymarketApiKeyService,
private val orderPushService: OrderPushService
) {
private val logger = LoggerFactory.getLogger(AccountService::class.java)
@@ -112,12 +113,16 @@ class AccountService(
apiPassphrase = apiKeyCreds.passphrase,
accountName = request.accountName,
isDefault = request.isDefault,
isEnabled = request.isEnabled,
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis()
)
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))
} catch (e: Exception) {
@@ -147,14 +152,21 @@ class AccountService(
}
}
// 更新启用状态
val updatedIsEnabled = request.isEnabled ?: account.isEnabled
val updated = account.copy(
accountName = updatedAccountName,
isDefault = updatedIsDefault,
isEnabled = updatedIsEnabled,
updatedAt = System.currentTimeMillis()
)
val saved = accountRepository.save(updated)
logger.info("成功更新账户: ${saved.id}")
logger.info("成功更新账户: ${saved.id}, 启用状态: ${saved.isEnabled}")
// 刷新订单推送订阅(账户状态变更时)
orderPushService.refreshSubscriptions()
Result.success(toDto(saved))
} catch (e: Exception) {
@@ -194,6 +206,9 @@ class AccountService(
accountRepository.delete(account)
logger.info("成功删除账户: $accountId")
// 刷新订单推送订阅(账户删除时)
orderPushService.refreshSubscriptions()
Result.success(Unit)
} catch (e: Exception) {
logger.error("删除账户失败", e)
@@ -370,6 +385,7 @@ class AccountService(
walletAddress = account.walletAddress,
accountName = account.accountName,
isDefault = account.isDefault,
isEnabled = account.isEnabled,
apiKeyConfigured = account.apiKey != null,
apiSecretConfigured = account.apiSecret != 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
import com.wrbug.polymarketbot.api.*
import com.wrbug.polymarketbot.util.RetrofitFactory
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
@@ -10,7 +11,8 @@ import org.springframework.stereotype.Service
*/
@Service
class PolymarketClobService(
private val clobApi: PolymarketClobApi
private val clobApi: PolymarketClobApi, // 用于不需要认证的接口
private val retrofitFactory: RetrofitFactory // 用于创建带认证的客户端
) {
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
import com.wrbug.polymarketbot.dto.OrderPushMessage
import com.wrbug.polymarketbot.dto.PositionPushMessage
import com.wrbug.polymarketbot.dto.WebSocketMessage as WsMessage
import com.wrbug.polymarketbot.dto.WebSocketMessageType
@@ -14,7 +15,8 @@ import java.util.concurrent.ConcurrentHashMap
*/
@Service
class WebSocketSubscriptionService(
private val positionPushService: PositionPushService
private val positionPushService: PositionPushService,
private val orderPushService: OrderPushService
) {
private val logger = LoggerFactory.getLogger(WebSocketSubscriptionService::class.java)
@@ -31,6 +33,9 @@ class WebSocketSubscriptionService(
// 存储每个频道的订阅会话数:channel -> Set<sessionId>
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)
}
// 清理 order 频道的回调
orderChannelCallbacks.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 -> {
logger.warn("未知的频道: $channel")
sendSubscribeAck(sessionId, channel, false, "未知的频道")
@@ -112,6 +129,14 @@ class WebSocketSubscriptionService(
// 取消推送服务的订阅(推送服务内部会处理是否停止轮询)
when (channel) {
"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 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客户端
* @return OkHttpClient.Builder
@@ -99,13 +99,6 @@ class PolymarketAuthInterceptor(
.header("User-Agent", "@polymarket/clob-client")
.header("Accept", "*/*")
.header("Connection", "keep-alive")
.header("Content-Type", "application/json")
.apply {
// GET 请求添加 Accept-Encoding: gzip
if (method == "GET") {
header("Accept-Encoding", "gzip")
}
}
// 如果有请求体,重新设置请求体
if (newRequestBody != null) {
@@ -1,11 +1,19 @@
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.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.stereotype.Component
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.io.IOException
/**
* Retrofit 客户端工厂
@@ -14,7 +22,9 @@ import retrofit2.converter.gson.GsonConverterFactory
@Component
class RetrofitFactory(
@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 {
val authInterceptor = PolymarketAuthInterceptor(apiKey, apiSecret, apiPassphrase, walletAddress)
// 添加响应日志拦截器,用于调试 JSON 解析错误
val responseLoggingInterceptor = ResponseLoggingInterceptor()
val okHttpClient = createClient()
.addInterceptor(authInterceptor)
.addInterceptor(responseLoggingInterceptor)
.build()
// 创建 lenient 模式的 Gson,允许解析格式不严格的 JSON
val gson = GsonBuilder()
.setLenient()
.create()
return Retrofit.Builder()
.baseUrl(clobBaseUrl)
.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()
.create(PolymarketClobApi::class.java)
}
@@ -53,12 +98,83 @@ class RetrofitFactory(
fun createEthereumRpcApi(rpcUrl: String): EthereumRpcApi {
val okHttpClient = createClient().build()
// 创建 lenient 模式的 Gson
val gson = GsonBuilder()
.setLenient()
.create()
return Retrofit.Builder()
.baseUrl(rpcUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.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
import com.fasterxml.jackson.databind.ObjectMapper
import org.java_websocket.client.WebSocketClient
import org.java_websocket.handshake.ServerHandshake
import com.wrbug.polymarketbot.util.createClient
import com.wrbug.polymarketbot.util.getProxyConfig
import kotlinx.coroutines.*
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.ByteString
import org.slf4j.LoggerFactory
import java.net.URI
/**
* Polymarket WebSocket 客户端
* Polymarket WebSocket 客户端(使用 OkHttp 实现)
* 用于连接到 Polymarket RTDS
* 支持代理配置(通过环境变量 ENABLE_PROXY、PROXY_HOST、PROXY_PORT 控制)
*/
class PolymarketWebSocketClient(
serverUri: URI,
private val objectMapper: ObjectMapper,
private val url: String,
private val sessionId: String,
private val onMessage: (String) -> Unit
) : WebSocketClient(serverUri) {
private val onMessage: (String) -> Unit,
private val onOpen: (() -> Unit)? = null, // 连接建立后的回调,用于发送订阅消息
private val onReconnect: (() -> Unit)? = null // 重连回调,用于重新发送订阅消息
) {
private val logger = LoggerFactory.getLogger(PolymarketWebSocketClient::class.java)
override fun onOpen(handshakedata: ServerHandshake?) {
logger.info("已成功连接到 Polymarket RTDS: $sessionId")
private var webSocket: WebSocket? = null
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) {
logger.debug("收到 Polymarket 消息: $sessionId, $message")
onMessage(message)
/**
* 连接 WebSocket
*/
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() {
if (isOpen) {
try {
closeBlocking()
} catch (e: Exception) {
logger.error("关闭连接失败: $sessionId, ${e.message}", e)
}
try {
shouldReconnect = false // 禁用自动重连
stopReconnect() // 停止重连任务
stopPing()
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
*/
fun sendMessage(message: String) {
if (isOpen) {
val ws = webSocket
if (ws != null && isConnected) {
try {
send(message)
val sent = ws.send(message)
if (!sent) {
logger.warn("发送消息失败(连接可能已关闭): $sessionId")
throw IllegalStateException("WebSocket 连接已关闭,无法发送消息")
}
} catch (e: Exception) {
logger.error("发送消息失败: $sessionId, ${e.message}", e)
throw e
}
} else {
logger.warn("WebSocket 未连接,无法发送消息: $sessionId")
throw IllegalStateException("WebSocket 未连接")
}
}
@@ -71,7 +271,7 @@ class PolymarketWebSocketClient(
* 检查连接状态
*/
fun isConnected(): Boolean {
return isOpen
return isConnected && webSocket != null
}
}
@@ -1,11 +1,9 @@
package com.wrbug.polymarketbot.websocket
import com.fasterxml.jackson.databind.ObjectMapper
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import org.springframework.web.socket.*
import java.net.URI
import java.util.concurrent.ConcurrentHashMap
/**
@@ -13,9 +11,7 @@ import java.util.concurrent.ConcurrentHashMap
* 转发前端 WebSocket 连接到 Polymarket RTDS
*/
@Component
class PolymarketWebSocketHandler(
private val objectMapper: ObjectMapper
) : WebSocketHandler {
class PolymarketWebSocketHandler : WebSocketHandler {
private val logger = LoggerFactory.getLogger(PolymarketWebSocketHandler::class.java)
@@ -33,13 +29,13 @@ class PolymarketWebSocketHandler(
try {
// 创建到 Polymarket 的 WebSocket 连接
val polymarketClient = PolymarketWebSocketClient(
URI(polymarketWsUrl),
objectMapper,
session.id
) { message ->
// 当收到 Polymarket 消息时,转发给客户端
forwardToClient(session.id, message)
}
url = polymarketWsUrl,
sessionId = session.id,
onMessage = { message ->
// 当收到 Polymarket 消息时,转发给客户端
forwardToClient(session.id, message)
}
)
polymarketConnections[session.id] = polymarketClient
@@ -32,8 +32,9 @@ logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} - %msg%n
# Polymarket API 配置
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.gamma.base-url=https://gamma-api.polymarket.com
# Ethereum RPC 配置(用于查询链上余额)
# 可选:如果未配置,将无法查询 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 { ConfigProvider } from 'antd'
import { ConfigProvider, notification } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import Layout from './components/Layout'
import AccountList from './pages/AccountList'
@@ -13,8 +13,83 @@ import ConfigPage from './pages/ConfigPage'
import PositionList from './pages/PositionList'
import Statistics from './pages/Statistics'
import { wsManager } from './services/websocket'
import type { OrderPushMessage } from './types'
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 连接
useEffect(() => {
// 立即建立连接(如果还未连接)
@@ -26,6 +101,17 @@ function App() {
// WebSocket 连接会在整个应用生命周期中保持,并自动重连
}, [])
// 订阅订单推送并显示全局通知
useEffect(() => {
const unsubscribe = wsManager.subscribe('order', (data: OrderPushMessage) => {
handleOrderPush(data)
})
return () => {
unsubscribe()
}
}, [handleOrderPush])
return (
<ConfigProvider locale={zhCN}>
<BrowserRouter>
+2 -2
View File
@@ -1,12 +1,12 @@
import { useEffect, useState, useRef } from 'react'
import { wsManager, SubscriptionCallback } from '../services/websocket'
import { wsManager } from '../services/websocket'
/**
* 使用 WebSocket 订阅
*/
export function useWebSocketSubscription<T = any>(
channel: string,
callback: SubscriptionCallback,
callback: (data: T) => void,
payload?: any
): { connected: boolean } {
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}`
}
/**
* 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 // 推送时间戳
}