Compare commits

..

26 Commits

Author SHA1 Message Date
WrBug 07b4d654b4 清理 MarketPollingService 调试日志
- 删除多余的 debug 日志输出
2026-01-19 12:50:34 +08:00
WrBug b65827038f 添加订单详情查询脚本
- 添加 get-order-detail.js 脚本,用于获取 Polymarket 订单详情
- 添加 package.json 配置文件
- 忽略 package-lock.json (已在 .gitignore 中通过 node_modules/ 规则处理)
2026-01-19 12:50:20 +08:00
WrBug d768da72c6 清理 MarketPollingService 中多余的 debug 日志 2026-01-19 12:48:18 +08:00
WrBug 7385efff1a 优化订单详情为null时的处理逻辑
- 当订单详情为null且已发送通知超过60秒时,将订单状态改为fully_matched而不是删除
- 避免已经通过订单详情更正并发送通知的订单被意外删除
- fully_matched状态的订单不会被包含在待查询列表中,自动跳过查询
2026-01-19 12:47:03 +08:00
WrBug 3e2e97e572 fix: 修复 CopyOrderTrackingService 和 CopyTradingService 的 @Transactional 自调用问题
1. CopyOrderTrackingService 修复
   - 实现 ApplicationContextAware 接口
   - 添加 getSelf() 方法获取代理对象
   - 在 processTrade() 中通过代理调用 processBuyTrade() 和 processSellTrade()
   - 确保两个子方法的 @Transactional 生效

2. CopyTradingService 修复
   - 实现 ApplicationContextAware 接口
   - 添加 getSelf() 方法获取代理对象
   - 在 updateCopyTradingStatus() 中通过代理调用 updateCopyTrading()
   - 确保内部调用的 @Transactional 生效

修改的文件:
- CopyOrderTrackingService.kt
- CopyTradingService.kt
2026-01-16 10:28:39 +08:00
WrBug ae68a33c1e fix: 优化仓位列表分页和统计功能
- 添加完整的分页功能,支持移动端和桌面端
- 移动端:自定义分页控件,支持切换每页 10/20/50 条
- 桌面端:使用 Ant Design Table 分页,支持切换每页 10/20/50 条
- 优化仓位统计逻辑,区分当前仓位和历史仓位
- 当前仓位:显示浮动盈亏和已实现盈亏
- 历史仓位:移除盈亏统计显示
- 筛选条件变化时自动重置到第一页
2026-01-16 10:14:54 +08:00
WrBug deea59fdbf fix: 修复 OrderStatusUpdateService 的事务和并发问题
1. 解决 @Transactional 自调用问题
   - 实现 ApplicationContextAware 接口
   - 通过代理对象调用 @Transactional 方法,确保事务生效

2. 防止定时任务并发执行
   - 添加 updateJob 跟踪上一次任务状态
   - 如果上一次任务还在执行,跳过本次执行
   - 避免多个更新任务同时运行导致的数据竞争

修改的方法:
- updateOrderStatus(): 添加并发控制
- cleanupDeletedAccountOrders(): 通过代理对象调用
- checkAndDeleteUnfilledOrders(): 通过代理对象调用
- updatePendingSellOrderPrices(): 通过代理对象调用
- updatePendingBuyOrders(): 通过代理对象调用
2026-01-15 15:21:29 +08:00
WrBug b90f86b081 fix: 修复 @Transactional 方法的可见性问题
将 OrderStatusUpdateService 中 4 个使用 @Transactional 的方法从 private 改为 public,
确保 Spring 能够正确代理这些方法。

修改的方法:
- cleanupDeletedAccountOrders()
- checkAndDeleteUnfilledOrders()
- updatePendingSellOrderPrices()
- updatePendingBuyOrders()

参考 UserService.kt 的实现模式,@Transactional 方法必须是 public 的。
2026-01-15 15:05:53 +08:00
WrBug f6f5866118 refactor: 清理 PositionPollingService 代码
- 移除多余的空行
- 移除调试日志
2026-01-15 04:35:11 +08:00
WrBug b1e69135b8 feat: 添加跟单订单来源字段
- 在 CopyOrderTracking 实体添加 source 字段,记录订单来源
- 支持 activity-ws(Polymarket WebSocket)和 onchain-ws(OnChain WebSocket)两种来源
- 更新 processBuyTrade 方法,接收并保存 source 参数
- 更新 OrderStatusUpdateService,保留原始 source 信息
- 添加数据库迁移脚本 V25
- 统一 CopyTradingWebSocketService 使用 activity-ws 作为来源
2026-01-15 04:33:55 +08:00
WrBug 0c7f34a28a fix: 添加事务注解修复删除操作异常
- 在 cleanupExpiredProcessedTrades 方法上添加 @Transactional 注解
- 修复 TransactionRequiredException 异常
- 删除操作必须在事务中执行
2026-01-15 04:19:46 +08:00
WrBug c53fcde5d7 feat: 添加已处理交易记录定时清理服务
- 创建 ProcessedTradeCleanupService 定时清理过期的去重记录
- 保留时间设置为10分钟(重复订单通常10秒后就不会出现)
- 清理间隔为10分钟,避免数据库空间浪费
- 记录清理日志,便于监控和调试
2026-01-15 04:17:54 +08:00
WrBug 81a620af12 feat(activity-ws): 同时监听 trades 和 orders_matched,添加 txHash 去重
- 订阅消息同时包含 trades 和 orders_matched 两种类型
- 添加 processedTxHashes Cache (LRU 100 条,10 分钟过期)
- 防止同一交易被重复处理
- 新增 duplicateTxHashMessages 统计指标
- 更新日志信息,明确标注监听范围
2026-01-15 04:13:27 +08:00
WrBug 5f44a0ca20 优化订单状态更新和统计服务
1. 订单详情为 null 且已部分卖出的订单超时清理
   - 新增 PARTIAL_SOLD_CLEANUP_WINDOW_MS 常量(1小时)
   - 超过1小时无法获取详情的已部分卖出订单自动清理
   - 避免无效订单长期占用数据库空间

2. 优化买入订单分组排序逻辑
   - 按最近买入订单时间排序,提升用户体验
   - 修复日志中的错误描述(卖出订单列表)

3. 代码格式化优化
2026-01-15 02:28:11 +08:00
WrBug 5d2cf945f3 fix: 修复 AccountOnChainMonitorService 中 JsonNull 导致的崩溃
- 增加 receiptRpcResponse.result.isJsonNull 检查
- 防止在链上查询返回 JsonNull 时调用 asJsonObject() 抛出 IllegalStateException
- 优化空值处理逻辑,确保程序安全退出
2026-01-15 02:10:49 +08:00
WrBug 227a38fa89 Merge branch 'main' into dev 2026-01-14 15:00:41 +08:00
WrBug abcc004606 fix: 添加订单查询失败的兜底逻辑,避免因网络异常误删订单
- 新增 orderNullDetectionTime 缓存,记录订单详情首次检测为 null 的时间戳
- 订单详情为 null 时,首次检测不删除,等待 1 分钟重试窗口
- 超过重试窗口仍为 null 才删除订单
- 订单详情正常或已部分卖出时清除缓存
- 避免 ResponseLoggingInterceptor 导致的响应体被消费问题

修改文件:
- OrderStatusUpdateService.kt

相关 issue: 订单 0xd245a852... 因响应体为 null 被误删
2026-01-14 14:55:05 +08:00
WrBug cc40493ec6 Merge pull request #21 from WrBug/dev 2026-01-13 12:55:21 +08:00
WrBug 692cbd9a80 Merge pull request #20 from WrBug/dev 2026-01-12 15:40:39 +08:00
WrBug 43de0104e2 Merge pull request #19 from WrBug/dev 2026-01-10 17:55:31 +08:00
WrBug 2ccab42894 Merge pull request #18 from WrBug/dev
Release v1.1.8: Polymarket Activity WebSocket 双重监听方案
2026-01-09 11:11:51 +08:00
WrBug 3008cbcb50 Merge pull request #17 from WrBug/release/v1.1.7
Release v1.1.7
2026-01-07 12:57:12 +08:00
WrBug 42a318b501 Merge pull request #15 from WrBug/dev
v1.1.6 Release: 优化跟单仓位检查逻辑与精度修正
2026-01-05 11:13:49 +08:00
WrBug ecb737ec67 Merge pull request #14 from WrBug/dev
Release v1.1.5: 功能优化与改进
2026-01-04 13:09:11 +08:00
WrBug 0f3baec6cb Merge branch 'pre_release' 2026-01-03 21:58:29 +08:00
WrBug 764d684846 Merge pull request #13 from WrBug/pre_release
Release v1.1.4: WebSocket 票据认证和钱包类型支持
2026-01-03 18:33:23 +08:00
16 changed files with 1005 additions and 305 deletions
@@ -58,7 +58,10 @@ data class CopyOrderTracking(
@Column(name = "notification_sent", nullable = false)
var notificationSent: Boolean = false, // 是否已发送通知(从订单详情获取实际数据后发送)
@Column(name = "source", nullable = false, length = 20)
val source: String, // 订单来源:activity-wsPolymarket WebSocket)、onchain-wsOnChain WebSocket
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@@ -21,29 +21,29 @@ import java.util.concurrent.CopyOnWriteArrayList
class PositionPollingService(
private val accountService: AccountService
) {
private val logger = LoggerFactory.getLogger(PositionPollingService::class.java)
@Value("\${position.polling.interval:2000}")
private var pollingInterval: Long = 2000 // 轮训间隔(毫秒),默认2秒
// 订阅者列表(支持多个订阅者)
private val subscribers = CopyOnWriteArrayList<(PositionListResponse) -> Unit>()
// 最新仓位数据(用于丢弃机制)
@Volatile
private var latestPositions: PositionListResponse? = null
// 协程作用域和任务
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private var pollingJob: Job? = null
// 事件分发协程(使用专门的线程,避免阻塞轮训)
private val eventDispatcherScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// 同步锁,确保轮询任务的启动和停止是线程安全的
private val lock = Any()
/**
* 初始化服务(后端启动时直接启动轮训)
*/
@@ -52,7 +52,7 @@ class PositionPollingService(
logger.info("PositionPollingService 初始化,启动仓位轮训任务,轮训间隔: ${pollingInterval}ms")
startPolling()
}
/**
* 清理资源
*/
@@ -66,7 +66,7 @@ class PositionPollingService(
scope.cancel()
eventDispatcherScope.cancel()
}
/**
* 订阅仓位事件
* @param callback 回调函数,接收最新的仓位数据
@@ -78,7 +78,7 @@ class PositionPollingService(
latestPositions?.let { callback(it) }
}
}
/**
* 取消订阅仓位事件
*/
@@ -87,7 +87,7 @@ class PositionPollingService(
subscribers.remove(callback)
}
}
/**
* 启动轮训任务
*/
@@ -95,7 +95,7 @@ class PositionPollingService(
synchronized(lock) {
// 如果已经有轮训任务在运行,先取消
pollingJob?.cancel()
// 启动新的轮训任务
pollingJob = scope.launch {
while (isActive) {
@@ -109,7 +109,7 @@ class PositionPollingService(
}
}
}
/**
* 轮训仓位数据并发布事件
* 使用专门的线程分发事件,避免阻塞轮训
@@ -123,7 +123,7 @@ class PositionPollingService(
if (positions != null) {
// 更新最新数据(丢弃旧数据,只保留最新的)
latestPositions = positions
// 在专门的线程中分发事件,避免阻塞轮训
eventDispatcherScope.launch {
try {
@@ -131,7 +131,7 @@ class PositionPollingService(
val currentSubscribers = synchronized(lock) {
subscribers.toList() // 复制列表,避免并发修改
}
currentSubscribers.forEach { callback ->
try {
callback(positions)
@@ -139,8 +139,6 @@ class PositionPollingService(
logger.error("通知订阅者失败: ${e.message}", e)
}
}
logger.debug("发布仓位数据事件: currentPositions=${positions.currentPositions.size}, historyPositions=${positions.historyPositions.size}, subscribers=${currentSubscribers.size}")
} catch (e: Exception) {
logger.error("分发仓位数据事件失败: ${e.message}", e)
}
@@ -89,8 +89,6 @@ class MarketPollingService(
*/
private suspend fun checkAndUpdateMissingMarkets() {
try {
logger.debug("开始检查缺失的市场信息...")
// 1. 获取所有买入订单的市场ID(去重)
val allOrders = copyOrderTrackingRepository.findAll()
val marketIds = allOrders.map { it.marketId }.distinct()
@@ -99,9 +97,6 @@ class MarketPollingService(
logger.debug("没有找到任何订单,跳过市场信息检查")
return
}
logger.debug("找到 ${marketIds.size} 个不同的市场ID")
// 2. 检查哪些市场信息在数据库中缺失
val existingMarkets = marketService.marketRepository.findByMarketIdIn(marketIds)
val existingMarketIds = existingMarkets.map { it.marketId }.toSet()
@@ -113,7 +108,6 @@ class MarketPollingService(
}
if (validMissingMarketIds.isEmpty()) {
logger.debug("所有市场信息都已存在,无需更新")
return
}
@@ -14,6 +14,8 @@ import com.wrbug.polymarketbot.util.IllegalBigDecimal
import com.wrbug.polymarketbot.util.JsonUtils
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import org.slf4j.LoggerFactory
import org.springframework.context.ApplicationContext
import org.springframework.context.ApplicationContextAware
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
@@ -30,10 +32,24 @@ class CopyTradingService(
private val monitorService: CopyTradingMonitorService,
private val jsonUtils: JsonUtils,
private val gson: Gson
) {
) : ApplicationContextAware {
private val logger = LoggerFactory.getLogger(CopyTradingService::class.java)
private var applicationContext: ApplicationContext? = null
override fun setApplicationContext(applicationContext: ApplicationContext) {
this.applicationContext = applicationContext
}
/**
* 获取代理对象,用于解决 @Transactional 自调用问题
*/
private fun getSelf(): CopyTradingService {
return applicationContext?.getBean(CopyTradingService::class.java)
?: throw IllegalStateException("ApplicationContext not initialized")
}
/**
* 创建跟单配置
* 支持两种方式:
@@ -331,7 +347,7 @@ class CopyTradingService(
*/
@Transactional
fun updateCopyTradingStatus(request: CopyTradingUpdateStatusRequest): Result<CopyTradingDto> {
return updateCopyTrading(
return getSelf().updateCopyTrading(
CopyTradingUpdateRequest(
copyTradingId = request.copyTradingId,
enabled = request.enabled
@@ -114,10 +114,10 @@ class AccountOnChainMonitorService(
}
val receiptRpcResponse = receiptResponse.body()!!
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null) {
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null || receiptRpcResponse.result.isJsonNull) {
return
}
// 使用 Gson 解析 receipt JSON
val receiptJson = receiptRpcResponse.result.asJsonObject
@@ -213,7 +213,7 @@ class CopyTradingWebSocketService(
// 处理交易
scope.launch {
try {
copyOrderTrackingService.processTrade(leaderId, trade, "websocket")
copyOrderTrackingService.processTrade(leaderId, trade, "activity-ws")
} catch (e: Exception) {
logger.error("处理交易失败: leaderId=$leaderId, tradeId=${trade.id}", e)
}
@@ -1,5 +1,7 @@
package com.wrbug.polymarketbot.service.copytrading.monitor
import com.github.benmanes.caffeine.cache.Cache
import com.github.benmanes.caffeine.cache.Caffeine
import com.google.gson.JsonNull
import com.wrbug.polymarketbot.api.*
import com.wrbug.polymarketbot.entity.Leader
@@ -12,6 +14,7 @@ import okhttp3.OkHttpClient
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
/**
* 链上 WebSocket 监听服务
@@ -30,6 +33,11 @@ class OnChainWsService(
// 存储需要监听的LeaderleaderId -> Leader
private val monitoredLeaders = ConcurrentHashMap<Long, Leader>()
// 存储已处理的交易哈希,用于去重(LRU 缓存,保留最近 100 条)
private val processedTxHashes: Cache<String, Long> = Caffeine.newBuilder()
.maximumSize(100)
.build()
/**
* 启动链上 WebSocket 监听
* 通过统一服务订阅所有 Leader
@@ -95,6 +103,14 @@ class OnChainWsService(
) {
val leader = monitoredLeaders[leaderId] ?: return
// 根据 txHash 去重(使用原子操作避免竞态条件)
val currentTime = System.currentTimeMillis()
val existingTimestamp = processedTxHashes.asMap().putIfAbsent(txHash, currentTime)
if (existingTimestamp != null) {
logger.debug("交易已处理过,跳过: leaderId=$leaderId, txHash=$txHash, firstProcessedAt=$existingTimestamp")
return
}
logger.debug("开始处理 Leader 交易: leaderId=$leaderId, txHash=$txHash, leaderAddress=${leader.leaderAddress}")
try {
@@ -1,5 +1,7 @@
package com.wrbug.polymarketbot.service.copytrading.monitor
import com.github.benmanes.caffeine.cache.Cache
import com.github.benmanes.caffeine.cache.Caffeine
import com.wrbug.polymarketbot.api.TradeResponse
import com.wrbug.polymarketbot.dto.ActivityTradeMessage
import com.wrbug.polymarketbot.dto.ActivityTradePayload
@@ -15,10 +17,11 @@ import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
/**
* Polymarket Activity WebSocket 监听服务
* 通过订阅全局 activity 交易流,客户端过滤 Leader 地址,实现实时交易检测
* 通过订阅全局 activity 交易流trades + orders_matched,客户端过滤 Leader 地址,实现实时交易检测
* 延迟 < 100ms,适合快速跟单场景
*/
@Service
@@ -39,6 +42,13 @@ class PolymarketActivityWsService(
// 要监听的 Leader 地址集合(小写地址 -> leaderId
private val monitoredAddresses = ConcurrentHashMap<String, Long>()
// 存储已处理的交易哈希,用于去重(LRU 缓存,保留最近 100 条)
// 因为同时订阅 trades 和 orders_matched,同一个交易可能被推送两次
private val processedTxHashes: Cache<String, Long> = Caffeine.newBuilder()
.maximumSize(100)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build()
// 是否已订阅
@Volatile
private var isSubscribed = false
@@ -50,6 +60,12 @@ class PolymarketActivityWsService(
// Activity 消息超时检测任务
private var activityTimeoutJob: Job? = null
// 性能统计
private var totalMessagesProcessed = 0L
private var addressMatchMessages = 0L
private var jsonParseMessages = 0L
private var duplicateTxHashMessages = 0L
/**
* 启动监听
*/
@@ -68,7 +84,7 @@ class PolymarketActivityWsService(
return
}
logger.info("启动 Activity WebSocket 监听,监控 ${monitoredAddresses.size} 个 Leader 地址")
logger.info("启动 Activity WebSocket 监听trades + orders_matched,监控 ${monitoredAddresses.size} 个 Leader 地址")
connectAndSubscribe()
}
@@ -165,6 +181,7 @@ class PolymarketActivityWsService(
* 订阅全局 activity
* 根据 @polymarket/real-time-data-client 的协议格式
* 使用 "action": "subscribe" 而不是 "type": "subscribe"
* 同时订阅 trades 和 orders_matched 两种类型
*/
private fun subscribeAllActivity() {
val client = wsClient
@@ -176,6 +193,7 @@ class PolymarketActivityWsService(
try {
// 根据 real-time-data-client 的协议格式
// 订阅消息应包含 "action": "subscribe" 和 "subscriptions" 数组
// 同时订阅 trades 和 orders_matched 两种类型
val subscribeMessage = """
{
"action": "subscribe",
@@ -183,6 +201,10 @@ class PolymarketActivityWsService(
{
"topic": "activity",
"type": "trades"
},
{
"topic": "activity",
"type": "orders_matched"
}
]
}
@@ -193,8 +215,8 @@ class PolymarketActivityWsService(
// 重置最后一次收到 activity 消息的时间
lastActivityTime = System.currentTimeMillis()
// 启动 Activity 消息超时检测
startActivityTimeoutCheck()
logger.info("Activity WebSocket 订阅成功(全局交易流)")
// startActivityTimeoutCheck()
logger.info("Activity WebSocket 订阅成功(全局交易流: trades + orders_matched")
} catch (e: Exception) {
logger.error("订阅 Activity WebSocket 失败", e)
isSubscribed = false
@@ -208,24 +230,24 @@ class PolymarketActivityWsService(
private fun startActivityTimeoutCheck() {
// 先停止之前的检测任务
stopActivityTimeoutCheck()
activityTimeoutJob = scope.launch {
while (isActive && isSubscribed) {
delay(30000) // 每30秒检查一次
// 如果已经取消订阅,停止检测
if (!isSubscribed) {
break
}
// 如果 lastActivityTime 为 0,说明还没有收到过消息,跳过本次检测
if (lastActivityTime == 0L) {
continue
}
val currentTime = System.currentTimeMillis()
val timeSinceLastActivity = currentTime - lastActivityTime
// 如果超过30秒没有收到activity消息,触发重连
if (timeSinceLastActivity >= 30000) {
logger.warn("超过30秒未收到 Activity 消息,触发重连。距离上次消息: ${timeSinceLastActivity}ms")
@@ -240,7 +262,7 @@ class PolymarketActivityWsService(
}
}
}
/**
* 停止 Activity 消息超时检测
*/
@@ -249,42 +271,95 @@ class PolymarketActivityWsService(
activityTimeoutJob = null
}
/**
* 检查消息是否包含监听的 Leader 地址
* 快速过滤,避免不必要的 JSON 解析
* 只需要检查 "proxyWallet":"0x..." 或 "trader":{"address":"0x..."} 格式
*/
private fun containsMonitoredAddress(message: String): Boolean {
// 快速检查:如果消息很短,不可能包含地址
if (message.length < 50) {
return false
}
// 遍历所有监听的地址
for ((address, leaderId) in monitoredAddresses) {
// 检查 proxyWallet:格式为 "proxyWallet":"0x..."
if (message.contains("\"proxyWallet\":\"$address\"", ignoreCase = true)) {
addressMatchMessages++
return true
}
// 检查 trader.address:格式为 "trader":{"address":"0x..."}
if (message.contains("\"trader\"", ignoreCase = true) &&
message.contains("\"address\":\"$address\"", ignoreCase = true)
) {
addressMatchMessages++
return true
}
}
return false
}
/**
* 处理消息
*/
private fun handleMessage(message: String) {
try {
totalMessagesProcessed++
// 处理 PONG 响应
if (message.trim() == "PONG" || message.trim() == "pong") {
return
}
// 使用扩展函数解析消息
// 快速预检查:检查是否包含监听地址
// 绝大部分消息会在这一步被过滤掉,避免不必要的 JSON 解析
if (!containsMonitoredAddress(message)) {
return
}
logger.info("发现leader交易:${message}")
// 使用扩展函数解析消息(只对包含监听地址的消息)
val tradeMessage = message.fromJson<ActivityTradeMessage>() ?: run {
// 不是有效的 JSON 或格式不匹配,跳过
logger.warn("无法解析为 ActivityTradeMessage,可能不是 activity 消息: ${message.take(200)}")
logger.warn("无法解析为 ActivityTradeMessage: ${message.take(200)}")
return
}
// 检查是否是 activity trade 消息
if (tradeMessage.topic != "activity" || tradeMessage.type != "trades") {
// 不是我们关心的消息,直接返回
jsonParseMessages++
// 检查是否是 activity 消息(trades 或 orders_matched
if (tradeMessage.topic != "activity" ||
(tradeMessage.type != "trades" && tradeMessage.type != "orders_matched")) {
return
}
// 更新最后一次收到 activity 消息的时间(即使不是我们监听的 Leader 的交易)
lastActivityTime = System.currentTimeMillis()
val payload = tradeMessage.payload
// 根据 txHash 去重(使用原子操作避免竞态条件)
val txHash = payload.transactionHash
if (txHash != null && txHash.isNotBlank()) {
val currentTime = System.currentTimeMillis()
val existingTimestamp = processedTxHashes.asMap().putIfAbsent(txHash, currentTime)
if (existingTimestamp != null) {
duplicateTxHashMessages++
logger.debug("交易已处理过,跳过: txHash=$txHash, firstProcessedAt=$existingTimestamp, type=${tradeMessage.type}")
return
}
}
// 提取交易者地址
val traderAddress = extractTraderAddress(payload) ?: run {
// 没有交易者地址,跳过
logger.warn("Activity Trade 消息中没有交易者地址: trader=${payload.trader}, proxyWallet=${payload.proxyWallet}, asset=${payload.asset}")
return
}
// 检查是否是我们监听的 Leader
// 二次验证:确认地址匹配
val normalizedAddress = traderAddress.lowercase()
val leaderId = monitoredAddresses[normalizedAddress] ?: run {
return
@@ -449,6 +524,7 @@ class PolymarketActivityWsService(
wsClient = null
isSubscribed = false
monitoredAddresses.clear()
processedTxHashes.invalidateAll() // 清空去重缓存
lastActivityTime = 0
}
@@ -466,8 +542,33 @@ class PolymarketActivityWsService(
return monitoredAddresses.size
}
/**
* 获取性能统计信息
*/
fun getPerformanceStats(): Map<String, Any> {
val jsonParseRate = if (totalMessagesProcessed > 0) {
(jsonParseMessages.toDouble() / totalMessagesProcessed * 100).toInt()
} else {
0
}
return mapOf(
"totalMessages" to totalMessagesProcessed,
"addressMatches" to addressMatchMessages,
"jsonParses" to jsonParseMessages,
"duplicateTxHashes" to duplicateTxHashMessages,
"jsonParseRate" to "$jsonParseRate%",
"filteringEfficiency" to if (totalMessagesProcessed > 0) {
((1.0 - jsonParseMessages.toDouble() / totalMessagesProcessed) * 100).toInt()
} else {
0
}
)
}
@PreDestroy
fun destroy() {
logger.info("Activity WS 性能统计: ${getPerformanceStats()}")
stop()
scope.cancel()
}
@@ -23,6 +23,8 @@ import com.wrbug.polymarketbot.service.common.MarketService
import com.wrbug.polymarketbot.service.common.PolymarketClobService
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
import com.wrbug.polymarketbot.util.CryptoUtils
import org.springframework.context.ApplicationContext
import org.springframework.context.ApplicationContextAware
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
@@ -51,12 +53,26 @@ open class CopyOrderTrackingService(
private val cryptoUtils: CryptoUtils,
private val marketService: MarketService, // 市场信息服务
private val telegramNotificationService: TelegramNotificationService? = null // 可选,避免循环依赖
) {
) : ApplicationContextAware {
private val logger = LoggerFactory.getLogger(CopyOrderTrackingService::class.java)
// 协程作用域(用于异步发送通知)
private val notificationScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var applicationContext: ApplicationContext? = null
override fun setApplicationContext(applicationContext: ApplicationContext) {
this.applicationContext = applicationContext
}
/**
* 获取代理对象,用于解决 @Transactional 自调用问题
*/
private fun getSelf(): CopyOrderTrackingService {
return applicationContext?.getBean(CopyOrderTrackingService::class.java)
?: throw IllegalStateException("ApplicationContext not initialized")
}
// 使用 Mutex 保证线程安全(按交易ID锁定)
private val tradeMutexMap = ConcurrentHashMap<String, Mutex>()
@@ -138,10 +154,11 @@ open class CopyOrderTrackingService(
return@withLock Result.success(Unit)
}
// 2. 处理交易逻辑
// 2. 处理交易逻辑(通过代理对象调用,确保 @Transactional 生效)
val self = getSelf()
val result = when (trade.side.uppercase()) {
"BUY" -> processBuyTrade(leaderId, trade)
"SELL" -> processSellTrade(leaderId, trade)
"BUY" -> self.processBuyTrade(leaderId, trade, source)
"SELL" -> self.processSellTrade(leaderId, trade)
else -> {
logger.warn("未知的交易方向: ${trade.side}")
Result.failure(IllegalArgumentException("未知的交易方向: ${trade.side}"))
@@ -213,7 +230,7 @@ open class CopyOrderTrackingService(
* 创建跟单买入订单并记录到跟踪表
*/
@Transactional
suspend fun processBuyTrade(leaderId: Long, trade: TradeResponse): Result<Unit> {
suspend fun processBuyTrade(leaderId: Long, trade: TradeResponse, source: String): Result<Unit> {
return try {
// 1. 查找所有启用且支持该Leader的跟单关系
val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId)
@@ -622,7 +639,8 @@ open class CopyOrderTrackingService(
price = buyPrice, // 使用下单价格,临时值
remainingQuantity = finalBuyQuantity,
status = "filled",
notificationSent = false // 标记为未发送通知,等待轮询任务获取实际数据后发送
notificationSent = false, // 标记为未发送通知,等待轮询任务获取实际数据后发送
source = source // 订单来源
)
copyOrderTrackingRepository.save(tracking)
@@ -611,22 +611,22 @@ class CopyTradingStatisticsService(
// 4. 转换为分组数据并计算统计信息
val marketIds = groups.keys.toList()
val list = marketIds.map { marketId ->
val marketOrders = groups[marketId] ?: mutableListOf()
// 计算统计信息
val count = marketOrders.size.toLong()
val totalAmount = marketOrders.sumOf { order ->
order.quantity.toSafeBigDecimal().multi(order.price)
}
// 计算订单状态统计
val fullyMatchedCount = marketOrders.count { it.status == "fully_matched" }
val partiallyMatchedCount = marketOrders.count { it.status == "partially_matched" }
val filledCount = marketOrders.count { it.status == "filled" }
val fullyMatched = fullyMatchedCount == marketOrders.size
val stats = MarketOrderStats(
count = count,
totalAmount = totalAmount.toString(),
@@ -636,10 +636,10 @@ class CopyTradingStatisticsService(
partiallyMatchedCount = partiallyMatchedCount.toLong(),
filledCount = filledCount.toLong()
)
// 排序(按创建时间倒序)
marketOrders.sortByDescending { it.createdAt }
// 转换为 DTO
val orderDtos = marketOrders.map { order ->
val amount = order.quantity.toSafeBigDecimal().multi(order.price)
@@ -662,7 +662,7 @@ class CopyTradingStatisticsService(
createdAt = order.createdAt
)
}
MarketOrderGroup(
marketId = marketId,
marketTitle = markets[marketId]?.title,
@@ -672,27 +672,35 @@ class CopyTradingStatisticsService(
stats = stats,
orders = orderDtos as List<Any>
)
}.sortedByDescending { it.stats.count }
}.sortedByDescending { group ->
// 找出该市场最近的买入订单时间
group.orders.mapNotNull { order ->
when (order) {
is BuyOrderInfo -> order.createdAt
else -> null
}
}.maxOrNull() ?: 0L
}
// 5. 分页
val page = (request.page ?: 1)
val limit = request.limit ?: 20
val total = list.size.toLong()
val start = (page - 1) * limit
val end = minOf(start + limit, list.size)
val pagedList = if (start < list.size) list.subList(start, end) else emptyList()
val response = MarketGroupedOrdersResponse(
list = pagedList,
total = total,
page = page,
limit = limit
)
Result.success(response)
} catch (e: Exception) {
logger.error("获取按市场分组的买入订单列表失败: copyTradingId=${request.copyTradingId}", e)
logger.error("获取按市场分组的卖出订单列表失败: copyTradingId=${request.copyTradingId}", e)
Result.failure(e)
}
}
@@ -0,0 +1,48 @@
package com.wrbug.polymarketbot.service.system
import com.wrbug.polymarketbot.repository.ProcessedTradeRepository
import org.slf4j.LoggerFactory
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
/**
* 已处理交易清理服务
* 定期清理过期的去重记录
*/
@Service
class ProcessedTradeCleanupService(
private val processedTradeRepository: ProcessedTradeRepository
) {
companion object {
private val logger = LoggerFactory.getLogger(ProcessedTradeCleanupService::class.java)
// 保留时间:1小时(3600000毫秒)
// 说明:重复订单通常10秒后就不会再出现,保留10分钟是为了安全起见
private const val RETENTION_MS = 600_000L
// 定时清理间隔:10分钟(600000毫秒)
private const val CLEANUP_INTERVAL_MS = 600_000L
}
/**
* 定时清理过期记录
* 每10分钟执行一次
*/
@Scheduled(fixedDelay = CLEANUP_INTERVAL_MS)
@Transactional
fun cleanupExpiredProcessedTrades() {
try {
val expireTime = System.currentTimeMillis() - RETENTION_MS
val deletedCount = processedTradeRepository.deleteByProcessedAtBefore(expireTime)
if (deletedCount > 0) {
logger.info("清理过期已处理交易记录: deletedCount=$deletedCount, expireTime=$expireTime")
}
} catch (e: Exception) {
logger.error("清理过期已处理交易记录失败", e)
}
}
}
@@ -0,0 +1,12 @@
-- ============================================
-- V25: 添加订单来源字段到跟单订单跟踪表
-- 用于记录订单是从哪个数据源接收到的(activity-ws 或 onchain-ws
-- ============================================
-- 添加订单来源字段
ALTER TABLE copy_order_tracking
ADD COLUMN source VARCHAR(20) NOT NULL DEFAULT 'unknown' COMMENT '订单来源:activity-wsPolymarket WebSocket)、onchain-wsOnChain WebSocket';
-- 对于已有数据,设置为默认值 unknown(不影响现有功能)
-- 新创建的记录会在创建时自动填充此字段
+125 -40
View File
@@ -40,6 +40,8 @@ const PositionList: React.FC = () => {
const [redeemableSummary, setRedeemableSummary] = useState<RedeemablePositionsSummary | null>(null)
const [loadingRedeemableSummary, setLoadingRedeemableSummary] = useState(false)
const [redeeming, setRedeeming] = useState(false)
const [currentPage, setCurrentPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
useEffect(() => {
fetchAccounts()
@@ -66,6 +68,11 @@ const PositionList: React.FC = () => {
fetchRedeemableSummary()
}
}, [currentPositions, selectedAccountId])
// 当筛选条件或搜索关键词变化时,重置分页到第一页
useEffect(() => {
setCurrentPage(1)
}, [positionFilter, selectedAccountId, searchKeyword])
// 获取可赎回仓位统计
const fetchRedeemableSummary = async () => {
@@ -265,12 +272,12 @@ const PositionList: React.FC = () => {
// 本地搜索和筛选过滤
const filteredPositions = useMemo(() => {
let filtered = basePositions
// 1. 先按账户筛选
if (selectedAccountId !== undefined) {
filtered = filtered.filter(p => p.accountId === selectedAccountId)
}
// 2. 最后按关键词搜索
if (searchKeyword.trim()) {
const keyword = searchKeyword.trim().toLowerCase()
@@ -302,9 +309,16 @@ const PositionList: React.FC = () => {
return false
})
}
return filtered
}, [basePositions, searchKeyword, selectedAccountId])
// 分页后的数据
const paginatedPositions = useMemo(() => {
const startIndex = (currentPage - 1) * pageSize
const endIndex = startIndex + pageSize
return filteredPositions.slice(startIndex, endIndex)
}, [filteredPositions, currentPage, pageSize])
const getSideColor = (side: string) => {
return side === 'YES' ? 'green' : 'red'
@@ -349,14 +363,32 @@ const PositionList: React.FC = () => {
if (!isNaN(initialValue)) {
totalInitialValue += initialValue
}
// 当前仓位:统计持仓价值
// 历史仓位:currentValue 应该为 0(已平仓)
if (!isNaN(currentValue)) {
totalCurrentValue += currentValue
}
if (!isNaN(pnl)) {
totalPnl += pnl
}
if (!isNaN(realizedPnl)) {
totalRealizedPnl += realizedPnl
// 对于当前仓位:
// - pnl:未实现盈亏(浮动盈亏)
// - realizedPnl:已实现盈亏(部分平仓时产生)
// 对于历史仓位:
// - pnl:总已实现盈亏(包含部分平仓 + 完全平仓)
// - realizedPnl:部分平仓的已实现盈亏(可能与 pnl 重复)
if (pos.isCurrent) {
// 当前仓位:未实现盈亏 + 已实现盈亏
if (!isNaN(pnl)) {
totalPnl += pnl
}
if (!isNaN(realizedPnl)) {
totalRealizedPnl += realizedPnl
}
} else {
// 历史仓位:pnl 是总已实现盈亏,realizedPnl 可能重复,所以只统计 pnl
if (!isNaN(pnl)) {
totalRealizedPnl += pnl
}
}
})
@@ -516,10 +548,10 @@ const PositionList: React.FC = () => {
// 渲染卡片视图
const renderCardView = () => {
if (filteredPositions.length === 0) {
if (paginatedPositions.length === 0) {
return (
<Empty
description="暂无仓位数据"
<Empty
description="暂无仓位数据"
style={{ padding: '60px 0' }}
/>
)
@@ -527,7 +559,7 @@ const PositionList: React.FC = () => {
return (
<Row gutter={[16, 16]}>
{filteredPositions.map((position, index) => {
{paginatedPositions.map((position, index) => {
const pnlNum = parseFloat(position.pnl || '0')
const isProfit = pnlNum >= 0
// 只有当前仓位才根据盈亏显示边框颜色
@@ -1235,8 +1267,8 @@ const PositionList: React.FC = () => {
)}
</div>
</div>
{/* 合计信息:开仓价值、当前价值、盈亏、已实现盈亏(基于当前筛选后的仓位 */}
{filteredPositions.length > 0 && (
{/* 合计信息:开仓价值、当前价值、盈亏、已实现盈亏(仅当前仓位显示 */}
{filteredPositions.length > 0 && positionFilter === 'current' && (
<div
style={{
marginTop: '12px',
@@ -1259,13 +1291,11 @@ const PositionList: React.FC = () => {
<span>
{' '}
<span style={{ fontWeight: 600 }}>
{positionFilter === 'current'
? `${formatUSDC(positionTotals.totalCurrentValue.toString())} USDC`
: '-'}
{formatUSDC(positionTotals.totalCurrentValue.toString())} USDC
</span>
</span>
<span>
{' '}
{' '}
<span
style={{
fontWeight: 600,
@@ -1295,32 +1325,87 @@ const PositionList: React.FC = () => {
{(isMobile || viewMode === 'card') ? (
<Card loading={loading}>
{renderCardView()}
{/* 移动端分页 */}
{filteredPositions.length > 0 && (
<div style={{
marginTop: '24px',
textAlign: 'center',
color: '#999',
fontSize: '14px'
}}>
{filteredPositions.length} {searchKeyword ? `(已过滤)` : ''}
</div>
<>
<div style={{
marginTop: '16px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: '8px'
}}>
<div style={{ fontSize: '14px', color: '#666' }}>
{filteredPositions.length} {searchKeyword ? `(已过滤)` : ''}
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<Button
size="small"
disabled={currentPage === 1}
onClick={() => setCurrentPage(currentPage - 1)}
>
</Button>
<span style={{ lineHeight: '32px', fontSize: '14px' }}>
{currentPage} / {Math.ceil(filteredPositions.length / pageSize)}
</span>
<Button
size="small"
disabled={currentPage >= Math.ceil(filteredPositions.length / pageSize)}
onClick={() => setCurrentPage(currentPage + 1)}
>
</Button>
</div>
</div>
{/* 每页条数选择器 */}
<div style={{
marginTop: '8px',
textAlign: 'right',
fontSize: '14px'
}}>
<Select
value={pageSize}
onChange={(value) => {
setPageSize(value)
setCurrentPage(1)
}}
size="small"
style={{ width: '100px' }}
>
<Select.Option value={10}>10 /</Select.Option>
<Select.Option value={20}>20 /</Select.Option>
<Select.Option value={50}>50 /</Select.Option>
</Select>
</div>
</>
)}
</Card>
) : (
<Card>
<Table
dataSource={filteredPositions}
columns={columns}
rowKey={(record, index) => `${record.accountId}-${record.marketId}-${index}`}
loading={loading}
pagination={{
pageSize: 20,
showSizeChanger: !isMobile,
showTotal: (total) => `${total} 个仓位${searchKeyword ? `(已过滤)` : ''}`
}}
scroll={isMobile ? { x: 1500 } : undefined}
/>
</Card>
<Card>
<Table
dataSource={filteredPositions}
columns={columns}
rowKey={(record, index) => `${record.accountId}-${record.marketId}-${index}`}
loading={loading}
pagination={{
current: currentPage,
pageSize: pageSize,
total: filteredPositions.length,
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50'],
showTotal: (total) => `${total} 个仓位${searchKeyword ? `(已过滤)` : ''}`,
onChange: (page, size) => {
setCurrentPage(page)
if (size !== pageSize) {
setPageSize(size)
}
}
}}
scroll={isMobile ? { x: 1500 } : undefined}
/>
</Card>
)}
{/* 出售模态框 */}
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env node
/**
* 获取订单详情脚本
*
* 使用方法:
* node scripts/get-order-detail.js <private_key> <order_id>
*
* 参数说明:
* private_key: 钱包私钥(用于签名)
* order_id: 订单 ID
*
* 示例:
* node scripts/get-order-detail.js "0x..." "0x123..."
*/
import { Wallet } from '@ethersproject/wallet';
import { ClobClient } from '@polymarket/clob-client';
// Polymarket CLOB 主机地址
const HOST = 'https://clob.polymarket.com';
const CHAIN_ID = 137; // Polygon 主网
async function getOrderDetail(privateKey, orderId) {
try {
console.log('正在初始化钱包...');
const wallet = new Wallet(privateKey);
console.log(`钱包地址: ${wallet.address}`);
console.log('\n正在初始化 ClobClient...');
const clobClient = new ClobClient(
HOST,
CHAIN_ID,
wallet
);
console.log('\n正在获取或创建 API Key...');
try {
// 尝试 derive API key(如果已存在)
const creds = await clobClient.deriveApiKey();
console.log(`✅ API Key 已获取`);
console.log(` API Key: ${creds.key.substring(0, 10)}...`);
console.log(` Passphrase: ${creds.passphrase.substring(0, 10)}...`);
// 使用 creds 初始化一个新的 ClobClient 实例用于 L2 认证
const authenticatedClient = new ClobClient(
HOST,
CHAIN_ID,
wallet,
creds
);
console.log(`\n正在获取订单详情...`);
console.log(` 订单 ID: ${orderId}`);
const orderDetail = await authenticatedClient.getOrder(orderId);
console.log('\n================ 订单详情 ================');
console.log(`订单 ID: ${orderDetail.id}`);
console.log(`状态: ${orderDetail.status}`);
console.log(`所有者: ${orderDetail.owner}`);
console.log(`Maker 地址: ${orderDetail.maker_address}`);
console.log(`市场 ID: ${orderDetail.market}`);
console.log(`资产 ID: ${orderDetail.asset_id}`);
console.log(`方向: ${orderDetail.side}`);
console.log(`原始数量: ${orderDetail.original_size}`);
console.log(`已匹配数量: ${orderDetail.size_matched}`);
console.log(`价格: ${orderDetail.price}`);
console.log(`结果: ${orderDetail.outcome}`);
console.log(`创建时间: ${new Date(orderDetail.created_at * 1000).toISOString()}`);
console.log(`过期时间: ${orderDetail.expiration}`);
console.log(`订单类型: ${orderDetail.order_type}`);
if (orderDetail.associate_trades && orderDetail.associate_trades.length > 0) {
console.log(`关联交易数量: ${orderDetail.associate_trades.length}`);
console.log(`关联交易 IDs: ${orderDetail.associate_trades.join(', ')}`);
}
console.log('=========================================\n');
} catch (apiKeyError) {
if (apiKeyError.message && apiKeyError.message.includes('API key does not exist')) {
console.log('⚠️ API Key 不存在,正在创建新的 API Key...');
// 创建新的 API key
const creds = await clobClient.createApiKey();
console.log(`✅ API Key 已创建`);
console.log(` API Key: ${creds.key.substring(0, 10)}...`);
console.log(` Passphrase: ${creds.passphrase.substring(0, 10)}...`);
// 使用 creds 初始化一个新的 ClobClient 实例用于 L2 认证
const authenticatedClient = new ClobClient(
HOST,
CHAIN_ID,
wallet,
creds
);
console.log(`\n正在获取订单详情...`);
console.log(` 订单 ID: ${orderId}`);
const orderDetail = await authenticatedClient.getOrder(orderId);
console.log('\n================ 订单详情 ================');
console.log(`订单 ID: ${orderDetail.id}`);
console.log(`状态: ${orderDetail.status}`);
console.log(`所有者: ${orderDetail.owner}`);
console.log(`Maker 地址: ${orderDetail.maker_address}`);
console.log(`市场 ID: ${orderDetail.market}`);
console.log(`资产 ID: ${orderDetail.asset_id}`);
console.log(`方向: ${orderDetail.side}`);
console.log(`原始数量: ${orderDetail.original_size}`);
console.log(`已匹配数量: ${orderDetail.size_matched}`);
console.log(`价格: ${orderDetail.price}`);
console.log(`结果: ${orderDetail.outcome}`);
console.log(`创建时间: ${new Date(orderDetail.created_at * 1000).toISOString()}`);
console.log(`过期时间: ${orderDetail.expiration}`);
console.log(`订单类型: ${orderDetail.order_type}`);
if (orderDetail.associate_trades && orderDetail.associate_trades.length > 0) {
console.log(`关联交易数量: ${orderDetail.associate_trades.length}`);
console.log(`关联交易 IDs: ${orderDetail.associate_trades.join(', ')}`);
}
console.log('=========================================\n');
} else {
throw apiKeyError;
}
}
} catch (error) {
console.error('\n❌ 获取订单详情失败:');
console.error(error.message);
if (error.response) {
console.error(`\nHTTP 状态码: ${error.response.status}`);
console.error(`响应数据:`, error.response.data);
}
process.exit(1);
}
}
// 检查命令行参数
const args = process.argv.slice(2);
if (args.length < 2) {
console.error('错误: 缺少必要参数');
console.error('\n使用方法:');
console.error(' node scripts/get-order-detail.js <private_key> <order_id>');
console.error('\n参数说明:');
console.error(' private_key: 钱包私钥(用于签名)');
console.error(' order_id: 订单 ID');
console.error('\n示例:');
console.error(' node scripts/get-order-detail.js "0x123..." "0x456..."');
process.exit(1);
}
const [privateKey, orderId] = args;
// 验证私钥格式
if (!privateKey.startsWith('0x')) {
console.error('错误: 私钥格式不正确,必须以 0x 开头');
process.exit(1);
}
if (privateKey.length !== 66) {
console.error('错误: 私钥长度不正确,应为 66 个字符(包括 0x 前缀)');
process.exit(1);
}
// 验证订单 ID
if (!orderId.startsWith('0x')) {
console.error('错误: 订单 ID 格式不正确,必须以 0x 开头');
process.exit(1);
}
// 执行主函数
getOrderDetail(privateKey, orderId);
+13
View File
@@ -0,0 +1,13 @@
{
"name": "polyhermes-scripts",
"version": "1.0.0",
"description": "Utility scripts for Polyhermes",
"type": "module",
"scripts": {
"get-order-detail": "node get-order-detail.js"
},
"dependencies": {
"@ethersproject/wallet": "^5.7.0",
"@polymarket/clob-client": "^5.2.1"
}
}