feat: 实现订单推送服务和 WebSocket 重连机制
- 实现订单推送服务,支持多账户订单实时推送 - 添加 WebSocket 自动重连机制,支持指数退避策略 - 修复订单详情接口 L2 认证问题,通过 PolymarketClobService 获取 - 配置 Gson lenient 模式,支持解析格式不严格的 JSON - 添加响应日志拦截器,便于调试 API 响应问题 - 使用 Gamma API 获取市场信息,支持通过 condition_ids 查询 - 修复字段映射问题,使用 @SerializedName 替代 @JsonProperty - 订单推送消息包含订单详情和市场信息
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取活跃订单
|
||||
*/
|
||||
|
||||
+26
-1
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+230
-30
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
-12
@@ -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;
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 // 推送时间戳
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user