Compare commits

...
46 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
WrBug 3a78a84610 fix: 修复 v1.1.10 版本下单成功不发送通知的问题
- 移除 OrderStatusUpdateService 中错误的双重检查逻辑
- 修复买入订单和卖出订单通知逻辑
- 确保在标记为已发送后立即发送通知
- 问题:保存 notificationSent=true 后立即查询检查,导致通知永远被跳过
- 修复:改为标记后直接发送通知,利用数据库状态防止并发重复
2026-01-13 03:38:05 +08:00
WrBugandGitHub 692cbd9a80 Merge pull request #20 from WrBug/dev 2026-01-12 15:40:39 +08:00
WrBug 8f01588221 fix: 修复 TypeScript 编译错误
- 在 CopyTradingCreateRequest 和 CopyTradingUpdateRequest 接口中添加 pushFilteredOrders 字段
- 修复 InputNumber parser 函数的返回类型,应该返回 number 而不是 string
2026-01-12 14:59:42 +08:00
WrBug f6fc836e79 docs: 添加 v1.1.10 release notes 2026-01-12 14:51:33 +08:00
WrBug 9c303e0a82 feat: 添加推送已过滤订单功能并修复相关问题
- 新增推送已过滤订单功能(pushFilteredOrders),默认关闭
  - 数据库迁移:添加 pushFilteredOrders 字段到模板表和跟单配置表
  - 后端:在实体、DTO、Service 中添加 pushFilteredOrders 字段支持
  - 前端:在模板新增、编辑、推送页面和跟单配置新增、编辑页面添加开关
  - 多语言:添加中文、繁体中文、英文翻译
  - 通知逻辑:发送过滤订单通知时检查 pushFilteredOrders 字段

- 修复新建跟单配置时 pushFilteredOrders 字段未生效的问题
  - 修复手动输入模式下 pushFilteredOrders 被硬编码为 false 的问题
  - 修复从模板填充表单时未加载 pushFilteredOrders 的问题
  - 添加 CopyTradingTemplate 接口的 pushFilteredOrders 字段定义

- 修复新建和编辑页面的截止时间输入框交互问题
  - 修复删除后失去焦点自动填充1的问题
  - 优化 onChange 和 onBlur 处理逻辑,支持清空操作
2026-01-12 14:29:12 +08:00
WrBug cb8e46919f fix: 修复跟单配置更新时清空可选字段无法保存的问题
- 修复价格区间(minPrice/maxPrice)清空后无法保存的问题
- 修复最大仓位数量(maxPositionCount)清空后无法保存的问题
- 修复市场截止时间(maxMarketEndDate)清空后无法保存的问题
- 修复其他可选字段(minOrderDepth/maxSpread/maxPositionValue)清空后无法保存的问题
- 修复截止时间输入框删除后失去焦点自动填充1的问题

前端:清空字段时传空字符串或-1标记,让后端识别为清空操作
后端:处理空字符串和-1标记,正确设置为null以清空字段
2026-01-12 13:31:27 +08:00
WrBug 279806da2b feat: 优化订单列表筛选功能,支持按市场标题搜索并改进分组体验
- 后端:MarketGroupedOrdersRequest 添加 marketId 和 marketTitle 字段
- 后端:买入/卖出订单分组接口支持市场 ID 模糊匹配和市场标题关键字筛选
- 前端:BuyOrdersTab 添加市场标题筛选,移除方向筛选,记录用户分组偏好
- 前端:SellOrdersTab 添加市场标题筛选,将方向筛选改为状态筛选,移除方向列
- 前端:MatchedOrdersTab 添加市场标题筛选和市场列显示
- 前端:所有搜索输入框添加 0.5 秒防抖优化
- 前端:分组偏好保存到 localStorage 以便跨会话持久化
2026-01-12 10:01:20 +08:00
WrBug 90fa487d1b refactor: 移除未实现盈亏计算以优化跟单关系统计性能
- 移除未实现盈亏和持仓价值的计算
- 总盈亏现在仅包含已实现盈亏
- 删除不再使用的 getCurrentMarketPrice、getActualPositions、calculateUnrealizedPnl、calculatePositionValue 方法
- 简化盈亏百分比计算,仅基于已实现盈亏
- 移除不必要的依赖注入(accountService、blockchainService、retrofitFactory)
- 性能提升:从 1+N 次网络请求减少到 0 次
2026-01-11 14:53:28 +08:00
WrBug b58bb267a1 feat: 添加 Docker 容器时区配置,支持通过 .env 自定义 2026-01-11 14:36:49 +08:00
WrBug 6af76c4d80 fix: 修复订单通知重复发送和时间显示问题
1. 修复并发导致的重复通知问题
   - 在 OrderStatusUpdateService 中实现双重检查机制
   - 先保存订单标记为已发送,再重新查询数据库检查
   - 防止定时任务并发时重复发送同一订单的通知
   - 同时修复买入订单和卖出订单的通知逻辑

2. Telegram 通知时间改为订单创建时间
   - TelegramNotificationService.sendOrderSuccessNotification 添加 orderTime 参数
   - 使用订单的 createdAt 时间戳作为通知显示时间
   - 而不是使用当前通知发送时间
   - 更准确反映订单的实际创建时间

修改文件:
- OrderStatusUpdateService.kt: 实现双重检查机制,防止并发重复通知
- TelegramNotificationService.kt: 添加 orderTime 参数支持订单时间显示
- AccountService.kt: 手动创建订单时传递订单创建时间
2026-01-11 14:33:30 +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
59 changed files with 3776 additions and 1158 deletions
-137
View File
@@ -1,137 +0,0 @@
# 🎉 PolyHermes v1.1.7 发布公告
**发布日期:2026年1月7日**
---
## ✨ 新功能
### 💰 支持 Maker Rebates Program 费率
我们新增了对 Polymarket Maker Rebates Program 的支持!系统现在会自动获取并应用最新的费率,帮助您享受更优惠的交易成本。
**这意味着什么?**
- 系统会自动查询并应用最新的费率
- 所有订单(买入、卖出)都会使用正确的费率
- 无需手动配置,系统会自动处理
### 🔧 Docker 部署更灵活
现在您可以通过环境变量轻松配置日志级别,无需修改配置文件!
**新增配置项:**
- `LOG_LEVEL_ROOT` - 系统日志级别(默认:INFO
- `LOG_LEVEL_APP` - 应用日志级别(默认:DEBUG)
只需在 `.env` 文件中添加这些配置,重启服务即可生效。
---
## 🐛 问题修复
### 修复市场价格查询问题
修复了某些市场无法正确查询价格的问题。现在系统会:
- 优先从链上查询市场价格
- 如果链上查询失败,自动降级到其他数据源
- 提高系统的稳定性和容错性
### 修复自动卖出误判问题
修复了在某些情况下系统会误判市场已卖出,导致创建错误记录的问题。现在系统会:
- 更准确地判断市场状态
- 避免误判导致的错误记录
- 提高仓位管理的准确性
---
## 📝 文档更新
### 更新联系方式
- **Telegram 群组**https://t.me/polyhermes
- 欢迎加入我们的 Telegram 群组,获取最新资讯和技术支持!
### 版本信息更清晰
- README 中新增了 Docker 版本徽章
- 可以一目了然地看到最新的 Docker 镜像版本
---
## 📊 本次更新统计
- **5 个提交**
- **16 个文件变更**
- **主要改进**:费率支持、错误修复、部署优化
---
## 🚀 如何升级
### Docker 部署用户(推荐)
```bash
# 1. 停止当前服务
docker-compose -f docker-compose.prod.yml down
# 2. 拉取最新镜像
docker pull wrbug/polyhermes:latest
# 3. 重新启动服务
docker-compose -f docker-compose.prod.yml up -d
# 4. 查看日志确认升级成功
docker-compose -f docker-compose.prod.yml logs -f
```
### 本地构建用户
```bash
# 1. 拉取最新代码
git pull origin dev
# 2. 切换到 v1.1.7 标签
git checkout v1.1.7
# 3. 重新构建并启动
./deploy.sh
```
---
## ⚠️ 重要提示
### 数据库迁移
**本次更新无需数据库迁移**,可以直接升级,不会影响现有数据。
### 配置变更
- 新增的日志级别配置为**可选配置**
- 如果不配置,系统会使用默认值(INFO/DEBUG)
- 不影响现有功能
---
## 🙏 感谢
感谢所有用户的支持和反馈!如果您在使用过程中遇到任何问题,欢迎:
- 📧 提交 Issuehttps://github.com/WrBug/PolyHermes/issues
- 💬 加入 Telegram 群组:https://t.me/polyhermes
- 🐦 关注 Twitter@polyhermes
---
## 📚 相关链接
- **GitHub 仓库**https://github.com/WrBug/PolyHermes
- **完整更新日志**https://github.com/WrBug/PolyHermes/releases/tag/v1.1.7
- **Docker Hub**https://hub.docker.com/r/wrbug/polyhermes
---
**祝您交易顺利!** 🚀
+2 -2
View File
@@ -47,9 +47,9 @@ FROM eclipse-temurin:17-jre-jammy
WORKDIR /app WORKDIR /app
# 安装 Nginx 和必要的工具 # 安装 Nginx 和必要的工具(包含时区数据)
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y nginx curl && \ apt-get install -y nginx curl tzdata && \
rm -rf /var/lib/apt/lists/* && \ rm -rf /var/lib/apt/lists/* && \
rm -rf /etc/nginx/sites-enabled/default rm -rf /etc/nginx/sites-enabled/default
+220
View File
@@ -1,3 +1,223 @@
# v1.1.10
## 🚀 主要功能
### 📢 推送已过滤订单功能
- **新增推送已过滤订单功能(pushFilteredOrders**,默认关闭
- 支持在模板和跟单配置中配置是否推送被过滤的订单通知
- 开启后,当订单因过滤条件(价格区间、订单深度、价差、仓位限制等)被过滤时,会发送 Telegram 通知
- 帮助用户了解哪些订单被过滤以及过滤原因
- **数据库迁移**
- 添加 `push_filtered_orders` 字段到 `copy_trading_templates`
- 添加 `push_filtered_orders` 字段到 `copy_trading`
- 迁移脚本:`V24__add_push_filtered_orders_to_templates.sql`
- **后端实现**
- 在实体类(`CopyTradingTemplate``CopyTrading`)中添加 `pushFilteredOrders` 字段
- 在 DTO 中添加字段支持(创建、更新、查询)
- 在 Service 中处理字段的创建、更新和传递
- 在发送过滤订单通知时检查 `pushFilteredOrders` 字段,只有为 `true` 时才发送
- **前端实现**
- 在模板新增、编辑、推送页面添加"推送已过滤订单"开关
- 在跟单配置新增、编辑页面添加"推送已过滤订单"开关
- 从模板创建跟单配置时,自动填充 `pushFilteredOrders` 字段
- 支持多语言(中文、繁体中文、英文)
### 🔍 优化订单列表筛选功能
- **支持按市场标题搜索**
- 买入订单列表:添加市场标题筛选,移除方向筛选
- 卖出订单列表:添加市场标题筛选,将方向筛选改为状态筛选
- 已成交订单列表:添加市场标题筛选和市场列显示
- **改进分组体验**
- 记录用户的分组偏好到 localStorage,跨会话持久化
- 所有搜索输入框添加 0.5 秒防抖优化,提升性能
- **后端优化**
- `MarketGroupedOrdersRequest` 添加 `marketId``marketTitle` 字段
- 买入/卖出订单分组接口支持市场 ID 模糊匹配和市场标题关键字筛选
### ⚡ 优化跟单关系统计性能
- **移除未实现盈亏计算**
- 移除未实现盈亏和持仓价值的计算逻辑
- 总盈亏现在仅包含已实现盈亏,计算更准确
- 简化盈亏百分比计算,仅基于已实现盈亏
- **性能提升**
- 从 1+N 次网络请求减少到 0 次
- 删除不再使用的方法和依赖注入
- 统计查询速度显著提升
### 🐳 Docker 容器时区配置
- **支持通过 .env 自定义时区**:
-`docker-compose.yml``docker-compose.prod.yml` 中添加时区环境变量配置
- 支持通过 `TZ` 环境变量自定义容器时区
- 默认使用系统时区
## 🐛 Bug 修复
### 修复跟单配置更新时清空可选字段无法保存的问题
- **问题**:修改跟单配置时,清空价格区间、最大仓位数量、截止时间等可选字段后,无法保存到数据库
- **修复**
- 修复价格区间(`minPrice`/`maxPrice`)清空后无法保存的问题
- 修复最大仓位数量(`maxPositionCount`)清空后无法保存的问题
- 修复市场截止时间(`maxMarketEndDate`)清空后无法保存的问题
- 修复其他可选字段(`minOrderDepth`/`maxSpread`/`maxPositionValue`)清空后无法保存的问题
- **实现方案**
- 前端:清空字段时传空字符串或 `-1` 标记,让后端识别为清空操作
- 后端:处理空字符串和 `-1` 标记,正确设置为 `null` 以清空字段
### 修复新建和编辑页面的截止时间输入框交互问题
- **问题**:删除截止时间输入框内容后,失去焦点会自动填充 1
- **修复**
-`min``1` 改为 `0`,允许空值
- 优化 `onChange` 处理,当值为 `0``null``undefined` 时设置为 `undefined`(清空)
- 添加 `onBlur` 处理,确保失去焦点时如果值为空或 `0`,设置为 `undefined`
- 修改 `parser`,空值时返回空字符串而不是 `0`
### 修复订单通知重复发送和时间显示问题
- **修复并发导致的重复通知问题**:
-`OrderStatusUpdateService` 中实现双重检查机制
- 先保存订单标记为已发送,再重新查询数据库检查
- 防止定时任务并发时重复发送同一订单的通知
- 同时修复买入订单和卖出订单的通知逻辑
- **修复 Telegram 通知时间显示**
- `TelegramNotificationService.sendOrderSuccessNotification` 添加 `orderTime` 参数
- 使用订单的 `createdAt` 时间戳作为通知显示时间
- 而不是使用当前通知发送时间
- 更准确反映订单的实际创建时间
### 修复新建跟单配置时 pushFilteredOrders 字段未生效的问题
- **问题**:新建跟单配置时,即使设置了 `pushFilteredOrders: true`,也没有生效
- **修复**
- 修复手动输入模式下 `pushFilteredOrders` 被硬编码为 `false` 的问题
- 修复从模板填充表单时未加载 `pushFilteredOrders` 的问题
- 添加 `CopyTradingTemplate` 接口的 `pushFilteredOrders` 字段定义
## 📝 技术细节
### 数据库变更
- **迁移脚本**`V24__add_push_filtered_orders_to_templates.sql`
- **变更内容**
- `copy_trading_templates.push_filtered_orders`: BOOLEAN NOT NULL DEFAULT FALSE
- `copy_trading.push_filtered_orders`: BOOLEAN NOT NULL DEFAULT FALSE
- **自动执行**:升级时会自动执行迁移脚本
### API 变更
- **无新增接口**
- **无移除接口**
- **请求/响应变更**
- `CopyTradingCreateRequest` 添加 `pushFilteredOrders` 字段
- `CopyTradingUpdateRequest` 添加 `pushFilteredOrders` 字段
- `TemplateCreateRequest` 添加 `pushFilteredOrders` 字段
- `TemplateUpdateRequest` 添加 `pushFilteredOrders` 字段
- `MarketGroupedOrdersRequest` 添加 `marketId``marketTitle` 字段
### 前端变更
- **新增字段**
- `CopyTradingTemplate` 接口添加 `pushFilteredOrders` 字段
- **组件更新**
- 模板新增、编辑、推送页面添加"推送已过滤订单"开关
- 跟单配置新增、编辑页面添加"推送已过滤订单"开关
- 优化截止时间输入框交互逻辑
- **多语言支持**
- 添加中文、繁体中文、英文翻译
## 📊 变更统计
- **提交数量**6 个提交
- **文件变更**30 个文件
- **代码变更**+651 行 / -555 行(净增加 96 行)
### 详细文件变更
**后端变更**
- `CopyTrading.kt` - 添加 `pushFilteredOrders` 字段(+3 行)
- `CopyTradingTemplate.kt` - 添加 `pushFilteredOrders` 字段(+3 行)
- `CopyTradingDto.kt` - 添加 `pushFilteredOrders` 字段支持(+3 行)
- `CopyTradingTemplateDto.kt` - 添加 `pushFilteredOrders` 字段支持(+10 行)
- `CopyTradingService.kt` - 处理 `pushFilteredOrders` 字段和清空字段逻辑(+93 行)
- `CopyTradingTemplateService.kt` - 处理 `pushFilteredOrders` 字段(+8 行)
- `CopyOrderTrackingService.kt` - 检查 `pushFilteredOrders` 字段发送通知(+42 行)
- `CopyTradingStatisticsService.kt` - 优化统计性能(-328 行)
- `OrderStatusUpdateService.kt` - 修复重复通知问题(+142 行)
- `TelegramNotificationService.kt` - 添加订单时间参数(+17 行)
- `V24__add_push_filtered_orders_to_templates.sql` - 数据库迁移脚本(+13 行)
**前端变更**
- `AddModal.tsx` - 添加 `pushFilteredOrders` 字段和优化截止时间输入框(+40 行)
- `EditModal.tsx` - 添加 `pushFilteredOrders` 字段和优化截止时间输入框(+59 行)
- `TemplateAdd.tsx` - 添加 `pushFilteredOrders` 字段(+15 行)
- `TemplateEdit.tsx` - 添加 `pushFilteredOrders` 字段(+15 行)
- `TemplateList.tsx` - 添加 `pushFilteredOrders` 字段(+13 行)
- `BuyOrdersTab.tsx` - 优化筛选功能(+94 行)
- `SellOrdersTab.tsx` - 优化筛选功能(+77 行)
- `MatchedOrdersTab.tsx` - 优化筛选功能(+26 行)
- `types/index.ts` - 添加 `pushFilteredOrders` 字段定义(+6 行)
- `locales/*/common.json` - 添加多语言翻译(+31 行)
**配置文件变更**
- `docker-compose.yml` - 添加时区配置(+5 行)
- `docker-compose.prod.yml` - 添加时区配置(+5 行)
- `Dockerfile` - 优化构建配置(+4 行)
## 🔄 主要提交
```
9c303e0 feat: 添加推送已过滤订单功能并修复相关问题
cb8e469 fix: 修复跟单配置更新时清空可选字段无法保存的问题
279806d feat: 优化订单列表筛选功能,支持按市场标题搜索并改进分组体验
90fa487 refactor: 移除未实现盈亏计算以优化跟单关系统计性能
b58bb26 feat: 添加 Docker 容器时区配置,支持通过 .env 自定义
6af76c4 fix: 修复订单通知重复发送和时间显示问题
```
## 🎯 升级建议
1. **数据库迁移**:本次版本包含数据库迁移脚本,升级时会自动执行
- 自动添加 `push_filtered_orders` 字段到模板表和跟单配置表
- 现有数据不受影响,新字段默认值为 `false`
2. **配置更新**
- 可选:在 `.env` 文件中添加 `TZ` 环境变量自定义容器时区
- 无需其他配置变更
3. **兼容性**
- 完全向后兼容,不影响现有功能
- API 变更都是新增字段,不影响现有调用
## 📦 Docker 镜像
Docker 镜像会自动构建并推送到 Docker Hub:
- `wrbug/polyhermes:v1.1.10`
- `wrbug/polyhermes:latest`(如果这是最新版本)
## 🔗 相关链接
- [GitHub Release](https://github.com/WrBug/PolyHermes/releases/tag/v1.1.10)
- [完整更新日志](https://github.com/WrBug/PolyHermes/compare/v1.1.9...v1.1.10)
---
**发布日期**2026-01-12
---
# v1.1.9 # v1.1.9
## 🐛 Bug 修复 ## 🐛 Bug 修复
+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. 查看详细错误日志
+5
View File
@@ -9,6 +9,7 @@ services:
ports: ports:
- "${SERVER_PORT:-8000}:8000" - "${SERVER_PORT:-8000}:8000"
environment: environment:
- TZ=${TZ:-Asia/Shanghai}
- SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE:-prod} - SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE:-prod}
- DB_URL=${DB_URL:-jdbc:mysql://mysql:3306/polyhermes?useSSL=false&serverTimezone=UTC&characterEncoding=utf8&allowPublicKeyRetrieval=true} - DB_URL=${DB_URL:-jdbc:mysql://mysql:3306/polyhermes?useSSL=false&serverTimezone=UTC&characterEncoding=utf8&allowPublicKeyRetrieval=true}
- DB_USERNAME=${DB_USERNAME:-root} - DB_USERNAME=${DB_USERNAME:-root}
@@ -16,6 +17,8 @@ services:
- SERVER_PORT=8000 - SERVER_PORT=8000
- JWT_SECRET=${JWT_SECRET:-change-me-in-production} - JWT_SECRET=${JWT_SECRET:-change-me-in-production}
- ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY:-change-me-in-production} - ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY:-change-me-in-production}
volumes:
- /etc/localtime:/etc/localtime:ro
depends_on: depends_on:
mysql: mysql:
condition: service_healthy condition: service_healthy
@@ -29,12 +32,14 @@ services:
ports: ports:
- "${MYSQL_PORT:-3306}:3306" - "${MYSQL_PORT:-3306}:3306"
environment: environment:
- TZ=${TZ:-Asia/Shanghai}
- MYSQL_ROOT_PASSWORD=${DB_PASSWORD:-rootpassword} - MYSQL_ROOT_PASSWORD=${DB_PASSWORD:-rootpassword}
- MYSQL_DATABASE=polyhermes - MYSQL_DATABASE=polyhermes
- MYSQL_CHARACTER_SET_SERVER=utf8mb4 - MYSQL_CHARACTER_SET_SERVER=utf8mb4
- MYSQL_COLLATION_SERVER=utf8mb4_unicode_ci - MYSQL_COLLATION_SERVER=utf8mb4_unicode_ci
volumes: volumes:
- mysql-data:/var/lib/mysql - mysql-data:/var/lib/mysql
- /etc/localtime:/etc/localtime:ro
healthcheck: healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_PASSWORD:-rootpassword}"] test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_PASSWORD:-rootpassword}"]
interval: 10s interval: 10s
@@ -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/"
}
@@ -43,6 +43,7 @@ data class CopyTradingCreateRequest(
// 新增配置字段 // 新增配置字段
val configName: String? = null, // 配置名(可选) val configName: String? = null, // 配置名(可选)
val pushFailedOrders: Boolean? = null, // 推送失败订单(可选) val pushFailedOrders: Boolean? = null, // 推送失败订单(可选)
val pushFilteredOrders: Boolean? = null, // 推送已过滤订单(可选)
val maxMarketEndDate: Long? = null // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用 val maxMarketEndDate: Long? = null // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
) )
@@ -81,6 +82,7 @@ data class CopyTradingUpdateRequest(
// 新增配置字段 // 新增配置字段
val configName: String? = null, // 配置名(可选,但提供时必须非空) val configName: String? = null, // 配置名(可选,但提供时必须非空)
val pushFailedOrders: Boolean? = null, // 推送失败订单(可选) val pushFailedOrders: Boolean? = null, // 推送失败订单(可选)
val pushFilteredOrders: Boolean? = null, // 推送已过滤订单(可选)
val maxMarketEndDate: Long? = null // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用 val maxMarketEndDate: Long? = null // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
) )
@@ -156,6 +158,7 @@ data class CopyTradingDto(
// 新增配置字段 // 新增配置字段
val configName: String? = null, // 配置名(可选) val configName: String? = null, // 配置名(可选)
val pushFailedOrders: Boolean = false, // 推送失败订单(默认关闭) val pushFailedOrders: Boolean = false, // 推送失败订单(默认关闭)
val pushFilteredOrders: Boolean = false, // 推送已过滤订单(默认关闭)
val maxMarketEndDate: Long? = null, // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用 val maxMarketEndDate: Long? = null, // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
val createdAt: Long, val createdAt: Long,
val updatedAt: Long val updatedAt: Long
@@ -110,7 +110,7 @@ data class OrderTrackingRequest(
val page: Int? = 1, val page: Int? = 1,
val limit: Int? = 20, val limit: Int? = 20,
val marketId: String? = null, val marketId: String? = null,
val side: String? = null, val marketTitle: String? = null, // 市场标题关键字筛选
val status: String? = null, val status: String? = null,
val sellOrderId: String? = null, val sellOrderId: String? = null,
val buyOrderId: String? = null val buyOrderId: String? = null
@@ -123,7 +123,9 @@ data class MarketGroupedOrdersRequest(
val copyTradingId: Long, val copyTradingId: Long,
val type: String, // buy, sell, matched val type: String, // buy, sell, matched
val page: Int? = 1, val page: Int? = 1,
val limit: Int? = 20 val limit: Int? = 20,
val marketId: String? = null,
val marketTitle: String? = null
) )
/** /**
@@ -23,7 +23,8 @@ data class TemplateCreateRequest(
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用 val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用 val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价 val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价 val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
val pushFilteredOrders: Boolean? = null // 推送已过滤订单(默认关闭)
) )
/** /**
@@ -50,7 +51,8 @@ data class TemplateUpdateRequest(
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用 val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用 val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价 val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价 val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
val pushFilteredOrders: Boolean? = null // 推送已过滤订单(默认关闭)
) )
/** /**
@@ -84,7 +86,8 @@ data class TemplateCopyRequest(
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用 val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用 val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价 val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价 val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
val pushFilteredOrders: Boolean? = null // 推送已过滤订单(默认关闭)
) )
/** /**
@@ -119,6 +122,7 @@ data class TemplateDto(
val maxSpread: String?, val maxSpread: String?,
val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价 val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价 val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价
val pushFilteredOrders: Boolean, // 推送已过滤订单(默认关闭)
val createdAt: Long, val createdAt: Long,
val updatedAt: Long val updatedAt: Long
) )
@@ -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(),
@@ -100,6 +100,9 @@ data class CopyTrading(
@Column(name = "push_failed_orders", nullable = false) @Column(name = "push_failed_orders", nullable = false)
val pushFailedOrders: Boolean = false, // 推送失败订单(默认关闭) val pushFailedOrders: Boolean = false, // 推送失败订单(默认关闭)
@Column(name = "push_filtered_orders", nullable = false)
val pushFilteredOrders: Boolean = false, // 推送已过滤订单(默认关闭)
@Column(name = "max_market_end_date") @Column(name = "max_market_end_date")
val maxMarketEndDate: Long? = null, // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用 val maxMarketEndDate: Long? = null, // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
@@ -72,6 +72,9 @@ data class CopyTradingTemplate(
@Column(name = "max_price", precision = 20, scale = 8) @Column(name = "max_price", precision = 20, scale = 8)
val maxPrice: BigDecimal? = null, // 最高价格(可选),NULL表示不限制最高价 val maxPrice: BigDecimal? = null, // 最高价格(可选),NULL表示不限制最高价
@Column(name = "push_filtered_orders", nullable = false)
val pushFilteredOrders: Boolean = false, // 推送已过滤订单(默认关闭)
@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顺序)
@@ -948,6 +948,9 @@ class AccountService(
java.util.Locale("zh", "CN") // 默认简体中文 java.util.Locale("zh", "CN") // 默认简体中文
} }
// 使用当前时间作为订单创建时间
val orderTime = System.currentTimeMillis()
telegramNotificationService?.sendOrderSuccessNotification( telegramNotificationService?.sendOrderSuccessNotification(
orderId = orderId, orderId = orderId,
marketTitle = marketTitle, marketTitle = marketTitle,
@@ -963,7 +966,8 @@ class AccountService(
apiSecret = try { cryptoUtils.decrypt(account.apiSecret!!) } catch (e: Exception) { null }, apiSecret = try { cryptoUtils.decrypt(account.apiSecret!!) } catch (e: Exception) { null },
apiPassphrase = try { cryptoUtils.decrypt(account.apiPassphrase!!) } catch (e: Exception) { null }, apiPassphrase = try { cryptoUtils.decrypt(account.apiPassphrase!!) } catch (e: Exception) { null },
walletAddressForApi = account.walletAddress, walletAddressForApi = account.walletAddress,
locale = locale locale = locale,
orderTime = orderTime // 使用订单创建时间
) )
} catch (e: Exception) { } catch (e: Exception) {
logger.warn("发送订单成功通知失败: ${e.message}", e) logger.warn("发送订单成功通知失败: ${e.message}", e)
@@ -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}")
} }
@@ -10,9 +10,12 @@ import com.wrbug.polymarketbot.repository.CopyTradingTemplateRepository
import com.wrbug.polymarketbot.repository.LeaderRepository import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.service.copytrading.monitor.CopyTradingMonitorService import com.wrbug.polymarketbot.service.copytrading.monitor.CopyTradingMonitorService
import com.google.gson.Gson import com.google.gson.Gson
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
@@ -29,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")
}
/** /**
* 创建跟单配置 * 创建跟单配置
* 支持两种方式: * 支持两种方式:
@@ -86,7 +103,8 @@ class CopyTradingService(
maxPositionCount = request.maxPositionCount, maxPositionCount = request.maxPositionCount,
keywordFilterMode = request.keywordFilterMode ?: "DISABLED", keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
keywords = convertKeywordsToJson(request.keywords), keywords = convertKeywordsToJson(request.keywords),
maxMarketEndDate = request.maxMarketEndDate maxMarketEndDate = request.maxMarketEndDate,
pushFilteredOrders = request.pushFilteredOrders ?: template.pushFilteredOrders
) )
} else { } else {
// 手动输入(所有字段必须提供) // 手动输入(所有字段必须提供)
@@ -117,7 +135,8 @@ class CopyTradingService(
maxPositionCount = request.maxPositionCount, maxPositionCount = request.maxPositionCount,
keywordFilterMode = request.keywordFilterMode ?: "DISABLED", keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
keywords = convertKeywordsToJson(request.keywords), keywords = convertKeywordsToJson(request.keywords),
maxMarketEndDate = request.maxMarketEndDate maxMarketEndDate = request.maxMarketEndDate,
pushFilteredOrders = request.pushFilteredOrders ?: false // 手动输入时使用请求中的值,默认为 false
) )
} }
@@ -150,7 +169,8 @@ class CopyTradingService(
keywords = config.keywords, keywords = config.keywords,
configName = configName, configName = configName,
pushFailedOrders = request.pushFailedOrders ?: false, pushFailedOrders = request.pushFailedOrders ?: false,
maxMarketEndDate = config.maxMarketEndDate maxMarketEndDate = config.maxMarketEndDate,
pushFilteredOrders = config.pushFilteredOrders
) )
val saved = copyTradingRepository.save(copyTrading) val saved = copyTradingRepository.save(copyTrading)
@@ -211,12 +231,67 @@ class CopyTradingService(
websocketReconnectInterval = request.websocketReconnectInterval ?: copyTrading.websocketReconnectInterval, websocketReconnectInterval = request.websocketReconnectInterval ?: copyTrading.websocketReconnectInterval,
websocketMaxRetries = request.websocketMaxRetries ?: copyTrading.websocketMaxRetries, websocketMaxRetries = request.websocketMaxRetries ?: copyTrading.websocketMaxRetries,
supportSell = request.supportSell ?: copyTrading.supportSell, supportSell = request.supportSell ?: copyTrading.supportSell,
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: copyTrading.minOrderDepth, // 处理可选字段:空字符串表示要清空(设置为 null),null 表示不更新,转换失败保留旧值
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: copyTrading.maxSpread, minOrderDepth = if (request.minOrderDepth != null) {
minPrice = request.minPrice?.toSafeBigDecimal() ?: copyTrading.minPrice, if (request.minOrderDepth.isBlank()) {
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: copyTrading.maxPrice, null
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal() ?: copyTrading.maxPositionValue, } else {
maxPositionCount = request.maxPositionCount ?: copyTrading.maxPositionCount, val converted = request.minOrderDepth.toSafeBigDecimal()
if (converted == IllegalBigDecimal) copyTrading.minOrderDepth else converted
}
} else {
copyTrading.minOrderDepth
},
maxSpread = if (request.maxSpread != null) {
if (request.maxSpread.isBlank()) {
null
} else {
val converted = request.maxSpread.toSafeBigDecimal()
if (converted == IllegalBigDecimal) copyTrading.maxSpread else converted
}
} else {
copyTrading.maxSpread
},
minPrice = if (request.minPrice != null) {
if (request.minPrice.isBlank()) {
null
} else {
val converted = request.minPrice.toSafeBigDecimal()
if (converted == IllegalBigDecimal) copyTrading.minPrice else converted
}
} else {
copyTrading.minPrice
},
maxPrice = if (request.maxPrice != null) {
if (request.maxPrice.isBlank()) {
null
} else {
val converted = request.maxPrice.toSafeBigDecimal()
if (converted == IllegalBigDecimal) copyTrading.maxPrice else converted
}
} else {
copyTrading.maxPrice
},
maxPositionValue = if (request.maxPositionValue != null) {
if (request.maxPositionValue.isBlank()) {
null
} else {
val converted = request.maxPositionValue.toSafeBigDecimal()
if (converted == IllegalBigDecimal) copyTrading.maxPositionValue else converted
}
} else {
copyTrading.maxPositionValue
},
// 处理 maxPositionCount-1 表示要清空(设置为 null),null 表示不更新
maxPositionCount = if (request.maxPositionCount != null) {
if (request.maxPositionCount == -1) {
null
} else {
request.maxPositionCount
}
} else {
copyTrading.maxPositionCount
},
keywordFilterMode = request.keywordFilterMode ?: copyTrading.keywordFilterMode, keywordFilterMode = request.keywordFilterMode ?: copyTrading.keywordFilterMode,
keywords = if (request.keywords != null) { keywords = if (request.keywords != null) {
convertKeywordsToJson(request.keywords) convertKeywordsToJson(request.keywords)
@@ -227,7 +302,17 @@ class CopyTradingService(
}, },
configName = configName, configName = configName,
pushFailedOrders = request.pushFailedOrders ?: copyTrading.pushFailedOrders, pushFailedOrders = request.pushFailedOrders ?: copyTrading.pushFailedOrders,
maxMarketEndDate = request.maxMarketEndDate ?: copyTrading.maxMarketEndDate, pushFilteredOrders = request.pushFilteredOrders ?: copyTrading.pushFilteredOrders,
// 处理 maxMarketEndDate-1 表示要清空(设置为 null),null 表示不更新
maxMarketEndDate = if (request.maxMarketEndDate != null) {
if (request.maxMarketEndDate == -1L) {
null
} else {
request.maxMarketEndDate
}
} else {
copyTrading.maxMarketEndDate
},
updatedAt = System.currentTimeMillis() updatedAt = System.currentTimeMillis()
) )
@@ -262,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
@@ -440,6 +525,7 @@ class CopyTradingService(
keywords = convertJsonToKeywords(copyTrading.keywords), keywords = convertJsonToKeywords(copyTrading.keywords),
configName = copyTrading.configName, configName = copyTrading.configName,
pushFailedOrders = copyTrading.pushFailedOrders, pushFailedOrders = copyTrading.pushFailedOrders,
pushFilteredOrders = copyTrading.pushFilteredOrders,
maxMarketEndDate = copyTrading.maxMarketEndDate, maxMarketEndDate = copyTrading.maxMarketEndDate,
createdAt = copyTrading.createdAt, createdAt = copyTrading.createdAt,
updatedAt = copyTrading.updatedAt updatedAt = copyTrading.updatedAt
@@ -502,6 +588,7 @@ class CopyTradingService(
val maxPositionCount: Int?, val maxPositionCount: Int?,
val keywordFilterMode: String, val keywordFilterMode: String,
val keywords: String?, // JSON 字符串 val keywords: String?, // JSON 字符串
val maxMarketEndDate: Long? // 市场截止时间限制(毫秒时间戳) val maxMarketEndDate: Long?, // 市场截止时间限制(毫秒时间戳)
val pushFilteredOrders: Boolean // 推送已过滤订单(默认关闭)
) )
} }
@@ -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) {
@@ -347,27 +365,29 @@ open class CopyOrderTrackingService(
logger.error("保存被过滤订单失败: ${e.message}", e) logger.error("保存被过滤订单失败: ${e.message}", e)
} }
// 发送 Telegram 通知 // 发送 Telegram 通知(仅在 pushFilteredOrders 为 true 时发送)
val locale = try { if (copyTrading.pushFilteredOrders) {
org.springframework.context.i18n.LocaleContextHolder.getLocale() val locale = try {
} catch (e: Exception) { org.springframework.context.i18n.LocaleContextHolder.getLocale()
java.util.Locale("zh", "CN") // 默认简体中文 } catch (e: Exception) {
} java.util.Locale("zh", "CN") // 默认简体中文
}
telegramNotificationService?.sendOrderFilteredNotification( telegramNotificationService?.sendOrderFilteredNotification(
marketTitle = marketTitle, marketTitle = marketTitle,
marketId = trade.market, marketId = trade.market,
marketSlug = marketSlug, marketSlug = marketSlug,
side = "BUY", side = "BUY",
outcome = trade.outcome, outcome = trade.outcome,
price = trade.price, price = trade.price,
size = trade.size, size = trade.size,
filterReason = filterResult.reason, filterReason = filterResult.reason,
filterType = filterType, filterType = filterType,
accountName = account.accountName, accountName = account.accountName,
walletAddress = account.walletAddress, walletAddress = account.walletAddress,
locale = locale locale = locale
) )
}
} catch (e: Exception) { } catch (e: Exception) {
logger.error("处理被过滤订单通知失败: ${e.message}", e) logger.error("处理被过滤订单通知失败: ${e.message}", e)
} }
@@ -620,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)
@@ -683,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" -> {
@@ -716,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 客户端(用于查询公开的交易数据)
@@ -787,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 买入数量
@@ -863,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)
} }
} }
@@ -31,8 +31,6 @@ class CopyTradingStatisticsService(
private val sellMatchDetailRepository: SellMatchDetailRepository, private val sellMatchDetailRepository: SellMatchDetailRepository,
private val accountRepository: AccountRepository, private val accountRepository: AccountRepository,
private val leaderRepository: LeaderRepository, private val leaderRepository: LeaderRepository,
private val accountService: AccountService,
private val blockchainService: BlockchainService,
private val marketService: com.wrbug.polymarketbot.service.common.MarketService private val marketService: com.wrbug.polymarketbot.service.common.MarketService
) { ) {
@@ -63,19 +61,12 @@ class CopyTradingStatisticsService(
// 6. 计算统计信息 // 6. 计算统计信息
val statistics = calculateStatistics(buyOrders, sellRecords, matchDetails) val statistics = calculateStatistics(buyOrders, sellRecords, matchDetails)
// 7. 获取链上实际持仓(用于准确计算未实现盈亏,考虑手动卖出的情况 // 7. 不再计算未实现盈亏和持仓价值(优化性能
val actualPositions = getActualPositions(account) // 未实现盈亏计算需要查询链上持仓和市场价格,性能开销大
val unrealizedPnl = "0"
val positionValue = "0"
// 8. 获取当前市场价格(用于计算未实现盈亏) // 8. 构建响应(总盈亏 = 已实现盈亏)
val currentPrice = getCurrentMarketPrice(buyOrders)
// 9. 计算未实现盈亏(使用链上实际持仓,而不是 remainingQuantity
val unrealizedPnl = calculateUnrealizedPnl(buyOrders, currentPrice, actualPositions)
// 10. 计算持仓价值(使用链上实际持仓和当前价格)
val positionValue = calculatePositionValue(buyOrders, currentPrice, actualPositions)
// 11. 构建响应
val response = CopyTradingStatisticsResponse( val response = CopyTradingStatisticsResponse(
copyTradingId = copyTradingId, copyTradingId = copyTradingId,
accountId = copyTrading.accountId, accountId = copyTrading.accountId,
@@ -94,8 +85,8 @@ class CopyTradingStatisticsService(
currentPositionValue = positionValue, currentPositionValue = positionValue,
totalRealizedPnl = statistics.totalRealizedPnl, totalRealizedPnl = statistics.totalRealizedPnl,
totalUnrealizedPnl = unrealizedPnl, totalUnrealizedPnl = unrealizedPnl,
totalPnl = (statistics.totalRealizedPnl.toSafeBigDecimal().add(unrealizedPnl.toSafeBigDecimal())).toString(), totalPnl = statistics.totalRealizedPnl,
totalPnlPercent = calculatePnlPercent(statistics.totalBuyAmount, statistics.totalRealizedPnl, unrealizedPnl) totalPnlPercent = calculatePnlPercentOnlyRealized(statistics.totalBuyAmount, statistics.totalRealizedPnl)
) )
Result.success(response) Result.success(response)
@@ -143,12 +134,21 @@ class CopyTradingStatisticsService(
private fun getBuyOrderList(request: OrderTrackingRequest): Pair<List<BuyOrderInfo>, Long> { private fun getBuyOrderList(request: OrderTrackingRequest): Pair<List<BuyOrderInfo>, Long> {
var orders = copyOrderTrackingRepository.findByCopyTradingId(request.copyTradingId) var orders = copyOrderTrackingRepository.findByCopyTradingId(request.copyTradingId)
// 批量获取市场信息(用于筛选)
val allMarketIds = orders.map { it.marketId }.distinct()
val markets = marketService.getMarkets(allMarketIds)
// 筛选 // 筛选
if (!request.marketId.isNullOrBlank()) { if (!request.marketId.isNullOrBlank()) {
orders = orders.filter { it.marketId == request.marketId } // marketId 支持模糊匹配
orders = orders.filter { it.marketId.contains(request.marketId!!, ignoreCase = true) }
} }
if (!request.side.isNullOrBlank()) { if (!request.marketTitle.isNullOrBlank()) {
orders = orders.filter { it.side == request.side } // marketTitle 关键字筛选
orders = orders.filter { order ->
val market = markets[order.marketId]
market?.title?.contains(request.marketTitle!!, ignoreCase = true) == true
}
} }
if (!request.status.isNullOrBlank()) { if (!request.status.isNullOrBlank()) {
orders = orders.filter { it.status == request.status } orders = orders.filter { it.status == request.status }
@@ -166,10 +166,6 @@ class CopyTradingStatisticsService(
val end = minOf(start + limit, orders.size) val end = minOf(start + limit, orders.size)
val pagedOrders = if (start < orders.size) orders.subList(start, end) else emptyList() val pagedOrders = if (start < orders.size) orders.subList(start, end) else emptyList()
// 批量获取市场信息
val marketIds = pagedOrders.map { it.marketId }.distinct()
val markets = marketService.getMarkets(marketIds)
// 转换为DTO // 转换为DTO
val list = pagedOrders.map { order -> val list = pagedOrders.map { order ->
val amount = order.quantity.toSafeBigDecimal().multi(order.price) val amount = order.quantity.toSafeBigDecimal().multi(order.price)
@@ -202,12 +198,21 @@ class CopyTradingStatisticsService(
private fun getSellOrderList(request: OrderTrackingRequest): Pair<List<SellOrderInfo>, Long> { private fun getSellOrderList(request: OrderTrackingRequest): Pair<List<SellOrderInfo>, Long> {
var records = sellMatchRecordRepository.findByCopyTradingId(request.copyTradingId) var records = sellMatchRecordRepository.findByCopyTradingId(request.copyTradingId)
// 批量获取市场信息(用于筛选)
val allMarketIds = records.map { it.marketId }.distinct()
val markets = marketService.getMarkets(allMarketIds)
// 筛选 // 筛选
if (!request.marketId.isNullOrBlank()) { if (!request.marketId.isNullOrBlank()) {
records = records.filter { it.marketId == request.marketId } // marketId 支持模糊匹配
records = records.filter { it.marketId.contains(request.marketId!!, ignoreCase = true) }
} }
if (!request.side.isNullOrBlank()) { if (!request.marketTitle.isNullOrBlank()) {
records = records.filter { it.side == request.side } // marketTitle 关键字筛选
records = records.filter { record ->
val market = markets[record.marketId]
market?.title?.contains(request.marketTitle!!, ignoreCase = true) == true
}
} }
val total = records.size.toLong() val total = records.size.toLong()
@@ -222,10 +227,6 @@ class CopyTradingStatisticsService(
val end = minOf(start + limit, records.size) val end = minOf(start + limit, records.size)
val pagedRecords = if (start < records.size) records.subList(start, end) else emptyList() val pagedRecords = if (start < records.size) records.subList(start, end) else emptyList()
// 批量获取市场信息
val marketIds = pagedRecords.map { it.marketId }.distinct()
val markets = marketService.getMarkets(marketIds)
// 转换为DTO // 转换为DTO
val list = pagedRecords.map { record -> val list = pagedRecords.map { record ->
val amount = record.totalMatchedQuantity.toSafeBigDecimal().multi(record.sellPrice) val amount = record.totalMatchedQuantity.toSafeBigDecimal().multi(record.sellPrice)
@@ -256,6 +257,14 @@ class CopyTradingStatisticsService(
private fun getMatchedOrderList(request: OrderTrackingRequest): Pair<List<MatchedOrderInfo>, Long> { private fun getMatchedOrderList(request: OrderTrackingRequest): Pair<List<MatchedOrderInfo>, Long> {
val matchDetails = sellMatchDetailRepository.findByCopyTradingId(request.copyTradingId) val matchDetails = sellMatchDetailRepository.findByCopyTradingId(request.copyTradingId)
// 获取所有相关的卖出记录(用于筛选)
val matchRecordIds = matchDetails.map { it.matchRecordId }.distinct()
val matchRecords = matchRecordIds.mapNotNull { id ->
sellMatchRecordRepository.findById(id).orElse(null)
}
val marketIds = matchRecords.map { it.marketId }.distinct()
val markets = marketService.getMarkets(marketIds)
// 筛选 // 筛选
var filtered = matchDetails var filtered = matchDetails
if (!request.sellOrderId.isNullOrBlank()) { if (!request.sellOrderId.isNullOrBlank()) {
@@ -269,6 +278,21 @@ class CopyTradingStatisticsService(
if (!request.buyOrderId.isNullOrBlank()) { if (!request.buyOrderId.isNullOrBlank()) {
filtered = filtered.filter { it.buyOrderId == request.buyOrderId } filtered = filtered.filter { it.buyOrderId == request.buyOrderId }
} }
if (!request.marketId.isNullOrBlank()) {
// marketId 支持模糊匹配
filtered = filtered.filter { detail ->
val matchRecord = matchRecords.find { it.id == detail.matchRecordId }
matchRecord?.marketId?.contains(request.marketId!!, ignoreCase = true) == true
}
}
if (!request.marketTitle.isNullOrBlank()) {
// marketTitle 关键字筛选
filtered = filtered.filter { detail ->
val matchRecord = matchRecords.find { it.id == detail.matchRecordId }
val market = matchRecord?.let { markets[it.marketId] }
market?.title?.contains(request.marketTitle!!, ignoreCase = true) == true
}
}
val total = filtered.size.toLong() val total = filtered.size.toLong()
@@ -283,17 +307,17 @@ class CopyTradingStatisticsService(
val pagedDetails = if (start < filtered.size) filtered.subList(start, end) else emptyList() val pagedDetails = if (start < filtered.size) filtered.subList(start, end) else emptyList()
// 获取匹配记录以获取市场ID // 获取匹配记录以获取市场ID
val matchRecordIds = pagedDetails.map { it.matchRecordId }.distinct() val pagedMatchRecordIds = pagedDetails.map { it.matchRecordId }.distinct()
val matchRecords = matchRecordIds.mapNotNull { id -> val pagedMatchRecords = pagedMatchRecordIds.mapNotNull { id ->
sellMatchRecordRepository.findById(id).orElse(null) sellMatchRecordRepository.findById(id).orElse(null)
} }
val marketIds = matchRecords.map { it.marketId }.distinct() val pagedMarketIds = pagedMatchRecords.map { it.marketId }.distinct()
val markets = marketService.getMarkets(marketIds) val pagedMarkets = marketService.getMarkets(pagedMarketIds)
// 转换为DTO // 转换为DTO
val list = pagedDetails.map { detail -> val list = pagedDetails.map { detail ->
val matchRecord = matchRecords.find { it.id == detail.matchRecordId } val matchRecord = pagedMatchRecords.find { it.id == detail.matchRecordId }
val market = matchRecord?.let { markets[it.marketId] } val market = matchRecord?.let { pagedMarkets[it.marketId] }
MatchedOrderInfo( MatchedOrderInfo(
sellOrderId = matchRecord?.sellOrderId ?: "", sellOrderId = matchRecord?.sellOrderId ?: "",
buyOrderId = detail.buyOrderId, buyOrderId = detail.buyOrderId,
@@ -358,200 +382,16 @@ class CopyTradingStatisticsService(
} }
/** /**
* 获取当前市场价格 * 计算盈亏百分比仅基于已实现盈亏
* (marketId, outcomeIndex) 组合获取价格支持多元市场
*/ */
private suspend fun getCurrentMarketPrice(buyOrders: List<CopyOrderTracking>): Map<String, String> { private fun calculatePnlPercentOnlyRealized(
val prices = mutableMapOf<String, String>()
// 获取所有不同的 (marketId, outcomeIndex) 组合
val marketOutcomePairs = buyOrders
.filter { it.outcomeIndex != null }
.map { Pair(it.marketId, it.outcomeIndex!!) }
.distinct()
for ((marketId, outcomeIndex) in marketOutcomePairs) {
try {
// 传递 outcomeIndex 参数,确保获取对应 outcome 的价格
val result = accountService.getMarketPrice(marketId, outcomeIndex)
result.onSuccess { response ->
// 使用当前价格
val price = response.currentPrice
if (price.isNotBlank() && price != "0") {
// 使用 "marketId:outcomeIndex" 作为 key
val key = "$marketId:$outcomeIndex"
prices[key] = price
}
}
} catch (e: Exception) {
logger.warn("获取市场价格失败: marketId=$marketId, outcomeIndex=$outcomeIndex", e)
}
}
return prices
}
/**
* 获取链上实际持仓
* (marketId, outcomeIndex) 组合返回实际持仓数量
*/
private suspend fun getActualPositions(account: Account?): Map<String, BigDecimal> {
val positions = mutableMapOf<String, BigDecimal>()
if (account == null || account.proxyAddress.isBlank()) {
return positions
}
try {
val positionsResult = blockchainService.getPositions(account.proxyAddress)
if (positionsResult.isSuccess) {
val positionList = positionsResult.getOrNull() ?: emptyList()
for (pos in positionList) {
// 只处理有 conditionId 和 outcomeIndex 的仓位
if (pos.conditionId != null && pos.outcomeIndex != null && pos.size != null) {
val key = "${pos.conditionId}:${pos.outcomeIndex}"
val size = pos.size.toSafeBigDecimal()
// 如果 size > 0,表示有持仓;如果 size < 0,表示做空(取绝对值)
positions[key] = size.abs()
}
}
}
} catch (e: Exception) {
logger.warn("获取链上持仓失败: accountId=${account.id}, error=${e.message}", e)
}
return positions
}
/**
* 计算未实现盈亏
* 使用链上实际持仓数量而不是 remainingQuantity考虑手动卖出的情况
* 按市场聚合订单计算加权平均买入价格避免重复计算
*/
private fun calculateUnrealizedPnl(
buyOrders: List<CopyOrderTracking>,
currentPrices: Map<String, String>,
actualPositions: Map<String, BigDecimal>
): String {
var totalUnrealizedPnl = BigDecimal.ZERO
// 按市场聚合订单,计算加权平均买入价格
val marketAggregates = mutableMapOf<String, Pair<BigDecimal, BigDecimal>>() // key -> (总持仓, 总成本)
for (order in buyOrders) {
// 如果没有 outcomeIndex,跳过(无法确定价格和持仓)
if (order.outcomeIndex == null) {
logger.warn("订单缺少 outcomeIndex,跳过未实现盈亏计算: orderId=${order.buyOrderId}, marketId=${order.marketId}")
continue
}
// 使用 "marketId:outcomeIndex" 作为 key
val key = "${order.marketId}:${order.outcomeIndex}"
// 获取订单的持仓数量(使用 remainingQuantity,因为这是该订单的持仓)
val orderQty = order.remainingQuantity.toSafeBigDecimal()
// 如果订单持仓 <= 0,跳过
if (orderQty.lte(BigDecimal.ZERO)) continue
val buyPrice = order.price.toSafeBigDecimal()
val orderCost = orderQty.multi(buyPrice)
// 聚合同一市场的订单
val existing = marketAggregates[key]
if (existing != null) {
val totalQty = existing.first.add(orderQty)
val totalCost = existing.second.add(orderCost)
marketAggregates[key] = Pair(totalQty, totalCost)
} else {
marketAggregates[key] = Pair(orderQty, orderCost)
}
}
// 计算每个市场的未实现盈亏
for ((key, aggregate) in marketAggregates) {
val (totalQty, totalCost) = aggregate
// 获取链上实际持仓数量(如果存在),否则使用聚合的持仓数量
val actualQty = actualPositions[key] ?: totalQty
// 如果实际持仓 <= 0,说明已全部卖出(包括手动卖出),跳过未实现盈亏计算
if (actualQty.lte(BigDecimal.ZERO)) continue
// 获取当前市场价格
val currentPrice = currentPrices[key]?.toSafeBigDecimal()
?: continue // 如果没有当前价格,跳过
// 计算加权平均买入价格
val avgBuyPrice = if (totalQty.gt(BigDecimal.ZERO)) {
totalCost.div(totalQty)
} else {
continue
}
// 使用实际持仓数量和加权平均买入价格计算未实现盈亏
val unrealizedPnl = currentPrice.subtract(avgBuyPrice).multi(actualQty)
totalUnrealizedPnl = totalUnrealizedPnl.add(unrealizedPnl)
}
return totalUnrealizedPnl.toString()
}
/**
* 计算持仓价值
* 使用链上实际持仓数量和当前市场价格计算
* 按市场聚合避免重复计算
*/
private fun calculatePositionValue(
buyOrders: List<CopyOrderTracking>,
currentPrices: Map<String, String>,
actualPositions: Map<String, BigDecimal>
): String {
var totalPositionValue = BigDecimal.ZERO
// 按市场聚合,获取所有不同的市场
val marketKeys = buyOrders
.filter { it.outcomeIndex != null }
.map { "${it.marketId}:${it.outcomeIndex}" }
.distinct()
for (key in marketKeys) {
// 获取链上实际持仓数量(如果存在)
val actualQty = actualPositions[key]
// 如果没有链上持仓,计算该市场的总持仓(所有订单的 remainingQuantity 之和)
val totalQty = actualQty ?: buyOrders
.filter { it.outcomeIndex != null && "${it.marketId}:${it.outcomeIndex}" == key }
.sumOf { it.remainingQuantity.toSafeBigDecimal() }
// 如果持仓 <= 0,跳过
if (totalQty.lte(BigDecimal.ZERO)) continue
// 获取当前市场价格
val currentPrice = currentPrices[key]?.toSafeBigDecimal()
?: continue // 如果没有当前价格,跳过
// 计算持仓价值:持仓数量 × 当前价格
val positionValue = totalQty.multi(currentPrice)
totalPositionValue = totalPositionValue.add(positionValue)
}
return totalPositionValue.toString()
}
/**
* 计算盈亏百分比
*/
private fun calculatePnlPercent(
totalBuyAmount: String, totalBuyAmount: String,
totalRealizedPnl: String, totalRealizedPnl: String
totalUnrealizedPnl: String
): String { ): String {
val buyAmount = totalBuyAmount.toSafeBigDecimal() val buyAmount = totalBuyAmount.toSafeBigDecimal()
if (buyAmount.lte(BigDecimal.ZERO)) return "0" if (buyAmount.lte(BigDecimal.ZERO)) return "0"
val totalPnl = totalRealizedPnl.toSafeBigDecimal().add(totalUnrealizedPnl.toSafeBigDecimal()) val percent = totalRealizedPnl.toSafeBigDecimal().div(buyAmount).multi(100)
val percent = totalPnl.div(buyAmount).multi(100)
return percent.setScale(2, RoundingMode.HALF_UP).toString() return percent.setScale(2, RoundingMode.HALF_UP).toString()
} }
@@ -743,7 +583,23 @@ class CopyTradingStatisticsService(
// 2. 获取所有买入订单 // 2. 获取所有买入订单
var orders = copyOrderTrackingRepository.findByCopyTradingId(request.copyTradingId) var orders = copyOrderTrackingRepository.findByCopyTradingId(request.copyTradingId)
// 3. 按市场ID分组 // 3. 批量获取市场信息(用于筛选)
val allMarketIds = orders.map { it.marketId }.distinct()
val markets = marketService.getMarkets(allMarketIds)
// 4. 筛选
if (!request.marketId.isNullOrBlank()) {
// marketId 支持模糊匹配
orders = orders.filter { it.marketId.contains(request.marketId!!, ignoreCase = true) }
}
if (!request.marketTitle.isNullOrBlank()) {
// marketTitle 关键字筛选
orders = orders.filter { order ->
val market = markets[order.marketId]
market?.title?.contains(request.marketTitle!!, ignoreCase = true) == true
}
}
// 5. 按市场ID分组
val groups = mutableMapOf<String, MutableList<CopyOrderTracking>>() val groups = mutableMapOf<String, MutableList<CopyOrderTracking>>()
orders.forEach { order -> orders.forEach { order ->
val marketId = order.marketId val marketId = order.marketId
@@ -755,23 +611,22 @@ class CopyTradingStatisticsService(
// 4. 转换为分组数据并计算统计信息 // 4. 转换为分组数据并计算统计信息
val marketIds = groups.keys.toList() val marketIds = groups.keys.toList()
val markets = marketService.getMarkets(marketIds)
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(),
@@ -781,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)
@@ -807,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,
@@ -817,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)
} }
} }
@@ -852,9 +715,25 @@ class CopyTradingStatisticsService(
?: return Result.failure(IllegalArgumentException("跟单关系不存在: ${request.copyTradingId}")) ?: return Result.failure(IllegalArgumentException("跟单关系不存在: ${request.copyTradingId}"))
// 2. 获取所有卖出记录 // 2. 获取所有卖出记录
val sellRecords = sellMatchRecordRepository.findByCopyTradingId(request.copyTradingId) var sellRecords = sellMatchRecordRepository.findByCopyTradingId(request.copyTradingId)
// 3. 按市场ID分组 // 3. 批量获取市场信息(用于筛选)
val allMarketIds = sellRecords.map { it.marketId }.distinct()
val markets = marketService.getMarkets(allMarketIds)
// 4. 筛选
if (!request.marketId.isNullOrBlank()) {
// marketId 支持模糊匹配
sellRecords = sellRecords.filter { it.marketId.contains(request.marketId!!, ignoreCase = true) }
}
if (!request.marketTitle.isNullOrBlank()) {
// marketTitle 关键字筛选
sellRecords = sellRecords.filter { record ->
val market = markets[record.marketId]
market?.title?.contains(request.marketTitle!!, ignoreCase = true) == true
}
}
// 5. 按市场ID分组
val groups = mutableMapOf<String, MutableList<SellMatchRecord>>() val groups = mutableMapOf<String, MutableList<SellMatchRecord>>()
sellRecords.forEach { record -> sellRecords.forEach { record ->
val marketId = record.marketId val marketId = record.marketId
@@ -866,7 +745,6 @@ class CopyTradingStatisticsService(
// 4. 转换为分组数据并计算统计信息 // 4. 转换为分组数据并计算统计信息
val marketIds = groups.keys.toList() val marketIds = groups.keys.toList()
val markets = marketService.getMarkets(marketIds)
val list = marketIds.map { marketId -> val list = marketIds.map { marketId ->
val marketRecords = groups[marketId] ?: mutableListOf() val marketRecords = groups[marketId] ?: mutableListOf()
@@ -921,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)
@@ -62,7 +62,8 @@ class CopyTradingTemplateService(
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal(), minOrderDepth = request.minOrderDepth?.toSafeBigDecimal(),
maxSpread = request.maxSpread?.toSafeBigDecimal(), maxSpread = request.maxSpread?.toSafeBigDecimal(),
minPrice = request.minPrice?.toSafeBigDecimal(), minPrice = request.minPrice?.toSafeBigDecimal(),
maxPrice = request.maxPrice?.toSafeBigDecimal() maxPrice = request.maxPrice?.toSafeBigDecimal(),
pushFilteredOrders = request.pushFilteredOrders ?: false
) )
val saved = templateRepository.save(template) val saved = templateRepository.save(template)
@@ -121,6 +122,7 @@ class CopyTradingTemplateService(
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: template.maxSpread, maxSpread = request.maxSpread?.toSafeBigDecimal() ?: template.maxSpread,
minPrice = request.minPrice?.toSafeBigDecimal() ?: template.minPrice, minPrice = request.minPrice?.toSafeBigDecimal() ?: template.minPrice,
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice, maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice,
pushFilteredOrders = request.pushFilteredOrders ?: template.pushFilteredOrders,
updatedAt = System.currentTimeMillis() updatedAt = System.currentTimeMillis()
) )
@@ -186,7 +188,8 @@ class CopyTradingTemplateService(
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: sourceTemplate.minOrderDepth, minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: sourceTemplate.minOrderDepth,
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: sourceTemplate.maxSpread, maxSpread = request.maxSpread?.toSafeBigDecimal() ?: sourceTemplate.maxSpread,
minPrice = request.minPrice?.toSafeBigDecimal() ?: sourceTemplate.minPrice, minPrice = request.minPrice?.toSafeBigDecimal() ?: sourceTemplate.minPrice,
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: sourceTemplate.maxPrice maxPrice = request.maxPrice?.toSafeBigDecimal() ?: sourceTemplate.maxPrice,
pushFilteredOrders = request.pushFilteredOrders ?: sourceTemplate.pushFilteredOrders
) )
val saved = templateRepository.save(newTemplate) val saved = templateRepository.save(newTemplate)
@@ -260,6 +263,7 @@ class CopyTradingTemplateService(
maxSpread = template.maxSpread?.toPlainString(), maxSpread = template.maxSpread?.toPlainString(),
minPrice = template.minPrice?.toPlainString(), minPrice = template.minPrice?.toPlainString(),
maxPrice = template.maxPrice?.toPlainString(), maxPrice = template.maxPrice?.toPlainString(),
pushFilteredOrders = template.pushFilteredOrders,
createdAt = template.createdAt, createdAt = template.createdAt,
updatedAt = template.updatedAt updatedAt = template.updatedAt
) )
@@ -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
} }
/** /**
@@ -77,6 +77,7 @@ class TelegramNotificationService(
* @param apiPassphrase API Passphrase可选用于查询订单详情 * @param apiPassphrase API Passphrase可选用于查询订单详情
* @param walletAddressForApi 钱包地址可选用于查询订单详情 * @param walletAddressForApi 钱包地址可选用于查询订单详情
* @param locale 语言设置可选如果提供则使用否则使用 LocaleContextHolder 获取 * @param locale 语言设置可选如果提供则使用否则使用 LocaleContextHolder 获取
* @param orderTime 订单时间可选如果提供则使用订单创建时间否则使用当前通知时间
*/ */
suspend fun sendOrderSuccessNotification( suspend fun sendOrderSuccessNotification(
orderId: String?, orderId: String?,
@@ -96,7 +97,8 @@ class TelegramNotificationService(
walletAddressForApi: String? = null, walletAddressForApi: String? = null,
locale: java.util.Locale? = null, locale: java.util.Locale? = null,
leaderName: String? = null, // Leader 名称(备注) leaderName: String? = null, // Leader 名称(备注)
configName: String? = null // 跟单配置名 configName: String? = null, // 跟单配置名
orderTime: Long? = null // 订单创建时间(毫秒时间戳),用于通知中的时间显示
) { ) {
// 1. 如果提供了 orderId,检查是否已发送过通知(去重) // 1. 如果提供了 orderId,检查是否已发送过通知(去重)
if (orderId != null) { if (orderId != null) {
@@ -189,7 +191,8 @@ class TelegramNotificationService(
walletAddress = walletAddress, walletAddress = walletAddress,
locale = currentLocale, locale = currentLocale,
leaderName = leaderName, leaderName = leaderName,
configName = configName configName = configName,
orderTime = orderTime
) )
sendMessage(message) sendMessage(message)
} }
@@ -699,7 +702,8 @@ class TelegramNotificationService(
walletAddress: String?, walletAddress: String?,
locale: java.util.Locale, locale: java.util.Locale,
leaderName: String? = null, // Leader 名称(备注) leaderName: String? = null, // Leader 名称(备注)
configName: String? = null // 跟单配置名 configName: String? = null, // 跟单配置名
orderTime: Long? = null // 订单创建时间(毫秒时间戳)
): String { ): String {
// 获取多语言文本 // 获取多语言文本
@@ -749,7 +753,12 @@ class TelegramNotificationService(
"" ""
} }
val time = DateUtils.formatDateTime() // 使用订单时间(如果提供),否则使用当前通知时间
val time = if (orderTime != null) {
DateUtils.formatDateTime(orderTime)
} else {
DateUtils.formatDateTime()
}
// 转义 HTML 特殊字符 // 转义 HTML 特殊字符
val escapedMarketTitle = marketTitle.replace("<", "&lt;").replace(">", "&gt;") val escapedMarketTitle = marketTitle.replace("<", "&lt;").replace(">", "&gt;")
@@ -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,13 @@
-- ============================================
-- V24: 添加推送已过滤订单字段到模板表和跟单配置表
-- 用于配置是否推送被过滤的订单通知,默认关闭
-- ============================================
-- 添加推送已过滤订单字段到模板表
ALTER TABLE copy_trading_templates
ADD COLUMN push_filtered_orders BOOLEAN NOT NULL DEFAULT FALSE COMMENT '推送已过滤订单(默认关闭)';
-- 添加推送已过滤订单字段到跟单配置表
ALTER TABLE copy_trading
ADD COLUMN push_filtered_orders BOOLEAN NOT NULL DEFAULT FALSE COMMENT '推送已过滤订单(默认关闭)';
@@ -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(不影响现有功能)
-- 新创建的记录会在创建时自动填充此字段
+5
View File
@@ -19,6 +19,7 @@ services:
ports: ports:
- "${SERVER_PORT:-80}:80" - "${SERVER_PORT:-80}:80"
environment: environment:
- TZ=${TZ:-Asia/Shanghai}
- SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE:-prod} - SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE:-prod}
- DB_URL=${DB_URL:-jdbc:mysql://mysql:3306/polyhermes?useSSL=false&serverTimezone=UTC&characterEncoding=utf8&allowPublicKeyRetrieval=true} - DB_URL=${DB_URL:-jdbc:mysql://mysql:3306/polyhermes?useSSL=false&serverTimezone=UTC&characterEncoding=utf8&allowPublicKeyRetrieval=true}
- DB_USERNAME=${DB_USERNAME:-root} - DB_USERNAME=${DB_USERNAME:-root}
@@ -33,6 +34,8 @@ services:
# 可选值:TRACE, DEBUG, INFO, WARN, ERROR, OFF # 可选值:TRACE, DEBUG, INFO, WARN, ERROR, OFF
- LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT:-WARN} - LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT:-WARN}
- LOG_LEVEL_APP=${LOG_LEVEL_APP:-INFO} - LOG_LEVEL_APP=${LOG_LEVEL_APP:-INFO}
volumes:
- /etc/localtime:/etc/localtime:ro
depends_on: depends_on:
mysql: mysql:
condition: service_healthy condition: service_healthy
@@ -46,12 +49,14 @@ services:
ports: ports:
- "${MYSQL_PORT:-3307}:3306" - "${MYSQL_PORT:-3307}:3306"
environment: environment:
- TZ=${TZ:-Asia/Shanghai}
- MYSQL_ROOT_PASSWORD=${DB_PASSWORD:-rootpassword} - MYSQL_ROOT_PASSWORD=${DB_PASSWORD:-rootpassword}
- MYSQL_DATABASE=polyhermes - MYSQL_DATABASE=polyhermes
- MYSQL_CHARACTER_SET_SERVER=utf8mb4 - MYSQL_CHARACTER_SET_SERVER=utf8mb4
- MYSQL_COLLATION_SERVER=utf8mb4_unicode_ci - MYSQL_COLLATION_SERVER=utf8mb4_unicode_ci
volumes: volumes:
- mysql-data:/var/lib/mysql - mysql-data:/var/lib/mysql
- /etc/localtime:/etc/localtime:ro
healthcheck: healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_PASSWORD:-rootpassword}"] test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_PASSWORD:-rootpassword}"]
interval: 10s interval: 10s
+5
View File
@@ -17,6 +17,7 @@ services:
ports: ports:
- "${SERVER_PORT:-80}:80" - "${SERVER_PORT:-80}:80"
environment: environment:
- TZ=${TZ:-Asia/Shanghai}
- SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE:-prod} - SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE:-prod}
- DB_URL=${DB_URL:-jdbc:mysql://mysql:3306/polyhermes?useSSL=false&serverTimezone=UTC&characterEncoding=utf8&allowPublicKeyRetrieval=true} - DB_URL=${DB_URL:-jdbc:mysql://mysql:3306/polyhermes?useSSL=false&serverTimezone=UTC&characterEncoding=utf8&allowPublicKeyRetrieval=true}
- DB_USERNAME=${DB_USERNAME:-root} - DB_USERNAME=${DB_USERNAME:-root}
@@ -31,6 +32,8 @@ services:
# 可选值:TRACE, DEBUG, INFO, WARN, ERROR, OFF # 可选值:TRACE, DEBUG, INFO, WARN, ERROR, OFF
- LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT:-WARN} - LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT:-WARN}
- LOG_LEVEL_APP=${LOG_LEVEL_APP:-INFO} - LOG_LEVEL_APP=${LOG_LEVEL_APP:-INFO}
volumes:
- /etc/localtime:/etc/localtime:ro
depends_on: depends_on:
mysql: mysql:
condition: service_healthy condition: service_healthy
@@ -44,12 +47,14 @@ services:
ports: ports:
- "${MYSQL_PORT:-3307}:3306" - "${MYSQL_PORT:-3307}:3306"
environment: environment:
- TZ=${TZ:-Asia/Shanghai}
- MYSQL_ROOT_PASSWORD=${DB_PASSWORD:-rootpassword} - MYSQL_ROOT_PASSWORD=${DB_PASSWORD:-rootpassword}
- MYSQL_DATABASE=polyhermes - MYSQL_DATABASE=polyhermes
- MYSQL_CHARACTER_SET_SERVER=utf8mb4 - MYSQL_CHARACTER_SET_SERVER=utf8mb4
- MYSQL_COLLATION_SERVER=utf8mb4_unicode_ci - MYSQL_COLLATION_SERVER=utf8mb4_unicode_ci
volumes: volumes:
- mysql-data:/var/lib/mysql - mysql-data:/var/lib/mysql
- /etc/localtime:/etc/localtime:ro
healthcheck: healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_PASSWORD:-rootpassword}"] test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_PASSWORD:-rootpassword}"]
interval: 10s interval: 10s
+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
+18 -5
View File
@@ -571,6 +571,8 @@
"deleteConfirmDesc": "This action cannot be undone. Please ensure no copy trading relationships are using this template", "deleteConfirmDesc": "This action cannot be undone. Please ensure no copy trading relationships are using this template",
"copySuccess": "Template copied successfully", "copySuccess": "Template copied successfully",
"copyFailed": "Failed to copy template", "copyFailed": "Failed to copy template",
"pushFilteredOrders": "Push Filtered Orders",
"pushFilteredOrdersTooltip": "When enabled, filtered orders will be pushed to Telegram",
"minAmountError": "Minimum amount must be >= 1", "minAmountError": "Minimum amount must be >= 1",
"fixedAmountRequired": "Please enter fixed copy trading amount", "fixedAmountRequired": "Please enter fixed copy trading amount",
"invalidNumber": "Please enter a valid number", "invalidNumber": "Please enter a valid number",
@@ -623,6 +625,8 @@
"maxPricePlaceholder": "Max Price (leave empty for no limit)", "maxPricePlaceholder": "Max Price (leave empty for no limit)",
"supportSell": "Support Sell", "supportSell": "Support Sell",
"supportSellTooltip": "Whether to copy Leader's sell orders. Enabled: copy both Leader's buy and sell orders; Disabled: only copy Leader's buy orders, ignore sell orders.", "supportSellTooltip": "Whether to copy Leader's sell orders. Enabled: copy both Leader's buy and sell orders; Disabled: only copy Leader's buy orders, ignore sell orders.",
"pushFilteredOrders": "Push Filtered Orders",
"pushFilteredOrdersTooltip": "When enabled, filtered orders will be pushed to Telegram",
"create": "Create Template", "create": "Create Template",
"createSuccess": "Template created successfully", "createSuccess": "Template created successfully",
"createFailed": "Failed to create template", "createFailed": "Failed to create template",
@@ -687,6 +691,8 @@
"maxPositionCountPlaceholder": "For example: 10 (optional, leave empty to disable)", "maxPositionCountPlaceholder": "For example: 10 (optional, leave empty to disable)",
"supportSell": "Support Sell", "supportSell": "Support Sell",
"supportSellTooltip": "Whether to copy Leader's sell orders. Enabled: copy both Leader's buy and sell orders; Disabled: only copy Leader's buy orders, ignore sell orders.", "supportSellTooltip": "Whether to copy Leader's sell orders. Enabled: copy both Leader's buy and sell orders; Disabled: only copy Leader's buy orders, ignore sell orders.",
"pushFilteredOrders": "Push Filtered Orders",
"pushFilteredOrdersTooltip": "When enabled, filtered orders will be pushed to Telegram",
"invalidNumber": "Please enter a valid number" "invalidNumber": "Please enter a valid number"
}, },
"copyTradingAdd": { "copyTradingAdd": {
@@ -783,6 +789,8 @@
"advancedSettings": "Advanced Settings", "advancedSettings": "Advanced Settings",
"pushFailedOrders": "Push Failed Orders", "pushFailedOrders": "Push Failed Orders",
"pushFailedOrdersTooltip": "When enabled, failed orders will be pushed to Telegram", "pushFailedOrdersTooltip": "When enabled, failed orders will be pushed to Telegram",
"pushFilteredOrders": "Push Filtered Orders",
"pushFilteredOrdersTooltip": "When enabled, filtered orders will be pushed to Telegram",
"autoRedeem": "Auto Redeem", "autoRedeem": "Auto Redeem",
"autoRedeemTooltip": "When enabled, the system will automatically redeem redeemable positions. Requires Builder API Key configuration to take effect", "autoRedeemTooltip": "When enabled, the system will automatically redeem redeemable positions. Requires Builder API Key configuration to take effect",
"builderApiKeyNotConfigured": "Builder API Key Not Configured", "builderApiKeyNotConfigured": "Builder API Key Not Configured",
@@ -888,6 +896,8 @@
"advancedSettings": "Advanced Settings", "advancedSettings": "Advanced Settings",
"pushFailedOrders": "Push Failed Orders", "pushFailedOrders": "Push Failed Orders",
"pushFailedOrdersTooltip": "When enabled, failed orders will be pushed to Telegram", "pushFailedOrdersTooltip": "When enabled, failed orders will be pushed to Telegram",
"pushFilteredOrders": "Push Filtered Orders",
"pushFilteredOrdersTooltip": "When enabled, filtered orders will be pushed to Telegram",
"autoRedeem": "Auto Redeem", "autoRedeem": "Auto Redeem",
"autoRedeemTooltip": "When enabled, the system will automatically redeem redeemable positions. Requires Builder API Key configuration to take effect", "autoRedeemTooltip": "When enabled, the system will automatically redeem redeemable positions. Requires Builder API Key configuration to take effect",
"builderApiKeyNotConfigured": "Builder API Key Not Configured", "builderApiKeyNotConfigured": "Builder API Key Not Configured",
@@ -1089,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",
@@ -1099,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",
@@ -1116,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",
@@ -1185,4 +1198,4 @@
"providerChainstack": "Chainstack", "providerChainstack": "Chainstack",
"providerGetBlock": "GetBlock" "providerGetBlock": "GetBlock"
} }
} }
+17 -5
View File
@@ -571,6 +571,8 @@
"deleteConfirmDesc": "删除后无法恢复,请确保没有跟单关系在使用该模板", "deleteConfirmDesc": "删除后无法恢复,请确保没有跟单关系在使用该模板",
"copySuccess": "复制模板成功", "copySuccess": "复制模板成功",
"copyFailed": "复制模板失败", "copyFailed": "复制模板失败",
"pushFilteredOrders": "推送已过滤订单",
"pushFilteredOrdersTooltip": "开启后,被过滤的订单会推送到 Telegram",
"minAmountError": "最小金额必须 >= 1", "minAmountError": "最小金额必须 >= 1",
"fixedAmountRequired": "请输入固定跟单金额", "fixedAmountRequired": "请输入固定跟单金额",
"invalidNumber": "请输入有效的数字", "invalidNumber": "请输入有效的数字",
@@ -623,6 +625,8 @@
"maxPricePlaceholder": "最高价(留空不限制)", "maxPricePlaceholder": "最高价(留空不限制)",
"supportSell": "跟单卖出", "supportSell": "跟单卖出",
"supportSellTooltip": "是否跟单 Leader 的卖出订单。开启:跟单 Leader 的买入和卖出订单;关闭:只跟单 Leader 的买入订单,忽略卖出订单。", "supportSellTooltip": "是否跟单 Leader 的卖出订单。开启:跟单 Leader 的买入和卖出订单;关闭:只跟单 Leader 的买入订单,忽略卖出订单。",
"pushFilteredOrders": "推送已过滤订单",
"pushFilteredOrdersTooltip": "开启后,被过滤的订单会推送到 Telegram",
"create": "创建模板", "create": "创建模板",
"createSuccess": "创建模板成功", "createSuccess": "创建模板成功",
"createFailed": "创建模板失败", "createFailed": "创建模板失败",
@@ -687,6 +691,8 @@
"maxPositionCountPlaceholder": "例如:10(可选,不填写表示不启用)", "maxPositionCountPlaceholder": "例如:10(可选,不填写表示不启用)",
"supportSell": "跟单卖出", "supportSell": "跟单卖出",
"supportSellTooltip": "是否跟单 Leader 的卖出订单。开启:跟单 Leader 的买入和卖出订单;关闭:只跟单 Leader 的买入订单,忽略卖出订单。", "supportSellTooltip": "是否跟单 Leader 的卖出订单。开启:跟单 Leader 的买入和卖出订单;关闭:只跟单 Leader 的买入订单,忽略卖出订单。",
"pushFilteredOrders": "推送已过滤订单",
"pushFilteredOrdersTooltip": "开启后,被过滤的订单会推送到 Telegram",
"invalidNumber": "请输入有效的数字" "invalidNumber": "请输入有效的数字"
}, },
"copyTradingAdd": { "copyTradingAdd": {
@@ -699,6 +705,8 @@
"advancedSettings": "高级设置", "advancedSettings": "高级设置",
"pushFailedOrders": "推送失败订单", "pushFailedOrders": "推送失败订单",
"pushFailedOrdersTooltip": "开启后,失败的订单会推送到 Telegram", "pushFailedOrdersTooltip": "开启后,失败的订单会推送到 Telegram",
"pushFilteredOrders": "推送已过滤订单",
"pushFilteredOrdersTooltip": "开启后,被过滤的订单会推送到 Telegram",
"autoRedeem": "自动赎回", "autoRedeem": "自动赎回",
"autoRedeemTooltip": "开启后,系统会自动赎回可赎回的仓位。需要配置 Builder API Key 才能生效", "autoRedeemTooltip": "开启后,系统会自动赎回可赎回的仓位。需要配置 Builder API Key 才能生效",
"builderApiKeyNotConfigured": "Builder API Key 未配置", "builderApiKeyNotConfigured": "Builder API Key 未配置",
@@ -812,6 +820,8 @@
"advancedSettings": "高级设置", "advancedSettings": "高级设置",
"pushFailedOrders": "推送失败订单", "pushFailedOrders": "推送失败订单",
"pushFailedOrdersTooltip": "开启后,失败的订单会推送到 Telegram", "pushFailedOrdersTooltip": "开启后,失败的订单会推送到 Telegram",
"pushFilteredOrders": "推送已过滤订单",
"pushFilteredOrdersTooltip": "开启后,被过滤的订单会推送到 Telegram",
"autoRedeem": "自动赎回", "autoRedeem": "自动赎回",
"autoRedeemTooltip": "开启后,系统会自动赎回可赎回的仓位。需要配置 Builder API Key 才能生效", "autoRedeemTooltip": "开启后,系统会自动赎回可赎回的仓位。需要配置 Builder API Key 才能生效",
"builderApiKeyNotConfigured": "Builder API Key 未配置", "builderApiKeyNotConfigured": "Builder API Key 未配置",
@@ -1070,8 +1080,8 @@
"sellStatus": "卖出状态", "sellStatus": "卖出状态",
"status": "状态", "status": "状态",
"statusFilled": "未成交", "statusFilled": "未成交",
"statusPartiallySold": "部分成交", "statusPartiallySold": "部分卖出",
"statusFullySold": "全部成交", "statusFullySold": "全部卖出",
"realizedPnl": "已实现盈亏", "realizedPnl": "已实现盈亏",
"createdAt": "创建时间", "createdAt": "创建时间",
"matchedAt": "匹配时间", "matchedAt": "匹配时间",
@@ -1089,6 +1099,7 @@
"price": "价格", "price": "价格",
"amount": "金额", "amount": "金额",
"filterMarketId": "筛选市场ID", "filterMarketId": "筛选市场ID",
"filterMarketTitle": "筛选市场标题",
"filterSide": "筛选方向", "filterSide": "筛选方向",
"filterStatus": "筛选状态", "filterStatus": "筛选状态",
"filterSellOrderId": "筛选卖出订单ID", "filterSellOrderId": "筛选卖出订单ID",
@@ -1099,8 +1110,9 @@
"groupByMarket": "按市场分组", "groupByMarket": "按市场分组",
"expandAll": "展开全部", "expandAll": "展开全部",
"collapseAll": "折叠全部", "collapseAll": "折叠全部",
"allFullyMatched": "全部成交", "allFullySold": "全部卖出",
"partiallyMatched": "部分成交", "notSold": "未卖出",
"partiallySold": "部分卖出",
"orderCount": "订单数", "orderCount": "订单数",
"totalAmount": "总金额", "totalAmount": "总金额",
"statusBreakdown": "状态", "statusBreakdown": "状态",
@@ -1184,4 +1196,4 @@
"providerChainstack": "Chainstack", "providerChainstack": "Chainstack",
"providerGetBlock": "GetBlock" "providerGetBlock": "GetBlock"
} }
} }
+18 -7
View File
@@ -571,6 +571,8 @@
"deleteConfirmDesc": "刪除後無法恢復,請確保沒有跟單關係在使用該模板", "deleteConfirmDesc": "刪除後無法恢復,請確保沒有跟單關係在使用該模板",
"copySuccess": "複製模板成功", "copySuccess": "複製模板成功",
"copyFailed": "複製模板失敗", "copyFailed": "複製模板失敗",
"pushFilteredOrders": "推送已過濾訂單",
"pushFilteredOrdersTooltip": "開啟後,被過濾的訂單會推送到 Telegram",
"minAmountError": "最小金額必須 >= 1", "minAmountError": "最小金額必須 >= 1",
"fixedAmountRequired": "請輸入固定跟單金額", "fixedAmountRequired": "請輸入固定跟單金額",
"invalidNumber": "請輸入有效的數字", "invalidNumber": "請輸入有效的數字",
@@ -623,6 +625,8 @@
"maxPricePlaceholder": "最高價(留空不限制)", "maxPricePlaceholder": "最高價(留空不限制)",
"supportSell": "跟單賣出", "supportSell": "跟單賣出",
"supportSellTooltip": "是否跟單 Leader 的賣出訂單。開啟:跟單 Leader 的買入和賣出訂單;關閉:只跟單 Leader 的買入訂單,忽略賣出訂單。", "supportSellTooltip": "是否跟單 Leader 的賣出訂單。開啟:跟單 Leader 的買入和賣出訂單;關閉:只跟單 Leader 的買入訂單,忽略賣出訂單。",
"pushFilteredOrders": "推送已過濾訂單",
"pushFilteredOrdersTooltip": "開啟後,被過濾的訂單會推送到 Telegram",
"create": "創建模板", "create": "創建模板",
"createSuccess": "創建模板成功", "createSuccess": "創建模板成功",
"createFailed": "創建模板失敗", "createFailed": "創建模板失敗",
@@ -687,6 +691,8 @@
"maxPositionCountPlaceholder": "例如:10(可選,不填寫表示不啟用)", "maxPositionCountPlaceholder": "例如:10(可選,不填寫表示不啟用)",
"supportSell": "跟單賣出", "supportSell": "跟單賣出",
"supportSellTooltip": "是否跟單 Leader 的賣出訂單。開啟:跟單 Leader 的買入和賣出訂單;關閉:只跟單 Leader 的買入訂單,忽略賣出訂單。", "supportSellTooltip": "是否跟單 Leader 的賣出訂單。開啟:跟單 Leader 的買入和賣出訂單;關閉:只跟單 Leader 的買入訂單,忽略賣出訂單。",
"pushFilteredOrders": "推送已過濾訂單",
"pushFilteredOrdersTooltip": "開啟後,被過濾的訂單會推送到 Telegram",
"invalidNumber": "請輸入有效的數字" "invalidNumber": "請輸入有效的數字"
}, },
"copyTradingAdd": { "copyTradingAdd": {
@@ -783,6 +789,8 @@
"advancedSettings": "高級設置", "advancedSettings": "高級設置",
"pushFailedOrders": "推送失敗訂單", "pushFailedOrders": "推送失敗訂單",
"pushFailedOrdersTooltip": "開啟後,失敗的訂單會推送到 Telegram", "pushFailedOrdersTooltip": "開啟後,失敗的訂單會推送到 Telegram",
"pushFilteredOrders": "推送已過濾訂單",
"pushFilteredOrdersTooltip": "開啟後,被過濾的訂單會推送到 Telegram",
"autoRedeem": "自動贖回", "autoRedeem": "自動贖回",
"autoRedeemTooltip": "開啟後,系統會自動贖回可贖回的倉位。需要配置 Builder API Key 才能生效", "autoRedeemTooltip": "開啟後,系統會自動贖回可贖回的倉位。需要配置 Builder API Key 才能生效",
"builderApiKeyNotConfigured": "Builder API Key 未配置", "builderApiKeyNotConfigured": "Builder API Key 未配置",
@@ -888,6 +896,8 @@
"advancedSettings": "高級設置", "advancedSettings": "高級設置",
"pushFailedOrders": "推送失敗訂單", "pushFailedOrders": "推送失敗訂單",
"pushFailedOrdersTooltip": "開啟後,失敗的訂單會推送到 Telegram", "pushFailedOrdersTooltip": "開啟後,失敗的訂單會推送到 Telegram",
"pushFilteredOrders": "推送已過濾訂單",
"pushFilteredOrdersTooltip": "開啟後,被過濾的訂單會推送到 Telegram",
"autoRedeem": "自動贖回", "autoRedeem": "自動贖回",
"autoRedeemTooltip": "開啟後,系統會自動贖回可贖回的倉位。需要配置 Builder API Key 才能生效", "autoRedeemTooltip": "開啟後,系統會自動贖回可贖回的倉位。需要配置 Builder API Key 才能生效",
"builderApiKeyNotConfigured": "Builder API Key 未配置", "builderApiKeyNotConfigured": "Builder API Key 未配置",
@@ -1008,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 消息",
@@ -1070,8 +1080,8 @@
"sellStatus": "賣出狀態", "sellStatus": "賣出狀態",
"status": "狀態", "status": "狀態",
"statusFilled": "已完成", "statusFilled": "已完成",
"statusPartiallySold": "部分成交", "statusPartiallySold": "部分賣出",
"statusFullySold": "全部成交", "statusFullySold": "全部賣出",
"realizedPnl": "已實現盈虧", "realizedPnl": "已實現盈虧",
"createdAt": "創建時間", "createdAt": "創建時間",
"matchedAt": "匹配時間", "matchedAt": "匹配時間",
@@ -1089,6 +1099,7 @@
"price": "價格", "price": "價格",
"amount": "金額", "amount": "金額",
"filterMarketId": "篩選市場ID", "filterMarketId": "篩選市場ID",
"filterMarketTitle": "篩選市場標題",
"filterSide": "篩選方向", "filterSide": "篩選方向",
"filterStatus": "篩選狀態", "filterStatus": "篩選狀態",
"filterSellOrderId": "篩選賣出訂單ID", "filterSellOrderId": "篩選賣出訂單ID",
@@ -1099,8 +1110,9 @@
"groupByMarket": "按市場分組", "groupByMarket": "按市場分組",
"expandAll": "展開全部", "expandAll": "展開全部",
"collapseAll": "折疊全部", "collapseAll": "折疊全部",
"allFullyMatched": "全部成交", "allFullySold": "全部賣出",
"partiallyMatched": "部分成交", "notSold": "未賣出",
"partiallySold": "部分賣出",
"orderCount": "訂單數", "orderCount": "訂單數",
"totalAmount": "總金額", "totalAmount": "總金額",
"totalPnl": "總盈虧", "totalPnl": "總盈虧",
@@ -1116,7 +1128,6 @@
"totalMatchedOrders": "總匹配訂單數", "totalMatchedOrders": "總匹配訂單數",
"totalBuyAmount": "總買入金額", "totalBuyAmount": "總買入金額",
"totalSellAmount": "總賣出金額", "totalSellAmount": "總賣出金額",
"totalPnl": "總盈虧",
"totalRealizedPnl": "總已實現盈虧", "totalRealizedPnl": "總已實現盈虧",
"totalUnrealizedPnl": "總未實現盈虧", "totalUnrealizedPnl": "總未實現盈虧",
"winRate": "勝率", "winRate": "勝率",
@@ -1185,4 +1196,4 @@
"providerChainstack": "Chainstack", "providerChainstack": "Chainstack",
"providerGetBlock": "GetBlock" "providerGetBlock": "GetBlock"
} }
} }
@@ -120,7 +120,8 @@ const AddModal: React.FC<AddModalProps> = ({
minPrice: template.minPrice ? parseFloat(template.minPrice) : undefined, minPrice: template.minPrice ? parseFloat(template.minPrice) : undefined,
maxPrice: template.maxPrice ? parseFloat(template.maxPrice) : undefined, maxPrice: template.maxPrice ? parseFloat(template.maxPrice) : undefined,
maxPositionValue: (template as any).maxPositionValue ? parseFloat((template as any).maxPositionValue) : undefined, maxPositionValue: (template as any).maxPositionValue ? parseFloat((template as any).maxPositionValue) : undefined,
maxPositionCount: (template as any).maxPositionCount maxPositionCount: (template as any).maxPositionCount,
pushFilteredOrders: template.pushFilteredOrders ?? false
}) })
setCopyMode(template.copyMode) setCopyMode(template.copyMode)
setTemplateModalVisible(false) setTemplateModalVisible(false)
@@ -255,6 +256,7 @@ const AddModal: React.FC<AddModalProps> = ({
: undefined, : undefined,
configName: values.configName?.trim(), configName: values.configName?.trim(),
pushFailedOrders: values.pushFailedOrders ?? false, pushFailedOrders: values.pushFailedOrders ?? false,
pushFilteredOrders: values.pushFilteredOrders ?? false,
maxMarketEndDate maxMarketEndDate
} }
@@ -307,6 +309,7 @@ const AddModal: React.FC<AddModalProps> = ({
websocketMaxRetries: 10, websocketMaxRetries: 10,
supportSell: true, supportSell: true,
pushFailedOrders: false, pushFailedOrders: false,
pushFilteredOrders: false,
keywordFilterMode: 'DISABLED' keywordFilterMode: 'DISABLED'
}} }}
> >
@@ -804,12 +807,29 @@ const AddModal: React.FC<AddModalProps> = ({
> >
<Input.Group compact style={{ display: 'flex' }}> <Input.Group compact style={{ display: 'flex' }}>
<InputNumber <InputNumber
min={1} min={0}
max={9999} max={9999}
step={1} step={1}
precision={0} precision={0}
value={maxMarketEndDateValue} value={maxMarketEndDateValue}
onChange={(value) => setMaxMarketEndDateValue(value !== null && value !== undefined ? Math.floor(value) : undefined)} onChange={(value) => {
// 允许设置为 null 或 undefined(清空)
if (value === null || value === undefined) {
setMaxMarketEndDateValue(undefined)
} else {
const num = Math.floor(value)
// 如果值为 0,也设置为 undefined(表示清空)
setMaxMarketEndDateValue(num > 0 ? num : undefined)
}
}}
onBlur={(e) => {
// 失去焦点时,如果值为 0 或空,设置为 undefined
const input = e.target as HTMLInputElement
const value = input.value
if (!value || value === '0') {
setMaxMarketEndDateValue(undefined)
}
}}
style={{ width: '60%' }} style={{ width: '60%' }}
placeholder={t('copyTradingAdd.maxMarketEndDatePlaceholder') || '输入时间值(可选)'} placeholder={t('copyTradingAdd.maxMarketEndDatePlaceholder') || '输入时间值(可选)'}
parser={(value) => { parser={(value) => {
@@ -862,6 +882,16 @@ const AddModal: React.FC<AddModalProps> = ({
<Switch /> <Switch />
</Form.Item> </Form.Item>
{/* 推送已过滤订单 */}
<Form.Item
label={t('copyTradingAdd.pushFilteredOrders') || '推送已过滤订单'}
name="pushFilteredOrders"
tooltip={t('copyTradingAdd.pushFilteredOrdersTooltip') || '开启后,被过滤的订单会推送到 Telegram'}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item> <Form.Item>
<Space> <Space>
<Button <Button
@@ -25,19 +25,44 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
const [limit, setLimit] = useState(20) const [limit, setLimit] = useState(20)
const [filters, setFilters] = useState<{ const [filters, setFilters] = useState<{
marketId?: string marketId?: string
side?: string marketTitle?: string
status?: string status?: string
}>({}) }>({})
const [groupByMarket, setGroupByMarket] = useState(false) // 是否按市场分组 // 从 localStorage 读取用户分组偏好,如果没有则默认为 false
const [groupByMarket, setGroupByMarket] = useState(() => {
const savedPreference = localStorage.getItem('copyTradingOrders_groupByMarket')
return savedPreference === 'true'
})
const [expandedMarkets, setExpandedMarkets] = useState<Set<string>>(new Set()) // 展开的市场ID集合 const [expandedMarkets, setExpandedMarkets] = useState<Set<string>>(new Set()) // 展开的市场ID集合
const [groupedOrders, setGroupedOrders] = useState<MarketOrderGroup[]>([]) // 分组数据(从后端获取) const [groupedOrders, setGroupedOrders] = useState<MarketOrderGroup[]>([]) // 分组数据(从后端获取)
const [groupedTotal, setGroupedTotal] = useState(0) // 分组总数(市场数量) const [groupedTotal, setGroupedTotal] = useState(0) // 分组总数(市场数量)
const handleMarketTitleChange = (value: string) => {
setFilters({ ...filters, marketTitle: value || undefined })
}
// 切换分组模式时,保存用户偏好到 localStorage
const handleToggleGroupByMarket = (checked: boolean) => {
setGroupByMarket(checked)
localStorage.setItem('copyTradingOrders_groupByMarket', String(checked))
}
useEffect(() => { useEffect(() => {
if (copyTradingId && active) { if (copyTradingId && active) {
fetchOrders() fetchOrders()
} }
}, [copyTradingId, active, page, limit, filters, groupByMarket]) }, [copyTradingId, active, page, limit, filters.marketId, filters.status, groupByMarket])
// 防抖搜索 - marketTitle 和 marketId 变化时延迟0.5秒再搜索
useEffect(() => {
if (!copyTradingId || !active) return
const timer = setTimeout(() => {
fetchOrders()
}, 500) // 0.5秒延迟
return () => clearTimeout(timer)
}, [filters.marketTitle])
const fetchOrders = async () => { const fetchOrders = async () => {
if (!copyTradingId) return if (!copyTradingId) return
@@ -45,12 +70,14 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
setLoading(true) setLoading(true)
try { try {
if (groupByMarket) { if (groupByMarket) {
// 调用分组接口 // 调用分组接口(支持市场筛选)
const request: MarketGroupedOrdersRequest = { const request: MarketGroupedOrdersRequest = {
copyTradingId: parseInt(copyTradingId), copyTradingId: parseInt(copyTradingId),
type: 'buy', type: 'buy',
page, page,
limit limit,
marketId: filters.marketId,
marketTitle: filters.marketTitle
} }
const response = await apiService.orderTracking.listGroupedByMarket(request) const response = await apiService.orderTracking.listGroupedByMarket(request)
@@ -123,7 +150,7 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
} }
} }
const columns = [ const columns = [
{ {
title: t('copyTradingOrders.orderId') || '订单ID', title: t('copyTradingOrders.orderId') || '订单ID',
dataIndex: 'orderId', dataIndex: 'orderId',
@@ -208,16 +235,6 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
) )
} }
}, },
{
title: t('copyTradingOrders.side') || '方向',
dataIndex: 'side',
key: 'side',
width: isMobile ? 60 : 80,
render: (side: string) => {
const displaySide = side === '0' ? 'YES' : side === '1' ? 'NO' : side
return <Tag style={{ fontSize: isMobile ? 11 : 12 }}>{displaySide}</Tag>
}
},
{ {
title: t('copyTradingOrders.buyQuantity') || '买入数量', title: t('copyTradingOrders.buyQuantity') || '买入数量',
dataIndex: 'quantity', dataIndex: 'quantity',
@@ -356,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>
@@ -367,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>
@@ -400,7 +419,6 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
minute: '2-digit' minute: '2-digit'
}) })
const amount = (parseFloat(order.quantity) * parseFloat(order.price)).toString() const amount = (parseFloat(order.quantity) * parseFloat(order.price)).toString()
const displaySide = order.side === '0' ? 'YES' : order.side === '1' ? 'NO' : order.side
return ( return (
<Card <Card
@@ -436,7 +454,6 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
)} )}
</div> </div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
<Tag style={{ fontSize: '11px' }}>{displaySide}</Tag>
{getStatusTag(order.status)} {getStatusTag(order.status)}
</div> </div>
</div> </div>
@@ -485,18 +502,17 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{orders.map((order) => { {orders.map((order) => {
const date = new Date(order.createdAt) const date = new Date(order.createdAt)
const formattedDate = date.toLocaleString('zh-CN', { const formattedDate = date.toLocaleString('zh-CN', {
year: 'numeric', year: 'numeric',
month: '2-digit', month: '2-digit',
day: '2-digit', day: '2-digit',
hour: '2-digit', hour: '2-digit',
minute: '2-digit' minute: '2-digit'
}) })
const amount = (parseFloat(order.quantity) * parseFloat(order.price)).toString() const amount = (parseFloat(order.quantity) * parseFloat(order.price)).toString()
const displaySide = order.side === '0' ? 'YES' : order.side === '1' ? 'NO' : order.side
return ( return (
<Card <Card
key={order.orderId} key={order.orderId}
style={{ style={{
@@ -529,7 +545,6 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
)} )}
</div> </div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
<Tag>{displaySide}</Tag>
{getStatusTag(order.status)} {getStatusTag(order.status)}
</div> </div>
</div> </div>
@@ -633,23 +648,20 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
<div> <div>
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'center' }}> <div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'center' }}>
<Input <Input
placeholder={t('copyTradingOrders.filterMarketId') || '筛选市场ID'} placeholder={t('copyTradingOrders.filterMarketId') || '筛选市场ID(支持模糊匹配)'}
allowClear allowClear
style={{ width: isMobile ? '100%' : 200 }} style={{ width: isMobile ? '100%' : 200 }}
value={filters.marketId} value={filters.marketId}
onChange={(e) => setFilters({ ...filters, marketId: e.target.value || undefined })} onChange={(e) => setFilters({ ...filters, marketId: e.target.value || undefined })}
/> />
<Select <Input
placeholder={t('copyTradingOrders.filterSide') || '筛选方向'} placeholder={t('copyTradingOrders.filterMarketTitle') || '筛选市场标题'}
allowClear allowClear
style={{ width: isMobile ? '100%' : 150 }} style={{ width: isMobile ? '100%' : 200 }}
value={filters.side} value={filters.marketTitle}
onChange={(value) => setFilters({ ...filters, side: value || undefined })} onChange={(e) => handleMarketTitleChange(e.target.value)}
> />
<Option value="0">YES</Option>
<Option value="1">NO</Option>
</Select>
<Select <Select
placeholder={t('copyTradingOrders.filterStatus') || '筛选状态'} placeholder={t('copyTradingOrders.filterStatus') || '筛选状态'}
@@ -658,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>
@@ -669,7 +681,7 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
</span> </span>
<Switch <Switch
checked={groupByMarket} checked={groupByMarket}
onChange={setGroupByMarket} onChange={handleToggleGroupByMarket}
checkedChildren={<AppstoreOutlined />} checkedChildren={<AppstoreOutlined />}
unCheckedChildren={<UnorderedListOutlined />} unCheckedChildren={<UnorderedListOutlined />}
/> />
@@ -91,7 +91,8 @@ const EditModal: React.FC<EditModalProps> = ({
maxPositionCount: found.maxPositionCount, maxPositionCount: found.maxPositionCount,
keywordFilterMode: found.keywordFilterMode || 'DISABLED', keywordFilterMode: found.keywordFilterMode || 'DISABLED',
configName: found.configName || '', configName: found.configName || '',
pushFailedOrders: found.pushFailedOrders ?? false pushFailedOrders: found.pushFailedOrders ?? false,
pushFilteredOrders: found.pushFilteredOrders ?? false
}) })
// 设置关键字列表 // 设置关键字列表
setKeywords(found.keywords || []) setKeywords(found.keywords || [])
@@ -168,12 +169,17 @@ const EditModal: React.FC<EditModalProps> = ({
} }
// 计算市场截止时间(毫秒) // 计算市场截止时间(毫秒)
// 如果用户清空了,传 -1 表示要清空(后端会识别并设置为 null)
let maxMarketEndDate: number | undefined let maxMarketEndDate: number | undefined
if (maxMarketEndDateValue !== undefined && maxMarketEndDateValue > 0) { if (maxMarketEndDateValue !== undefined && maxMarketEndDateValue !== null && maxMarketEndDateValue > 0) {
const multiplier = maxMarketEndDateUnit === 'HOUR' const multiplier = maxMarketEndDateUnit === 'HOUR'
? 60 * 60 * 1000 // 小时转毫秒 ? 60 * 60 * 1000 // 小时转毫秒
: 24 * 60 * 60 * 1000 // 天转毫秒 : 24 * 60 * 60 * 1000 // 天转毫秒
maxMarketEndDate = maxMarketEndDateValue * multiplier maxMarketEndDate = maxMarketEndDateValue * multiplier
} else {
// 如果值为 null/undefined/0/负数,传 -1 表示要清空
// 这样无论之前是否有值,清空后都会设置为 null
maxMarketEndDate = -1
} }
setLoading(true) setLoading(true)
@@ -195,18 +201,21 @@ const EditModal: React.FC<EditModalProps> = ({
websocketReconnectInterval: values.websocketReconnectInterval, websocketReconnectInterval: values.websocketReconnectInterval,
websocketMaxRetries: values.websocketMaxRetries, websocketMaxRetries: values.websocketMaxRetries,
supportSell: values.supportSell, supportSell: values.supportSell,
minOrderDepth: values.minOrderDepth?.toString(), // 对于可选字段,始终发送(即使为空也发送空字符串,让后端知道要清空)
maxSpread: values.maxSpread?.toString(), minOrderDepth: values.minOrderDepth != null ? values.minOrderDepth.toString() : '',
minPrice: values.minPrice?.toString(), maxSpread: values.maxSpread != null ? values.maxSpread.toString() : '',
maxPrice: values.maxPrice?.toString(), minPrice: values.minPrice != null ? values.minPrice.toString() : '',
maxPositionValue: values.maxPositionValue?.toString(), maxPrice: values.maxPrice != null ? values.maxPrice.toString() : '',
maxPositionCount: values.maxPositionCount, maxPositionValue: values.maxPositionValue != null ? values.maxPositionValue.toString() : '',
// 对于 maxPositionCount,如果值为 null/undefined,传 -1 表示要清空(后端会识别并设置为 null)
maxPositionCount: values.maxPositionCount != null ? values.maxPositionCount : -1,
keywordFilterMode: values.keywordFilterMode || 'DISABLED', keywordFilterMode: values.keywordFilterMode || 'DISABLED',
keywords: (values.keywordFilterMode === 'WHITELIST' || values.keywordFilterMode === 'BLACKLIST') keywords: (values.keywordFilterMode === 'WHITELIST' || values.keywordFilterMode === 'BLACKLIST')
? keywords ? keywords
: undefined, : undefined,
configName: values.configName?.trim() || undefined, configName: values.configName?.trim() || undefined,
pushFailedOrders: values.pushFailedOrders, pushFailedOrders: values.pushFailedOrders,
pushFilteredOrders: values.pushFilteredOrders,
maxMarketEndDate maxMarketEndDate
} }
@@ -722,12 +731,29 @@ const EditModal: React.FC<EditModalProps> = ({
> >
<Input.Group compact style={{ display: 'flex' }}> <Input.Group compact style={{ display: 'flex' }}>
<InputNumber <InputNumber
min={1} min={0}
max={9999} max={9999}
step={1} step={1}
precision={0} precision={0}
value={maxMarketEndDateValue} value={maxMarketEndDateValue}
onChange={(value) => setMaxMarketEndDateValue(value !== null && value !== undefined ? Math.floor(value) : undefined)} onChange={(value) => {
// 允许设置为 null 或 undefined(清空)
if (value === null || value === undefined) {
setMaxMarketEndDateValue(undefined)
} else {
const num = Math.floor(value)
// 如果值为 0,也设置为 undefined(表示清空)
setMaxMarketEndDateValue(num > 0 ? num : undefined)
}
}}
onBlur={(e) => {
// 失去焦点时,如果值为 0 或空,设置为 undefined
const input = e.target as HTMLInputElement
const value = input.value
if (!value || value === '0') {
setMaxMarketEndDateValue(undefined)
}
}}
style={{ width: '60%' }} style={{ width: '60%' }}
placeholder={t('copyTradingEdit.maxMarketEndDatePlaceholder') || '输入时间值(可选)'} placeholder={t('copyTradingEdit.maxMarketEndDatePlaceholder') || '输入时间值(可选)'}
parser={(value) => { parser={(value) => {
@@ -778,6 +804,15 @@ const EditModal: React.FC<EditModalProps> = ({
<Switch /> <Switch />
</Form.Item> </Form.Item>
<Form.Item
label={t('copyTradingEdit.pushFilteredOrders') || '推送已过滤订单'}
name="pushFilteredOrders"
tooltip={t('copyTradingEdit.pushFilteredOrdersTooltip') || '开启后,被过滤的订单会推送到 Telegram'}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item> <Form.Item>
<Space> <Space>
<Button <Button
@@ -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'
@@ -24,13 +24,29 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId, acti
const [filters, setFilters] = useState<{ const [filters, setFilters] = useState<{
sellOrderId?: string sellOrderId?: string
buyOrderId?: string buyOrderId?: string
marketTitle?: string
}>({}) }>({})
const handleMarketTitleChange = (value: string) => {
setFilters({ ...filters, marketTitle: value || undefined })
}
useEffect(() => { useEffect(() => {
if (copyTradingId && active) { if (copyTradingId && active) {
fetchOrders() fetchOrders()
} }
}, [copyTradingId, active, page, limit, filters]) }, [copyTradingId, active, page, limit])
// 防抖搜索 - marketTitle 和 orderId 变化时延迟0.5秒再搜索
useEffect(() => {
if (!copyTradingId || !active) return
const timer = setTimeout(() => {
fetchOrders()
}, 500) // 0.5秒延迟
return () => clearTimeout(timer)
}, [filters.marketTitle, filters.buyOrderId, filters.sellOrderId])
const fetchOrders = async () => { const fetchOrders = async () => {
if (!copyTradingId) return if (!copyTradingId) return
@@ -75,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>
) )
} }
@@ -193,6 +251,14 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId, acti
return ( return (
<div> <div>
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}> <div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<Input
placeholder={t('copyTradingOrders.filterMarketTitle') || '筛选市场标题'}
allowClear
style={{ width: isMobile ? '100%' : 200 }}
value={filters.marketTitle}
onChange={(e) => handleMarketTitleChange(e.target.value)}
/>
<Input <Input
placeholder={t('copyTradingOrders.filterSellOrderId') || '筛选卖出订单ID'} placeholder={t('copyTradingOrders.filterSellOrderId') || '筛选卖出订单ID'}
allowClear allowClear
@@ -209,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 ? (
@@ -249,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' }}>
@@ -260,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' }} />
@@ -25,18 +25,44 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
const [limit, setLimit] = useState(20) const [limit, setLimit] = useState(20)
const [filters, setFilters] = useState<{ const [filters, setFilters] = useState<{
marketId?: string marketId?: string
side?: string marketTitle?: string
status?: string
}>({}) }>({})
const [groupByMarket, setGroupByMarket] = useState(false) // 是否按市场分组 // 从 localStorage 读取用户分组偏好,如果没有则默认为 false
const [groupByMarket, setGroupByMarket] = useState(() => {
const savedPreference = localStorage.getItem('copyTradingOrders_groupByMarket_sell')
return savedPreference === 'true'
})
const [expandedMarkets, setExpandedMarkets] = useState<Set<string>>(new Set()) // 展开的市场ID集合 const [expandedMarkets, setExpandedMarkets] = useState<Set<string>>(new Set()) // 展开的市场ID集合
const [groupedOrders, setGroupedOrders] = useState<MarketOrderGroup[]>([]) // 分组数据(从后端获取) const [groupedOrders, setGroupedOrders] = useState<MarketOrderGroup[]>([]) // 分组数据(从后端获取)
const [groupedTotal, setGroupedTotal] = useState(0) // 分组总数(市场数量) const [groupedTotal, setGroupedTotal] = useState(0) // 分组总数(市场数量)
const handleMarketTitleChange = (value: string) => {
setFilters({ ...filters, marketTitle: value || undefined })
}
// 切换分组模式时,保存用户偏好到 localStorage
const handleToggleGroupByMarket = (checked: boolean) => {
setGroupByMarket(checked)
localStorage.setItem('copyTradingOrders_groupByMarket_sell', String(checked))
}
useEffect(() => { useEffect(() => {
if (copyTradingId && active) { if (copyTradingId && active) {
fetchOrders() fetchOrders()
} }
}, [copyTradingId, active, page, limit, filters, groupByMarket]) }, [copyTradingId, active, page, limit, filters.marketId, filters.status, groupByMarket])
// 防抖搜索 - marketTitle 变化时延迟0.5秒再搜索
useEffect(() => {
if (!copyTradingId || !active) return
const timer = setTimeout(() => {
fetchOrders()
}, 500) // 0.5秒延迟
return () => clearTimeout(timer)
}, [filters.marketTitle])
const fetchOrders = async () => { const fetchOrders = async () => {
if (!copyTradingId) return if (!copyTradingId) return
@@ -49,7 +75,9 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
copyTradingId: parseInt(copyTradingId), copyTradingId: parseInt(copyTradingId),
type: 'sell', type: 'sell',
page, page,
limit limit,
marketId: filters.marketId,
marketTitle: filters.marketTitle
} }
const response = await apiService.orderTracking.listGroupedByMarket(request) const response = await apiService.orderTracking.listGroupedByMarket(request)
@@ -204,16 +232,6 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
) )
} }
}, },
{
title: t('copyTradingOrders.side') || '方向',
dataIndex: 'side',
key: 'side',
width: isMobile ? 60 : 80,
render: (side: string) => {
const displaySide = side === '0' ? 'YES' : side === '1' ? 'NO' : side
return <Tag style={{ fontSize: isMobile ? 11 : 12 }}>{displaySide}</Tag>
}
},
{ {
title: t('copyTradingOrders.sellQuantity') || '卖出数量', title: t('copyTradingOrders.sellQuantity') || '卖出数量',
dataIndex: 'quantity', dataIndex: 'quantity',
@@ -340,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>
@@ -378,7 +396,6 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
minute: '2-digit' minute: '2-digit'
}) })
const amount = (parseFloat(order.quantity) * parseFloat(order.price)).toString() const amount = (parseFloat(order.quantity) * parseFloat(order.price)).toString()
const displaySide = order.side === '0' ? 'YES' : order.side === '1' ? 'NO' : order.side
return ( return (
<Card <Card
@@ -413,9 +430,6 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
/> />
)} )}
</div> </div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
<Tag style={{ fontSize: '11px' }}>{displaySide}</Tag>
</div>
</div> </div>
<div style={{ fontSize: '12px', color: '#666' }}> <div style={{ fontSize: '12px', color: '#666' }}>
@@ -473,7 +487,6 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
minute: '2-digit' minute: '2-digit'
}) })
const amount = (parseFloat(order.quantity) * parseFloat(order.price)).toString() const amount = (parseFloat(order.quantity) * parseFloat(order.price)).toString()
const displaySide = order.side === '0' ? 'YES' : order.side === '1' ? 'NO' : order.side
return ( return (
<Card <Card
@@ -507,9 +520,6 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
/> />
)} )}
</div> </div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
<Tag>{displaySide}</Tag>
</div>
</div> </div>
<Divider style={{ margin: '12px 0' }} /> <Divider style={{ margin: '12px 0' }} />
@@ -622,17 +632,24 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
onChange={(e) => setFilters({ ...filters, marketId: e.target.value || undefined })} onChange={(e) => setFilters({ ...filters, marketId: e.target.value || undefined })}
/> />
<Input
placeholder={t('copyTradingOrders.filterMarketTitle') || '筛选市场标题'}
allowClear
style={{ width: isMobile ? '100%' : 200 }}
value={filters.marketTitle}
onChange={(e) => handleMarketTitleChange(e.target.value)}
/>
<Select <Select
placeholder={t('copyTradingOrders.filterSide') || '筛选方向'} placeholder={t('copyTradingOrders.filterStatus') || '筛选状态'}
allowClear allowClear
style={{ width: isMobile ? '100%' : 150 }} style={{ width: isMobile ? '100%' : 150 }}
value={filters.side} value={filters.status}
onChange={(value) => setFilters({ ...filters, side: value || undefined })} onChange={(value) => setFilters({ ...filters, status: value || undefined })}
> >
<Option value="0">YES</Option> <Option value="filled">{t('copyTradingOrders.statusFilled') || '未成交'}</Option>
<Option value="1">NO</Option> <Option value="partially_matched">{t('copyTradingOrders.partiallySold') || '部分卖出'}</Option>
<Option value="YES">YES</Option> <Option value="fully_matched">{t('copyTradingOrders.allFullySold') || '全部卖出'}</Option>
<Option value="NO">NO</Option>
</Select> </Select>
<Space> <Space>
@@ -641,7 +658,7 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
</span> </span>
<Switch <Switch
checked={groupByMarket} checked={groupByMarket}
onChange={setGroupByMarket} onChange={handleToggleGroupByMarket}
checkedChildren={<AppstoreOutlined />} checkedChildren={<AppstoreOutlined />}
unCheckedChildren={<UnorderedListOutlined />} unCheckedChildren={<UnorderedListOutlined />}
/> />
+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>
)} )}
{/* 出售模态框 */} {/* 出售模态框 */}
+13 -2
View File
@@ -55,7 +55,8 @@ const TemplateAdd: React.FC = () => {
minOrderDepth: values.minOrderDepth?.toString(), minOrderDepth: values.minOrderDepth?.toString(),
maxSpread: values.maxSpread?.toString(), maxSpread: values.maxSpread?.toString(),
minPrice: values.minPrice?.toString(), minPrice: values.minPrice?.toString(),
maxPrice: values.maxPrice?.toString() maxPrice: values.maxPrice?.toString(),
pushFilteredOrders: values.pushFilteredOrders ?? false
}) })
if (response.data.code === 0) { if (response.data.code === 0) {
@@ -96,7 +97,8 @@ const TemplateAdd: React.FC = () => {
minOrderSize: 1, minOrderSize: 1,
maxDailyOrders: 100, maxDailyOrders: 100,
priceTolerance: 5, priceTolerance: 5,
supportSell: true supportSell: true,
pushFilteredOrders: false
}} }}
> >
<Form.Item <Form.Item
@@ -373,6 +375,15 @@ const TemplateAdd: React.FC = () => {
<Switch /> <Switch />
</Form.Item> </Form.Item>
<Form.Item
label={t('templateAdd.pushFilteredOrders') || '推送已过滤订单'}
name="pushFilteredOrders"
tooltip={t('templateAdd.pushFilteredOrdersTooltip') || '开启后,被过滤的订单会推送到 Telegram'}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item shouldUpdate> <Form.Item shouldUpdate>
{({ getFieldsError }) => { {({ getFieldsError }) => {
const errors = getFieldsError() const errors = getFieldsError()
+13 -2
View File
@@ -41,7 +41,8 @@ const TemplateEdit: React.FC = () => {
minOrderDepth: template.minOrderDepth ? parseFloat(template.minOrderDepth) : undefined, minOrderDepth: template.minOrderDepth ? parseFloat(template.minOrderDepth) : undefined,
maxSpread: template.maxSpread ? parseFloat(template.maxSpread) : undefined, maxSpread: template.maxSpread ? parseFloat(template.maxSpread) : undefined,
minPrice: template.minPrice ? parseFloat(template.minPrice) : undefined, minPrice: template.minPrice ? parseFloat(template.minPrice) : undefined,
maxPrice: template.maxPrice ? parseFloat(template.maxPrice) : undefined maxPrice: template.maxPrice ? parseFloat(template.maxPrice) : undefined,
pushFilteredOrders: template.pushFilteredOrders ?? false
}) })
} else { } else {
message.error(response.data.msg || t('templateEdit.fetchFailed') || '获取模板详情失败') message.error(response.data.msg || t('templateEdit.fetchFailed') || '获取模板详情失败')
@@ -99,7 +100,8 @@ const TemplateEdit: React.FC = () => {
minOrderDepth: values.minOrderDepth?.toString(), minOrderDepth: values.minOrderDepth?.toString(),
maxSpread: values.maxSpread?.toString(), maxSpread: values.maxSpread?.toString(),
minPrice: values.minPrice?.toString(), minPrice: values.minPrice?.toString(),
maxPrice: values.maxPrice?.toString() maxPrice: values.maxPrice?.toString(),
pushFilteredOrders: values.pushFilteredOrders
}) })
if (response.data.code === 0) { if (response.data.code === 0) {
@@ -409,6 +411,15 @@ const TemplateEdit: React.FC = () => {
<Switch /> <Switch />
</Form.Item> </Form.Item>
<Form.Item
label={t('templateEdit.pushFilteredOrders') || '推送已过滤订单'}
name="pushFilteredOrders"
tooltip={t('templateEdit.pushFilteredOrdersTooltip') || '开启后,被过滤的订单会推送到 Telegram'}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item shouldUpdate> <Form.Item shouldUpdate>
{({ getFieldsError }) => { {({ getFieldsError }) => {
const errors = getFieldsError() const errors = getFieldsError()
+12 -1
View File
@@ -72,6 +72,7 @@ const TemplateList: React.FC = () => {
maxDailyOrders: template.maxDailyOrders, maxDailyOrders: template.maxDailyOrders,
priceTolerance: parseFloat(template.priceTolerance), priceTolerance: parseFloat(template.priceTolerance),
supportSell: template.supportSell, supportSell: template.supportSell,
pushFilteredOrders: template.pushFilteredOrders ?? false,
minOrderDepth: template.minOrderDepth ? parseFloat(template.minOrderDepth) : undefined, minOrderDepth: template.minOrderDepth ? parseFloat(template.minOrderDepth) : undefined,
maxSpread: template.maxSpread ? parseFloat(template.maxSpread) : undefined, maxSpread: template.maxSpread ? parseFloat(template.maxSpread) : undefined,
minPrice: template.minPrice ? parseFloat(template.minPrice) : undefined, minPrice: template.minPrice ? parseFloat(template.minPrice) : undefined,
@@ -122,7 +123,8 @@ const TemplateList: React.FC = () => {
minOrderDepth: values.minOrderDepth?.toString(), minOrderDepth: values.minOrderDepth?.toString(),
maxSpread: values.maxSpread?.toString(), maxSpread: values.maxSpread?.toString(),
minPrice: values.minPrice?.toString(), minPrice: values.minPrice?.toString(),
maxPrice: values.maxPrice?.toString() maxPrice: values.maxPrice?.toString(),
pushFilteredOrders: values.pushFilteredOrders ?? false
}) })
if (response.data.code === 0) { if (response.data.code === 0) {
@@ -631,6 +633,15 @@ const TemplateList: React.FC = () => {
<Switch /> <Switch />
</Form.Item> </Form.Item>
<Form.Item
label={t('templateList.pushFilteredOrders') || '推送已过滤订单'}
name="pushFilteredOrders"
tooltip={t('templateList.pushFilteredOrdersTooltip') || '开启后,被过滤的订单会推送到 Telegram'}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Divider></Divider> <Divider></Divider>
<Form.Item <Form.Item
+7 -1
View File
@@ -116,6 +116,7 @@ export interface CopyTradingTemplate {
maxSpread?: string maxSpread?: string
minPrice?: string // 最低价格(可选),NULL表示不限制最低价 minPrice?: string // 最低价格(可选),NULL表示不限制最低价
maxPrice?: string // 最高价格(可选),NULL表示不限制最高价 maxPrice?: string // 最高价格(可选),NULL表示不限制最高价
pushFilteredOrders?: boolean // 推送已过滤订单(默认关闭)
createdAt: number createdAt: number
updatedAt: number updatedAt: number
} }
@@ -266,6 +267,7 @@ export interface CopyTradingCreateRequest {
// 新增配置字段 // 新增配置字段
configName?: string // 配置名(可选,但提供时必须非空) configName?: string // 配置名(可选,但提供时必须非空)
pushFailedOrders?: boolean // 推送失败订单(可选) pushFailedOrders?: boolean // 推送失败订单(可选)
pushFilteredOrders?: boolean // 推送已过滤订单(可选)
maxMarketEndDate?: number // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用 maxMarketEndDate?: number // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
} }
@@ -304,6 +306,7 @@ export interface CopyTradingUpdateRequest {
// 新增配置字段 // 新增配置字段
configName?: string // 配置名(可选,但提供时必须非空) configName?: string // 配置名(可选,但提供时必须非空)
pushFailedOrders?: boolean // 推送失败订单(可选) pushFailedOrders?: boolean // 推送失败订单(可选)
pushFilteredOrders?: boolean // 推送已过滤订单(可选)
maxMarketEndDate?: number // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用 maxMarketEndDate?: number // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
} }
@@ -670,6 +673,7 @@ export interface SellOrderInfo {
price: string price: string
amount: string amount: string
realizedPnl: string realizedPnl: string
status?: string // 卖出状态(filled, partially_matched, fully_matched
createdAt: number createdAt: number
} }
@@ -710,7 +714,7 @@ export interface OrderTrackingRequest {
page?: number page?: number
limit?: number limit?: number
marketId?: string marketId?: string
side?: string marketTitle?: string
status?: string status?: string
sellOrderId?: string sellOrderId?: string
buyOrderId?: string buyOrderId?: string
@@ -724,6 +728,8 @@ export interface MarketGroupedOrdersRequest {
type: 'buy' | 'sell' type: 'buy' | 'sell'
page?: number page?: number
limit?: number limit?: number
marketId?: string
marketTitle?: string
} }
/** /**
+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"
}
}