Compare commits

..

5 Commits

Author SHA1 Message Date
WrBug e48149a19b feat: 优化跟单仓位检查逻辑,使用本地订单数据库解决推送延迟问题,并引入混合检查机制 2026-01-05 10:49:59 +08:00
WrBug c0e0942404 fix: prevent duplicate telegram notifications for same orderId
- feat: add deduplication cache in TelegramNotificationService
- fix: skip sending notification if orderId was sent within last 5 minutes
2026-01-04 21:36:02 +08:00
WrBug 6b8c11f171 fix: refine price rounding and optimize order polling
- fix: round buy prices UP and sell prices DOWN to 2 decimal places to match USDC precision
- perf: optimize order status polling by filtering fully_matched orders in SQL
- chore: add debug logging for order creation and FAK error investigation
2026-01-04 21:30:46 +08:00
WrBug 3e667b70fd feat: enhance copy trading logic and UI
- fix: correct copyRatio scaling (divide by 100)
- fix: enforce minimum order size with round-up logic
- feat: customize Telegram notification icons
- feat: optimize frontend order list (active loading, refresh buttons)
- refactor: optimize OnChain WebSocket connection management
2026-01-04 20:36:41 +08:00
WrBug 2bb8cbc564 移除跟单信号中的轮询 activity 接口逻辑
- 移除 CopyTradingMonitorService 中的 pollingService 相关调用
- 删除不再使用的 CopyTradingPollingService 类
- 跟单信号现在完全依赖链上 WebSocket 监听,不再使用轮询接口
- 优化账户列表接口性能(仅返回基本信息,不查询统计数据)
- 改进 PositionCheckService 的错误处理(市场价格获取失败时跳过处理)
2026-01-04 15:50:23 +08:00
20 changed files with 907 additions and 1017 deletions
@@ -61,5 +61,28 @@ interface CopyOrderTrackingRepository : JpaRepository<CopyOrderTracking, Long> {
*/
@Query("SELECT t FROM CopyOrderTracking t WHERE t.createdAt <= :beforeTime")
fun findByCreatedAtBefore(beforeTime: Long): List<CopyOrderTracking>
/**
* 查询指定时间之前创建且状态不为指定状态的订单
*/
fun findByCreatedAtBeforeAndStatusNot(beforeTime: Long, status: String): List<CopyOrderTracking>
/**
* 查询指定跟单配置下的活跃仓位数量
* 活跃仓位定义为 remainingQuantity > 0 的不同 (marketId, outcomeIndex) 组合
*/
@Query("SELECT COUNT(DISTINCT CONCAT(t.marketId, '_', COALESCE(t.outcomeIndex, -1))) FROM CopyOrderTracking t WHERE t.copyTradingId = :copyTradingId AND t.remainingQuantity > 0")
fun countActivePositions(copyTradingId: Long): Int
/**
* 检查指定市场是否存在活跃仓位
*/
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.remainingQuantity > 0")
fun sumCurrentPositionValueByMarket(copyTradingId: Long, marketId: String): BigDecimal?
}
@@ -228,11 +228,12 @@ class AccountService(
/**
* 查询账户列表
* 列表接口只返回基本信息,不查询统计信息(统计信息只在详情接口中查询)
*/
fun getAccountList(): Result<AccountListResponse> {
return try {
val accounts = accountRepository.findAllByOrderByCreatedAtAsc()
val accountDtos = accounts.map { toDto(it) }
val accountDtos = accounts.map { toBasicDto(it) }
Result.success(
AccountListResponse(
@@ -353,7 +354,30 @@ class AccountService(
}
/**
* 转换为 DTO
* 转换为基础 DTO(列表使用,不包含统计信息)
* 列表接口只返回基本信息,不查询统计信息,以提高性能
*/
private fun toBasicDto(account: Account): AccountDto {
return AccountDto(
id = account.id!!,
walletAddress = account.walletAddress,
proxyAddress = account.proxyAddress,
accountName = account.accountName,
isEnabled = account.isEnabled,
walletType = account.walletType,
apiKeyConfigured = account.apiKey != null,
apiSecretConfigured = account.apiSecret != null,
apiPassphraseConfigured = account.apiPassphrase != null,
totalOrders = null,
totalPnl = null,
activeOrders = null,
completedOrders = null,
positionCount = null
)
}
/**
* 转换为完整 DTO(详情使用,包含交易统计数据)
* 包含交易统计数据(总订单数、总盈亏、活跃订单数、已完成订单数、持仓数量)
*/
private fun toDto(account: Account): AccountDto {
@@ -382,9 +382,15 @@ class PositionCheckService(
if (ordersToMarkAsSold.isNotEmpty()) {
// 有订单创建时间超过2分钟,认为仓位已被出售
try {
val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex)
updateOrdersAsSold(ordersToMarkAsSold, currentPrice, copyTrading.id, marketId, outcomeIndex)
logger.debug("仓位不存在且订单创建时间超过2分钟,标记为已卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, orderCount=${ordersToMarkAsSold.size}")
} catch (e: Exception) {
logger.warn("无法获取市场价格,跳过标记为已卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}")
// 无法获取价格时,跳过该市场的处理,等待下次检查时再试
continue
}
} else {
// 订单创建时间不足2分钟,可能是刚创建的订单,暂时不处理
logger.debug("仓位不存在但订单创建时间不足2分钟,暂不标记为已卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, orderCount=${orders.size}, oldestOrderAge=${orders.minOfOrNull { now - it.createdAt }?.let { "${it}ms" } ?: "N/A"}")
@@ -413,9 +419,15 @@ class PositionCheckService(
}
// 如果已成交数量 > 0,按FIFO顺序匹配订单
try {
val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex)
updateOrdersAsSoldByFIFO(orders, soldQuantity, currentPrice,
copyTrading.id, marketId, outcomeIndex)
} catch (e: Exception) {
logger.warn("无法获取市场价格,跳过FIFO匹配: marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}")
// 无法获取价格时,跳过该市场的处理,等待下次检查时再试
continue
}
}
}
}
@@ -715,7 +715,7 @@ class BlockchainService(
/**
* 从链上查询市场条件(Condition)的结算结果
* 通过调用 ConditionalTokens 合约的 getCondition 函数获取 payouts
* 通过调用 ConditionalTokens 合约的 conditions mapping 和 payoutNumerators mapping
*
* @param conditionId 市场条件IDbytes32,必须是 0x 开头的 66 位十六进制字符串)
* @return Result<Pair<payoutDenominator, payouts>>
@@ -734,44 +734,101 @@ class BlockchainService(
val rpcApi = polygonRpcApi
// 构建 getCondition(bytes32) 函数调用
// 函数签名: getCondition(bytes32)
val functionSelector = EthereumUtils.getFunctionSelector("getCondition(bytes32)")
// 1. 调用 conditions(bytes32) 获取 outcomeSlotCount 和 payoutDenominator
// 注意:这是一个公开的 mappingSolidity 自动生成的 getter
// 函数签名: conditions(bytes32) returns (uint outcomeSlotCount, uint payoutDenominator)
val conditionsFunctionSelector = EthereumUtils.getFunctionSelector("conditions(bytes32)")
val encodedConditionId = EthereumUtils.encodeBytes32(conditionId)
val data = functionSelector + encodedConditionId
val conditionsData = conditionsFunctionSelector + encodedConditionId
// 构建 JSON-RPC 请求
val rpcRequest = JsonRpcRequest(
val conditionsRequest = JsonRpcRequest(
method = "eth_call",
params = listOf(
mapOf(
"to" to conditionalTokensAddress,
"data" to data
"data" to conditionsData
),
"latest"
)
)
// 发送 RPC 请求
val response = rpcApi.call(rpcRequest)
val conditionsResponse = rpcApi.call(conditionsRequest)
if (!response.isSuccessful || response.body() == null) {
return Result.failure(Exception("RPC 请求失败: ${response.code()} ${response.message()}"))
if (!conditionsResponse.isSuccessful || conditionsResponse.body() == null) {
return Result.failure(Exception("RPC 请求失败 (conditions): ${conditionsResponse.code()} ${conditionsResponse.message()}"))
}
val rpcResponse = response.body()!!
val conditionsRpcResponse = conditionsResponse.body()!!
// 检查错误
if (rpcResponse.error != null) {
return Result.failure(Exception("RPC 错误: ${rpcResponse.error.message}"))
if (conditionsRpcResponse.error != null) {
// 记录完整的错误信息,包括 code 和 data
val errorMsg = "RPC 错误 (code=${conditionsRpcResponse.error.code}): ${conditionsRpcResponse.error.message}, data=${conditionsRpcResponse.error.data}"
logger.error("查询市场条件(conditions)出现RPC错误: conditionId=$conditionId, $errorMsg")
logger.debug("RPC 请求详情: to=$conditionalTokensAddress, data=$conditionsData")
return Result.failure(Exception(errorMsg))
}
// 使用 Gson 解析 resultJsonElement
val hexResult = rpcResponse.result?.asString
val hexResult = conditionsRpcResponse.result?.asString
?: return Result.failure(Exception("RPC 响应格式错误: result 为空"))
// 解析 ABI 编码的返回结果
val (payoutDenominator, payouts) = EthereumUtils.decodeConditionResult(hexResult)
// 解析返回的 (outcomeSlotCount, payoutDenominator)
val cleanHex = hexResult.removePrefix("0x")
val outcomeSlotCountHex = cleanHex.substring(0, 64)
val payoutDenominatorHex = cleanHex.substring(64, 128)
val outcomeSlotCount = BigInteger(outcomeSlotCountHex, 16).toInt()
val payoutDenominator = BigInteger(payoutDenominatorHex, 16)
// 如果 outcomeSlotCount 为 0,说明市场尚未创建或不存在
if (outcomeSlotCount <= 0) {
logger.debug("市场尚未创建或不存在: conditionId=$conditionId, outcomeSlotCount=$outcomeSlotCount")
return Result.success(Pair(BigInteger.ZERO, emptyList()))
}
// 如果 payoutDenominator 为 0,说明市场尚未结算
if (payoutDenominator == BigInteger.ZERO) {
logger.debug("市场尚未结算: conditionId=$conditionId, payoutDenominator=$payoutDenominator")
return Result.success(Pair(BigInteger.ZERO, emptyList()))
}
// 2. 查询每个 outcome 的 payoutNumerators
val payouts = mutableListOf<BigInteger>()
for (i in 0 until outcomeSlotCount) {
val payoutNumeratorsFunctionSelector = EthereumUtils.getFunctionSelector("payoutNumerators(bytes32,uint256)")
val encodedIndex = EthereumUtils.encodeUint256(BigInteger.valueOf(i.toLong()))
val payoutNumeratorsData = payoutNumeratorsFunctionSelector + encodedConditionId + encodedIndex
val payoutRequest = JsonRpcRequest(
method = "eth_call",
params = listOf(
mapOf(
"to" to conditionalTokensAddress,
"data" to payoutNumeratorsData
),
"latest"
)
)
val payoutResponse = rpcApi.call(payoutRequest)
if (!payoutResponse.isSuccessful || payoutResponse.body() == null) {
logger.warn("查询 payoutNumerators 失败: index=$i")
continue
}
val payoutRpcResponse = payoutResponse.body()!!
if (payoutRpcResponse.error != null) {
logger.warn("查询 payoutNumerators 错误: index=$i, error=${payoutRpcResponse.error.message}")
continue
}
val payoutHex = payoutRpcResponse.result?.asString ?: "0x0"
val payout = EthereumUtils.decodeUint256(payoutHex)
payouts.add(payout)
}
Result.success(Pair(payoutDenominator, payouts))
} catch (e: Exception) {
@@ -31,7 +31,8 @@ class MarketPriceService(
* 获取当前市场最新价
* 优先级:
* 1. 链上查询市场结算结果(如果已结算,返回 1.0 或 0.0)
* 2. CLOB API 查询订单簿价格(最准确,使用 bestBid)
* 2. CLOB API 查询订单簿价格(最准确,优先使用,使用 bestBid
* 3. Gamma Market API 查询市场价格(快速,作为备选)
*
* 价格会被截位到 4 位小数(向下截断,不四舍五入),用于显示和后续计算
*
@@ -48,15 +49,22 @@ class MarketPriceService(
return chainPrice.setScale(4, java.math.RoundingMode.DOWN)
}
// 2. 从 CLOB API 查询订单簿价格(最准确)
// 2. 从 CLOB API 查询订单簿价格(最准确,优先使用
val orderbookPrice = getPriceFromClobOrderbook(marketId, outcomeIndex)
if (orderbookPrice != null) {
// 截位到 4 位小数(向下截断,不四舍五入)
return orderbookPrice.setScale(4, java.math.RoundingMode.DOWN)
}
// 3. 从 Gamma Market API 查询市场价格(作为备选)
val marketPrice = getPriceFromGammaMarket(marketId, outcomeIndex)
if (marketPrice != null) {
// 截位到 4 位小数(向下截断,不四舍五入)
return marketPrice.setScale(4, java.math.RoundingMode.DOWN)
}
// 如果所有数据源都失败,抛出异常
val errorMsg = "无法获取市场价格: marketId=$marketId, outcomeIndex=$outcomeIndex (链上查询订单簿查询均失败)"
val errorMsg = "无法获取市场价格: marketId=$marketId, outcomeIndex=$outcomeIndex (链上查询订单簿查询和 Market API 均失败)"
logger.error(errorMsg)
throw IllegalStateException(errorMsg)
}
@@ -106,6 +114,61 @@ class MarketPriceService(
}
}
/**
* 从 Gamma Market API 获取价格
* 使用 outcomePrices 字段,格式通常为 JSON 字符串 "[\"0.5\", \"0.5\"]"
* 如果查询失败或 outcomePrices 为空,返回 null
*/
private suspend fun getPriceFromGammaMarket(marketId: String, outcomeIndex: Int): BigDecimal? {
return try {
val gammaApi = retrofitFactory.createGammaApi()
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(marketId))
if (!marketResponse.isSuccessful || marketResponse.body() == null) {
logger.debug("Gamma Market API 查询失败: marketId=$marketId, code=${marketResponse.code()}")
return null
}
val markets = marketResponse.body()!!
if (markets.isEmpty()) {
logger.debug("Gamma Market API 未找到市场: marketId=$marketId")
return null
}
val market = markets.first()
// 尝试从 outcomePrices 字段获取价格
val outcomePricesStr = market.outcomePrices
if (outcomePricesStr.isNullOrBlank()) {
logger.debug("Market outcomePrices 为空: marketId=$marketId")
return null
}
// 解析 outcomePrices(通常是 JSON 数组字符串)
val outcomePrices = try {
// 移除首尾的方括号和引号,按逗号分割
val cleanStr = outcomePricesStr.trim().removeSurrounding("[", "]")
cleanStr.split(",").map {
it.trim().removeSurrounding("\"").toSafeBigDecimal()
}
} catch (e: Exception) {
logger.warn("解析 outcomePrices 失败: marketId=$marketId, outcomePrices=$outcomePricesStr, error=${e.message}")
null
}
if (outcomePrices != null && outcomeIndex < outcomePrices.size) {
val price = outcomePrices[outcomeIndex]
logger.debug("从 Gamma Market API 获取价格: marketId=$marketId, outcomeIndex=$outcomeIndex, price=$price")
return price
}
null
} catch (e: Exception) {
logger.debug("Gamma Market API 查询异常: marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}")
null
}
}
/**
* 从 CLOB API 查询订单簿价格
@@ -9,6 +9,7 @@ import com.wrbug.polymarketbot.util.toSafeBigDecimal
import org.slf4j.LoggerFactory
import com.wrbug.polymarketbot.service.common.PolymarketClobService
import com.wrbug.polymarketbot.service.accounts.AccountService
import com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository
import org.springframework.stereotype.Service
import java.math.BigDecimal
@@ -18,7 +19,8 @@ import java.math.BigDecimal
@Service
class CopyTradingFilterService(
private val clobService: PolymarketClobService,
private val accountService: AccountService
private val accountService: AccountService,
private val copyOrderTrackingRepository: CopyOrderTrackingRepository
) {
private val logger = LoggerFactory.getLogger(CopyTradingFilterService::class.java)
@@ -234,32 +236,46 @@ class CopyTradingFilterService(
// 检查最大仓位金额(如果配置了)
if (copyTrading.maxPositionValue != null) {
// 计算该市场的当前仓位总价值(累加该市场所有仓位的 currentValue
val currentPositionValue = marketPositions.sumOf { position ->
position.currentValue.toSafeBigDecimal()
}
// 比较数据库成本价(本地订单记录)和外部持仓市值(可能来自其他终端的操作),取最大值
val dbValue = copyOrderTrackingRepository.sumCurrentPositionValueByMarket(copyTrading.id!!, marketId) ?: BigDecimal.ZERO
val extValue = marketPositions.sumOf { it.currentValue.toSafeBigDecimal() }
val currentPositionValue = dbValue.max(extValue)
// 检查:该市场的当前仓位 + 跟单金额 <= 最大仓位金额
val totalValueAfterOrder = currentPositionValue.add(copyOrderAmount)
if (totalValueAfterOrder.gt(copyTrading.maxPositionValue)) {
return FilterResult.maxPositionValueFailed(
"超过最大仓位金额限制: 当前该市场仓位=${currentPositionValue} USDC, 跟单金额=${copyOrderAmount} USDC, 总计=${totalValueAfterOrder} USDC > 最大限制=${copyTrading.maxPositionValue} USDC"
"超过最大仓位金额限制: 当前该市场仓位(取最大值)=${currentPositionValue} USDC (DB=${dbValue}, Ext=${extValue}), 跟单金额=${copyOrderAmount} USDC, 总计=${totalValueAfterOrder} USDC > 最大限制=${copyTrading.maxPositionValue} USDC"
)
}
}
// 检查最大仓位数量(如果配置了)
if (copyTrading.maxPositionCount != null) {
// 计算该市场的当前仓位数量(该市场不同方向的仓位算不同仓位
val currentPositionCount = marketPositions.size
// 使用数据库中的订单记录计算活跃仓位数量(解决延迟问题
val dbCount = copyOrderTrackingRepository.countActivePositions(copyTrading.id!!)
// 检查:该市场的当前仓位数量 <= 最大仓位数量
// 注意:如果该市场已有仓位,跟单可能会增加新的仓位(不同方向)或增加现有仓位
// 为了简化,我们检查当前该市场的仓位数量是否已经达到或超过限制
if (currentPositionCount >= copyTrading.maxPositionCount) {
// 计算外部持仓中的唯一市场数量(防止遗漏非本项目创建的仓位)
val extCount = positions.currentPositions
.filter { it.accountId == copyTrading.accountId }
.map { it.marketId }
.distinct()
.size
val currentPositionCount = maxOf(dbCount, extCount)
// 检查:如果当前没有该市场的活跃仓位,且总仓位数量已达到限制,则不允许开新仓
// 判断当前市场是否已有活跃仓位(数据库或外部持仓)
val hasDbPosition = copyOrderTrackingRepository.existsByCopyTradingIdAndMarketIdAndRemainingQuantityGreaterThan(
copyTrading.id, marketId, BigDecimal.ZERO
)
val hasExtPosition = marketPositions.isNotEmpty()
val hasCurrentMarketPosition = hasDbPosition || hasExtPosition
if (!hasCurrentMarketPosition && currentPositionCount >= copyTrading.maxPositionCount) {
return FilterResult.maxPositionCountFailed(
"超过最大仓位数量限制: 当前该市场仓位数量=${currentPositionCount} >= 最大限制=${copyTrading.maxPositionCount}"
"超过最大仓位数量限制: 当前活跃仓位总数(取最大值)=${currentPositionCount} (DB=${dbCount}, Ext=${extCount}) >= 最大限制=${copyTrading.maxPositionCount}"
)
}
}
@@ -14,7 +14,7 @@ import org.springframework.stereotype.Service
/**
* 跟单监听服务(主服务)
* 管理所有Leader的交易监听
* 同时运行链上 WebSocket 监听和轮询监听(并行处理
* 使用链上 WebSocket 监听 Leader 的交易(实时,秒级延迟
* 同时监听跟单账户的卖出/赎回事件(通过链上 WebSocket
*/
@Service
@@ -22,7 +22,6 @@ class CopyTradingMonitorService(
private val copyTradingRepository: CopyTradingRepository,
private val leaderRepository: LeaderRepository,
private val accountRepository: AccountRepository,
private val pollingService: CopyTradingPollingService,
private val onChainWsService: OnChainWsService,
private val accountOnChainMonitorService: AccountOnChainMonitorService
) {
@@ -51,15 +50,14 @@ class CopyTradingMonitorService(
@PreDestroy
fun destroy() {
scope.cancel()
// 停止轮询和链上 WS 监听
pollingService.stop()
// 停止链上 WS 监听
onChainWsService.stop()
accountOnChainMonitorService.stop()
}
/**
* 启动监听
* 同时启动链上 WebSocket 监听和轮询监听(并行运行
* 启动链上 WebSocket 监听 Leader 的交易(实时,秒级延迟
* 同时启动跟单账户的链上 WebSocket 监听(用于检测卖出/赎回事件)
*/
suspend fun startMonitoring() {
@@ -82,13 +80,9 @@ class CopyTradingMonitorService(
accountRepository.findById(accountId).orElse(null)
}
// 4. 同时启动链上 WebSocket 监听和轮询监听(并行运行
// 链上 WS 监听 Leader 的交易(实时,秒级延迟)
// 4. 启动链上 WebSocket 监听 Leader 的交易(实时,秒级延迟
onChainWsService.start(leaders)
// 轮询监听 Leader 的交易(延迟,2秒间隔,作为备份)
pollingService.start(leaders)
// 5. 启动跟单账户的链上 WebSocket 监听(用于检测卖出/赎回事件)
accountOnChainMonitorService.start(accounts)
}
@@ -106,9 +100,8 @@ class CopyTradingMonitorService(
return
}
// 同时添加到链上 WS 监听和轮询监听(如果不在列表中才添加)
// 添加到链上 WS 监听(如果不在列表中才添加)
onChainWsService.addLeader(leader)
pollingService.addLeader(leader)
}
/**
@@ -124,7 +117,6 @@ class CopyTradingMonitorService(
// 没有启用的跟单配置了,移除监听
onChainWsService.removeLeader(leaderId)
pollingService.removeLeader(leaderId)
}
/**
@@ -139,7 +131,6 @@ class CopyTradingMonitorService(
if (copyTradings.isNotEmpty()) {
// 有启用的跟单配置,确保在监听列表中
onChainWsService.addLeader(leader)
pollingService.addLeader(leader)
// 更新账户监听(添加该配置关联的账户)
val accountIds = copyTradings.map { it.accountId }.distinct()
@@ -152,7 +143,6 @@ class CopyTradingMonitorService(
} else {
// 没有启用的跟单配置,移除监听
onChainWsService.removeLeader(leaderId)
pollingService.removeLeader(leaderId)
}
}
@@ -182,7 +172,6 @@ class CopyTradingMonitorService(
suspend fun restartMonitoring() {
// 停止所有监听
onChainWsService.stop()
pollingService.stop()
delay(1000) // 等待1秒
startMonitoring()
}
@@ -1,279 +0,0 @@
package com.wrbug.polymarketbot.service.copytrading.monitor
import com.wrbug.polymarketbot.api.TradeResponse
import com.wrbug.polymarketbot.api.UserActivityResponse
import com.wrbug.polymarketbot.entity.Leader
import com.wrbug.polymarketbot.repository.CopyTradingTemplateRepository
import com.wrbug.polymarketbot.util.RetrofitFactory
import jakarta.annotation.PreDestroy
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
import org.springframework.stereotype.Service
import retrofit2.Response
import java.util.concurrent.ConcurrentHashMap
/**
* 跟单轮询监听服务
* 通过定期轮询 Polymarket Data API 的 /activity 接口获取Leader的交易记录
* 使用 /activity 接口可以查询用户的链上活动,包括交易
*/
@Service
class CopyTradingPollingService(
private val copyOrderTrackingService: CopyOrderTrackingService,
private val retrofitFactory: RetrofitFactory,
private val templateRepository: CopyTradingTemplateRepository
) {
private val logger = LoggerFactory.getLogger(CopyTradingPollingService::class.java)
@Value("\${copy.trading.polling.interval:2000}")
private var pollingInterval: Long = 2000 // 轮询间隔(毫秒),默认2秒
@Value("\${copy.trading.polling.enabled:true}")
private var pollingEnabled: Boolean = true // 是否启用轮询
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
// 存储需要监听的LeaderleaderId -> Leader
private val monitoredLeaders = ConcurrentHashMap<Long, Leader>()
// 存储每个Leader已缓存的交易ID集合:leaderId -> Set<tradeId>
private val cachedTradeIds = ConcurrentHashMap<Long, MutableSet<String>>()
// 存储每个Leader是否首次轮询:leaderId -> isFirstPoll
private val isFirstPoll = ConcurrentHashMap<Long, Boolean>()
// 轮询任务
private var pollingJob: Job? = null
/**
* 启动轮询监听
*/
fun start(leaders: List<Leader>) {
if (!pollingEnabled) {
return
}
leaders.forEach { leader ->
addLeader(leader)
}
// 启动轮询任务
startPolling()
}
/**
* 添加Leader监听
*/
fun addLeader(leader: Leader) {
if (leader.id == null) {
logger.warn("Leader ID为空,跳过: ${leader.leaderAddress}")
return
}
val leaderId = leader.id
monitoredLeaders[leaderId] = leader
// 初始化缓存的交易ID集合
cachedTradeIds[leaderId] = mutableSetOf()
// 首次轮询标志,用于缓存数据而不处理
isFirstPoll[leaderId] = true
// 如果轮询任务没有运行,启动它
startPolling()
}
/**
* 移除Leader监听
*/
fun removeLeader(leaderId: Long) {
monitoredLeaders.remove(leaderId)
cachedTradeIds.remove(leaderId)
isFirstPoll.remove(leaderId)
// 如果没有需要监听的Leader了,停止轮询任务
if (monitoredLeaders.isEmpty()) {
stopPolling()
}
}
/**
* 停止所有监听
*/
fun stop() {
stopPolling()
monitoredLeaders.clear()
cachedTradeIds.clear()
isFirstPoll.clear()
}
/**
* 启动轮询任务
*/
private fun startPolling() {
if (pollingJob != null && pollingJob!!.isActive) {
return
}
if (monitoredLeaders.isEmpty()) {
return
}
pollingJob = scope.launch {
while (isActive) {
try {
// 轮询所有Leader的交易
pollAllLeaders()
// 等待下一次轮询
delay(pollingInterval)
} catch (e: Exception) {
logger.error("轮询任务异常", e)
delay(pollingInterval) // 异常后继续等待
}
}
}
}
/**
* 停止轮询任务
*/
private fun stopPolling() {
pollingJob?.cancel()
pollingJob = null
}
/**
* 轮询所有Leader的交易
*/
private suspend fun pollAllLeaders() {
val leaders = monitoredLeaders.values.toList()
if (leaders.isEmpty()) {
return
}
// 并发轮询所有Leader(限制并发数)
leaders.chunked(10).forEach { chunk ->
chunk.forEach { leader ->
try {
pollLeaderTrades(leader)
} catch (e: Exception) {
logger.error("轮询Leader交易失败: leaderId=${leader.id}, address=${leader.leaderAddress}", e)
}
}
// 每个chunk之间稍作延迟,避免API限流
delay(100)
}
}
/**
* 轮询单个Leader的交易
* 使用 Polymarket Data API 的 /activity 接口
* 通过 diff 分析增量数据,不使用 start 字段
*/
private suspend fun pollLeaderTrades(leader: Leader) {
if (leader.id == null) {
return
}
val leaderId = leader.id
val leaderAddress = leader.leaderAddress
try {
val firstPoll = isFirstPoll[leaderId] == true
val cachedIds = cachedTradeIds[leaderId] ?: mutableSetOf()
// 创建 Data API 客户端(不需要认证)
val dataApi = retrofitFactory.createDataApi()
// 查询用户活动(只查询交易类型,不使用 start 字段)
// 查询最近的数据(limit=100),通过 diff 找出增量
val response: Response<List<UserActivityResponse>> = dataApi.getUserActivity(
user = leaderAddress,
limit = 100, // 每次最多查询100条
offset = 0,
type = listOf("TRADE"), // 只查询交易类型
start = null, // 不使用 start 字段
sortBy = "TIMESTAMP",
sortDirection = "DESC" // 按时间戳降序,最新的在前
)
if (!response.isSuccessful || response.body() == null) {
logger.warn("获取Leader活动失败: leaderId=$leaderId, address=$leaderAddress, code=${response.code()}, message=${response.message()}")
return
}
val activities = response.body()!!
// 将 UserActivityResponse 转换为 TradeResponse
val allTrades = activities.mapNotNull { activity ->
// 只处理交易类型
if (activity.type != "TRADE" || activity.side == null || activity.price == null || activity.size == null) {
return@mapNotNull null
}
// 转换为 TradeResponse
TradeResponse(
id = activity.transactionHash ?: "${activity.timestamp}_${activity.conditionId}",
market = activity.conditionId,
side = activity.side, // BUY 或 SELL
price = activity.price.toString(),
size = activity.size.toString(),
timestamp = activity.timestamp.toString(), // 时间戳(秒)
user = activity.proxyWallet,
outcomeIndex = activity.outcomeIndex, // 结果索引(0=YES, 1=NO
outcome = activity.outcome // 结果名称
)
}
if (firstPoll) {
// 首次轮询:缓存所有查询到的交易ID,不处理
val tradeIds = allTrades.map { it.id }.toSet()
cachedIds.addAll(tradeIds)
cachedTradeIds[leaderId] = cachedIds
// 标记首次轮询完成
isFirstPoll[leaderId] = false
} else {
// 后续轮询:通过 diff 找出新增的交易
val newTradeIds = allTrades.map { it.id }.toSet()
val incrementalTradeIds = newTradeIds - cachedIds
if (incrementalTradeIds.isNotEmpty()) {
// 找出新增的交易
val incrementalTrades = allTrades.filter { it.id in incrementalTradeIds }
// 处理新增的交易
incrementalTrades.forEach { trade ->
try {
// 检查是否已处理(去重由processTrade内部处理)
copyOrderTrackingService.processTrade(leaderId, trade, "polling")
} catch (e: Exception) {
logger.error("处理交易失败: leaderId=$leaderId, tradeId=${trade.id}", e)
}
}
// 更新缓存:添加新增的交易ID
cachedIds.addAll(incrementalTradeIds)
cachedTradeIds[leaderId] = cachedIds
} else {
}
// 限制缓存大小,避免内存溢出(只保留最近1000条)
if (cachedIds.size > 1000) {
// 保留最新的1000条(由于查询是按时间戳降序,保留前1000条即可)
val sortedTradeIds = allTrades.map { it.id }.take(1000).toSet()
cachedTradeIds[leaderId] = sortedTradeIds.toMutableSet()
}
}
} catch (e: Exception) {
logger.error("轮询Leader交易异常: leaderId=$leaderId, address=$leaderAddress", e)
}
}
}
@@ -89,6 +89,8 @@ class OnChainWsService(
private suspend fun handleLeaderTransaction(leaderId: Long, txHash: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
val leader = monitoredLeaders[leaderId] ?: return
logger.debug("开始处理 Leader 交易: leaderId=$leaderId, txHash=$txHash, leaderAddress=${leader.leaderAddress}")
try {
// 获取交易 receipt
val receiptRequest = JsonRpcRequest(
@@ -98,11 +100,13 @@ class OnChainWsService(
val receiptResponse = rpcApi.call(receiptRequest)
if (!receiptResponse.isSuccessful || receiptResponse.body() == null) {
logger.warn("获取交易 receipt 失败: leaderId=$leaderId, txHash=$txHash, code=${receiptResponse.code()}")
return
}
val receiptRpcResponse = receiptResponse.body()!!
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null) {
logger.warn("交易 receipt 错误: leaderId=$leaderId, txHash=$txHash, error=${receiptRpcResponse.error}")
return
}
@@ -118,8 +122,12 @@ class OnChainWsService(
}
// 解析 receipt 中的 Transfer 日志
val logs = receiptJson.getAsJsonArray("logs") ?: return
val logs = receiptJson.getAsJsonArray("logs") ?: run {
logger.warn("交易 receipt 中没有日志: leaderId=$leaderId, txHash=$txHash")
return
}
val (erc20Transfers, erc1155Transfers) = OnChainWsUtils.parseReceiptTransfers(logs)
logger.debug("解析交易日志: leaderId=$leaderId, txHash=$txHash, erc20Transfers=${erc20Transfers.size}, erc1155Transfers=${erc1155Transfers.size}")
// 解析交易信息
val trade = OnChainWsUtils.parseTradeFromTransfers(
@@ -132,12 +140,15 @@ class OnChainWsService(
)
if (trade != null) {
logger.info("成功解析交易: leaderId=$leaderId, txHash=$txHash, side=${trade.side}, market=${trade.market}, size=${trade.size}")
// 调用 processTrade 处理交易
copyOrderTrackingService.processTrade(
leaderId = leaderId,
trade = trade,
source = "onchain-ws"
)
} else {
logger.warn("无法解析交易(返回 null: leaderId=$leaderId, txHash=$txHash, erc20Transfers=${erc20Transfers.size}, erc1155Transfers=${erc1155Transfers.size}")
}
} catch (e: Exception) {
logger.error("处理 Leader 交易失败: leaderId=$leaderId, txHash=$txHash, ${e.message}", e)
@@ -207,6 +207,7 @@ object OnChainWsUtils {
usdcRaw = usdcIn
} else {
// 无法判断交易方向
logger.debug("无法判断交易方向: txHash=$txHash, bestInId=$bestInId, bestInVal=$bestInVal, bestOutId=$bestOutId, bestOutVal=$bestOutVal, usdcOut=$usdcOut, usdcIn=$usdcIn")
return null
}
@@ -1,6 +1,7 @@
package com.wrbug.polymarketbot.service.copytrading.monitor
import com.google.gson.Gson
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.wrbug.polymarketbot.api.*
import com.wrbug.polymarketbot.service.system.RpcNodeService
@@ -19,6 +20,7 @@ import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
/**
* 统一的链上 WebSocket 服务
@@ -38,27 +40,8 @@ class UnifiedOnChainWsService(
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
// WebSocket 连接(唯一)
private var webSocket: WebSocket? = null
@Volatile
private var isConnected = false
// 订阅ID计数器(用于请求 ID)
private var requestIdCounter = 0
// 连接任务(确保只有一个连接任务在运行)
private var connectionJob: Job? = null
// 存储所有订阅:subscriptionId -> 订阅信息
private val subscriptions = ConcurrentHashMap<String, SubscriptionInfo>()
// 存储请求 ID 到订阅 ID 的映射:requestId -> subscriptionId
// 用于在收到订阅响应时,将 subscription ID 关联到对应的订阅
private val requestIdToSubscriptionId = ConcurrentHashMap<Int, String>()
// 存储 RPC subscriptionId 到订阅 ID 的映射:rpcSubscriptionId -> subscriptionId
// 用于在收到日志通知时,知道是哪个订阅
private val rpcSubscriptionIdToSubscriptionId = ConcurrentHashMap<String, String>()
// 存储所有地址的连接:address -> AddressWsConnection
private val addressConnections = ConcurrentHashMap<String, AddressWsConnection>()
/**
* 订阅信息
@@ -88,31 +71,24 @@ class UnifiedOnChainWsService(
callback: suspend (String, OkHttpClient, EthereumRpcApi) -> Unit
): Boolean {
try {
// 如果已经订阅,先取消
if (subscriptions.containsKey(subscriptionId)) {
unsubscribe(subscriptionId)
val lowerAddress = address.lowercase()
// 找到或创建该地址的连接
val connection = addressConnections.computeIfAbsent(lowerAddress) {
AddressWsConnection(it).apply { start() }
}
// 创建订阅信息
val subscription = SubscriptionInfo(
subscriptionId = subscriptionId,
address = address.lowercase(),
address = lowerAddress,
entityType = entityType,
entityId = entityId,
callback = callback
)
subscriptions[subscriptionId] = subscription
// 如果已连接,立即订阅
if (isConnected) {
scope.launch {
subscribeAddress(subscription)
}
} else {
// 如果未连接,启动连接
startConnection()
}
// 添加订阅
connection.addSubscription(subscription)
logger.info("订阅地址监听: subscriptionId=$subscriptionId, address=$address, entityType=$entityType, entityId=$entityId")
return true
@@ -126,434 +102,37 @@ class UnifiedOnChainWsService(
* 取消订阅
*/
fun unsubscribe(subscriptionId: String) {
val subscription = subscriptions.remove(subscriptionId)
if (subscription != null && isConnected) {
// 取消该订阅的所有 RPC 订阅
scope.launch {
// 查找该订阅的所有 RPC subscriptionId
val rpcSubscriptionIds = rpcSubscriptionIdToSubscriptionId.entries
.filter { it.value == subscriptionId }
.map { it.key }
// 遍历所有连接找到含有该订阅的连接
for (connection in addressConnections.values) {
if (connection.hasSubscription(subscriptionId)) {
connection.removeSubscription(subscriptionId)
for (rpcSubId in rpcSubscriptionIds) {
unsubscribeRpc(rpcSubId)
rpcSubscriptionIdToSubscriptionId.remove(rpcSubId)
}
}
logger.info("取消订阅: subscriptionId=$subscriptionId")
}
// 如果没有订阅了,停止连接
if (subscriptions.isEmpty()) {
stop()
}
}
/**
* 启动连接(如果还没有连接)
*/
private fun startConnection() {
// 如果没有订阅,不启动连接
if (subscriptions.isEmpty()) {
return
}
// 如果连接任务已经在运行,不重复启动
if (connectionJob != null && connectionJob!!.isActive) {
return
}
// 启动连接任务
connectionJob = scope.launch {
startConnectionLoop()
}
}
/**
* 启动连接循环
*/
private suspend fun startConnectionLoop() {
while (scope.isActive) {
try {
// 如果没有订阅,停止连接
if (subscriptions.isEmpty()) {
logger.info("没有订阅,停止连接")
stop()
break
// 如果该连接没有订阅了,停止并移除
if (connection.isSubscriptionsEmpty()) {
connection.stop()
addressConnections.remove(connection.address)
logger.info("连接已无订阅,关闭连接: address=${connection.address}")
}
// 如果已经连接,等待断开
if (isConnected && webSocket != null) {
waitForDisconnect()
continue
}
// 获取可用的 RPC 节点
val wsUrl = rpcNodeService.getWsUrl()
val httpUrl = rpcNodeService.getHttpUrl()
if (wsUrl.isBlank() || httpUrl.isBlank()) {
logger.warn("没有可用的 RPC 节点,等待重试...")
delay(reconnectDelay)
continue
}
logger.info("连接链上 WebSocket: $wsUrl (${subscriptions.size} 个订阅)")
// 创建 HTTP 客户端(用于 RPC 调用)
val httpClient = createHttpClient()
// 创建 RPC API 客户端
val rpcApi = retrofitFactory.createEthereumRpcApi(httpUrl)
// 连接 WebSocket
connectWebSocket(wsUrl, httpClient, rpcApi)
// 等待连接建立
waitForConnect()
// 如果连接成功,订阅所有地址
if (isConnected) {
logger.info("WebSocket 连接已建立,开始订阅")
for (subscription in subscriptions.values) {
subscribeAddress(subscription)
}
// 等待连接断开
waitForDisconnect()
}
// 连接断开后,如果没有订阅了,不再重连
if (subscriptions.isEmpty()) {
logger.info("没有订阅,停止重连")
break
}
// 等待后重连
logger.info("WebSocket 连接断开,等待 ${reconnectDelay}ms 后重连")
delay(reconnectDelay)
} catch (e: Exception) {
logger.error("连接异常: ${e.message}", e)
delay(reconnectDelay)
}
}
}
/**
* 创建 HTTP 客户端
*/
private fun createHttpClient(): OkHttpClient {
val proxy = getProxyConfig()
val builder = createClient()
if (proxy != null) {
builder.proxy(proxy)
}
return builder.build()
}
/**
* 连接 WebSocket
*/
private fun connectWebSocket(wsUrl: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
// 先关闭旧连接
webSocket?.close(1000, "重新连接")
webSocket = null
isConnected = false
val request = Request.Builder()
.url(wsUrl)
.build()
webSocket = httpClient.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
isConnected = true
logger.info("链上 WebSocket 连接成功")
}
override fun onMessage(webSocket: WebSocket, text: String) {
scope.launch {
handleMessage(text, httpClient, rpcApi)
}
}
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
scope.launch {
handleMessage(bytes.utf8(), httpClient, rpcApi)
}
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
isConnected = false
logger.warn("链上 WebSocket 连接关闭: code=$code, reason=$reason")
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
isConnected = false
logger.warn("链上 WebSocket 连接已关闭: code=$code, reason=$reason")
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
logger.error("链上 WebSocket 连接失败: ${t.message}", t)
isConnected = false
}
})
}
/**
* 等待连接建立
*/
private suspend fun waitForConnect() {
var waited = 0L
val timeout = 15000L // 15秒超时
while (!isConnected && waited < timeout) {
delay(100)
waited += 100
}
if (!isConnected) {
logger.warn("WebSocket 连接超时,等待重连")
}
}
/**
* 等待连接断开
*/
private suspend fun waitForDisconnect() {
while (isConnected && scope.isActive) {
delay(1000)
}
}
/**
* 订阅地址(为每个地址订阅 6 个事件)
*/
private suspend fun subscribeAddress(subscription: SubscriptionInfo) {
if (webSocket == null || !isConnected) {
return
}
val address = subscription.address
val walletTopic = OnChainWsUtils.addressToTopic32(address)
val subscriptionId = subscription.subscriptionId
try {
// 订阅 USDC Transfer (from wallet)
subscribeLogs(OnChainWsUtils.USDC_CONTRACT, listOf(OnChainWsUtils.ERC20_TRANSFER_TOPIC, walletTopic), subscriptionId)
// 订阅 USDC Transfer (to wallet)
subscribeLogs(OnChainWsUtils.USDC_CONTRACT, listOf(OnChainWsUtils.ERC20_TRANSFER_TOPIC, null, walletTopic), subscriptionId)
// 订阅 ERC1155 TransferSingle (from wallet)
subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_SINGLE_TOPIC, null, walletTopic), subscriptionId)
// 订阅 ERC1155 TransferSingle (to wallet)
subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_SINGLE_TOPIC, null, null, walletTopic), subscriptionId)
// 订阅 ERC1155 TransferBatch (from wallet)
subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_BATCH_TOPIC, null, walletTopic), subscriptionId)
// 订阅 ERC1155 TransferBatch (to wallet)
subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_BATCH_TOPIC, null, null, walletTopic), subscriptionId)
logger.debug("已订阅地址: subscriptionId=$subscriptionId, address=$address")
} catch (e: Exception) {
logger.error("订阅地址失败: subscriptionId=$subscriptionId, address=$address, error=${e.message}", e)
}
}
/**
* 订阅日志
*/
private fun subscribeLogs(address: String, topics: List<String?>, subscriptionId: String) {
val ws = webSocket ?: return
val params = mapOf(
"address" to address.lowercase(),
"topics" to topics.filterNotNull()
)
val requestId = ++requestIdCounter
requestIdToSubscriptionId[requestId] = subscriptionId
val request = mapOf(
"jsonrpc" to "2.0",
"id" to requestId,
"method" to "eth_subscribe",
"params" to listOf("logs", params)
)
val message = gson.toJson(request)
ws.send(message)
}
/**
* 取消 RPC 订阅
*/
private fun unsubscribeRpc(rpcSubscriptionId: String) {
val ws = webSocket ?: return
val requestId = ++requestIdCounter
val request = mapOf(
"jsonrpc" to "2.0",
"id" to requestId,
"method" to "eth_unsubscribe",
"params" to listOf(rpcSubscriptionId)
)
val message = gson.toJson(request)
ws.send(message)
}
/**
* 处理 WebSocket 消息
*/
private suspend fun handleMessage(text: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
try {
val message = gson.fromJson(text, JsonObject::class.java)
// 处理订阅响应
if (message.has("result") && message.has("id")) {
val requestId = message.get("id")?.asInt
val rpcSubscriptionId = message.get("result")?.asString
if (requestId != null && rpcSubscriptionId != null) {
val subscriptionId = requestIdToSubscriptionId.remove(requestId)
if (subscriptionId != null) {
// 保存 RPC subscriptionId 到订阅的映射
rpcSubscriptionIdToSubscriptionId[rpcSubscriptionId] = subscriptionId
logger.debug("订阅成功: subscriptionId=$subscriptionId, rpcSubscriptionId=$rpcSubscriptionId")
}
}
logger.info("取消订阅: subscriptionId=$subscriptionId")
return
}
// 处理日志通知
if (message.has("params")) {
val params = message.getAsJsonObject("params")
val subscriptionIdParam = params.get("subscription")?.asString
val result = params.getAsJsonObject("result")
if (result != null) {
val txHash = result.get("transactionHash")?.asString
if (txHash != null && subscriptionIdParam != null) {
// 根据 RPC subscriptionId 找到对应的订阅
val subscriptionId = rpcSubscriptionIdToSubscriptionId[subscriptionIdParam]
if (subscriptionId != null) {
// 处理交易,分发给对应的订阅者
processTransactionForSubscription(txHash, subscriptionId, httpClient, rpcApi)
} else {
// 如果没有找到订阅,可能是新订阅还未建立映射,尝试处理所有订阅
processTransaction(txHash, httpClient, rpcApi)
}
}
}
}
} catch (e: Exception) {
logger.error("处理 WebSocket 消息失败: ${e.message}", e)
}
}
/**
* 处理交易(为特定订阅)
* 直接调用订阅的回调
*/
private suspend fun processTransactionForSubscription(
txHash: String,
subscriptionId: String,
httpClient: OkHttpClient,
rpcApi: EthereumRpcApi
) {
val subscription = subscriptions[subscriptionId] ?: return
try {
subscription.callback(txHash, httpClient, rpcApi)
} catch (e: Exception) {
logger.error("调用订阅回调失败: subscriptionId=$subscriptionId, txHash=$txHash, error=${e.message}", e)
}
}
/**
* 处理交易(为所有订阅,用于兼容)
* 解析交易中的 Transfer 事件,分发给所有订阅者
*/
private suspend fun processTransaction(txHash: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
try {
// 获取交易 receipt
val receiptRequest = JsonRpcRequest(
method = "eth_getTransactionReceipt",
params = listOf(txHash)
)
val receiptResponse = rpcApi.call(receiptRequest)
if (!receiptResponse.isSuccessful || receiptResponse.body() == null) {
return
}
val receiptRpcResponse = receiptResponse.body()!!
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null) {
return
}
// 使用 Gson 解析 receipt JSON
val receiptJson = receiptRpcResponse.result.asJsonObject
// 解析 receipt 中的 Transfer 日志
val logs = receiptJson.getAsJsonArray("logs") ?: return
val (erc20Transfers, erc1155Transfers) = OnChainWsUtils.parseReceiptTransfers(logs)
// 为每个订阅检查是否匹配,如果匹配则调用回调
for (subscription in subscriptions.values) {
val address = subscription.address
// 检查该地址是否参与了交易(通过检查 Transfer 日志)
val isInvolved = erc20Transfers.any {
it.from.lowercase() == address || it.to.lowercase() == address
} || erc1155Transfers.any {
it.from.lowercase() == address || it.to.lowercase() == address
}
if (isInvolved) {
// 该地址参与了交易,调用回调
try {
subscription.callback(txHash, httpClient, rpcApi)
} catch (e: Exception) {
logger.error("调用订阅回调失败: subscriptionId=${subscription.subscriptionId}, txHash=$txHash, error=${e.message}", e)
}
}
}
} catch (e: Exception) {
logger.error("处理交易失败: txHash=$txHash, ${e.message}", e)
}
}
/**
* 停止连接
* 停止所有服务
*/
fun stop() {
connectionJob?.cancel()
connectionJob = null
// 关闭 WebSocket 连接
webSocket?.close(1000, "停止监听")
webSocket = null
isConnected = false
// 清空订阅信息
subscriptions.clear()
requestIdToSubscriptionId.clear()
rpcSubscriptionIdToSubscriptionId.clear()
for (connection in addressConnections.values) {
connection.stop()
}
addressConnections.clear()
}
@PostConstruct
fun init() {
// 服务启动时不自动连接,等待有订阅时再连接
logger.info("统一链上 WebSocket 服务已初始化")
logger.info("统一链上 WebSocket 服务已初始化 (独立连接模式)")
}
@PreDestroy
@@ -561,5 +140,295 @@ class UnifiedOnChainWsService(
stop()
scope.cancel()
}
/**
* 单个地址的 WebSocket 连接管理
*/
inner class AddressWsConnection(val address: String) {
private var webSocket: WebSocket? = null
@Volatile
private var isConnected = false
// 订阅ID计数器(用于请求 ID)
private var requestIdCounter = AtomicInteger(0)
// 连接任务
private var connectionJob: Job? = null
// 该连接下的所有订阅:subscriptionId -> SubscriptionInfo
// 理论上一个地址可能被多个业务订阅(如:既是被跟单者又是普通监控),虽然业务上通常只有一个
private val subscriptions = ConcurrentHashMap<String, SubscriptionInfo>()
// 存储请求 ID 到订阅 ID 的映射:requestId -> subscriptionId
private val requestIdToSubscriptionId = ConcurrentHashMap<Int, String>()
// 存储 RPC subscriptionId 到订阅 ID 的映射:rpcSubscriptionId -> subscriptionId
private val rpcSubscriptionIdToSubscriptionId = ConcurrentHashMap<String, String>()
fun start() {
if (connectionJob != null && connectionJob!!.isActive) return
connectionJob = scope.launch {
startConnectionLoop()
}
}
fun stop() {
connectionJob?.cancel()
connectionJob = null
webSocket?.close(1000, "停止监听")
webSocket = null
isConnected = false
subscriptions.clear()
requestIdToSubscriptionId.clear()
rpcSubscriptionIdToSubscriptionId.clear()
}
fun addSubscription(subscription: SubscriptionInfo) {
// 如果已经存在,先移除旧的
removeSubscription(subscription.subscriptionId)
subscriptions[subscription.subscriptionId] = subscription
// 如果已经连接,立即发送链上订阅请求
if (isConnected) {
scope.launch {
subscribeAddressOnChain(subscription)
}
}
}
fun removeSubscription(subscriptionId: String) {
subscriptions.remove(subscriptionId)
// 不需要显式发送 eth_unsubscribe,因为连接是 per-address 的,
// 只要只要连接还在,就保持该地址相关的所有 logs 订阅。
// 只有当所有 subscription 都移除了,连接才会关闭。
}
fun hasSubscription(subscriptionId: String): Boolean {
return subscriptions.containsKey(subscriptionId)
}
fun isSubscriptionsEmpty(): Boolean {
return subscriptions.isEmpty()
}
private suspend fun startConnectionLoop() {
while (scope.isActive) {
try {
if (subscriptions.isEmpty()) {
// 如果启动循环时还没订阅(不太可能,通常是先 addSubscription 再 start,或者是 start 后 addSubscription
// 或者订阅被清空了,外部应当掉 stop,但这里作为防守
delay(1000)
continue
}
if (isConnected && webSocket != null) {
waitForDisconnect()
continue
}
// 获取可用的 RPC 节点
val wsUrl = rpcNodeService.getWsUrl()
val httpUrl = rpcNodeService.getHttpUrl()
logger.info("[$address] 连接链上 WebSocket: $wsUrl")
val httpClient = createHttpClient()
val rpcApi = retrofitFactory.createEthereumRpcApi(httpUrl)
connectWebSocket(wsUrl, httpClient, rpcApi)
waitForConnect()
if (isConnected) {
logger.info("[$address] WebSocket 连接已建立,开始注册订阅")
// 重新为所有订阅注册链上监听
for (subscription in subscriptions.values) {
subscribeAddressOnChain(subscription)
}
waitForDisconnect()
}
logger.info("[$address] WebSocket 连接断开,等待 ${reconnectDelay}ms 后重连")
delay(reconnectDelay)
} catch (e: Exception) {
logger.error("[$address] 连接异常: ${e.message}", e)
delay(reconnectDelay)
}
}
}
private fun connectWebSocket(wsUrl: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
webSocket?.close(1000, "重新连接")
webSocket = null
isConnected = false
val request = Request.Builder().url(wsUrl).build()
webSocket = httpClient.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
isConnected = true
logger.info("[$address] 链上 WebSocket 连接成功")
}
override fun onMessage(webSocket: WebSocket, text: String) {
scope.launch { handleMessage(text, httpClient, rpcApi) }
}
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
scope.launch { handleMessage(bytes.utf8(), httpClient, rpcApi) }
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
isConnected = false
logger.warn("[$address] 链上 WebSocket 连接关闭: code=$code, reason=$reason")
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
isConnected = false
logger.warn("[$address] 链上 WebSocket 连接已关闭: code=$code, reason=$reason")
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
logger.error("[$address] 链上 WebSocket 连接失败: ${t.message}", t)
isConnected = false
}
})
}
private suspend fun subscribeAddressOnChain(subscription: SubscriptionInfo) {
if (webSocket == null || !isConnected) return
val walletTopic = OnChainWsUtils.addressToTopic32(address)
val subId = subscription.subscriptionId
try {
// 订阅该地址相关的所有事件
// USDC Transfer (from/to)
subscribeLogs(OnChainWsUtils.USDC_CONTRACT, listOf(OnChainWsUtils.ERC20_TRANSFER_TOPIC, walletTopic), subId)
subscribeLogs(OnChainWsUtils.USDC_CONTRACT, listOf(OnChainWsUtils.ERC20_TRANSFER_TOPIC, null, walletTopic), subId)
// ERC1155 TransferSingle (from/to)
subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_SINGLE_TOPIC, null, walletTopic), subId)
subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_SINGLE_TOPIC, null, null, walletTopic), subId)
// ERC1155 TransferBatch (from/to)
subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_BATCH_TOPIC, null, walletTopic), subId)
subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_BATCH_TOPIC, null, null, walletTopic), subId)
logger.debug("[$address] 已发送链上订阅请求: subscriptionId=$subId")
} catch (e: Exception) {
logger.error("[$address] 发送链上订阅请求失败: error=${e.message}", e)
}
}
private fun subscribeLogs(contractAddress: String, topics: List<String?>, subscriptionId: String) {
val ws = webSocket ?: return
val topicsArray = gson.toJsonTree(topics).asJsonArray
val logParams = JsonObject()
logParams.addProperty("address", contractAddress.lowercase())
logParams.add("topics", topicsArray)
val requestId = requestIdCounter.incrementAndGet()
requestIdToSubscriptionId[requestId] = subscriptionId
val request = JsonObject()
request.addProperty("jsonrpc", "2.0")
request.addProperty("id", requestId)
request.addProperty("method", "eth_subscribe")
val paramsArray = JsonArray()
paramsArray.add("logs")
paramsArray.add(logParams)
request.add("params", paramsArray)
ws.send(gson.toJson(request))
}
private suspend fun handleMessage(text: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
try {
val message = gson.fromJson(text, JsonObject::class.java)
// 1. 处理订阅响应 (eth_subscribe response)
if (message.has("result") && message.has("id")) {
val requestId = message.get("id")?.asInt
val rpcSubscriptionId = message.get("result")?.asString
if (requestId != null && rpcSubscriptionId != null) {
val subId = requestIdToSubscriptionId.remove(requestId)
if (subId != null) {
rpcSubscriptionIdToSubscriptionId[rpcSubscriptionId] = subId
logger.debug("[$address] 链上订阅成功: mapped connection rpcSubId=$rpcSubscriptionId to localSubId=$subId")
}
}
return
}
// 2. 处理日志通知 (eth_subscription)
val method = message.get("method")?.asString
if (method == "eth_subscription") {
val params = message.getAsJsonObject("params") ?: return
val rpcSubParam = params.get("subscription")?.asString
val result = params.getAsJsonObject("result") ?: return
val txHash = result.get("transactionHash")?.asString
if (txHash != null && rpcSubParam != null) {
// 找到触发此通知的本地订阅 ID
// 因为我们在这个连接里只订阅了 this.address,所以理论上所有通知都跟这个 address 有关
// 但我们需要找到对应的 callback
val localSubId = rpcSubscriptionIdToSubscriptionId[rpcSubParam]
if (localSubId != null) {
val subscription = subscriptions[localSubId]
if (subscription != null) {
logger.info("[$address] 收到交易通知: txHash=$txHash, subId=$localSubId")
runCatching {
subscription.callback(txHash, httpClient, rpcApi)
}.onFailure { e ->
logger.error("[$address] 回调执行失败: ${e.message}", e)
}
}
} else {
// 找不到具体是哪个订阅请求触发的(可能是重启后之前的订阅残留?或者映射丢失?)
// 在单地址单连接模式下,只要是这个 connection 收到的,肯定是关于这个 address 的
// 我们可以尝试通知所有订阅者(通常一个地址只有一个订阅者,除非此地址既是Leader又是User)
logger.warn("[$address] 未找到映射的订阅ID: rpcSubId=$rpcSubParam. 广播给所有订阅者.")
subscriptions.values.forEach { sub ->
runCatching {
sub.callback(txHash, httpClient, rpcApi)
}.onFailure { e ->
logger.error("[$address] 广播回调执行失败: ${e.message}", e)
}
}
}
}
}
} catch (e: Exception) {
logger.error("[$address] 处理消息失败: ${e.message}", e)
}
}
private suspend fun waitForConnect() {
var waited = 0L
val timeout = 15000L
while (!isConnected && waited < timeout) {
delay(100)
waited += 100
}
if (!isConnected) logger.warn("[$address] WebSocket 连接超时")
}
private suspend fun waitForDisconnect() {
while (isConnected && scope.isActive) {
delay(1000)
}
}
private fun createHttpClient(): OkHttpClient {
val proxy = getProxyConfig()
val builder = createClient()
if (proxy != null) builder.proxy(proxy)
return builder.build()
}
}
}
@@ -25,6 +25,7 @@ import com.wrbug.polymarketbot.util.CryptoUtils
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
import kotlin.math.max
/**
* 订单跟踪服务
@@ -54,16 +55,16 @@ open class CopyOrderTrackingService(
// 协程作用域(用于异步发送通知)
private val notificationScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// 使用 Mutex 保证线程安全(按交易ID锁定)
private val tradeMutexMap = ConcurrentHashMap<String, Mutex>()
// 订单创建重试配置
companion object {
private const val MAX_RETRY_ATTEMPTS = 2 // 最多重试次数(首次 + 1次重试)
private const val RETRY_DELAY_MS = 3000L // 重试前等待时间(毫秒,3秒)
}
/**
* 获取或创建 Mutex按交易ID
*/
@@ -121,85 +122,85 @@ open class CopyOrderTrackingService(
suspend fun processTrade(leaderId: Long, trade: TradeResponse, source: String): Result<Unit> {
// 获取该交易的 Mutex(按交易ID锁定,不同交易可以并行处理)
val mutex = getMutex(leaderId, trade.id)
return mutex.withLock {
try {
// 1. 检查是否已处理(去重,包括失败状态)
val existingProcessed = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
// 1. 检查是否已处理(去重,包括失败状态)
val existingProcessed = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
if (existingProcessed != null) {
if (existingProcessed.status == "FAILED") {
if (existingProcessed != null) {
if (existingProcessed.status == "FAILED") {
return@withLock Result.success(Unit)
}
return@withLock Result.success(Unit)
}
// 2. 处理交易逻辑
val result = when (trade.side.uppercase()) {
"BUY" -> processBuyTrade(leaderId, trade)
"SELL" -> processSellTrade(leaderId, trade)
else -> {
logger.warn("未知的交易方向: ${trade.side}")
Result.failure(IllegalArgumentException("未知的交易方向: ${trade.side}"))
}
}
if (result.isFailure) {
logger.error(
"处理交易失败: leaderId=$leaderId, tradeId=${trade.id}, side=${trade.side}",
result.exceptionOrNull()
)
return@withLock result
}
// 3. 标记为已处理(成功状态)
// 由于使用了 Mutex,这里理论上不会出现并发冲突,但保留异常处理作为兜底
try {
val processed = ProcessedTrade(
leaderId = leaderId,
leaderTradeId = trade.id,
tradeType = trade.side.uppercase(),
source = source,
status = "SUCCESS",
processedAt = System.currentTimeMillis()
)
processedTradeRepository.save(processed)
} catch (e: Exception) {
// 检查是否是唯一键冲突异常(理论上不会发生,但保留作为兜底)
if (isUniqueConstraintViolation(e)) {
val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
if (existing != null) {
if (existing.status == "FAILED") {
logger.debug("交易已标记为失败,跳过处理: leaderId=$leaderId, tradeId=${trade.id}")
return@withLock Result.success(Unit)
}
logger.debug("交易已处理(并发检测): leaderId=$leaderId, tradeId=${trade.id}, status=${existing.status}")
return@withLock Result.success(Unit)
} else {
// 如果检查不到,可能是事务隔离级别问题,等待一下再查询
delay(100)
val existingAfterDelay =
processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
if (existingAfterDelay != null) {
logger.debug("延迟查询到记录(并发检测): leaderId=$leaderId, tradeId=${trade.id}, status=${existingAfterDelay.status}")
return@withLock Result.success(Unit)
}
logger.warn(
"保存ProcessedTrade时发生唯一约束冲突,但查询不到记录: leaderId=$leaderId, tradeId=${trade.id}",
e
)
return@withLock Result.success(Unit)
}
} else {
// 其他类型的异常,重新抛出
throw e
return@withLock Result.success(Unit)
}
}
Result.success(Unit)
} catch (e: Exception) {
logger.error("处理交易异常: leaderId=$leaderId, tradeId=${trade.id}", e)
Result.failure(e)
// 2. 处理交易逻辑
val result = when (trade.side.uppercase()) {
"BUY" -> processBuyTrade(leaderId, trade)
"SELL" -> processSellTrade(leaderId, trade)
else -> {
logger.warn("未知的交易方向: ${trade.side}")
Result.failure(IllegalArgumentException("未知的交易方向: ${trade.side}"))
}
}
if (result.isFailure) {
logger.error(
"处理交易失败: leaderId=$leaderId, tradeId=${trade.id}, side=${trade.side}",
result.exceptionOrNull()
)
return@withLock result
}
// 3. 标记为已处理(成功状态)
// 由于使用了 Mutex,这里理论上不会出现并发冲突,但保留异常处理作为兜底
try {
val processed = ProcessedTrade(
leaderId = leaderId,
leaderTradeId = trade.id,
tradeType = trade.side.uppercase(),
source = source,
status = "SUCCESS",
processedAt = System.currentTimeMillis()
)
processedTradeRepository.save(processed)
} catch (e: Exception) {
// 检查是否是唯一键冲突异常(理论上不会发生,但保留作为兜底)
if (isUniqueConstraintViolation(e)) {
val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
if (existing != null) {
if (existing.status == "FAILED") {
logger.debug("交易已标记为失败,跳过处理: leaderId=$leaderId, tradeId=${trade.id}")
return@withLock Result.success(Unit)
}
logger.debug("交易已处理(并发检测): leaderId=$leaderId, tradeId=${trade.id}, status=${existing.status}")
return@withLock Result.success(Unit)
} else {
// 如果检查不到,可能是事务隔离级别问题,等待一下再查询
delay(100)
val existingAfterDelay =
processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
if (existingAfterDelay != null) {
logger.debug("延迟查询到记录(并发检测): leaderId=$leaderId, tradeId=${trade.id}, status=${existingAfterDelay.status}")
return@withLock Result.success(Unit)
}
logger.warn(
"保存ProcessedTrade时发生唯一约束冲突,但查询不到记录: leaderId=$leaderId, tradeId=${trade.id}",
e
)
return@withLock Result.success(Unit)
}
} else {
// 其他类型的异常,重新抛出
throw e
}
}
Result.success(Unit)
} catch (e: Exception) {
logger.error("处理交易异常: leaderId=$leaderId, tradeId=${trade.id}", e)
Result.failure(e)
}
}
}
@@ -253,13 +254,13 @@ open class CopyOrderTrackingService(
// 先计算跟单金额(用于仓位检查)
// 注意:这里先计算金额,即使后续被过滤也会记录
val tradePrice = trade.price.toSafeBigDecimal()
val buyQuantity = try {
var buyQuantity = try {
calculateBuyQuantity(trade, copyTrading)
} catch (e: Exception) {
logger.warn("计算买入数量失败: ${e.message}", e)
continue
}
// 计算跟单金额(USDC)= 买入数量 × 价格
val copyOrderAmount = buyQuantity.multi(tradePrice)
@@ -268,8 +269,8 @@ open class CopyOrderTrackingService(
// 传入跟单金额和市场ID,用于仓位检查(按市场检查仓位)
// 订单簿只请求一次,返回给后续逻辑使用
val filterResult = filterService.checkFilters(
copyTrading,
tokenId,
copyTrading,
tokenId,
tradePrice = tradePrice,
copyOrderAmount = copyOrderAmount,
marketId = trade.market
@@ -369,53 +370,72 @@ open class CopyOrderTrackingService(
// 买入数量已在过滤检查前计算,这里直接使用
// 如果数量为0或负数,跳过
if (buyQuantity.lte(BigDecimal.ZERO)) {
logger.warn("计算的买入数量为0或负数,跳过: copyTradingId=${copyTrading.id}, tradeId=${trade.id}")
logger.warn("计算得到的买入数量为0,跳过跟单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}")
continue
}
if (buyQuantity.lt(BigDecimal.ONE)) {
logger.warn("计算得到的买入数量小于1,自动调整为1 (Polymarket 最小下单数量): copyTradingId=${copyTrading.id}, tradeId=${trade.id}, originalQuantity=$buyQuantity")
buyQuantity = BigDecimal.ONE
}
// 验证订单数量限制(仅比例模式)
var finalBuyQuantity = buyQuantity
if (copyTrading.copyMode == "RATIO") {
val tradePrice = trade.price.toSafeBigDecimal()
val rawOrderAmount = buyQuantity.multi(tradePrice)
// 对按比例计算的金额进行向上取整处理(确保满足最小限制)
// 向上取整到 2 位小数(USDC 精度)
val roundedOrderAmount = rawOrderAmount.setScale(2, java.math.RoundingMode.CEILING)
// 如果原始金额或向上取整后的金额小于最小限制,调整 buyQuantity 以满足最小限制
// 这样可以避免精度问题导致订单被错误地跳过
if (roundedOrderAmount.lt(copyTrading.minOrderSize)) {
logger.debug("订单金额(向上取整后)低于最小限制,调整数量以满足最小限制: copyTradingId=${copyTrading.id}, rawAmount=$rawOrderAmount, roundedAmount=$roundedOrderAmount, min=${copyTrading.minOrderSize}")
// 计算满足最小限制所需的数量(向上取整)
val minQuantity = copyTrading.minOrderSize.div(tradePrice, 8, java.math.RoundingMode.CEILING)
val minQuantity =
copyTrading.minOrderSize.div(tradePrice, 8, java.math.RoundingMode.CEILING)
if (minQuantity.lte(BigDecimal.ZERO)) {
logger.warn("计算出的最小数量为0或负数,跳过: copyTradingId=${copyTrading.id}")
continue
}
// 使用调整后的数量
finalBuyQuantity = minQuantity
logger.debug("已调整数量以满足最小限制: copyTradingId=${copyTrading.id}, originalQuantity=$buyQuantity, adjustedQuantity=$finalBuyQuantity, adjustedAmount=${finalBuyQuantity.multi(tradePrice)}")
logger.debug(
"已调整数量以满足最小限制: copyTradingId=${copyTrading.id}, originalQuantity=$buyQuantity, adjustedQuantity=$finalBuyQuantity, adjustedAmount=${
finalBuyQuantity.multi(
tradePrice
)
}"
)
} else if (rawOrderAmount.lt(copyTrading.minOrderSize)) {
// 原始金额小于最小限制,但向上取整后满足,调整数量以满足最小限制
logger.debug("订单金额(精度处理后)低于最小限制,调整数量以满足最小限制: copyTradingId=${copyTrading.id}, rawAmount=$rawOrderAmount, min=${copyTrading.minOrderSize}")
// 计算满足最小限制所需的数量(向上取整)
val minQuantity = copyTrading.minOrderSize.div(tradePrice, 8, java.math.RoundingMode.CEILING)
val minQuantity =
copyTrading.minOrderSize.div(tradePrice, 8, java.math.RoundingMode.CEILING)
if (minQuantity.lte(BigDecimal.ZERO)) {
logger.warn("计算出的最小数量为0或负数,跳过: copyTradingId=${copyTrading.id}")
continue
}
// 使用调整后的数量
finalBuyQuantity = minQuantity
logger.debug("已调整数量以满足最小限制: copyTradingId=${copyTrading.id}, originalQuantity=$buyQuantity, adjustedQuantity=$finalBuyQuantity, adjustedAmount=${finalBuyQuantity.multi(tradePrice)}")
logger.debug(
"已调整数量以满足最小限制: copyTradingId=${copyTrading.id}, originalQuantity=$buyQuantity, adjustedQuantity=$finalBuyQuantity, adjustedAmount=${
finalBuyQuantity.multi(
tradePrice
)
}"
)
}
// 检查最大限制(使用调整后的数量)
val finalOrderAmount = finalBuyQuantity.multi(tradePrice)
if (finalOrderAmount.gt(copyTrading.maxOrderSize)) {
logger.warn("订单金额超过最大限制,调整数量: copyTradingId=${copyTrading.id}, amount=$finalOrderAmount, max=${copyTrading.maxOrderSize}")
// 调整数量到最大值
val adjustedQuantity = copyTrading.maxOrderSize.div(tradePrice, 8, java.math.RoundingMode.DOWN)
val adjustedQuantity =
copyTrading.maxOrderSize.div(tradePrice, 8, java.math.RoundingMode.DOWN)
if (adjustedQuantity.lte(BigDecimal.ZERO)) {
logger.warn("调整后的数量为0或负数,跳过: copyTradingId=${copyTrading.id}")
continue
@@ -440,7 +460,7 @@ open class CopyOrderTrackingService(
// 计算价格(应用价格容忍度)
val buyPrice = calculateAdjustedPrice(trade.price.toSafeBigDecimal(), copyTrading, isBuy = true)
logger.debug("计算价格结果:$buyPrice")
// 在创建订单前,检查订单簿中是否有可匹配的订单(避免 FAK 订单失败)
// 如果过滤检查时已经获取了订单簿,直接使用;否则重新获取
val orderbookForCheck = orderbook ?: run {
@@ -451,21 +471,21 @@ open class CopyOrderTrackingService(
null
}
}
// 检查是否有可匹配的卖单(asks)
if (orderbookForCheck != null) {
val bestAsk = orderbookForCheck.asks
.mapNotNull { it.price.toSafeBigDecimal() }
.minOrNull()
if (bestAsk == null) {
logger.warn("订单簿中没有卖单,跳过创建订单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}")
continue
}
// 如果调整后的买入价格低于最佳卖单价格,无法匹配
if (buyPrice.lt(bestAsk)) {
logger.warn("调整后的买入价格 ($buyPrice) 低于最佳卖单价格 ($bestAsk),无法匹配,跳过创建订单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}, leaderPrice=${trade.price}, tolerance=${copyTrading.priceTolerance}")
logger.info("调整后的买入价格 ($buyPrice) 低于最佳卖单价格 ($bestAsk),无法匹配,跳过创建订单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}, leaderPrice=${trade.price}, tolerance=${copyTrading.priceTolerance}")
continue
}
}
@@ -495,6 +515,8 @@ open class CopyOrderTrackingService(
// 解密私钥
val decryptedPrivateKey = decryptPrivateKey(account)
logger.info("准备创建买入订单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}, leaderPrice=${trade.price}, tolerance=${copyTrading.priceTolerance}, calculatedPrice=$buyPrice, quantity=$finalBuyQuantity")
// 调用API创建订单(带重试机制)
// 重试策略:最多重试 MAX_RETRY_ATTEMPTS 次,每次重试前等待 RETRY_DELAY_MS 毫秒
// 每次重试都会重新生成salt并重新签名,确保签名唯一性
@@ -515,6 +537,7 @@ open class CopyOrderTrackingService(
if (createOrderResult.isFailure) {
// 提取错误信息(只保留 code 和 errorBody
val exception = createOrderResult.exceptionOrNull()
logger.error("创建买入订单失败: copyTradingId=${copyTrading.id}, tradeId=${trade.id}, leaderPrice=${trade.price}, myPrice=$buyPrice, error=${exception?.message}")
// 发送订单失败通知(异步,不阻塞,仅在 pushFailedOrders 为 true 时发送)
if (copyTrading.pushFailedOrders) {
@@ -570,7 +593,7 @@ open class CopyOrderTrackingService(
}
val realOrderId = createOrderResult.getOrNull() ?: continue
// 验证 orderId 格式(必须以 0x 开头的 16 进制)
if (!isValidOrderId(realOrderId)) {
logger.warn("买入订单ID格式无效,跳过保存: orderId=$realOrderId")
@@ -656,8 +679,8 @@ open class CopyOrderTrackingService(
private fun calculateBuyQuantity(trade: TradeResponse, copyTrading: CopyTrading): BigDecimal {
return when (copyTrading.copyMode) {
"RATIO" -> {
// 比例模式:Leader 数量 × 比例
trade.size.toSafeBigDecimal().multi(copyTrading.copyRatio)
// 比例模式:Leader 数量 × (比例 / 100)
trade.size.toSafeBigDecimal().multi(copyTrading.copyRatio.div(100))
}
"FIXED" -> {
@@ -689,7 +712,7 @@ open class CopyOrderTrackingService(
val leader = leaderRepository.findById(copyTrading.leaderId).orElse(null)
?: run {
logger.warn("Leader 不存在,使用默认比例: leaderId=${copyTrading.leaderId}")
return leaderSellQuantity.multi(copyTrading.copyRatio)
return leaderSellQuantity.multi(copyTrading.copyRatio.div(100))
}
// 创建不需要认证的 CLOB API 客户端(用于查询公开的交易数据)
@@ -708,7 +731,7 @@ open class CopyOrderTrackingService(
for (order in unmatchedOrders) {
val copyQty = order.quantity.toSafeBigDecimal()
var leaderQty: BigDecimal? = null
// 优先使用存储的 leaderBuyQuantity
if (order.leaderBuyQuantity != null) {
leaderQty = order.leaderBuyQuantity.toSafeBigDecimal()
@@ -719,7 +742,7 @@ open class CopyOrderTrackingService(
logger.debug("Leader 买入数量未存储,尝试查询 API: leaderBuyTradeId=${order.leaderBuyTradeId}, copyOrderId=${order.buyOrderId}")
try {
val tradesResponse = clobApi.getTrades(id = order.leaderBuyTradeId)
if (tradesResponse.isSuccessful && tradesResponse.body() != null) {
val tradesData = tradesResponse.body()!!.data
if (tradesData.isNotEmpty()) {
@@ -745,7 +768,7 @@ open class CopyOrderTrackingService(
failCount++
}
}
// 如果成功获取到 Leader 买入数量,累加
if (leaderQty != null && leaderQty.gt(BigDecimal.ZERO)) {
totalCopyQuantity = totalCopyQuantity.add(copyQty)
@@ -754,23 +777,23 @@ open class CopyOrderTrackingService(
logger.warn("无法获取 Leader 买入数量,跳过该订单: copyOrderId=${order.buyOrderId}, leaderBuyTradeId=${order.leaderBuyTradeId}")
}
}
logger.info("固定金额模式计算结果汇总: copyTradingId=${copyTrading.id}, successCount=$successCount, failCount=$failCount, totalCopyQuantity=$totalCopyQuantity, totalLeaderQuantity=$totalLeaderQuantity")
// 如果无法计算总比例(查询失败),使用默认比例
if (totalLeaderQuantity.lte(BigDecimal.ZERO)) {
logger.warn("无法计算总比例(Leader 买入数量为 0),使用默认比例: copyTradingId=${copyTrading.id}")
return leaderSellQuantity.multi(copyTrading.copyRatio)
return leaderSellQuantity.multi(copyTrading.copyRatio.div(100))
}
// 计算实际比例:跟单买入数量 / Leader 买入数量
val actualRatio = totalCopyQuantity.div(totalLeaderQuantity)
// 计算需要卖出的数量:Leader 卖出数量 × 实际比例
val needMatch = leaderSellQuantity.multi(actualRatio)
logger.debug("固定金额模式卖出数量计算: copyTradingId=${copyTrading.id}, leaderSellQuantity=$leaderSellQuantity, totalCopyQuantity=$totalCopyQuantity, totalLeaderQuantity=$totalLeaderQuantity, actualRatio=$actualRatio, needMatch=$needMatch")
return needMatch
}
@@ -834,16 +857,26 @@ open class CopyOrderTrackingService(
copyTrading = copyTrading
)
}
"RATIO" -> {
// 比例模式:直接使用配置的 copyRatio
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio)
// 比例模式:直接使用配置的 copyRatio (需要除以100)
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio.div(100))
}
else -> {
logger.warn("不支持的 copyMode: ${copyTrading.copyMode},使用默认比例模式")
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio)
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio.div(100))
}
}
// 如果需要卖出的数量小于1(但大于0),自动调整为1(Polymarket 最小下单数量)
// 注意:如果实际持有数量不足1,后续的 totalMatched 检查会拦截
var finalNeedMatch = needMatch
if (finalNeedMatch.gt(BigDecimal.ZERO) && finalNeedMatch.lt(BigDecimal.ONE)) {
logger.warn("计算得到的卖出数量小于1,自动调整为1: copyTradingId=${copyTrading.id}, original=$needMatch")
finalNeedMatch = BigDecimal.ONE
}
// 4. 获取tokenId(直接使用outcomeIndex,支持多元市场)
val tokenIdResult = blockchainService.getTokenId(leaderSellTrade.market, leaderSellTrade.outcomeIndex)
if (tokenIdResult.isFailure) {
@@ -867,7 +900,7 @@ open class CopyOrderTrackingService(
// 6. 按FIFO顺序匹配,计算实际可以卖出的数量
// 使用计算出的实际卖出价格(而不是 Leader 价格)来创建匹配明细
var totalMatched = BigDecimal.ZERO
var remaining = needMatch
var remaining = finalNeedMatch
val matchDetails = mutableListOf<SellMatchDetail>()
for (order in unmatchedOrders) {
@@ -904,6 +937,11 @@ open class CopyOrderTrackingService(
return
}
if (totalMatched.lt(BigDecimal.ONE)) {
logger.warn("卖出数量小于1,跳过卖出 (Polymarket 最小下单数量为 1): copyTradingId=${copyTrading.id}, tradeId=${leaderSellTrade.id}, quantity=$totalMatched")
return
}
// 7. 解密 API 凭证
val apiSecret = try {
decryptApiSecret(account)
@@ -920,7 +958,7 @@ open class CopyOrderTrackingService(
// 8. 解密私钥(在方法开始时解密一次,后续复用)
val decryptedPrivateKey = decryptPrivateKey(account)
// 9. 创建并签名卖出订单
val signedOrder = try {
orderSigningService.createAndSignOrder(
@@ -990,7 +1028,7 @@ open class CopyOrderTrackingService(
} else {
logger.debug("卖出订单ID为0x开头,等待定时任务更新价格: orderId=$realSellOrderId")
}
// 使用下单价格,等待定时任务更新实际成交价
val actualSellPrice = sellPrice
@@ -1039,19 +1077,19 @@ open class CopyOrderTrackingService(
val savedDetail = detail.copy(matchRecordId = savedRecord.id!!)
sellMatchDetailRepository.save(savedDetail)
}
logger.info("卖出订单已保存,等待轮询任务获取实际数据后发送通知: orderId=$realSellOrderId, copyTradingId=${copyTrading.id}")
}
/**
* 创建订单带重试机制
*
*
* 重试策略
* - 最多重试 MAX_RETRY_ATTEMPTS 首次尝试 + 重试
* - 每次重试前等待 RETRY_DELAY_MS 毫秒
* - 每次重试都重新生成salt并重新签名确保签名唯一性
*
*
* @param clobApi CLOB API 客户端
* @param privateKey 私钥用于签名
* @param makerAddress 代理钱包地址
@@ -1117,10 +1155,10 @@ open class CopyOrderTrackingService(
}
val errorMsg = "code=${orderResponse.code()}, errorBody=${errorBody ?: "null"}"
lastError = Exception(errorMsg)
// 记录错误日志
logger.error("创建订单失败 (尝试 $attempt/$MAX_RETRY_ATTEMPTS): copyTradingId=$copyTradingId, tradeId=$tradeId, $errorMsg")
// 如果不是最后一次尝试,等待后重试
if (attempt < MAX_RETRY_ATTEMPTS) {
delay(RETRY_DELAY_MS)
@@ -1134,10 +1172,10 @@ open class CopyOrderTrackingService(
if (!response.success || response.orderId == null) {
val errorMsg = "errorMsg=${response.errorMsg}"
lastError = Exception(errorMsg)
// 记录错误日志
logger.error("创建订单失败 (尝试 $attempt/$MAX_RETRY_ATTEMPTS): copyTradingId=$copyTradingId, tradeId=$tradeId, $errorMsg")
// 如果不是最后一次尝试,等待后重试
if (attempt < MAX_RETRY_ATTEMPTS) {
delay(RETRY_DELAY_MS)
@@ -1149,14 +1187,17 @@ open class CopyOrderTrackingService(
// 创建订单成功
logger.info("创建订单成功: copyTradingId=$copyTradingId, tradeId=$tradeId, orderId=${response.orderId}, attempt=$attempt")
return Result.success(response.orderId)
} catch (e: Exception) {
val errorMsg = "error=${e.message}"
lastError = Exception(errorMsg, e)
// 记录错误日志(包含堆栈)
logger.error("创建订单异常 (尝试 $attempt/$MAX_RETRY_ATTEMPTS): copyTradingId=$copyTradingId, tradeId=$tradeId, $errorMsg", e)
logger.error(
"创建订单异常 (尝试 $attempt/$MAX_RETRY_ATTEMPTS): copyTradingId=$copyTradingId, tradeId=$tradeId, $errorMsg",
e
)
// 如果不是最后一次尝试,等待后重试
if (attempt < MAX_RETRY_ATTEMPTS) {
delay(RETRY_DELAY_MS)
@@ -1168,7 +1209,10 @@ open class CopyOrderTrackingService(
// 所有重试都失败
val finalError = lastError ?: Exception("error=未知错误")
logger.error("创建订单失败(所有重试都失败): copyTradingId=$copyTradingId, tradeId=$tradeId, side=$side, price=$price, size=$size", finalError)
logger.error(
"创建订单失败(所有重试都失败): copyTradingId=$copyTradingId, tradeId=$tradeId, side=$side, price=$price, size=$size",
finalError
)
return Result.failure(finalError)
}
@@ -1303,7 +1347,7 @@ open class CopyOrderTrackingService(
// 计算价格调整范围(百分比)
val tolerancePercent = tolerance.div(100)
val adjustment = originalPrice.multi(tolerancePercent)
val adjustment = originalPrice.multi(tolerancePercent).max(0.01.toSafeBigDecimal())
return if (isBuy) {
// 买入:可以稍微加价以确保成交(在原价格基础上加容忍度)
@@ -1356,7 +1400,7 @@ open class CopyOrderTrackingService(
/**
* 验证订单ID格式
* 订单ID必须以 0x 开头且是有效的 16 进制字符串
*
*
* @param orderId 订单ID
* @return 如果格式有效返回 true否则返回 false
*/
@@ -1372,11 +1416,11 @@ open class CopyOrderTrackingService(
// 检查是否只包含 0-9, a-f, A-F
return hexPart.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }
}
/**
* 获取订单的实际成交价
* 通过查询订单详情和关联的交易记录计算加权平均成交价
*
*
* @param orderId 订单ID
* @param clobApi CLOB API 客户端已认证
* @param fallbackPrice 如果查询失败使用此价格作为默认值
@@ -1395,14 +1439,14 @@ open class CopyOrderTrackingService(
logger.warn("查询订单详情失败: orderId=$orderId, code=${orderResponse.code()}, errorBody=$errorBody")
return fallbackPrice
}
val order = orderResponse.body()
if (order == null) {
// 响应体为空,可能是订单不存在或已过期
logger.warn("查询订单详情失败: 响应体为空, orderId=$orderId, code=${orderResponse.code()}")
return fallbackPrice
}
// 2. 如果订单未成交,使用下单价格
if (order.status != "FILLED" && order.sizeMatched.toSafeBigDecimal() <= BigDecimal.ZERO) {
logger.debug("订单未成交,使用下单价格: orderId=$orderId, status=${order.status}")
@@ -1443,7 +1487,7 @@ open class CopyOrderTrackingService(
for (trade in trades) {
val tradePrice = trade.price.toSafeBigDecimal()
val tradeSize = trade.size.toSafeBigDecimal()
if (tradeSize > BigDecimal.ZERO) {
totalAmount = totalAmount.add(tradePrice.multiply(tradeSize))
totalSize = totalSize.add(tradeSize)
@@ -143,8 +143,13 @@ class OrderStatusUpdateService(
// 计算30秒前的时间戳
val thirtySecondsAgo = System.currentTimeMillis() - 30000
// 查询30秒前创建的订单
val ordersToCheck = copyOrderTrackingRepository.findByCreatedAtBefore(thirtySecondsAgo)
// 查询30秒前创建的订单,并过滤掉已经完全匹配的订单
// 已经完全匹配的订单(status = "fully_matched")不需要再检查
// 使用数据库查询过滤,避免加载过多数据
val ordersToCheck = copyOrderTrackingRepository.findByCreatedAtBeforeAndStatusNot(
thirtySecondsAgo,
"fully_matched"
)
if (ordersToCheck.isEmpty()) {
return
@@ -43,6 +43,25 @@ class TelegramNotificationService(
// 协程作用域
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
/**
* 发送订单成功通知
* @param orderId 订单ID用于查询订单详情获取实际价格和数量
* @param marketTitle 市场标题
* @param marketId 市场IDconditionId用于生成链接
* @param marketSlug 市场slug用于生成链接
* @param side 订单方向BUY/SELL用于多语言显示
* @param accountName 账户名称
* @param walletAddress 钱包地址
* @param clobApi CLOB API 客户端可选如果提供则查询订单详情获取实际价格和数量
* @param apiKey API Key可选用于查询订单详情
* @param apiSecret API Secret可选用于查询订单详情
* @param apiPassphrase API Passphrase可选用于查询订单详情
* @param walletAddressForApi 钱包地址可选用于查询订单详情
* @param locale 语言设置可选如果提供则使用否则使用 LocaleContextHolder 获取
*/
// 已发送通知的订单ID缓存(key: orderId, value: timestamp
private val sentOrderIds = java.util.concurrent.ConcurrentHashMap<String, Long>()
/**
* 发送订单成功通知
* @param orderId 订单ID用于查询订单详情获取实际价格和数量
@@ -79,6 +98,26 @@ class TelegramNotificationService(
leaderName: String? = null, // Leader 名称(备注)
configName: String? = null // 跟单配置名
) {
// 1. 如果提供了 orderId,检查是否已发送过通知(去重)
if (orderId != null) {
val lastSentTime = sentOrderIds[orderId]
if (lastSentTime != null) {
// 如果5分钟内已发送过,跳过
if (System.currentTimeMillis() - lastSentTime < 5 * 60 * 1000) {
logger.info("订单通知已发送过(5分钟内),跳过: orderId=$orderId")
return
}
}
// 记录发送时间
sentOrderIds[orderId] = System.currentTimeMillis()
// 简单的清理逻辑:如果缓存过大,清理过期的
if (sentOrderIds.size > 1000) {
val expiryTime = System.currentTimeMillis() - 5 * 60 * 1000
sentOrderIds.entries.removeIf { it.value < expiryTime }
}
}
// 获取语言设置(优先使用传入的 locale,否则从 LocaleContextHolder 获取)
val currentLocale = locale ?: try {
LocaleContextHolder.getLocale()
@@ -686,6 +725,13 @@ class TelegramNotificationService(
else -> side
}
// 获取图标
val icon = when (side.uppercase()) {
"BUY" -> "🚀"
"SELL" -> "💰"
else -> "📣"
}
// 构建账户信息(格式:账户名(钱包地址))
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
@@ -761,7 +807,7 @@ class TelegramNotificationService(
val priceDisplay = formatPrice(price)
val sizeDisplay = formatQuantity(size)
return """ <b>$orderCreatedSuccess</b>
return """$icon <b>$orderCreatedSuccess</b>
📊 <b>$orderInfo</b>
$orderIdLabel: <code>${orderId ?: unknown}</code>
@@ -989,7 +1035,7 @@ class TelegramNotificationService(
"${position.marketId.substring(0, 8)}... (${position.side}): $quantityDisplay shares = $valueDisplay USDC"
}
return """ <b>$redeemSuccess</b>
return """💸 <b>$redeemSuccess</b>
📊 <b>$redeemInfo</b>
$accountLabel: $escapedAccountInfo
+1
View File
@@ -833,3 +833,4 @@ fun analyzeTrader(@RequestBody request: SmartMoneyAnalyzeRequest): ResponseEntit
4. **风险预警**:监控聪明钱交易者的风险指标,及时预警
5. **多维度分析**:增加更多分析维度(如市场类型、时间分布等)
+1 -1
View File
@@ -1026,7 +1026,7 @@
"remainingQuantity": "剩余",
"sellStatus": "卖出状态",
"status": "状态",
"statusFilled": "已完成",
"statusFilled": "未成交",
"statusPartiallySold": "部分成交",
"statusFullySold": "全部成交",
"realizedPnl": "已实现盈亏",
@@ -8,11 +8,14 @@ import type { BuyOrderInfo, OrderTrackingRequest, OrderTrackingListResponse } fr
const { Option } = Select
import { ReloadOutlined } from '@ant-design/icons'
interface BuyOrdersTabProps {
copyTradingId: string
active?: boolean
}
const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId }) => {
const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = false }) => {
const { t } = useTranslation()
const isMobile = useMediaQuery({ maxWidth: 768 })
const [loading, setLoading] = useState(false)
@@ -25,16 +28,16 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId }) => {
side?: string
status?: string
}>({})
useEffect(() => {
if (copyTradingId) {
if (copyTradingId && active) {
fetchOrders()
}
}, [copyTradingId, page, limit, filters])
}, [copyTradingId, active, page, limit, filters])
const fetchOrders = async () => {
if (!copyTradingId) return
setLoading(true)
try {
const request: OrderTrackingRequest = {
@@ -44,7 +47,7 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId }) => {
limit,
...filters
}
const response = await apiService.orderTracking.list(request)
if (response.data.code === 0 && response.data.data) {
const data = response.data.data as OrderTrackingListResponse
@@ -57,17 +60,17 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId }) => {
setLoading(false)
}
}
const getStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = {
filled: { color: 'processing', text: t('copyTradingOrders.statusFilled') || '已完成' },
filled: { color: 'processing', text: t('copyTradingOrders.statusFilled') || '未成交' },
partially_matched: { color: 'warning', text: t('copyTradingOrders.statusPartiallySold') || '部分成交' },
fully_matched: { color: 'success', text: t('copyTradingOrders.statusFullySold') || '全部成交' }
}
const config = statusMap[status] || { color: 'default', text: status }
return <Tag color={config.color}>{config.text}</Tag>
}
const columns = [
{
title: t('copyTradingOrders.orderId') || '订单ID',
@@ -76,7 +79,7 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId }) => {
width: isMobile ? 100 : 150,
render: (text: string) => (
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
@@ -90,7 +93,7 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId }) => {
width: isMobile ? 100 : 150,
render: (text: string) => (
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
@@ -104,7 +107,7 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId }) => {
width: isMobile ? 100 : 150,
render: (text: string) => (
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
@@ -184,7 +187,7 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId }) => {
width: isMobile ? 120 : 160,
render: (timestamp: number) => (
<span style={{ fontSize: isMobile ? 11 : 12 }}>
{isMobile
{isMobile
? new Date(timestamp).toLocaleDateString('zh-CN')
: new Date(timestamp).toLocaleString('zh-CN')
}
@@ -192,7 +195,7 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId }) => {
)
}
]
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
@@ -203,7 +206,7 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId }) => {
value={filters.marketId}
onChange={(e) => setFilters({ ...filters, marketId: e.target.value || undefined })}
/>
<Select
placeholder={t('copyTradingOrders.filterSide') || '筛选方向'}
allowClear
@@ -213,10 +216,8 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId }) => {
>
<Option value="0">YES</Option>
<Option value="1">NO</Option>
<Option value="YES">YES</Option>
<Option value="NO">NO</Option>
</Select>
<Select
placeholder={t('copyTradingOrders.filterStatus') || '筛选状态'}
allowClear
@@ -224,14 +225,14 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId }) => {
value={filters.status}
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.statusPartiallySold') || '部分成交'}</Option>
<Option value="fully_matched">{t('copyTradingOrders.statusFullySold') || '全部成交'}</Option>
</Select>
<Button onClick={fetchOrders}>{t('common.search') || '查询'}</Button>
<Button type="primary" onClick={fetchOrders} icon={<ReloadOutlined />}>{t('common.refresh') || '刷新'}</Button>
</div>
{isMobile ? (
<div>
{loading ? (
@@ -255,7 +256,7 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId }) => {
})
const amount = (parseFloat(order.quantity) * parseFloat(order.price)).toString()
const displaySide = order.side === '0' ? 'YES' : order.side === '1' ? 'NO' : order.side
return (
<Card
key={order.orderId}
@@ -267,9 +268,9 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId }) => {
bodyStyle={{ padding: '16px' }}
>
<div style={{ marginBottom: '12px' }}>
<div style={{
fontSize: '14px',
fontWeight: 'bold',
<div style={{
fontSize: '14px',
fontWeight: 'bold',
marginBottom: '8px',
fontFamily: 'monospace'
}}>
@@ -280,9 +281,9 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId }) => {
{getStatusTag(order.status)}
</div>
</div>
<Divider style={{ margin: '12px 0' }} />
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.buyInfo') || '买入信息'}</div>
<div style={{ fontSize: '14px', fontWeight: '500' }}>
@@ -292,28 +293,28 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId }) => {
{t('copyTradingOrders.amount') || '金额'}: {formatUSDC(amount)} USDC
</div>
</div>
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.matchInfo') || '匹配信息'}</div>
<div style={{ fontSize: '13px', color: '#333' }}>
{t('copyTradingOrders.matched') || '已匹配'}: {formatUSDC(order.matchedQuantity)} | {t('copyTradingOrders.remaining') || '剩余'}: {formatUSDC(order.remainingQuantity)}
</div>
</div>
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.leaderTradeId') || 'Leader 交易ID'}</div>
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
{order.leaderTradeId.slice(0, 8)}...{order.leaderTradeId.slice(-6)}
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.marketId') || '市场ID'}</div>
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
{order.marketId.slice(0, 8)}...{order.marketId.slice(-6)}
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#999' }}>
{t('copyTradingOrders.createdAt') || '创建时间'}: {formattedDate}
@@ -6,11 +6,14 @@ import { useMediaQuery } from 'react-responsive'
import { useTranslation } from 'react-i18next'
import type { MatchedOrderInfo, OrderTrackingRequest, OrderTrackingListResponse } from '../../types'
import { ReloadOutlined } from '@ant-design/icons'
interface MatchedOrdersTabProps {
copyTradingId: string
active?: boolean
}
const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId }) => {
const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId, active = false }) => {
const { t } = useTranslation()
const isMobile = useMediaQuery({ maxWidth: 768 })
const [loading, setLoading] = useState(false)
@@ -22,16 +25,16 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId }) =>
sellOrderId?: string
buyOrderId?: string
}>({})
useEffect(() => {
if (copyTradingId) {
if (copyTradingId && active) {
fetchOrders()
}
}, [copyTradingId, page, limit, filters])
}, [copyTradingId, active, page, limit, filters])
const fetchOrders = async () => {
if (!copyTradingId) return
setLoading(true)
try {
const request: OrderTrackingRequest = {
@@ -41,7 +44,7 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId }) =>
limit,
...filters
}
const response = await apiService.orderTracking.list(request)
if (response.data.code === 0 && response.data.data) {
const data = response.data.data as OrderTrackingListResponse
@@ -54,13 +57,13 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId }) =>
setLoading(false)
}
}
const getPnlColor = (value: string): string => {
const num = parseFloat(value)
if (isNaN(num)) return '#666'
return num >= 0 ? '#3f8600' : '#cf1322'
}
const columns = [
{
title: t('copyTradingOrders.sellOrderId') || '卖出订单ID',
@@ -69,7 +72,7 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId }) =>
width: isMobile ? 100 : 150,
render: (text: string) => (
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
@@ -83,7 +86,7 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId }) =>
width: isMobile ? 100 : 150,
render: (text: string) => (
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
@@ -123,8 +126,8 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId }) =>
key: 'realizedPnl',
width: isMobile ? 100 : 120,
render: (value: string) => (
<span style={{
color: getPnlColor(value),
<span style={{
color: getPnlColor(value),
fontWeight: 500,
fontSize: isMobile ? 12 : 14
}}>
@@ -139,7 +142,7 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId }) =>
width: isMobile ? 120 : 160,
render: (timestamp: number) => (
<span style={{ fontSize: isMobile ? 11 : 12 }}>
{isMobile
{isMobile
? new Date(timestamp).toLocaleDateString('zh-CN')
: new Date(timestamp).toLocaleString('zh-CN')
}
@@ -147,7 +150,7 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId }) =>
)
}
]
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
@@ -158,7 +161,7 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId }) =>
value={filters.sellOrderId}
onChange={(e) => setFilters({ ...filters, sellOrderId: e.target.value || undefined })}
/>
<Input
placeholder={t('copyTradingOrders.filterBuyOrderId') || '筛选买入订单ID'}
allowClear
@@ -166,10 +169,10 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId }) =>
value={filters.buyOrderId}
onChange={(e) => setFilters({ ...filters, buyOrderId: e.target.value || undefined })}
/>
<Button onClick={fetchOrders}>{t('common.search') || '查询'}</Button>
<Button type="primary" onClick={fetchOrders} icon={<ReloadOutlined />}>{t('common.search') || '查询'}</Button>
</div>
{isMobile ? (
<div>
{loading ? (
@@ -191,7 +194,7 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId }) =>
hour: '2-digit',
minute: '2-digit'
})
return (
<Card
key={`${order.sellOrderId}-${order.buyOrderId}-${order.matchedAt}`}
@@ -204,8 +207,8 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId }) =>
>
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.sellOrderId') || '卖出订单ID'}</div>
<div style={{
fontSize: '13px',
<div style={{
fontSize: '13px',
fontWeight: '500',
fontFamily: 'monospace',
marginBottom: '8px'
@@ -213,42 +216,42 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId }) =>
{order.sellOrderId.slice(0, 8)}...{order.sellOrderId.slice(-6)}
</div>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.buyOrderId') || '买入订单ID'}</div>
<div style={{
fontSize: '13px',
<div style={{
fontSize: '13px',
fontWeight: '500',
fontFamily: 'monospace'
}}>
{order.buyOrderId.slice(0, 8)}...{order.buyOrderId.slice(-6)}
</div>
</div>
<Divider style={{ margin: '12px 0' }} />
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.matchedQuantity') || '匹配数量'}</div>
<div style={{ fontSize: '14px', fontWeight: '500' }}>
{formatUSDC(order.matchedQuantity)}
</div>
</div>
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.priceInfo') || '价格信息'}</div>
<div style={{ fontSize: '13px', color: '#333' }}>
{t('copyTradingOrders.buy') || '买入'}: {formatUSDC(order.buyPrice)} | {t('copyTradingOrders.sell') || '卖出'}: {formatUSDC(order.sellPrice)}
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.realizedPnl') || '盈亏'}</div>
<div style={{
fontSize: '16px',
<div style={{
fontSize: '16px',
fontWeight: 'bold',
color: getPnlColor(order.realizedPnl)
}}>
{formatUSDC(order.realizedPnl)} USDC
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#999' }}>
{t('copyTradingOrders.matchedAt') || '匹配时间'}: {formattedDate}
@@ -8,11 +8,14 @@ import type { SellOrderInfo, OrderTrackingRequest, OrderTrackingListResponse } f
const { Option } = Select
import { ReloadOutlined } from '@ant-design/icons'
interface SellOrdersTabProps {
copyTradingId: string
active?: boolean
}
const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId }) => {
const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = false }) => {
const { t } = useTranslation()
const isMobile = useMediaQuery({ maxWidth: 768 })
const [loading, setLoading] = useState(false)
@@ -24,16 +27,16 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId }) => {
marketId?: string
side?: string
}>({})
useEffect(() => {
if (copyTradingId) {
if (copyTradingId && active) {
fetchOrders()
}
}, [copyTradingId, page, limit, filters])
}, [copyTradingId, active, page, limit, filters])
const fetchOrders = async () => {
if (!copyTradingId) return
setLoading(true)
try {
const request: OrderTrackingRequest = {
@@ -43,7 +46,7 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId }) => {
limit,
...filters
}
const response = await apiService.orderTracking.list(request)
if (response.data.code === 0 && response.data.data) {
const data = response.data.data as OrderTrackingListResponse
@@ -56,13 +59,13 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId }) => {
setLoading(false)
}
}
const getPnlColor = (value: string): string => {
const num = parseFloat(value)
if (isNaN(num)) return '#666'
return num >= 0 ? '#3f8600' : '#cf1322'
}
const columns = [
{
title: t('copyTradingOrders.orderId') || '订单ID',
@@ -71,7 +74,7 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId }) => {
width: isMobile ? 100 : 150,
render: (text: string) => (
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
@@ -85,7 +88,7 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId }) => {
width: isMobile ? 100 : 150,
render: (text: string) => (
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
@@ -99,7 +102,7 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId }) => {
width: isMobile ? 100 : 150,
render: (text: string) => (
<span style={{ fontFamily: 'monospace', fontSize: isMobile ? 11 : 12 }}>
{isMobile
{isMobile
? `${text.slice(0, 6)}...${text.slice(-4)}`
: `${text.slice(0, 8)}...${text.slice(-6)}`
}
@@ -153,8 +156,8 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId }) => {
key: 'realizedPnl',
width: isMobile ? 100 : 120,
render: (value: string) => (
<span style={{
color: getPnlColor(value),
<span style={{
color: getPnlColor(value),
fontWeight: 500,
fontSize: isMobile ? 12 : 14
}}>
@@ -169,7 +172,7 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId }) => {
width: isMobile ? 120 : 160,
render: (timestamp: number) => (
<span style={{ fontSize: isMobile ? 11 : 12 }}>
{isMobile
{isMobile
? new Date(timestamp).toLocaleDateString('zh-CN')
: new Date(timestamp).toLocaleString('zh-CN')
}
@@ -177,7 +180,7 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId }) => {
)
}
]
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
@@ -188,7 +191,7 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId }) => {
value={filters.marketId}
onChange={(e) => setFilters({ ...filters, marketId: e.target.value || undefined })}
/>
<Select
placeholder={t('copyTradingOrders.filterSide') || '筛选方向'}
allowClear
@@ -201,10 +204,10 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId }) => {
<Option value="YES">YES</Option>
<Option value="NO">NO</Option>
</Select>
<Button onClick={fetchOrders}>{t('common.search') || '查询'}</Button>
<Button type="primary" onClick={fetchOrders} icon={<ReloadOutlined />}>{t('common.refresh') || '刷新'}</Button>
</div>
{isMobile ? (
<div>
{loading ? (
@@ -228,7 +231,7 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId }) => {
})
const amount = (parseFloat(order.quantity) * parseFloat(order.price)).toString()
const displaySide = order.side === '0' ? 'YES' : order.side === '1' ? 'NO' : order.side
return (
<Card
key={order.orderId}
@@ -240,9 +243,9 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId }) => {
bodyStyle={{ padding: '16px' }}
>
<div style={{ marginBottom: '12px' }}>
<div style={{
fontSize: '14px',
fontWeight: 'bold',
<div style={{
fontSize: '14px',
fontWeight: 'bold',
marginBottom: '8px',
fontFamily: 'monospace'
}}>
@@ -252,9 +255,9 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId }) => {
<Tag>{displaySide}</Tag>
</div>
</div>
<Divider style={{ margin: '12px 0' }} />
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.sellInfo') || '卖出信息'}</div>
<div style={{ fontSize: '14px', fontWeight: '500' }}>
@@ -264,32 +267,32 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId }) => {
{t('copyTradingOrders.amount') || '金额'}: {formatUSDC(amount)} USDC
</div>
</div>
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.realizedPnl') || '已实现盈亏'}</div>
<div style={{
fontSize: '16px',
<div style={{
fontSize: '16px',
fontWeight: 'bold',
color: getPnlColor(order.realizedPnl)
}}>
{formatUSDC(order.realizedPnl)} USDC
</div>
</div>
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.leaderTradeId') || 'Leader 交易ID'}</div>
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
{order.leaderTradeId.slice(0, 8)}...{order.leaderTradeId.slice(-6)}
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>{t('copyTradingOrders.marketId') || '市场ID'}</div>
<div style={{ fontSize: '12px', color: '#999', fontFamily: 'monospace' }}>
{order.marketId.slice(0, 8)}...{order.marketId.slice(-6)}
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '12px', color: '#999' }}>
{t('copyTradingOrders.createdAt') || '创建时间'}: {formattedDate}
@@ -22,13 +22,13 @@ const CopyTradingOrdersModal: React.FC<CopyTradingOrdersModalProps> = ({
}) => {
const { t } = useTranslation()
const [activeTab, setActiveTab] = useState<TabType>(defaultTab)
useEffect(() => {
if (open) {
setActiveTab(defaultTab)
}
}, [open, defaultTab])
return (
<Modal
title={t('copyTradingOrders.title') || '订单列表'}
@@ -38,25 +38,26 @@ const CopyTradingOrdersModal: React.FC<CopyTradingOrdersModalProps> = ({
width="90%"
style={{ top: 20 }}
bodyStyle={{ padding: '24px', maxHeight: 'calc(100vh - 100px)', overflow: 'auto' }}
destroyOnClose
>
<Tabs
activeKey={activeTab}
<Tabs
activeKey={activeTab}
onChange={(key) => setActiveTab(key as TabType)}
items={[
{
key: 'buy',
label: t('copyTradingOrders.buyOrders') || '买入订单',
children: <BuyOrdersTab copyTradingId={copyTradingId} />
children: <BuyOrdersTab copyTradingId={copyTradingId} active={activeTab === 'buy'} />
},
{
key: 'sell',
label: t('copyTradingOrders.sellOrders') || '卖出订单',
children: <SellOrdersTab copyTradingId={copyTradingId} />
children: <SellOrdersTab copyTradingId={copyTradingId} active={activeTab === 'sell'} />
},
{
key: 'matched',
label: t('copyTradingOrders.matchedOrders') || '匹配关系',
children: <MatchedOrdersTab copyTradingId={copyTradingId} />
children: <MatchedOrdersTab copyTradingId={copyTradingId} active={activeTab === 'matched'} />
}
]}
/>