Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
662aa47de6 | ||
|
|
c3d9d10d5d | ||
|
|
9926533049 | ||
|
|
4d72017b97 | ||
|
|
45734c051e | ||
|
|
db8471bb16 | ||
|
|
0dc6f5894f | ||
|
|
c9769aa17a | ||
|
|
07b4d654b4 | ||
|
|
b65827038f | ||
|
|
d768da72c6 | ||
|
|
7385efff1a | ||
|
|
3e2e97e572 | ||
|
|
ae68a33c1e | ||
|
|
deea59fdbf | ||
|
|
b90f86b081 | ||
|
|
f6f5866118 | ||
|
|
b1e69135b8 | ||
|
|
0c7f34a28a | ||
|
|
c53fcde5d7 | ||
|
|
81a620af12 | ||
|
|
5f44a0ca20 | ||
|
|
5d2cf945f3 | ||
|
|
227a38fa89 | ||
|
|
abcc004606 | ||
|
|
64391503c2 | ||
|
|
92b75d1926 | ||
|
|
e072d0c894 | ||
|
|
cc40493ec6 | ||
|
|
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. 检查网络连接
|
2. 检查网络连接
|
||||||
3. 查看详细错误日志
|
3. 查看详细错误日志
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ package com.wrbug.polymarketbot.config
|
|||||||
|
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||||
|
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||||
import com.wrbug.polymarketbot.util.createClient
|
import com.wrbug.polymarketbot.util.createClient
|
||||||
import org.springframework.beans.factory.annotation.Value
|
|
||||||
import org.springframework.context.annotation.Bean
|
import org.springframework.context.annotation.Bean
|
||||||
import org.springframework.context.annotation.Configuration
|
import org.springframework.context.annotation.Configuration
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
@@ -23,9 +23,6 @@ class RetrofitConfig(
|
|||||||
private val gson: Gson
|
private val gson: Gson
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@Value("\${polymarket.clob.base-url}")
|
|
||||||
private lateinit var clobBaseUrl: String
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建 CLOB API 客户端
|
* 创建 CLOB API 客户端
|
||||||
* 用于跟单系统的订单操作和交易查询
|
* 用于跟单系统的订单操作和交易查询
|
||||||
@@ -38,7 +35,7 @@ class RetrofitConfig(
|
|||||||
val okHttpClient = createClient().build()
|
val okHttpClient = createClient().build()
|
||||||
|
|
||||||
return Retrofit.Builder()
|
return Retrofit.Builder()
|
||||||
.baseUrl(clobBaseUrl)
|
.baseUrl(PolymarketConstants.CLOB_BASE_URL)
|
||||||
.client(okHttpClient)
|
.client(okHttpClient)
|
||||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||||
.build()
|
.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)
|
@Column(name = "notification_sent", nullable = false)
|
||||||
var notificationSent: Boolean = 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)
|
@Column(name = "created_at", nullable = false)
|
||||||
val createdAt: Long = System.currentTimeMillis(),
|
val createdAt: Long = System.currentTimeMillis(),
|
||||||
|
|
||||||
|
|||||||
+4
-8
@@ -75,15 +75,11 @@ interface CopyOrderTrackingRepository : JpaRepository<CopyOrderTracking, Long> {
|
|||||||
fun countActivePositions(copyTradingId: Long): Int
|
fun countActivePositions(copyTradingId: Long): Int
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查指定市场是否存在活跃仓位
|
* 计算指定跟单配置、市场和方向下的当前持仓总价值 (成本价计算)
|
||||||
|
* 按市场+方向(outcomeIndex)分别统计
|
||||||
*/
|
*/
|
||||||
fun existsByCopyTradingIdAndMarketIdAndRemainingQuantityGreaterThan(copyTradingId: Long, marketId: String, remainingQuantity: BigDecimal): Boolean
|
@Query("SELECT SUM(t.remainingQuantity * t.price) FROM CopyOrderTracking t WHERE t.copyTradingId = :copyTradingId AND t.marketId = :marketId AND t.outcomeIndex = :outcomeIndex AND t.remainingQuantity > 0")
|
||||||
|
fun sumCurrentPositionValueByMarketAndOutcomeIndex(copyTradingId: Long, marketId: String, outcomeIndex: Int): BigDecimal?
|
||||||
/**
|
|
||||||
* 计算指定跟单配置和市场下的当前持仓总价值 (成本价计算)
|
|
||||||
*/
|
|
||||||
@Query("SELECT SUM(t.remainingQuantity * t.price) FROM CopyOrderTracking t WHERE t.copyTradingId = :copyTradingId AND t.marketId = :marketId AND t.remainingQuantity > 0")
|
|
||||||
fun sumCurrentPositionValueByMarket(copyTradingId: Long, marketId: String): BigDecimal?
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询指定跟单配置下,创建时间超过指定时间点的未匹配订单(FIFO顺序)
|
* 查询指定跟单配置下,创建时间超过指定时间点的未匹配订单(FIFO顺序)
|
||||||
|
|||||||
+16
-18
@@ -21,29 +21,29 @@ import java.util.concurrent.CopyOnWriteArrayList
|
|||||||
class PositionPollingService(
|
class PositionPollingService(
|
||||||
private val accountService: AccountService
|
private val accountService: AccountService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(PositionPollingService::class.java)
|
private val logger = LoggerFactory.getLogger(PositionPollingService::class.java)
|
||||||
|
|
||||||
@Value("\${position.polling.interval:2000}")
|
@Value("\${position.polling.interval:2000}")
|
||||||
private var pollingInterval: Long = 2000 // 轮训间隔(毫秒),默认2秒
|
private var pollingInterval: Long = 2000 // 轮训间隔(毫秒),默认2秒
|
||||||
|
|
||||||
// 订阅者列表(支持多个订阅者)
|
// 订阅者列表(支持多个订阅者)
|
||||||
private val subscribers = CopyOnWriteArrayList<(PositionListResponse) -> Unit>()
|
private val subscribers = CopyOnWriteArrayList<(PositionListResponse) -> Unit>()
|
||||||
|
|
||||||
// 最新仓位数据(用于丢弃机制)
|
// 最新仓位数据(用于丢弃机制)
|
||||||
@Volatile
|
@Volatile
|
||||||
private var latestPositions: PositionListResponse? = null
|
private var latestPositions: PositionListResponse? = null
|
||||||
|
|
||||||
// 协程作用域和任务
|
// 协程作用域和任务
|
||||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||||
private var pollingJob: Job? = null
|
private var pollingJob: Job? = null
|
||||||
|
|
||||||
// 事件分发协程(使用专门的线程,避免阻塞轮训)
|
// 事件分发协程(使用专门的线程,避免阻塞轮训)
|
||||||
private val eventDispatcherScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val eventDispatcherScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
// 同步锁,确保轮询任务的启动和停止是线程安全的
|
// 同步锁,确保轮询任务的启动和停止是线程安全的
|
||||||
private val lock = Any()
|
private val lock = Any()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化服务(后端启动时直接启动轮训)
|
* 初始化服务(后端启动时直接启动轮训)
|
||||||
*/
|
*/
|
||||||
@@ -52,7 +52,7 @@ class PositionPollingService(
|
|||||||
logger.info("PositionPollingService 初始化,启动仓位轮训任务,轮训间隔: ${pollingInterval}ms")
|
logger.info("PositionPollingService 初始化,启动仓位轮训任务,轮训间隔: ${pollingInterval}ms")
|
||||||
startPolling()
|
startPolling()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清理资源
|
* 清理资源
|
||||||
*/
|
*/
|
||||||
@@ -66,7 +66,7 @@ class PositionPollingService(
|
|||||||
scope.cancel()
|
scope.cancel()
|
||||||
eventDispatcherScope.cancel()
|
eventDispatcherScope.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 订阅仓位事件
|
* 订阅仓位事件
|
||||||
* @param callback 回调函数,接收最新的仓位数据
|
* @param callback 回调函数,接收最新的仓位数据
|
||||||
@@ -78,7 +78,7 @@ class PositionPollingService(
|
|||||||
latestPositions?.let { callback(it) }
|
latestPositions?.let { callback(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 取消订阅仓位事件
|
* 取消订阅仓位事件
|
||||||
*/
|
*/
|
||||||
@@ -87,7 +87,7 @@ class PositionPollingService(
|
|||||||
subscribers.remove(callback)
|
subscribers.remove(callback)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动轮训任务
|
* 启动轮训任务
|
||||||
*/
|
*/
|
||||||
@@ -95,7 +95,7 @@ class PositionPollingService(
|
|||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
// 如果已经有轮训任务在运行,先取消
|
// 如果已经有轮训任务在运行,先取消
|
||||||
pollingJob?.cancel()
|
pollingJob?.cancel()
|
||||||
|
|
||||||
// 启动新的轮训任务
|
// 启动新的轮训任务
|
||||||
pollingJob = scope.launch {
|
pollingJob = scope.launch {
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
@@ -109,7 +109,7 @@ class PositionPollingService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 轮训仓位数据并发布事件
|
* 轮训仓位数据并发布事件
|
||||||
* 使用专门的线程分发事件,避免阻塞轮训
|
* 使用专门的线程分发事件,避免阻塞轮训
|
||||||
@@ -123,7 +123,7 @@ class PositionPollingService(
|
|||||||
if (positions != null) {
|
if (positions != null) {
|
||||||
// 更新最新数据(丢弃旧数据,只保留最新的)
|
// 更新最新数据(丢弃旧数据,只保留最新的)
|
||||||
latestPositions = positions
|
latestPositions = positions
|
||||||
|
|
||||||
// 在专门的线程中分发事件,避免阻塞轮训
|
// 在专门的线程中分发事件,避免阻塞轮训
|
||||||
eventDispatcherScope.launch {
|
eventDispatcherScope.launch {
|
||||||
try {
|
try {
|
||||||
@@ -131,7 +131,7 @@ class PositionPollingService(
|
|||||||
val currentSubscribers = synchronized(lock) {
|
val currentSubscribers = synchronized(lock) {
|
||||||
subscribers.toList() // 复制列表,避免并发修改
|
subscribers.toList() // 复制列表,避免并发修改
|
||||||
}
|
}
|
||||||
|
|
||||||
currentSubscribers.forEach { callback ->
|
currentSubscribers.forEach { callback ->
|
||||||
try {
|
try {
|
||||||
callback(positions)
|
callback(positions)
|
||||||
@@ -139,8 +139,6 @@ class PositionPollingService(
|
|||||||
logger.error("通知订阅者失败: ${e.message}", e)
|
logger.error("通知订阅者失败: ${e.message}", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug("发布仓位数据事件: currentPositions=${positions.currentPositions.size}, historyPositions=${positions.historyPositions.size}, subscribers=${currentSubscribers.size}")
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("分发仓位数据事件失败: ${e.message}", e)
|
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.PolymarketDataApi
|
||||||
import com.wrbug.polymarketbot.api.PositionResponse
|
import com.wrbug.polymarketbot.api.PositionResponse
|
||||||
import com.wrbug.polymarketbot.api.ValueResponse
|
import com.wrbug.polymarketbot.api.ValueResponse
|
||||||
|
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||||
import com.wrbug.polymarketbot.util.EthereumUtils
|
import com.wrbug.polymarketbot.util.EthereumUtils
|
||||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||||
import com.wrbug.polymarketbot.util.createClient
|
import com.wrbug.polymarketbot.util.createClient
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.beans.factory.annotation.Value
|
|
||||||
import com.wrbug.polymarketbot.service.system.RelayClientService
|
import com.wrbug.polymarketbot.service.system.RelayClientService
|
||||||
import com.wrbug.polymarketbot.service.system.RpcNodeService
|
import com.wrbug.polymarketbot.service.system.RpcNodeService
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
@@ -26,8 +26,6 @@ import java.math.BigInteger
|
|||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
class BlockchainService(
|
class BlockchainService(
|
||||||
@Value("\${polymarket.data-api.base-url:https://data-api.polymarket.com}")
|
|
||||||
private val dataApiBaseUrl: String,
|
|
||||||
private val retrofitFactory: RetrofitFactory,
|
private val retrofitFactory: RetrofitFactory,
|
||||||
private val relayClientService: RelayClientService,
|
private val relayClientService: RelayClientService,
|
||||||
private val rpcNodeService: RpcNodeService,
|
private val rpcNodeService: RpcNodeService,
|
||||||
@@ -61,10 +59,10 @@ class BlockchainService(
|
|||||||
private val computeProxyAddressFunctionSignature = "computeProxyAddress(address)"
|
private val computeProxyAddressFunctionSignature = "computeProxyAddress(address)"
|
||||||
|
|
||||||
private val dataApi: PolymarketDataApi by lazy {
|
private val dataApi: PolymarketDataApi by lazy {
|
||||||
val baseUrl = if (dataApiBaseUrl.endsWith("/")) {
|
val baseUrl = if (PolymarketConstants.DATA_API_BASE_URL.endsWith("/")) {
|
||||||
dataApiBaseUrl.dropLast(1)
|
PolymarketConstants.DATA_API_BASE_URL.dropLast(1)
|
||||||
} else {
|
} else {
|
||||||
dataApiBaseUrl
|
PolymarketConstants.DATA_API_BASE_URL
|
||||||
}
|
}
|
||||||
val okHttpClient = createClient()
|
val okHttpClient = createClient()
|
||||||
.followRedirects(true)
|
.followRedirects(true)
|
||||||
|
|||||||
@@ -89,8 +89,6 @@ class MarketPollingService(
|
|||||||
*/
|
*/
|
||||||
private suspend fun checkAndUpdateMissingMarkets() {
|
private suspend fun checkAndUpdateMissingMarkets() {
|
||||||
try {
|
try {
|
||||||
logger.debug("开始检查缺失的市场信息...")
|
|
||||||
|
|
||||||
// 1. 获取所有买入订单的市场ID(去重)
|
// 1. 获取所有买入订单的市场ID(去重)
|
||||||
val allOrders = copyOrderTrackingRepository.findAll()
|
val allOrders = copyOrderTrackingRepository.findAll()
|
||||||
val marketIds = allOrders.map { it.marketId }.distinct()
|
val marketIds = allOrders.map { it.marketId }.distinct()
|
||||||
@@ -99,9 +97,6 @@ class MarketPollingService(
|
|||||||
logger.debug("没有找到任何订单,跳过市场信息检查")
|
logger.debug("没有找到任何订单,跳过市场信息检查")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug("找到 ${marketIds.size} 个不同的市场ID")
|
|
||||||
|
|
||||||
// 2. 检查哪些市场信息在数据库中缺失
|
// 2. 检查哪些市场信息在数据库中缺失
|
||||||
val existingMarkets = marketService.marketRepository.findByMarketIdIn(marketIds)
|
val existingMarkets = marketService.marketRepository.findByMarketIdIn(marketIds)
|
||||||
val existingMarketIds = existingMarkets.map { it.marketId }.toSet()
|
val existingMarketIds = existingMarkets.map { it.marketId }.toSet()
|
||||||
@@ -113,7 +108,6 @@ class MarketPollingService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (validMissingMarketIds.isEmpty()) {
|
if (validMissingMarketIds.isEmpty()) {
|
||||||
logger.debug("所有市场信息都已存在,无需更新")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-5
@@ -3,12 +3,12 @@ package com.wrbug.polymarketbot.service.common
|
|||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.wrbug.polymarketbot.api.ApiKeyResponse
|
import com.wrbug.polymarketbot.api.ApiKeyResponse
|
||||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||||
|
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||||
import com.wrbug.polymarketbot.util.PolymarketL1AuthInterceptor
|
import com.wrbug.polymarketbot.util.PolymarketL1AuthInterceptor
|
||||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||||
import com.wrbug.polymarketbot.util.createClient
|
import com.wrbug.polymarketbot.util.createClient
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.beans.factory.annotation.Value
|
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.converter.gson.GsonConverterFactory
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
@@ -19,8 +19,6 @@ import retrofit2.converter.gson.GsonConverterFactory
|
|||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
class PolymarketApiKeyService(
|
class PolymarketApiKeyService(
|
||||||
@Value("\${polymarket.clob.base-url}")
|
|
||||||
private val clobBaseUrl: String,
|
|
||||||
private val gson: Gson
|
private val gson: Gson
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@@ -224,7 +222,7 @@ class PolymarketApiKeyService(
|
|||||||
.build()
|
.build()
|
||||||
|
|
||||||
return Retrofit.Builder()
|
return Retrofit.Builder()
|
||||||
.baseUrl(clobBaseUrl)
|
.baseUrl(PolymarketConstants.CLOB_BASE_URL)
|
||||||
.client(okHttpClient)
|
.client(okHttpClient)
|
||||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||||
.build()
|
.build()
|
||||||
@@ -238,7 +236,7 @@ class PolymarketApiKeyService(
|
|||||||
val okHttpClient = createClient().build()
|
val okHttpClient = createClient().build()
|
||||||
|
|
||||||
return Retrofit.Builder()
|
return Retrofit.Builder()
|
||||||
.baseUrl(clobBaseUrl)
|
.baseUrl(PolymarketConstants.CLOB_BASE_URL)
|
||||||
.client(okHttpClient)
|
.client(okHttpClient)
|
||||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
+59
-33
@@ -45,7 +45,8 @@ class CopyTradingFilterService(
|
|||||||
copyOrderAmount: BigDecimal? = null, // 跟单金额(USDC),用于仓位检查
|
copyOrderAmount: BigDecimal? = null, // 跟单金额(USDC),用于仓位检查
|
||||||
marketId: String? = null, // 市场ID,用于仓位检查(按市场过滤仓位)
|
marketId: String? = null, // 市场ID,用于仓位检查(按市场过滤仓位)
|
||||||
marketTitle: String? = null, // 市场标题,用于关键字过滤
|
marketTitle: String? = null, // 市场标题,用于关键字过滤
|
||||||
marketEndDate: Long? = null // 市场截止时间,用于市场截止时间检查
|
marketEndDate: Long? = null, // 市场截止时间,用于市场截止时间检查
|
||||||
|
outcomeIndex: Int? = null // 方向索引(0, 1, 2, ...),用于按市场+方向检查仓位
|
||||||
): FilterResult {
|
): FilterResult {
|
||||||
// 1. 关键字过滤检查(如果配置了关键字过滤)
|
// 1. 关键字过滤检查(如果配置了关键字过滤)
|
||||||
if (copyTrading.keywordFilterMode != null && copyTrading.keywordFilterMode != "DISABLED") {
|
if (copyTrading.keywordFilterMode != null && copyTrading.keywordFilterMode != "DISABLED") {
|
||||||
@@ -71,12 +72,20 @@ class CopyTradingFilterService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 检查是否需要获取订单簿
|
// 3. 检查是否需要获取订单簿或需要执行仓位检查
|
||||||
// 只有在配置了需要订单簿的过滤条件时才获取
|
// 只有在配置了需要订单簿的过滤条件时才获取订单簿
|
||||||
val needOrderbook = copyTrading.maxSpread != null || copyTrading.minOrderDepth != null
|
val needOrderbook = copyTrading.maxSpread != null || copyTrading.minOrderDepth != null
|
||||||
|
|
||||||
|
// 3.5. 如果不需要订单簿,则跳过订单簿相关的检查,但仍然需要检查仓位限制
|
||||||
if (!needOrderbook) {
|
if (!needOrderbook) {
|
||||||
// 不需要订单簿,直接通过
|
// 仓位检查(如果配置了最大仓位限制且提供了跟单金额和市场ID)
|
||||||
|
if (copyOrderAmount != null && marketId != null) {
|
||||||
|
val positionCheck = checkPositionLimits(copyTrading, copyOrderAmount, marketId, outcomeIndex)
|
||||||
|
if (!positionCheck.isPassed) {
|
||||||
|
return positionCheck
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 通过所有检查
|
||||||
return FilterResult.passed()
|
return FilterResult.passed()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +117,7 @@ class CopyTradingFilterService(
|
|||||||
|
|
||||||
// 7. 仓位检查(如果配置了最大仓位限制且提供了跟单金额和市场ID)
|
// 7. 仓位检查(如果配置了最大仓位限制且提供了跟单金额和市场ID)
|
||||||
if (copyOrderAmount != null && marketId != null) {
|
if (copyOrderAmount != null && marketId != null) {
|
||||||
val positionCheck = checkPositionLimits(copyTrading, copyOrderAmount, marketId)
|
val positionCheck = checkPositionLimits(copyTrading, copyOrderAmount, marketId, outcomeIndex)
|
||||||
if (!positionCheck.isPassed) {
|
if (!positionCheck.isPassed) {
|
||||||
return positionCheck
|
return positionCheck
|
||||||
}
|
}
|
||||||
@@ -283,87 +292,104 @@ class CopyTradingFilterService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查仓位限制(按市场检查)
|
* 检查仓位限制(按市场+方向检查)
|
||||||
* @param copyTrading 跟单配置
|
* @param copyTrading 跟单配置
|
||||||
* @param copyOrderAmount 跟单金额(USDC)
|
* @param copyOrderAmount 跟单金额(USDC)
|
||||||
* @param marketId 市场ID,用于过滤该市场的仓位
|
* @param marketId 市场ID,用于过滤该市场的仓位
|
||||||
|
* @param outcomeIndex 方向索引(0, 1, 2, ...),用于按市场+方向检查仓位
|
||||||
* @return 过滤结果
|
* @return 过滤结果
|
||||||
*/
|
*/
|
||||||
private suspend fun checkPositionLimits(
|
private suspend fun checkPositionLimits(
|
||||||
copyTrading: CopyTrading,
|
copyTrading: CopyTrading,
|
||||||
copyOrderAmount: BigDecimal,
|
copyOrderAmount: BigDecimal,
|
||||||
marketId: String
|
marketId: String,
|
||||||
|
outcomeIndex: Int?
|
||||||
): FilterResult {
|
): FilterResult {
|
||||||
// 如果未配置仓位限制,直接通过
|
// 如果未配置仓位限制,直接通过
|
||||||
if (copyTrading.maxPositionValue == null && copyTrading.maxPositionCount == null) {
|
if (copyTrading.maxPositionValue == null && copyTrading.maxPositionCount == null) {
|
||||||
return FilterResult.passed()
|
return FilterResult.passed()
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 获取账户的所有仓位信息
|
// 获取账户的所有仓位信息
|
||||||
val positionsResult = accountService.getAllPositions()
|
val positionsResult = accountService.getAllPositions()
|
||||||
if (positionsResult.isFailure) {
|
if (positionsResult.isFailure) {
|
||||||
logger.warn("获取仓位信息失败,跳过仓位检查: accountId=${copyTrading.accountId}, marketId=$marketId, error=${positionsResult.exceptionOrNull()?.message}")
|
logger.warn("获取仓位信息失败,跳过仓位检查: accountId=${copyTrading.accountId}, marketId=$marketId, outcomeIndex=$outcomeIndex, error=${positionsResult.exceptionOrNull()?.message}")
|
||||||
// 如果获取仓位失败,为了安全起见,不通过检查
|
// 如果获取仓位失败,为了安全起见,不通过检查
|
||||||
return FilterResult.maxPositionValueFailed("获取仓位信息失败,无法进行仓位检查")
|
return FilterResult.maxPositionValueFailed("获取仓位信息失败,无法进行仓位检查")
|
||||||
}
|
}
|
||||||
|
|
||||||
val positions = positionsResult.getOrNull() ?: return FilterResult.maxPositionValueFailed("仓位信息为空")
|
val positions = positionsResult.getOrNull() ?: return FilterResult.maxPositionValueFailed("仓位信息为空")
|
||||||
|
|
||||||
// 过滤出当前账户且该市场的仓位
|
// 过滤出当前账户且该市场的仓位
|
||||||
val marketPositions = positions.currentPositions.filter {
|
val marketPositions = positions.currentPositions.filter {
|
||||||
it.accountId == copyTrading.accountId && it.marketId == marketId
|
it.accountId == copyTrading.accountId && it.marketId == marketId
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查最大仓位金额(如果配置了)
|
// 检查最大仓位金额(如果配置了)
|
||||||
if (copyTrading.maxPositionValue != null) {
|
if (copyTrading.maxPositionValue != null && outcomeIndex != null) {
|
||||||
// 比较数据库成本价(本地订单记录)和外部持仓市值(可能来自其他终端的操作),取最大值
|
// 按市场+方向(outcomeIndex)分别计算数据库成本价
|
||||||
val dbValue = copyOrderTrackingRepository.sumCurrentPositionValueByMarket(copyTrading.id!!, marketId) ?: BigDecimal.ZERO
|
val dbValue = copyOrderTrackingRepository.sumCurrentPositionValueByMarketAndOutcomeIndex(
|
||||||
val extValue = marketPositions.sumOf { it.currentValue.toSafeBigDecimal() }
|
copyTrading.id!!, marketId, outcomeIndex
|
||||||
|
) ?: BigDecimal.ZERO
|
||||||
|
|
||||||
|
// 外部持仓也需要按方向过滤,但由于外部持仓可能没有 outcomeIndex 信息,这里保守处理:
|
||||||
|
// 如果外部持仓存在,取该市场的所有外部持仓市值(与数据库取最大值)
|
||||||
|
val extValue = if (marketPositions.isNotEmpty()) {
|
||||||
|
marketPositions.sumOf { it.currentValue.toSafeBigDecimal() }
|
||||||
|
} else {
|
||||||
|
BigDecimal.ZERO
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取数据库值和外部持仓值的最大值
|
||||||
val currentPositionValue = dbValue.max(extValue)
|
val currentPositionValue = dbValue.max(extValue)
|
||||||
|
|
||||||
// 检查:该市场的当前仓位 + 跟单金额 <= 最大仓位金额
|
// 检查:该市场该方向的当前仓位 + 跟单金额 <= 最大仓位金额
|
||||||
val totalValueAfterOrder = currentPositionValue.add(copyOrderAmount)
|
val totalValueAfterOrder = currentPositionValue.add(copyOrderAmount)
|
||||||
|
|
||||||
if (totalValueAfterOrder.gt(copyTrading.maxPositionValue)) {
|
if (totalValueAfterOrder.gt(copyTrading.maxPositionValue)) {
|
||||||
return FilterResult.maxPositionValueFailed(
|
return FilterResult.maxPositionValueFailed(
|
||||||
"超过最大仓位金额限制: 当前该市场仓位(取最大值)=${currentPositionValue} USDC (DB=${dbValue}, Ext=${extValue}), 跟单金额=${copyOrderAmount} USDC, 总计=${totalValueAfterOrder} USDC > 最大限制=${copyTrading.maxPositionValue} USDC"
|
"超过最大仓位金额限制: 市场=$marketId, 方向=$outcomeIndex, 当前仓位(取最大值)=${currentPositionValue} USDC (DB=${dbValue}, Ext=${extValue}), 跟单金额=${copyOrderAmount} USDC, 总计=${totalValueAfterOrder} USDC > 最大限制=${copyTrading.maxPositionValue} USDC"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查最大仓位数量(如果配置了)
|
// 检查最大仓位数量(如果配置了)
|
||||||
if (copyTrading.maxPositionCount != null) {
|
if (copyTrading.maxPositionCount != null) {
|
||||||
// 使用数据库中的订单记录计算活跃仓位数量(解决延迟问题)
|
// 使用数据库中的订单记录计算活跃仓位数量(解决延迟问题)
|
||||||
val dbCount = copyOrderTrackingRepository.countActivePositions(copyTrading.id!!)
|
val dbCount = copyOrderTrackingRepository.countActivePositions(copyTrading.id!!)
|
||||||
|
|
||||||
// 计算外部持仓中的唯一市场数量(防止遗漏非本项目创建的仓位)
|
// 计算外部持仓中的唯一市场数量(防止遗漏非本项目创建的仓位)
|
||||||
val extCount = positions.currentPositions
|
val extCount = positions.currentPositions
|
||||||
.filter { it.accountId == copyTrading.accountId }
|
.filter { it.accountId == copyTrading.accountId }
|
||||||
.map { it.marketId }
|
.map { it.marketId }
|
||||||
.distinct()
|
.distinct()
|
||||||
.size
|
.size
|
||||||
|
|
||||||
val currentPositionCount = maxOf(dbCount, extCount)
|
val currentPositionCount = maxOf(dbCount, extCount)
|
||||||
|
|
||||||
// 检查:如果当前没有该市场的活跃仓位,且总仓位数量已达到限制,则不允许开新仓
|
// 检查:如果当前没有该市场该方向的活跃仓位,且总仓位数量已达到限制,则不允许开新仓
|
||||||
// 判断当前市场是否已有活跃仓位(数据库或外部持仓)
|
// 判断当前市场该方向是否已有活跃仓位(数据库)
|
||||||
val hasDbPosition = copyOrderTrackingRepository.existsByCopyTradingIdAndMarketIdAndRemainingQuantityGreaterThan(
|
val hasDbPosition = if (outcomeIndex != null) {
|
||||||
copyTrading.id, marketId, BigDecimal.ZERO
|
copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndex(
|
||||||
)
|
copyTrading.id, marketId, outcomeIndex
|
||||||
|
).isNotEmpty()
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
val hasExtPosition = marketPositions.isNotEmpty()
|
val hasExtPosition = marketPositions.isNotEmpty()
|
||||||
val hasCurrentMarketPosition = hasDbPosition || hasExtPosition
|
val hasCurrentMarketPosition = hasDbPosition || hasExtPosition
|
||||||
|
|
||||||
if (!hasCurrentMarketPosition && currentPositionCount >= copyTrading.maxPositionCount) {
|
if (!hasCurrentMarketPosition && currentPositionCount >= copyTrading.maxPositionCount) {
|
||||||
return FilterResult.maxPositionCountFailed(
|
return FilterResult.maxPositionCountFailed(
|
||||||
"超过最大仓位数量限制: 当前活跃仓位总数(取最大值)=${currentPositionCount} (DB=${dbCount}, Ext=${extCount}) >= 最大限制=${copyTrading.maxPositionCount}"
|
"超过最大仓位数量限制: 当前活跃仓位总数(取最大值)=${currentPositionCount} (DB=${dbCount}, Ext=${extCount}) >= 最大限制=${copyTrading.maxPositionCount}"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return FilterResult.passed()
|
return FilterResult.passed()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("仓位检查异常: accountId=${copyTrading.accountId}, marketId=$marketId, error=${e.message}", e)
|
logger.error("仓位检查异常: accountId=${copyTrading.accountId}, marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}", e)
|
||||||
// 如果检查异常,为了安全起见,不通过检查
|
// 如果检查异常,为了安全起见,不通过检查
|
||||||
return FilterResult.maxPositionValueFailed("仓位检查异常: ${e.message}")
|
return FilterResult.maxPositionValueFailed("仓位检查异常: ${e.message}")
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-3
@@ -14,6 +14,8 @@ import com.wrbug.polymarketbot.util.IllegalBigDecimal
|
|||||||
import com.wrbug.polymarketbot.util.JsonUtils
|
import com.wrbug.polymarketbot.util.JsonUtils
|
||||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.context.ApplicationContext
|
||||||
|
import org.springframework.context.ApplicationContextAware
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
@@ -30,10 +32,24 @@ class CopyTradingService(
|
|||||||
private val monitorService: CopyTradingMonitorService,
|
private val monitorService: CopyTradingMonitorService,
|
||||||
private val jsonUtils: JsonUtils,
|
private val jsonUtils: JsonUtils,
|
||||||
private val gson: Gson
|
private val gson: Gson
|
||||||
) {
|
) : ApplicationContextAware {
|
||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(CopyTradingService::class.java)
|
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
|
@Transactional
|
||||||
fun updateCopyTradingStatus(request: CopyTradingUpdateStatusRequest): Result<CopyTradingDto> {
|
fun updateCopyTradingStatus(request: CopyTradingUpdateStatusRequest): Result<CopyTradingDto> {
|
||||||
return updateCopyTrading(
|
return getSelf().updateCopyTrading(
|
||||||
CopyTradingUpdateRequest(
|
CopyTradingUpdateRequest(
|
||||||
copyTradingId = request.copyTradingId,
|
copyTradingId = request.copyTradingId,
|
||||||
enabled = request.enabled
|
enabled = request.enabled
|
||||||
|
|||||||
+2
-2
@@ -114,10 +114,10 @@ class AccountOnChainMonitorService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val receiptRpcResponse = receiptResponse.body()!!
|
val receiptRpcResponse = receiptResponse.body()!!
|
||||||
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null) {
|
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null || receiptRpcResponse.result.isJsonNull) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 Gson 解析 receipt JSON
|
// 使用 Gson 解析 receipt JSON
|
||||||
val receiptJson = receiptRpcResponse.result.asJsonObject
|
val receiptJson = receiptRpcResponse.result.asJsonObject
|
||||||
|
|
||||||
|
|||||||
+3
-4
@@ -9,8 +9,8 @@ import com.wrbug.polymarketbot.repository.CopyTradingTemplateRepository
|
|||||||
import com.wrbug.polymarketbot.websocket.PolymarketWebSocketClient
|
import com.wrbug.polymarketbot.websocket.PolymarketWebSocketClient
|
||||||
import jakarta.annotation.PreDestroy
|
import jakarta.annotation.PreDestroy
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
|
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.beans.factory.annotation.Value
|
|
||||||
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
|
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
@@ -28,8 +28,7 @@ class CopyTradingWebSocketService(
|
|||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(CopyTradingWebSocketService::class.java)
|
private val logger = LoggerFactory.getLogger(CopyTradingWebSocketService::class.java)
|
||||||
|
|
||||||
@Value("\${polymarket.websocket.url:wss://ws-live-data.polymarket.com}")
|
private val websocketUrl: String = PolymarketConstants.USER_WS_URL
|
||||||
private var websocketUrl: String = "wss://ws-live-data.polymarket.com"
|
|
||||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||||
|
|
||||||
// 存储每个Leader的WebSocket客户端:leaderId -> WebSocketClient
|
// 存储每个Leader的WebSocket客户端:leaderId -> WebSocketClient
|
||||||
@@ -214,7 +213,7 @@ class CopyTradingWebSocketService(
|
|||||||
// 处理交易
|
// 处理交易
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
copyOrderTrackingService.processTrade(leaderId, trade, "websocket")
|
copyOrderTrackingService.processTrade(leaderId, trade, "activity-ws")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("处理交易失败: leaderId=$leaderId, tradeId=${trade.id}", e)
|
logger.error("处理交易失败: leaderId=$leaderId, tradeId=${trade.id}", e)
|
||||||
}
|
}
|
||||||
|
|||||||
+49
-27
@@ -1,5 +1,8 @@
|
|||||||
package com.wrbug.polymarketbot.service.copytrading.monitor
|
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.api.*
|
||||||
import com.wrbug.polymarketbot.entity.Leader
|
import com.wrbug.polymarketbot.entity.Leader
|
||||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||||
@@ -11,6 +14,7 @@ import okhttp3.OkHttpClient
|
|||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 链上 WebSocket 监听服务
|
* 链上 WebSocket 监听服务
|
||||||
@@ -23,12 +27,17 @@ class OnChainWsService(
|
|||||||
private val copyOrderTrackingService: CopyOrderTrackingService,
|
private val copyOrderTrackingService: CopyOrderTrackingService,
|
||||||
private val leaderRepository: LeaderRepository
|
private val leaderRepository: LeaderRepository
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(OnChainWsService::class.java)
|
private val logger = LoggerFactory.getLogger(OnChainWsService::class.java)
|
||||||
|
|
||||||
// 存储需要监听的Leader:leaderId -> Leader
|
// 存储需要监听的Leader:leaderId -> Leader
|
||||||
private val monitoredLeaders = ConcurrentHashMap<Long, Leader>()
|
private val monitoredLeaders = ConcurrentHashMap<Long, Leader>()
|
||||||
|
|
||||||
|
// 存储已处理的交易哈希,用于去重(LRU 缓存,保留最近 100 条)
|
||||||
|
private val processedTxHashes: Cache<String, Long> = Caffeine.newBuilder()
|
||||||
|
.maximumSize(100)
|
||||||
|
.build()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动链上 WebSocket 监听
|
* 启动链上 WebSocket 监听
|
||||||
* 通过统一服务订阅所有 Leader
|
* 通过统一服务订阅所有 Leader
|
||||||
@@ -40,14 +49,14 @@ class OnChainWsService(
|
|||||||
stop()
|
stop()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新 Leader 列表
|
// 更新 Leader 列表
|
||||||
monitoredLeaders.clear()
|
monitoredLeaders.clear()
|
||||||
leaders.forEach { leader ->
|
leaders.forEach { leader ->
|
||||||
addLeader(leader)
|
addLeader(leader)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 添加Leader监听
|
* 添加Leader监听
|
||||||
* 通过统一服务订阅该 Leader 的地址
|
* 通过统一服务订阅该 Leader 的地址
|
||||||
@@ -57,17 +66,17 @@ class OnChainWsService(
|
|||||||
logger.warn("Leader ID为空,跳过: ${leader.leaderAddress}")
|
logger.warn("Leader ID为空,跳过: ${leader.leaderAddress}")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val leaderId = leader.id!!
|
val leaderId = leader.id!!
|
||||||
|
|
||||||
// 如果已经在监听列表中,不重复添加
|
// 如果已经在监听列表中,不重复添加
|
||||||
if (monitoredLeaders.containsKey(leaderId)) {
|
if (monitoredLeaders.containsKey(leaderId)) {
|
||||||
logger.debug("Leader 已在监听列表中: ${leader.leaderName} (${leader.leaderAddress})")
|
logger.debug("Leader 已在监听列表中: ${leader.leaderName} (${leader.leaderAddress})")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
monitoredLeaders[leaderId] = leader
|
monitoredLeaders[leaderId] = leader
|
||||||
|
|
||||||
// 通过统一服务订阅
|
// 通过统一服务订阅
|
||||||
val subscriptionId = "LEADER_$leaderId"
|
val subscriptionId = "LEADER_$leaderId"
|
||||||
unifiedOnChainWsService.subscribe(
|
unifiedOnChainWsService.subscribe(
|
||||||
@@ -79,40 +88,53 @@ class OnChainWsService(
|
|||||||
handleLeaderTransaction(leaderId, txHash, httpClient, rpcApi)
|
handleLeaderTransaction(leaderId, txHash, httpClient, rpcApi)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("添加 Leader 监听: ${leader.leaderName} (${leader.leaderAddress})")
|
logger.info("添加 Leader 监听: ${leader.leaderName} (${leader.leaderAddress})")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理 Leader 的交易
|
* 处理 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
|
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}")
|
logger.debug("开始处理 Leader 交易: leaderId=$leaderId, txHash=$txHash, leaderAddress=${leader.leaderAddress}")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 获取交易 receipt
|
// 获取交易 receipt
|
||||||
val receiptRequest = JsonRpcRequest(
|
val receiptRequest = JsonRpcRequest(
|
||||||
method = "eth_getTransactionReceipt",
|
method = "eth_getTransactionReceipt",
|
||||||
params = listOf(txHash)
|
params = listOf(txHash)
|
||||||
)
|
)
|
||||||
|
|
||||||
val receiptResponse = rpcApi.call(receiptRequest)
|
val receiptResponse = rpcApi.call(receiptRequest)
|
||||||
if (!receiptResponse.isSuccessful || receiptResponse.body() == null) {
|
if (!receiptResponse.isSuccessful || receiptResponse.body() == null) {
|
||||||
logger.warn("获取交易 receipt 失败: leaderId=$leaderId, txHash=$txHash, code=${receiptResponse.code()}")
|
logger.warn("获取交易 receipt 失败: leaderId=$leaderId, txHash=$txHash, code=${receiptResponse.code()}")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val receiptRpcResponse = receiptResponse.body()!!
|
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}")
|
logger.warn("交易 receipt 错误: leaderId=$leaderId, txHash=$txHash, error=${receiptRpcResponse.error}")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 Gson 解析 receipt JSON
|
// 使用 Gson 解析 receipt JSON
|
||||||
val receiptJson = receiptRpcResponse.result.asJsonObject
|
val receiptJson = receiptRpcResponse.result.asJsonObject
|
||||||
|
|
||||||
// 获取区块号和时间戳
|
// 获取区块号和时间戳
|
||||||
val blockNumber = receiptJson.get("blockNumber")?.asString
|
val blockNumber = receiptJson.get("blockNumber")?.asString
|
||||||
val blockTimestamp = if (blockNumber != null) {
|
val blockTimestamp = if (blockNumber != null) {
|
||||||
@@ -120,7 +142,7 @@ class OnChainWsService(
|
|||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析 receipt 中的 Transfer 日志
|
// 解析 receipt 中的 Transfer 日志
|
||||||
val logs = receiptJson.getAsJsonArray("logs") ?: run {
|
val logs = receiptJson.getAsJsonArray("logs") ?: run {
|
||||||
logger.warn("交易 receipt 中没有日志: leaderId=$leaderId, txHash=$txHash")
|
logger.warn("交易 receipt 中没有日志: leaderId=$leaderId, txHash=$txHash")
|
||||||
@@ -128,7 +150,7 @@ class OnChainWsService(
|
|||||||
}
|
}
|
||||||
val (erc20Transfers, erc1155Transfers) = OnChainWsUtils.parseReceiptTransfers(logs)
|
val (erc20Transfers, erc1155Transfers) = OnChainWsUtils.parseReceiptTransfers(logs)
|
||||||
logger.debug("解析交易日志: leaderId=$leaderId, txHash=$txHash, erc20Transfers=${erc20Transfers.size}, erc1155Transfers=${erc1155Transfers.size}")
|
logger.debug("解析交易日志: leaderId=$leaderId, txHash=$txHash, erc20Transfers=${erc20Transfers.size}, erc1155Transfers=${erc1155Transfers.size}")
|
||||||
|
|
||||||
// 解析交易信息
|
// 解析交易信息
|
||||||
val trade = OnChainWsUtils.parseTradeFromTransfers(
|
val trade = OnChainWsUtils.parseTradeFromTransfers(
|
||||||
txHash = txHash,
|
txHash = txHash,
|
||||||
@@ -138,7 +160,7 @@ class OnChainWsService(
|
|||||||
erc1155Transfers = erc1155Transfers,
|
erc1155Transfers = erc1155Transfers,
|
||||||
retrofitFactory = retrofitFactory
|
retrofitFactory = retrofitFactory
|
||||||
)
|
)
|
||||||
|
|
||||||
if (trade != null) {
|
if (trade != null) {
|
||||||
logger.info("成功解析交易: leaderId=$leaderId, txHash=$txHash, side=${trade.side}, market=${trade.market}, size=${trade.size}")
|
logger.info("成功解析交易: leaderId=$leaderId, txHash=$txHash, side=${trade.side}, market=${trade.market}, size=${trade.size}")
|
||||||
// 调用 processTrade 处理交易
|
// 调用 processTrade 处理交易
|
||||||
@@ -154,21 +176,21 @@ class OnChainWsService(
|
|||||||
logger.error("处理 Leader 交易失败: leaderId=$leaderId, txHash=$txHash, ${e.message}", e)
|
logger.error("处理 Leader 交易失败: leaderId=$leaderId, txHash=$txHash, ${e.message}", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 移除Leader监听
|
* 移除Leader监听
|
||||||
* 取消该 Leader 的订阅
|
* 取消该 Leader 的订阅
|
||||||
*/
|
*/
|
||||||
fun removeLeader(leaderId: Long) {
|
fun removeLeader(leaderId: Long) {
|
||||||
monitoredLeaders.remove(leaderId)
|
monitoredLeaders.remove(leaderId)
|
||||||
|
|
||||||
// 通过统一服务取消订阅
|
// 通过统一服务取消订阅
|
||||||
val subscriptionId = "LEADER_$leaderId"
|
val subscriptionId = "LEADER_$leaderId"
|
||||||
unifiedOnChainWsService.unsubscribe(subscriptionId)
|
unifiedOnChainWsService.unsubscribe(subscriptionId)
|
||||||
|
|
||||||
logger.info("移除 Leader 监听: leaderId=$leaderId")
|
logger.info("移除 Leader 监听: leaderId=$leaderId")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 停止监听
|
* 停止监听
|
||||||
*/
|
*/
|
||||||
@@ -180,7 +202,7 @@ class OnChainWsService(
|
|||||||
}
|
}
|
||||||
monitoredLeaders.clear()
|
monitoredLeaders.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreDestroy
|
@PreDestroy
|
||||||
fun destroy() {
|
fun destroy() {
|
||||||
stop()
|
stop()
|
||||||
|
|||||||
+181
-17
@@ -1,5 +1,7 @@
|
|||||||
package com.wrbug.polymarketbot.service.copytrading.monitor
|
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.api.TradeResponse
|
||||||
import com.wrbug.polymarketbot.dto.ActivityTradeMessage
|
import com.wrbug.polymarketbot.dto.ActivityTradeMessage
|
||||||
import com.wrbug.polymarketbot.dto.ActivityTradePayload
|
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.repository.LeaderRepository
|
||||||
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
|
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
|
||||||
import com.wrbug.polymarketbot.util.fromJson
|
import com.wrbug.polymarketbot.util.fromJson
|
||||||
|
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||||
import com.wrbug.polymarketbot.websocket.PolymarketWebSocketClient
|
import com.wrbug.polymarketbot.websocket.PolymarketWebSocketClient
|
||||||
import jakarta.annotation.PreDestroy
|
import jakarta.annotation.PreDestroy
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.beans.factory.annotation.Value
|
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Polymarket Activity WebSocket 监听服务
|
* Polymarket Activity WebSocket 监听服务
|
||||||
* 通过订阅全局 activity 交易流,客户端过滤 Leader 地址,实现实时交易检测
|
* 通过订阅全局 activity 交易流(trades + orders_matched),客户端过滤 Leader 地址,实现实时交易检测
|
||||||
* 延迟 < 100ms,适合快速跟单场景
|
* 延迟 < 100ms,适合快速跟单场景
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@@ -29,8 +32,7 @@ class PolymarketActivityWsService(
|
|||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(PolymarketActivityWsService::class.java)
|
private val logger = LoggerFactory.getLogger(PolymarketActivityWsService::class.java)
|
||||||
|
|
||||||
@Value("\${polymarket.websocket.activity.url:wss://ws-live-data.polymarket.com}")
|
private val websocketUrl: String = PolymarketConstants.ACTIVITY_WS_URL
|
||||||
private var websocketUrl: String = "wss://ws-live-data.polymarket.com"
|
|
||||||
|
|
||||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||||
|
|
||||||
@@ -40,10 +42,30 @@ class PolymarketActivityWsService(
|
|||||||
// 要监听的 Leader 地址集合(小写地址 -> leaderId)
|
// 要监听的 Leader 地址集合(小写地址 -> leaderId)
|
||||||
private val monitoredAddresses = ConcurrentHashMap<String, Long>()
|
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
|
@Volatile
|
||||||
private var isSubscribed = false
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("启动 Activity WebSocket 监听,监控 ${monitoredAddresses.size} 个 Leader 地址")
|
logger.info("启动 Activity WebSocket 监听(trades + orders_matched),监控 ${monitoredAddresses.size} 个 Leader 地址")
|
||||||
connectAndSubscribe()
|
connectAndSubscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +181,7 @@ class PolymarketActivityWsService(
|
|||||||
* 订阅全局 activity
|
* 订阅全局 activity
|
||||||
* 根据 @polymarket/real-time-data-client 的协议格式
|
* 根据 @polymarket/real-time-data-client 的协议格式
|
||||||
* 使用 "action": "subscribe" 而不是 "type": "subscribe"
|
* 使用 "action": "subscribe" 而不是 "type": "subscribe"
|
||||||
|
* 同时订阅 trades 和 orders_matched 两种类型
|
||||||
*/
|
*/
|
||||||
private fun subscribeAllActivity() {
|
private fun subscribeAllActivity() {
|
||||||
val client = wsClient
|
val client = wsClient
|
||||||
@@ -170,6 +193,7 @@ class PolymarketActivityWsService(
|
|||||||
try {
|
try {
|
||||||
// 根据 real-time-data-client 的协议格式
|
// 根据 real-time-data-client 的协议格式
|
||||||
// 订阅消息应包含 "action": "subscribe" 和 "subscriptions" 数组
|
// 订阅消息应包含 "action": "subscribe" 和 "subscriptions" 数组
|
||||||
|
// 同时订阅 trades 和 orders_matched 两种类型
|
||||||
val subscribeMessage = """
|
val subscribeMessage = """
|
||||||
{
|
{
|
||||||
"action": "subscribe",
|
"action": "subscribe",
|
||||||
@@ -177,6 +201,10 @@ class PolymarketActivityWsService(
|
|||||||
{
|
{
|
||||||
"topic": "activity",
|
"topic": "activity",
|
||||||
"type": "trades"
|
"type": "trades"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"topic": "activity",
|
||||||
|
"type": "orders_matched"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -184,46 +212,154 @@ class PolymarketActivityWsService(
|
|||||||
|
|
||||||
client.sendMessage(subscribeMessage)
|
client.sendMessage(subscribeMessage)
|
||||||
isSubscribed = true
|
isSubscribed = true
|
||||||
logger.info("Activity WebSocket 订阅成功(全局交易流)")
|
// 重置最后一次收到 activity 消息的时间
|
||||||
|
lastActivityTime = System.currentTimeMillis()
|
||||||
|
// 启动 Activity 消息超时检测
|
||||||
|
// startActivityTimeoutCheck()
|
||||||
|
logger.info("Activity WebSocket 订阅成功(全局交易流: trades + orders_matched)")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("订阅 Activity WebSocket 失败", e)
|
logger.error("订阅 Activity WebSocket 失败", e)
|
||||||
isSubscribed = false
|
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) {
|
private fun handleMessage(message: String) {
|
||||||
try {
|
try {
|
||||||
|
totalMessagesProcessed++
|
||||||
|
|
||||||
// 处理 PONG 响应
|
// 处理 PONG 响应
|
||||||
if (message.trim() == "PONG" || message.trim() == "pong") {
|
if (message.trim() == "PONG" || message.trim() == "pong") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用扩展函数解析消息
|
// 快速预检查:检查是否包含监听地址
|
||||||
|
// 绝大部分消息会在这一步被过滤掉,避免不必要的 JSON 解析
|
||||||
|
if (!containsMonitoredAddress(message)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.info("发现leader交易:${message}")
|
||||||
|
// 使用扩展函数解析消息(只对包含监听地址的消息)
|
||||||
val tradeMessage = message.fromJson<ActivityTradeMessage>() ?: run {
|
val tradeMessage = message.fromJson<ActivityTradeMessage>() ?: run {
|
||||||
// 不是有效的 JSON 或格式不匹配,跳过
|
// 不是有效的 JSON 或格式不匹配,跳过
|
||||||
logger.warn("无法解析为 ActivityTradeMessage,可能不是 activity 消息: ${message.take(200)}")
|
logger.warn("无法解析为 ActivityTradeMessage: ${message.take(200)}")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否是 activity trade 消息
|
jsonParseMessages++
|
||||||
if (tradeMessage.topic != "activity" || tradeMessage.type != "trades") {
|
|
||||||
// 不是我们关心的消息,直接返回
|
// 检查是否是 activity 消息(trades 或 orders_matched)
|
||||||
|
if (tradeMessage.topic != "activity" ||
|
||||||
|
(tradeMessage.type != "trades" && tradeMessage.type != "orders_matched")) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新最后一次收到 activity 消息的时间(即使不是我们监听的 Leader 的交易)
|
||||||
|
lastActivityTime = System.currentTimeMillis()
|
||||||
|
|
||||||
val payload = tradeMessage.payload
|
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 {
|
val traderAddress = extractTraderAddress(payload) ?: run {
|
||||||
// 没有交易者地址,跳过
|
// 没有交易者地址,跳过
|
||||||
logger.warn("Activity Trade 消息中没有交易者地址: trader=${payload.trader}, proxyWallet=${payload.proxyWallet}, asset=${payload.asset}")
|
logger.warn("Activity Trade 消息中没有交易者地址: trader=${payload.trader}, proxyWallet=${payload.proxyWallet}, asset=${payload.asset}")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否是我们监听的 Leader
|
// 二次验证:确认地址匹配
|
||||||
val normalizedAddress = traderAddress.lowercase()
|
val normalizedAddress = traderAddress.lowercase()
|
||||||
val leaderId = monitoredAddresses[normalizedAddress] ?: run {
|
val leaderId = monitoredAddresses[normalizedAddress] ?: run {
|
||||||
return
|
return
|
||||||
@@ -383,10 +519,13 @@ class PolymarketActivityWsService(
|
|||||||
*/
|
*/
|
||||||
fun stop() {
|
fun stop() {
|
||||||
logger.info("停止 Activity WebSocket 监听")
|
logger.info("停止 Activity WebSocket 监听")
|
||||||
|
stopActivityTimeoutCheck()
|
||||||
wsClient?.closeConnection()
|
wsClient?.closeConnection()
|
||||||
wsClient = null
|
wsClient = null
|
||||||
isSubscribed = false
|
isSubscribed = false
|
||||||
monitoredAddresses.clear()
|
monitoredAddresses.clear()
|
||||||
|
processedTxHashes.invalidateAll() // 清空去重缓存
|
||||||
|
lastActivityTime = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -403,8 +542,33 @@ class PolymarketActivityWsService(
|
|||||||
return monitoredAddresses.size
|
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
|
@PreDestroy
|
||||||
fun destroy() {
|
fun destroy() {
|
||||||
|
logger.info("Activity WS 性能统计: ${getPerformanceStats()}")
|
||||||
stop()
|
stop()
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-2
@@ -129,12 +129,20 @@ class UnifiedOnChainWsService(
|
|||||||
}
|
}
|
||||||
addressConnections.clear()
|
addressConnections.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取连接状态
|
||||||
|
* @return Map<address, isConnected>
|
||||||
|
*/
|
||||||
|
fun getConnectionStatuses(): Map<String, Boolean> {
|
||||||
|
return addressConnections.mapValues { (_, connection) -> connection.isConnected() }
|
||||||
|
}
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
fun init() {
|
fun init() {
|
||||||
logger.info("统一链上 WebSocket 服务已初始化 (独立连接模式)")
|
logger.info("统一链上 WebSocket 服务已初始化 (独立连接模式)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreDestroy
|
@PreDestroy
|
||||||
fun destroy() {
|
fun destroy() {
|
||||||
stop()
|
stop()
|
||||||
@@ -211,6 +219,10 @@ class UnifiedOnChainWsService(
|
|||||||
return subscriptions.isEmpty()
|
return subscriptions.isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun isConnected(): Boolean {
|
||||||
|
return isConnected
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun startConnectionLoop() {
|
private suspend fun startConnectionLoop() {
|
||||||
while (scope.isActive) {
|
while (scope.isActive) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+2
-2
@@ -18,6 +18,7 @@ import com.wrbug.polymarketbot.util.CryptoUtils
|
|||||||
import com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository
|
import com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository
|
||||||
import com.wrbug.polymarketbot.repository.CopyTradingRepository
|
import com.wrbug.polymarketbot.repository.CopyTradingRepository
|
||||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||||
|
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||||
import com.wrbug.polymarketbot.service.common.MarketService
|
import com.wrbug.polymarketbot.service.common.MarketService
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
@@ -41,8 +42,7 @@ class OrderPushService(
|
|||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(OrderPushService::class.java)
|
private val logger = LoggerFactory.getLogger(OrderPushService::class.java)
|
||||||
|
|
||||||
@Value("\${polymarket.rtds.ws-url}")
|
private val polymarketWsUrl: String = PolymarketConstants.RTDS_WS_URL
|
||||||
private lateinit var polymarketWsUrl: String
|
|
||||||
|
|
||||||
// 存储账户 ID 和对应的 WebSocket 连接
|
// 存储账户 ID 和对应的 WebSocket 连接
|
||||||
private val accountConnections = ConcurrentHashMap<Long, PolymarketWebSocketClient>()
|
private val accountConnections = ConcurrentHashMap<Long, PolymarketWebSocketClient>()
|
||||||
|
|||||||
+34
-15
@@ -23,6 +23,8 @@ import com.wrbug.polymarketbot.service.common.MarketService
|
|||||||
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
||||||
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
|
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
|
||||||
import com.wrbug.polymarketbot.util.CryptoUtils
|
import com.wrbug.polymarketbot.util.CryptoUtils
|
||||||
|
import org.springframework.context.ApplicationContext
|
||||||
|
import org.springframework.context.ApplicationContextAware
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
@@ -51,12 +53,26 @@ open class CopyOrderTrackingService(
|
|||||||
private val cryptoUtils: CryptoUtils,
|
private val cryptoUtils: CryptoUtils,
|
||||||
private val marketService: MarketService, // 市场信息服务
|
private val marketService: MarketService, // 市场信息服务
|
||||||
private val telegramNotificationService: TelegramNotificationService? = null // 可选,避免循环依赖
|
private val telegramNotificationService: TelegramNotificationService? = null // 可选,避免循环依赖
|
||||||
) {
|
) : ApplicationContextAware {
|
||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(CopyOrderTrackingService::class.java)
|
private val logger = LoggerFactory.getLogger(CopyOrderTrackingService::class.java)
|
||||||
|
|
||||||
// 协程作用域(用于异步发送通知)
|
// 协程作用域(用于异步发送通知)
|
||||||
private val notificationScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
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锁定)
|
// 使用 Mutex 保证线程安全(按交易ID锁定)
|
||||||
private val tradeMutexMap = ConcurrentHashMap<String, Mutex>()
|
private val tradeMutexMap = ConcurrentHashMap<String, Mutex>()
|
||||||
@@ -138,10 +154,11 @@ open class CopyOrderTrackingService(
|
|||||||
return@withLock Result.success(Unit)
|
return@withLock Result.success(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 处理交易逻辑
|
// 2. 处理交易逻辑(通过代理对象调用,确保 @Transactional 生效)
|
||||||
|
val self = getSelf()
|
||||||
val result = when (trade.side.uppercase()) {
|
val result = when (trade.side.uppercase()) {
|
||||||
"BUY" -> processBuyTrade(leaderId, trade)
|
"BUY" -> self.processBuyTrade(leaderId, trade, source)
|
||||||
"SELL" -> processSellTrade(leaderId, trade)
|
"SELL" -> self.processSellTrade(leaderId, trade)
|
||||||
else -> {
|
else -> {
|
||||||
logger.warn("未知的交易方向: ${trade.side}")
|
logger.warn("未知的交易方向: ${trade.side}")
|
||||||
Result.failure(IllegalArgumentException("未知的交易方向: ${trade.side}"))
|
Result.failure(IllegalArgumentException("未知的交易方向: ${trade.side}"))
|
||||||
@@ -213,7 +230,7 @@ open class CopyOrderTrackingService(
|
|||||||
* 创建跟单买入订单并记录到跟踪表
|
* 创建跟单买入订单并记录到跟踪表
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
suspend fun processBuyTrade(leaderId: Long, trade: TradeResponse): Result<Unit> {
|
suspend fun processBuyTrade(leaderId: Long, trade: TradeResponse, source: String): Result<Unit> {
|
||||||
return try {
|
return try {
|
||||||
// 1. 查找所有启用且支持该Leader的跟单关系
|
// 1. 查找所有启用且支持该Leader的跟单关系
|
||||||
val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId)
|
val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId)
|
||||||
@@ -285,7 +302,7 @@ open class CopyOrderTrackingService(
|
|||||||
|
|
||||||
// 过滤条件检查(在计算订单参数之前)
|
// 过滤条件检查(在计算订单参数之前)
|
||||||
// 传入 Leader 交易价格,用于价格区间检查
|
// 传入 Leader 交易价格,用于价格区间检查
|
||||||
// 传入跟单金额和市场ID,用于仓位检查(按市场检查仓位)
|
// 传入跟单金额和市场ID,用于仓位检查(按市场+方向检查仓位)
|
||||||
// 传入市场标题,用于关键字过滤
|
// 传入市场标题,用于关键字过滤
|
||||||
// 传入市场截止时间,用于市场截止时间检查
|
// 传入市场截止时间,用于市场截止时间检查
|
||||||
// 订单簿只请求一次,返回给后续逻辑使用
|
// 订单簿只请求一次,返回给后续逻辑使用
|
||||||
@@ -296,7 +313,8 @@ open class CopyOrderTrackingService(
|
|||||||
copyOrderAmount = copyOrderAmount,
|
copyOrderAmount = copyOrderAmount,
|
||||||
marketId = trade.market,
|
marketId = trade.market,
|
||||||
marketTitle = marketTitle,
|
marketTitle = marketTitle,
|
||||||
marketEndDate = marketEndDate
|
marketEndDate = marketEndDate,
|
||||||
|
outcomeIndex = trade.outcomeIndex
|
||||||
)
|
)
|
||||||
val orderbook = filterResult.orderbook // 获取订单簿(如果需要)
|
val orderbook = filterResult.orderbook // 获取订单簿(如果需要)
|
||||||
if (!filterResult.isPassed) {
|
if (!filterResult.isPassed) {
|
||||||
@@ -622,7 +640,8 @@ open class CopyOrderTrackingService(
|
|||||||
price = buyPrice, // 使用下单价格,临时值
|
price = buyPrice, // 使用下单价格,临时值
|
||||||
remainingQuantity = finalBuyQuantity,
|
remainingQuantity = finalBuyQuantity,
|
||||||
status = "filled",
|
status = "filled",
|
||||||
notificationSent = false // 标记为未发送通知,等待轮询任务获取实际数据后发送
|
notificationSent = false, // 标记为未发送通知,等待轮询任务获取实际数据后发送
|
||||||
|
source = source // 订单来源
|
||||||
)
|
)
|
||||||
|
|
||||||
copyOrderTrackingRepository.save(tracking)
|
copyOrderTrackingRepository.save(tracking)
|
||||||
@@ -685,8 +704,8 @@ open class CopyOrderTrackingService(
|
|||||||
private fun calculateBuyQuantity(trade: TradeResponse, copyTrading: CopyTrading): BigDecimal {
|
private fun calculateBuyQuantity(trade: TradeResponse, copyTrading: CopyTrading): BigDecimal {
|
||||||
return when (copyTrading.copyMode) {
|
return when (copyTrading.copyMode) {
|
||||||
"RATIO" -> {
|
"RATIO" -> {
|
||||||
// 比例模式:Leader 数量 × (比例 / 100)
|
// 比例模式:Leader 数量 × 比例倍数(copyRatio 已经是倍数值,如 1.3 表示 130%)
|
||||||
trade.size.toSafeBigDecimal().multi(copyTrading.copyRatio.div(100))
|
trade.size.toSafeBigDecimal().multi(copyTrading.copyRatio)
|
||||||
}
|
}
|
||||||
|
|
||||||
"FIXED" -> {
|
"FIXED" -> {
|
||||||
@@ -718,7 +737,7 @@ open class CopyOrderTrackingService(
|
|||||||
val leader = leaderRepository.findById(copyTrading.leaderId).orElse(null)
|
val leader = leaderRepository.findById(copyTrading.leaderId).orElse(null)
|
||||||
?: run {
|
?: run {
|
||||||
logger.warn("Leader 不存在,使用默认比例: leaderId=${copyTrading.leaderId}")
|
logger.warn("Leader 不存在,使用默认比例: leaderId=${copyTrading.leaderId}")
|
||||||
return leaderSellQuantity.multi(copyTrading.copyRatio.div(100))
|
return leaderSellQuantity.multi(copyTrading.copyRatio)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建不需要认证的 CLOB API 客户端(用于查询公开的交易数据)
|
// 创建不需要认证的 CLOB API 客户端(用于查询公开的交易数据)
|
||||||
@@ -789,7 +808,7 @@ open class CopyOrderTrackingService(
|
|||||||
// 如果无法计算总比例(查询失败),使用默认比例
|
// 如果无法计算总比例(查询失败),使用默认比例
|
||||||
if (totalLeaderQuantity.lte(BigDecimal.ZERO)) {
|
if (totalLeaderQuantity.lte(BigDecimal.ZERO)) {
|
||||||
logger.warn("无法计算总比例(Leader 买入数量为 0),使用默认比例: copyTradingId=${copyTrading.id}")
|
logger.warn("无法计算总比例(Leader 买入数量为 0),使用默认比例: copyTradingId=${copyTrading.id}")
|
||||||
return leaderSellQuantity.multi(copyTrading.copyRatio.div(100))
|
return leaderSellQuantity.multi(copyTrading.copyRatio)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算实际比例:跟单买入数量 / Leader 买入数量
|
// 计算实际比例:跟单买入数量 / Leader 买入数量
|
||||||
@@ -865,13 +884,13 @@ open class CopyOrderTrackingService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
"RATIO" -> {
|
"RATIO" -> {
|
||||||
// 比例模式:直接使用配置的 copyRatio (需要除以100)
|
// 比例模式:直接使用配置的 copyRatio(已经是倍数值,如 1.3 表示 130%)
|
||||||
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio.div(100))
|
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
logger.warn("不支持的 copyMode: ${copyTrading.copyMode},使用默认比例模式")
|
logger.warn("不支持的 copyMode: ${copyTrading.copyMode},使用默认比例模式")
|
||||||
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio.div(100))
|
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+30
-14
@@ -611,22 +611,22 @@ class CopyTradingStatisticsService(
|
|||||||
|
|
||||||
// 4. 转换为分组数据并计算统计信息
|
// 4. 转换为分组数据并计算统计信息
|
||||||
val marketIds = groups.keys.toList()
|
val marketIds = groups.keys.toList()
|
||||||
|
|
||||||
val list = marketIds.map { marketId ->
|
val list = marketIds.map { marketId ->
|
||||||
val marketOrders = groups[marketId] ?: mutableListOf()
|
val marketOrders = groups[marketId] ?: mutableListOf()
|
||||||
|
|
||||||
// 计算统计信息
|
// 计算统计信息
|
||||||
val count = marketOrders.size.toLong()
|
val count = marketOrders.size.toLong()
|
||||||
val totalAmount = marketOrders.sumOf { order ->
|
val totalAmount = marketOrders.sumOf { order ->
|
||||||
order.quantity.toSafeBigDecimal().multi(order.price)
|
order.quantity.toSafeBigDecimal().multi(order.price)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算订单状态统计
|
// 计算订单状态统计
|
||||||
val fullyMatchedCount = marketOrders.count { it.status == "fully_matched" }
|
val fullyMatchedCount = marketOrders.count { it.status == "fully_matched" }
|
||||||
val partiallyMatchedCount = marketOrders.count { it.status == "partially_matched" }
|
val partiallyMatchedCount = marketOrders.count { it.status == "partially_matched" }
|
||||||
val filledCount = marketOrders.count { it.status == "filled" }
|
val filledCount = marketOrders.count { it.status == "filled" }
|
||||||
val fullyMatched = fullyMatchedCount == marketOrders.size
|
val fullyMatched = fullyMatchedCount == marketOrders.size
|
||||||
|
|
||||||
val stats = MarketOrderStats(
|
val stats = MarketOrderStats(
|
||||||
count = count,
|
count = count,
|
||||||
totalAmount = totalAmount.toString(),
|
totalAmount = totalAmount.toString(),
|
||||||
@@ -636,10 +636,10 @@ class CopyTradingStatisticsService(
|
|||||||
partiallyMatchedCount = partiallyMatchedCount.toLong(),
|
partiallyMatchedCount = partiallyMatchedCount.toLong(),
|
||||||
filledCount = filledCount.toLong()
|
filledCount = filledCount.toLong()
|
||||||
)
|
)
|
||||||
|
|
||||||
// 排序(按创建时间倒序)
|
// 排序(按创建时间倒序)
|
||||||
marketOrders.sortByDescending { it.createdAt }
|
marketOrders.sortByDescending { it.createdAt }
|
||||||
|
|
||||||
// 转换为 DTO
|
// 转换为 DTO
|
||||||
val orderDtos = marketOrders.map { order ->
|
val orderDtos = marketOrders.map { order ->
|
||||||
val amount = order.quantity.toSafeBigDecimal().multi(order.price)
|
val amount = order.quantity.toSafeBigDecimal().multi(order.price)
|
||||||
@@ -662,7 +662,7 @@ class CopyTradingStatisticsService(
|
|||||||
createdAt = order.createdAt
|
createdAt = order.createdAt
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
MarketOrderGroup(
|
MarketOrderGroup(
|
||||||
marketId = marketId,
|
marketId = marketId,
|
||||||
marketTitle = markets[marketId]?.title,
|
marketTitle = markets[marketId]?.title,
|
||||||
@@ -672,27 +672,35 @@ class CopyTradingStatisticsService(
|
|||||||
stats = stats,
|
stats = stats,
|
||||||
orders = orderDtos as List<Any>
|
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. 分页
|
// 5. 分页
|
||||||
val page = (request.page ?: 1)
|
val page = (request.page ?: 1)
|
||||||
val limit = request.limit ?: 20
|
val limit = request.limit ?: 20
|
||||||
val total = list.size.toLong()
|
val total = list.size.toLong()
|
||||||
|
|
||||||
val start = (page - 1) * limit
|
val start = (page - 1) * limit
|
||||||
val end = minOf(start + limit, list.size)
|
val end = minOf(start + limit, list.size)
|
||||||
val pagedList = if (start < list.size) list.subList(start, end) else emptyList()
|
val pagedList = if (start < list.size) list.subList(start, end) else emptyList()
|
||||||
|
|
||||||
val response = MarketGroupedOrdersResponse(
|
val response = MarketGroupedOrdersResponse(
|
||||||
list = pagedList,
|
list = pagedList,
|
||||||
total = total,
|
total = total,
|
||||||
page = page,
|
page = page,
|
||||||
limit = limit
|
limit = limit
|
||||||
)
|
)
|
||||||
|
|
||||||
Result.success(response)
|
Result.success(response)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("获取按市场分组的买入订单列表失败: copyTradingId=${request.copyTradingId}", e)
|
logger.error("获取按市场分组的卖出订单列表失败: copyTradingId=${request.copyTradingId}", e)
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -791,7 +799,15 @@ class CopyTradingStatisticsService(
|
|||||||
stats = stats,
|
stats = stats,
|
||||||
orders = orderDtos as List<Any>
|
orders = orderDtos as List<Any>
|
||||||
)
|
)
|
||||||
}.sortedByDescending { it.stats.count }
|
}.sortedByDescending { group ->
|
||||||
|
// 找出该市场最近的卖出订单时间(与买入订单分组排序规则一致)
|
||||||
|
group.orders.mapNotNull { order ->
|
||||||
|
when (order) {
|
||||||
|
is SellOrderInfo -> order.createdAt
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}.maxOrNull() ?: 0L
|
||||||
|
}
|
||||||
|
|
||||||
// 5. 分页
|
// 5. 分页
|
||||||
val page = (request.page ?: 1)
|
val page = (request.page ?: 1)
|
||||||
|
|||||||
+404
-193
File diff suppressed because it is too large
Load Diff
+154
-85
@@ -1,5 +1,6 @@
|
|||||||
package com.wrbug.polymarketbot.service.system
|
package com.wrbug.polymarketbot.service.system
|
||||||
|
|
||||||
|
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||||
import com.wrbug.polymarketbot.dto.ApiHealthCheckDto
|
import com.wrbug.polymarketbot.dto.ApiHealthCheckDto
|
||||||
import com.wrbug.polymarketbot.dto.ApiHealthCheckResponse
|
import com.wrbug.polymarketbot.dto.ApiHealthCheckResponse
|
||||||
import com.wrbug.polymarketbot.util.createClient
|
import com.wrbug.polymarketbot.util.createClient
|
||||||
@@ -11,9 +12,9 @@ import org.slf4j.LoggerFactory
|
|||||||
import org.springframework.beans.BeansException
|
import org.springframework.beans.BeansException
|
||||||
import org.springframework.context.ApplicationContext
|
import org.springframework.context.ApplicationContext
|
||||||
import org.springframework.context.ApplicationContextAware
|
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.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 org.springframework.stereotype.Service
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
@@ -22,16 +23,6 @@ import java.util.concurrent.TimeUnit
|
|||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
class ApiHealthCheckService(
|
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
|
private val rpcNodeService: RpcNodeService
|
||||||
) : ApplicationContextAware {
|
) : 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 避免循环依赖)
|
* 获取 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)
|
private val logger = LoggerFactory.getLogger(ApiHealthCheckService::class.java)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -89,7 +91,9 @@ class ApiHealthCheckService(
|
|||||||
async { checkDataApi() },
|
async { checkDataApi() },
|
||||||
async { checkGammaApi() },
|
async { checkGammaApi() },
|
||||||
async { checkPolygonRpc() },
|
async { checkPolygonRpc() },
|
||||||
async { checkPolymarketWebSocket() },
|
async { checkPolymarketRtdsWebSocket() },
|
||||||
|
async { checkPolymarketActivityWebSocket() },
|
||||||
|
async { checkUnifiedOnChainWebSocket() },
|
||||||
async { checkBuilderRelayerApi() },
|
async { checkBuilderRelayerApi() },
|
||||||
async { checkGitHubApi() }
|
async { checkGitHubApi() }
|
||||||
)
|
)
|
||||||
@@ -106,7 +110,7 @@ class ApiHealthCheckService(
|
|||||||
* 检查 Polymarket CLOB API
|
* 检查 Polymarket CLOB API
|
||||||
*/
|
*/
|
||||||
private suspend fun checkClobApi(): ApiHealthCheckDto = withContext(Dispatchers.IO) {
|
private suspend fun checkClobApi(): ApiHealthCheckDto = withContext(Dispatchers.IO) {
|
||||||
val url = "$clobBaseUrl/"
|
val url = "${PolymarketConstants.CLOB_BASE_URL}/"
|
||||||
checkApi("Polymarket CLOB API", url)
|
checkApi("Polymarket CLOB API", url)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +118,7 @@ class ApiHealthCheckService(
|
|||||||
* 检查 Polymarket Data API
|
* 检查 Polymarket Data API
|
||||||
*/
|
*/
|
||||||
private suspend fun checkDataApi(): ApiHealthCheckDto = withContext(Dispatchers.IO) {
|
private suspend fun checkDataApi(): ApiHealthCheckDto = withContext(Dispatchers.IO) {
|
||||||
val url = "$dataApiBaseUrl/"
|
val url = "${PolymarketConstants.DATA_API_BASE_URL}/"
|
||||||
checkApi("Polymarket Data API", url)
|
checkApi("Polymarket Data API", url)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +135,7 @@ class ApiHealthCheckService(
|
|||||||
.build()
|
.build()
|
||||||
|
|
||||||
// 使用 /markets 接口检查(不传参数,返回空列表或少量市场数据)
|
// 使用 /markets 接口检查(不传参数,返回空列表或少量市场数据)
|
||||||
val url = "$gammaBaseUrl/markets"
|
val url = "${PolymarketConstants.GAMMA_BASE_URL}/markets"
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url(url)
|
.url(url)
|
||||||
.get()
|
.get()
|
||||||
@@ -176,7 +180,7 @@ class ApiHealthCheckService(
|
|||||||
logger.warn("检查 Polymarket Gamma API 失败", e)
|
logger.warn("检查 Polymarket Gamma API 失败", e)
|
||||||
ApiHealthCheckDto(
|
ApiHealthCheckDto(
|
||||||
name = "Polymarket Gamma API",
|
name = "Polymarket Gamma API",
|
||||||
url = "$gammaBaseUrl/markets",
|
url = "${PolymarketConstants.GAMMA_BASE_URL}/markets",
|
||||||
status = "error",
|
status = "error",
|
||||||
message = e.message ?: "连接失败"
|
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 {
|
try {
|
||||||
// 检查订单推送服务的连接状态
|
|
||||||
val orderPushService = getOrderPushService()
|
val orderPushService = getOrderPushService()
|
||||||
val orderPushStatuses = orderPushService?.getConnectionStatuses() ?: emptyMap()
|
val statuses = orderPushService?.getConnectionStatuses() ?: emptyMap()
|
||||||
val orderPushConnected = orderPushStatuses.values.any { it }
|
val total = statuses.size
|
||||||
val orderPushTotal = orderPushStatuses.size
|
val connected = statuses.values.count { it }
|
||||||
val orderPushConnectedCount = orderPushStatuses.values.count { it }
|
|
||||||
|
|
||||||
// 检查跟单 WebSocket 服务的连接状态
|
if (total == 0) {
|
||||||
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 连接
|
|
||||||
ApiHealthCheckDto(
|
ApiHealthCheckDto(
|
||||||
name = "Polymarket WebSocket",
|
name = "Polymarket RTDS WebSocket",
|
||||||
url = url,
|
url = PolymarketConstants.RTDS_WS_URL,
|
||||||
status = "skipped",
|
status = "skipped",
|
||||||
message = "未配置 WebSocket 连接"
|
message = "未配置账户连接"
|
||||||
)
|
)
|
||||||
} else if (hasAnyConnection) {
|
} else if (connected > 0) {
|
||||||
// 至少有一个连接是活跃的
|
val message = if (connected == total) {
|
||||||
val message = if (connectedConnections == totalConnections) {
|
"所有账户连接正常 ($connected/$total)"
|
||||||
"所有连接正常 ($connectedConnections/$totalConnections)"
|
|
||||||
} else {
|
} else {
|
||||||
"部分连接正常 ($connectedConnections/$totalConnections)"
|
"部分账户连接正常 ($connected/$total)"
|
||||||
}
|
}
|
||||||
ApiHealthCheckDto(
|
ApiHealthCheckDto(
|
||||||
name = "Polymarket WebSocket",
|
name = "Polymarket RTDS WebSocket",
|
||||||
url = url,
|
url = PolymarketConstants.RTDS_WS_URL,
|
||||||
status = "success",
|
status = "success",
|
||||||
message = message
|
message = message
|
||||||
// 不设置 responseTime,WebSocket 不显示延时
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// 所有连接都断开
|
|
||||||
ApiHealthCheckDto(
|
ApiHealthCheckDto(
|
||||||
name = "Polymarket WebSocket",
|
name = "Polymarket RTDS WebSocket",
|
||||||
url = url,
|
url = PolymarketConstants.RTDS_WS_URL,
|
||||||
status = "error",
|
status = "error",
|
||||||
message = "所有连接断开 ($connectedConnections/$totalConnections)"
|
message = "所有账户连接断开 (0/$total)"
|
||||||
// 不设置 responseTime,WebSocket 不显示延时
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.warn("检查 Polymarket WebSocket 状态失败", e)
|
logger.warn("检查 Polymarket RTDS WebSocket 状态失败", e)
|
||||||
ApiHealthCheckDto(
|
ApiHealthCheckDto(
|
||||||
name = "Polymarket WebSocket",
|
name = "Polymarket RTDS WebSocket",
|
||||||
url = polymarketWsUrl,
|
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",
|
status = "error",
|
||||||
message = "检查失败:${e.message}"
|
message = "检查失败:${e.message}"
|
||||||
)
|
)
|
||||||
@@ -390,19 +468,10 @@ class ApiHealthCheckService(
|
|||||||
private suspend fun checkBuilderRelayerApi(): ApiHealthCheckDto = withContext(Dispatchers.IO) {
|
private suspend fun checkBuilderRelayerApi(): ApiHealthCheckDto = withContext(Dispatchers.IO) {
|
||||||
val relayClientService = getRelayClientService()
|
val relayClientService = getRelayClientService()
|
||||||
|
|
||||||
if (builderRelayerUrl.isBlank()) {
|
|
||||||
return@withContext ApiHealthCheckDto(
|
|
||||||
name = "Builder Relayer API",
|
|
||||||
url = "未配置",
|
|
||||||
status = "skipped",
|
|
||||||
message = "未配置 Builder Relayer URL"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (relayClientService == null) {
|
if (relayClientService == null) {
|
||||||
return@withContext ApiHealthCheckDto(
|
return@withContext ApiHealthCheckDto(
|
||||||
name = "Builder Relayer API",
|
name = "Builder Relayer API",
|
||||||
url = builderRelayerUrl,
|
url = PolymarketConstants.BUILDER_RELAYER_URL,
|
||||||
status = "error",
|
status = "error",
|
||||||
message = "服务未初始化"
|
message = "服务未初始化"
|
||||||
)
|
)
|
||||||
@@ -411,7 +480,7 @@ class ApiHealthCheckService(
|
|||||||
if (!relayClientService.isBuilderApiKeyConfigured()) {
|
if (!relayClientService.isBuilderApiKeyConfigured()) {
|
||||||
return@withContext ApiHealthCheckDto(
|
return@withContext ApiHealthCheckDto(
|
||||||
name = "Builder Relayer API",
|
name = "Builder Relayer API",
|
||||||
url = builderRelayerUrl,
|
url = PolymarketConstants.BUILDER_RELAYER_URL,
|
||||||
status = "skipped",
|
status = "skipped",
|
||||||
message = "Builder API Key 未配置"
|
message = "Builder API Key 未配置"
|
||||||
)
|
)
|
||||||
@@ -423,7 +492,7 @@ class ApiHealthCheckService(
|
|||||||
onSuccess = { responseTime ->
|
onSuccess = { responseTime ->
|
||||||
ApiHealthCheckDto(
|
ApiHealthCheckDto(
|
||||||
name = "Builder Relayer API",
|
name = "Builder Relayer API",
|
||||||
url = builderRelayerUrl,
|
url = PolymarketConstants.BUILDER_RELAYER_URL,
|
||||||
status = "success",
|
status = "success",
|
||||||
message = "连接成功",
|
message = "连接成功",
|
||||||
responseTime = responseTime
|
responseTime = responseTime
|
||||||
@@ -432,7 +501,7 @@ class ApiHealthCheckService(
|
|||||||
onFailure = { e ->
|
onFailure = { e ->
|
||||||
ApiHealthCheckDto(
|
ApiHealthCheckDto(
|
||||||
name = "Builder Relayer API",
|
name = "Builder Relayer API",
|
||||||
url = builderRelayerUrl,
|
url = PolymarketConstants.BUILDER_RELAYER_URL,
|
||||||
status = "error",
|
status = "error",
|
||||||
message = e.message ?: "连接失败"
|
message = e.message ?: "连接失败"
|
||||||
)
|
)
|
||||||
@@ -442,7 +511,7 @@ class ApiHealthCheckService(
|
|||||||
logger.warn("检查 Builder Relayer API 失败", e)
|
logger.warn("检查 Builder Relayer API 失败", e)
|
||||||
ApiHealthCheckDto(
|
ApiHealthCheckDto(
|
||||||
name = "Builder Relayer API",
|
name = "Builder Relayer API",
|
||||||
url = builderRelayerUrl,
|
url = PolymarketConstants.BUILDER_RELAYER_URL,
|
||||||
status = "error",
|
status = "error",
|
||||||
message = e.message ?: "连接失败"
|
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.BuilderRelayerApi
|
||||||
import com.wrbug.polymarketbot.api.EthereumRpcApi
|
import com.wrbug.polymarketbot.api.EthereumRpcApi
|
||||||
import com.wrbug.polymarketbot.api.JsonRpcRequest
|
import com.wrbug.polymarketbot.api.JsonRpcRequest
|
||||||
|
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||||
import com.wrbug.polymarketbot.util.EthereumUtils
|
import com.wrbug.polymarketbot.util.EthereumUtils
|
||||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||||
import com.wrbug.polymarketbot.util.createClient
|
import com.wrbug.polymarketbot.util.createClient
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.beans.factory.annotation.Value
|
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.math.BigInteger
|
import java.math.BigInteger
|
||||||
|
|
||||||
@@ -24,8 +24,6 @@ import java.math.BigInteger
|
|||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
class RelayClientService(
|
class RelayClientService(
|
||||||
@Value("\${polymarket.builder.relayer-url:}")
|
|
||||||
private val builderRelayerUrl: String,
|
|
||||||
private val retrofitFactory: RetrofitFactory,
|
private val retrofitFactory: RetrofitFactory,
|
||||||
private val systemConfigService: SystemConfigService,
|
private val systemConfigService: SystemConfigService,
|
||||||
private val rpcNodeService: RpcNodeService
|
private val rpcNodeService: RpcNodeService
|
||||||
@@ -54,10 +52,10 @@ class RelayClientService(
|
|||||||
val builderApiKey = systemConfigService.getBuilderApiKey()
|
val builderApiKey = systemConfigService.getBuilderApiKey()
|
||||||
val builderSecret = systemConfigService.getBuilderSecret()
|
val builderSecret = systemConfigService.getBuilderSecret()
|
||||||
val builderPassphrase = systemConfigService.getBuilderPassphrase()
|
val builderPassphrase = systemConfigService.getBuilderPassphrase()
|
||||||
|
|
||||||
if (isBuilderRelayerEnabled(builderApiKey, builderSecret, builderPassphrase)) {
|
if (isBuilderRelayerEnabled(builderApiKey, builderSecret, builderPassphrase)) {
|
||||||
return retrofitFactory.createBuilderRelayerApi(
|
return retrofitFactory.createBuilderRelayerApi(
|
||||||
relayerUrl = builderRelayerUrl,
|
relayerUrl = PolymarketConstants.BUILDER_RELAYER_URL,
|
||||||
apiKey = builderApiKey!!,
|
apiKey = builderApiKey!!,
|
||||||
secret = builderSecret!!,
|
secret = builderSecret!!,
|
||||||
passphrase = builderPassphrase!!
|
passphrase = builderPassphrase!!
|
||||||
@@ -74,19 +72,19 @@ class RelayClientService(
|
|||||||
builderSecret: String?,
|
builderSecret: String?,
|
||||||
builderPassphrase: String?
|
builderPassphrase: String?
|
||||||
): Boolean {
|
): Boolean {
|
||||||
return builderRelayerUrl.isNotBlank() &&
|
return PolymarketConstants.BUILDER_RELAYER_URL.isNotBlank() &&
|
||||||
builderApiKey != null && builderApiKey.isNotBlank() &&
|
builderApiKey != null && builderApiKey.isNotBlank() &&
|
||||||
builderSecret != null && builderSecret.isNotBlank() &&
|
builderSecret != null && builderSecret.isNotBlank() &&
|
||||||
builderPassphrase != null && builderPassphrase.isNotBlank()
|
builderPassphrase != null && builderPassphrase.isNotBlank()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查 Builder API Key 是否已配置
|
* 检查 Builder API Key 是否已配置
|
||||||
*/
|
*/
|
||||||
fun isBuilderApiKeyConfigured(): Boolean {
|
fun isBuilderApiKeyConfigured(): Boolean {
|
||||||
return systemConfigService.isBuilderApiKeyConfigured()
|
return systemConfigService.isBuilderApiKeyConfigured()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查 Builder Relayer API 健康状态(用于 API 健康检查)
|
* 检查 Builder Relayer API 健康状态(用于 API 健康检查)
|
||||||
*/
|
*/
|
||||||
@@ -95,24 +93,24 @@ class RelayClientService(
|
|||||||
val builderApiKey = systemConfigService.getBuilderApiKey()
|
val builderApiKey = systemConfigService.getBuilderApiKey()
|
||||||
val builderSecret = systemConfigService.getBuilderSecret()
|
val builderSecret = systemConfigService.getBuilderSecret()
|
||||||
val builderPassphrase = systemConfigService.getBuilderPassphrase()
|
val builderPassphrase = systemConfigService.getBuilderPassphrase()
|
||||||
|
|
||||||
if (builderApiKey == null || builderSecret == null || builderPassphrase == null) {
|
if (builderApiKey == null || builderSecret == null || builderPassphrase == null) {
|
||||||
return Result.failure(IllegalStateException("Builder API Key 未配置"))
|
return Result.failure(IllegalStateException("Builder API Key 未配置"))
|
||||||
}
|
}
|
||||||
|
|
||||||
val relayerApi = retrofitFactory.createBuilderRelayerApi(
|
val relayerApi = retrofitFactory.createBuilderRelayerApi(
|
||||||
relayerUrl = builderRelayerUrl,
|
relayerUrl = PolymarketConstants.BUILDER_RELAYER_URL,
|
||||||
apiKey = builderApiKey,
|
apiKey = builderApiKey,
|
||||||
secret = builderSecret,
|
secret = builderSecret,
|
||||||
passphrase = builderPassphrase
|
passphrase = builderPassphrase
|
||||||
)
|
)
|
||||||
|
|
||||||
// 使用一个测试地址来检查 API 是否可用(使用一个已知的地址,如零地址)
|
// 使用一个测试地址来检查 API 是否可用(使用一个已知的地址,如零地址)
|
||||||
val testAddress = "0x0000000000000000000000000000000000000000"
|
val testAddress = "0x0000000000000000000000000000000000000000"
|
||||||
val startTime = System.currentTimeMillis()
|
val startTime = System.currentTimeMillis()
|
||||||
val response = relayerApi.getDeployed(testAddress)
|
val response = relayerApi.getDeployed(testAddress)
|
||||||
val responseTime = System.currentTimeMillis() - startTime
|
val responseTime = System.currentTimeMillis() - startTime
|
||||||
|
|
||||||
if (response.isSuccessful) {
|
if (response.isSuccessful) {
|
||||||
Result.success(responseTime)
|
Result.success(responseTime)
|
||||||
} else {
|
} else {
|
||||||
@@ -228,11 +226,18 @@ class RelayClientService(
|
|||||||
val builderApiKey = systemConfigService.getBuilderApiKey()
|
val builderApiKey = systemConfigService.getBuilderApiKey()
|
||||||
val builderSecret = systemConfigService.getBuilderSecret()
|
val builderSecret = systemConfigService.getBuilderSecret()
|
||||||
val builderPassphrase = systemConfigService.getBuilderPassphrase()
|
val builderPassphrase = systemConfigService.getBuilderPassphrase()
|
||||||
|
|
||||||
// 优先使用 Builder Relayer(Gasless)
|
// 优先使用 Builder Relayer(Gasless)
|
||||||
if (isBuilderRelayerEnabled(builderApiKey, builderSecret, builderPassphrase)) {
|
if (isBuilderRelayerEnabled(builderApiKey, builderSecret, builderPassphrase)) {
|
||||||
logger.info("使用 Builder Relayer 执行 Gasless 交易")
|
logger.info("使用 Builder Relayer 执行 Gasless 交易")
|
||||||
return executeViaBuilderRelayer(privateKey, proxyAddress, safeTx, builderApiKey!!, builderSecret!!, builderPassphrase!!)
|
return executeViaBuilderRelayer(
|
||||||
|
privateKey,
|
||||||
|
proxyAddress,
|
||||||
|
safeTx,
|
||||||
|
builderApiKey!!,
|
||||||
|
builderSecret!!,
|
||||||
|
builderPassphrase!!
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 回退到手动发送交易(需要用户支付 gas)
|
// 回退到手动发送交易(需要用户支付 gas)
|
||||||
@@ -256,9 +261,8 @@ class RelayClientService(
|
|||||||
builderSecret: String,
|
builderSecret: String,
|
||||||
builderPassphrase: String
|
builderPassphrase: String
|
||||||
): Result<String> {
|
): Result<String> {
|
||||||
val rpcApi = polygonRpcApi
|
|
||||||
val relayerApi = retrofitFactory.createBuilderRelayerApi(
|
val relayerApi = retrofitFactory.createBuilderRelayerApi(
|
||||||
relayerUrl = builderRelayerUrl,
|
relayerUrl = PolymarketConstants.BUILDER_RELAYER_URL,
|
||||||
apiKey = builderApiKey,
|
apiKey = builderApiKey,
|
||||||
secret = builderSecret,
|
secret = builderSecret,
|
||||||
passphrase = builderPassphrase
|
passphrase = builderPassphrase
|
||||||
@@ -320,19 +324,19 @@ class RelayClientService(
|
|||||||
val messageWithPrefix = ByteArray(prefix.size + safeTxStructuredHash.size)
|
val messageWithPrefix = ByteArray(prefix.size + safeTxStructuredHash.size)
|
||||||
System.arraycopy(prefix, 0, messageWithPrefix, 0, prefix.size)
|
System.arraycopy(prefix, 0, messageWithPrefix, 0, prefix.size)
|
||||||
System.arraycopy(safeTxStructuredHash, 0, messageWithPrefix, prefix.size, safeTxStructuredHash.size)
|
System.arraycopy(safeTxStructuredHash, 0, messageWithPrefix, prefix.size, safeTxStructuredHash.size)
|
||||||
|
|
||||||
// 对带前缀的消息进行 keccak256 哈希
|
// 对带前缀的消息进行 keccak256 哈希
|
||||||
val keccak256 = org.bouncycastle.crypto.digests.KeccakDigest(256)
|
val keccak256 = org.bouncycastle.crypto.digests.KeccakDigest(256)
|
||||||
keccak256.update(messageWithPrefix, 0, messageWithPrefix.size)
|
keccak256.update(messageWithPrefix, 0, messageWithPrefix.size)
|
||||||
val hashWithPrefix = ByteArray(keccak256.digestSize)
|
val hashWithPrefix = ByteArray(keccak256.digestSize)
|
||||||
keccak256.doFinal(hashWithPrefix, 0)
|
keccak256.doFinal(hashWithPrefix, 0)
|
||||||
|
|
||||||
val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
|
val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
|
||||||
val safeSignature = org.web3j.crypto.Sign.signMessage(hashWithPrefix, ecKeyPair, false)
|
val safeSignature = org.web3j.crypto.Sign.signMessage(hashWithPrefix, ecKeyPair, false)
|
||||||
|
|
||||||
// 打包签名(参考 builder-relayer-client/src/utils/index.ts 的 splitAndPackSig)
|
// 打包签名(参考 builder-relayer-client/src/utils/index.ts 的 splitAndPackSig)
|
||||||
val packedSignature = splitAndPackSig(safeSignature)
|
val packedSignature = splitAndPackSig(safeSignature)
|
||||||
|
|
||||||
// 调试日志(地址已遮蔽)
|
// 调试日志(地址已遮蔽)
|
||||||
logger.debug("=== Builder Relayer 签名调试 ===")
|
logger.debug("=== Builder Relayer 签名调试 ===")
|
||||||
logger.debug("Safe: ${proxyAddress.take(10)}..., From: ${fromAddress.take(10)}..., Nonce: $proxyNonce")
|
logger.debug("Safe: ${proxyAddress.take(10)}..., From: ${fromAddress.take(10)}..., Nonce: $proxyNonce")
|
||||||
@@ -358,7 +362,7 @@ class RelayClientService(
|
|||||||
),
|
),
|
||||||
metadata = "Redeem positions via Builder Relayer"
|
metadata = "Redeem positions via Builder Relayer"
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.debug("Request: type=${request.type}, dataLen=${request.data.length}, sigLen=${request.signature.length}, nonce=${request.nonce}")
|
logger.debug("Request: type=${request.type}, dataLen=${request.data.length}, sigLen=${request.signature.length}, nonce=${request.nonce}")
|
||||||
|
|
||||||
// 调用 Builder Relayer API(认证头通过拦截器添加)
|
// 调用 Builder Relayer API(认证头通过拦截器添加)
|
||||||
@@ -372,7 +376,7 @@ class RelayClientService(
|
|||||||
|
|
||||||
val relayerResponse = response.body()!!
|
val relayerResponse = response.body()!!
|
||||||
val txHash = relayerResponse.transactionHash ?: relayerResponse.hash
|
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")
|
logger.info("Builder Relayer 执行成功: transactionID=${relayerResponse.transactionID}, txHash=$txHash")
|
||||||
return Result.success(txHash)
|
return Result.success(txHash)
|
||||||
@@ -381,14 +385,14 @@ class RelayClientService(
|
|||||||
/**
|
/**
|
||||||
* 打包签名(参考 builder-relayer-client/src/utils/index.ts 的 splitAndPackSig)
|
* 打包签名(参考 builder-relayer-client/src/utils/index.ts 的 splitAndPackSig)
|
||||||
* 将签名打包成 Gnosis Safe 接受的格式:encodePacked(["uint256", "uint256", "uint8"], [r, s, v])
|
* 将签名打包成 Gnosis Safe 接受的格式:encodePacked(["uint256", "uint256", "uint8"], [r, s, v])
|
||||||
*
|
*
|
||||||
* TypeScript 实现流程:
|
* TypeScript 实现流程:
|
||||||
* 1. 从签名字符串中提取 v(最后 2 个字符)
|
* 1. 从签名字符串中提取 v(最后 2 个字符)
|
||||||
* 2. 调整 v 值(0,1 -> +31; 27,28 -> +4)
|
* 2. 调整 v 值(0,1 -> +31; 27,28 -> +4)
|
||||||
* 3. 修改签名字符串(替换最后 2 个字符)
|
* 3. 修改签名字符串(替换最后 2 个字符)
|
||||||
* 4. 从修改后的签名字符串中提取 r, s, v(作为十进制字符串)
|
* 4. 从修改后的签名字符串中提取 r, s, v(作为十进制字符串)
|
||||||
* 5. 使用 encodePacked 打包:uint256(BigInt(r)) + uint256(BigInt(s)) + uint8(parseInt(v))
|
* 5. 使用 encodePacked 打包:uint256(BigInt(r)) + uint256(BigInt(s)) + uint8(parseInt(v))
|
||||||
*
|
*
|
||||||
* 关键:encodePacked 会将 BigInt 编码为 32 字节(64 个十六进制字符),uint8 编码为 1 字节(2 个十六进制字符)
|
* 关键:encodePacked 会将 BigInt 编码为 32 字节(64 个十六进制字符),uint8 编码为 1 字节(2 个十六进制字符)
|
||||||
*/
|
*/
|
||||||
private fun splitAndPackSig(signature: org.web3j.crypto.Sign.SignatureData): String {
|
private fun splitAndPackSig(signature: org.web3j.crypto.Sign.SignatureData): String {
|
||||||
@@ -403,37 +407,37 @@ class RelayClientService(
|
|||||||
}
|
}
|
||||||
val originalVHex = String.format("%02x", originalV)
|
val originalVHex = String.format("%02x", originalV)
|
||||||
val sigString = "0x$rHex$sHex$originalVHex" // 130 个十六进制字符(65 字节)
|
val sigString = "0x$rHex$sHex$originalVHex" // 130 个十六进制字符(65 字节)
|
||||||
|
|
||||||
// 2. 从签名字符串中提取 v(最后 2 个字符,作为十六进制)
|
// 2. 从签名字符串中提取 v(最后 2 个字符,作为十六进制)
|
||||||
val sigV = sigString.substring(sigString.length - 2).toInt(16)
|
val sigV = sigString.substring(sigString.length - 2).toInt(16)
|
||||||
|
|
||||||
// 3. 调整 v 值(参考 TypeScript 实现)
|
// 3. 调整 v 值(参考 TypeScript 实现)
|
||||||
val adjustedV = when (sigV) {
|
val adjustedV = when (sigV) {
|
||||||
0, 1 -> sigV + 31
|
0, 1 -> sigV + 31
|
||||||
27, 28 -> sigV + 4
|
27, 28 -> sigV + 4
|
||||||
else -> throw IllegalArgumentException("Invalid signature v value: $sigV")
|
else -> throw IllegalArgumentException("Invalid signature v value: $sigV")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 修改签名字符串(替换最后 2 个字符)
|
// 4. 修改签名字符串(替换最后 2 个字符)
|
||||||
val modifiedSigString = sigString.substring(0, sigString.length - 2) + String.format("%02x", adjustedV)
|
val modifiedSigString = sigString.substring(0, sigString.length - 2) + String.format("%02x", adjustedV)
|
||||||
|
|
||||||
// 5. 从修改后的签名字符串中提取 r, s, v(作为十六进制字符串)
|
// 5. 从修改后的签名字符串中提取 r, s, v(作为十六进制字符串)
|
||||||
// modifiedSigString 格式:0x + r(64) + s(64) + v(2) = 132 个字符
|
// modifiedSigString 格式:0x + r(64) + s(64) + v(2) = 132 个字符
|
||||||
val rHexStr = modifiedSigString.substring(2, 66) // 64 个字符(十六进制)
|
val rHexStr = modifiedSigString.substring(2, 66) // 64 个字符(十六进制)
|
||||||
val sHexStr = modifiedSigString.substring(66, 130) // 64 个字符(十六进制)
|
val sHexStr = modifiedSigString.substring(66, 130) // 64 个字符(十六进制)
|
||||||
val vHexStr = modifiedSigString.substring(130, 132) // 2 个字符(十六进制)
|
val vHexStr = modifiedSigString.substring(130, 132) // 2 个字符(十六进制)
|
||||||
|
|
||||||
// 6. 转换为 BigInteger 和 Int(模拟 TypeScript 的 BigInt 和 parseInt)
|
// 6. 转换为 BigInteger 和 Int(模拟 TypeScript 的 BigInt 和 parseInt)
|
||||||
val rBigInt = BigInteger(rHexStr, 16)
|
val rBigInt = BigInteger(rHexStr, 16)
|
||||||
val sBigInt = BigInteger(sHexStr, 16)
|
val sBigInt = BigInteger(sHexStr, 16)
|
||||||
val vInt = vHexStr.toInt(16)
|
val vInt = vHexStr.toInt(16)
|
||||||
|
|
||||||
// 7. 使用 encodePacked 打包:uint256(r) + uint256(s) + uint8(v)
|
// 7. 使用 encodePacked 打包:uint256(r) + uint256(s) + uint8(v)
|
||||||
// encodePacked 会将 BigInt 编码为 32 字节(64 个十六进制字符),uint8 编码为 1 字节(2 个十六进制字符)
|
// encodePacked 会将 BigInt 编码为 32 字节(64 个十六进制字符),uint8 编码为 1 字节(2 个十六进制字符)
|
||||||
val rEncoded = EthereumUtils.encodeUint256(rBigInt) // 64 个十六进制字符
|
val rEncoded = EthereumUtils.encodeUint256(rBigInt) // 64 个十六进制字符
|
||||||
val sEncoded = EthereumUtils.encodeUint256(sBigInt) // 64 个十六进制字符
|
val sEncoded = EthereumUtils.encodeUint256(sBigInt) // 64 个十六进制字符
|
||||||
val vEncoded = String.format("%02x", vInt) // 2 个十六进制字符
|
val vEncoded = String.format("%02x", vInt) // 2 个十六进制字符
|
||||||
|
|
||||||
return "0x$rEncoded$sEncoded$vEncoded"
|
return "0x$rEncoded$sEncoded$vEncoded"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,13 +508,13 @@ class RelayClientService(
|
|||||||
val messageWithPrefix = ByteArray(prefix.size + safeTxStructuredHash.size)
|
val messageWithPrefix = ByteArray(prefix.size + safeTxStructuredHash.size)
|
||||||
System.arraycopy(prefix, 0, messageWithPrefix, 0, prefix.size)
|
System.arraycopy(prefix, 0, messageWithPrefix, 0, prefix.size)
|
||||||
System.arraycopy(safeTxStructuredHash, 0, messageWithPrefix, prefix.size, safeTxStructuredHash.size)
|
System.arraycopy(safeTxStructuredHash, 0, messageWithPrefix, prefix.size, safeTxStructuredHash.size)
|
||||||
|
|
||||||
// 对带前缀的消息进行 keccak256 哈希
|
// 对带前缀的消息进行 keccak256 哈希
|
||||||
val keccak256 = org.bouncycastle.crypto.digests.KeccakDigest(256)
|
val keccak256 = org.bouncycastle.crypto.digests.KeccakDigest(256)
|
||||||
keccak256.update(messageWithPrefix, 0, messageWithPrefix.size)
|
keccak256.update(messageWithPrefix, 0, messageWithPrefix.size)
|
||||||
val hashWithPrefix = ByteArray(keccak256.digestSize)
|
val hashWithPrefix = ByteArray(keccak256.digestSize)
|
||||||
keccak256.doFinal(hashWithPrefix, 0)
|
keccak256.doFinal(hashWithPrefix, 0)
|
||||||
|
|
||||||
val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
|
val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
|
||||||
val safeSignature = org.web3j.crypto.Sign.signMessage(hashWithPrefix, ecKeyPair, false)
|
val safeSignature = org.web3j.crypto.Sign.signMessage(hashWithPrefix, ecKeyPair, false)
|
||||||
|
|
||||||
@@ -572,7 +576,8 @@ class RelayClientService(
|
|||||||
redeemCallData: String,
|
redeemCallData: String,
|
||||||
safeSignatureHex: String
|
safeSignatureHex: String
|
||||||
): 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 encodedTo = EthereumUtils.encodeAddress(safeTx.to)
|
||||||
val encodedValue = EthereumUtils.encodeUint256(BigInteger.ZERO)
|
val encodedValue = EthereumUtils.encodeUint256(BigInteger.ZERO)
|
||||||
@@ -600,20 +605,20 @@ class RelayClientService(
|
|||||||
val encodedSignatures = safeSignatureHex
|
val encodedSignatures = safeSignatureHex
|
||||||
|
|
||||||
return "0x" + execFunctionSelector.removePrefix("0x") +
|
return "0x" + execFunctionSelector.removePrefix("0x") +
|
||||||
encodedTo +
|
encodedTo +
|
||||||
encodedValue +
|
encodedValue +
|
||||||
encodedDataOffset +
|
encodedDataOffset +
|
||||||
encodedDataLength +
|
encodedDataLength +
|
||||||
encodedData +
|
encodedData +
|
||||||
encodedOperation +
|
encodedOperation +
|
||||||
encodedSafeTxGas +
|
encodedSafeTxGas +
|
||||||
encodedBaseGas +
|
encodedBaseGas +
|
||||||
encodedGasPrice +
|
encodedGasPrice +
|
||||||
encodedGasToken +
|
encodedGasToken +
|
||||||
encodedRefundReceiver +
|
encodedRefundReceiver +
|
||||||
encodedSignaturesOffset +
|
encodedSignaturesOffset +
|
||||||
encodedSignaturesLength +
|
encodedSignaturesLength +
|
||||||
encodedSignatures
|
encodedSignatures
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import com.wrbug.polymarketbot.api.GitHubApi
|
|||||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||||
import com.wrbug.polymarketbot.api.PolymarketDataApi
|
import com.wrbug.polymarketbot.api.PolymarketDataApi
|
||||||
import com.wrbug.polymarketbot.api.PolymarketGammaApi
|
import com.wrbug.polymarketbot.api.PolymarketGammaApi
|
||||||
|
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
@@ -18,7 +19,6 @@ import okhttp3.Response
|
|||||||
import okio.Buffer
|
import okio.Buffer
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.beans.factory.annotation.Value
|
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.converter.gson.GsonConverterFactory
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
@@ -34,10 +34,6 @@ import jakarta.annotation.PreDestroy
|
|||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
class RetrofitFactory(
|
class RetrofitFactory(
|
||||||
@Value("\${polymarket.clob.base-url}")
|
|
||||||
private val clobBaseUrl: String,
|
|
||||||
@Value("\${polymarket.gamma.base-url}")
|
|
||||||
private val gammaBaseUrl: String,
|
|
||||||
private val gson: Gson
|
private val gson: Gson
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@@ -58,10 +54,10 @@ class RetrofitFactory(
|
|||||||
|
|
||||||
// 缓存 Gamma API 客户端(单例)
|
// 缓存 Gamma API 客户端(单例)
|
||||||
private val gammaApi: PolymarketGammaApi by lazy {
|
private val gammaApi: PolymarketGammaApi by lazy {
|
||||||
val baseUrl = if (gammaBaseUrl.endsWith("/")) {
|
val baseUrl = if (PolymarketConstants.GAMMA_BASE_URL.endsWith("/")) {
|
||||||
gammaBaseUrl.dropLast(1)
|
PolymarketConstants.GAMMA_BASE_URL.dropLast(1)
|
||||||
} else {
|
} else {
|
||||||
gammaBaseUrl
|
PolymarketConstants.GAMMA_BASE_URL
|
||||||
}
|
}
|
||||||
|
|
||||||
Retrofit.Builder()
|
Retrofit.Builder()
|
||||||
@@ -74,7 +70,7 @@ class RetrofitFactory(
|
|||||||
|
|
||||||
// 缓存 Data API 客户端(单例)
|
// 缓存 Data API 客户端(单例)
|
||||||
private val dataApi: PolymarketDataApi by lazy {
|
private val dataApi: PolymarketDataApi by lazy {
|
||||||
val baseUrl = "https://data-api.polymarket.com"
|
val baseUrl = PolymarketConstants.DATA_API_BASE_URL
|
||||||
|
|
||||||
Retrofit.Builder()
|
Retrofit.Builder()
|
||||||
.baseUrl("$baseUrl/")
|
.baseUrl("$baseUrl/")
|
||||||
@@ -113,7 +109,7 @@ class RetrofitFactory(
|
|||||||
// 缓存不带认证的 CLOB API 客户端(单例)
|
// 缓存不带认证的 CLOB API 客户端(单例)
|
||||||
private val clobApiWithoutAuth: PolymarketClobApi by lazy {
|
private val clobApiWithoutAuth: PolymarketClobApi by lazy {
|
||||||
Retrofit.Builder()
|
Retrofit.Builder()
|
||||||
.baseUrl(clobBaseUrl)
|
.baseUrl(PolymarketConstants.CLOB_BASE_URL)
|
||||||
.client(sharedOkHttpClient)
|
.client(sharedOkHttpClient)
|
||||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||||
.build()
|
.build()
|
||||||
@@ -158,7 +154,7 @@ class RetrofitFactory(
|
|||||||
.build()
|
.build()
|
||||||
|
|
||||||
Retrofit.Builder()
|
Retrofit.Builder()
|
||||||
.baseUrl(clobBaseUrl)
|
.baseUrl(PolymarketConstants.CLOB_BASE_URL)
|
||||||
.client(okHttpClient)
|
.client(okHttpClient)
|
||||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
+2
-3
@@ -1,7 +1,7 @@
|
|||||||
package com.wrbug.polymarketbot.websocket
|
package com.wrbug.polymarketbot.websocket
|
||||||
|
|
||||||
|
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.beans.factory.annotation.Value
|
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
import org.springframework.web.socket.*
|
import org.springframework.web.socket.*
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
@@ -15,8 +15,7 @@ class PolymarketWebSocketHandler : WebSocketHandler {
|
|||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(PolymarketWebSocketHandler::class.java)
|
private val logger = LoggerFactory.getLogger(PolymarketWebSocketHandler::class.java)
|
||||||
|
|
||||||
@Value("\${polymarket.rtds.ws-url}")
|
private val polymarketWsUrl: String = PolymarketConstants.RTDS_WS_URL
|
||||||
private lateinit var polymarketWsUrl: String
|
|
||||||
|
|
||||||
// 存储客户端会话和对应的 Polymarket 连接的映射
|
// 存储客户端会话和对应的 Polymarket 连接的映射
|
||||||
private val clientSessions = ConcurrentHashMap<String, WebSocketSession>()
|
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
|
logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} - %msg%n
|
||||||
|
|
||||||
# Polymarket API 配置
|
# Polymarket API 配置
|
||||||
polymarket.clob.base-url=https://clob.polymarket.com
|
# 注意:Polymarket API URL 现在使用代码常量(PolymarketConstants),不再从配置文件读取
|
||||||
polymarket.rtds.ws-url=wss://ws-subscriptions-clob.polymarket.com
|
# 如需修改,请修改 com.wrbug.polymarketbot.constants.PolymarketConstants 类
|
||||||
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
|
|
||||||
|
|
||||||
# Builder Relayer 配置(用于 Gasless 交易)
|
# Builder Relayer 配置(用于 Gasless 交易)
|
||||||
# 从 polymarket.com/settings?tab=builder 获取 Builder API 凭证
|
# 从 polymarket.com/settings?tab=builder 获取 Builder API 凭证
|
||||||
# Builder API Key、Secret、Passphrase 现在通过系统设置页面配置,存储在数据库中
|
# Builder API Key、Secret、Passphrase 现在通过系统设置页面配置,存储在数据库中
|
||||||
# 如果未配置,将使用手动发送交易的方式(需要用户支付 gas)
|
# 如果未配置,将使用手动发送交易的方式(需要用户支付 gas)
|
||||||
polymarket.builder.relayer-url=${POLYMARKET_BUILDER_RELAYER_URL:https://relayer-v2.polymarket.com/}
|
# 注意:Builder Relayer URL 现在使用代码常量(PolymarketConstants.BUILDER_RELAYER_URL),不再从配置文件读取
|
||||||
|
|
||||||
# 跟单轮询配置
|
# 跟单轮询配置
|
||||||
# 轮询间隔(毫秒),默认2秒
|
# 轮询间隔(毫秒),默认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
|
### 5.1 application.properties
|
||||||
|
|
||||||
```properties
|
```properties
|
||||||
# Polymarket WebSocket
|
# 注意:Polymarket API URL 现在使用代码常量(PolymarketConstants),不再从配置文件读取
|
||||||
polymarket.websocket.url=wss://ws-live-data.polymarket.com
|
# 如需修改,请修改 com.wrbug.polymarketbot.constants.PolymarketConstants 类
|
||||||
|
|
||||||
# 监听策略
|
# 监听策略
|
||||||
copy.trading.monitor.strategy=dual
|
copy.trading.monitor.strategy=dual
|
||||||
|
|||||||
@@ -1099,6 +1099,7 @@
|
|||||||
"price": "Price",
|
"price": "Price",
|
||||||
"amount": "Amount",
|
"amount": "Amount",
|
||||||
"filterMarketId": "Filter Market ID",
|
"filterMarketId": "Filter Market ID",
|
||||||
|
"filterMarketTitle": "Filter Market Title",
|
||||||
"filterSide": "Filter Side",
|
"filterSide": "Filter Side",
|
||||||
"filterStatus": "Filter Status",
|
"filterStatus": "Filter Status",
|
||||||
"filterSellOrderId": "Filter Sell Order ID",
|
"filterSellOrderId": "Filter Sell Order ID",
|
||||||
@@ -1109,12 +1110,15 @@
|
|||||||
"groupByMarket": "Group by Market",
|
"groupByMarket": "Group by Market",
|
||||||
"expandAll": "Expand All",
|
"expandAll": "Expand All",
|
||||||
"collapseAll": "Collapse All",
|
"collapseAll": "Collapse All",
|
||||||
"allFullyMatched": "All Fully Matched",
|
"allFullySold": "All Fully Sold",
|
||||||
"partiallyMatched": "Partially Matched",
|
"notSold": "Not Sold",
|
||||||
|
"partiallySold": "Partially Sold",
|
||||||
"orderCount": "Order Count",
|
"orderCount": "Order Count",
|
||||||
"totalAmount": "Total Amount",
|
"totalAmount": "Total Amount",
|
||||||
"totalPnl": "Total PnL",
|
|
||||||
"statusBreakdown": "Status",
|
"statusBreakdown": "Status",
|
||||||
|
"allFullyMatched": "All Fully Sold",
|
||||||
|
"partiallyMatched": "Partially Sold",
|
||||||
|
"totalPnl": "Total PnL",
|
||||||
"markets": "markets",
|
"markets": "markets",
|
||||||
"fetchBuyOrdersFailed": "Failed to fetch buy orders",
|
"fetchBuyOrdersFailed": "Failed to fetch buy orders",
|
||||||
"fetchSellOrdersFailed": "Failed to fetch sell orders",
|
"fetchSellOrdersFailed": "Failed to fetch sell orders",
|
||||||
@@ -1126,7 +1130,6 @@
|
|||||||
"totalMatchedOrders": "Total Matched Orders",
|
"totalMatchedOrders": "Total Matched Orders",
|
||||||
"totalBuyAmount": "Total Buy Amount",
|
"totalBuyAmount": "Total Buy Amount",
|
||||||
"totalSellAmount": "Total Sell Amount",
|
"totalSellAmount": "Total Sell Amount",
|
||||||
"totalPnl": "Total PnL",
|
|
||||||
"totalRealizedPnl": "Total Realized PnL",
|
"totalRealizedPnl": "Total Realized PnL",
|
||||||
"totalUnrealizedPnl": "Total Unrealized PnL",
|
"totalUnrealizedPnl": "Total Unrealized PnL",
|
||||||
"winRate": "Win Rate",
|
"winRate": "Win Rate",
|
||||||
@@ -1195,4 +1198,4 @@
|
|||||||
"providerChainstack": "Chainstack",
|
"providerChainstack": "Chainstack",
|
||||||
"providerGetBlock": "GetBlock"
|
"providerGetBlock": "GetBlock"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1080,8 +1080,8 @@
|
|||||||
"sellStatus": "卖出状态",
|
"sellStatus": "卖出状态",
|
||||||
"status": "状态",
|
"status": "状态",
|
||||||
"statusFilled": "未成交",
|
"statusFilled": "未成交",
|
||||||
"statusPartiallySold": "部分成交",
|
"statusPartiallySold": "部分卖出",
|
||||||
"statusFullySold": "全部成交",
|
"statusFullySold": "全部卖出",
|
||||||
"realizedPnl": "已实现盈亏",
|
"realizedPnl": "已实现盈亏",
|
||||||
"createdAt": "创建时间",
|
"createdAt": "创建时间",
|
||||||
"matchedAt": "匹配时间",
|
"matchedAt": "匹配时间",
|
||||||
@@ -1110,8 +1110,9 @@
|
|||||||
"groupByMarket": "按市场分组",
|
"groupByMarket": "按市场分组",
|
||||||
"expandAll": "展开全部",
|
"expandAll": "展开全部",
|
||||||
"collapseAll": "折叠全部",
|
"collapseAll": "折叠全部",
|
||||||
"allFullyMatched": "全部成交",
|
"allFullySold": "全部卖出",
|
||||||
"partiallyMatched": "部分成交",
|
"notSold": "未卖出",
|
||||||
|
"partiallySold": "部分卖出",
|
||||||
"orderCount": "订单数",
|
"orderCount": "订单数",
|
||||||
"totalAmount": "总金额",
|
"totalAmount": "总金额",
|
||||||
"statusBreakdown": "状态",
|
"statusBreakdown": "状态",
|
||||||
@@ -1195,4 +1196,4 @@
|
|||||||
"providerChainstack": "Chainstack",
|
"providerChainstack": "Chainstack",
|
||||||
"providerGetBlock": "GetBlock"
|
"providerGetBlock": "GetBlock"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1018,7 +1018,7 @@
|
|||||||
"telegramConfig": {
|
"telegramConfig": {
|
||||||
"title": "Telegram 配置說明",
|
"title": "Telegram 配置說明",
|
||||||
"step1": "通過 <strong>@BotFather</strong> 創建 Telegram 機器人,獲取 Bot Token",
|
"step1": "通過 <strong>@BotFather</strong> 創建 Telegram 機器人,獲取 Bot Token",
|
||||||
"step2": "填寫 Bot Token 後,點擊\"獲取 Chat ID\"按鈕自動獲取(需要先向機器人發送消息)",
|
"step2": "填寫 Bot Token 後,點擊\"獲取 Chat ID\"按鈕自動獲取(需要先向機器人發送消息)",
|
||||||
"step3": "或通過 <strong>@userinfobot</strong> 手動獲取 Chat ID",
|
"step3": "或通過 <strong>@userinfobot</strong> 手動獲取 Chat ID",
|
||||||
"step4": "支持配置多個 Chat ID(用逗號分隔),所有配置的用戶都會收到通知",
|
"step4": "支持配置多個 Chat ID(用逗號分隔),所有配置的用戶都會收到通知",
|
||||||
"step5": "訂單成功或失敗時會自動發送 Telegram 消息",
|
"step5": "訂單成功或失敗時會自動發送 Telegram 消息",
|
||||||
@@ -1080,8 +1080,8 @@
|
|||||||
"sellStatus": "賣出狀態",
|
"sellStatus": "賣出狀態",
|
||||||
"status": "狀態",
|
"status": "狀態",
|
||||||
"statusFilled": "已完成",
|
"statusFilled": "已完成",
|
||||||
"statusPartiallySold": "部分成交",
|
"statusPartiallySold": "部分賣出",
|
||||||
"statusFullySold": "全部成交",
|
"statusFullySold": "全部賣出",
|
||||||
"realizedPnl": "已實現盈虧",
|
"realizedPnl": "已實現盈虧",
|
||||||
"createdAt": "創建時間",
|
"createdAt": "創建時間",
|
||||||
"matchedAt": "匹配時間",
|
"matchedAt": "匹配時間",
|
||||||
@@ -1099,6 +1099,7 @@
|
|||||||
"price": "價格",
|
"price": "價格",
|
||||||
"amount": "金額",
|
"amount": "金額",
|
||||||
"filterMarketId": "篩選市場ID",
|
"filterMarketId": "篩選市場ID",
|
||||||
|
"filterMarketTitle": "篩選市場標題",
|
||||||
"filterSide": "篩選方向",
|
"filterSide": "篩選方向",
|
||||||
"filterStatus": "篩選狀態",
|
"filterStatus": "篩選狀態",
|
||||||
"filterSellOrderId": "篩選賣出訂單ID",
|
"filterSellOrderId": "篩選賣出訂單ID",
|
||||||
@@ -1109,8 +1110,9 @@
|
|||||||
"groupByMarket": "按市場分組",
|
"groupByMarket": "按市場分組",
|
||||||
"expandAll": "展開全部",
|
"expandAll": "展開全部",
|
||||||
"collapseAll": "折疊全部",
|
"collapseAll": "折疊全部",
|
||||||
"allFullyMatched": "全部成交",
|
"allFullySold": "全部賣出",
|
||||||
"partiallyMatched": "部分成交",
|
"notSold": "未賣出",
|
||||||
|
"partiallySold": "部分賣出",
|
||||||
"orderCount": "訂單數",
|
"orderCount": "訂單數",
|
||||||
"totalAmount": "總金額",
|
"totalAmount": "總金額",
|
||||||
"totalPnl": "總盈虧",
|
"totalPnl": "總盈虧",
|
||||||
@@ -1126,7 +1128,6 @@
|
|||||||
"totalMatchedOrders": "總匹配訂單數",
|
"totalMatchedOrders": "總匹配訂單數",
|
||||||
"totalBuyAmount": "總買入金額",
|
"totalBuyAmount": "總買入金額",
|
||||||
"totalSellAmount": "總賣出金額",
|
"totalSellAmount": "總賣出金額",
|
||||||
"totalPnl": "總盈虧",
|
|
||||||
"totalRealizedPnl": "總已實現盈虧",
|
"totalRealizedPnl": "總已實現盈虧",
|
||||||
"totalUnrealizedPnl": "總未實現盈虧",
|
"totalUnrealizedPnl": "總未實現盈虧",
|
||||||
"winRate": "勝率",
|
"winRate": "勝率",
|
||||||
@@ -1195,4 +1196,4 @@
|
|||||||
"providerChainstack": "Chainstack",
|
"providerChainstack": "Chainstack",
|
||||||
"providerGetBlock": "GetBlock"
|
"providerGetBlock": "GetBlock"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -373,10 +373,12 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{group.stats.fullyMatched ? (
|
{group.stats.fullyMatched ? (
|
||||||
<Tag color="success">{t('copyTradingOrders.allFullyMatched') || '全部成交'}</Tag>
|
<Tag color="success">{t('copyTradingOrders.allFullySold') || '全部卖出'}</Tag>
|
||||||
|
) : group.stats.fullyMatchedCount === 0 ? (
|
||||||
|
<Tag color="default">{t('copyTradingOrders.notSold') || '未卖出'}</Tag>
|
||||||
) : (
|
) : (
|
||||||
<Tag color="warning">
|
<Tag color="warning">
|
||||||
{t('copyTradingOrders.partiallyMatched') || '部分成交'} ({group.stats.fullyMatchedCount}/{group.stats.count})
|
{t('copyTradingOrders.partiallySold') || '部分卖出'} ({group.stats.fullyMatchedCount}/{group.stats.count})
|
||||||
</Tag>
|
</Tag>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -384,10 +386,10 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
|
|||||||
<span>{t('copyTradingOrders.orderCount') || '订单数'}: {group.stats.count}</span>
|
<span>{t('copyTradingOrders.orderCount') || '订单数'}: {group.stats.count}</span>
|
||||||
<span>{t('copyTradingOrders.totalAmount') || '总金额'}: {formatUSDC(group.stats.totalAmount)} USDC</span>
|
<span>{t('copyTradingOrders.totalAmount') || '总金额'}: {formatUSDC(group.stats.totalAmount)} USDC</span>
|
||||||
<span>
|
<span>
|
||||||
{t('copyTradingOrders.statusBreakdown') || '状态'}:
|
{t('copyTradingOrders.statusBreakdown') || '状态'}:
|
||||||
{group.stats.fullyMatchedCount > 0 && ` ${t('copyTradingOrders.statusFullySold') || '全部成交'} ${group.stats.fullyMatchedCount}`}
|
{group.stats.fullyMatchedCount > 0 && ` ${t('copyTradingOrders.allFullySold') || '全部卖出'} ${group.stats.fullyMatchedCount}`}
|
||||||
{group.stats.partiallyMatchedCount > 0 && ` ${t('copyTradingOrders.statusPartiallySold') || '部分成交'} ${group.stats.partiallyMatchedCount}`}
|
{group.stats.partiallyMatchedCount > 0 && ` ${t('copyTradingOrders.partiallySold') || '部分卖出'} ${group.stats.partiallyMatchedCount}`}
|
||||||
{group.stats.filledCount > 0 && ` ${t('copyTradingOrders.statusFilled') || '未成交'} ${group.stats.filledCount}`}
|
{group.stats.filledCount > 0 && ` ${t('copyTradingOrders.notSold') || '未卖出'} ${group.stats.filledCount}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -668,9 +670,9 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
|
|||||||
value={filters.status}
|
value={filters.status}
|
||||||
onChange={(value) => setFilters({ ...filters, status: value || undefined })}
|
onChange={(value) => setFilters({ ...filters, status: value || undefined })}
|
||||||
>
|
>
|
||||||
<Option value="filled">{t('copyTradingOrders.statusFilled') || '未成交'}</Option>
|
<Option value="filled">{t('copyTradingOrders.notSold') || '未卖出'}</Option>
|
||||||
<Option value="partially_matched">{t('copyTradingOrders.statusPartiallySold') || '部分成交'}</Option>
|
<Option value="partially_matched">{t('copyTradingOrders.partiallySold') || '部分卖出'}</Option>
|
||||||
<Option value="fully_matched">{t('copyTradingOrders.statusFullySold') || '全部成交'}</Option>
|
<Option value="fully_matched">{t('copyTradingOrders.allFullySold') || '全部卖出'}</Option>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Space>
|
<Space>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Table, Input, Button, Card, Divider, Spin, message } from 'antd'
|
import { Table, Input, Button, Card, Divider, Spin, message } from 'antd'
|
||||||
import { apiService } from '../../services/api'
|
import { apiService } from '../../services/api'
|
||||||
import { formatUSDC, isAutoGeneratedOrderId, copyToClipboard } from '../../utils'
|
import { formatUSDC, isAutoGeneratedOrderId, copyToClipboard, getPolymarketUrl } from '../../utils'
|
||||||
import { useMediaQuery } from 'react-responsive'
|
import { useMediaQuery } from 'react-responsive'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import type { MatchedOrderInfo, OrderTrackingRequest, OrderTrackingListResponse } from '../../types'
|
import type { MatchedOrderInfo, OrderTrackingRequest, OrderTrackingListResponse } from '../../types'
|
||||||
@@ -91,59 +91,101 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId, acti
|
|||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: t('copyTradingOrders.sellOrderId') || '卖出订单ID',
|
title: t('copyTradingOrders.market') || '市场',
|
||||||
dataIndex: 'sellOrderId',
|
dataIndex: 'marketId',
|
||||||
key: 'sellOrderId',
|
key: 'marketId',
|
||||||
width: isMobile ? 120 : 180,
|
width: isMobile ? 120 : 200,
|
||||||
render: (text: string) => {
|
render: (text: string, record: MatchedOrderInfo) => {
|
||||||
const isAuto = isAutoGeneratedOrderId(text)
|
const marketUrl = getPolymarketUrl(record.marketSlug, record.eventSlug, record.marketCategory, record.marketId)
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
||||||
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
|
{record.marketTitle ? (
|
||||||
|
marketUrl ? (
|
||||||
|
<a
|
||||||
|
href={marketUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{
|
||||||
|
fontSize: isMobile ? 11 : 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: '#1890ff',
|
||||||
|
textDecoration: 'none',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{record.marketTitle}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span style={{ fontSize: isMobile ? 11 : 12, fontWeight: 500 }}>
|
||||||
|
{record.marketTitle}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 10 : 11, color: '#999' }}>
|
||||||
{isMobile
|
{isMobile
|
||||||
? `${text.slice(0, 6)}...${text.slice(-4)}`
|
? `${text.slice(0, 6)}...${text.slice(-4)}`
|
||||||
: `${text.slice(0, 8)}...${text.slice(-6)}`
|
: `${text.slice(0, 8)}...${text.slice(-6)}`
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
{!isAuto && (
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
icon={<CopyOutlined />}
|
|
||||||
onClick={() => handleCopyOrderId(text)}
|
|
||||||
style={{ padding: 0, height: 'auto', fontSize: isMobile ? 11 : 12 }}
|
|
||||||
title={t('common.copy') || '复制'}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('copyTradingOrders.buyOrderId') || '买入订单ID',
|
title: t('copyTradingOrders.orderId') || '订单ID',
|
||||||
dataIndex: 'buyOrderId',
|
dataIndex: 'orderId',
|
||||||
key: 'buyOrderId',
|
key: 'orderId',
|
||||||
width: isMobile ? 120 : 180,
|
width: isMobile ? 150 : 200,
|
||||||
render: (text: string) => {
|
render: (_: any, record: MatchedOrderInfo) => {
|
||||||
const isAuto = isAutoGeneratedOrderId(text)
|
const buyOrderId = record.buyOrderId
|
||||||
|
const sellOrderId = record.sellOrderId
|
||||||
|
const isBuyAuto = isAutoGeneratedOrderId(buyOrderId)
|
||||||
|
const isSellAuto = isAutoGeneratedOrderId(sellOrderId)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<div>
|
||||||
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
|
||||||
{isMobile
|
<span style={{ fontSize: isMobile ? 11 : 12, color: '#999' }}>
|
||||||
? `${text.slice(0, 6)}...${text.slice(-4)}`
|
{t('copyTradingOrders.buy') || '买入'}:
|
||||||
: `${text.slice(0, 8)}...${text.slice(-6)}`
|
</span>
|
||||||
}
|
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
|
||||||
</span>
|
{isMobile
|
||||||
{!isAuto && (
|
? `${buyOrderId.slice(0, 6)}...${buyOrderId.slice(-4)}`
|
||||||
<Button
|
: `${buyOrderId.slice(0, 8)}...${buyOrderId.slice(-6)}`
|
||||||
type="text"
|
}
|
||||||
size="small"
|
</span>
|
||||||
icon={<CopyOutlined />}
|
{!isBuyAuto && (
|
||||||
onClick={() => handleCopyOrderId(text)}
|
<Button
|
||||||
style={{ padding: 0, height: 'auto', fontSize: isMobile ? 11 : 12 }}
|
type="text"
|
||||||
title={t('common.copy') || '复制'}
|
size="small"
|
||||||
/>
|
icon={<CopyOutlined />}
|
||||||
)}
|
onClick={() => handleCopyOrderId(buyOrderId)}
|
||||||
|
style={{ padding: 0, height: 'auto', fontSize: isMobile ? 11 : 12 }}
|
||||||
|
title={t('common.copy') || '复制'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<span style={{ fontSize: isMobile ? 11 : 12, color: '#999' }}>
|
||||||
|
{t('copyTradingOrders.sell') || '卖出'}:
|
||||||
|
</span>
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
|
||||||
|
{isMobile
|
||||||
|
? `${sellOrderId.slice(0, 6)}...${sellOrderId.slice(-4)}`
|
||||||
|
: `${sellOrderId.slice(0, 8)}...${sellOrderId.slice(-6)}`
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
{!isSellAuto && (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
onClick={() => handleCopyOrderId(sellOrderId)}
|
||||||
|
style={{ padding: 0, height: 'auto', fontSize: isMobile ? 11 : 12 }}
|
||||||
|
title={t('common.copy') || '复制'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -233,7 +275,7 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId, acti
|
|||||||
onChange={(e) => setFilters({ ...filters, buyOrderId: e.target.value || undefined })}
|
onChange={(e) => setFilters({ ...filters, buyOrderId: e.target.value || undefined })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button type="primary" onClick={fetchOrders} icon={<ReloadOutlined />}>{t('common.search') || '查询'}</Button>
|
<Button type="primary" onClick={fetchOrders} icon={<ReloadOutlined />}>{t('common.refresh') || '刷新'}</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
@@ -273,9 +315,31 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId, acti
|
|||||||
<div style={{ marginBottom: '12px' }}>
|
<div style={{ marginBottom: '12px' }}>
|
||||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.market') || '市场'}</div>
|
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.market') || '市场'}</div>
|
||||||
{order.marketTitle ? (
|
{order.marketTitle ? (
|
||||||
<div style={{ fontSize: '13px', fontWeight: '500', marginBottom: '4px' }}>
|
(() => {
|
||||||
{order.marketTitle}
|
const marketUrl = getPolymarketUrl(order.marketSlug, order.eventSlug, order.marketCategory, order.marketId)
|
||||||
</div>
|
return marketUrl ? (
|
||||||
|
<a
|
||||||
|
href={marketUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: '500',
|
||||||
|
marginBottom: '4px',
|
||||||
|
color: '#1890ff',
|
||||||
|
textDecoration: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'block'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{order.marketTitle}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: '13px', fontWeight: '500', marginBottom: '4px' }}>
|
||||||
|
{order.marketTitle}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()
|
||||||
) : null}
|
) : null}
|
||||||
{order.marketId && (
|
{order.marketId && (
|
||||||
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
|
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
|
||||||
@@ -284,50 +348,42 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId, acti
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.sellOrderId') || '卖出订单ID'}</div>
|
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.orderId') || '订单ID'}</div>
|
||||||
<div style={{
|
<div style={{ marginBottom: '8px' }}>
|
||||||
fontSize: '13px',
|
<div style={{ marginBottom: '4px' }}>
|
||||||
fontWeight: '500',
|
<div style={{ fontSize: '11px', color: '#999', marginBottom: '2px' }}>{t('copyTradingOrders.buy') || '买入'}:</div>
|
||||||
fontFamily: 'monospace',
|
<div style={{ fontSize: '13px', fontWeight: '500', fontFamily: 'monospace', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
marginBottom: '8px',
|
<span>{order.buyOrderId.slice(0, 8)}...{order.buyOrderId.slice(-6)}</span>
|
||||||
display: 'flex',
|
{!isAutoGeneratedOrderId(order.buyOrderId) && (
|
||||||
alignItems: 'center',
|
<Button
|
||||||
gap: '8px'
|
type="text"
|
||||||
}}>
|
size="small"
|
||||||
<span>{order.sellOrderId.slice(0, 8)}...{order.sellOrderId.slice(-6)}</span>
|
icon={<CopyOutlined />}
|
||||||
{!isAutoGeneratedOrderId(order.sellOrderId) && (
|
onClick={() => handleCopyOrderId(order.buyOrderId)}
|
||||||
<Button
|
style={{ padding: 0, height: 'auto', fontSize: '12px' }}
|
||||||
type="text"
|
title={t('common.copy') || '复制'}
|
||||||
size="small"
|
/>
|
||||||
icon={<CopyOutlined />}
|
)}
|
||||||
onClick={() => handleCopyOrderId(order.sellOrderId)}
|
</div>
|
||||||
style={{ padding: 0, height: 'auto', fontSize: '12px' }}
|
|
||||||
title={t('common.copy') || '复制'}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.buyOrderId') || '买入订单ID'}</div>
|
<div>
|
||||||
<div style={{
|
<div style={{ fontSize: '11px', color: '#999', marginBottom: '2px' }}>{t('copyTradingOrders.sell') || '卖出'}:</div>
|
||||||
fontSize: '13px',
|
<div style={{ fontSize: '13px', fontWeight: '500', fontFamily: 'monospace', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
fontWeight: '500',
|
<span>{order.sellOrderId.slice(0, 8)}...{order.sellOrderId.slice(-6)}</span>
|
||||||
fontFamily: 'monospace',
|
{!isAutoGeneratedOrderId(order.sellOrderId) && (
|
||||||
display: 'flex',
|
<Button
|
||||||
alignItems: 'center',
|
type="text"
|
||||||
gap: '8px'
|
size="small"
|
||||||
}}>
|
icon={<CopyOutlined />}
|
||||||
<span>{order.buyOrderId.slice(0, 8)}...{order.buyOrderId.slice(-6)}</span>
|
onClick={() => handleCopyOrderId(order.sellOrderId)}
|
||||||
{!isAutoGeneratedOrderId(order.buyOrderId) && (
|
style={{ padding: 0, height: 'auto', fontSize: '12px' }}
|
||||||
<Button
|
title={t('common.copy') || '复制'}
|
||||||
type="text"
|
/>
|
||||||
size="small"
|
)}
|
||||||
icon={<CopyOutlined />}
|
</div>
|
||||||
onClick={() => handleCopyOrderId(order.buyOrderId)}
|
|
||||||
style={{ padding: 0, height: 'auto', fontSize: '12px' }}
|
|
||||||
title={t('common.copy') || '复制'}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Divider style={{ margin: '12px 0' }} />
|
<Divider style={{ margin: '12px 0' }} />
|
||||||
|
|
||||||
|
|||||||
@@ -358,7 +358,7 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
|
|||||||
{marketDisplayName}
|
{marketDisplayName}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<Tag color="success">{t('copyTradingOrders.allFullyMatched') || '全部成交'}</Tag>
|
<Tag color="success">{t('copyTradingOrders.allFullySold') || '全部卖出'}</Tag>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap', fontSize: isMobile ? '12px' : '13px', color: '#666' }}>
|
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap', fontSize: isMobile ? '12px' : '13px', color: '#666' }}>
|
||||||
<span>{t('copyTradingOrders.orderCount') || '订单数'}: {group.stats.count}</span>
|
<span>{t('copyTradingOrders.orderCount') || '订单数'}: {group.stats.count}</span>
|
||||||
@@ -648,8 +648,8 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
|
|||||||
onChange={(value) => setFilters({ ...filters, status: value || undefined })}
|
onChange={(value) => setFilters({ ...filters, status: value || undefined })}
|
||||||
>
|
>
|
||||||
<Option value="filled">{t('copyTradingOrders.statusFilled') || '未成交'}</Option>
|
<Option value="filled">{t('copyTradingOrders.statusFilled') || '未成交'}</Option>
|
||||||
<Option value="partially_matched">{t('copyTradingOrders.statusPartiallyMatched') || '部分成交'}</Option>
|
<Option value="partially_matched">{t('copyTradingOrders.partiallySold') || '部分卖出'}</Option>
|
||||||
<Option value="fully_matched">{t('copyTradingOrders.statusFullyMatched') || '完全成交'}</Option>
|
<Option value="fully_matched">{t('copyTradingOrders.allFullySold') || '全部卖出'}</Option>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Space>
|
<Space>
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ const PositionList: React.FC = () => {
|
|||||||
const [redeemableSummary, setRedeemableSummary] = useState<RedeemablePositionsSummary | null>(null)
|
const [redeemableSummary, setRedeemableSummary] = useState<RedeemablePositionsSummary | null>(null)
|
||||||
const [loadingRedeemableSummary, setLoadingRedeemableSummary] = useState(false)
|
const [loadingRedeemableSummary, setLoadingRedeemableSummary] = useState(false)
|
||||||
const [redeeming, setRedeeming] = useState(false)
|
const [redeeming, setRedeeming] = useState(false)
|
||||||
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(20)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAccounts()
|
fetchAccounts()
|
||||||
@@ -66,6 +68,11 @@ const PositionList: React.FC = () => {
|
|||||||
fetchRedeemableSummary()
|
fetchRedeemableSummary()
|
||||||
}
|
}
|
||||||
}, [currentPositions, selectedAccountId])
|
}, [currentPositions, selectedAccountId])
|
||||||
|
|
||||||
|
// 当筛选条件或搜索关键词变化时,重置分页到第一页
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1)
|
||||||
|
}, [positionFilter, selectedAccountId, searchKeyword])
|
||||||
|
|
||||||
// 获取可赎回仓位统计
|
// 获取可赎回仓位统计
|
||||||
const fetchRedeemableSummary = async () => {
|
const fetchRedeemableSummary = async () => {
|
||||||
@@ -265,12 +272,12 @@ const PositionList: React.FC = () => {
|
|||||||
// 本地搜索和筛选过滤
|
// 本地搜索和筛选过滤
|
||||||
const filteredPositions = useMemo(() => {
|
const filteredPositions = useMemo(() => {
|
||||||
let filtered = basePositions
|
let filtered = basePositions
|
||||||
|
|
||||||
// 1. 先按账户筛选
|
// 1. 先按账户筛选
|
||||||
if (selectedAccountId !== undefined) {
|
if (selectedAccountId !== undefined) {
|
||||||
filtered = filtered.filter(p => p.accountId === selectedAccountId)
|
filtered = filtered.filter(p => p.accountId === selectedAccountId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 最后按关键词搜索
|
// 2. 最后按关键词搜索
|
||||||
if (searchKeyword.trim()) {
|
if (searchKeyword.trim()) {
|
||||||
const keyword = searchKeyword.trim().toLowerCase()
|
const keyword = searchKeyword.trim().toLowerCase()
|
||||||
@@ -302,9 +309,16 @@ const PositionList: React.FC = () => {
|
|||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return filtered
|
return filtered
|
||||||
}, [basePositions, searchKeyword, selectedAccountId])
|
}, [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) => {
|
const getSideColor = (side: string) => {
|
||||||
return side === 'YES' ? 'green' : 'red'
|
return side === 'YES' ? 'green' : 'red'
|
||||||
@@ -349,14 +363,32 @@ const PositionList: React.FC = () => {
|
|||||||
if (!isNaN(initialValue)) {
|
if (!isNaN(initialValue)) {
|
||||||
totalInitialValue += initialValue
|
totalInitialValue += initialValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 当前仓位:统计持仓价值
|
||||||
|
// 历史仓位:currentValue 应该为 0(已平仓)
|
||||||
if (!isNaN(currentValue)) {
|
if (!isNaN(currentValue)) {
|
||||||
totalCurrentValue += currentValue
|
totalCurrentValue += currentValue
|
||||||
}
|
}
|
||||||
if (!isNaN(pnl)) {
|
|
||||||
totalPnl += pnl
|
// 对于当前仓位:
|
||||||
}
|
// - pnl:未实现盈亏(浮动盈亏)
|
||||||
if (!isNaN(realizedPnl)) {
|
// - realizedPnl:已实现盈亏(部分平仓时产生)
|
||||||
totalRealizedPnl += 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 = () => {
|
const renderCardView = () => {
|
||||||
if (filteredPositions.length === 0) {
|
if (paginatedPositions.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Empty
|
<Empty
|
||||||
description="暂无仓位数据"
|
description="暂无仓位数据"
|
||||||
style={{ padding: '60px 0' }}
|
style={{ padding: '60px 0' }}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -527,7 +559,7 @@ const PositionList: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
{filteredPositions.map((position, index) => {
|
{paginatedPositions.map((position, index) => {
|
||||||
const pnlNum = parseFloat(position.pnl || '0')
|
const pnlNum = parseFloat(position.pnl || '0')
|
||||||
const isProfit = pnlNum >= 0
|
const isProfit = pnlNum >= 0
|
||||||
// 只有当前仓位才根据盈亏显示边框颜色
|
// 只有当前仓位才根据盈亏显示边框颜色
|
||||||
@@ -1235,8 +1267,8 @@ const PositionList: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* 合计信息:开仓价值、当前价值、盈亏、已实现盈亏(基于当前筛选后的仓位) */}
|
{/* 合计信息:开仓价值、当前价值、盈亏、已实现盈亏(仅当前仓位显示) */}
|
||||||
{filteredPositions.length > 0 && (
|
{filteredPositions.length > 0 && positionFilter === 'current' && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginTop: '12px',
|
marginTop: '12px',
|
||||||
@@ -1259,13 +1291,11 @@ const PositionList: React.FC = () => {
|
|||||||
<span>
|
<span>
|
||||||
当前价值合计:{' '}
|
当前价值合计:{' '}
|
||||||
<span style={{ fontWeight: 600 }}>
|
<span style={{ fontWeight: 600 }}>
|
||||||
{positionFilter === 'current'
|
{formatUSDC(positionTotals.totalCurrentValue.toString())} USDC
|
||||||
? `${formatUSDC(positionTotals.totalCurrentValue.toString())} USDC`
|
|
||||||
: '-'}
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
盈亏合计:{' '}
|
浮动盈亏合计:{' '}
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
@@ -1295,32 +1325,87 @@ const PositionList: React.FC = () => {
|
|||||||
{(isMobile || viewMode === 'card') ? (
|
{(isMobile || viewMode === 'card') ? (
|
||||||
<Card loading={loading}>
|
<Card loading={loading}>
|
||||||
{renderCardView()}
|
{renderCardView()}
|
||||||
|
{/* 移动端分页 */}
|
||||||
{filteredPositions.length > 0 && (
|
{filteredPositions.length > 0 && (
|
||||||
<div style={{
|
<>
|
||||||
marginTop: '24px',
|
<div style={{
|
||||||
textAlign: 'center',
|
marginTop: '16px',
|
||||||
color: '#999',
|
display: 'flex',
|
||||||
fontSize: '14px'
|
justifyContent: 'space-between',
|
||||||
}}>
|
alignItems: 'center',
|
||||||
共 {filteredPositions.length} 个仓位{searchKeyword ? `(已过滤)` : ''}
|
flexWrap: 'wrap',
|
||||||
</div>
|
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>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<Table
|
<Table
|
||||||
dataSource={filteredPositions}
|
dataSource={filteredPositions}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rowKey={(record, index) => `${record.accountId}-${record.marketId}-${index}`}
|
rowKey={(record, index) => `${record.accountId}-${record.marketId}-${index}`}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={{
|
pagination={{
|
||||||
pageSize: 20,
|
current: currentPage,
|
||||||
showSizeChanger: !isMobile,
|
pageSize: pageSize,
|
||||||
showTotal: (total) => `共 ${total} 个仓位${searchKeyword ? `(已过滤)` : ''}`
|
total: filteredPositions.length,
|
||||||
}}
|
showSizeChanger: true,
|
||||||
scroll={isMobile ? { x: 1500 } : undefined}
|
pageSizeOptions: ['10', '20', '50'],
|
||||||
/>
|
showTotal: (total) => `共 ${total} 个仓位${searchKeyword ? `(已过滤)` : ''}`,
|
||||||
</Card>
|
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);
|
||||||
Generated
+1352
File diff suppressed because it is too large
Load Diff
@@ -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