Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 07b4d654b4 | |||
| b65827038f | |||
| d768da72c6 | |||
| 7385efff1a | |||
| 3e2e97e572 | |||
| ae68a33c1e | |||
| deea59fdbf | |||
| b90f86b081 | |||
| f6f5866118 | |||
| b1e69135b8 | |||
| 0c7f34a28a | |||
| c53fcde5d7 | |||
| 81a620af12 | |||
| 5f44a0ca20 | |||
| 5d2cf945f3 | |||
| 227a38fa89 | |||
| abcc004606 | |||
| 64391503c2 | |||
| 92b75d1926 | |||
| e072d0c894 | |||
| cc40493ec6 | |||
| 3a78a84610 | |||
| 692cbd9a80 | |||
| 43de0104e2 | |||
| 2ccab42894 | |||
| 3008cbcb50 | |||
| 42a318b501 | |||
| ecb737ec67 | |||
| 0f3baec6cb | |||
| 764d684846 |
@@ -130,7 +130,7 @@ export PROXY_PORT=8888
|
||||
- 代理配置错误
|
||||
|
||||
**排查步骤**:
|
||||
1. 检查 `polymarket.rtds.ws-url` 配置是否正确
|
||||
1. 检查 Polymarket RTDS WebSocket URL(现在使用代码常量 `PolymarketConstants.RTDS_WS_URL`)
|
||||
2. 检查网络连接
|
||||
3. 查看详细错误日志
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ package com.wrbug.polymarketbot.config
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import retrofit2.Retrofit
|
||||
@@ -23,9 +23,6 @@ class RetrofitConfig(
|
||||
private val gson: Gson
|
||||
) {
|
||||
|
||||
@Value("\${polymarket.clob.base-url}")
|
||||
private lateinit var clobBaseUrl: String
|
||||
|
||||
/**
|
||||
* 创建 CLOB API 客户端
|
||||
* 用于跟单系统的订单操作和交易查询
|
||||
@@ -38,7 +35,7 @@ class RetrofitConfig(
|
||||
val okHttpClient = createClient().build()
|
||||
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(clobBaseUrl)
|
||||
.baseUrl(PolymarketConstants.CLOB_BASE_URL)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.build()
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.wrbug.polymarketbot.constants
|
||||
|
||||
/**
|
||||
* Polymarket API 常量
|
||||
* 集中管理所有 Polymarket API 的 URL 配置
|
||||
*/
|
||||
object PolymarketConstants {
|
||||
|
||||
/**
|
||||
* Polymarket CLOB API 基础 URL
|
||||
*/
|
||||
const val CLOB_BASE_URL = "https://clob.polymarket.com"
|
||||
|
||||
/**
|
||||
* Polymarket RTDS WebSocket URL
|
||||
* 用于订单推送服务
|
||||
*/
|
||||
const val RTDS_WS_URL = "wss://ws-subscriptions-clob.polymarket.com"
|
||||
|
||||
/**
|
||||
* Polymarket User Channel WebSocket URL
|
||||
* 用于跟单服务(订阅 Leader 交易)
|
||||
*/
|
||||
const val USER_WS_URL = "wss://ws-live-data.polymarket.com"
|
||||
|
||||
/**
|
||||
* Polymarket Activity WebSocket URL
|
||||
* 用于 Activity 全局交易流监听
|
||||
*/
|
||||
const val ACTIVITY_WS_URL = "wss://ws-live-data.polymarket.com"
|
||||
|
||||
/**
|
||||
* Polymarket Data API 基础 URL
|
||||
*/
|
||||
const val DATA_API_BASE_URL = "https://data-api.polymarket.com"
|
||||
|
||||
/**
|
||||
* Polymarket Gamma API 基础 URL
|
||||
*/
|
||||
const val GAMMA_BASE_URL = "https://gamma-api.polymarket.com"
|
||||
|
||||
/**
|
||||
* Builder Relayer API URL
|
||||
* 用于 Gasless 交易
|
||||
*/
|
||||
const val BUILDER_RELAYER_URL = "https://relayer-v2.polymarket.com/"
|
||||
}
|
||||
|
||||
@@ -58,7 +58,10 @@ data class CopyOrderTracking(
|
||||
|
||||
@Column(name = "notification_sent", nullable = false)
|
||||
var notificationSent: Boolean = false, // 是否已发送通知(从订单详情获取实际数据后发送)
|
||||
|
||||
|
||||
@Column(name = "source", nullable = false, length = 20)
|
||||
val source: String, // 订单来源:activity-ws(Polymarket WebSocket)、onchain-ws(OnChain WebSocket)
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
|
||||
+16
-18
@@ -21,29 +21,29 @@ import java.util.concurrent.CopyOnWriteArrayList
|
||||
class PositionPollingService(
|
||||
private val accountService: AccountService
|
||||
) {
|
||||
|
||||
|
||||
private val logger = LoggerFactory.getLogger(PositionPollingService::class.java)
|
||||
|
||||
|
||||
@Value("\${position.polling.interval:2000}")
|
||||
private var pollingInterval: Long = 2000 // 轮训间隔(毫秒),默认2秒
|
||||
|
||||
|
||||
// 订阅者列表(支持多个订阅者)
|
||||
private val subscribers = CopyOnWriteArrayList<(PositionListResponse) -> Unit>()
|
||||
|
||||
|
||||
// 最新仓位数据(用于丢弃机制)
|
||||
@Volatile
|
||||
private var latestPositions: PositionListResponse? = null
|
||||
|
||||
|
||||
// 协程作用域和任务
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
private var pollingJob: Job? = null
|
||||
|
||||
|
||||
// 事件分发协程(使用专门的线程,避免阻塞轮训)
|
||||
private val eventDispatcherScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
|
||||
// 同步锁,确保轮询任务的启动和停止是线程安全的
|
||||
private val lock = Any()
|
||||
|
||||
|
||||
/**
|
||||
* 初始化服务(后端启动时直接启动轮训)
|
||||
*/
|
||||
@@ -52,7 +52,7 @@ class PositionPollingService(
|
||||
logger.info("PositionPollingService 初始化,启动仓位轮训任务,轮训间隔: ${pollingInterval}ms")
|
||||
startPolling()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 清理资源
|
||||
*/
|
||||
@@ -66,7 +66,7 @@ class PositionPollingService(
|
||||
scope.cancel()
|
||||
eventDispatcherScope.cancel()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 订阅仓位事件
|
||||
* @param callback 回调函数,接收最新的仓位数据
|
||||
@@ -78,7 +78,7 @@ class PositionPollingService(
|
||||
latestPositions?.let { callback(it) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 取消订阅仓位事件
|
||||
*/
|
||||
@@ -87,7 +87,7 @@ class PositionPollingService(
|
||||
subscribers.remove(callback)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 启动轮训任务
|
||||
*/
|
||||
@@ -95,7 +95,7 @@ class PositionPollingService(
|
||||
synchronized(lock) {
|
||||
// 如果已经有轮训任务在运行,先取消
|
||||
pollingJob?.cancel()
|
||||
|
||||
|
||||
// 启动新的轮训任务
|
||||
pollingJob = scope.launch {
|
||||
while (isActive) {
|
||||
@@ -109,7 +109,7 @@ class PositionPollingService(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 轮训仓位数据并发布事件
|
||||
* 使用专门的线程分发事件,避免阻塞轮训
|
||||
@@ -123,7 +123,7 @@ class PositionPollingService(
|
||||
if (positions != null) {
|
||||
// 更新最新数据(丢弃旧数据,只保留最新的)
|
||||
latestPositions = positions
|
||||
|
||||
|
||||
// 在专门的线程中分发事件,避免阻塞轮训
|
||||
eventDispatcherScope.launch {
|
||||
try {
|
||||
@@ -131,7 +131,7 @@ class PositionPollingService(
|
||||
val currentSubscribers = synchronized(lock) {
|
||||
subscribers.toList() // 复制列表,避免并发修改
|
||||
}
|
||||
|
||||
|
||||
currentSubscribers.forEach { callback ->
|
||||
try {
|
||||
callback(positions)
|
||||
@@ -139,8 +139,6 @@ class PositionPollingService(
|
||||
logger.error("通知订阅者失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug("发布仓位数据事件: currentPositions=${positions.currentPositions.size}, historyPositions=${positions.historyPositions.size}, subscribers=${currentSubscribers.size}")
|
||||
} catch (e: Exception) {
|
||||
logger.error("分发仓位数据事件失败: ${e.message}", e)
|
||||
}
|
||||
|
||||
@@ -7,11 +7,11 @@ import com.wrbug.polymarketbot.api.JsonRpcResponse
|
||||
import com.wrbug.polymarketbot.api.PolymarketDataApi
|
||||
import com.wrbug.polymarketbot.api.PositionResponse
|
||||
import com.wrbug.polymarketbot.api.ValueResponse
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import com.wrbug.polymarketbot.util.EthereumUtils
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import com.wrbug.polymarketbot.service.system.RelayClientService
|
||||
import com.wrbug.polymarketbot.service.system.RpcNodeService
|
||||
import org.springframework.stereotype.Service
|
||||
@@ -26,8 +26,6 @@ import java.math.BigInteger
|
||||
*/
|
||||
@Service
|
||||
class BlockchainService(
|
||||
@Value("\${polymarket.data-api.base-url:https://data-api.polymarket.com}")
|
||||
private val dataApiBaseUrl: String,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val relayClientService: RelayClientService,
|
||||
private val rpcNodeService: RpcNodeService,
|
||||
@@ -61,10 +59,10 @@ class BlockchainService(
|
||||
private val computeProxyAddressFunctionSignature = "computeProxyAddress(address)"
|
||||
|
||||
private val dataApi: PolymarketDataApi by lazy {
|
||||
val baseUrl = if (dataApiBaseUrl.endsWith("/")) {
|
||||
dataApiBaseUrl.dropLast(1)
|
||||
val baseUrl = if (PolymarketConstants.DATA_API_BASE_URL.endsWith("/")) {
|
||||
PolymarketConstants.DATA_API_BASE_URL.dropLast(1)
|
||||
} else {
|
||||
dataApiBaseUrl
|
||||
PolymarketConstants.DATA_API_BASE_URL
|
||||
}
|
||||
val okHttpClient = createClient()
|
||||
.followRedirects(true)
|
||||
|
||||
@@ -89,8 +89,6 @@ class MarketPollingService(
|
||||
*/
|
||||
private suspend fun checkAndUpdateMissingMarkets() {
|
||||
try {
|
||||
logger.debug("开始检查缺失的市场信息...")
|
||||
|
||||
// 1. 获取所有买入订单的市场ID(去重)
|
||||
val allOrders = copyOrderTrackingRepository.findAll()
|
||||
val marketIds = allOrders.map { it.marketId }.distinct()
|
||||
@@ -99,9 +97,6 @@ class MarketPollingService(
|
||||
logger.debug("没有找到任何订单,跳过市场信息检查")
|
||||
return
|
||||
}
|
||||
|
||||
logger.debug("找到 ${marketIds.size} 个不同的市场ID")
|
||||
|
||||
// 2. 检查哪些市场信息在数据库中缺失
|
||||
val existingMarkets = marketService.marketRepository.findByMarketIdIn(marketIds)
|
||||
val existingMarketIds = existingMarkets.map { it.marketId }.toSet()
|
||||
@@ -113,7 +108,6 @@ class MarketPollingService(
|
||||
}
|
||||
|
||||
if (validMissingMarketIds.isEmpty()) {
|
||||
logger.debug("所有市场信息都已存在,无需更新")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+3
-5
@@ -3,12 +3,12 @@ package com.wrbug.polymarketbot.service.common
|
||||
import com.google.gson.Gson
|
||||
import com.wrbug.polymarketbot.api.ApiKeyResponse
|
||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import com.wrbug.polymarketbot.util.PolymarketL1AuthInterceptor
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Service
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
@@ -19,8 +19,6 @@ import retrofit2.converter.gson.GsonConverterFactory
|
||||
*/
|
||||
@Service
|
||||
class PolymarketApiKeyService(
|
||||
@Value("\${polymarket.clob.base-url}")
|
||||
private val clobBaseUrl: String,
|
||||
private val gson: Gson
|
||||
) {
|
||||
|
||||
@@ -224,7 +222,7 @@ class PolymarketApiKeyService(
|
||||
.build()
|
||||
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(clobBaseUrl)
|
||||
.baseUrl(PolymarketConstants.CLOB_BASE_URL)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.build()
|
||||
@@ -238,7 +236,7 @@ class PolymarketApiKeyService(
|
||||
val okHttpClient = createClient().build()
|
||||
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(clobBaseUrl)
|
||||
.baseUrl(PolymarketConstants.CLOB_BASE_URL)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.build()
|
||||
|
||||
+11
-3
@@ -71,12 +71,20 @@ class CopyTradingFilterService(
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 检查是否需要获取订单簿
|
||||
// 只有在配置了需要订单簿的过滤条件时才获取
|
||||
// 3. 检查是否需要获取订单簿或需要执行仓位检查
|
||||
// 只有在配置了需要订单簿的过滤条件时才获取订单簿
|
||||
val needOrderbook = copyTrading.maxSpread != null || copyTrading.minOrderDepth != null
|
||||
|
||||
// 3.5. 如果不需要订单簿,则跳过订单簿相关的检查,但仍然需要检查仓位限制
|
||||
if (!needOrderbook) {
|
||||
// 不需要订单簿,直接通过
|
||||
// 仓位检查(如果配置了最大仓位限制且提供了跟单金额和市场ID)
|
||||
if (copyOrderAmount != null && marketId != null) {
|
||||
val positionCheck = checkPositionLimits(copyTrading, copyOrderAmount, marketId)
|
||||
if (!positionCheck.isPassed) {
|
||||
return positionCheck
|
||||
}
|
||||
}
|
||||
// 通过所有检查
|
||||
return FilterResult.passed()
|
||||
}
|
||||
|
||||
|
||||
+19
-3
@@ -14,6 +14,8 @@ import com.wrbug.polymarketbot.util.IllegalBigDecimal
|
||||
import com.wrbug.polymarketbot.util.JsonUtils
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.ApplicationContext
|
||||
import org.springframework.context.ApplicationContextAware
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
@@ -30,10 +32,24 @@ class CopyTradingService(
|
||||
private val monitorService: CopyTradingMonitorService,
|
||||
private val jsonUtils: JsonUtils,
|
||||
private val gson: Gson
|
||||
) {
|
||||
|
||||
) : ApplicationContextAware {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CopyTradingService::class.java)
|
||||
|
||||
private var applicationContext: ApplicationContext? = null
|
||||
|
||||
override fun setApplicationContext(applicationContext: ApplicationContext) {
|
||||
this.applicationContext = applicationContext
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取代理对象,用于解决 @Transactional 自调用问题
|
||||
*/
|
||||
private fun getSelf(): CopyTradingService {
|
||||
return applicationContext?.getBean(CopyTradingService::class.java)
|
||||
?: throw IllegalStateException("ApplicationContext not initialized")
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建跟单配置
|
||||
* 支持两种方式:
|
||||
@@ -331,7 +347,7 @@ class CopyTradingService(
|
||||
*/
|
||||
@Transactional
|
||||
fun updateCopyTradingStatus(request: CopyTradingUpdateStatusRequest): Result<CopyTradingDto> {
|
||||
return updateCopyTrading(
|
||||
return getSelf().updateCopyTrading(
|
||||
CopyTradingUpdateRequest(
|
||||
copyTradingId = request.copyTradingId,
|
||||
enabled = request.enabled
|
||||
|
||||
+2
-2
@@ -114,10 +114,10 @@ class AccountOnChainMonitorService(
|
||||
}
|
||||
|
||||
val receiptRpcResponse = receiptResponse.body()!!
|
||||
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null) {
|
||||
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null || receiptRpcResponse.result.isJsonNull) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// 使用 Gson 解析 receipt JSON
|
||||
val receiptJson = receiptRpcResponse.result.asJsonObject
|
||||
|
||||
|
||||
+3
-4
@@ -9,8 +9,8 @@ import com.wrbug.polymarketbot.repository.CopyTradingTemplateRepository
|
||||
import com.wrbug.polymarketbot.websocket.PolymarketWebSocketClient
|
||||
import jakarta.annotation.PreDestroy
|
||||
import kotlinx.coroutines.*
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
@@ -28,8 +28,7 @@ class CopyTradingWebSocketService(
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CopyTradingWebSocketService::class.java)
|
||||
|
||||
@Value("\${polymarket.websocket.url:wss://ws-live-data.polymarket.com}")
|
||||
private var websocketUrl: String = "wss://ws-live-data.polymarket.com"
|
||||
private val websocketUrl: String = PolymarketConstants.USER_WS_URL
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
// 存储每个Leader的WebSocket客户端:leaderId -> WebSocketClient
|
||||
@@ -214,7 +213,7 @@ class CopyTradingWebSocketService(
|
||||
// 处理交易
|
||||
scope.launch {
|
||||
try {
|
||||
copyOrderTrackingService.processTrade(leaderId, trade, "websocket")
|
||||
copyOrderTrackingService.processTrade(leaderId, trade, "activity-ws")
|
||||
} catch (e: Exception) {
|
||||
logger.error("处理交易失败: leaderId=$leaderId, tradeId=${trade.id}", e)
|
||||
}
|
||||
|
||||
+49
-27
@@ -1,5 +1,8 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.monitor
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache
|
||||
import com.github.benmanes.caffeine.cache.Caffeine
|
||||
import com.google.gson.JsonNull
|
||||
import com.wrbug.polymarketbot.api.*
|
||||
import com.wrbug.polymarketbot.entity.Leader
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
@@ -11,6 +14,7 @@ import okhttp3.OkHttpClient
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* 链上 WebSocket 监听服务
|
||||
@@ -23,12 +27,17 @@ class OnChainWsService(
|
||||
private val copyOrderTrackingService: CopyOrderTrackingService,
|
||||
private val leaderRepository: LeaderRepository
|
||||
) {
|
||||
|
||||
|
||||
private val logger = LoggerFactory.getLogger(OnChainWsService::class.java)
|
||||
|
||||
|
||||
// 存储需要监听的Leader:leaderId -> Leader
|
||||
private val monitoredLeaders = ConcurrentHashMap<Long, Leader>()
|
||||
|
||||
|
||||
// 存储已处理的交易哈希,用于去重(LRU 缓存,保留最近 100 条)
|
||||
private val processedTxHashes: Cache<String, Long> = Caffeine.newBuilder()
|
||||
.maximumSize(100)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* 启动链上 WebSocket 监听
|
||||
* 通过统一服务订阅所有 Leader
|
||||
@@ -40,14 +49,14 @@ class OnChainWsService(
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// 更新 Leader 列表
|
||||
monitoredLeaders.clear()
|
||||
leaders.forEach { leader ->
|
||||
addLeader(leader)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 添加Leader监听
|
||||
* 通过统一服务订阅该 Leader 的地址
|
||||
@@ -57,17 +66,17 @@ class OnChainWsService(
|
||||
logger.warn("Leader ID为空,跳过: ${leader.leaderAddress}")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
val leaderId = leader.id!!
|
||||
|
||||
|
||||
// 如果已经在监听列表中,不重复添加
|
||||
if (monitoredLeaders.containsKey(leaderId)) {
|
||||
logger.debug("Leader 已在监听列表中: ${leader.leaderName} (${leader.leaderAddress})")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
monitoredLeaders[leaderId] = leader
|
||||
|
||||
|
||||
// 通过统一服务订阅
|
||||
val subscriptionId = "LEADER_$leaderId"
|
||||
unifiedOnChainWsService.subscribe(
|
||||
@@ -79,40 +88,53 @@ class OnChainWsService(
|
||||
handleLeaderTransaction(leaderId, txHash, httpClient, rpcApi)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
logger.info("添加 Leader 监听: ${leader.leaderName} (${leader.leaderAddress})")
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 处理 Leader 的交易
|
||||
*/
|
||||
private suspend fun handleLeaderTransaction(leaderId: Long, txHash: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
|
||||
private suspend fun handleLeaderTransaction(
|
||||
leaderId: Long,
|
||||
txHash: String,
|
||||
httpClient: OkHttpClient,
|
||||
rpcApi: EthereumRpcApi
|
||||
) {
|
||||
val leader = monitoredLeaders[leaderId] ?: return
|
||||
|
||||
|
||||
// 根据 txHash 去重(使用原子操作避免竞态条件)
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val existingTimestamp = processedTxHashes.asMap().putIfAbsent(txHash, currentTime)
|
||||
if (existingTimestamp != null) {
|
||||
logger.debug("交易已处理过,跳过: leaderId=$leaderId, txHash=$txHash, firstProcessedAt=$existingTimestamp")
|
||||
return
|
||||
}
|
||||
|
||||
logger.debug("开始处理 Leader 交易: leaderId=$leaderId, txHash=$txHash, leaderAddress=${leader.leaderAddress}")
|
||||
|
||||
|
||||
try {
|
||||
// 获取交易 receipt
|
||||
val receiptRequest = JsonRpcRequest(
|
||||
method = "eth_getTransactionReceipt",
|
||||
params = listOf(txHash)
|
||||
)
|
||||
|
||||
|
||||
val receiptResponse = rpcApi.call(receiptRequest)
|
||||
if (!receiptResponse.isSuccessful || receiptResponse.body() == null) {
|
||||
logger.warn("获取交易 receipt 失败: leaderId=$leaderId, txHash=$txHash, code=${receiptResponse.code()}")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
val receiptRpcResponse = receiptResponse.body()!!
|
||||
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null) {
|
||||
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null || receiptRpcResponse.result is JsonNull) {
|
||||
logger.warn("交易 receipt 错误: leaderId=$leaderId, txHash=$txHash, error=${receiptRpcResponse.error}")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// 使用 Gson 解析 receipt JSON
|
||||
val receiptJson = receiptRpcResponse.result.asJsonObject
|
||||
|
||||
|
||||
// 获取区块号和时间戳
|
||||
val blockNumber = receiptJson.get("blockNumber")?.asString
|
||||
val blockTimestamp = if (blockNumber != null) {
|
||||
@@ -120,7 +142,7 @@ class OnChainWsService(
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
|
||||
// 解析 receipt 中的 Transfer 日志
|
||||
val logs = receiptJson.getAsJsonArray("logs") ?: run {
|
||||
logger.warn("交易 receipt 中没有日志: leaderId=$leaderId, txHash=$txHash")
|
||||
@@ -128,7 +150,7 @@ class OnChainWsService(
|
||||
}
|
||||
val (erc20Transfers, erc1155Transfers) = OnChainWsUtils.parseReceiptTransfers(logs)
|
||||
logger.debug("解析交易日志: leaderId=$leaderId, txHash=$txHash, erc20Transfers=${erc20Transfers.size}, erc1155Transfers=${erc1155Transfers.size}")
|
||||
|
||||
|
||||
// 解析交易信息
|
||||
val trade = OnChainWsUtils.parseTradeFromTransfers(
|
||||
txHash = txHash,
|
||||
@@ -138,7 +160,7 @@ class OnChainWsService(
|
||||
erc1155Transfers = erc1155Transfers,
|
||||
retrofitFactory = retrofitFactory
|
||||
)
|
||||
|
||||
|
||||
if (trade != null) {
|
||||
logger.info("成功解析交易: leaderId=$leaderId, txHash=$txHash, side=${trade.side}, market=${trade.market}, size=${trade.size}")
|
||||
// 调用 processTrade 处理交易
|
||||
@@ -154,21 +176,21 @@ class OnChainWsService(
|
||||
logger.error("处理 Leader 交易失败: leaderId=$leaderId, txHash=$txHash, ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 移除Leader监听
|
||||
* 取消该 Leader 的订阅
|
||||
*/
|
||||
fun removeLeader(leaderId: Long) {
|
||||
monitoredLeaders.remove(leaderId)
|
||||
|
||||
|
||||
// 通过统一服务取消订阅
|
||||
val subscriptionId = "LEADER_$leaderId"
|
||||
unifiedOnChainWsService.unsubscribe(subscriptionId)
|
||||
|
||||
|
||||
logger.info("移除 Leader 监听: leaderId=$leaderId")
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 停止监听
|
||||
*/
|
||||
@@ -180,7 +202,7 @@ class OnChainWsService(
|
||||
}
|
||||
monitoredLeaders.clear()
|
||||
}
|
||||
|
||||
|
||||
@PreDestroy
|
||||
fun destroy() {
|
||||
stop()
|
||||
|
||||
+181
-17
@@ -1,5 +1,7 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.monitor
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache
|
||||
import com.github.benmanes.caffeine.cache.Caffeine
|
||||
import com.wrbug.polymarketbot.api.TradeResponse
|
||||
import com.wrbug.polymarketbot.dto.ActivityTradeMessage
|
||||
import com.wrbug.polymarketbot.dto.ActivityTradePayload
|
||||
@@ -7,18 +9,19 @@ import com.wrbug.polymarketbot.entity.Leader
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
|
||||
import com.wrbug.polymarketbot.util.fromJson
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import com.wrbug.polymarketbot.websocket.PolymarketWebSocketClient
|
||||
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.math.BigDecimal
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Polymarket Activity WebSocket 监听服务
|
||||
* 通过订阅全局 activity 交易流,客户端过滤 Leader 地址,实现实时交易检测
|
||||
* 通过订阅全局 activity 交易流(trades + orders_matched),客户端过滤 Leader 地址,实现实时交易检测
|
||||
* 延迟 < 100ms,适合快速跟单场景
|
||||
*/
|
||||
@Service
|
||||
@@ -29,8 +32,7 @@ class PolymarketActivityWsService(
|
||||
|
||||
private val logger = LoggerFactory.getLogger(PolymarketActivityWsService::class.java)
|
||||
|
||||
@Value("\${polymarket.websocket.activity.url:wss://ws-live-data.polymarket.com}")
|
||||
private var websocketUrl: String = "wss://ws-live-data.polymarket.com"
|
||||
private val websocketUrl: String = PolymarketConstants.ACTIVITY_WS_URL
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
@@ -40,10 +42,30 @@ class PolymarketActivityWsService(
|
||||
// 要监听的 Leader 地址集合(小写地址 -> leaderId)
|
||||
private val monitoredAddresses = ConcurrentHashMap<String, Long>()
|
||||
|
||||
// 存储已处理的交易哈希,用于去重(LRU 缓存,保留最近 100 条)
|
||||
// 因为同时订阅 trades 和 orders_matched,同一个交易可能被推送两次
|
||||
private val processedTxHashes: Cache<String, Long> = Caffeine.newBuilder()
|
||||
.maximumSize(100)
|
||||
.expireAfterWrite(10, TimeUnit.MINUTES)
|
||||
.build()
|
||||
|
||||
// 是否已订阅
|
||||
@Volatile
|
||||
private var isSubscribed = false
|
||||
|
||||
// 最后一次收到 activity 消息的时间(毫秒时间戳)
|
||||
@Volatile
|
||||
private var lastActivityTime: Long = 0
|
||||
|
||||
// Activity 消息超时检测任务
|
||||
private var activityTimeoutJob: Job? = null
|
||||
|
||||
// 性能统计
|
||||
private var totalMessagesProcessed = 0L
|
||||
private var addressMatchMessages = 0L
|
||||
private var jsonParseMessages = 0L
|
||||
private var duplicateTxHashMessages = 0L
|
||||
|
||||
/**
|
||||
* 启动监听
|
||||
*/
|
||||
@@ -62,7 +84,7 @@ class PolymarketActivityWsService(
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("启动 Activity WebSocket 监听,监控 ${monitoredAddresses.size} 个 Leader 地址")
|
||||
logger.info("启动 Activity WebSocket 监听(trades + orders_matched),监控 ${monitoredAddresses.size} 个 Leader 地址")
|
||||
connectAndSubscribe()
|
||||
}
|
||||
|
||||
@@ -159,6 +181,7 @@ class PolymarketActivityWsService(
|
||||
* 订阅全局 activity
|
||||
* 根据 @polymarket/real-time-data-client 的协议格式
|
||||
* 使用 "action": "subscribe" 而不是 "type": "subscribe"
|
||||
* 同时订阅 trades 和 orders_matched 两种类型
|
||||
*/
|
||||
private fun subscribeAllActivity() {
|
||||
val client = wsClient
|
||||
@@ -170,6 +193,7 @@ class PolymarketActivityWsService(
|
||||
try {
|
||||
// 根据 real-time-data-client 的协议格式
|
||||
// 订阅消息应包含 "action": "subscribe" 和 "subscriptions" 数组
|
||||
// 同时订阅 trades 和 orders_matched 两种类型
|
||||
val subscribeMessage = """
|
||||
{
|
||||
"action": "subscribe",
|
||||
@@ -177,6 +201,10 @@ class PolymarketActivityWsService(
|
||||
{
|
||||
"topic": "activity",
|
||||
"type": "trades"
|
||||
},
|
||||
{
|
||||
"topic": "activity",
|
||||
"type": "orders_matched"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -184,46 +212,154 @@ class PolymarketActivityWsService(
|
||||
|
||||
client.sendMessage(subscribeMessage)
|
||||
isSubscribed = true
|
||||
logger.info("Activity WebSocket 订阅成功(全局交易流)")
|
||||
// 重置最后一次收到 activity 消息的时间
|
||||
lastActivityTime = System.currentTimeMillis()
|
||||
// 启动 Activity 消息超时检测
|
||||
// startActivityTimeoutCheck()
|
||||
logger.info("Activity WebSocket 订阅成功(全局交易流: trades + orders_matched)")
|
||||
} catch (e: Exception) {
|
||||
logger.error("订阅 Activity WebSocket 失败", e)
|
||||
isSubscribed = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动 Activity 消息超时检测
|
||||
* 每30秒检查一次,如果超过30秒没有收到activity消息,则重连
|
||||
*/
|
||||
private fun startActivityTimeoutCheck() {
|
||||
// 先停止之前的检测任务
|
||||
stopActivityTimeoutCheck()
|
||||
|
||||
activityTimeoutJob = scope.launch {
|
||||
while (isActive && isSubscribed) {
|
||||
delay(30000) // 每30秒检查一次
|
||||
|
||||
// 如果已经取消订阅,停止检测
|
||||
if (!isSubscribed) {
|
||||
break
|
||||
}
|
||||
|
||||
// 如果 lastActivityTime 为 0,说明还没有收到过消息,跳过本次检测
|
||||
if (lastActivityTime == 0L) {
|
||||
continue
|
||||
}
|
||||
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val timeSinceLastActivity = currentTime - lastActivityTime
|
||||
|
||||
// 如果超过30秒没有收到activity消息,触发重连
|
||||
if (timeSinceLastActivity >= 30000) {
|
||||
logger.warn("超过30秒未收到 Activity 消息,触发重连。距离上次消息: ${timeSinceLastActivity}ms")
|
||||
// 关闭当前连接并重连
|
||||
wsClient?.closeConnection()
|
||||
wsClient = null
|
||||
isSubscribed = false
|
||||
// 重新连接
|
||||
connectAndSubscribe()
|
||||
break // 重连后会重新启动检测任务
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止 Activity 消息超时检测
|
||||
*/
|
||||
private fun stopActivityTimeoutCheck() {
|
||||
activityTimeoutJob?.cancel()
|
||||
activityTimeoutJob = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查消息是否包含监听的 Leader 地址
|
||||
* 快速过滤,避免不必要的 JSON 解析
|
||||
* 只需要检查 "proxyWallet":"0x..." 或 "trader":{"address":"0x..."} 格式
|
||||
*/
|
||||
private fun containsMonitoredAddress(message: String): Boolean {
|
||||
// 快速检查:如果消息很短,不可能包含地址
|
||||
if (message.length < 50) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 遍历所有监听的地址
|
||||
for ((address, leaderId) in monitoredAddresses) {
|
||||
// 检查 proxyWallet:格式为 "proxyWallet":"0x..."
|
||||
if (message.contains("\"proxyWallet\":\"$address\"", ignoreCase = true)) {
|
||||
addressMatchMessages++
|
||||
return true
|
||||
}
|
||||
|
||||
// 检查 trader.address:格式为 "trader":{"address":"0x..."}
|
||||
if (message.contains("\"trader\"", ignoreCase = true) &&
|
||||
message.contains("\"address\":\"$address\"", ignoreCase = true)
|
||||
) {
|
||||
addressMatchMessages++
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理消息
|
||||
*/
|
||||
private fun handleMessage(message: String) {
|
||||
try {
|
||||
totalMessagesProcessed++
|
||||
|
||||
// 处理 PONG 响应
|
||||
if (message.trim() == "PONG" || message.trim() == "pong") {
|
||||
return
|
||||
}
|
||||
|
||||
// 使用扩展函数解析消息
|
||||
|
||||
// 快速预检查:检查是否包含监听地址
|
||||
// 绝大部分消息会在这一步被过滤掉,避免不必要的 JSON 解析
|
||||
if (!containsMonitoredAddress(message)) {
|
||||
return
|
||||
}
|
||||
logger.info("发现leader交易:${message}")
|
||||
// 使用扩展函数解析消息(只对包含监听地址的消息)
|
||||
val tradeMessage = message.fromJson<ActivityTradeMessage>() ?: run {
|
||||
// 不是有效的 JSON 或格式不匹配,跳过
|
||||
logger.warn("无法解析为 ActivityTradeMessage,可能不是 activity 消息: ${message.take(200)}")
|
||||
logger.warn("无法解析为 ActivityTradeMessage: ${message.take(200)}")
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否是 activity trade 消息
|
||||
if (tradeMessage.topic != "activity" || tradeMessage.type != "trades") {
|
||||
// 不是我们关心的消息,直接返回
|
||||
|
||||
jsonParseMessages++
|
||||
|
||||
// 检查是否是 activity 消息(trades 或 orders_matched)
|
||||
if (tradeMessage.topic != "activity" ||
|
||||
(tradeMessage.type != "trades" && tradeMessage.type != "orders_matched")) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// 更新最后一次收到 activity 消息的时间(即使不是我们监听的 Leader 的交易)
|
||||
lastActivityTime = System.currentTimeMillis()
|
||||
|
||||
val payload = tradeMessage.payload
|
||||
|
||||
|
||||
// 根据 txHash 去重(使用原子操作避免竞态条件)
|
||||
val txHash = payload.transactionHash
|
||||
if (txHash != null && txHash.isNotBlank()) {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val existingTimestamp = processedTxHashes.asMap().putIfAbsent(txHash, currentTime)
|
||||
if (existingTimestamp != null) {
|
||||
duplicateTxHashMessages++
|
||||
logger.debug("交易已处理过,跳过: txHash=$txHash, firstProcessedAt=$existingTimestamp, type=${tradeMessage.type}")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 提取交易者地址
|
||||
val traderAddress = extractTraderAddress(payload) ?: run {
|
||||
// 没有交易者地址,跳过
|
||||
logger.warn("Activity Trade 消息中没有交易者地址: trader=${payload.trader}, proxyWallet=${payload.proxyWallet}, asset=${payload.asset}")
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否是我们监听的 Leader
|
||||
|
||||
// 二次验证:确认地址匹配
|
||||
val normalizedAddress = traderAddress.lowercase()
|
||||
val leaderId = monitoredAddresses[normalizedAddress] ?: run {
|
||||
return
|
||||
@@ -383,10 +519,13 @@ class PolymarketActivityWsService(
|
||||
*/
|
||||
fun stop() {
|
||||
logger.info("停止 Activity WebSocket 监听")
|
||||
stopActivityTimeoutCheck()
|
||||
wsClient?.closeConnection()
|
||||
wsClient = null
|
||||
isSubscribed = false
|
||||
monitoredAddresses.clear()
|
||||
processedTxHashes.invalidateAll() // 清空去重缓存
|
||||
lastActivityTime = 0
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -403,8 +542,33 @@ class PolymarketActivityWsService(
|
||||
return monitoredAddresses.size
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取性能统计信息
|
||||
*/
|
||||
fun getPerformanceStats(): Map<String, Any> {
|
||||
val jsonParseRate = if (totalMessagesProcessed > 0) {
|
||||
(jsonParseMessages.toDouble() / totalMessagesProcessed * 100).toInt()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
return mapOf(
|
||||
"totalMessages" to totalMessagesProcessed,
|
||||
"addressMatches" to addressMatchMessages,
|
||||
"jsonParses" to jsonParseMessages,
|
||||
"duplicateTxHashes" to duplicateTxHashMessages,
|
||||
"jsonParseRate" to "$jsonParseRate%",
|
||||
"filteringEfficiency" to if (totalMessagesProcessed > 0) {
|
||||
((1.0 - jsonParseMessages.toDouble() / totalMessagesProcessed) * 100).toInt()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
fun destroy() {
|
||||
logger.info("Activity WS 性能统计: ${getPerformanceStats()}")
|
||||
stop()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
+14
-2
@@ -129,12 +129,20 @@ class UnifiedOnChainWsService(
|
||||
}
|
||||
addressConnections.clear()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取连接状态
|
||||
* @return Map<address, isConnected>
|
||||
*/
|
||||
fun getConnectionStatuses(): Map<String, Boolean> {
|
||||
return addressConnections.mapValues { (_, connection) -> connection.isConnected() }
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
fun init() {
|
||||
logger.info("统一链上 WebSocket 服务已初始化 (独立连接模式)")
|
||||
}
|
||||
|
||||
|
||||
@PreDestroy
|
||||
fun destroy() {
|
||||
stop()
|
||||
@@ -211,6 +219,10 @@ class UnifiedOnChainWsService(
|
||||
return subscriptions.isEmpty()
|
||||
}
|
||||
|
||||
fun isConnected(): Boolean {
|
||||
return isConnected
|
||||
}
|
||||
|
||||
private suspend fun startConnectionLoop() {
|
||||
while (scope.isActive) {
|
||||
try {
|
||||
|
||||
+2
-2
@@ -18,6 +18,7 @@ import com.wrbug.polymarketbot.util.CryptoUtils
|
||||
import com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository
|
||||
import com.wrbug.polymarketbot.repository.CopyTradingRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import com.wrbug.polymarketbot.service.common.MarketService
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
@@ -41,8 +42,7 @@ class OrderPushService(
|
||||
|
||||
private val logger = LoggerFactory.getLogger(OrderPushService::class.java)
|
||||
|
||||
@Value("\${polymarket.rtds.ws-url}")
|
||||
private lateinit var polymarketWsUrl: String
|
||||
private val polymarketWsUrl: String = PolymarketConstants.RTDS_WS_URL
|
||||
|
||||
// 存储账户 ID 和对应的 WebSocket 连接
|
||||
private val accountConnections = ConcurrentHashMap<Long, PolymarketWebSocketClient>()
|
||||
|
||||
+24
-6
@@ -23,6 +23,8 @@ import com.wrbug.polymarketbot.service.common.MarketService
|
||||
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
||||
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
|
||||
import com.wrbug.polymarketbot.util.CryptoUtils
|
||||
import org.springframework.context.ApplicationContext
|
||||
import org.springframework.context.ApplicationContextAware
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
@@ -51,12 +53,26 @@ open class CopyOrderTrackingService(
|
||||
private val cryptoUtils: CryptoUtils,
|
||||
private val marketService: MarketService, // 市场信息服务
|
||||
private val telegramNotificationService: TelegramNotificationService? = null // 可选,避免循环依赖
|
||||
) {
|
||||
) : ApplicationContextAware {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CopyOrderTrackingService::class.java)
|
||||
|
||||
// 协程作用域(用于异步发送通知)
|
||||
private val notificationScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
private var applicationContext: ApplicationContext? = null
|
||||
|
||||
override fun setApplicationContext(applicationContext: ApplicationContext) {
|
||||
this.applicationContext = applicationContext
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取代理对象,用于解决 @Transactional 自调用问题
|
||||
*/
|
||||
private fun getSelf(): CopyOrderTrackingService {
|
||||
return applicationContext?.getBean(CopyOrderTrackingService::class.java)
|
||||
?: throw IllegalStateException("ApplicationContext not initialized")
|
||||
}
|
||||
|
||||
// 使用 Mutex 保证线程安全(按交易ID锁定)
|
||||
private val tradeMutexMap = ConcurrentHashMap<String, Mutex>()
|
||||
@@ -138,10 +154,11 @@ open class CopyOrderTrackingService(
|
||||
return@withLock Result.success(Unit)
|
||||
}
|
||||
|
||||
// 2. 处理交易逻辑
|
||||
// 2. 处理交易逻辑(通过代理对象调用,确保 @Transactional 生效)
|
||||
val self = getSelf()
|
||||
val result = when (trade.side.uppercase()) {
|
||||
"BUY" -> processBuyTrade(leaderId, trade)
|
||||
"SELL" -> processSellTrade(leaderId, trade)
|
||||
"BUY" -> self.processBuyTrade(leaderId, trade, source)
|
||||
"SELL" -> self.processSellTrade(leaderId, trade)
|
||||
else -> {
|
||||
logger.warn("未知的交易方向: ${trade.side}")
|
||||
Result.failure(IllegalArgumentException("未知的交易方向: ${trade.side}"))
|
||||
@@ -213,7 +230,7 @@ open class CopyOrderTrackingService(
|
||||
* 创建跟单买入订单并记录到跟踪表
|
||||
*/
|
||||
@Transactional
|
||||
suspend fun processBuyTrade(leaderId: Long, trade: TradeResponse): Result<Unit> {
|
||||
suspend fun processBuyTrade(leaderId: Long, trade: TradeResponse, source: String): Result<Unit> {
|
||||
return try {
|
||||
// 1. 查找所有启用且支持该Leader的跟单关系
|
||||
val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId)
|
||||
@@ -622,7 +639,8 @@ open class CopyOrderTrackingService(
|
||||
price = buyPrice, // 使用下单价格,临时值
|
||||
remainingQuantity = finalBuyQuantity,
|
||||
status = "filled",
|
||||
notificationSent = false // 标记为未发送通知,等待轮询任务获取实际数据后发送
|
||||
notificationSent = false, // 标记为未发送通知,等待轮询任务获取实际数据后发送
|
||||
source = source // 订单来源
|
||||
)
|
||||
|
||||
copyOrderTrackingRepository.save(tracking)
|
||||
|
||||
+21
-13
@@ -611,22 +611,22 @@ class CopyTradingStatisticsService(
|
||||
|
||||
// 4. 转换为分组数据并计算统计信息
|
||||
val marketIds = groups.keys.toList()
|
||||
|
||||
|
||||
val list = marketIds.map { marketId ->
|
||||
val marketOrders = groups[marketId] ?: mutableListOf()
|
||||
|
||||
|
||||
// 计算统计信息
|
||||
val count = marketOrders.size.toLong()
|
||||
val totalAmount = marketOrders.sumOf { order ->
|
||||
order.quantity.toSafeBigDecimal().multi(order.price)
|
||||
}
|
||||
|
||||
|
||||
// 计算订单状态统计
|
||||
val fullyMatchedCount = marketOrders.count { it.status == "fully_matched" }
|
||||
val partiallyMatchedCount = marketOrders.count { it.status == "partially_matched" }
|
||||
val filledCount = marketOrders.count { it.status == "filled" }
|
||||
val fullyMatched = fullyMatchedCount == marketOrders.size
|
||||
|
||||
|
||||
val stats = MarketOrderStats(
|
||||
count = count,
|
||||
totalAmount = totalAmount.toString(),
|
||||
@@ -636,10 +636,10 @@ class CopyTradingStatisticsService(
|
||||
partiallyMatchedCount = partiallyMatchedCount.toLong(),
|
||||
filledCount = filledCount.toLong()
|
||||
)
|
||||
|
||||
|
||||
// 排序(按创建时间倒序)
|
||||
marketOrders.sortByDescending { it.createdAt }
|
||||
|
||||
|
||||
// 转换为 DTO
|
||||
val orderDtos = marketOrders.map { order ->
|
||||
val amount = order.quantity.toSafeBigDecimal().multi(order.price)
|
||||
@@ -662,7 +662,7 @@ class CopyTradingStatisticsService(
|
||||
createdAt = order.createdAt
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
MarketOrderGroup(
|
||||
marketId = marketId,
|
||||
marketTitle = markets[marketId]?.title,
|
||||
@@ -672,27 +672,35 @@ class CopyTradingStatisticsService(
|
||||
stats = stats,
|
||||
orders = orderDtos as List<Any>
|
||||
)
|
||||
}.sortedByDescending { it.stats.count }
|
||||
|
||||
}.sortedByDescending { group ->
|
||||
// 找出该市场最近的买入订单时间
|
||||
group.orders.mapNotNull { order ->
|
||||
when (order) {
|
||||
is BuyOrderInfo -> order.createdAt
|
||||
else -> null
|
||||
}
|
||||
}.maxOrNull() ?: 0L
|
||||
}
|
||||
|
||||
// 5. 分页
|
||||
val page = (request.page ?: 1)
|
||||
val limit = request.limit ?: 20
|
||||
val total = list.size.toLong()
|
||||
|
||||
|
||||
val start = (page - 1) * limit
|
||||
val end = minOf(start + limit, list.size)
|
||||
val pagedList = if (start < list.size) list.subList(start, end) else emptyList()
|
||||
|
||||
|
||||
val response = MarketGroupedOrdersResponse(
|
||||
list = pagedList,
|
||||
total = total,
|
||||
page = page,
|
||||
limit = limit
|
||||
)
|
||||
|
||||
|
||||
Result.success(response)
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取按市场分组的买入订单列表失败: copyTradingId=${request.copyTradingId}", e)
|
||||
logger.error("获取按市场分组的卖出订单列表失败: copyTradingId=${request.copyTradingId}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
+402
-223
File diff suppressed because it is too large
Load Diff
+154
-85
@@ -1,5 +1,6 @@
|
||||
package com.wrbug.polymarketbot.service.system
|
||||
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import com.wrbug.polymarketbot.dto.ApiHealthCheckDto
|
||||
import com.wrbug.polymarketbot.dto.ApiHealthCheckResponse
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
@@ -11,9 +12,9 @@ import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.BeansException
|
||||
import org.springframework.context.ApplicationContext
|
||||
import org.springframework.context.ApplicationContextAware
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import com.wrbug.polymarketbot.service.copytrading.orders.OrderPushService
|
||||
import com.wrbug.polymarketbot.service.copytrading.monitor.CopyTradingWebSocketService
|
||||
import com.wrbug.polymarketbot.service.copytrading.monitor.PolymarketActivityWsService
|
||||
import com.wrbug.polymarketbot.service.copytrading.monitor.UnifiedOnChainWsService
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@@ -22,16 +23,6 @@ import java.util.concurrent.TimeUnit
|
||||
*/
|
||||
@Service
|
||||
class ApiHealthCheckService(
|
||||
@Value("\${polymarket.clob.base-url}")
|
||||
private val clobBaseUrl: String,
|
||||
@Value("\${polymarket.data-api.base-url}")
|
||||
private val dataApiBaseUrl: String,
|
||||
@Value("\${polymarket.gamma.base-url}")
|
||||
private val gammaBaseUrl: String,
|
||||
@Value("\${polymarket.rtds.ws-url}")
|
||||
private val polymarketWsUrl: String,
|
||||
@Value("\${polymarket.builder.relayer-url:}")
|
||||
private val builderRelayerUrl: String,
|
||||
private val rpcNodeService: RpcNodeService
|
||||
) : ApplicationContextAware {
|
||||
|
||||
@@ -52,17 +43,6 @@ class ApiHealthCheckService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取跟单 WebSocket 服务(通过 ApplicationContext 避免循环依赖)
|
||||
*/
|
||||
private fun getCopyTradingWebSocketService(): CopyTradingWebSocketService? {
|
||||
return try {
|
||||
applicationContext?.getBean(CopyTradingWebSocketService::class.java)
|
||||
} catch (e: BeansException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 RelayClientService(通过 ApplicationContext 避免循环依赖)
|
||||
*/
|
||||
@@ -74,6 +54,28 @@ class ApiHealthCheckService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 PolymarketActivityWsService(通过 ApplicationContext 避免循环依赖)
|
||||
*/
|
||||
private fun getPolymarketActivityWsService(): PolymarketActivityWsService? {
|
||||
return try {
|
||||
applicationContext?.getBean(PolymarketActivityWsService::class.java)
|
||||
} catch (e: BeansException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 UnifiedOnChainWsService(通过 ApplicationContext 避免循环依赖)
|
||||
*/
|
||||
private fun getUnifiedOnChainWsService(): UnifiedOnChainWsService? {
|
||||
return try {
|
||||
applicationContext?.getBean(UnifiedOnChainWsService::class.java)
|
||||
} catch (e: BeansException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private val logger = LoggerFactory.getLogger(ApiHealthCheckService::class.java)
|
||||
|
||||
/**
|
||||
@@ -89,7 +91,9 @@ class ApiHealthCheckService(
|
||||
async { checkDataApi() },
|
||||
async { checkGammaApi() },
|
||||
async { checkPolygonRpc() },
|
||||
async { checkPolymarketWebSocket() },
|
||||
async { checkPolymarketRtdsWebSocket() },
|
||||
async { checkPolymarketActivityWebSocket() },
|
||||
async { checkUnifiedOnChainWebSocket() },
|
||||
async { checkBuilderRelayerApi() },
|
||||
async { checkGitHubApi() }
|
||||
)
|
||||
@@ -106,7 +110,7 @@ class ApiHealthCheckService(
|
||||
* 检查 Polymarket CLOB API
|
||||
*/
|
||||
private suspend fun checkClobApi(): ApiHealthCheckDto = withContext(Dispatchers.IO) {
|
||||
val url = "$clobBaseUrl/"
|
||||
val url = "${PolymarketConstants.CLOB_BASE_URL}/"
|
||||
checkApi("Polymarket CLOB API", url)
|
||||
}
|
||||
|
||||
@@ -114,7 +118,7 @@ class ApiHealthCheckService(
|
||||
* 检查 Polymarket Data API
|
||||
*/
|
||||
private suspend fun checkDataApi(): ApiHealthCheckDto = withContext(Dispatchers.IO) {
|
||||
val url = "$dataApiBaseUrl/"
|
||||
val url = "${PolymarketConstants.DATA_API_BASE_URL}/"
|
||||
checkApi("Polymarket Data API", url)
|
||||
}
|
||||
|
||||
@@ -131,7 +135,7 @@ class ApiHealthCheckService(
|
||||
.build()
|
||||
|
||||
// 使用 /markets 接口检查(不传参数,返回空列表或少量市场数据)
|
||||
val url = "$gammaBaseUrl/markets"
|
||||
val url = "${PolymarketConstants.GAMMA_BASE_URL}/markets"
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
@@ -176,7 +180,7 @@ class ApiHealthCheckService(
|
||||
logger.warn("检查 Polymarket Gamma API 失败", e)
|
||||
ApiHealthCheckDto(
|
||||
name = "Polymarket Gamma API",
|
||||
url = "$gammaBaseUrl/markets",
|
||||
url = "${PolymarketConstants.GAMMA_BASE_URL}/markets",
|
||||
status = "error",
|
||||
message = e.message ?: "连接失败"
|
||||
)
|
||||
@@ -194,69 +198,143 @@ class ApiHealthCheckService(
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Polymarket WebSocket 连接状态
|
||||
* 不显示延时,只显示连接状态
|
||||
* 检查 Polymarket RTDS WebSocket 连接状态
|
||||
* 用于订单推送服务
|
||||
*/
|
||||
private suspend fun checkPolymarketWebSocket(): ApiHealthCheckDto = withContext(Dispatchers.Default) {
|
||||
private suspend fun checkPolymarketRtdsWebSocket(): ApiHealthCheckDto = withContext(Dispatchers.Default) {
|
||||
try {
|
||||
// 检查订单推送服务的连接状态
|
||||
val orderPushService = getOrderPushService()
|
||||
val orderPushStatuses = orderPushService?.getConnectionStatuses() ?: emptyMap()
|
||||
val orderPushConnected = orderPushStatuses.values.any { it }
|
||||
val orderPushTotal = orderPushStatuses.size
|
||||
val orderPushConnectedCount = orderPushStatuses.values.count { it }
|
||||
val statuses = orderPushService?.getConnectionStatuses() ?: emptyMap()
|
||||
val total = statuses.size
|
||||
val connected = statuses.values.count { it }
|
||||
|
||||
// 检查跟单 WebSocket 服务的连接状态
|
||||
val copyTradingWebSocketService = getCopyTradingWebSocketService()
|
||||
val copyTradingStatuses = copyTradingWebSocketService?.getConnectionStatuses() ?: emptyMap()
|
||||
val copyTradingConnected = copyTradingStatuses.values.any { it }
|
||||
val copyTradingTotal = copyTradingStatuses.size
|
||||
val copyTradingConnectedCount = copyTradingStatuses.values.count { it }
|
||||
|
||||
// 计算总体状态
|
||||
val totalConnections = orderPushTotal + copyTradingTotal
|
||||
val connectedConnections = orderPushConnectedCount + copyTradingConnectedCount
|
||||
|
||||
val url = polymarketWsUrl
|
||||
val hasAnyConnection = orderPushConnected || copyTradingConnected
|
||||
|
||||
if (totalConnections == 0) {
|
||||
// 没有配置任何 WebSocket 连接
|
||||
if (total == 0) {
|
||||
ApiHealthCheckDto(
|
||||
name = "Polymarket WebSocket",
|
||||
url = url,
|
||||
name = "Polymarket RTDS WebSocket",
|
||||
url = PolymarketConstants.RTDS_WS_URL,
|
||||
status = "skipped",
|
||||
message = "未配置 WebSocket 连接"
|
||||
message = "未配置账户连接"
|
||||
)
|
||||
} else if (hasAnyConnection) {
|
||||
// 至少有一个连接是活跃的
|
||||
val message = if (connectedConnections == totalConnections) {
|
||||
"所有连接正常 ($connectedConnections/$totalConnections)"
|
||||
} else if (connected > 0) {
|
||||
val message = if (connected == total) {
|
||||
"所有账户连接正常 ($connected/$total)"
|
||||
} else {
|
||||
"部分连接正常 ($connectedConnections/$totalConnections)"
|
||||
"部分账户连接正常 ($connected/$total)"
|
||||
}
|
||||
ApiHealthCheckDto(
|
||||
name = "Polymarket WebSocket",
|
||||
url = url,
|
||||
name = "Polymarket RTDS WebSocket",
|
||||
url = PolymarketConstants.RTDS_WS_URL,
|
||||
status = "success",
|
||||
message = message
|
||||
// 不设置 responseTime,WebSocket 不显示延时
|
||||
)
|
||||
} else {
|
||||
// 所有连接都断开
|
||||
ApiHealthCheckDto(
|
||||
name = "Polymarket WebSocket",
|
||||
url = url,
|
||||
name = "Polymarket RTDS WebSocket",
|
||||
url = PolymarketConstants.RTDS_WS_URL,
|
||||
status = "error",
|
||||
message = "所有连接断开 ($connectedConnections/$totalConnections)"
|
||||
// 不设置 responseTime,WebSocket 不显示延时
|
||||
message = "所有账户连接断开 (0/$total)"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("检查 Polymarket WebSocket 状态失败", e)
|
||||
logger.warn("检查 Polymarket RTDS WebSocket 状态失败", e)
|
||||
ApiHealthCheckDto(
|
||||
name = "Polymarket WebSocket",
|
||||
url = polymarketWsUrl,
|
||||
name = "Polymarket RTDS WebSocket",
|
||||
url = PolymarketConstants.RTDS_WS_URL,
|
||||
status = "error",
|
||||
message = "检查失败:${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Polymarket Activity WebSocket 连接状态
|
||||
* 用于 Activity 全局交易流监听
|
||||
*/
|
||||
private suspend fun checkPolymarketActivityWebSocket(): ApiHealthCheckDto = withContext(Dispatchers.Default) {
|
||||
try {
|
||||
val activityWsService = getPolymarketActivityWsService()
|
||||
val isConnected = activityWsService?.isConnected() ?: false
|
||||
|
||||
if (isConnected) {
|
||||
ApiHealthCheckDto(
|
||||
name = "Polymarket Activity WebSocket",
|
||||
url = PolymarketConstants.ACTIVITY_WS_URL,
|
||||
status = "success",
|
||||
message = "连接正常"
|
||||
)
|
||||
} else {
|
||||
ApiHealthCheckDto(
|
||||
name = "Polymarket Activity WebSocket",
|
||||
url = PolymarketConstants.ACTIVITY_WS_URL,
|
||||
status = "error",
|
||||
message = "连接断开"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("检查 Polymarket Activity WebSocket 状态失败", e)
|
||||
ApiHealthCheckDto(
|
||||
name = "Polymarket Activity WebSocket",
|
||||
url = PolymarketConstants.ACTIVITY_WS_URL,
|
||||
status = "error",
|
||||
message = "检查失败:${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查统一链上 WebSocket 连接状态
|
||||
* 用于监听链上事件
|
||||
*/
|
||||
private suspend fun checkUnifiedOnChainWebSocket(): ApiHealthCheckDto = withContext(Dispatchers.Default) {
|
||||
try {
|
||||
val unifiedOnChainWsService = getUnifiedOnChainWsService()
|
||||
|
||||
if (unifiedOnChainWsService == null) {
|
||||
return@withContext ApiHealthCheckDto(
|
||||
name = "链上 WebSocket",
|
||||
url = rpcNodeService.getWsUrl(),
|
||||
status = "error",
|
||||
message = "服务未初始化"
|
||||
)
|
||||
}
|
||||
|
||||
// 检查连接状态
|
||||
val statuses = unifiedOnChainWsService.getConnectionStatuses()
|
||||
val total = statuses.size
|
||||
val connected = statuses.values.count { it }
|
||||
|
||||
if (total == 0) {
|
||||
ApiHealthCheckDto(
|
||||
name = "链上 WebSocket",
|
||||
url = rpcNodeService.getWsUrl(),
|
||||
status = "skipped",
|
||||
message = "未配置地址监听"
|
||||
)
|
||||
} else if (connected > 0) {
|
||||
val message = if (connected == total) {
|
||||
"所有地址连接正常 ($connected/$total)"
|
||||
} else {
|
||||
"部分地址连接正常 ($connected/$total)"
|
||||
}
|
||||
ApiHealthCheckDto(
|
||||
name = "链上 WebSocket",
|
||||
url = rpcNodeService.getWsUrl(),
|
||||
status = "success",
|
||||
message = message
|
||||
)
|
||||
} else {
|
||||
ApiHealthCheckDto(
|
||||
name = "链上 WebSocket",
|
||||
url = rpcNodeService.getWsUrl(),
|
||||
status = "error",
|
||||
message = "所有地址连接断开 (0/$total)"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("检查链上 WebSocket 状态失败", e)
|
||||
ApiHealthCheckDto(
|
||||
name = "链上 WebSocket",
|
||||
url = rpcNodeService.getWsUrl(),
|
||||
status = "error",
|
||||
message = "检查失败:${e.message}"
|
||||
)
|
||||
@@ -390,19 +468,10 @@ class ApiHealthCheckService(
|
||||
private suspend fun checkBuilderRelayerApi(): ApiHealthCheckDto = withContext(Dispatchers.IO) {
|
||||
val relayClientService = getRelayClientService()
|
||||
|
||||
if (builderRelayerUrl.isBlank()) {
|
||||
return@withContext ApiHealthCheckDto(
|
||||
name = "Builder Relayer API",
|
||||
url = "未配置",
|
||||
status = "skipped",
|
||||
message = "未配置 Builder Relayer URL"
|
||||
)
|
||||
}
|
||||
|
||||
if (relayClientService == null) {
|
||||
return@withContext ApiHealthCheckDto(
|
||||
name = "Builder Relayer API",
|
||||
url = builderRelayerUrl,
|
||||
url = PolymarketConstants.BUILDER_RELAYER_URL,
|
||||
status = "error",
|
||||
message = "服务未初始化"
|
||||
)
|
||||
@@ -411,7 +480,7 @@ class ApiHealthCheckService(
|
||||
if (!relayClientService.isBuilderApiKeyConfigured()) {
|
||||
return@withContext ApiHealthCheckDto(
|
||||
name = "Builder Relayer API",
|
||||
url = builderRelayerUrl,
|
||||
url = PolymarketConstants.BUILDER_RELAYER_URL,
|
||||
status = "skipped",
|
||||
message = "Builder API Key 未配置"
|
||||
)
|
||||
@@ -423,7 +492,7 @@ class ApiHealthCheckService(
|
||||
onSuccess = { responseTime ->
|
||||
ApiHealthCheckDto(
|
||||
name = "Builder Relayer API",
|
||||
url = builderRelayerUrl,
|
||||
url = PolymarketConstants.BUILDER_RELAYER_URL,
|
||||
status = "success",
|
||||
message = "连接成功",
|
||||
responseTime = responseTime
|
||||
@@ -432,7 +501,7 @@ class ApiHealthCheckService(
|
||||
onFailure = { e ->
|
||||
ApiHealthCheckDto(
|
||||
name = "Builder Relayer API",
|
||||
url = builderRelayerUrl,
|
||||
url = PolymarketConstants.BUILDER_RELAYER_URL,
|
||||
status = "error",
|
||||
message = e.message ?: "连接失败"
|
||||
)
|
||||
@@ -442,7 +511,7 @@ class ApiHealthCheckService(
|
||||
logger.warn("检查 Builder Relayer API 失败", e)
|
||||
ApiHealthCheckDto(
|
||||
name = "Builder Relayer API",
|
||||
url = builderRelayerUrl,
|
||||
url = PolymarketConstants.BUILDER_RELAYER_URL,
|
||||
status = "error",
|
||||
message = e.message ?: "连接失败"
|
||||
)
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package com.wrbug.polymarketbot.service.system
|
||||
|
||||
import com.wrbug.polymarketbot.repository.ProcessedTradeRepository
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
/**
|
||||
* 已处理交易清理服务
|
||||
* 定期清理过期的去重记录
|
||||
*/
|
||||
@Service
|
||||
class ProcessedTradeCleanupService(
|
||||
private val processedTradeRepository: ProcessedTradeRepository
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private val logger = LoggerFactory.getLogger(ProcessedTradeCleanupService::class.java)
|
||||
|
||||
// 保留时间:1小时(3600000毫秒)
|
||||
// 说明:重复订单通常10秒后就不会再出现,保留10分钟是为了安全起见
|
||||
private const val RETENTION_MS = 600_000L
|
||||
|
||||
// 定时清理间隔:10分钟(600000毫秒)
|
||||
private const val CLEANUP_INTERVAL_MS = 600_000L
|
||||
}
|
||||
|
||||
/**
|
||||
* 定时清理过期记录
|
||||
* 每10分钟执行一次
|
||||
*/
|
||||
@Scheduled(fixedDelay = CLEANUP_INTERVAL_MS)
|
||||
@Transactional
|
||||
fun cleanupExpiredProcessedTrades() {
|
||||
try {
|
||||
val expireTime = System.currentTimeMillis() - RETENTION_MS
|
||||
val deletedCount = processedTradeRepository.deleteByProcessedAtBefore(expireTime)
|
||||
|
||||
if (deletedCount > 0) {
|
||||
logger.info("清理过期已处理交易记录: deletedCount=$deletedCount, expireTime=$expireTime")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("清理过期已处理交易记录失败", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+53
-48
@@ -3,11 +3,11 @@ package com.wrbug.polymarketbot.service.system
|
||||
import com.wrbug.polymarketbot.api.BuilderRelayerApi
|
||||
import com.wrbug.polymarketbot.api.EthereumRpcApi
|
||||
import com.wrbug.polymarketbot.api.JsonRpcRequest
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import com.wrbug.polymarketbot.util.EthereumUtils
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Service
|
||||
import java.math.BigInteger
|
||||
|
||||
@@ -24,8 +24,6 @@ import java.math.BigInteger
|
||||
*/
|
||||
@Service
|
||||
class RelayClientService(
|
||||
@Value("\${polymarket.builder.relayer-url:}")
|
||||
private val builderRelayerUrl: String,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val systemConfigService: SystemConfigService,
|
||||
private val rpcNodeService: RpcNodeService
|
||||
@@ -54,10 +52,10 @@ class RelayClientService(
|
||||
val builderApiKey = systemConfigService.getBuilderApiKey()
|
||||
val builderSecret = systemConfigService.getBuilderSecret()
|
||||
val builderPassphrase = systemConfigService.getBuilderPassphrase()
|
||||
|
||||
|
||||
if (isBuilderRelayerEnabled(builderApiKey, builderSecret, builderPassphrase)) {
|
||||
return retrofitFactory.createBuilderRelayerApi(
|
||||
relayerUrl = builderRelayerUrl,
|
||||
relayerUrl = PolymarketConstants.BUILDER_RELAYER_URL,
|
||||
apiKey = builderApiKey!!,
|
||||
secret = builderSecret!!,
|
||||
passphrase = builderPassphrase!!
|
||||
@@ -74,19 +72,19 @@ class RelayClientService(
|
||||
builderSecret: String?,
|
||||
builderPassphrase: String?
|
||||
): Boolean {
|
||||
return builderRelayerUrl.isNotBlank() &&
|
||||
return PolymarketConstants.BUILDER_RELAYER_URL.isNotBlank() &&
|
||||
builderApiKey != null && builderApiKey.isNotBlank() &&
|
||||
builderSecret != null && builderSecret.isNotBlank() &&
|
||||
builderPassphrase != null && builderPassphrase.isNotBlank()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 检查 Builder API Key 是否已配置
|
||||
*/
|
||||
fun isBuilderApiKeyConfigured(): Boolean {
|
||||
return systemConfigService.isBuilderApiKeyConfigured()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 检查 Builder Relayer API 健康状态(用于 API 健康检查)
|
||||
*/
|
||||
@@ -95,24 +93,24 @@ class RelayClientService(
|
||||
val builderApiKey = systemConfigService.getBuilderApiKey()
|
||||
val builderSecret = systemConfigService.getBuilderSecret()
|
||||
val builderPassphrase = systemConfigService.getBuilderPassphrase()
|
||||
|
||||
|
||||
if (builderApiKey == null || builderSecret == null || builderPassphrase == null) {
|
||||
return Result.failure(IllegalStateException("Builder API Key 未配置"))
|
||||
}
|
||||
|
||||
|
||||
val relayerApi = retrofitFactory.createBuilderRelayerApi(
|
||||
relayerUrl = builderRelayerUrl,
|
||||
relayerUrl = PolymarketConstants.BUILDER_RELAYER_URL,
|
||||
apiKey = builderApiKey,
|
||||
secret = builderSecret,
|
||||
passphrase = builderPassphrase
|
||||
)
|
||||
|
||||
|
||||
// 使用一个测试地址来检查 API 是否可用(使用一个已知的地址,如零地址)
|
||||
val testAddress = "0x0000000000000000000000000000000000000000"
|
||||
val startTime = System.currentTimeMillis()
|
||||
val response = relayerApi.getDeployed(testAddress)
|
||||
val responseTime = System.currentTimeMillis() - startTime
|
||||
|
||||
|
||||
if (response.isSuccessful) {
|
||||
Result.success(responseTime)
|
||||
} else {
|
||||
@@ -228,11 +226,18 @@ class RelayClientService(
|
||||
val builderApiKey = systemConfigService.getBuilderApiKey()
|
||||
val builderSecret = systemConfigService.getBuilderSecret()
|
||||
val builderPassphrase = systemConfigService.getBuilderPassphrase()
|
||||
|
||||
|
||||
// 优先使用 Builder Relayer(Gasless)
|
||||
if (isBuilderRelayerEnabled(builderApiKey, builderSecret, builderPassphrase)) {
|
||||
logger.info("使用 Builder Relayer 执行 Gasless 交易")
|
||||
return executeViaBuilderRelayer(privateKey, proxyAddress, safeTx, builderApiKey!!, builderSecret!!, builderPassphrase!!)
|
||||
return executeViaBuilderRelayer(
|
||||
privateKey,
|
||||
proxyAddress,
|
||||
safeTx,
|
||||
builderApiKey!!,
|
||||
builderSecret!!,
|
||||
builderPassphrase!!
|
||||
)
|
||||
}
|
||||
|
||||
// 回退到手动发送交易(需要用户支付 gas)
|
||||
@@ -256,9 +261,8 @@ class RelayClientService(
|
||||
builderSecret: String,
|
||||
builderPassphrase: String
|
||||
): Result<String> {
|
||||
val rpcApi = polygonRpcApi
|
||||
val relayerApi = retrofitFactory.createBuilderRelayerApi(
|
||||
relayerUrl = builderRelayerUrl,
|
||||
relayerUrl = PolymarketConstants.BUILDER_RELAYER_URL,
|
||||
apiKey = builderApiKey,
|
||||
secret = builderSecret,
|
||||
passphrase = builderPassphrase
|
||||
@@ -320,19 +324,19 @@ class RelayClientService(
|
||||
val messageWithPrefix = ByteArray(prefix.size + safeTxStructuredHash.size)
|
||||
System.arraycopy(prefix, 0, messageWithPrefix, 0, prefix.size)
|
||||
System.arraycopy(safeTxStructuredHash, 0, messageWithPrefix, prefix.size, safeTxStructuredHash.size)
|
||||
|
||||
|
||||
// 对带前缀的消息进行 keccak256 哈希
|
||||
val keccak256 = org.bouncycastle.crypto.digests.KeccakDigest(256)
|
||||
keccak256.update(messageWithPrefix, 0, messageWithPrefix.size)
|
||||
val hashWithPrefix = ByteArray(keccak256.digestSize)
|
||||
keccak256.doFinal(hashWithPrefix, 0)
|
||||
|
||||
|
||||
val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
|
||||
val safeSignature = org.web3j.crypto.Sign.signMessage(hashWithPrefix, ecKeyPair, false)
|
||||
|
||||
// 打包签名(参考 builder-relayer-client/src/utils/index.ts 的 splitAndPackSig)
|
||||
val packedSignature = splitAndPackSig(safeSignature)
|
||||
|
||||
|
||||
// 调试日志(地址已遮蔽)
|
||||
logger.debug("=== Builder Relayer 签名调试 ===")
|
||||
logger.debug("Safe: ${proxyAddress.take(10)}..., From: ${fromAddress.take(10)}..., Nonce: $proxyNonce")
|
||||
@@ -358,7 +362,7 @@ class RelayClientService(
|
||||
),
|
||||
metadata = "Redeem positions via Builder Relayer"
|
||||
)
|
||||
|
||||
|
||||
logger.debug("Request: type=${request.type}, dataLen=${request.data.length}, sigLen=${request.signature.length}, nonce=${request.nonce}")
|
||||
|
||||
// 调用 Builder Relayer API(认证头通过拦截器添加)
|
||||
@@ -372,7 +376,7 @@ class RelayClientService(
|
||||
|
||||
val relayerResponse = response.body()!!
|
||||
val txHash = relayerResponse.transactionHash ?: relayerResponse.hash
|
||||
?: return Result.failure(Exception("Builder Relayer 返回的交易哈希为空"))
|
||||
?: return Result.failure(Exception("Builder Relayer 返回的交易哈希为空"))
|
||||
|
||||
logger.info("Builder Relayer 执行成功: transactionID=${relayerResponse.transactionID}, txHash=$txHash")
|
||||
return Result.success(txHash)
|
||||
@@ -381,14 +385,14 @@ class RelayClientService(
|
||||
/**
|
||||
* 打包签名(参考 builder-relayer-client/src/utils/index.ts 的 splitAndPackSig)
|
||||
* 将签名打包成 Gnosis Safe 接受的格式:encodePacked(["uint256", "uint256", "uint8"], [r, s, v])
|
||||
*
|
||||
*
|
||||
* TypeScript 实现流程:
|
||||
* 1. 从签名字符串中提取 v(最后 2 个字符)
|
||||
* 2. 调整 v 值(0,1 -> +31; 27,28 -> +4)
|
||||
* 3. 修改签名字符串(替换最后 2 个字符)
|
||||
* 4. 从修改后的签名字符串中提取 r, s, v(作为十进制字符串)
|
||||
* 5. 使用 encodePacked 打包:uint256(BigInt(r)) + uint256(BigInt(s)) + uint8(parseInt(v))
|
||||
*
|
||||
*
|
||||
* 关键:encodePacked 会将 BigInt 编码为 32 字节(64 个十六进制字符),uint8 编码为 1 字节(2 个十六进制字符)
|
||||
*/
|
||||
private fun splitAndPackSig(signature: org.web3j.crypto.Sign.SignatureData): String {
|
||||
@@ -403,37 +407,37 @@ class RelayClientService(
|
||||
}
|
||||
val originalVHex = String.format("%02x", originalV)
|
||||
val sigString = "0x$rHex$sHex$originalVHex" // 130 个十六进制字符(65 字节)
|
||||
|
||||
|
||||
// 2. 从签名字符串中提取 v(最后 2 个字符,作为十六进制)
|
||||
val sigV = sigString.substring(sigString.length - 2).toInt(16)
|
||||
|
||||
|
||||
// 3. 调整 v 值(参考 TypeScript 实现)
|
||||
val adjustedV = when (sigV) {
|
||||
0, 1 -> sigV + 31
|
||||
27, 28 -> sigV + 4
|
||||
else -> throw IllegalArgumentException("Invalid signature v value: $sigV")
|
||||
}
|
||||
|
||||
|
||||
// 4. 修改签名字符串(替换最后 2 个字符)
|
||||
val modifiedSigString = sigString.substring(0, sigString.length - 2) + String.format("%02x", adjustedV)
|
||||
|
||||
|
||||
// 5. 从修改后的签名字符串中提取 r, s, v(作为十六进制字符串)
|
||||
// modifiedSigString 格式:0x + r(64) + s(64) + v(2) = 132 个字符
|
||||
val rHexStr = modifiedSigString.substring(2, 66) // 64 个字符(十六进制)
|
||||
val sHexStr = modifiedSigString.substring(66, 130) // 64 个字符(十六进制)
|
||||
val vHexStr = modifiedSigString.substring(130, 132) // 2 个字符(十六进制)
|
||||
|
||||
|
||||
// 6. 转换为 BigInteger 和 Int(模拟 TypeScript 的 BigInt 和 parseInt)
|
||||
val rBigInt = BigInteger(rHexStr, 16)
|
||||
val sBigInt = BigInteger(sHexStr, 16)
|
||||
val vInt = vHexStr.toInt(16)
|
||||
|
||||
|
||||
// 7. 使用 encodePacked 打包:uint256(r) + uint256(s) + uint8(v)
|
||||
// encodePacked 会将 BigInt 编码为 32 字节(64 个十六进制字符),uint8 编码为 1 字节(2 个十六进制字符)
|
||||
val rEncoded = EthereumUtils.encodeUint256(rBigInt) // 64 个十六进制字符
|
||||
val sEncoded = EthereumUtils.encodeUint256(sBigInt) // 64 个十六进制字符
|
||||
val vEncoded = String.format("%02x", vInt) // 2 个十六进制字符
|
||||
|
||||
|
||||
return "0x$rEncoded$sEncoded$vEncoded"
|
||||
}
|
||||
|
||||
@@ -504,13 +508,13 @@ class RelayClientService(
|
||||
val messageWithPrefix = ByteArray(prefix.size + safeTxStructuredHash.size)
|
||||
System.arraycopy(prefix, 0, messageWithPrefix, 0, prefix.size)
|
||||
System.arraycopy(safeTxStructuredHash, 0, messageWithPrefix, prefix.size, safeTxStructuredHash.size)
|
||||
|
||||
|
||||
// 对带前缀的消息进行 keccak256 哈希
|
||||
val keccak256 = org.bouncycastle.crypto.digests.KeccakDigest(256)
|
||||
keccak256.update(messageWithPrefix, 0, messageWithPrefix.size)
|
||||
val hashWithPrefix = ByteArray(keccak256.digestSize)
|
||||
keccak256.doFinal(hashWithPrefix, 0)
|
||||
|
||||
|
||||
val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
|
||||
val safeSignature = org.web3j.crypto.Sign.signMessage(hashWithPrefix, ecKeyPair, false)
|
||||
|
||||
@@ -572,7 +576,8 @@ class RelayClientService(
|
||||
redeemCallData: String,
|
||||
safeSignatureHex: String
|
||||
): String {
|
||||
val execFunctionSelector = EthereumUtils.getFunctionSelector("execTransaction(address,uint256,bytes,uint8,uint256,uint256,uint256,address,address,bytes)")
|
||||
val execFunctionSelector =
|
||||
EthereumUtils.getFunctionSelector("execTransaction(address,uint256,bytes,uint8,uint256,uint256,uint256,address,address,bytes)")
|
||||
|
||||
val encodedTo = EthereumUtils.encodeAddress(safeTx.to)
|
||||
val encodedValue = EthereumUtils.encodeUint256(BigInteger.ZERO)
|
||||
@@ -600,20 +605,20 @@ class RelayClientService(
|
||||
val encodedSignatures = safeSignatureHex
|
||||
|
||||
return "0x" + execFunctionSelector.removePrefix("0x") +
|
||||
encodedTo +
|
||||
encodedValue +
|
||||
encodedDataOffset +
|
||||
encodedDataLength +
|
||||
encodedData +
|
||||
encodedOperation +
|
||||
encodedSafeTxGas +
|
||||
encodedBaseGas +
|
||||
encodedGasPrice +
|
||||
encodedGasToken +
|
||||
encodedRefundReceiver +
|
||||
encodedSignaturesOffset +
|
||||
encodedSignaturesLength +
|
||||
encodedSignatures
|
||||
encodedTo +
|
||||
encodedValue +
|
||||
encodedDataOffset +
|
||||
encodedDataLength +
|
||||
encodedData +
|
||||
encodedOperation +
|
||||
encodedSafeTxGas +
|
||||
encodedBaseGas +
|
||||
encodedGasPrice +
|
||||
encodedGasToken +
|
||||
encodedRefundReceiver +
|
||||
encodedSignaturesOffset +
|
||||
encodedSignaturesLength +
|
||||
encodedSignatures
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.wrbug.polymarketbot.api.GitHubApi
|
||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||
import com.wrbug.polymarketbot.api.PolymarketDataApi
|
||||
import com.wrbug.polymarketbot.api.PolymarketGammaApi
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.Interceptor
|
||||
@@ -18,7 +19,6 @@ import okhttp3.Response
|
||||
import okio.Buffer
|
||||
import java.util.concurrent.TimeUnit
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Component
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
@@ -34,10 +34,6 @@ import jakarta.annotation.PreDestroy
|
||||
*/
|
||||
@Component
|
||||
class RetrofitFactory(
|
||||
@Value("\${polymarket.clob.base-url}")
|
||||
private val clobBaseUrl: String,
|
||||
@Value("\${polymarket.gamma.base-url}")
|
||||
private val gammaBaseUrl: String,
|
||||
private val gson: Gson
|
||||
) {
|
||||
|
||||
@@ -58,10 +54,10 @@ class RetrofitFactory(
|
||||
|
||||
// 缓存 Gamma API 客户端(单例)
|
||||
private val gammaApi: PolymarketGammaApi by lazy {
|
||||
val baseUrl = if (gammaBaseUrl.endsWith("/")) {
|
||||
gammaBaseUrl.dropLast(1)
|
||||
val baseUrl = if (PolymarketConstants.GAMMA_BASE_URL.endsWith("/")) {
|
||||
PolymarketConstants.GAMMA_BASE_URL.dropLast(1)
|
||||
} else {
|
||||
gammaBaseUrl
|
||||
PolymarketConstants.GAMMA_BASE_URL
|
||||
}
|
||||
|
||||
Retrofit.Builder()
|
||||
@@ -74,7 +70,7 @@ class RetrofitFactory(
|
||||
|
||||
// 缓存 Data API 客户端(单例)
|
||||
private val dataApi: PolymarketDataApi by lazy {
|
||||
val baseUrl = "https://data-api.polymarket.com"
|
||||
val baseUrl = PolymarketConstants.DATA_API_BASE_URL
|
||||
|
||||
Retrofit.Builder()
|
||||
.baseUrl("$baseUrl/")
|
||||
@@ -113,7 +109,7 @@ class RetrofitFactory(
|
||||
// 缓存不带认证的 CLOB API 客户端(单例)
|
||||
private val clobApiWithoutAuth: PolymarketClobApi by lazy {
|
||||
Retrofit.Builder()
|
||||
.baseUrl(clobBaseUrl)
|
||||
.baseUrl(PolymarketConstants.CLOB_BASE_URL)
|
||||
.client(sharedOkHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.build()
|
||||
@@ -158,7 +154,7 @@ class RetrofitFactory(
|
||||
.build()
|
||||
|
||||
Retrofit.Builder()
|
||||
.baseUrl(clobBaseUrl)
|
||||
.baseUrl(PolymarketConstants.CLOB_BASE_URL)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.build()
|
||||
|
||||
+2
-3
@@ -1,7 +1,7 @@
|
||||
package com.wrbug.polymarketbot.websocket
|
||||
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.socket.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
@@ -15,8 +15,7 @@ class PolymarketWebSocketHandler : WebSocketHandler {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(PolymarketWebSocketHandler::class.java)
|
||||
|
||||
@Value("\${polymarket.rtds.ws-url}")
|
||||
private lateinit var polymarketWsUrl: String
|
||||
private val polymarketWsUrl: String = PolymarketConstants.RTDS_WS_URL
|
||||
|
||||
// 存储客户端会话和对应的 Polymarket 连接的映射
|
||||
private val clientSessions = ConcurrentHashMap<String, WebSocketSession>()
|
||||
|
||||
@@ -35,18 +35,14 @@ logging.level.com.wrbug.polymarketbot=${LOG_LEVEL_APP:INFO}
|
||||
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-subscriptions-clob.polymarket.com
|
||||
polymarket.websocket.url=wss://ws-live-data.polymarket.com
|
||||
polymarket.websocket.activity.url=${POLYMARKET_WEBSOCKET_ACTIVITY_URL:wss://ws-live-data.polymarket.com}
|
||||
polymarket.data-api.base-url=https://data-api.polymarket.com
|
||||
polymarket.gamma.base-url=https://gamma-api.polymarket.com
|
||||
# 注意:Polymarket API URL 现在使用代码常量(PolymarketConstants),不再从配置文件读取
|
||||
# 如需修改,请修改 com.wrbug.polymarketbot.constants.PolymarketConstants 类
|
||||
|
||||
# Builder Relayer 配置(用于 Gasless 交易)
|
||||
# 从 polymarket.com/settings?tab=builder 获取 Builder API 凭证
|
||||
# Builder API Key、Secret、Passphrase 现在通过系统设置页面配置,存储在数据库中
|
||||
# 如果未配置,将使用手动发送交易的方式(需要用户支付 gas)
|
||||
polymarket.builder.relayer-url=${POLYMARKET_BUILDER_RELAYER_URL:https://relayer-v2.polymarket.com/}
|
||||
# 注意:Builder Relayer URL 现在使用代码常量(PolymarketConstants.BUILDER_RELAYER_URL),不再从配置文件读取
|
||||
|
||||
# 跟单轮询配置
|
||||
# 轮询间隔(毫秒),默认2秒
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
-- ============================================
|
||||
-- V25: 添加订单来源字段到跟单订单跟踪表
|
||||
-- 用于记录订单是从哪个数据源接收到的(activity-ws 或 onchain-ws)
|
||||
-- ============================================
|
||||
|
||||
-- 添加订单来源字段
|
||||
ALTER TABLE copy_order_tracking
|
||||
ADD COLUMN source VARCHAR(20) NOT NULL DEFAULT 'unknown' COMMENT '订单来源:activity-ws(Polymarket WebSocket)、onchain-ws(OnChain WebSocket)';
|
||||
|
||||
-- 对于已有数据,设置为默认值 unknown(不影响现有功能)
|
||||
-- 新创建的记录会在创建时自动填充此字段
|
||||
|
||||
@@ -399,8 +399,8 @@ CopyOrderTrackingService.processTrade(
|
||||
### 5.1 application.properties
|
||||
|
||||
```properties
|
||||
# Polymarket WebSocket
|
||||
polymarket.websocket.url=wss://ws-live-data.polymarket.com
|
||||
# 注意:Polymarket API URL 现在使用代码常量(PolymarketConstants),不再从配置文件读取
|
||||
# 如需修改,请修改 com.wrbug.polymarketbot.constants.PolymarketConstants 类
|
||||
|
||||
# 监听策略
|
||||
copy.trading.monitor.strategy=dual
|
||||
|
||||
@@ -40,6 +40,8 @@ const PositionList: React.FC = () => {
|
||||
const [redeemableSummary, setRedeemableSummary] = useState<RedeemablePositionsSummary | null>(null)
|
||||
const [loadingRedeemableSummary, setLoadingRedeemableSummary] = useState(false)
|
||||
const [redeeming, setRedeeming] = useState(false)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
|
||||
useEffect(() => {
|
||||
fetchAccounts()
|
||||
@@ -66,6 +68,11 @@ const PositionList: React.FC = () => {
|
||||
fetchRedeemableSummary()
|
||||
}
|
||||
}, [currentPositions, selectedAccountId])
|
||||
|
||||
// 当筛选条件或搜索关键词变化时,重置分页到第一页
|
||||
useEffect(() => {
|
||||
setCurrentPage(1)
|
||||
}, [positionFilter, selectedAccountId, searchKeyword])
|
||||
|
||||
// 获取可赎回仓位统计
|
||||
const fetchRedeemableSummary = async () => {
|
||||
@@ -265,12 +272,12 @@ const PositionList: React.FC = () => {
|
||||
// 本地搜索和筛选过滤
|
||||
const filteredPositions = useMemo(() => {
|
||||
let filtered = basePositions
|
||||
|
||||
|
||||
// 1. 先按账户筛选
|
||||
if (selectedAccountId !== undefined) {
|
||||
filtered = filtered.filter(p => p.accountId === selectedAccountId)
|
||||
}
|
||||
|
||||
|
||||
// 2. 最后按关键词搜索
|
||||
if (searchKeyword.trim()) {
|
||||
const keyword = searchKeyword.trim().toLowerCase()
|
||||
@@ -302,9 +309,16 @@ const PositionList: React.FC = () => {
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
return filtered
|
||||
}, [basePositions, searchKeyword, selectedAccountId])
|
||||
|
||||
// 分页后的数据
|
||||
const paginatedPositions = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * pageSize
|
||||
const endIndex = startIndex + pageSize
|
||||
return filteredPositions.slice(startIndex, endIndex)
|
||||
}, [filteredPositions, currentPage, pageSize])
|
||||
|
||||
const getSideColor = (side: string) => {
|
||||
return side === 'YES' ? 'green' : 'red'
|
||||
@@ -349,14 +363,32 @@ const PositionList: React.FC = () => {
|
||||
if (!isNaN(initialValue)) {
|
||||
totalInitialValue += initialValue
|
||||
}
|
||||
|
||||
// 当前仓位:统计持仓价值
|
||||
// 历史仓位:currentValue 应该为 0(已平仓)
|
||||
if (!isNaN(currentValue)) {
|
||||
totalCurrentValue += currentValue
|
||||
}
|
||||
if (!isNaN(pnl)) {
|
||||
totalPnl += pnl
|
||||
}
|
||||
if (!isNaN(realizedPnl)) {
|
||||
totalRealizedPnl += realizedPnl
|
||||
|
||||
// 对于当前仓位:
|
||||
// - pnl:未实现盈亏(浮动盈亏)
|
||||
// - realizedPnl:已实现盈亏(部分平仓时产生)
|
||||
// 对于历史仓位:
|
||||
// - pnl:总已实现盈亏(包含部分平仓 + 完全平仓)
|
||||
// - realizedPnl:部分平仓的已实现盈亏(可能与 pnl 重复)
|
||||
if (pos.isCurrent) {
|
||||
// 当前仓位:未实现盈亏 + 已实现盈亏
|
||||
if (!isNaN(pnl)) {
|
||||
totalPnl += pnl
|
||||
}
|
||||
if (!isNaN(realizedPnl)) {
|
||||
totalRealizedPnl += realizedPnl
|
||||
}
|
||||
} else {
|
||||
// 历史仓位:pnl 是总已实现盈亏,realizedPnl 可能重复,所以只统计 pnl
|
||||
if (!isNaN(pnl)) {
|
||||
totalRealizedPnl += pnl
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -516,10 +548,10 @@ const PositionList: React.FC = () => {
|
||||
|
||||
// 渲染卡片视图
|
||||
const renderCardView = () => {
|
||||
if (filteredPositions.length === 0) {
|
||||
if (paginatedPositions.length === 0) {
|
||||
return (
|
||||
<Empty
|
||||
description="暂无仓位数据"
|
||||
<Empty
|
||||
description="暂无仓位数据"
|
||||
style={{ padding: '60px 0' }}
|
||||
/>
|
||||
)
|
||||
@@ -527,7 +559,7 @@ const PositionList: React.FC = () => {
|
||||
|
||||
return (
|
||||
<Row gutter={[16, 16]}>
|
||||
{filteredPositions.map((position, index) => {
|
||||
{paginatedPositions.map((position, index) => {
|
||||
const pnlNum = parseFloat(position.pnl || '0')
|
||||
const isProfit = pnlNum >= 0
|
||||
// 只有当前仓位才根据盈亏显示边框颜色
|
||||
@@ -1235,8 +1267,8 @@ const PositionList: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 合计信息:开仓价值、当前价值、盈亏、已实现盈亏(基于当前筛选后的仓位) */}
|
||||
{filteredPositions.length > 0 && (
|
||||
{/* 合计信息:开仓价值、当前价值、盈亏、已实现盈亏(仅当前仓位显示) */}
|
||||
{filteredPositions.length > 0 && positionFilter === 'current' && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '12px',
|
||||
@@ -1259,13 +1291,11 @@ const PositionList: React.FC = () => {
|
||||
<span>
|
||||
当前价值合计:{' '}
|
||||
<span style={{ fontWeight: 600 }}>
|
||||
{positionFilter === 'current'
|
||||
? `${formatUSDC(positionTotals.totalCurrentValue.toString())} USDC`
|
||||
: '-'}
|
||||
{formatUSDC(positionTotals.totalCurrentValue.toString())} USDC
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
盈亏合计:{' '}
|
||||
浮动盈亏合计:{' '}
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
@@ -1295,32 +1325,87 @@ const PositionList: React.FC = () => {
|
||||
{(isMobile || viewMode === 'card') ? (
|
||||
<Card loading={loading}>
|
||||
{renderCardView()}
|
||||
{/* 移动端分页 */}
|
||||
{filteredPositions.length > 0 && (
|
||||
<div style={{
|
||||
marginTop: '24px',
|
||||
textAlign: 'center',
|
||||
color: '#999',
|
||||
fontSize: '14px'
|
||||
}}>
|
||||
共 {filteredPositions.length} 个仓位{searchKeyword ? `(已过滤)` : ''}
|
||||
</div>
|
||||
<>
|
||||
<div style={{
|
||||
marginTop: '16px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||
共 {filteredPositions.length} 个仓位{searchKeyword ? `(已过滤)` : ''}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={currentPage === 1}
|
||||
onClick={() => setCurrentPage(currentPage - 1)}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span style={{ lineHeight: '32px', fontSize: '14px' }}>
|
||||
{currentPage} / {Math.ceil(filteredPositions.length / pageSize)}
|
||||
</span>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={currentPage >= Math.ceil(filteredPositions.length / pageSize)}
|
||||
onClick={() => setCurrentPage(currentPage + 1)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* 每页条数选择器 */}
|
||||
<div style={{
|
||||
marginTop: '8px',
|
||||
textAlign: 'right',
|
||||
fontSize: '14px'
|
||||
}}>
|
||||
<Select
|
||||
value={pageSize}
|
||||
onChange={(value) => {
|
||||
setPageSize(value)
|
||||
setCurrentPage(1)
|
||||
}}
|
||||
size="small"
|
||||
style={{ width: '100px' }}
|
||||
>
|
||||
<Select.Option value={10}>10 条/页</Select.Option>
|
||||
<Select.Option value={20}>20 条/页</Select.Option>
|
||||
<Select.Option value={50}>50 条/页</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<Table
|
||||
dataSource={filteredPositions}
|
||||
columns={columns}
|
||||
rowKey={(record, index) => `${record.accountId}-${record.marketId}-${index}`}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
pageSize: 20,
|
||||
showSizeChanger: !isMobile,
|
||||
showTotal: (total) => `共 ${total} 个仓位${searchKeyword ? `(已过滤)` : ''}`
|
||||
}}
|
||||
scroll={isMobile ? { x: 1500 } : undefined}
|
||||
/>
|
||||
</Card>
|
||||
<Card>
|
||||
<Table
|
||||
dataSource={filteredPositions}
|
||||
columns={columns}
|
||||
rowKey={(record, index) => `${record.accountId}-${record.marketId}-${index}`}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: currentPage,
|
||||
pageSize: pageSize,
|
||||
total: filteredPositions.length,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['10', '20', '50'],
|
||||
showTotal: (total) => `共 ${total} 个仓位${searchKeyword ? `(已过滤)` : ''}`,
|
||||
onChange: (page, size) => {
|
||||
setCurrentPage(page)
|
||||
if (size !== pageSize) {
|
||||
setPageSize(size)
|
||||
}
|
||||
}
|
||||
}}
|
||||
scroll={isMobile ? { x: 1500 } : undefined}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 出售模态框 */}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* 获取订单详情脚本
|
||||
*
|
||||
* 使用方法:
|
||||
* node scripts/get-order-detail.js <private_key> <order_id>
|
||||
*
|
||||
* 参数说明:
|
||||
* private_key: 钱包私钥(用于签名)
|
||||
* order_id: 订单 ID
|
||||
*
|
||||
* 示例:
|
||||
* node scripts/get-order-detail.js "0x..." "0x123..."
|
||||
*/
|
||||
|
||||
import { Wallet } from '@ethersproject/wallet';
|
||||
import { ClobClient } from '@polymarket/clob-client';
|
||||
|
||||
// Polymarket CLOB 主机地址
|
||||
const HOST = 'https://clob.polymarket.com';
|
||||
const CHAIN_ID = 137; // Polygon 主网
|
||||
|
||||
async function getOrderDetail(privateKey, orderId) {
|
||||
try {
|
||||
console.log('正在初始化钱包...');
|
||||
const wallet = new Wallet(privateKey);
|
||||
console.log(`钱包地址: ${wallet.address}`);
|
||||
|
||||
console.log('\n正在初始化 ClobClient...');
|
||||
const clobClient = new ClobClient(
|
||||
HOST,
|
||||
CHAIN_ID,
|
||||
wallet
|
||||
);
|
||||
|
||||
console.log('\n正在获取或创建 API Key...');
|
||||
try {
|
||||
// 尝试 derive API key(如果已存在)
|
||||
const creds = await clobClient.deriveApiKey();
|
||||
console.log(`✅ API Key 已获取`);
|
||||
console.log(` API Key: ${creds.key.substring(0, 10)}...`);
|
||||
console.log(` Passphrase: ${creds.passphrase.substring(0, 10)}...`);
|
||||
|
||||
// 使用 creds 初始化一个新的 ClobClient 实例用于 L2 认证
|
||||
const authenticatedClient = new ClobClient(
|
||||
HOST,
|
||||
CHAIN_ID,
|
||||
wallet,
|
||||
creds
|
||||
);
|
||||
|
||||
console.log(`\n正在获取订单详情...`);
|
||||
console.log(` 订单 ID: ${orderId}`);
|
||||
|
||||
const orderDetail = await authenticatedClient.getOrder(orderId);
|
||||
|
||||
console.log('\n================ 订单详情 ================');
|
||||
console.log(`订单 ID: ${orderDetail.id}`);
|
||||
console.log(`状态: ${orderDetail.status}`);
|
||||
console.log(`所有者: ${orderDetail.owner}`);
|
||||
console.log(`Maker 地址: ${orderDetail.maker_address}`);
|
||||
console.log(`市场 ID: ${orderDetail.market}`);
|
||||
console.log(`资产 ID: ${orderDetail.asset_id}`);
|
||||
console.log(`方向: ${orderDetail.side}`);
|
||||
console.log(`原始数量: ${orderDetail.original_size}`);
|
||||
console.log(`已匹配数量: ${orderDetail.size_matched}`);
|
||||
console.log(`价格: ${orderDetail.price}`);
|
||||
console.log(`结果: ${orderDetail.outcome}`);
|
||||
console.log(`创建时间: ${new Date(orderDetail.created_at * 1000).toISOString()}`);
|
||||
console.log(`过期时间: ${orderDetail.expiration}`);
|
||||
console.log(`订单类型: ${orderDetail.order_type}`);
|
||||
|
||||
if (orderDetail.associate_trades && orderDetail.associate_trades.length > 0) {
|
||||
console.log(`关联交易数量: ${orderDetail.associate_trades.length}`);
|
||||
console.log(`关联交易 IDs: ${orderDetail.associate_trades.join(', ')}`);
|
||||
}
|
||||
console.log('=========================================\n');
|
||||
|
||||
} catch (apiKeyError) {
|
||||
if (apiKeyError.message && apiKeyError.message.includes('API key does not exist')) {
|
||||
console.log('⚠️ API Key 不存在,正在创建新的 API Key...');
|
||||
|
||||
// 创建新的 API key
|
||||
const creds = await clobClient.createApiKey();
|
||||
console.log(`✅ API Key 已创建`);
|
||||
console.log(` API Key: ${creds.key.substring(0, 10)}...`);
|
||||
console.log(` Passphrase: ${creds.passphrase.substring(0, 10)}...`);
|
||||
|
||||
// 使用 creds 初始化一个新的 ClobClient 实例用于 L2 认证
|
||||
const authenticatedClient = new ClobClient(
|
||||
HOST,
|
||||
CHAIN_ID,
|
||||
wallet,
|
||||
creds
|
||||
);
|
||||
|
||||
console.log(`\n正在获取订单详情...`);
|
||||
console.log(` 订单 ID: ${orderId}`);
|
||||
|
||||
const orderDetail = await authenticatedClient.getOrder(orderId);
|
||||
|
||||
console.log('\n================ 订单详情 ================');
|
||||
console.log(`订单 ID: ${orderDetail.id}`);
|
||||
console.log(`状态: ${orderDetail.status}`);
|
||||
console.log(`所有者: ${orderDetail.owner}`);
|
||||
console.log(`Maker 地址: ${orderDetail.maker_address}`);
|
||||
console.log(`市场 ID: ${orderDetail.market}`);
|
||||
console.log(`资产 ID: ${orderDetail.asset_id}`);
|
||||
console.log(`方向: ${orderDetail.side}`);
|
||||
console.log(`原始数量: ${orderDetail.original_size}`);
|
||||
console.log(`已匹配数量: ${orderDetail.size_matched}`);
|
||||
console.log(`价格: ${orderDetail.price}`);
|
||||
console.log(`结果: ${orderDetail.outcome}`);
|
||||
console.log(`创建时间: ${new Date(orderDetail.created_at * 1000).toISOString()}`);
|
||||
console.log(`过期时间: ${orderDetail.expiration}`);
|
||||
console.log(`订单类型: ${orderDetail.order_type}`);
|
||||
|
||||
if (orderDetail.associate_trades && orderDetail.associate_trades.length > 0) {
|
||||
console.log(`关联交易数量: ${orderDetail.associate_trades.length}`);
|
||||
console.log(`关联交易 IDs: ${orderDetail.associate_trades.join(', ')}`);
|
||||
}
|
||||
console.log('=========================================\n');
|
||||
} else {
|
||||
throw apiKeyError;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ 获取订单详情失败:');
|
||||
console.error(error.message);
|
||||
|
||||
if (error.response) {
|
||||
console.error(`\nHTTP 状态码: ${error.response.status}`);
|
||||
console.error(`响应数据:`, error.response.data);
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查命令行参数
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length < 2) {
|
||||
console.error('错误: 缺少必要参数');
|
||||
console.error('\n使用方法:');
|
||||
console.error(' node scripts/get-order-detail.js <private_key> <order_id>');
|
||||
console.error('\n参数说明:');
|
||||
console.error(' private_key: 钱包私钥(用于签名)');
|
||||
console.error(' order_id: 订单 ID');
|
||||
console.error('\n示例:');
|
||||
console.error(' node scripts/get-order-detail.js "0x123..." "0x456..."');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [privateKey, orderId] = args;
|
||||
|
||||
// 验证私钥格式
|
||||
if (!privateKey.startsWith('0x')) {
|
||||
console.error('错误: 私钥格式不正确,必须以 0x 开头');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (privateKey.length !== 66) {
|
||||
console.error('错误: 私钥长度不正确,应为 66 个字符(包括 0x 前缀)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 验证订单 ID
|
||||
if (!orderId.startsWith('0x')) {
|
||||
console.error('错误: 订单 ID 格式不正确,必须以 0x 开头');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 执行主函数
|
||||
getOrderDetail(privateKey, orderId);
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "polyhermes-scripts",
|
||||
"version": "1.0.0",
|
||||
"description": "Utility scripts for Polyhermes",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"get-order-detail": "node get-order-detail.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ethersproject/wallet": "^5.7.0",
|
||||
"@polymarket/clob-client": "^5.2.1"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user