Compare commits

...
37 Commits
Author SHA1 Message Date
WrBug 662aa47de6 refactor: 优化匹配关系列表的订单ID显示
- 将'卖出订单ID'和'买入订单ID'两列合并为一列'订单ID'
- 第一行显示买入订单ID,第二行显示卖出订单ID
- 添加'买入'和'卖出'标签区分两种订单ID
- 保持复制按钮功能
- 优化移动端和桌面端的显示效果
- 桌面端列宽从120+180调整为150+200以容纳更多内容
2026-01-20 23:19:05 +08:00
WrBug c3d9d10d5d fix: 修复卖出订单标签页的多语言键值错误
- 修复分组标签使用错误的键值:allFullyMatched -> allFullySold
- 修复筛选器选项使用错误的键值:statusPartiallyMatched -> partiallySold,statusFullyMatched -> allFullySold
- 确保中文环境显示正确的中文文案:'全部成交' -> '全部卖出','部分成交' -> '部分卖出'
- 与买入订单标签页的键值使用保持一致
2026-01-20 23:11:54 +08:00
WrBug 9926533049 fix: 修复多语言配置中的键值重复和缺失问题
- 修复 copyTradingOrders 对象中重复的键值
- 统一键名规范:allFullyMatched -> allFullySold, partiallyMatched -> partiallySold
- 添加缺失的 filterMarketTitle 键值
- 确保中英文和繁体中文的键值配置一致
- 修复所有语言文件的 JSON 格式(添加换行符)
2026-01-20 22:57:17 +08:00
WrBug 4d72017b97 fix: 优化匹配关系列表页的显示和交互
- 将'查询'按钮改为'刷新'按钮,文案更准确
- 在表格中新增'市场'列,显示市场标题和ID
- 为移动端和桌面端的市场标题添加跳转到 Polymarket 的链接
- 导入 getPolymarketUrl 工具函数支持市场跳转
- 优化用户体验,便于快速查看市场详情
2026-01-20 11:48:14 +08:00
WrBug 45734c051e fix: 优化买入订单分组的卖出状态文案显示
- 当完全成交数为0时,显示'未卖出'而不是'部分成交 (0/X)'
- 统一状态文案:'部分成交'改为'部分卖出','全部成交'改为'全部卖出'
- 更新中英文和繁体中文的多语言配置
- 优化筛选选项中的状态文案,与分组显示保持一致
2026-01-20 11:44:33 +08:00
WrBug db8471bb16 fix: 统一卖出订单和买入订单的市场分组排序规则
- 修改卖出订单的市场分组排序逻辑
- 按照该市场最近的卖出订单时间倒序排序(与买入订单保持一致)
- 替换原有的按订单数量倒序排序规则
- 优化分组显示的用户体验,便于快速定位活跃市场
2026-01-20 11:38:41 +08:00
WrBug 0dc6f5894f refactor: 优化最大仓位价值检查逻辑,按市场+方向分别限制
- 修改 maxPositionValue 检查逻辑,从按市场累计改为按市场+方向(outcomeIndex)分别计算
- 新增 Repository 方法 sumCurrentPositionValueByMarketAndOutcomeIndex 支持按方向统计
- 移除未使用的 sumCurrentPositionValueByMarket 和 existsByCopyTradingIdAndMarketIdAndRemainingQuantityGreaterThan 方法
- 更新 checkPositionLimits 方法,增加 outcomeIndex 参数
- 更新 checkFilters 方法,传递 outcomeIndex 参数
- 更新日志和注释,明确说明按市场+方向检查仓位

说明:同一市场的 YES 和 NO 方向现在分别受 maxPositionValue 限制,不再累加计算
2026-01-20 11:15:38 +08:00
WrBug c9769aa17a fix: 修复比例模式计算数量错误
- 移除 copyRatio 多余的除以 100 操作
- copyRatio 字段存储的是倍数值(如 1.3 表示 130%),不需要再转换
- 影响:买入数量计算、固定金额模式卖出数量计算
- 修复示例:配置 130% 后,Leader 买入 6.25 份,跟单数量应为 8.125 而不是 0.08125
2026-01-20 10:45:03 +08:00
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 64391503c2 fix: 修复最大仓位金额未生效和交易receipt解析问题
1. 修复 CopyTradingFilterService 中最大仓位金额(maxPositionValue)未生效的问题
   - 当只配置了最大仓位金额但未配置 maxSpread 或 minOrderDepth 时,
     仓位检查被跳过导致过滤失效
   - 将仓位检查移到 needOrderbook 判断之前,确保始终执行仓位检查

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