Compare commits

..

63 Commits

Author SHA1 Message Date
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
WrBug d624eaeb57 docs: 添加 v1.1.9 release notes 2026-01-10 17:51:54 +08:00
WrBug 6ad40242b3 fix: 优化自动卖出订单筛选逻辑,避免刚创建的订单被误判
问题:在仓位检查时,刚创建的订单可能因仓位更新延迟被误判为已卖出

修改:
1. Repository 层面新增 findUnmatchedBuyOrdersByOutcomeIndexOlderThan 方法
   - 在 SQL 层直接过滤创建时间超过阈值的订单
   - 提高查询效率,减少数据传输

2. PositionCheckService 两个场景都使用 SQL 过滤
   - 场景1:仓位不存在时,延迟检测使用 SQL 过滤
   - 场景2:仓位部分存在时,FIFO 匹配使用 SQL 过滤
   - 统一使用 2 分钟阈值保护刚创建的订单

优化效果:
- SQL 层面直接过滤,减少数据传输
- 代码更简洁,逻辑更清晰
- 刚创建的订单(< 2 分钟)不会被误判为已卖出
- 日志更详细,便于排查问题
2026-01-10 03:39:06 +08:00
WrBug 424153cfc7 docs: 添加 v1.1.8 release notes 2026-01-09 11:08:40 +08:00
WrBug 19508dcd19 feat: 实现 Polymarket Activity WebSocket 双重监听方案
主要功能:
- 新增 PolymarketActivityWsService:通过 Activity WebSocket 实时监听 Leader 交易(延迟 < 100ms)
- 实现双重监听机制:Activity WS(低延迟)+ On-Chain WS(高可靠性)
- 支持动态添加/移除 Leader 监听
- 地址筛选忽略大小写

修复和改进:
- 修复订阅消息格式:使用 'action' 而不是 'type'(与 @polymarket/real-time-data-client 保持一致)
- 修复 PING 消息格式:使用小写 'ping'(与官方客户端保持一致)
- 添加 outcomeIndex 字段支持:优先使用消息中的 outcomeIndex,fallback 到从 outcome 解析
- 添加 connection_id 字段到 DTO
- 改进日志记录:增加 INFO 级别日志以便调试

代码重构:
- JsonUtils 重构:将 fromJson 和 toJson 改为扩展方法
- 更新后端开发规范:JSON 解析和 Data Class 规范

前端修复:
- 修复 InputNumber parser 类型错误:返回 number 而不是 string

文档:
- 添加双重监听方案文档
- 添加 Polymarket Activity WebSocket API 格式文档
2026-01-09 10:41:15 +08:00
WrBug a16b6fce54 feat: 支持同一用户创建多个相同 leader 的跟单配置
- 删除 copy_trading 表的唯一约束 uk_account_leader
- 移除实体类 CopyTrading 的唯一约束注解
- 移除创建跟单时的重复检查逻辑
- 修改 Repository 方法 findByAccountIdAndLeaderId 返回列表而非单个对象
- 更新查询逻辑以支持返回多个跟单配置

现在允许同一用户对同一个 leader 创建多个不同配置的跟单关系。
2026-01-09 08:56:14 +08:00
WrBug 0327eaffe3 feat: 添加市场截止时间筛选功能
后端:
- 为 copy_trading 表添加 max_market_end_date 字段(毫秒时间戳)
- 为 markets 表添加 end_date 字段,迁移时清空已有数据
- 实现市场截止时间过滤逻辑(仅跟单截止时间小于设置时间的订单)
- 更新 CopyTradingService 支持 maxMarketEndDate 的创建和更新
- 更新 FilterResult 添加 FAILED_MARKET_END_DATE 状态
- 更新 CopyOrderTrackingService 传递 marketEndDate 参数

前端:
- 添加市场截止时间筛选 UI(支持小时和天两种单位)
- 输入限制:仅支持整数输入(precision=0)
- 更新 AddModal 和 EditModal 支持市场截止时间配置
- 更新过滤类型显示映射(MARKET_END_DATE)
- 添加多语言支持(中文、英文、繁体中文)

数据库迁移:
- V22: 合并添加 max_market_end_date 和 end_date 字段
- 迁移时清空 markets 表数据,确保所有数据都包含 end_date
2026-01-09 08:35:05 +08:00
WrBug b13aef944c refactor: 统一使用 MarketService 并改用 LRU 缓存
主要改进:
1. 将 MarketService 缓存从 ConcurrentHashMap 改为 Caffeine LRU 缓存
   - 最多缓存 200 条市场记录
   - 自动淘汰最近最少使用的记录
   - 提高缓存命中率和性能

2. 添加 Caffeine 依赖 (com.github.ben-manes.caffeine:caffeine:3.1.8)

3. 重构以下服务,统一使用 MarketService 获取市场信息:
   - OrderPushService: 删除 fetchMarketInfo() 方法,直接使用 MarketService
   - CopyOrderTrackingService: 移除直接调用 Gamma API 的代码
   - AccountService: 订单通知中使用 MarketService
   - OrderStatusUpdateService: 买入/卖出订单通知使用 MarketService

优势:
- 减少重复的 API 调用,利用三级缓存(内存 → 数据库 → API)
- 提高代码一致性和可维护性
- 统一市场数据获取逻辑,便于后续扩展
2026-01-08 18:33:49 +08:00
WrBug 9ed5190bfe feat: 添加关键字过滤功能并优化市场 slug 处理
主要变更:
1. 关键字过滤功能
   - 添加关键字过滤模式(白名单/黑名单/禁用)
   - 支持关键字列表配置(JSON 存储)
   - 实现关键字匹配逻辑(不区分大小写)
   - 数据库迁移:V20__add_keyword_filter.sql

2. 市场 slug 优化
   - 区分显示用 slug 和跳转用 eventSlug
   - 添加 event_slug 字段到 markets 表(V21__add_event_slug_to_markets.sql)
   - 从 events[0].slug 获取跳转用的 slug 并存入数据库
   - 减少 API 请求,提高性能

3. UI 优化
   - 将创建/编辑跟单配置改为 Modal 方式
   - 删除独立的 CopyTradingAdd 和 CopyTradingEdit 页面
   - 更新前端跳转逻辑,优先使用 eventSlug

4. API 响应优化
   - 更新 MarketResponse 添加 events 字段
   - 统一从 events[0].slug 获取跳转用的 slug
   - 更新所有相关服务使用新的 slug 获取逻辑
2026-01-08 17:47:00 +08:00
WrBug 3d5a923caf feat: 订单列表按市场分组功能重构
- 后端实现按市场分组的订单列表接口
  - 添加 MarketGroupedOrdersRequest、MarketOrderStats、MarketOrderGroup、MarketGroupedOrdersResponse DTO
  - 实现 getBuyOrderListGroupedByMarket 和 getSellOrderListGroupedByMarket 方法
  - 添加 POST /api/copy-trading/orders/grouped-by-market 接口
  - 支持分页和统计信息(订单数、总金额、盈亏、成交状态等)

- 前端重构:移除前端分组逻辑,改为调用后端分组接口
  - BuyOrdersTab 和 SellOrdersTab 使用后端分组接口
  - 移除前端 useMemo 分组计算逻辑
  - 添加分组数据状态管理和分页支持

- 分组默认折叠状态
  - 移除桌面端自动展开逻辑
  - 所有设备默认折叠,用户手动展开

- 修复 moneyline URL 逻辑
  - getPolymarketUrl 函数添加 isMoneyline 参数
  - 暂时不自动跳转到 moneyline 页面(需要后端提供标识)

- 多语言支持
  - 添加 markets 翻译键(中文、繁体中文、英文)
2026-01-08 13:19:52 +08:00
WrBug 2af2c0e86a feat: 订单列表按市场分组并支持跳转到Polymarket
- 后端添加市场slug和分类字段
  - 在 BuyOrderInfo、SellOrderInfo、MatchedOrderInfo DTO 中添加 marketSlug 和 marketCategory 字段
  - 在 CopyTradingStatisticsService 中填充这些字段(从 Market 实体获取)

- 前端实现按市场分组功能
  - 添加分组视图切换开关(列表视图/分组视图)
  - 分组显示:按市场分组,显示市场名称、订单数、总金额、盈亏等统计信息
  - 买入订单分组显示是否完全成交状态(全部成交/部分成交)
  - 卖出订单分组显示总盈亏
  - 支持展开/折叠功能(单个和全部)
  - 桌面端默认全部展开,移动端默认全部折叠

- 市场名称点击跳转功能
  - 添加 getPolymarketUrl 工具函数,根据市场分类构建URL
  - sports 分类市场跳转到 /moneyline 页面(spread 市场)
  - 其他分类市场跳转到普通市场页面
  - 如果没有 slug,使用 marketId 作为后备(跳转到 condition 页面)
  - 分组视图、列表视图、移动端卡片视图均支持点击跳转

- 多语言支持
  - 添加分组相关翻译键:groupByMarket、expandAll、collapseAll、allFullyMatched、partiallyMatched、orderCount、totalAmount、totalPnl、statusBreakdown
2026-01-08 12:39:09 +08:00
WrBug d376a82ccc feat: 添加市场信息管理和订单ID复制功能
- 新增市场信息表和服务,支持市场名称缓存和自动更新
  - 创建 Market 实体和 MarketRepository
  - 实现 MarketService 提供市场信息查询和缓存
  - 实现 MarketPollingService 每30秒自动检查并更新缺失的市场信息
  - 添加数据库迁移 V19 创建 markets 表

- 订单列表显示市场名称
  - 在 BuyOrderInfo、SellOrderInfo、MatchedOrderInfo 中添加 marketTitle 字段
  - 订单查询时自动查询并填充市场名称
  - 前端订单列表显示市场名称(优先显示名称,ID作为辅助信息)

- 前端订单ID复制功能
  - 添加 isAutoGeneratedOrderId 和 copyToClipboard 工具函数
  - 非自动生成的订单ID支持一键复制
  - 买入、卖出、匹配订单列表均支持复制功能

- 仓位检查延迟检测机制优化
  - 首次检测到仓位不存在时先记录,3分钟后再次检查
  - 避免因API延迟导致的误判
2026-01-08 11:56:52 +08:00
WrBug e75c93ddf4 Merge pull request #16 from WrBug/release/v1.1.7
Release v1.1.7
2026-01-07 12:53:27 +08:00
WrBug 018697935a docs: 添加 v1.1.7 release notes 2026-01-07 12:41:59 +08:00
WrBug 53ec252609 更新 Telegram 群链接并添加 Docker 版本徽章
- 将所有 Telegram 群链接统一更新为 t.me/polyhermes
- 在 README 和 README_EN.md 中添加动态 Docker 版本徽章,自动显示最新版本
- 更新 Layout.tsx、RELEASE.md 中的 Telegram 链接
2026-01-07 12:37:23 +08:00
WrBug 6a0fcdaef2 修复市场条件查询的 RPC 调用错误
- 修复 BlockchainService.getCondition 方法:将错误的 conditions(bytes32) 函数调用改为正确的 getOutcomeSlotCount(bytes32) 和 payoutDenominator(bytes32) 函数调用,解决 execution reverted 错误
- 改进 MarketPriceService 的错误处理:当链上查询出现 RPC 错误时,降级到 CLOB API 或 Gamma API 查询,而不是直接抛出异常,提高容错性
2026-01-07 12:22:44 +08:00
WrBug 9a00bb19dc feat: 添加 Docker 部署日志级别环境变量支持
- 在 application.properties 中支持通过 LOG_LEVEL_ROOT 和 LOG_LEVEL_APP 环境变量配置日志级别
- 在 docker-compose.yml 和 docker-compose.prod.yml 中添加日志级别环境变量配置
- 在 deploy.sh 的 .env 模板中添加日志级别配置说明
- 支持通过环境变量动态配置日志级别,无需修改配置文件
- 默认值:root=INFO, app=DEBUG
2026-01-07 11:26:03 +08:00
WrBug 3d603cac32 fix: 修复 RPC 错误时误创建自动卖出记录的问题
- 当链上查询市场条件出现 RPC 错误(execution reverted)时,不再降级到其他数据源
- 抛出异常让调用方决定如何处理,避免在市场不存在或尚未创建时误判为已卖出
- 修改 getPriceFromChainCondition 返回 Pair<BigDecimal?, Boolean>,第二个值表示是否发生 RPC 错误
- 在 getCurrentMarketPrice 中检测到 RPC 错误时抛出异常,PositionCheckService 会捕获并跳过该市场的处理
2026-01-07 10:55:11 +08:00
WrBug f257457ad3 feat: 实现 Polymarket Maker Rebates Program 费率支持
- 添加费率查询 API 接口 (getFeeRate)
- 修正 API 返回字段名:使用 base_fee 而非 fee_rate_bps(与 TypeScript clob-client 一致)
- 在所有订单创建处动态获取费率:
  * 跟单买入订单 (processBuyTrade)
  * 跟单卖出订单 (matchSellOrder)
  * 账户卖出订单 (sellPosition)
- 费率获取失败时降级到默认值 "0",确保系统可用性
- 添加详细的日志记录,便于监控和调试

参考文档: https://docs.polymarket.com/developers/market-makers/maker-rebates-program
2026-01-07 00:31:07 +08:00
WrBug e48149a19b feat: 优化跟单仓位检查逻辑,使用本地订单数据库解决推送延迟问题,并引入混合检查机制 2026-01-05 10:49:59 +08:00
WrBug c0e0942404 fix: prevent duplicate telegram notifications for same orderId
- feat: add deduplication cache in TelegramNotificationService
- fix: skip sending notification if orderId was sent within last 5 minutes
2026-01-04 21:36:02 +08:00
WrBug 6b8c11f171 fix: refine price rounding and optimize order polling
- fix: round buy prices UP and sell prices DOWN to 2 decimal places to match USDC precision
- perf: optimize order status polling by filtering fully_matched orders in SQL
- chore: add debug logging for order creation and FAK error investigation
2026-01-04 21:30:46 +08:00
WrBug 3e667b70fd feat: enhance copy trading logic and UI
- fix: correct copyRatio scaling (divide by 100)
- fix: enforce minimum order size with round-up logic
- feat: customize Telegram notification icons
- feat: optimize frontend order list (active loading, refresh buttons)
- refactor: optimize OnChain WebSocket connection management
2026-01-04 20:36:41 +08:00
WrBug 2bb8cbc564 移除跟单信号中的轮询 activity 接口逻辑
- 移除 CopyTradingMonitorService 中的 pollingService 相关调用
- 删除不再使用的 CopyTradingPollingService 类
- 跟单信号现在完全依赖链上 WebSocket 监听,不再使用轮询接口
- 优化账户列表接口性能(仅返回基本信息,不查询统计数据)
- 改进 PositionCheckService 的错误处理(市场价格获取失败时跳过处理)
2026-01-04 15:50:23 +08:00
WrBug 8b73121c5d docs: 更新 RELEASE.md,添加 v1.1.5 版本说明
- 在 RELEASE.md 顶部添加 v1.1.5 release notes
- 删除临时文件:PR12_REVIEW.md、RELEASE_v1.1.4.md、RELEASE_v1.1.4_简版.md、RELEASE_v1.1.5.md
- 统一使用 RELEASE.md 管理所有版本发布说明
2026-01-04 13:07:08 +08:00
WrBug 95930332df refactor: 移除刷新代理钱包接口,增加copyRatio精度支持
- 移除刷新代理钱包相关接口和方法
  - AccountController: 移除 /refresh-proxy 和 /refresh-all-proxies 接口
  - AccountService: 移除 refreshProxyAddress 和 refreshAllProxyAddresses 方法

- 增加 copyRatio 字段精度支持
  - CopyTrading: copyRatio 精度从 DECIMAL(10,2) 增加到 DECIMAL(20,8)
  - CopyTradingTemplate: copyRatio 精度从 DECIMAL(10,2) 增加到 DECIMAL(20,8)
  - 新增数据库迁移脚本 V18__increase_copy_ratio_precision.sql

- 前端工具函数
  - 添加 formatNumber 函数用于格式化数字显示

修复的文件:
- AccountController.kt
- AccountService.kt
- CopyTrading.kt
- CopyTradingTemplate.kt
- frontend/src/utils/index.ts
- V18__increase_copy_ratio_precision.sql
2026-01-04 12:49:20 +08:00
WrBug 185cade11d fix: 修复按比例跟单金额精度问题,对金额进行向上取整处理
- 修复按比例计算的订单金额因精度问题导致低于最小限制的问题
- 对按比例计算的金额进行向上取整到2位小数(USDC精度)
- 如果向上取整后的金额仍低于最小限制,调整 buyQuantity 以满足最小限制
- 优化 BigDecimal.div 扩展函数,支持精度和舍入模式参数(带默认值)

修复的文件:
- MathExt.kt: 添加 div 扩展函数的重载版本,支持精度和舍入模式参数
- CopyOrderTrackingService.kt: 修复订单金额精度处理逻辑

问题:
订单金额 0.000999999860888892000000 因精度问题低于最小限制 1.00000000,
导致订单被错误跳过。

解决方案:
1. 对按比例计算的金额向上取整到2位小数
2. 如果向上取整后仍低于最小限制,调整数量以满足最小限制
3. 确保调整后的订单金额 >= minOrderSize
2026-01-04 12:46:35 +08:00
WrBug d9443e0d56 fix: 修复 InputNumber formatter 正则表达式错误,导致输入 10 变成 1 的问题
- 修复 copyRatio InputNumber 的 parser 函数,正确处理 % 符号和最小值检查
- 修复所有 formatter 函数中的正则表达式:将 /\.?0+$/ 改为 /\.0+$/
- 添加调试日志用于问题排查(可后续移除)

修复的文件:
- CopyTradingAdd.tsx
- CopyTradingEdit.tsx
- CopyTradingOrders/EditModal.tsx
- CopyTradingList.tsx
- TemplateList.tsx
- TemplateEdit.tsx
- TemplateAdd.tsx

问题原因:
错误的正则表达式 /\.?0+$/ 会匹配整数末尾的 0(如 "10" 的末尾 "0"),
导致 "10" 被错误替换为 "1"。
正确的正则表达式 /\.0+$/ 只匹配小数点后的尾随零。
2026-01-04 12:13:40 +08:00
WrBug a097376db7 fix: 移除 AccountList 中未使用的 navigate 导入,修复前端编译错误 2026-01-03 21:55:34 +08:00
WrBug 6e5ccdfe5c fix: 修复 walletType 字段问题和 WebSocket 内存泄漏,优化账户导入 UI
后端修复:
- 添加 walletType 字段到 Account 实体和数据库(V17 迁移)
- 修复 refreshProxyAddress 方法使用正确的 walletType
- 修复 WebSocketTicketService 内存泄漏(添加定时清理任务)

前端优化:
- 账户导入改为 Modal 弹窗,提升用户体验
- 默认钱包类型改为 Web3 Wallet(safe),并将其选项排在首位
- 更新多语言文本:将 MetaMask(浏览器钱包)改为 Web3钱包
- 修复 walletTypeHelp Tooltip 换行显示问题
2026-01-03 18:29:39 +08:00
WrBug 21da06d8b0 Merge pull request #12 from wry5560/feature/wallet-type-support
feat: 添加WebSocket票据认证和钱包类型支持
2026-01-03 17:57:27 +08:00
wry5560 5a83444b56 feat: 添加WebSocket票据认证和钱包类型支持
- 新增WebSocket票据服务,用于短期有效的WebSocket连接认证
- 支持Magic和Safe两种钱包类型,分别对应邮箱/OAuth登录和MetaMask用户
- 添加登录频率限制和安全防护
- 优化日志记录,屏蔽敏感信息
- 升级ethers.js到v6.16.0
- 新增多语言钱包类型说明
- 重构代理地址计算逻辑,支持CREATE2计算Magic代理地址
2026-01-03 12:27:59 +08:00
WrBug 591e678f73 Merge pull request #11 from WrBug/pre_release
Release v1.1.3: 添加 Telegram 通知功能
2026-01-02 05:13:45 +08:00
WrBug 1cfdcb88d3 feat: 优化 Telegram 通知功能
- 简化消息内容,仅保留标题、版本号等关键信息
- Docker 构建前发送构建中通知
- Docker 构建成功后添加部署文档链接
- PR 合并消息标题改为 'main 分支代码更新'
2026-01-02 05:12:24 +08:00
WrBug 2cc9cf82ba Merge pull request #10 from WrBug/pre_release
Release v1.1.3: 添加 Telegram 通知功能
2026-01-02 05:05:52 +08:00
WrBug 00f0898d98 fix: 修复 workflow YAML 语法错误,移除 heredoc 格式
- 将 Python 脚本的 heredoc 格式改为使用多个 echo 命令
- 避免 GitHub Actions YAML 解析器将 heredoc 误判为 YAML 语法
- 确保 workflow 文件符合 GitHub Actions 语法规范
2026-01-02 05:04:02 +08:00
WrBug 1de9b5e958 fix: PR 仅关闭时不发送 Telegram 通知
- 在发送通知步骤中添加 PR 合并状态检查
- 如果 PR 仅关闭(未合并),直接退出,不发送通知
- 确保只有合并到 main 分支的 PR 才会发送通知
2026-01-02 05:02:10 +08:00
WrBug c8e422a94b fix: 修复 workflow YAML 语法错误,移除 heredoc 格式
- 修复 docker-build.yml 和 telegram-notify.yml 中的 heredoc 格式问题
- 使用 $'\n' 格式构建多行字符串,避免 YAML 解析器误判
- 确保所有 workflow 文件符合 GitHub Actions YAML 语法规范
2026-01-02 04:58:14 +08:00
WrBug 60e9f9235d fix: 修复 workflow YAML 语法错误
- 修复 docker-build.yml 和 telegram-notify.yml 中的多行字符串格式问题
- 使用 heredoc 格式构建消息,避免 YAML 解析器误判
- 使用 $'\n' 格式追加内容,避免多行字符串导致 YAML 解析问题
2026-01-02 04:57:00 +08:00
WrBug 46baa416f4 fix: 修复 telegram-notify.yml YAML 语法错误
- 使用 heredoc 格式构建消息,避免 YAML 解析器误判
- 修复第 75 行的多行字符串格式问题
2026-01-02 04:52:50 +08:00
WrBug ecdb8af14a feat: 添加 Telegram 通知功能
- 添加 PR 合并到 main 分支时的 Telegram 通知
- 添加 Docker 镜像构建成功时的 Telegram 通知
- 支持 Markdown 转 HTML 格式
- 移除作者、仓库、变更统计、提交记录等冗余信息
2026-01-02 04:48:23 +08:00
WrBug d6027e48eb fix: 优化订单状态更新逻辑,避免误删订单和匹配明细
- 先检查 HTTP 状态码,非 200 的都跳过,不删除订单
- 只有当 HTTP 200 且响应体为 null 时,才表示订单不存在
- 保护已部分卖出的订单,如果已部分卖出则保留用于统计
- 避免因临时网络问题或 API 错误导致订单和 sell_match_detail 被误删
2026-01-02 04:13:10 +08:00
WrBug eb45013a93 Merge pull request #9 from WrBug/pre_release
fix: 修复前端编译错误(v1.1.2 后续修复)
2026-01-02 00:28:58 +08:00
WrBug f97bd5b9d9 docs: 更新 v1.1.2 发布说明,添加前端编译错误修复 2026-01-02 00:25:43 +08:00
WrBug df272156cf fix: 修复前端编译错误,移除不存在的 bestBid 属性引用 2026-01-02 00:24:31 +08:00
WrBug 502b4fb0b7 Merge pull request #8 from WrBug/pre_release
Release v1.1.2
2026-01-02 00:21:56 +08:00
WrBug 64f6a2b897 docs: 添加 v1.1.2 发布说明 2026-01-02 00:19:14 +08:00
WrBug f7f2411b9d 优化市场价格服务:移除降级查询逻辑,仅保留链上和订单簿查询
- 移除 CLOB Trades、Gamma Market Status、Gamma Market Price 查询逻辑
- 只保留链上 RPC 查询(市场结算结果)和 CLOB 订单簿查询
- 如果所有数据源都失败,抛出 IllegalStateException 异常
- 价格截位到 4 位小数(向下截断,不四舍五入)
- 移除未使用的导入和方法(MarketResponse, JsonUtils, OutcomeResult 等)
- 所有调用方都已正确处理异常,无需额外修改
2026-01-02 00:14:42 +08:00
WrBug d96eb3e00a fix: 恢复 V1 migration 文件,避免 checksum 不匹配
保持 V1__init_database.sql 的原有内容不变,避免破坏已有数据库的 migration checksum。
failed_trade 表的删除将通过 V16 migration 处理。
2026-01-01 22:33:23 +08:00
WrBug cc1a732984 refactor: 移除下单失败存储数据库的功能
- 删除 FailedTrade 实体类和 FailedTradeRepository
- 从 CopyOrderTrackingService 中移除失败交易存储逻辑
- 移除 recordFailedTrade 方法
- 移除检查失败交易的代码
- 创建 Flyway migration V16 删除 failed_trade 表
- 从 V1__init_database.sql 中移除 failed_trade 表创建语句

下单失败时仅记录日志,不再存储到数据库
2026-01-01 22:24:47 +08:00
WrBug ec06003157 feat: 自动使用当前分支名作为 Docker 版本号
- deploy.sh 自动获取当前 Git 分支名作为版本号
- 分支名中的 / 自动替换为 -(Docker tag 不支持 /)
- docker-compose.yml 启用 build args,从环境变量读取版本号
- 前端页面将显示当前分支名作为版本号
- 如果没有 Git 仓库或获取失败,使用默认值 'dev'
2026-01-01 22:10:21 +08:00
WrBug 3d05b13298 fix: 修复内存泄漏问题,缓存和复用 Retrofit/OkHttpClient 实例
问题:
- 每次调用 createClobApi、createGammaApi 等方法都创建新的 OkHttpClient 和 Retrofit 实例
- OkHttpClient 包含连接池、线程池等资源,导致内存不断增长
- 运行几小时后内存从 400MB 涨到 1GB+

解决方案:
- 为不需要认证的 API 创建共享的 OkHttpClient 实例
- Gamma API、Data API、GitHub API、不带认证的 CLOB API 使用单例客户端
- 带认证的 CLOB API 按钱包地址缓存(每个账户一个客户端)
- RPC API 按 RPC URL 缓存
- Builder Relayer API 按 relayerUrl 缓存
- 添加 @PreDestroy 方法清理缓存

效果:
- 大幅减少内存占用,避免内存泄漏
- 复用连接池和线程池,提高性能
- 内存占用将保持稳定,不再持续增长
2026-01-01 22:07:24 +08:00
WrBug fe2db11b75 其他代码修改
- CopyOrderTrackingRepository: 添加查询方法
- PolymarketClobService: 功能更新
- CopyOrderTrackingService: 逻辑优化
- OrderStatusUpdateService: 功能增强
- TelegramNotificationService: 通知优化
2026-01-01 12:03:05 +08:00
WrBug 5c18cbd95d 统一 Gson 使用,改为依赖注入方式
- 在 GsonConfig 中统一配置 Gson Bean(lenient 模式)
- 所有 Service 类通过构造函数注入 Gson 实例
- RetrofitFactory、BlockchainService、PolymarketApiKeyService 等统一使用注入的 Gson
- AccountService、PositionCheckService 添加 JsonUtils 注入
- OnChainWsUtils(object 单例)使用私有 Gson 实例(与 GsonConfig 配置一致)
- 移除所有 GsonConverterFactory.create() 无参调用,统一使用注入的 Gson
2026-01-01 12:01:17 +08:00
WrBug ad2fa4eef2 Merge pull request #7 from WrBug/pre_release
Release v1.1.1: 链上监听优化、市场查询优化和 Bug 修复
2025-12-28 05:13:18 +08:00
WrBug b5908a9bbf Merge pull request #6 from WrBug/pre_release
feat: 添加最大仓位限制配置功能 (v1.0.3)
2025-12-23 01:02:07 +08:00
116 changed files with 9668 additions and 4086 deletions
+171 -594
View File
@@ -5,391 +5,62 @@ path: backend/**
# 后端开发规范
## 代码完成规范
### TODO 处理规则
- **禁止**在代码中添加 TODO 注释
- **必须**根据 TODO 的内容直接完成代码实现
- 如果遇到暂时无法完全实现的功能,应该:
1. 实现一个可用的基础版本
2. 添加清晰的注释说明当前实现的限制和后续改进方向
3. 确保代码可以正常编译和运行
- **禁止**使用 `// TODO: 实现XXX` 这样的注释
- **禁止**使用 `// FIXME:` 或 `// XXX:` 这样的注释
- 如果某个功能需要依赖外部资源(如 API、库等),应该:
1. 先实现一个占位或模拟实现
2. 在注释中说明依赖关系和实现方式
3. 确保代码逻辑完整,不会因为未实现的功能而崩溃
### API 调用实现规则
- **必须**查找相关的 API 文档或接口定义
- **必须**根据 API 文档完成代码实现
- **禁止**在 API 调用处添加 TODO 注释
- **禁止**直接返回 mock 数据或硬编码的假数据
- **必须**调用真实的 API 或查询真实的数据库
- 如果 API 文档不完整,应该:
1. 查找项目中已有的类似 API 调用作为参考
2. 查看 API 接口定义(如 `PolymarketClobApi.kt`
3. 查看 API 文档(如 `docs/polymarket-api-reference.md`
4. 实现一个可用的版本,包含错误处理
- 如果 API 调用失败,应该:
1. 返回明确的错误信息
2. 记录错误日志
3. 返回错误响应,而不是返回 mock 数据
### 代码完成示例
```kotlin
// ❌ 错误:添加 TODO 注释
fun getAccountBalance(accountId: Long?): Result<AccountBalanceResponse> {
// TODO: 调用 Polymarket API 查询余额
return Result.success(AccountBalanceResponse(usdcBalance = "0"))
}
// ✅ 正确:查找 API 文档并完成实现
// 1. 查找 API 接口定义:PolymarketClobApi.getActiveOrders()
// 2. 查找 API 文档:docs/polymarket-api-reference.md
// 3. 实现完整的 API 调用逻辑,调用真实的 API
fun getAccountBalance(accountId: Long?): Result<AccountBalanceResponse> {
return try {
val account = getAccount(accountId) ?: return Result.failure(IllegalArgumentException("账户不存在"))
// 如果账户没有配置 API 凭证,返回错误而不是 mock 数据
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
return Result.failure(IllegalStateException("账户未配置 API 凭证,无法查询余额"))
}
// 解密 API 凭证并创建认证客户端
val apiKey = cryptoUtils.decrypt(account.apiKey!!)
val apiSecret = cryptoUtils.decrypt(account.apiSecret!!)
val apiPassphrase = cryptoUtils.decrypt(account.apiPassphrase!!)
val clobApi = retrofitFactory.createClobApi(apiKey, apiSecret, apiPassphrase)
// 调用真实的 API 查询余额
val response = clobApi.getActiveOrders(limit = 100)
if (response.isSuccessful && response.body() != null) {
// 根据 API 响应处理余额数据(从真实响应中解析)
val orders = response.body()!!
// 实际应该调用余额查询接口或从链上查询
// 这里只是示例,实际应该调用真实的余额查询 API
val balance = queryRealBalanceFromApi(clobApi, account.walletAddress)
Result.success(AccountBalanceResponse(usdcBalance = balance))
} else {
logger.error("查询余额失败: ${response.code()} ${response.message()}")
Result.failure(Exception("查询余额失败: ${response.code()} ${response.message()}"))
}
} catch (e: Exception) {
logger.error("查询账户余额失败", e)
Result.failure(e)
}
}
```
## 需求文档引用
### 跟单系统需求
- **需求文档**: `docs/copy-trading-requirements.md`
- **说明**: 所有跟单系统相关的功能实现必须严格按照需求文档执行
- **核心功能**:
- 账户管理(通过私钥导入,支持多账户)
- Leader 管理(被跟单者管理)
- 订单同步与执行(监控 Leader 交易并自动复制)
- 跟单配置管理(全局配置和单个 Leader 配置)
- 风险控制(每日亏损限制、订单数限制等)
- 跟单记录与统计
**重要提示**: 在实现跟单系统相关功能时,请先查阅 `docs/copy-trading-requirements.md` 了解详细需求,包括:
- 数据模型设计(Account、Leader、CopyOrder 等)
- API 接口设计(请求/响应格式)
- 业务规则和验证逻辑
- 安全要求(私钥加密存储、API Key 管理等)
## 核心原则
- **禁止**在代码中添加 TODO/FIXME/XXX 注释
- **禁止**返回 mock 数据或硬编码的假数据
- **禁止**在 API 调用失败时返回默认值作为 fallback
- 所有功能必须完整实现,确保代码可以正常运行
## 项目范围
### 平台支持
- **仅支持**: Polymarket 平台
- **不支持**: 其他预测市场平台(如 Kalshi 等)
### 分类支持
- **仅支持**:
- `sports`: 体育相关市场
- `crypto`: 加密货币相关市场
- **不支持**: 其他分类(如 politics、entertainment 等)
### 分类验证
- 所有涉及分类的接口、实体、服务必须验证分类参数
- 分类参数只能是 `sports` 或 `crypto`
- 无效分类应返回明确的错误提示
## 包名规范
- **平台**: 仅支持 Polymarket
- **包名**: `com.wrbug.polymarketbot`
- 所有代码必须使用此包名
## 实体类规范
### ID字段规范
- **必须**使用 `Long? = null` 作为 `@GeneratedValue` 的 id 字段
- **禁止**使用 `Long = 0` 或其他默认值
```kotlin
// ✅ 正确
@Entity
@Table(name = "example_table")
data class ExampleEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
// ...
)
// ❌ 错误
@Entity
data class ExampleEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0, // 禁止使用
// ...
val id: Long? = null, // ✅ 正确:使用可空类型
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(), // ✅ 使用 Long 时间戳
@Column(name = "amount", nullable = false, precision = 20, scale = 8)
val amount: BigDecimal = BigDecimal.ZERO // ✅ 使用 BigDecimal
)
```
**规则**:
- ID 字段必须使用 `Long? = null`
- 时间字段使用 `Long` 时间戳(毫秒)
- 数值字段使用 `BigDecimal`
- **禁止**使用 `LocalDateTime` 或 `Double`
## 配置文件规范
### 配置文件格式
- **必须**使用 `application.properties` 格式
- **禁止**使用 `application.yml` 格式
- 配置文件位置: `src/main/resources/application.properties`
### 配置示例
```properties
# 应用配置
spring.application.name=polymarket-bot-backend
# 数据源配置
spring.datasource.url=jdbc:mysql://localhost:3306/polymarket_bot?useSSL=false&serverTimezone=UTC&characterEncoding=utf8mb4
spring.datasource.username=${DB_USERNAME:root}
spring.datasource.password=${DB_PASSWORD:password}
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
# HikariCP 连接池配置
spring.datasource.hikari.maximum-pool-size=10
spring.datasource.hikari.minimum-idle=2
spring.datasource.hikari.connection-timeout=30000
# JPA 配置
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.show-sql=false
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect
# Flyway 配置
spring.flyway.enabled=true
spring.flyway.locations=classpath:db/migration
spring.flyway.baseline-on-migrate=true
# 服务器配置
server.port=${SERVER_PORT:8000}
# 日志配置
logging.level.root=INFO
logging.level.com.wrbug.polymarketbot=DEBUG
logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} - %msg%n
```
### 环境变量引用
- 使用 `${ENV_VAR:default}` 格式引用环境变量
- 支持多环境配置: `application-dev.properties`, `application-prod.properties`
- **必须**使用 `application.properties` 格式(禁止 `application.yml`
- 使用 `${ENV_VAR:default}` 引用环境变量
## 代码规范
### Controller规范
- Controller 方法**禁止**使用 `suspend`
- 如需调用 suspend 方法,使用 `runBlocking`(最小化使用)
- 只作用于 suspend 方法调用
### Controller
- **禁止**使用 `suspend`
- 使用 `runBlocking` 调用 suspend 方法
- 统一使用 `@PostMapping`
```kotlin
// ✅ 正确
@RestController
class ExampleController(
private val exampleService: ExampleService
) {
@PostMapping("/example")
fun getExample(): ResponseEntity<ApiResponse<ExampleDto>> {
val data = runBlocking { exampleService.getData() }
return ResponseEntity.ok(ApiResponse.success(data))
}
}
// ❌ 错误
@RestController
class ExampleController {
@PostMapping("/example")
suspend fun getExample(): ResponseEntity<ApiResponse<ExampleDto>> { // 禁止使用suspend
// ...
}
}
```
### Service规范
- Service 层可以使用 `suspend` 方法
### Service
- 可以使用 `suspend` 方法
- 使用 `@Transactional` 管理事务
- 使用构造函数注入依赖
```kotlin
@Service
class ExampleService(
private val exampleRepository: ExampleRepository
) {
suspend fun getAllData(): List<ExampleEntity> {
return exampleRepository.findAll()
}
@Transactional
fun saveData(entity: ExampleEntity): ExampleEntity {
return exampleRepository.save(entity)
}
}
```
### Repository规范
- Repository 接口继承 `JpaRepository`
### Repository
- 继承 `JpaRepository<Entity, Long>`
- 使用 Spring Data JPA 方法命名规范
```kotlin
@Repository
interface ExampleRepository : JpaRepository<ExampleEntity, Long> {
fun findByCode(code: String): ExampleEntity?
fun findByCategoryAndStatus(category: String, status: String): List<ExampleEntity>
fun findByCategory(category: String): List<ExampleEntity> // category: sports 或 crypto
}
```
## API 接口规范
### Entity规范
- 使用 `@Entity` 和 `@Table` 注解
- ID字段使用 `Long? = null`
- 时间字段使用 `Long` 时间戳(毫秒)
- 数值字段使用 `BigDecimal`,使用 `String` 存储
```kotlin
@Entity
@Table(name = "example_table")
data class ExampleEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "code", unique = true, nullable = false, length = 100)
val code: String = "",
@Column(name = "category", nullable = false, length = 20)
val category: String = "", // sports 或 crypto
@Column(name = "amount", nullable = false, precision = 20, scale = 8)
val amount: BigDecimal = BigDecimal.ZERO,
@Column(name = "status", nullable = false, length = 20)
val status: String = "", // active, inactive
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@Column(name = "updated_at", nullable = false)
var updatedAt: Long = System.currentTimeMillis()
)
```
## 数值计算规范
### BigDecimal使用
- 所有数值计算必须使用 `BigDecimal`
- 使用 `String` 存储数值
- 使用扩展函数进行安全转换和比较
```kotlin
// 使用扩展函数
val amount = "0.5".toSafeBigDecimal()
val total = amount.add("0.4".toSafeBigDecimal())
// 比较
if (total.lt(BigDecimal.ONE)) {
// 业务逻辑
}
```
## 时间字段规范
### 时间戳使用
- 所有时间字段使用 `Long` 类型存储毫秒级时间戳
- **禁止**使用 `LocalDateTime` 或其他时间类型
```kotlin
// ✅ 正确
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis()
// ❌ 错误
@Column(name = "created_at")
val createdAt: LocalDateTime = LocalDateTime.now() // 禁止使用
```
## HTTP客户端规范
### Retrofit使用
- 使用 Retrofit 定义 API 接口
- 使用 OkHttp 作为底层 HTTP 客户端
- 使用拦截器处理认证
```kotlin
// Polymarket CLOB API接口定义(跟单系统需要)
interface PolymarketClobApi {
@POST("/orders")
suspend fun createOrder(@Body order: CreateOrderRequest): Response<OrderResponse>
@GET("/orders/active")
suspend fun getActiveOrders(
@Query("market") market: String?,
@Query("limit") limit: Int?,
@Query("offset") offset: Int?
): Response<List<OrderResponse>>
@DELETE("/orders/{orderId}")
suspend fun cancelOrder(@Path("orderId") orderId: String): Response<CancelOrderResponse>
@GET("/trades")
suspend fun getTrades(
@Query("market") market: String?,
@Query("user") user: String?,
@Query("limit") limit: Int?,
@Query("offset") offset: Int?
): Response<List<TradeResponse>>
}
// Retrofit配置
@Configuration
class RetrofitConfig {
@Bean
fun polymarketClobApi(): PolymarketClobApi {
val okHttpClient = OkHttpClient.Builder()
.addInterceptor(AuthInterceptor())
.build()
return Retrofit.Builder()
.baseUrl("https://clob.polymarket.com")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(PolymarketClobApi::class.java)
}
}
```
## API接口规范
### 请求规范
- **所有接口统一使用POST方法**,包括查询类接口
- 请求头: `Content-Type: application/json`
- 请求体: JSON格式
### 响应规范
- **统一响应格式**:
### 统一响应格式
```json
{
"code": 0,
@@ -398,77 +69,7 @@ class RetrofitConfig {
}
```
- **响应字段说明**:
- `code`: 响应码,0表示成功,非0表示失败
- `data`: 响应数据,可以是任意类型(对象、数组、字符串、数字等)
- `msg`: 响应消息,成功时通常为空,失败时包含错误提示
### 响应示例
**成功响应**:
```json
{
"code": 0,
"data": {
"id": "123",
"name": "example"
},
"msg": ""
}
```
**失败响应**:
```json
{
"code": 1001,
"data": null,
"msg": "参数错误:参数不能为空"
}
```
### Controller实现示例
```kotlin
@RestController
@RequestMapping("/api/example")
class ExampleController(
private val exampleService: ExampleService
) {
private val logger = LoggerFactory.getLogger(ExampleController::class.java)
@PostMapping("/list")
fun getList(@RequestBody request: ExampleListRequest): ResponseEntity<ApiResponse<ExampleListResponse>> {
return try {
val data = runBlocking { exampleService.getList(request) }
val response = ExampleListResponse(
list = data,
total = data.size.toLong(),
page = request.page ?: 1,
limit = request.limit ?: 20
)
ResponseEntity.ok(ApiResponse.success(response))
} catch (e: Exception) {
logger.error("Failed to get list", e)
ResponseEntity.ok(ApiResponse.serverError("获取列表失败:${e.message}"))
}
}
}
// 统一响应格式
data class ApiResponse<T>(
val code: Int,
val data: T?,
val msg: String
) {
companion object {
fun <T> success(data: T): ApiResponse<T> = ApiResponse(0, data, "")
fun <T> paramError(msg: String): ApiResponse<T> = ApiResponse(1001, null, msg)
fun <T> serverError(msg: String): ApiResponse<T> = ApiResponse(5001, null, msg)
}
}
```
### 错误码规范
### 错误码范围
- `0`: 成功
- `1001-1999`: 参数错误
- `2001-2999`: 认证/权限错误
@@ -476,7 +77,131 @@ data class ApiResponse<T>(
- `4001-4999`: 业务逻辑错误
- `5001-5999`: 服务器内部错误
详细错误码定义参见需求文档
### Controller 示例
```kotlin
@RestController
@RequestMapping("/api/example")
class ExampleController(
private val exampleService: ExampleService,
private val messageSource: MessageSource
) {
@PostMapping("/list")
fun getList(@RequestBody request: ExampleListRequest): ResponseEntity<ApiResponse<ExampleListResponse>> {
return try {
val data = runBlocking { exampleService.getList(request) }
ResponseEntity.ok(ApiResponse.success(data))
} catch (e: Exception) {
logger.error("Failed to get list", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource))
}
}
}
```
## 数值计算
```kotlin
val amount = "0.5".toSafeBigDecimal()
val total = amount.add("0.4".toSafeBigDecimal())
if (total.lt(BigDecimal.ONE)) {
// 业务逻辑
}
```
## JSON 解析规范
### 使用扩展函数(推荐)
- **优先**使用扩展函数 `fromJson<T>()` 和 `toJson()`,禁止直接使用 `Gson`
- 扩展函数提供了类型安全的解析,使用方便
```kotlin
// ✅ 正确:使用扩展函数(推荐)
val json = "{\"name\":\"test\"}"
val obj = json.fromJson<MyDataClass>()
val jsonStr = obj.toJson()
// ✅ 也可以:使用 JsonUtils 类(兼容旧代码)
val obj = jsonUtils.fromJson<MyDataClass>(json)
// ❌ 错误:直接使用 Gson
val obj = gson.fromJson(json, MyDataClass::class.java)
```
### 扩展函数列表
- `String?.fromJson<T>()` - 解析 JSON 字符串为对象(支持泛型)
- `JsonElement?.fromJson<T>()` - 解析 JsonElement 为对象(支持泛型)
- `Any?.toJson()` - 将对象转换为 JSON 字符串
- `String?.parseStringArray()` - 解析 JSON 字符串数组
### JsonUtils 类
- `JsonUtils` 主要用于初始化全局 Gson 实例,供扩展函数使用
- 保留 `parseStringArray()` 方法用于兼容旧代码
- 不推荐直接使用 `JsonUtils` 的方法,优先使用扩展函数
### Data Class 规范
- **所有 data class 字段必须提供默认值**
- 可空字段使用 `? = null`
- 非空字段提供合适的默认值(空字符串、空集合、默认数值等)
```kotlin
// ✅ 正确:所有字段都有默认值
data class MyDto(
val name: String = "",
val age: Int = 0,
val tags: List<String> = emptyList(),
val optional: String? = null
)
// ❌ 错误:缺少默认值
data class MyDto(
val name: String, // 缺少默认值
val age: Int = 0
)
```
## Side 判断规范
**禁止**使用 "YES"/"NO" 字符串判断 side
```kotlin
// ❌ 错误
if (side != null && side.uppercase() == "NO") { }
// ✅ 正确
if (outcomeIndex != null && outcomeIndex == 1) { }
```
## 多语言规范
- **禁止**硬编码中文或英文错误消息
- **必须**使用 `ErrorCode` 枚举和 `MessageSource`
```kotlin
// ❌ 错误
return ResponseEntity.ok(ApiResponse.paramError("配置ID不能为空"))
// ✅ 正确
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, messageSource))
```
### 多语言支持范围
1. **API 响应消息**: 使用 `ErrorCode` + `MessageSource`
2. **日志消息**: 可使用中文或英文
3. **代码注释**: 建议使用中文
4. **数据库字段**: 使用英文(snake_case
## HTTP 客户端
- 使用 Retrofit + OkHttp
- 使用拦截器处理认证
## 跟单系统需求
参考文档: `docs/copy-trading-requirements.md`
核心功能:
- 账户管理(私钥导入,多账户)
- Leader 管理
- 订单同步与执行
- 跟单配置管理
- 风险控制
- 跟单记录与统计
## 禁止事项
@@ -484,173 +209,25 @@ data class ApiResponse<T>(
- ❌ 禁止使用 `!!` 除非有明确原因
- ❌ 禁止忽略异常
- ❌ 禁止硬编码配置值
- ❌ 禁止提交敏感信息到Git
- ❌ Controller 方法禁止使用 `suspend`
- ❌ 实体类ID禁止使用 `Long = 0`
- ❌ **禁止直接返回 mock 数据或硬编码的假数据**
- ❌ **禁止在 API 调用失败时返回 mock 数据作为默认值**
- ❌ **所有返回的数据必须来自真实的 API 调用或数据库查询**
### 配置
- ❌ 禁止使用 `application.yml`
- ❌ 禁止在代码中硬编码配置值
- ❌ 禁止提交敏感信息到 Git
### 类型
- ❌ 禁止使用 `Double` 进行数值计算
- ❌ 禁止使用 `LocalDateTime` 存储时间
- ❌ 禁止实体类ID使用非空默认值
- ❌ 禁止实体类 ID 使用非空默认值
- ❌ 禁止直接使用 `Gson`(必须使用 `JsonUtils`
- ❌ 禁止 data class 字段缺少默认值
### API接口
- ❌ 禁止使用GETPUTDELETE等方法(统一使用POST
### API 接口
- ❌ 禁止使用 GET/PUT/DELETE(统一使用 POST
- ❌ 禁止返回不符合统一格式的响应
- ❌ 禁止在响应中直接返回Map类型(使用data class
- ❌ 禁止在响应中直接返回 Map 类型
### Side 判断规范
- ❌ **禁止使用 "YES""NO" 字符串判断 side**
- ✅ **必须使用 `outcomeIndex` 判断方向**0 = 第一个 outcome1 = 第二个 outcome,以此类推)
- ✅ 如果必须使用 side 字符串,应该从市场的 outcomes 数组中获取,而不是硬编码 "YES"/"NO"
- ✅ 对于二元市场的价格转换,应该通过 `outcomeIndex` 判断是否为第二个 outcomeindex = 1),而不是判断 side 是否为 "NO"
### Side 判断
- ❌ 禁止使用 "YES"/"NO" 字符串判断 side
- ✅ 必须使用 `outcomeIndex` 判断
```kotlin
//错误:使用字符串比较判断 side
if (side != null && side.uppercase() == "NO") {
// 转换价格
}
// ❌ 错误:硬编码 "YES"/"NO" 判断
when (side.uppercase()) {
"YES" -> // ...
"NO" -> // ...
}
// ✅ 正确:使用 outcomeIndex 判断
if (outcomeIndex != null && outcomeIndex == 1) {
// 第二个 outcome(在二元市场中通常是 NO),转换价格
}
// ✅ 正确:从市场 outcomes 获取 side 信息
val outcomes = JsonUtils.parseStringArray(market.outcomes)
val targetOutcomeIndex = outcomes.indexOfFirst { it.equals(side, ignoreCase = true) }
if (targetOutcomeIndex >= 0) {
// 使用 targetOutcomeIndex 进行判断
}
```
## 多语言使用规范
### 错误消息和响应文本
- **禁止**在代码中硬编码中文或英文错误消息
- **必须**使用 `ErrorCode` 枚举定义错误码和消息
- **必须**使用 `ApiResponse.error(ErrorCode, messageSource)` 或 `MessageUtils.getMessage()` 获取国际化消息
- **禁止**直接使用 `ApiResponse.paramError("硬编码消息")` 或 `ApiResponse.serverError("硬编码消息")`
- 错误消息的默认语言使用中文(在 `ErrorCode` 枚举中定义),通过 `MessageSource` 支持多语言
### 使用 ErrorCode 和 MessageSource
项目已经实现了国际化支持,必须使用以下方式:
```kotlin
// ❌ 错误:硬编码错误消息
return ResponseEntity.ok(ApiResponse.paramError("配置ID不能为空"))
return ResponseEntity.ok(ApiResponse.serverError("获取配置列表失败:${e.message}"))
// ✅ 正确:使用 ErrorCode 枚举
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, messageSource = messageSource))
// ✅ 正确:使用 ErrorCode 和自定义消息(如果需要动态消息)
return ResponseEntity.ok(ApiResponse.error(
ErrorCode.PARAM_ERROR,
customMsg = "配置ID不能为空",
messageSource = messageSource
))
// ✅ 正确:使用 MessageUtils
@Autowired
private lateinit var messageUtils: MessageUtils
return ResponseEntity.ok(ApiResponse.error(
ErrorCode.PARAM_EMPTY,
messageSource = messageSource
))
```
### 添加新的错误码
如果需要添加新的错误码,必须在 `ErrorCode` 枚举中定义:
```kotlin
enum class ErrorCode(
val code: Int,
val message: String, // 默认消息(中文)
val messageKey: String // 国际化消息键
) {
// 新错误码示例
NOTIFICATION_CONFIG_NOT_FOUND(3009, "通知配置不存在", "error.notification_config_not_found"),
NOTIFICATION_CONFIG_INVALID(4009, "通知配置无效", "error.notification_config_invalid"),
}
```
然后在语言资源文件中添加对应的翻译:
- `src/main/resources/messages_zh_CN.properties`
- `src/main/resources/messages_zh_TW.properties`
- `src/main/resources/messages_en.properties`
### 日志消息规范
- **日志消息可以使用中文或英文**,便于开发调试
- **禁止**在日志中硬编码用户可见的错误消息(应该使用 ErrorCode)
- 日志消息应该清晰、简洁,便于排查问题
- 日志中的业务数据(如账户名、订单ID等)可以使用原始值
### 代码注释规范
- **代码注释可以使用中文或英文**
- **业务逻辑注释**建议使用中文,便于团队理解
- **API 文档注释**(KDoc)建议使用中文,但也可以使用英文
- **类和方法注释**应该清晰说明功能和参数
### 数据库字段和配置
- **数据库字段名**使用英文(snake_case
- **配置项名称**使用英文(kebab-case 或 dot.notation
- **枚举值**使用英文(UPPER_SNAKE_CASE
- **常量定义**使用英文(UPPER_SNAKE_CASE
### 多语言支持策略
1. **API 响应消息**:使用 `ErrorCode` 枚举,通过 `MessageSource` 支持多语言
2. **错误码**:使用 `ErrorCode` 枚举,包含 `code`、`message`(默认中文)和 `messageKey`(国际化键)
3. **日志消息**:可以使用中文或英文,便于开发调试
4. **代码注释**:可以使用中文或英文,建议使用中文
5. **业务数据**:根据实际需求,可以包含多语言内容(如市场标题、描述等)
### Controller 中使用多语言示例
```kotlin
@RestController
class NotificationController(
private val notificationConfigService: NotificationConfigService,
private val messageSource: MessageSource // 注入 MessageSource
) {
@PostMapping("/configs/list")
fun list(@RequestBody request: NotificationConfigListRequest): ResponseEntity<ApiResponse<List<NotificationConfigDto>>> {
return try {
// ... 业务逻辑
ResponseEntity.ok(ApiResponse.success(configs))
} catch (e: Exception) {
logger.error("获取配置列表失败: ${e.message}", e)
// ✅ 正确:使用 ErrorCode 和 MessageSource
ResponseEntity.ok(ApiResponse.error(
ErrorCode.SERVER_ERROR,
messageSource = messageSource
))
}
}
@PostMapping("/configs/detail")
fun detail(@RequestBody request: NotificationConfigDetailRequest): ResponseEntity<ApiResponse<NotificationConfigDto>> {
if (request.id == null) {
// ✅ 正确:使用 ErrorCode
return ResponseEntity.ok(ApiResponse.error(
ErrorCode.PARAM_EMPTY,
messageSource = messageSource
))
}
// ... 业务逻辑
}
}
```
### 数据源
-禁止直接返回 mock 数据
- ❌ 禁止在 API 调用失败时返回 mock 数据
- ✅ 所有数据必须来自真实 API 或数据库查询
+68 -232
View File
@@ -5,107 +5,41 @@ path: frontend/**
# 前端开发规范
## 代码完成规范
### TODO 处理规则
- **禁止**在代码中添加 TODO 注释
- **必须**根据 TODO 的内容直接完成代码实现
- 如果遇到暂时无法完全实现的功能,应该:
1. 实现一个可用的基础版本
2. 添加清晰的注释说明当前实现的限制和后续改进方向
3. 确保代码可以正常编译和运行
- **禁止**使用 `// TODO: 实现XXX` 这样的注释
- **禁止**使用 `// FIXME:` 或 `// XXX:` 这样的注释
- 如果某个功能需要依赖外部资源(如 API、库等),应该:
1. 先实现一个占位或模拟实现
2. 在注释中说明依赖关系和实现方式
3. 确保代码逻辑完整,不会因为未实现的功能而崩溃
### API 调用实现规则
- **必须**查找相关的 API 文档或接口定义
- **必须**根据 API 文档完成代码实现
- **禁止**在 API 调用处添加 TODO 注释
- 如果 API 文档不完整,应该:
1. 查找项目中已有的类似 API 调用作为参考
2. 查看 API 服务定义(如 `services/api.ts`
3. 查看 API 文档(如 `docs/copy-trading-requirements.md`
4. 实现一个可用的版本,包含错误处理
### 代码完成示例
```typescript
// ❌ 错误:添加 TODO 注释
const fetchAccountBalance = async (accountId: number) => {
// TODO: 调用 API 查询余额
return { balance: '0' }
}
// ✅ 正确:查找 API 定义并完成实现
// 1. 查找 API 服务定义:services/api.ts 中的 accounts.balance
// 2. 查找 API 文档:docs/copy-trading-requirements.md
// 3. 实现完整的 API 调用逻辑
const fetchAccountBalance = async (accountId: number) => {
try {
// 根据 API 服务定义调用接口
const response = await apiService.accounts.balance({ accountId })
// 根据 API 响应格式处理数据
if (response.data.code === 0 && response.data.data) {
return response.data.data
} else {
// API 调用失败时返回默认值
console.warn('查询余额失败,返回默认值:', response.data.msg)
return { balance: '0' }
}
} catch (error) {
console.error('查询余额异常:', error)
// 异常时返回默认值,确保代码可以正常运行
return { balance: '0' }
}
}
```
## 核心原则
- **禁止**在代码中添加 TODO/FIXME/XXX 注释
- **禁止**使用 `any` 类型
- **禁止**硬编码中文或英文文本
- **必须**支持移动端和桌面端
## 技术栈
- **框架**: React + TypeScript
- **UI库**: Ant Design 或 Material-UI(推荐 Ant Design Mobile 用于移动端
- **HTTP客户端**: axios
- **状态管理**: Zustand 或 Redux
- **响应式设计**: 必须支持移动端和桌面端
- **UI 库**: Ant Design(移动端使用 Ant Design Mobile
- **HTTP 客户端**: axios
- **状态管理**: Zustand
- **国际化**: react-i18next
## 移动端适配要求
## 移动端适配
### 响应式设计
- **必须支持移动端和桌面端**
- 使用响应式布局(Responsive Design
- 移动端优先(Mobile First)设计原则
- 支持触摸操作和手势
### 断点设置
### 响应式断点
- **移动端**: < 768px
- **平板**: 768px - 1024px
- **桌面端**: > 1024px
### UI 组件适配
- 使用 Ant Design 的响应式组件
- 移动端使用 Ant Design Mobile(如果使用 Ant Design
- 表格使用虚拟滚动或分页(移动端性能优化)
- 表单使用移动端友好的输入组件
### 布局适配
- 导航栏:移动端使用抽屉菜单,桌面端使用顶部导航
- 列表:移动端使用卡片布局,桌面端使用表格布局
- 按钮:移动端按钮尺寸不小于 44x44px(触摸友好)
- 间距:移动端使用更大的间距,提高可点击区域
### 适配要求
- 使用响应式布局(Mobile First
- 导航栏:移动端抽屉菜单,桌面端顶部导航
- 列表:移动端卡片布局,桌面端表格布局
- 按钮:移动端不小于 44x44px
- 支持触摸操作和手势
### 性能优化
- 图片懒加载
- 代码分割(Code Splitting
- 移动端减少动画效果
- 使用 CSS 媒体查询优化样式
## 代码规范
### 组件规范
### 组件
- 使用函数式组件
- 使用 TypeScript 类型定义
- 组件文件使用 PascalCase 命名
@@ -122,24 +56,18 @@ export const MarketCard: React.FC<MarketProps> = ({ marketId, platform }) => {
};
// ❌ 错误
export const marketCard = (props: any) => { // 禁止使用any
// ...
};
export const marketCard = (props: any) => { }
```
### API调用规范
- 使用 axios 进行 HTTP 请求
### API 调用
- 使用 axios
- 统一错误处理
- 使用 TypeScript 定义响应类型
```typescript
// API服务
import axios from 'axios';
interface Market {
id: string;
marketId: string;
platform: string;
title: string;
}
@@ -147,24 +75,16 @@ export const marketService = {
getMarkets: async (): Promise<Market[]> => {
const response = await axios.get<Market[]>('/api/markets');
return response.data;
},
getMarketById: async (id: string): Promise<Market> => {
const response = await axios.get<Market>(`/api/markets/${id}`);
return response.data;
}
};
```
### 状态管理规范
- 使用 Zustand 或 Redux 管理全局状态
### 状态管理
- 全局状态使用 Zustand
- 本地状态使用 `useState`
- 复杂状态使用 `useReducer`
```typescript
// Zustand Store示例
import { create } from 'zustand';
interface MarketStore {
markets: Market[];
setMarkets: (markets: Market[]) => void;
@@ -176,80 +96,64 @@ export const useMarketStore = create<MarketStore>((set) => ({
}));
```
### USDC 金额格式化规范
- **必须**使用 `formatUSDC` 函数格式化所有 USDC 金额显示
- **禁止**直接使用 `toFixed()` 或 `parseFloat().toFixed()` 格式化 USDC
- **禁止**硬编码小数位数格式化 USDC
- 所有 USDC 金额显示必须统一使用 `formatUSDC` 函数
#### formatUSDC 函数说明
- **位置**: `src/utils/index.ts`
- **功能**: 格式化 USDC 金额,最多显示 4 位小数,自动去除尾随零(截断,不四舍五入)
- **参数**: `value: string | number | undefined | null`
- **返回值**: 格式化后的字符串,如果值为空或无效则返回 `'-'`
#### 使用示例
### USDC 金额格式化
**必须**使用 `formatUSDC` 函数(从 `../utils` 导入)
```typescript
// ✅ 正确:使用 formatUSDC 格式化 USDC 金额
import { formatUSDC } from '../utils'
const BalanceDisplay: React.FC<{ balance: string }> = ({ balance }) => {
return <span>{formatUSDC(balance)} USDC</span>
}
// ✅ 正确:在表格列中使用
const columns = [
{
title: '余额',
dataIndex: 'balance',
render: (balance: string) => `${formatUSDC(balance)} USDC`
}
]
// ✅ 正确:在统计组件中使用
// ✅ 正确
<Statistic
title="总盈亏"
value={formatUSDC(stats?.totalPnl || '0')}
suffix="USDC"
/>
// ❌ 错误:直接使用 toFixed
const balance = parseFloat(value).toFixed(4) // 禁止
// ❌ 错误:硬编码格式化
const balance = `${parseFloat(value).toFixed(2)} USDC` // 禁止
// ❌ 错误:使用自定义格式化函数
const formatBalance = (value: string) => parseFloat(value).toFixed(4) // 禁止
// ❌ 错误
const balance = parseFloat(value).toFixed(4)
```
#### 格式化规则
- **最多显示 4 位小数**:如果金额超过 4 位小数,截断到 4 位(不四舍五入)
- **自动去除尾随零**:去除不必要的尾随零和小数点
- **示例**
- `formatUSDC(1.23)` => `"1.23"`
- `formatUSDC(1.23456)` => `"1.2345"`(截断,不四舍五入)
- `formatUSDC(1.2)` => `"1.2"`
- `formatUSDC(1)` => `"1"`
- `formatUSDC(1.23459)` => `"1.2345"`(截断,不四舍五入)
- `formatUSDC(null)` => `"-"`
- `formatUSDC(undefined)` => `"-"`
**格式化规则**:
- 最多显示 4 位小数(截断,不四舍五入)
- 自动去除尾随零
- 空值返回 `'-'`
#### 工具函数统一导出
- 所有工具函数统一从 `src/utils/index.ts` 导出
- **必须**从 `../utils` 导入工具函数,而不是从具体文件导入
- `ethers.ts` 中的函数也会在 `index.ts` 中统一导出
## 多语言规范
### 使用方式
**必须**使用 `useTranslation` Hook
```typescript
// ✅ 正确:从 utils 统一导入
import { formatUSDC, getAddressFromPrivateKey, isValidWalletAddress } from '../utils'
// ✅ 正确
import { useTranslation } from 'react-i18next'
// ❌ 错误:从具体文件导入
import { formatUSDC } from '../utils/index'
import { getAddressFromPrivateKey } from '../utils/ethers'
const MyComponent: React.FC = () => {
const { t } = useTranslation()
return (
<div>
<h1>{t('page.title')}</h1>
<Button>{t('common.save')}</Button>
</div>
)
}
// ❌ 错误:硬编码文本
<h1>页面标题</h1>
<Button>保存</Button>
```
### 语言文件
- 位置: `src/locales/{locale}/common.json`
- 支持: `zh-CN`、`zh-TW`、`en`
- 键名: 点号分隔的层级结构(如 `notificationSettings.title`
### 添加新翻译
1. 在 `src/locales/zh-CN/common.json` 添加中文
2. 在 `src/locales/zh-TW/common.json` 添加繁体
3. 在 `src/locales/en/common.json` 添加英文
4. 在组件中使用 `t('key')`
## 移动端适配示例
### 响应式布局
@@ -267,29 +171,6 @@ const MyComponent: React.FC = () => {
};
```
### 移动端导航
```typescript
import { Drawer } from 'antd';
const MobileNav: React.FC = () => {
const [open, setOpen] = useState(false);
return (
<>
<Button onClick={() => setOpen(true)}>菜单</Button>
<Drawer
title="导航"
placement="left"
onClose={() => setOpen(false)}
open={open}
>
{/* 导航内容 */}
</Drawer>
</>
);
};
```
### 响应式表格
```typescript
import { Table } from 'antd';
@@ -316,63 +197,18 @@ const ResponsiveTable: React.FC = () => {
### 代码质量
- ❌ 禁止使用 `any` 类型
- ❌ 禁止忽略错误处理
- ❌ 禁止硬编码API地址
- ❌ 禁止硬编码 API 地址
- ❌ 禁止在组件中直接使用 `fetch`
- ❌ 禁止忽略移动端适配
### 类型安全
- ❌ 禁止使用 `any`
- ❌ 禁止忽略 TypeScript 类型检查
- ❌ 禁止使用 `@ts-ignore` 除非有明确原因
### 移动端适配
- ❌ 禁止固定宽度布局
- ❌ 禁止使用过小的触摸目标(< 44x44px
- ❌ 禁止忽略移动端性能优化
- ❌ 禁止使用桌面端专用的交互方式(如 hover)
- ❌ 禁止使用桌面端专用交互(如 hover
### 多语言使用规范
- **必须**使用多语言(i18n)进行所有文本显示
- **禁止**在组件中硬编码中文或英文文本
- **必须**使用 `useTranslation` Hook 获取翻译函数
- **必须**在语言文件中定义所有显示的文本
- **必须**支持至少中文(简体/繁体)和英文
#### 多语言使用示例
```typescript
// ✅ 正确:使用多语言
import { useTranslation } from 'react-i18next'
const MyComponent: React.FC = () => {
const { t } = useTranslation()
return (
<div>
<h1>{t('page.title')}</h1>
<Button>{t('common.save')}</Button>
</div>
)
}
// ❌ 错误:硬编码文本
const MyComponent: React.FC = () => {
return (
<div>
<h1>页面标题</h1> {/* 禁止硬编码 */}
<Button>保存</Button> {/* 禁止硬编码 */}
</div>
)
}
```
#### 语言文件结构
- 语言文件位置:`src/locales/{locale}/common.json`
- 支持的语言:`zh-CN`(简体中文)、`zh-TW`(繁体中文)、`en`(英文)
- 键名使用点号分隔的层级结构,如:`notificationSettings.title`
#### 添加新翻译的步骤
1. 在 `src/locales/zh-CN/common.json` 中添加中文翻译
2. 在 `src/locales/zh-TW/common.json` 中添加繁体中文翻译
3. 在 `src/locales/en/common.json` 中添加英文翻译
4. 在组件中使用 `t('key')` 获取翻译
### 多语言
- ❌ 禁止硬编码中文或英文文本
- ❌ 禁止绕过 `useTranslation` 直接显示文本
+89
View File
@@ -36,6 +36,49 @@ jobs:
echo "Extracted version: $VERSION"
echo "Full tag: $TAG_NAME"
- name: Send Telegram notification (build started)
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
run: |
# 检查必要的环境变量
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
echo "⚠️ Telegram Bot Token 或 Chat ID 未配置,跳过通知"
exit 0
fi
# 获取构建信息
VERSION="${{ steps.extract_version.outputs.VERSION }}"
TAG="${{ steps.extract_version.outputs.TAG }}"
RELEASE_URL="${{ github.event.release.html_url }}"
# 构建消息内容(仅包含关键信息)
MESSAGE="🔨 <b>Docker 镜像构建中</b>"$'\n'$'\n'"📦 <b>版本:</b> ${VERSION}"$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"
# 发送 Telegram 消息(使用 jq 转义 JSON
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg chat_id "$TELEGRAM_CHAT_ID" \
--arg text "$MESSAGE" \
'{chat_id: $chat_id, text: $text, parse_mode: "HTML", disable_web_page_preview: false}')" > /tmp/telegram_response.json
# 检查发送结果
if [ $? -eq 0 ]; then
RESPONSE=$(cat /tmp/telegram_response.json)
if echo "$RESPONSE" | grep -q '"ok":true'; then
echo "✅ Telegram 通知发送成功"
else
echo "❌ Telegram 通知发送失败: $RESPONSE"
# 通知失败不应该导致整个 job 失败
exit 0
fi
else
echo "❌ 发送 Telegram 消息时发生错误"
# 通知失败不应该导致整个 job 失败
exit 0
fi
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
@@ -65,3 +108,49 @@ jobs:
GITHUB_REPO_URL=https://github.com/WrBug/PolyHermes
cache-from: type=registry,ref=wrbug/polyhermes:latest
cache-to: type=inline
- name: Send Telegram notification
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
run: |
# 检查必要的环境变量
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
echo "⚠️ Telegram Bot Token 或 Chat ID 未配置,跳过通知"
exit 0
fi
# 获取构建信息
VERSION="${{ steps.extract_version.outputs.VERSION }}"
TAG="${{ steps.extract_version.outputs.TAG }}"
RELEASE_NAME="${{ github.event.release.name }}"
RELEASE_URL="${{ github.event.release.html_url }}"
REPO_NAME="${{ github.repository }}"
# 构建消息内容(仅包含关键信息)
DEPLOY_DOC_URL="https://github.com/WrBug/PolyHermes/blob/main/docs/zh/DEPLOYMENT.md"
MESSAGE="✅ <b>Docker 镜像构建成功</b>"$'\n'$'\n'"📦 <b>版本:</b> ${VERSION}"$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker 部署文档</a>"
# 发送 Telegram 消息(使用 jq 转义 JSON
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg chat_id "$TELEGRAM_CHAT_ID" \
--arg text "$MESSAGE" \
'{chat_id: $chat_id, text: $text, parse_mode: "HTML", disable_web_page_preview: false}')" > /tmp/telegram_response.json
# 检查发送结果
if [ $? -eq 0 ]; then
RESPONSE=$(cat /tmp/telegram_response.json)
if echo "$RESPONSE" | grep -q '"ok":true'; then
echo "✅ Telegram 通知发送成功"
else
echo "❌ Telegram 通知发送失败: $RESPONSE"
# 构建成功,通知失败不应该导致整个 job 失败
exit 0
fi
else
echo "❌ 发送 Telegram 消息时发生错误"
# 构建成功,通知失败不应该导致整个 job 失败
exit 0
fi
+103
View File
@@ -0,0 +1,103 @@
name: Telegram Notification on PR Merge
on:
pull_request:
types:
- closed # 当 PR 被关闭(合并或关闭)时触发
jobs:
notify:
runs-on: ubuntu-latest
# 只在 PR 被合并到 main 分支时执行
if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Get PR details
id: pr_details
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUMBER="${{ github.event.pull_request.number }}"
REPO="${{ github.repository }}"
# 获取 PR 详细信息
PR_RESPONSE=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/${REPO}/pulls/${PR_NUMBER}")
# 获取 PR 变更的文件列表
FILES_RESPONSE=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/${REPO}/pulls/${PR_NUMBER}/files")
# 提取 PR 描述(body),保留换行,限制长度
PR_BODY=$(echo "$PR_RESPONSE" | jq -r '.body // ""')
if [ ${#PR_BODY} -gt 500 ]; then
PR_BODY="${PR_BODY:0:500}..."
fi
# 保存到输出变量(使用 base64 编码避免特殊字符问题)
echo "pr_body<<EOF" >> $GITHUB_OUTPUT
echo "$PR_BODY" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Send Telegram notification
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
run: |
# 检查 PR 是否被合并(而不是仅关闭)
PR_MERGED="${{ github.event.pull_request.merged }}"
if [ "$PR_MERGED" != "true" ]; then
echo "ℹ️ PR 仅关闭,未合并,跳过通知"
exit 0
fi
# 检查必要的环境变量
# 注意:TELEGRAM_CHAT_ID 可以是个人聊天 ID(正数)或群组 ID(负数,如 -1001234567890
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
echo "⚠️ Telegram Bot Token 或 Chat ID 未配置,跳过通知"
exit 0
fi
# 获取 PR 基本信息
PR_NUMBER="${{ github.event.pull_request.number }}"
PR_TITLE="${{ github.event.pull_request.title }}"
PR_URL="${{ github.event.pull_request.html_url }}"
PR_MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}"
# 获取 PR 详细信息
PR_BODY="${{ steps.pr_details.outputs.pr_body }}"
# 转义 PR 标题中的 HTML 特殊字符
PR_TITLE_ESCAPED=$(echo "$PR_TITLE" | sed 's/&/\&amp;/g' | sed 's/</\&lt;/g' | sed 's/>/\&gt;/g')
# 构建消息内容(仅包含关键信息)
MESSAGE="🚀 <b>main 分支代码更新</b>"$'\n'$'\n'"📝 <b>PR #${PR_NUMBER}:</b> ${PR_TITLE_ESCAPED}"$'\n'"🔗 <a href=\"${PR_URL}\">查看 PR</a>"
# 发送 Telegram 消息(使用 jq 转义 JSON
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg chat_id "$TELEGRAM_CHAT_ID" \
--arg text "$MESSAGE" \
'{chat_id: $chat_id, text: $text, parse_mode: "HTML", disable_web_page_preview: false}')" > /tmp/telegram_response.json
# 检查发送结果
if [ $? -eq 0 ]; then
RESPONSE=$(cat /tmp/telegram_response.json)
if echo "$RESPONSE" | grep -q '"ok":true'; then
echo "✅ Telegram 通知发送成功"
else
echo "❌ Telegram 通知发送失败: $RESPONSE"
exit 1
fi
else
echo "❌ 发送 Telegram 消息时发生错误"
exit 1
fi
+2 -2
View File
@@ -47,9 +47,9 @@ FROM eclipse-temurin:17-jre-jammy
WORKDIR /app
# 安装 Nginx 和必要的工具
# 安装 Nginx 和必要的工具(包含时区数据)
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 /etc/nginx/sites-enabled/default
+2
View File
@@ -2,6 +2,7 @@
[![GitHub](https://img.shields.io/badge/GitHub-WrBug%2FPolyHermes-blue?logo=github)](https://github.com/WrBug/PolyHermes)
[![Twitter](https://img.shields.io/badge/Twitter-@polyhermes-blue?logo=twitter)](https://x.com/polyhermes)
[![Docker](https://img.shields.io/docker/v/wrbug/polyhermes?label=Docker&logo=docker)](https://hub.docker.com/r/wrbug/polyhermes)
> 🌐 **Language**: [English](README_EN.md) | 中文
@@ -432,6 +433,7 @@ cd frontend
- [GitHub 仓库](https://github.com/WrBug/PolyHermes)
- [Twitter](https://x.com/polyhermes)
- [Telegram 群组](https://t.me/polyhermes)
- [Polymarket 官网](https://polymarket.com)
- [Polymarket API 文档](https://docs.polymarket.com)
+2
View File
@@ -2,6 +2,7 @@
[![GitHub](https://img.shields.io/badge/GitHub-WrBug%2FPolyHermes-blue?logo=github)](https://github.com/WrBug/PolyHermes)
[![Twitter](https://img.shields.io/badge/Twitter-@polyhermes-blue?logo=twitter)](https://x.com/polyhermes)
[![Docker](https://img.shields.io/docker/v/wrbug/polyhermes?label=Docker&logo=docker)](https://hub.docker.com/r/wrbug/polyhermes)
> 🌐 **Language**: English | [中文](README.md)
@@ -432,6 +433,7 @@ This project is licensed under the MIT License. See the [LICENSE](LICENSE) file
- [GitHub Repository](https://github.com/WrBug/PolyHermes)
- [Twitter](https://x.com/polyhermes)
- [Telegram Group](https://t.me/polyhermes)
- [Polymarket Official Website](https://polymarket.com)
- [Polymarket API Documentation](https://docs.polymarket.com)
+806 -1
View File
@@ -1,3 +1,808 @@
# 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
## 🐛 Bug 修复
### 修复盈亏统计计算错误问题
- **问题**:在仓位检查时,刚创建的订单可能因仓位更新延迟被误判为已卖出,导致盈亏统计计算错误
- **修复**
- 优化自动卖出订单筛选逻辑,在 SQL 层直接过滤创建时间超过阈值的订单
- 新增 `findUnmatchedBuyOrdersByOutcomeIndexOlderThan` Repository 方法,提高查询效率
- 统一使用 2 分钟阈值保护刚创建的订单,避免误判
- 两个场景都使用 SQL 过滤:
* 场景1:仓位不存在时,延迟检测使用 SQL 过滤
* 场景2:仓位部分存在时,FIFO 匹配使用 SQL 过滤
- **优化效果**
- SQL 层面直接过滤,减少数据传输,提高查询效率
- 代码更简洁,逻辑更清晰
- 刚创建的订单(< 2 分钟)不会被误判为已卖出
- 确保盈亏统计计算的准确性
## 📝 技术细节
- **Repository 变更**
- 新增 `findUnmatchedBuyOrdersByOutcomeIndexOlderThan` 方法
- 在 SQL 查询中添加 `createdAt < :thresholdTime` 条件过滤
- **Service 变更**
- `PositionCheckService` 中两个场景都使用 SQL 过滤替代内存过滤
- 统一使用 2 分钟(120000 毫秒)作为时间阈值
## 📊 变更统计
- **提交数量**1 个提交
- **文件变更**2 个文件
- **代码变更**+57 行 / -19 行(净增加 38 行)
### 详细文件变更
**后端变更**
- `CopyOrderTrackingRepository.kt` - 新增 SQL 查询方法(+18 行)
- `PositionCheckService.kt` - 优化订单筛选逻辑(+39 行 / -19 行)
## 🔄 主要提交
```
6ad4024 fix: 优化自动卖出订单筛选逻辑,避免刚创建的订单被误判
```
## 🎯 升级建议
1. **数据库迁移**:无需数据库迁移,可直接升级
2. **配置更新**:无需配置变更
3. **兼容性**:完全向后兼容,不影响现有功能
## 📦 Docker 镜像
Docker 镜像会自动构建并推送到 Docker Hub:
- `wrbug/polyhermes:v1.1.9`
- `wrbug/polyhermes:latest`(如果这是最新版本)
## 🔗 相关链接
- [GitHub Release](https://github.com/WrBug/PolyHermes/releases/tag/v1.1.9)
---
# v1.1.8
## 🚀 主要功能
### ⚡ Polymarket Activity WebSocket 双重监听方案
- **新增 PolymarketActivityWsService**:通过 Activity WebSocket 实时监听 Leader 交易
- 延迟 < 100ms,适合快速跟单场景
- 订阅全局 activity 交易流,客户端过滤 Leader 地址
- 支持动态添加/移除 Leader 监听
- 地址筛选忽略大小写,提高匹配准确性
- **双重监听机制**
- Activity WebSocket(低延迟,< 100ms):作为主要监听方式
- On-Chain WebSocket(高可靠性,~2-3s):作为兜底监听方式
- 自动去重,避免重复处理同一笔交易
- **协议支持**
- 支持 `connection_id` 字段
- 优先使用消息中的 `outcomeIndex` 字段,fallback 到从 `outcome` 解析
- **参考文档**
- [双重监听方案文档](docs/zh/copy-trading-dual-monitoring-plan.md)
- [Activity WebSocket API 文档](docs/zh/polymarket-activity-websocket-api.md)
### 🔧 支持同一用户创建多个相同 Leader 的跟单配置
- **移除唯一约束**:允许同一用户创建多个跟单同一个 Leader 的配置
- **应用场景**:支持不同参数(比例、过滤条件等)的多配置跟单
- **数据库迁移**`V23__remove_unique_constraint_from_copy_trading.sql`
### 🎯 市场截止时间筛选功能
- **新增市场截止时间过滤**:支持设置跟单配置的最大市场截止时间
- **自动过滤过期市场**:系统会自动跳过超过设定截止时间的市场
- **数据库字段**`max_market_end_date`(时间戳,单位:秒)
- **数据库迁移**`V22__add_max_market_end_date_to_copy_trading.sql`
### 🔍 关键字过滤功能
- **新增关键字过滤**:支持在黑名单或白名单模式下过滤市场标题关键字
- **过滤模式**
- `DISABLED`:禁用关键字过滤
- `BLACKLIST`:黑名单模式(包含关键字的市场会被过滤)
- `WHITELIST`:白名单模式(只允许包含关键字的市场)
- **多关键字支持**:支持多个关键字,以 JSON 数组格式存储
- **数据库字段**`keyword_filter_mode``keywords`JSON 数组)
- **数据库迁移**`V20__add_keyword_filter.sql`
### 📊 订单列表功能重构
- **按市场分组显示**:订单列表按市场分组,便于查看和管理
- **市场信息展示**
- 显示市场标题和 slug
- 支持跳转到 Polymarket 市场页面
- 显示市场基本信息(标题、slug、截止时间等)
- **订单 ID 复制功能**:一键复制订单 ID,方便查询和调试
- **UI/UX 优化**
- 优化订单列表展示样式
- 改进移动端适配
### 📈 市场信息管理和缓存优化
- **新增 MarketService**:统一管理市场信息查询和缓存
- 使用 LRU 缓存提高查询性能
- 支持从 Gamma API 和 CLOB API 查询市场信息
- 自动缓存市场信息,减少 API 调用
- **新增 Market 实体和表**:持久化存储市场信息
- 存储市场标题、slug、eventSlug、endDate 等基本信息
- 支持通过 marketId 快速查询市场信息
- **数据库迁移**
- `V19__create_markets_table.sql`:创建市场信息表
- `V21__add_event_slug_to_markets.sql`:添加 eventSlug 字段
- **市场信息轮询服务**:定期更新市场信息,保持数据新鲜度
## 🐛 Bug 修复
### 前端 TypeScript 类型错误修复
- **修复 InputNumber parser 类型错误**`parser` 函数应返回 `number` 而不是 `string`
- **修复文件**
- `src/pages/CopyTradingOrders/AddModal.tsx`
- `src/pages/CopyTradingOrders/EditModal.tsx`
## 📝 文档更新
- **新增双重监听方案文档**:详细说明 Activity WebSocket 和 On-Chain WebSocket 的双重监听机制
- **新增 Activity WebSocket API 文档**:详细的 API 格式和消息结构说明
- **更新开发规范**JSON 解析规范和 Data Class 规范
## 📊 统计信息
- **61 个文件被修改**
- **+5125 行新增代码**
- **-1942 行删除代码**
- **净增加 3183 行代码**
## 🔄 主要提交
```
d376a82 feat: 添加市场信息管理和订单ID复制功能
2af2c0e feat: 订单列表按市场分组并支持跳转到Polymarket
9ed5190 feat: 添加关键字过滤功能并优化市场 slug 处理
0327eaf feat: 添加市场截止时间筛选功能
a16b6fc feat: 支持同一用户创建多个相同 leader 的跟单配置
19508dc feat: 实现 Polymarket Activity WebSocket 双重监听方案
```
## 🎯 升级建议
1. **数据库迁移**:确保执行所有数据库迁移脚本(V19-V23)
2. **配置更新**:新版本的 `application.properties` 中添加了 Activity WebSocket URL 配置
3. **环境变量**:如果使用 Docker 部署,建议更新 `docker-compose.yml` 中的配置
## 📦 Docker 镜像
Docker 镜像会自动构建并推送到 Docker Hub:
- `wrbug/polyhermes:v1.1.8`
- `wrbug/polyhermes:latest`(如果这是最新版本)
## 🔗 相关链接
- [GitHub Release](https://github.com/WrBug/PolyHermes/releases/tag/v1.1.8)
- [双重监听方案文档](docs/zh/copy-trading-dual-monitoring-plan.md)
- [Activity WebSocket API 文档](docs/zh/polymarket-activity-websocket-api.md)
---
# v1.1.7
## 🚀 主要功能
### 💰 Polymarket Maker Rebates Program 费率支持
- **新增费率查询 API 接口** (`getFeeRate`)
- 支持动态查询 Maker Rebates Program 费率
- 修正 API 返回字段名:使用 `base_fee` 而非 `fee_rate_bps`(与 TypeScript clob-client 一致)
- **动态费率获取**
- 在所有订单创建处动态获取费率:
* 跟单买入订单 (`processBuyTrade`)
* 跟单卖出订单 (`matchSellOrder`)
* 账户卖出订单 (`sellPosition`)
- 费率获取失败时降级到默认值 "0",确保系统可用性
- 添加详细的日志记录,便于监控和调试
- **参考文档**: https://docs.polymarket.com/developers/market-makers/maker-rebates-program
### 🔧 Docker 部署优化
- **日志级别环境变量支持**
-`application.properties` 中支持通过 `LOG_LEVEL_ROOT``LOG_LEVEL_APP` 环境变量配置日志级别
-`docker-compose.yml``docker-compose.prod.yml` 中添加日志级别环境变量配置
-`deploy.sh``.env` 模板中添加日志级别配置说明
- 支持通过环境变量动态配置日志级别,无需修改配置文件
- 默认值:`root=INFO`, `app=DEBUG`
## 🐛 Bug 修复
### 修复市场条件查询的 RPC 调用错误
- **问题**:使用错误的函数签名 `conditions(bytes32)` 导致 RPC 调用失败(execution reverted
- **修复**
- 将错误的 `conditions(bytes32)` 函数调用改为正确的 `getOutcomeSlotCount(bytes32)``payoutDenominator(bytes32)` 函数调用
- 修复 `BlockchainService.getCondition` 方法,使用正确的 ConditionalTokens 合约函数签名
- 改进 `MarketPriceService` 的错误处理:当链上查询出现 RPC 错误时,降级到 CLOB API 或 Gamma API 查询,而不是直接抛出异常,提高容错性
### 修复 RPC 错误时误创建自动卖出记录的问题
- **问题**:当链上查询市场条件出现 RPC 错误(execution reverted)时,系统会误判为市场已卖出,创建错误的自动卖出记录
- **修复**
- 修改 `getPriceFromChainCondition` 返回 `Pair<BigDecimal?, Boolean>`,第二个值表示是否发生 RPC 错误
-`getCurrentMarketPrice` 中检测到 RPC 错误时抛出异常,`PositionCheckService` 会捕获并跳过该市场的处理
- 避免在市场不存在或尚未创建时误判为已卖出
## 📝 文档更新
### 更新 Telegram 群链接
- 将所有 Telegram 群链接统一更新为 `t.me/polyhermes`
- 更新了以下文件:
- `frontend/src/components/Layout.tsx` - 桌面端和移动端导航链接
- `RELEASE.md` - 相关链接
- `README.md``README_EN.md` - 相关链接部分
### 添加 Docker 版本徽章
- 在 README 和 README_EN.md 中添加动态 Docker 版本徽章
- 使用 shields.io 自动显示 Docker Hub 上 `wrbug/polyhermes` 镜像的最新版本
- 版本信息自动更新,无需手动维护
## 📊 变更统计
- **提交数量**5 个提交
- **文件变更**16 个文件
- **代码变更**+205 行 / -886 行(净减少 681 行)
### 详细文件变更
**后端变更**
- `PolymarketClobApi.kt` - 添加费率查询接口(+25 行)
- `AccountService.kt` - 在订单创建处添加动态费率获取(+11 行)
- `BlockchainService.kt` - 修复市场条件查询的 RPC 调用错误(+84 行)
- `MarketPriceService.kt` - 改进错误处理,支持降级到其他数据源(+36 行)
- `PolymarketClobService.kt` - 添加费率查询服务(+32 行)
- `CopyOrderTrackingService.kt` - 在跟单订单创建处添加费率获取(+34 行)
- `PositionCheckService.kt` - 修复 RPC 错误处理逻辑(+2 行)
- `application.properties` - 添加日志级别环境变量支持(+6 行)
**前端变更**
- `Layout.tsx` - 更新 Telegram 群链接(+4 行)
**配置文件变更**
- `docker-compose.yml` - 添加日志级别环境变量(+4 行)
- `docker-compose.prod.yml` - 添加日志级别环境变量(+4 行)
- `deploy.sh` - 添加日志级别配置说明(+5 行)
**文档变更**
- `README.md` - 更新 Telegram 链接,添加 Docker 版本徽章(+2 行)
- `README_EN.md` - 更新 Telegram 链接,添加 Docker 版本徽章(+2 行)
- `RELEASE.md` - 更新 Telegram 链接(+4 行)
- `docs/zh/smart-money-analysis.md` - 删除文档(-836 行)
## 🔧 技术细节
### API 变更
- **新增接口**
- `POST /api/clob/fee-rate` - 获取 Maker Rebates Program 费率(内部使用)
- **无移除接口**
### 环境变量变更
- **新增环境变量**
- `LOG_LEVEL_ROOT` - Root 日志级别(默认:INFO
- `LOG_LEVEL_APP` - 应用日志级别(默认:DEBUG)
### 合约调用修复
- **修复的函数调用**
-`conditions(bytes32)` 改为 `getOutcomeSlotCount(bytes32)``payoutDenominator(bytes32)`
- 使用正确的 ConditionalTokens 合约函数签名
- 参考:https://polygonscan.com/address/0x4d97dcd97ec945f40cf65f87097ace5ea0476045#code
## 📝 升级说明
### 数据库升级
- **无需数据库迁移**:本次更新不涉及数据库结构变更
### 配置更新
- **可选配置**:新增日志级别环境变量,如不配置将使用默认值
- `LOG_LEVEL_ROOT=INFO`(默认)
- `LOG_LEVEL_APP=DEBUG`(默认)
### Docker 部署
- **推荐更新**:使用 Docker Hub 镜像部署的用户,建议更新到最新版本
```bash
docker pull wrbug/polyhermes:latest
docker-compose -f docker-compose.prod.yml up -d
```
## 🔗 相关链接
- **GitHub 仓库**https://github.com/WrBug/PolyHermes
- **Twitter**@polyhermes
- **Telegram 群组**https://t.me/polyhermes
---
**发布日期**2026-01-07
---
# v1.1.5
## 🔧 功能优化与改进
### 前端优化
#### 优化 InputNumber 输入框格式化
- 优化数值输入框的格式化逻辑,修正正则表达式以正确处理整数显示
- 更新所有相关 InputNumber 组件的 formatter 函数,确保显示准确性
- 影响的组件:CopyTradingAdd、CopyTradingEdit、EditModal、TemplateAdd、TemplateEdit、TemplateList
- 影响范围:跟单配置、模板配置中的所有数值输入框
#### 优化数字显示格式
- 添加 `formatNumber` 工具函数,自动去除小数尾随零(如 100.00 → 100
- 统一所有数值输入框的显示格式,提升用户体验
### 后端优化
#### 优化按比例跟单金额计算逻辑
- 优化按比例计算的订单金额处理,使用向上取整确保满足最小限制要求
- 对订单金额进行向上取整处理(保留 2 位小数精度)
- 自动调整订单数量以满足最小限制要求
- 使用 `RoundingMode.CEILING` 确保金额满足最小限制
- 影响范围:按比例跟单的订单创建逻辑
- 技术细节:
- 扩展 `BigDecimal.div()` 扩展函数,支持指定精度和舍入模式
- 在 `CopyOrderTrackingService` 中优化金额计算和验证逻辑
#### 增强 copyRatio 精度支持
- 将 copyRatio 字段精度从 DECIMAL(10,2) 增加到 DECIMAL(20,8)
- 支持更精确的跟单比例设置(最小 0.01%,最大 10000%
- 影响的实体:CopyTrading、CopyTradingTemplate
- 数据库迁移:新增 V18 迁移脚本,自动升级数据库字段精度
## 🔧 功能优化
### 移除刷新代理钱包接口
- **移除接口**
- `POST /api/accounts/refresh-proxy` - 刷新单个账户的代理地址
- `POST /api/accounts/refresh-all-proxies` - 刷新所有账户的代理地址
- **原因**:代理地址应在账户导入时自动计算,无需手动刷新
- **影响范围**AccountController、AccountService
- **向后兼容性**:这些接口已不再使用,移除不影响现有功能
### 前端跟单比例配置优化
- **最小比例**:从 10% 降低到 0.01%,支持更灵活的跟单比例设置
- **最大比例**:增加到 10000%,满足大比例跟单需求
- **显示格式**:比例模式显示为百分比(如 "100%" 而不是 "1x"
- **输入验证**:增强输入验证,确保比例在合理范围内
## 📊 变更统计
- **提交数量**3 个提交
- **文件变更**15 个文件
- **代码变更**+575 行 / -194 行(净增加 381 行)
### 详细文件变更
**后端变更**
- `AccountController.kt` - 移除刷新代理钱包接口(-59 行)
- `AccountService.kt` - 移除刷新代理钱包方法(-79 行)
- `CopyTrading.kt` - 增加 copyRatio 精度
- `CopyTradingTemplate.kt` - 增加 copyRatio 精度
- `CopyOrderTrackingService.kt` - 优化按比例跟单金额计算逻辑(+45 行)
- `MathExt.kt` - 扩展 div 函数支持精度和舍入模式(+20 行)
- `V18__increase_copy_ratio_precision.sql` - 数据库迁移脚本(+14 行)
**前端变更**
- `CopyTradingAdd.tsx` - 优化 formatter、优化比例配置(+106 行)
- `CopyTradingEdit.tsx` - 优化 formatter、优化比例配置(+106 行)
- `CopyTradingList.tsx` - 优化比例显示格式
- `CopyTradingOrders/EditModal.tsx` - 优化 formatter、优化比例配置(+106 行)
- `TemplateAdd.tsx` - 优化 formatter、优化比例配置(+65 行)
- `TemplateEdit.tsx` - 优化 formatter、优化比例配置(+65 行)
- `TemplateList.tsx` - 优化 formatter、优化比例配置(+65 行)
- `utils/index.ts` - 添加 formatNumber 工具函数(+31 行)
## 🔧 技术细节
### 数据库变更
- **迁移脚本**`V18__increase_copy_ratio_precision.sql`
- **变更内容**
- `copy_trading.copy_ratio`: DECIMAL(10,2) → DECIMAL(20,8)
- `copy_trading_templates.copy_ratio`: DECIMAL(10,2) → DECIMAL(20,8)
- **自动执行**:升级时会自动执行迁移脚本
### API 变更
- **移除接口**
- `POST /api/accounts/refresh-proxy`
- `POST /api/accounts/refresh-all-proxies`
- **无新增接口**
### 前端变更
- **工具函数**:新增 `formatNumber()` 函数,用于格式化数字显示
- **组件更新**:所有数值输入框统一使用新的 formatter 函数
- **显示优化**:跟单模式的比例显示为百分比格式
## 📝 升级说明
### 数据库升级
本次版本包含数据库迁移脚本,升级时会自动执行:
- 自动增加 `copy_ratio` 字段的精度
- 现有数据不受影响,精度升级是向后兼容的
### 配置变更
无需额外配置变更。
### 兼容性
- **向后兼容**:所有变更都是向后兼容的
- **API 兼容**:移除的接口不影响现有功能(这些接口已不再使用)
- **数据兼容**:数据库字段精度升级不会影响现有数据
## 🎯 主要改进
1. **优化输入框格式化**:优化数值输入框的显示逻辑
2. **优化跟单金额计算**:确保按比例跟单的金额满足最小限制要求
3. **提升精度支持**:支持更精确的跟单比例设置(0.01% - 10000%
4. **代码清理**:移除不再使用的刷新代理钱包接口
## 🔗 相关链接
- [GitHub Tag](https://github.com/WrBug/PolyHermes/releases/tag/v1.1.5)
- [变更日志](https://github.com/WrBug/PolyHermes/compare/v1.1.4...v1.1.5)
## 🙏 致谢
感谢所有贡献者和测试用户的反馈与支持!
---
# v1.1.2
## 🚀 主要功能
### 🐛 修复内存泄漏问题
- 修复 Retrofit/OkHttpClient 实例重复创建导致的内存泄漏问题
- 为不需要认证的 API 创建共享的 OkHttpClient 实例(Gamma API、Data API、GitHub API 等)
- 带认证的 CLOB API 按钱包地址缓存(每个账户一个客户端)
- RPC API 按 RPC URL 缓存,Builder Relayer API 按 relayerUrl 缓存
- 添加 `@PreDestroy` 方法清理缓存,确保资源正确释放
- **效果**:内存占用从运行几小时后从 400MB 涨到 1GB+ 变为保持稳定,大幅减少内存占用
### 📊 市场价格服务优化
- 移除降级查询逻辑,仅保留链上 RPC 查询和 CLOB 订单簿查询
- 移除 CLOB Trades、Gamma Market Status、Gamma Market Price 查询逻辑
- 如果所有数据源都失败,抛出明确的异常信息
- 价格截位到 4 位小数(向下截断,不四舍五入)
- 简化代码逻辑,提高查询效率和准确性
### 🔧 代码架构优化
- 统一 Gson 使用,改为依赖注入方式
- 在 `GsonConfig` 中统一配置 Gson Beanlenient 模式)
- 所有 Service 类通过构造函数注入 Gson 实例
- 移除所有 `GsonConverterFactory.create()` 无参调用,统一使用注入的 Gson
- 提高代码一致性和可维护性
### 🗑️ 功能清理
- 移除下单失败存储数据库的功能
- 删除 `FailedTrade` 实体类和 `FailedTradeRepository`
- 从 `CopyOrderTrackingService` 中移除失败交易存储逻辑
- 创建 Flyway migration V16 删除 `failed_trade` 表
- 下单失败时仅记录日志,不再存储到数据库,简化数据模型
### 🚀 部署优化
- 自动使用当前分支名作为 Docker 版本号
- 分支名中的 `/` 自动替换为 `-`Docker tag 不支持 `/
- `docker-compose.yml` 启用 build args,从环境变量读取版本号
- 前端页面将显示当前分支名作为版本号
- 如果没有 Git 仓库或获取失败,使用默认值 `dev`
## 🐛 Bug 修复
### 修复 Flyway Migration 问题
- 恢复 V1 migration 文件,避免 checksum 不匹配
- 保持 `V1__init_database.sql` 的原有内容不变
- `failed_trade` 表的删除通过 V16 migration 处理
- 确保已有数据库的 migration checksum 保持一致
### 修复前端编译错误
- 修复 `PositionList.tsx` 中引用不存在的 `bestBid` 属性导致的编译错误
- 使用 `currentPrice` 替代 `bestBid`,确保前端代码可以正常编译
## 📚 文档更新
- 新增智能资金分析文档(`docs/zh/smart-money-analysis.md`
- 详细说明智能资金分析功能的使用方法和策略
## 🔧 技术改进
- 优化 `RetrofitFactory`,实现客户端实例缓存和复用
- 优化 `CopyOrderTrackingService`,移除失败交易相关逻辑
- 优化 `OrderStatusUpdateService`,增强订单状态更新功能
- 优化 `TelegramNotificationService`,改进通知逻辑
- 优化 `PositionCheckService`,简化代码结构
- 优化 `PolymarketClobService`,改进 API 调用逻辑
## 📦 数据库变更
- 删除 `failed_trade` 表(Migration: V16
## 🔗 相关链接
- **GitHub Release**: https://github.com/WrBug/PolyHermes/releases/tag/v1.1.2
- **完整更新日志**: https://github.com/WrBug/PolyHermes/compare/v1.1.1...v1.1.2
- **Docker Hub**: https://hub.docker.com/r/wrbug/polyhermes
## 📊 统计信息
- **文件变更**: 29 个文件
- **代码变更**: +1597 行 / -678 行
- **主要提交**: 8 个提交
## ⚠️ 重要提醒
**请务必使用官方 Docker 镜像源,避免财产损失!**
### ✅ 官方 Docker Hub 镜像
**官方镜像地址**`wrbug/polyhermes`
```bash
# ✅ 正确:使用官方镜像
docker pull wrbug/polyhermes:v1.1.2
# ❌ 错误:不要使用其他来源的镜像
# 任何非官方来源的镜像都可能包含恶意代码,导致您的私钥和资产被盗
```
### 🔗 官方渠道
请通过以下**唯一官方渠道**获取 PolyHermes
* **GitHub 仓库**https://github.com/WrBug/PolyHermes
* **Twitter**@polyhermes
* **Telegram 群组**https://t.me/polyhermes
---
**⭐ 如果这个项目对您有帮助,请给个 Star 支持一下!**
---
# v1.1.1
## 🚀 主要功能
@@ -157,7 +962,7 @@ docker pull wrbug/polyhermes:v1.1.1
* **GitHub 仓库**https://github.com/WrBug/PolyHermes
* **Twitter**@polyhermes
* **Telegram 群组**加入群组
* **Telegram 群组**https://t.me/polyhermes
---
+3
View File
@@ -68,6 +68,9 @@ dependencies {
// Logging
implementation("org.slf4j:slf4j-api")
// Caffeine Cache (LRU)
implementation("com.github.ben-manes.caffeine:caffeine:3.1.8")
// Test
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test")
+5
View File
@@ -9,6 +9,7 @@ services:
ports:
- "${SERVER_PORT:-8000}:8000"
environment:
- TZ=${TZ:-Asia/Shanghai}
- 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_USERNAME=${DB_USERNAME:-root}
@@ -16,6 +17,8 @@ services:
- SERVER_PORT=8000
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
- ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY:-change-me-in-production}
volumes:
- /etc/localtime:/etc/localtime:ro
depends_on:
mysql:
condition: service_healthy
@@ -29,12 +32,14 @@ services:
ports:
- "${MYSQL_PORT:-3306}:3306"
environment:
- TZ=${TZ:-Asia/Shanghai}
- MYSQL_ROOT_PASSWORD=${DB_PASSWORD:-rootpassword}
- MYSQL_DATABASE=polyhermes
- MYSQL_CHARACTER_SET_SERVER=utf8mb4
- MYSQL_COLLATION_SERVER=utf8mb4_unicode_ci
volumes:
- mysql-data:/var/lib/mysql
- /etc/localtime:/etc/localtime:ro
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_PASSWORD:-rootpassword}"]
interval: 10s
@@ -149,6 +149,19 @@ interface PolymarketClobApi {
@GET("/auth/derive-api-key")
suspend fun deriveApiKey(): Response<ApiKeyResponse>
/**
* 获取费率
* 文档: https://docs.polymarket.com/developers/market-makers/maker-rebates-program#1-fetch-the-fee-rate
* 端点: GET /fee-rate
*
* @param tokenId Token ID
* @return 费率响应
*/
@GET("/fee-rate")
suspend fun getFeeRate(
@Query("token_id") tokenId: String
): Response<FeeRateResponse>
/**
* 获取服务器时间
* 端点: /time
@@ -357,6 +370,18 @@ data class ServerTimeResponse(
val timestamp: Long
)
/**
* 费率响应
* 文档: https://docs.polymarket.com/developers/market-makers/maker-rebates-program#1-fetch-the-fee-rate
*
* 注意:根据 TypeScript clob-client 源码,API 返回的字段名是 base_fee,而不是文档中的 fee_rate_bps
* 参考: clob-client/src/client.ts:312
*/
data class FeeRateResponse(
@SerializedName("base_fee")
val baseFee: Int // 费率基点(0 表示无费率,1000 表示 10%)
)
/**
* 最新价响应(从订单表获取)
*/
@@ -28,6 +28,23 @@ interface PolymarketGammaApi {
): Response<List<MarketResponse>>
}
/**
* 事件响应(从 MarketResponse.events 解析)
*/
data class EventResponse(
val id: String? = null,
val ticker: String? = null,
val slug: String,
val title: String,
val category: String? = null,
val active: Boolean? = null,
val closed: Boolean? = null,
val archived: Boolean? = null,
val startDate: String? = null,
val endDate: String? = null,
val createdAt: String? = null
)
/**
* 市场响应(根据 Gamma API 文档)
*/
@@ -54,6 +71,7 @@ data class MarketResponse(
val lastTradePrice: Double? = null,
val bestBid: Double? = null,
val bestAsk: Double? = null,
val events: List<EventResponse>? = null, // 事件列表(从 events[0] 获取 slug
// 以下字段可能存在于响应中,但不在标准文档中
val clobTokenIds: String? = null, // CLOB token IDs(可能是 JSON 字符串或数组)
val clob_token_ids: String? = null // 下划线格式(兼容不同 API 版本)
@@ -0,0 +1,26 @@
package com.wrbug.polymarketbot.config
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
/**
* Gson 配置类
* 统一配置 Gson 实例,使用 lenient 模式允许解析格式不严格的 JSON
*/
@Configuration
class GsonConfig {
/**
* 创建 Gson Bean
* 使用 lenient 模式,允许解析格式不严格的 JSON
*/
@Bean
fun gson(): Gson {
return GsonBuilder()
.setLenient()
.create()
}
}
@@ -36,20 +36,19 @@ class JwtAuthenticationInterceptor(
handler: Any
): Boolean {
val path = request.requestURI
val method = request.method
// 只拦截POST请求
if (method != "POST") {
// 只拦截 /api/** 路径
if (!path.startsWith("/api/")) {
return true
}
// 排除不需要鉴权的路径
if (excludePaths.contains(path)) {
return true
}
// 只拦截 /api/** 路径
if (!path.startsWith("/api/")) {
// 允许 OPTIONS 请求(CORS 预检请求)
if (request.method == "OPTIONS") {
return true
}
@@ -1,5 +1,6 @@
package com.wrbug.polymarketbot.config
import com.google.gson.Gson
import com.wrbug.polymarketbot.api.PolymarketClobApi
import com.wrbug.polymarketbot.util.createClient
import org.springframework.beans.factory.annotation.Value
@@ -18,7 +19,9 @@ import retrofit2.converter.gson.GsonConverterFactory
* - 账户 API Key 在调用时动态设置,不在此处配置
*/
@Configuration
class RetrofitConfig {
class RetrofitConfig(
private val gson: Gson
) {
@Value("\${polymarket.clob.base-url}")
private lateinit var clobBaseUrl: String
@@ -37,7 +40,7 @@ class RetrofitConfig {
return Retrofit.Builder()
.baseUrl(clobBaseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(PolymarketClobApi::class.java)
}
@@ -21,10 +21,6 @@ class WebMvcConfig(
// 再注册JWT认证拦截器
registry.addInterceptor(jwtAuthenticationInterceptor)
.addPathPatterns("/api/**")
registry.addInterceptor(jwtAuthenticationInterceptor)
.addPathPatterns("/api/**")
registry.addInterceptor(jwtAuthenticationInterceptor)
.addPathPatterns("/api/**")
}
}
@@ -1,6 +1,7 @@
package com.wrbug.polymarketbot.config
import com.wrbug.polymarketbot.repository.UserRepository
import com.wrbug.polymarketbot.service.auth.WebSocketTicketService
import com.wrbug.polymarketbot.util.JwtUtils
import org.slf4j.LoggerFactory
import org.springframework.http.server.ServerHttpRequest
@@ -11,12 +12,13 @@ import org.springframework.web.socket.server.HandshakeInterceptor
/**
* WebSocket 握手拦截器
* 用于验证 JWT token
* 优先使用短期票据验证,其次使用 JWT token
*/
@Component
class WebSocketAuthInterceptor(
private val jwtUtils: JwtUtils,
private val userRepository: UserRepository
private val userRepository: UserRepository,
private val webSocketTicketService: WebSocketTicketService
) : HandshakeInterceptor {
private val logger = LoggerFactory.getLogger(WebSocketAuthInterceptor::class.java)
@@ -27,22 +29,36 @@ class WebSocketAuthInterceptor(
wsHandler: WebSocketHandler,
attributes: MutableMap<String, Any>
): Boolean {
// 从查询参数或请求头获取 token
val token = getTokenFromRequest(request)
if (token == null) {
logger.warn("WebSocket 连接缺少认证令牌: ${request.uri}")
// 优先使用票据验证(推荐方式,不暴露 JWT)
val ticket = getTicketFromRequest(request)
if (ticket != null) {
val username = webSocketTicketService.validateAndConsumeTicket(ticket)
if (username != null) {
attributes["username"] = username
logger.debug("WebSocket 连接票据认证成功: username=$username")
return true
}
logger.warn("WebSocket 连接票据验证失败(可能已过期或已使用)")
response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED)
return false
}
// 兼容旧方式:使用 JWT token(不推荐,但保持向后兼容)
val token = getTokenFromRequest(request)
if (token == null) {
logger.warn("WebSocket 连接缺少认证令牌: ${request.uri.path}")
response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED)
return false
}
// 验证 token
if (!jwtUtils.validateToken(token)) {
logger.warn("WebSocket 连接 token 验证失败: ${request.uri}")
logger.warn("WebSocket 连接 token 验证失败")
response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED)
return false
}
// 验证tokenVersion(检查token是否因密码修改而失效)
val username = jwtUtils.getUsernameFromToken(token)
if (username != null) {
@@ -50,21 +66,21 @@ class WebSocketAuthInterceptor(
if (user != null) {
val tokenVersion = jwtUtils.getTokenVersionFromToken(token)
if (tokenVersion == null || tokenVersion != user.tokenVersion) {
logger.warn("WebSocket 连接 token 版本不匹配,token已失效: username=$username, tokenVersion=$tokenVersion, userTokenVersion=${user.tokenVersion}, uri=${request.uri}")
logger.warn("WebSocket 连接 token 版本不匹配,token已失效: username=$username")
response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED)
return false
}
}
// 获取用户名并存入 attributes,供后续使用
attributes["username"] = username
logger.debug("WebSocket 连接认证成功: username=$username, uri=${request.uri}")
logger.debug("WebSocket 连接 JWT 认证成功: username=$username")
} else {
logger.warn("WebSocket 连接无法获取用户名: ${request.uri}")
logger.warn("WebSocket 连接无法获取用户名")
response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED)
return false
}
return true
}
@@ -78,7 +94,22 @@ class WebSocketAuthInterceptor(
}
/**
* 从请求中获取 token
* 从请求中获取票据
*/
private fun getTicketFromRequest(request: ServerHttpRequest): String? {
val queryParams = request.uri.query ?: return null
val params = queryParams.split("&")
for (param in params) {
val parts = param.split("=", limit = 2)
if (parts.size == 2 && parts[0] == "ticket") {
return parts[1]
}
}
return null
}
/**
* 从请求中获取 token(兼容旧方式)
* 支持从查询参数 token 或请求头 Authorization 获取
*/
private fun getTokenFromRequest(request: ServerHttpRequest): String? {
@@ -93,13 +124,13 @@ class WebSocketAuthInterceptor(
}
}
}
// 从请求头获取
val authHeader = request.headers.getFirst("Authorization")
if (authHeader != null && authHeader.startsWith("Bearer ")) {
return authHeader.substring(7)
}
return null
}
}
@@ -2,6 +2,7 @@ package com.wrbug.polymarketbot.config
import com.wrbug.polymarketbot.websocket.PolymarketWebSocketHandler
import com.wrbug.polymarketbot.websocket.UnifiedWebSocketHandler
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Configuration
import org.springframework.web.socket.config.annotation.EnableWebSocket
import org.springframework.web.socket.config.annotation.WebSocketConfigurer
@@ -16,21 +17,46 @@ import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry
class WebSocketConfig(
private val polymarketWebSocketHandler: PolymarketWebSocketHandler,
private val unifiedWebSocketHandler: UnifiedWebSocketHandler,
private val webSocketAuthInterceptor: WebSocketAuthInterceptor
private val webSocketAuthInterceptor: WebSocketAuthInterceptor,
@Value("\${websocket.allowed-origins:}") private val allowedOriginsConfig: String
) : WebSocketConfigurer {
/**
* 获取允许的 WebSocket 来源
* 如果配置了 WEBSOCKET_ALLOWED_ORIGINS 环境变量,使用配置的域名
* 否则使用 setAllowedOriginPatterns 允许同源访问
*/
private fun getAllowedOrigins(): Array<String> {
return if (allowedOriginsConfig.isNotBlank()) {
allowedOriginsConfig.split(",").map { it.trim() }.toTypedArray()
} else {
emptyArray()
}
}
override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) {
val origins = getAllowedOrigins()
// Polymarket RTDS 转发端点(转发外部 Polymarket 实时数据流)
// 注意:此端点不需要鉴权,因为它只是转发外部数据
registry.addHandler(polymarketWebSocketHandler, "/ws/polymarket")
.setAllowedOrigins("*") // 生产环境应该配置具体的域名
val polymarketHandler = registry.addHandler(polymarketWebSocketHandler, "/ws/polymarket")
if (origins.isNotEmpty()) {
polymarketHandler.setAllowedOrigins(*origins)
} else {
// 使用 setAllowedOriginPatterns 替代 setAllowedOrigins("*"),更安全
polymarketHandler.setAllowedOriginPatterns("*")
}
// 统一 WebSocket 端点(所有推送服务统一使用此路径,通过 channel 区分)
// 支持的频道:position(仓位推送)、order(订单推送,待实现)等
// 支持的频道:position(仓位推送)、order(订单推送)等
// 需要 JWT 鉴权
registry.addHandler(unifiedWebSocketHandler, "/ws")
.addInterceptors(webSocketAuthInterceptor) // 添加鉴权拦截器
.setAllowedOrigins("*") // 生产环境应该配置具体的域名
val unifiedHandler = registry.addHandler(unifiedWebSocketHandler, "/ws")
.addInterceptors(webSocketAuthInterceptor)
if (origins.isNotEmpty()) {
unifiedHandler.setAllowedOrigins(*origins)
} else {
unifiedHandler.setAllowedOriginPatterns("*")
}
}
}
@@ -3,6 +3,7 @@ package com.wrbug.polymarketbot.controller.auth
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.auth.AuthService
import com.wrbug.polymarketbot.service.auth.WebSocketTicketService
import jakarta.servlet.http.HttpServletRequest
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
@@ -16,7 +17,8 @@ import org.springframework.web.bind.annotation.*
@RequestMapping("/api/auth")
class AuthController(
private val authService: AuthService,
private val messageSource: MessageSource
private val messageSource: MessageSource,
private val webSocketTicketService: WebSocketTicketService
) {
private val logger = LoggerFactory.getLogger(AuthController::class.java)
@@ -25,7 +27,10 @@ class AuthController(
* 登录接口
*/
@PostMapping("/login")
fun login(@RequestBody request: LoginRequest): ResponseEntity<ApiResponse<LoginResponse>> {
fun login(
@RequestBody request: LoginRequest,
httpRequest: HttpServletRequest
): ResponseEntity<ApiResponse<LoginResponse>> {
return try {
if (request.username.isBlank()) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, "用户名不能为空", messageSource))
@@ -33,15 +38,19 @@ class AuthController(
if (request.password.isBlank()) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, "密码不能为空", messageSource))
}
val result = authService.login(request.username, request.password)
val ipAddress = getClientIpAddress(httpRequest)
val result = authService.login(request.username, request.password, ipAddress)
result.fold(
onSuccess = { loginResponse ->
ResponseEntity.ok(ApiResponse.success(loginResponse))
},
onFailure = { e ->
logger.error("登录失败: ${e.message}", e)
when (e) {
is IllegalStateException -> {
// 限速或锁定错误
ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, e.message ?: "登录失败", messageSource))
}
is IllegalArgumentException -> {
if (e.message == ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR.message) {
ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR, messageSource = messageSource))
@@ -49,15 +58,36 @@ class AuthController(
ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, e.message, messageSource))
}
}
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, "登录失败: ${e.message}", messageSource))
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, "登录失败", messageSource))
}
}
)
} catch (e: Exception) {
logger.error("登录异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, "登录失败: ${e.message}", messageSource))
ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, "登录失败", messageSource))
}
}
/**
* 获取客户端IP地址
*/
private fun getClientIpAddress(request: HttpServletRequest): String {
var ip = request.getHeader("X-Forwarded-For")
if (ip.isNullOrBlank() || "unknown".equals(ip, ignoreCase = true)) {
ip = request.getHeader("X-Real-IP")
}
if (ip.isNullOrBlank() || "unknown".equals(ip, ignoreCase = true)) {
ip = request.getHeader("Proxy-Client-IP")
}
if (ip.isNullOrBlank() || "unknown".equals(ip, ignoreCase = true)) {
ip = request.remoteAddr
}
// 处理多个IP的情况
if (ip.contains(",")) {
ip = ip.split(",")[0].trim()
}
return ip
}
/**
* 重置密码接口
@@ -132,5 +162,27 @@ class AuthController(
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "检查首次使用失败: ${e.message}", messageSource))
}
}
/**
* 获取 WebSocket 连接票据
* 返回一个短期有效(30秒)的一次性票据,用于 WebSocket 连接认证
* 避免在 WebSocket URL 中暴露 JWT
*/
@PostMapping("/ws-ticket")
fun getWebSocketTicket(httpRequest: HttpServletRequest): ResponseEntity<ApiResponse<WebSocketTicketResponse>> {
return try {
// 从请求属性中获取用户名(由 JWT 拦截器设置)
val username = httpRequest.getAttribute("username") as? String
if (username == null) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, "未认证", messageSource))
}
val ticket = webSocketTicketService.generateTicket(username)
ResponseEntity.ok(ApiResponse.success(WebSocketTicketResponse(ticket = ticket)))
} catch (e: Exception) {
logger.error("获取 WebSocket 票据异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "获取票据失败", messageSource))
}
}
}
@@ -200,5 +200,51 @@ class CopyOrderTrackingController(
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ORDER_TRACKING_LIST_FETCH_FAILED, e.message, messageSource))
}
}
/**
* 查询按市场分组的订单列表(买入/卖出)
* POST /api/copy-trading/orders/grouped-by-market
*/
@PostMapping("/grouped-by-market")
fun getOrderListGroupedByMarket(@RequestBody request: MarketGroupedOrdersRequest): ResponseEntity<ApiResponse<MarketGroupedOrdersResponse>> {
return try {
if (request.copyTradingId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_COPY_TRADING_ID_INVALID, messageSource = messageSource))
}
if (request.type.isBlank()) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, "订单类型不能为空", messageSource))
}
val validTypes = listOf("buy", "sell")
if (!validTypes.contains(request.type.lowercase())) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, "订单类型必须是 buy 或 sell", messageSource))
}
val result: Result<MarketGroupedOrdersResponse> = runBlocking {
when (request.type.lowercase()) {
"buy" -> statisticsService.getBuyOrderListGroupedByMarket(request)
"sell" -> statisticsService.getSellOrderListGroupedByMarket(request)
else -> Result.failure(IllegalArgumentException("不支持的订单类型: ${request.type}"))
}
}
result.fold(
onSuccess = { response: MarketGroupedOrdersResponse ->
ResponseEntity.ok(ApiResponse.success(response))
},
onFailure = { e: Throwable ->
logger.error("查询按市场分组的订单列表失败: copyTradingId=${request.copyTradingId}, type=${request.type}", e)
when (e) {
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, e.message, messageSource))
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ORDER_TRACKING_LIST_FETCH_FAILED, e.message, messageSource))
}
}
)
} catch (e: Exception) {
logger.error("查询按市场分组的订单列表异常: copyTradingId=${request.copyTradingId}, type=${request.type}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ORDER_TRACKING_LIST_FETCH_FAILED, e.message, messageSource))
}
}
}
@@ -4,8 +4,10 @@ import com.wrbug.polymarketbot.api.LatestPriceResponse
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.accounts.AccountService
import com.wrbug.polymarketbot.service.common.MarketPriceService
import com.wrbug.polymarketbot.service.common.PolymarketClobService
import kotlinx.coroutines.runBlocking
import java.math.BigDecimal
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
import org.springframework.http.ResponseEntity
@@ -20,14 +22,16 @@ import org.springframework.web.bind.annotation.*
class MarketController(
private val accountService: AccountService,
private val clobService: PolymarketClobService,
private val marketPriceService: MarketPriceService,
private val messageSource: MessageSource
) {
private val logger = LoggerFactory.getLogger(MarketController::class.java)
/**
* 获取市场价格(通过 Gamma API
* 使用 Gamma API 获取价格信息,因为 Gamma API 支持 condition_ids 参数
* 获取市场价格
* 使用 MarketPriceService 获取当前市场价格(支持多数据源降级)
* 返回当前价格,前端接收后自行填充到 bestBid 字段
*/
@PostMapping("/price")
fun getMarketPrice(@RequestBody request: MarketPriceRequest): ResponseEntity<ApiResponse<MarketPriceResponse>> {
@@ -36,16 +40,16 @@ class MarketController(
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_MARKET_ID_EMPTY, messageSource = messageSource))
}
val result = runBlocking { accountService.getMarketPrice(request.marketId, request.outcomeIndex) }
result.fold(
onSuccess = { response ->
ResponseEntity.ok(ApiResponse.success(response))
},
onFailure = { e ->
logger.error("获取市场价格失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_MARKET_PRICE_FETCH_FAILED, e.message, messageSource))
}
val outcomeIndex = request.outcomeIndex ?: 0
val price = runBlocking {
marketPriceService.getCurrentMarketPrice(request.marketId, outcomeIndex)
}
val response = MarketPriceResponse(
marketId = request.marketId,
currentPrice = price.toString()
)
ResponseEntity.ok(ApiResponse.success(response))
} catch (e: Exception) {
logger.error("获取市场价格异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_MARKET_PRICE_FETCH_FAILED, e.message, messageSource))
@@ -7,7 +7,8 @@ data class AccountImportRequest(
val privateKey: String, // 私钥(前端加密后传输)
val walletAddress: String, // 钱包地址(前端从私钥推导,用于验证)
val accountName: String? = null,
val isEnabled: Boolean = true // 是否启用(用于订单推送等功能的开关)
val isEnabled: Boolean = true, // 是否启用(用于订单推送等功能的开关)
val walletType: String = "magic" // 钱包类型:magic(邮箱/OAuth登录)或 safeMetaMask浏览器钱包)
)
/**
@@ -72,6 +73,7 @@ data class AccountDto(
val proxyAddress: String, // Polymarket 代理钱包地址
val accountName: String?,
val isEnabled: Boolean, // 是否启用(用于订单推送等功能的开关)
val walletType: String = "magic", // 钱包类型:magic(邮箱/OAuth登录)或 safeMetaMask浏览器钱包)
val apiKeyConfigured: Boolean, // API Key 是否已配置(不返回实际 Key)
val apiSecretConfigured: Boolean, // API Secret 是否已配置
val apiPassphraseConfigured: Boolean, // API Passphrase 是否已配置
@@ -123,7 +125,8 @@ data class AccountPositionDto(
val proxyAddress: String,
val marketId: String,
val marketTitle: String?,
val marketSlug: String?,
val marketSlug: String?, // 显示用的 slug
val eventSlug: String? = null, // 跳转用的 slug(从 events[0].slug 获取)
val marketIcon: String?, // 市场图标 URL
val side: String, // 结果名称(如 "YES", "NO", "Pakistan" 等)
val outcomeIndex: Int? = null, // 结果索引(0, 1, 2...),用于计算 tokenId
@@ -195,14 +198,11 @@ data class LatestPriceRequest(
)
/**
* 市场价格响应
* 市场当前价格响应
*/
data class MarketPriceResponse(
val marketId: String,
val lastPrice: String?, // 最新成交价
val bestBid: String?, // 最优买价(用于卖出参考)
val bestAsk: String?, // 最优卖价(用于买入参考)
val midpoint: String? // 中间价
val currentPrice: String // 当前价格(通过 MarketPriceService 获取,支持多数据源降级)
)
/**
@@ -0,0 +1,64 @@
package com.wrbug.polymarketbot.dto
import com.google.gson.annotations.SerializedName
/**
* Activity WebSocket 交易消息 DTO
* 根据 Polymarket RTDS Activity WebSocket API 格式定义
*/
data class ActivityTradeMessage(
val topic: String = "", // "activity"
val type: String = "", // "trades"
val timestamp: Long? = null, // 消息时间戳(可选)
@SerializedName("connection_id")
val connectionId: String? = null, // 连接 ID(可选,由服务器返回)
val payload: ActivityTradePayload = ActivityTradePayload() // 交易数据
)
/**
* Activity Trade Payload
*/
data class ActivityTradePayload(
val asset: String = "", // Token ID (用于下单)
@SerializedName("conditionId")
val conditionId: String = "", // Market condition ID
@SerializedName("eventSlug")
val eventSlug: String? = null, // 事件 slug
val slug: String? = null, // 市场 slug
val outcome: String? = null, // 结果方向 (Yes/No/Up/Down)
@SerializedName("outcomeIndex")
val outcomeIndex: Int? = null, // 结果索引 (0=Yes/Up, 1=No/Down) - 优先使用此字段
val side: String = "", // 交易方向 (BUY/SELL)
// price 和 size 可能是数字或字符串,使用 Any 类型,后续转换为 String
val price: Any? = null, // 交易价格
val size: Any? = null, // 交易数量 (shares)
val timestamp: Any? = null, // Unix 时间戳(可能是秒或毫秒,可能是数字或字符串)
@SerializedName("transactionHash")
val transactionHash: String? = null, // 交易哈希
val trader: ActivityTrader? = null, // 交易者信息对象(优先)
@SerializedName("proxyWallet")
val proxyWallet: String? = null, // 交易者地址(fallback,如果 trader 不存在)
val name: String? = null // 交易者名称(fallback,如果 trader 不存在)
)
/**
* Activity Trader 信息
*/
data class ActivityTrader(
val name: String? = null, // 交易者用户名(可选)
val address: String? = null // 交易者钱包地址 ⭐ 关键字段
)
@@ -14,3 +14,10 @@ data class CheckFirstUseResponse(
val isFirstUse: Boolean
)
/**
* WebSocket 票据响应
*/
data class WebSocketTicketResponse(
val ticket: String
)
@@ -37,9 +37,14 @@ data class CopyTradingCreateRequest(
// 最大仓位配置
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
// 关键字过滤配置
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
// 新增配置字段
val configName: String? = null, // 配置名(可选)
val pushFailedOrders: Boolean? = null // 推送失败订单(可选)
val pushFailedOrders: Boolean? = null, // 推送失败订单(可选)
val pushFilteredOrders: Boolean? = null, // 推送已过滤订单(可选)
val maxMarketEndDate: Long? = null // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
)
/**
@@ -71,9 +76,14 @@ data class CopyTradingUpdateRequest(
// 最大仓位配置
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
// 关键字过滤配置
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
// 新增配置字段
val configName: String? = null, // 配置名(可选,但提供时必须非空)
val pushFailedOrders: Boolean? = null // 推送失败订单(可选)
val pushFailedOrders: Boolean? = null, // 推送失败订单(可选)
val pushFilteredOrders: Boolean? = null, // 推送已过滤订单(可选)
val maxMarketEndDate: Long? = null // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
)
/**
@@ -142,9 +152,14 @@ data class CopyTradingDto(
// 最大仓位配置
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
// 关键字过滤配置
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
// 新增配置字段
val configName: String? = null, // 配置名(可选)
val pushFailedOrders: Boolean = false, // 推送失败订单(默认关闭)
val pushFilteredOrders: Boolean = false, // 推送已过滤订单(默认关闭)
val maxMarketEndDate: Long? = null, // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
val createdAt: Long,
val updatedAt: Long
)
@@ -40,6 +40,10 @@ data class BuyOrderInfo(
val orderId: String,
val leaderTradeId: String,
val marketId: String,
val marketTitle: String? = null, // 市场名称
val marketSlug: String? = null, // 市场 slug(用于显示)
val eventSlug: String? = null, // 跳转用的 slug(从 events[0].slug 获取)
val marketCategory: String? = null, // 市场分类(sports, crypto 等)
val side: String,
val quantity: String,
val price: String,
@@ -57,6 +61,10 @@ data class SellOrderInfo(
val orderId: String,
val leaderTradeId: String,
val marketId: String,
val marketTitle: String? = null, // 市场名称
val marketSlug: String? = null, // 市场 slug(用于显示)
val eventSlug: String? = null, // 跳转用的 slug(从 events[0].slug 获取)
val marketCategory: String? = null, // 市场分类(sports, crypto 等)
val side: String,
val quantity: String,
val price: String,
@@ -71,6 +79,11 @@ data class SellOrderInfo(
data class MatchedOrderInfo(
val sellOrderId: String,
val buyOrderId: String,
val marketId: String? = null, // 市场ID(从买入订单获取)
val marketTitle: String? = null, // 市场名称
val marketSlug: String? = null, // 市场 slug(用于显示)
val eventSlug: String? = null, // 跳转用的 slug(从 events[0].slug 获取)
val marketCategory: String? = null, // 市场分类(sports, crypto 等)
val matchedQuantity: String,
val buyPrice: String,
val sellPrice: String,
@@ -97,12 +110,60 @@ data class OrderTrackingRequest(
val page: Int? = 1,
val limit: Int? = 20,
val marketId: String? = null,
val side: String? = null,
val marketTitle: String? = null, // 市场标题关键字筛选
val status: String? = null,
val sellOrderId: String? = null,
val buyOrderId: String? = null
)
/**
* 按市场分组的订单查询请求
*/
data class MarketGroupedOrdersRequest(
val copyTradingId: Long,
val type: String, // buy, sell, matched
val page: Int? = 1,
val limit: Int? = 20,
val marketId: String? = null,
val marketTitle: String? = null
)
/**
* 单个市场的订单统计信息
*/
data class MarketOrderStats(
val count: Long,
val totalAmount: String, // 总金额
val totalPnl: String?, // 总盈亏(买入订单未实现盈亏,此字段为空)
val fullyMatched: Boolean, // 是否全部成交
val fullyMatchedCount: Long, // 完全成交的订单数
val partiallyMatchedCount: Long, // 部分成交的订单数
val filledCount: Long // 未成交的订单数
)
/**
* 单个市场分组的响应数据
*/
data class MarketOrderGroup(
val marketId: String,
val marketTitle: String?,
val marketSlug: String?, // 显示用的 slug
val eventSlug: String? = null, // 跳转用的 slug(从 events[0].slug 获取)
val marketCategory: String?,
val stats: MarketOrderStats,
val orders: List<Any> // BuyOrderInfo, SellOrderInfo 或 MatchedOrderInfo 的列表
)
/**
* 按市场分组的订单列表响应
*/
data class MarketGroupedOrdersResponse(
val list: List<MarketOrderGroup>,
val total: Long, // 市场总数
val page: Int,
val limit: Int
)
/**
* 统计查询请求
*/
@@ -23,7 +23,8 @@ data class TemplateCreateRequest(
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
val maxSpread: 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 maxSpread: 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 maxSpread: 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 minPrice: String?, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价
val pushFilteredOrders: Boolean, // 推送已过滤订单(默认关闭)
val createdAt: Long,
val updatedAt: Long
)
@@ -25,7 +25,8 @@ data class MarketDto(
val liquidityNum: Double?,
val bestBid: Double?,
val bestAsk: Double?,
val lastTradePrice: Double?
val lastTradePrice: Double?,
val events: List<MarketDto>? = null // 事件列表(从 events[0] 获取 slug
)
/**
@@ -37,9 +38,9 @@ data class OutcomeDto(
)
/**
* 事件 DTO
* 事件 DTO(用于其他 API 调用)
*/
data class EventDto(
data class EventListDto(
val id: String,
val title: String,
val category: String,
@@ -55,7 +56,7 @@ data class SeriesDto(
val id: String,
val title: String,
val category: String,
val events: List<EventDto>?,
val events: List<EventListDto>?,
val createdAt: Long? // 时间戳(毫秒)
)
@@ -70,4 +71,3 @@ data class CommentDto(
val createdAt: Long, // 时间戳(毫秒)
val user: String?
)
@@ -40,6 +40,9 @@ data class Account(
@Column(name = "is_enabled", nullable = false)
val isEnabled: Boolean = true, // 是否启用(用于订单推送等功能的开关)
@Column(name = "wallet_type", nullable = false, length = 20)
val walletType: String = "magic", // 钱包类型:magic(邮箱/OAuth登录)或 safeMetaMask浏览器钱包)
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@@ -8,12 +8,7 @@ import com.wrbug.polymarketbot.util.toSafeBigDecimal
* 跟单配置实体(独立配置,不再绑定模板)
*/
@Entity
@Table(
name = "copy_trading",
uniqueConstraints = [
UniqueConstraint(columnNames = ["account_id", "leader_id"])
]
)
@Table(name = "copy_trading")
data class CopyTrading(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@@ -32,7 +27,7 @@ data class CopyTrading(
@Column(name = "copy_mode", nullable = false, length = 10)
val copyMode: String = "RATIO", // "RATIO" 或 "FIXED"
@Column(name = "copy_ratio", nullable = false, precision = 10, scale = 2)
@Column(name = "copy_ratio", nullable = false, precision = 20, scale = 8)
val copyRatio: BigDecimal = BigDecimal.ONE, // 仅在 copyMode="RATIO" 时生效
@Column(name = "fixed_amount", precision = 20, scale = 8)
@@ -91,6 +86,13 @@ data class CopyTrading(
@Column(name = "max_position_count")
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
// 关键字过滤配置
@Column(name = "keyword_filter_mode", nullable = false, length = 20)
val keywordFilterMode: String = "DISABLED", // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
@Column(name = "keywords", columnDefinition = "JSON")
val keywords: String? = null, // 关键字列表(JSON数组),例如:["NBA", "足球", "NBA总决赛"],当keywordFilterMode为DISABLED时为NULL
// 新增配置字段
@Column(name = "config_name", length = 255)
val configName: String? = null, // 配置名(可选)
@@ -98,6 +100,12 @@ data class CopyTrading(
@Column(name = "push_failed_orders", nullable = false)
val pushFailedOrders: Boolean = false, // 推送失败订单(默认关闭)
@Column(name = "push_filtered_orders", nullable = false)
val pushFilteredOrders: Boolean = false, // 推送已过滤订单(默认关闭)
@Column(name = "max_market_end_date")
val maxMarketEndDate: Long? = null, // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@@ -20,7 +20,7 @@ data class CopyTradingTemplate(
@Column(name = "copy_mode", nullable = false, length = 10)
val copyMode: String = "RATIO", // "RATIO" 或 "FIXED"
@Column(name = "copy_ratio", nullable = false, precision = 10, scale = 2)
@Column(name = "copy_ratio", nullable = false, precision = 20, scale = 8)
val copyRatio: BigDecimal = BigDecimal.ONE, // 仅在 copyMode="RATIO" 时生效
@Column(name = "fixed_amount", precision = 20, scale = 8)
@@ -72,6 +72,9 @@ data class CopyTradingTemplate(
@Column(name = "max_price", precision = 20, scale = 8)
val maxPrice: BigDecimal? = null, // 最高价格(可选),NULL表示不限制最高价
@Column(name = "push_filtered_orders", nullable = false)
val pushFilteredOrders: Boolean = false, // 推送已过滤订单(默认关闭)
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@@ -1,55 +0,0 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
/**
* 失败交易实体
* 记录处理失败的交易信息
*/
@Entity
@Table(name = "failed_trade")
data class FailedTrade(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "leader_id", nullable = false)
val leaderId: Long,
@Column(name = "leader_trade_id", nullable = false, length = 100)
val leaderTradeId: String, // Leader 的交易ID
@Column(name = "trade_type", nullable = false, length = 10)
val tradeType: String, // BUY 或 SELL
@Column(name = "copy_trading_id", nullable = false)
val copyTradingId: Long,
@Column(name = "account_id", nullable = false)
val accountId: Long,
@Column(name = "market_id", nullable = false, length = 100)
val marketId: String,
@Column(name = "side", nullable = false, length = 10)
val side: String, // YES/NO
@Column(name = "price", nullable = false, length = 50)
val price: String, // 价格(字符串格式)
@Column(name = "size", nullable = false, length = 50)
val size: String, // 数量(字符串格式)
@Column(name = "error_message", columnDefinition = "TEXT")
val errorMessage: String? = null, // 错误信息
@Column(name = "retry_count", nullable = false)
val retryCount: Int = 0, // 重试次数
@Column(name = "failed_at", nullable = false)
val failedAt: Long = System.currentTimeMillis(),
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis()
)
@@ -0,0 +1,60 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
/**
* 市场信息实体
* 用于缓存市场的基本信息(名称、slug等)
*/
@Entity
@Table(name = "markets", indexes = [
Index(name = "idx_market_id", columnList = "market_id", unique = true)
])
data class Market(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "market_id", unique = true, nullable = false, length = 100)
val marketId: String, // 市场IDcondition ID
@Column(name = "title", nullable = false, length = 500)
val title: String, // 市场名称(question
@Column(name = "slug", length = 200)
val slug: String? = null, // 市场slug(用于显示)
@Column(name = "event_slug", length = 200)
val eventSlug: String? = null, // 跳转用的 slug(从 events[0].slug 获取)
@Column(name = "category", length = 50)
val category: String? = null, // 市场分类
@Column(name = "icon", length = 500)
val icon: String? = null, // 市场图标URL
@Column(name = "image", length = 500)
val image: String? = null, // 市场图片URL
@Column(name = "description", columnDefinition = "TEXT")
val description: String? = null, // 市场描述
@Column(name = "active", nullable = false)
val active: Boolean = true, // 是否活跃
@Column(name = "closed", nullable = false)
val closed: Boolean = false, // 是否已关闭
@Column(name = "archived", nullable = false)
val archived: Boolean = false, // 是否已归档
@Column(name = "end_date")
val endDate: Long? = null, // 市场截止时间(毫秒时间戳)
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@Column(name = "updated_at", nullable = false)
var updatedAt: Long = System.currentTimeMillis()
)
@@ -55,5 +55,52 @@ interface CopyOrderTrackingRepository : JpaRepository<CopyOrderTracking, Long> {
* 查询未发送通知的买入订单(用于轮询更新)
*/
fun findByNotificationSentFalse(): List<CopyOrderTracking>
/**
* 查询指定时间之前创建的订单(用于检查30秒后未成交的订单)
*/
@Query("SELECT t FROM CopyOrderTracking t WHERE t.createdAt <= :beforeTime")
fun findByCreatedAtBefore(beforeTime: Long): List<CopyOrderTracking>
/**
* 查询指定时间之前创建且状态不为指定状态的订单
*/
fun findByCreatedAtBeforeAndStatusNot(beforeTime: Long, status: String): List<CopyOrderTracking>
/**
* 查询指定跟单配置下的活跃仓位数量
* 活跃仓位定义为 remainingQuantity > 0 的不同 (marketId, outcomeIndex) 组合
*/
@Query("SELECT COUNT(DISTINCT CONCAT(t.marketId, '_', COALESCE(t.outcomeIndex, -1))) FROM CopyOrderTracking t WHERE t.copyTradingId = :copyTradingId AND t.remainingQuantity > 0")
fun countActivePositions(copyTradingId: Long): Int
/**
* 检查指定市场是否存在活跃仓位
*/
fun existsByCopyTradingIdAndMarketIdAndRemainingQuantityGreaterThan(copyTradingId: Long, marketId: String, remainingQuantity: BigDecimal): Boolean
/**
* 计算指定跟单配置和市场下的当前持仓总价值 (成本价计算)
*/
@Query("SELECT SUM(t.remainingQuantity * t.price) FROM CopyOrderTracking t WHERE t.copyTradingId = :copyTradingId AND t.marketId = :marketId AND t.remainingQuantity > 0")
fun sumCurrentPositionValueByMarket(copyTradingId: Long, marketId: String): BigDecimal?
/**
* 查询指定跟单配置下,创建时间超过指定时间点的未匹配订单(FIFO顺序)
* 用于避免刚创建的订单被误判为已卖出
*
* @param copyTradingId 跟单配置ID
* @param marketId 市场ID
* @param outcomeIndex 结果索引
* @param thresholdTime 时间阈值(毫秒时间戳),只查询创建时间小于该值的订单
* @return 未匹配订单列表(按创建时间升序排列)
*/
@Query("SELECT t FROM CopyOrderTracking t WHERE t.copyTradingId = :copyTradingId AND t.marketId = :marketId AND t.outcomeIndex = :outcomeIndex AND t.remainingQuantity > 0 AND t.createdAt < :thresholdTime ORDER BY t.createdAt ASC")
fun findUnmatchedBuyOrdersByOutcomeIndexOlderThan(
copyTradingId: Long,
marketId: String,
outcomeIndex: Int,
thresholdTime: Long
): List<CopyOrderTracking>
}
@@ -21,12 +21,12 @@ interface CopyTradingRepository : JpaRepository<CopyTrading, Long> {
fun findByLeaderId(leaderId: Long): List<CopyTrading>
/**
* 根据账户ID和Leader ID查找跟单
* 根据账户ID和Leader ID查找跟单列表
*/
fun findByAccountIdAndLeaderId(
accountId: Long,
leaderId: Long
): CopyTrading?
): List<CopyTrading>
/**
* 查找所有启用的跟单
@@ -1,23 +0,0 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.FailedTrade
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
/**
* 失败交易Repository
*/
@Repository
interface FailedTradeRepository : JpaRepository<FailedTrade, Long> {
/**
* 根据Leader ID和交易ID查询
*/
fun findByLeaderIdAndLeaderTradeId(leaderId: Long, leaderTradeId: String): FailedTrade?
/**
* 检查是否存在失败的交易
*/
fun existsByLeaderIdAndLeaderTradeId(leaderId: Long, leaderTradeId: String): Boolean
}
@@ -0,0 +1,12 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.Market
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface MarketRepository : JpaRepository<Market, Long> {
fun findByMarketId(marketId: String): Market?
fun findByMarketIdIn(marketIds: List<String>): List<Market>
}
@@ -8,8 +8,10 @@ import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import com.wrbug.polymarketbot.util.eq
import com.wrbug.polymarketbot.util.JsonUtils
import com.wrbug.polymarketbot.util.getEventSlug
import com.wrbug.polymarketbot.service.common.PolymarketClobService
import com.wrbug.polymarketbot.service.common.BlockchainService
import com.wrbug.polymarketbot.service.common.MarketService
import com.wrbug.polymarketbot.service.common.PolymarketApiKeyService
import com.wrbug.polymarketbot.service.copytrading.orders.OrderPushService
import com.wrbug.polymarketbot.service.copytrading.orders.OrderSigningService
@@ -36,8 +38,10 @@ class AccountService(
private val orderPushService: OrderPushService,
private val orderSigningService: OrderSigningService,
private val cryptoUtils: CryptoUtils,
private val marketService: MarketService, // 市场信息服务
private val telegramNotificationService: TelegramNotificationService? = null, // 可选,避免循环依赖
private val relayClientService: RelayClientService
private val relayClientService: RelayClientService,
private val jsonUtils: JsonUtils
) {
private val logger = LoggerFactory.getLogger(AccountService::class.java)
@@ -98,8 +102,9 @@ class AccountService(
}
// 5. 获取代理地址(必须成功,否则导入失败)
// 根据用户选择的钱包类型计算代理地址
val proxyAddress = runBlocking {
val proxyResult = blockchainService.getProxyAddress(request.walletAddress)
val proxyResult = blockchainService.getProxyAddress(request.walletAddress, request.walletType)
if (proxyResult.isSuccess) {
val address = proxyResult.getOrNull()
if (address != null) {
@@ -149,6 +154,7 @@ class AccountService(
accountName = accountName,
isDefault = false, // 不再支持默认账户
isEnabled = request.isEnabled,
walletType = request.walletType, // 保存钱包类型
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis()
)
@@ -225,11 +231,12 @@ class AccountService(
/**
* 查询账户列表
* 列表接口只返回基本信息,不查询统计信息(统计信息只在详情接口中查询)
*/
fun getAccountList(): Result<AccountListResponse> {
return try {
val accounts = accountRepository.findAllByOrderByCreatedAtAsc()
val accountDtos = accounts.map { toDto(it) }
val accountDtos = accounts.map { toBasicDto(it) }
Result.success(
AccountListResponse(
@@ -350,7 +357,30 @@ class AccountService(
}
/**
* 转换为 DTO
* 转换为基础 DTO(列表使用,不包含统计信息)
* 列表接口只返回基本信息,不查询统计信息,以提高性能
*/
private fun toBasicDto(account: Account): AccountDto {
return AccountDto(
id = account.id!!,
walletAddress = account.walletAddress,
proxyAddress = account.proxyAddress,
accountName = account.accountName,
isEnabled = account.isEnabled,
walletType = account.walletType,
apiKeyConfigured = account.apiKey != null,
apiSecretConfigured = account.apiSecret != null,
apiPassphraseConfigured = account.apiPassphrase != null,
totalOrders = null,
totalPnl = null,
activeOrders = null,
completedOrders = null,
positionCount = null
)
}
/**
* 转换为完整 DTO(详情使用,包含交易统计数据)
* 包含交易统计数据(总订单数、总盈亏、活跃订单数、已完成订单数、持仓数量)
*/
private fun toDto(account: Account): AccountDto {
@@ -362,6 +392,7 @@ class AccountService(
proxyAddress = account.proxyAddress,
accountName = account.accountName,
isEnabled = account.isEnabled,
walletType = account.walletType,
apiKeyConfigured = account.apiKey != null,
apiSecretConfigured = account.apiSecret != null,
apiPassphraseConfigured = account.apiPassphrase != null,
@@ -628,7 +659,8 @@ class AccountService(
proxyAddress = account.proxyAddress,
marketId = pos.conditionId ?: "",
marketTitle = pos.title ?: "",
marketSlug = pos.slug ?: "",
marketSlug = pos.slug ?: "", // 显示用的 slug
eventSlug = pos.eventSlug, // 跳转用的 slug(从 events[0].slug 获取)
marketIcon = pos.icon, // 市场图标
side = pos.outcome ?: "",
outcomeIndex = pos.outcomeIndex, // 添加 outcomeIndex
@@ -835,6 +867,15 @@ class AccountService(
// 7. 解密私钥
val decryptedPrivateKey = decryptPrivateKey(account)
// 获取费率(根据 Polymarket Maker Rebates Program 要求)
val feeRateResult = clobService.getFeeRate(tokenId)
val feeRateBps = if (feeRateResult.isSuccess) {
feeRateResult.getOrNull()?.toString() ?: "0"
} else {
logger.warn("获取费率失败,使用默认值 0: tokenId=$tokenId, error=${feeRateResult.exceptionOrNull()?.message}")
"0"
}
// 11. 创建并签名订单(使用计算后的卖出数量)
val signedOrder = try {
orderSigningService.createAndSignOrder(
@@ -846,7 +887,7 @@ class AccountService(
size = sellQuantity.toPlainString(), // 使用计算后的卖出数量
signatureType = 2, // Browser Wallet(与正确订单数据一致)
nonce = "0",
feeRateBps = "0",
feeRateBps = feeRateBps, // 使用动态获取的费率
expiration = expiration
)
} catch (e: Exception) {
@@ -896,31 +937,20 @@ class AccountService(
notificationScope.launch {
try {
// 获取市场信息(标题和slug
val marketInfo = withContext(Dispatchers.IO) {
try {
val gammaApi = retrofitFactory.createGammaApi()
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(request.marketId))
if (marketResponse.isSuccessful && marketResponse.body() != null) {
marketResponse.body()!!.firstOrNull()
} else {
null
}
} catch (e: Exception) {
logger.warn("获取市场信息失败: ${e.message}", e)
null
}
}
val marketTitle = marketInfo?.question ?: request.marketId
val marketSlug = marketInfo?.slug
val market = marketService.getMarket(request.marketId)
val marketTitle = market?.title ?: request.marketId
val marketSlug = market?.eventSlug // 跳转用的 slug
// 获取当前语言设置(从 LocaleContextHolder
val locale = try {
org.springframework.context.i18n.LocaleContextHolder.getLocale()
} catch (e: Exception) {
java.util.Locale("zh", "CN") // 默认简体中文
}
// 使用当前时间作为订单创建时间
val orderTime = System.currentTimeMillis()
telegramNotificationService?.sendOrderSuccessNotification(
orderId = orderId,
marketTitle = marketTitle,
@@ -936,7 +966,8 @@ class AccountService(
apiSecret = try { cryptoUtils.decrypt(account.apiSecret!!) } catch (e: Exception) { null },
apiPassphrase = try { cryptoUtils.decrypt(account.apiPassphrase!!) } catch (e: Exception) { null },
walletAddressForApi = account.walletAddress,
locale = locale
locale = locale,
orderTime = orderTime // 使用订单创建时间
)
} catch (e: Exception) {
logger.warn("发送订单成功通知失败: ${e.message}", e)
@@ -964,31 +995,17 @@ class AccountService(
notificationScope.launch {
try {
// 获取市场信息(标题和slug
val marketInfo = withContext(Dispatchers.IO) {
try {
val gammaApi = retrofitFactory.createGammaApi()
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(request.marketId))
if (marketResponse.isSuccessful && marketResponse.body() != null) {
marketResponse.body()!!.firstOrNull()
} else {
null
}
} catch (e: Exception) {
logger.warn("获取市场信息失败: ${e.message}", e)
null
}
}
val marketTitle = marketInfo?.question ?: request.marketId
val marketSlug = marketInfo?.slug
val market = marketService.getMarket(request.marketId)
val marketTitle = market?.title ?: request.marketId
val marketSlug = market?.eventSlug // 跳转用的 slug
// 获取当前语言设置(从 LocaleContextHolder
val locale = try {
org.springframework.context.i18n.LocaleContextHolder.getLocale()
} catch (e: Exception) {
java.util.Locale("zh", "CN") // 默认简体中文
}
telegramNotificationService?.sendOrderFailureNotification(
marketTitle = marketTitle,
marketId = request.marketId,
@@ -1022,34 +1039,20 @@ class AccountService(
notificationScope.launch {
try {
// 获取市场信息(标题和slug
val marketInfo = withContext(Dispatchers.IO) {
try {
val gammaApi = retrofitFactory.createGammaApi()
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(request.marketId))
if (marketResponse.isSuccessful && marketResponse.body() != null) {
marketResponse.body()!!.firstOrNull()
} else {
null
}
} catch (e: Exception) {
logger.warn("获取市场信息失败: ${e.message}", e)
null
}
}
val marketTitle = marketInfo?.question ?: request.marketId
val marketSlug = marketInfo?.slug
val market = marketService.getMarket(request.marketId)
val marketTitle = market?.title ?: request.marketId
val marketSlug = market?.eventSlug // 跳转用的 slug
// 获取当前语言设置(从 LocaleContextHolder
val locale = try {
org.springframework.context.i18n.LocaleContextHolder.getLocale()
} catch (e: Exception) {
java.util.Locale("zh", "CN") // 默认简体中文
}
// 只传递后端返回的 msg,不传递完整堆栈
val errorMsg = orderResponse.body()?.errorMsg ?: "创建订单失败"
telegramNotificationService?.sendOrderFailureNotification(
marketTitle = marketTitle,
marketId = request.marketId,
@@ -1121,7 +1124,7 @@ class AccountService(
// 如果目标 outcome 不是第一个(index != 0),需要转换价格
// 对于二元市场:第二个 outcome 的价格 = 1 - 第一个 outcome 的价格
if (outcomeIndex != null && outcomeIndex > 0) {
val outcomes = JsonUtils.parseStringArray(market.outcomes)
val outcomes = jsonUtils.parseStringArray(market.outcomes)
// 只对二元市场进行价格转换
if (outcomes.size == 2) {
// 保存原始第一个 outcome 的价格
@@ -1153,13 +1156,13 @@ class AccountService(
null
}
// 优先使用 lastPrice(最近成交价),如果没有则使用 bestBid,最后使用 midpoint
val currentPrice = lastPrice ?: bestBid ?: midpoint ?: "0"
Result.success(
MarketPriceResponse(
marketId = marketId,
lastPrice = lastPrice,
bestBid = bestBid,
bestAsk = bestAsk,
midpoint = midpoint
currentPrice = currentPrice
)
)
} else {
@@ -21,12 +21,9 @@ import org.springframework.context.i18n.LocaleContextHolder
import com.wrbug.polymarketbot.service.system.SystemConfigService
import com.wrbug.polymarketbot.service.system.RelayClientService
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.JsonUtils
import com.wrbug.polymarketbot.service.common.BlockchainService
import com.wrbug.polymarketbot.service.common.MarketPriceService
import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.math.BigInteger
import java.util.concurrent.ConcurrentHashMap
/**
@@ -47,8 +44,7 @@ class PositionCheckService(
private val telegramNotificationService: TelegramNotificationService?,
private val accountRepository: AccountRepository,
private val messageSource: MessageSource,
private val retrofitFactory: RetrofitFactory,
private val blockchainService: BlockchainService
private val marketPriceService: MarketPriceService
) {
private val logger = LoggerFactory.getLogger(PositionCheckService::class.java)
@@ -66,6 +62,19 @@ class PositionCheckService(
// 记录已发送提示的配置(避免重复推送)
private val notifiedConfigs = ConcurrentHashMap<Long, Long>() // accountId/copyTradingId -> lastNotificationTime
// 待检查的仓位记录(延迟检测机制)
// key: "accountId_marketId_outcomeIndex_copyTradingId"
// value: PendingPositionCheck(包含订单列表和首次检测时间)
private data class PendingPositionCheck(
val accountId: Long,
val marketId: String,
val outcomeIndex: Int,
val copyTradingId: Long,
val orders: List<CopyOrderTracking>,
val firstDetectedTime: Long // 首次检测到仓位不存在的时间
)
private val pendingPositionChecks = ConcurrentHashMap<String, PendingPositionCheck>()
// 同步锁,确保订阅任务的启动和停止是线程安全的
private val lock = Any()
@@ -77,6 +86,7 @@ class PositionCheckService(
logger.info("PositionCheckService 初始化,订阅仓位轮训事件")
startSubscription()
startCacheCleanup()
startPendingPositionCheckTask()
}
/**
@@ -136,6 +146,121 @@ class PositionCheckService(
}
}
/**
* 启动待检查仓位的定期检查任务
* 每30秒检查一次,如果超过3分钟且确实不存在,则标记为已卖出
*/
private fun startPendingPositionCheckTask() {
scope.launch(Dispatchers.IO) {
while (isActive) {
try {
delay(30000) // 每30秒检查一次
checkPendingPositions()
} catch (e: Exception) {
logger.error("检查待检查仓位异常: ${e.message}", e)
}
}
}
}
/**
* 检查待检查的仓位
* 如果超过3分钟且确实不存在,则标记为已卖出
* 如果存在,则删除记录
*/
private suspend fun checkPendingPositions() {
if (pendingPositionChecks.isEmpty()) {
return
}
try {
// 获取最新的仓位数据
val result = accountService.getAllPositions()
if (result.isFailure) {
logger.warn("获取仓位数据失败,跳过待检查仓位验证: ${result.exceptionOrNull()?.message}")
return
}
val positionListResponse = result.getOrNull() ?: return
val currentPositions = positionListResponse.currentPositions
// 按账户和市场分组当前仓位
val positionsByAccountAndMarket = currentPositions.groupBy {
"${it.accountId}_${it.marketId}_${it.outcomeIndex ?: 0}"
}
val now = System.currentTimeMillis()
val checkDelay = 180000L // 3分钟 = 180000毫秒
val toRemove = mutableListOf<String>()
val toMarkAsSold = mutableListOf<PendingPositionCheck>()
// 遍历所有待检查的仓位
for ((key, pendingCheck) in pendingPositionChecks) {
// 先过滤出仍然有效的订单(remainingQuantity > 0
val validOrders = pendingCheck.orders.filter { order ->
// 重新从数据库查询订单状态,确保数据是最新的
val currentOrder = copyOrderTrackingRepository.findById(order.id!!).orElse(null)
currentOrder != null && currentOrder.remainingQuantity > BigDecimal.ZERO
}
// 如果没有有效订单了,删除记录
if (validOrders.isEmpty()) {
toRemove.add(key)
logger.info("待检查仓位的订单已全部处理,删除记录: marketId=${pendingCheck.marketId}, outcomeIndex=${pendingCheck.outcomeIndex}, accountId=${pendingCheck.accountId}, copyTradingId=${pendingCheck.copyTradingId}")
continue
}
val positionKey = "${pendingCheck.accountId}_${pendingCheck.marketId}_${pendingCheck.outcomeIndex}"
val position = positionsByAccountAndMarket[positionKey]?.firstOrNull()
if (position != null) {
// 仓位存在,删除记录
toRemove.add(key)
logger.info("待检查仓位已恢复,删除记录: marketId=${pendingCheck.marketId}, outcomeIndex=${pendingCheck.outcomeIndex}, accountId=${pendingCheck.accountId}, copyTradingId=${pendingCheck.copyTradingId}, elapsedTime=${now - pendingCheck.firstDetectedTime}ms")
} else {
// 仓位不存在,检查是否超过3分钟
val elapsedTime = now - pendingCheck.firstDetectedTime
if (elapsedTime >= checkDelay) {
// 超过3分钟且确实不存在,标记为已卖出(使用有效订单)
toMarkAsSold.add(pendingCheck.copy(orders = validOrders))
toRemove.add(key)
logger.info("待检查仓位超过3分钟仍不存在,标记为已卖出: marketId=${pendingCheck.marketId}, outcomeIndex=${pendingCheck.outcomeIndex}, accountId=${pendingCheck.accountId}, copyTradingId=${pendingCheck.copyTradingId}, elapsedTime=${elapsedTime}ms, validOrderCount=${validOrders.size}, originalOrderCount=${pendingCheck.orders.size}")
} else {
// 未超过3分钟,更新订单列表(移除已处理的订单)
if (validOrders.size < pendingCheck.orders.size) {
pendingPositionChecks[key] = pendingCheck.copy(orders = validOrders)
logger.debug("更新待检查仓位记录,移除已处理的订单: marketId=${pendingCheck.marketId}, outcomeIndex=${pendingCheck.outcomeIndex}, validOrderCount=${validOrders.size}, originalOrderCount=${pendingCheck.orders.size}")
}
logger.debug("待检查仓位仍不存在,继续等待: marketId=${pendingCheck.marketId}, outcomeIndex=${pendingCheck.outcomeIndex}, accountId=${pendingCheck.accountId}, copyTradingId=${pendingCheck.copyTradingId}, elapsedTime=${elapsedTime}ms, remainingTime=${checkDelay - elapsedTime}ms")
}
}
}
// 删除已恢复或已处理的记录
toRemove.forEach { key ->
pendingPositionChecks.remove(key)
}
// 标记为已卖出
for (pendingCheck in toMarkAsSold) {
try {
val currentPrice = getCurrentMarketPrice(pendingCheck.marketId, pendingCheck.outcomeIndex)
updateOrdersAsSold(
pendingCheck.orders,
currentPrice,
pendingCheck.copyTradingId,
pendingCheck.marketId,
pendingCheck.outcomeIndex
)
} catch (e: Exception) {
logger.error("标记待检查仓位为已卖出失败: marketId=${pendingCheck.marketId}, outcomeIndex=${pendingCheck.outcomeIndex}, error=${e.message}", e)
}
}
} catch (e: Exception) {
logger.error("检查待检查仓位异常: ${e.message}", e)
}
}
/**
* 清理过期的缓存条目(超过2小时的记录)
*/
@@ -167,8 +292,16 @@ class PositionCheckService(
notifiedConfigs.remove(key)
}
if (expiredPositions.isNotEmpty() || expiredProcessed.isNotEmpty() || expiredConfigs.isNotEmpty()) {
logger.debug("清理过期缓存: positions=${expiredPositions.size}, processed=${expiredProcessed.size}, configs=${expiredConfigs.size}")
// 清理过期的待检查仓位记录(超过1小时的记录,正常情况下应该在3分钟内处理完)
val expiredPendingChecks = pendingPositionChecks.entries.filter { (_, check) ->
(now - check.firstDetectedTime) > 3600000 // 1小时
}
expiredPendingChecks.forEach { (key, _) ->
pendingPositionChecks.remove(key)
}
if (expiredPositions.isNotEmpty() || expiredProcessed.isNotEmpty() || expiredConfigs.isNotEmpty() || expiredPendingChecks.isNotEmpty()) {
logger.debug("清理过期缓存: positions=${expiredPositions.size}, processed=${expiredProcessed.size}, configs=${expiredConfigs.size}, pendingChecks=${expiredPendingChecks.size}")
}
}
@@ -375,51 +508,100 @@ class PositionCheckService(
val position = positionsByAccountAndMarket[positionKey]?.firstOrNull()
if (position == null) {
// 仓位不存在,检查订单创建时间
// 只有当订单创建时间超过2分钟时,才认为仓位被出售了
// 这样可以避免刚创建的订单因为API延迟而被误判为已卖出
// 仓位不存在,使用延迟检测机制
// 先查询创建时间超过2分钟的未匹配订单(SQL层过滤,避免刚创建的订单被误判)
val now = System.currentTimeMillis()
val ordersToMarkAsSold = orders.filter { order ->
val orderAge = now - order.createdAt
orderAge > 120000 // 2分钟 = 120000毫秒
}
if (ordersToMarkAsSold.isNotEmpty()) {
// 有订单创建时间超过2分钟,认为仓位已被出售
val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex)
updateOrdersAsSold(ordersToMarkAsSold, currentPrice, copyTrading.id, marketId, outcomeIndex)
logger.debug("仓位不存在且订单创建时间超过2分钟,标记为已卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, orderCount=${ordersToMarkAsSold.size}")
val thresholdTime = now - 120000 // 2分钟 = 120000毫秒
val ordersToCheck = copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndexOlderThan(
copyTradingId = copyTrading.id!!,
marketId = marketId,
outcomeIndex = outcomeIndex,
thresholdTime = thresholdTime
)
if (ordersToCheck.isNotEmpty()) {
// 有订单创建时间超过2分钟,记录到待检查列表
val checkKey = "${copyTrading.accountId}_${marketId}_${outcomeIndex}_${copyTrading.id}"
// 如果已经存在记录,更新订单列表(可能订单状态有变化)
val existingCheck = pendingPositionChecks[checkKey]
if (existingCheck == null) {
// 首次检测到,记录
pendingPositionChecks[checkKey] = PendingPositionCheck(
accountId = copyTrading.accountId,
marketId = marketId,
outcomeIndex = outcomeIndex,
copyTradingId = copyTrading.id!!,
orders = ordersToCheck,
firstDetectedTime = now
)
logger.info("首次检测到仓位不存在,记录待检查: marketId=$marketId, outcomeIndex=$outcomeIndex, accountId=${copyTrading.accountId}, copyTradingId=${copyTrading.id}, orderCount=${ordersToCheck.size}, positionKey=$positionKey")
} else {
// 已存在记录,更新订单列表(可能订单状态有变化)
pendingPositionChecks[checkKey] = existingCheck.copy(orders = ordersToCheck)
logger.debug("更新待检查仓位记录: marketId=$marketId, outcomeIndex=$outcomeIndex, accountId=${copyTrading.accountId}, copyTradingId=${copyTrading.id}, orderCount=${ordersToCheck.size}, elapsedTime=${now - existingCheck.firstDetectedTime}ms")
}
} else {
// 订单创建时间不足2分钟,可能是刚创建的订单,暂时不处理
logger.debug("仓位不存在但订单创建时间不足2分钟,暂不标记为已卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, orderCount=${orders.size}, oldestOrderAge=${orders.minOfOrNull { now - it.createdAt }?.let { "${it}ms" } ?: "N/A"}")
logger.debug("仓位不存在但无符合条件的订单创建时间不足2分钟,暂不标记为已卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, orderCount=${orders.size}, thresholdTime=$thresholdTime, positionKey=$positionKey")
}
} else {
// 有仓位,先检查是否有对应的待检查记录,如果有则删除(仓位已恢复)
val checkKey = "${copyTrading.accountId}_${marketId}_${outcomeIndex}_${copyTrading.id}"
val pendingCheck = pendingPositionChecks.remove(checkKey)
if (pendingCheck != null) {
logger.info("待检查仓位已恢复,删除待检查记录: marketId=$marketId, outcomeIndex=$outcomeIndex, accountId=${copyTrading.accountId}, copyTradingId=${copyTrading.id}, elapsedTime=${System.currentTimeMillis() - pendingCheck.firstDetectedTime}ms")
}
// 有仓位,按订单下单顺序(FIFO)更新状态
// 先查询创建时间超过2分钟的未匹配订单(SQL层过滤,避免刚创建的订单被误判)
val now = System.currentTimeMillis()
val thresholdTime = now - 120000 // 2分钟 = 120000毫秒
val validOrders = copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndexOlderThan(
copyTradingId = copyTrading.id!!,
marketId = marketId,
outcomeIndex = outcomeIndex,
thresholdTime = thresholdTime
)
// 如果没有符合条件的订单,跳过处理
if (validOrders.isEmpty()) {
logger.debug("仓位存在但无符合条件的订单(创建时间不足2分钟),暂不进行FIFO匹配: marketId=$marketId, outcomeIndex=$outcomeIndex, thresholdTime=$thresholdTime")
continue
}
// 计算逻辑:
// 1. 总订单数量 = 所有未卖出订单的剩余数量总和
// 1. 总订单数量 = 所有符合条件的未卖出订单的剩余数量总和
// 2. 已成交数量 = 总订单数量 - 仓位数量(因为还有仓位,说明部分订单已卖出)
// 3. 如果已成交数量 = 0,说明订单还没有卖出,不修改订单状态
// 4. 如果已成交数量 > 0,按FIFO顺序匹配订单
val positionQuantity = position.quantity.toSafeBigDecimal()
// 计算总订单数量
val totalOrderQuantity = orders.fold(BigDecimal.ZERO) { sum, order ->
// 计算总订单数量(只计算符合条件的订单)
val totalOrderQuantity = validOrders.fold(BigDecimal.ZERO) { sum, order ->
sum.add(order.remainingQuantity.toSafeBigDecimal())
}
// 计算已成交数量
val soldQuantity = totalOrderQuantity.subtract(positionQuantity)
// 如果已成交数量 <= 0,说明订单还没有卖出,不修改订单状态
if (soldQuantity <= BigDecimal.ZERO) {
logger.debug("仓位数量 >= 订单数量总和,订单尚未卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, positionQuantity=$positionQuantity, totalOrderQuantity=$totalOrderQuantity")
continue
}
// 如果已成交数量 > 0,按FIFO顺序匹配订单
// 如果已成交数量 > 0,按FIFO顺序匹配订单(只匹配符合条件的订单)
try {
val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex)
updateOrdersAsSoldByFIFO(orders, soldQuantity, currentPrice,
updateOrdersAsSoldByFIFO(validOrders, soldQuantity, currentPrice,
copyTrading.id, marketId, outcomeIndex)
} catch (e: Exception) {
logger.warn("无法获取市场价格,跳过FIFO匹配: marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}")
// 无法获取价格时,跳过该市场的处理,等待下次检查时再试
continue
}
}
}
}
@@ -430,200 +612,10 @@ class PositionCheckService(
/**
* 获取当前市场最新价(用于更新订单卖出价)
* 优先使用链上查询获取市场结算结果,如果未结算则使用 API 查询
* 如果市场已关闭:
* - 该 outcome 赢了,返回 1
* - 该 outcome 输了,返回 0
* 委托给 MarketPriceService 处理
*/
private suspend fun getCurrentMarketPrice(marketId: String, outcomeIndex: Int): BigDecimal {
return try {
// 优先从链上查询市场结算结果(实时性高)
val chainResult = blockchainService.getCondition(marketId)
chainResult.fold(
onSuccess = { (payoutDenominator, payouts) ->
// 如果 payouts 不为空,说明市场已结算
if (payouts.isNotEmpty() && outcomeIndex < payouts.size) {
val payout = payouts[outcomeIndex]
when {
payout > BigInteger.ZERO -> {
// payout > 0 表示赢了
logger.info("从链上查询到市场已结算,该 outcome 赢了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
return BigDecimal.ONE
}
payout == BigInteger.ZERO -> {
// payout == 0 表示输了
logger.info("从链上查询到市场已结算,该 outcome 输了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
return BigDecimal.ZERO
}
else -> {
logger.warn("从链上查询到异常的 payout 值: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
}
}
} else {
logger.debug("从链上查询到市场尚未结算: marketId=$marketId, payouts=${payouts.size}")
}
},
onFailure = { e ->
logger.debug("链上查询市场条件失败,降级到 API 查询: marketId=$marketId, error=${e.message}")
}
)
// 链上查询失败或市场未结算,降级到 API 查询
val gammaApi = retrofitFactory.createGammaApi()
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(marketId))
if (marketResponse.isSuccessful && marketResponse.body() != null) {
val markets = marketResponse.body()!!
val market = markets.firstOrNull()
if (market != null) {
// 检查市场是否已结束:1) closed == true 或 2) endDate 已过
val isMarketEnded = checkIfMarketEnded(market)
if (isMarketEnded) {
logger.debug("市场已结束: marketId=$marketId, closed=${market.closed}, endDate=${market.endDate}")
// 市场已结束,检查该 outcome 是赢了还是输了
val outcomeResult = checkOutcomeResult(market, outcomeIndex)
when (outcomeResult) {
OutcomeResult.WON -> {
logger.info("市场已结束且该 outcome 赢了,返回价格为 1: marketId=$marketId, outcomeIndex=$outcomeIndex")
return BigDecimal.ONE
}
OutcomeResult.LOST -> {
logger.info("市场已结束且该 outcome 输了,返回价格为 0: marketId=$marketId, outcomeIndex=$outcomeIndex")
return BigDecimal.ZERO
}
OutcomeResult.UNKNOWN -> {
// 无法判断,记录警告并继续使用正常价格逻辑
logger.warn("市场已结束但无法判断 outcome 结果,使用正常价格: marketId=$marketId, outcomeIndex=$outcomeIndex, closed=${market.closed}, endDate=${market.endDate}, outcomePrices=${market.outcomePrices}, bestBid=${market.bestBid}, bestAsk=${market.bestAsk}")
}
}
}
}
}
// 如果市场未关闭或无法判断输赢,获取正常价格
val priceResult = accountService.getMarketPrice(marketId, outcomeIndex)
val marketPrice = priceResult.getOrNull()
if (marketPrice != null) {
// 优先使用 bestBid(最优买价,用于卖出参考),如果没有则使用 midpoint
val priceStr = marketPrice.bestBid ?: marketPrice.midpoint ?: marketPrice.lastPrice
priceStr?.toSafeBigDecimal() ?: BigDecimal.ZERO
} else {
BigDecimal.ZERO
}
} catch (e: Exception) {
logger.error("获取市场最新价失败: marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}", e)
BigDecimal.ZERO
}
}
/**
* 检查市场是否已结束
* 判断条件:
* 1. closed == true
* 2. 或 endDate 已过(如果 endDate 不为空)
*/
private fun checkIfMarketEnded(market: com.wrbug.polymarketbot.api.MarketResponse): Boolean {
// 1. 检查 closed 字段
if (market.closed == true) {
return true
}
// 2. 检查 endDate 是否已过
val endDateStr = market.endDate
if (endDateStr != null && endDateStr.isNotBlank()) {
try {
// endDate 可能是 ISO 8601 格式字符串或时间戳
val endDate = if (endDateStr.matches(Regex("^\\d+$"))) {
// 时间戳(秒或毫秒)
val timestamp = endDateStr.toLong()
// 判断是秒还是毫秒(如果小于 10^10,认为是秒)
if (timestamp < 10000000000L) {
timestamp * 1000 // 转换为毫秒
} else {
timestamp
}
} else {
// ISO 8601 格式,尝试解析
java.time.Instant.parse(endDateStr).toEpochMilli()
}
val now = System.currentTimeMillis()
if (now >= endDate) {
logger.debug("市场 endDate 已过: marketId=${market.conditionId}, endDate=$endDateStr, now=$now")
return true
}
} catch (e: Exception) {
logger.warn("解析 endDate 失败: marketId=${market.conditionId}, endDate=$endDateStr, error=${e.message}")
}
}
return false
}
/**
* Outcome 结果枚举
*/
private enum class OutcomeResult {
WON, // 赢了
LOST, // 输了
UNKNOWN // 无法判断
}
/**
* 检查该 outcome 的结果(赢了、输了或无法判断)
* @param market 市场信息
* @param outcomeIndex outcome 索引
* @return OutcomeResult
*/
private fun checkOutcomeResult(market: com.wrbug.polymarketbot.api.MarketResponse, outcomeIndex: Int): OutcomeResult {
return try {
// 优先使用 outcomePrices(结算价格数组)
val outcomePrices = market.outcomePrices
if (outcomePrices != null && outcomePrices.isNotBlank()) {
val prices = JsonUtils.parseStringArray(outcomePrices)
if (outcomeIndex < prices.size) {
val price = prices[outcomeIndex].toSafeBigDecimal()
// 如果价格 >= 0.99,认为赢了
if (price >= BigDecimal("0.99")) {
return OutcomeResult.WON
}
// 如果价格 <= 0.01,认为输了
if (price <= BigDecimal("0.01")) {
return OutcomeResult.LOST
}
// 其他情况,无法判断
return OutcomeResult.UNKNOWN
}
}
// 如果没有 outcomePrices,使用 bestBid 和 bestAsk 判断
val bestBid = market.bestBid ?: 0.0
val bestAsk = market.bestAsk ?: 0.0
// 如果目标 outcome 不是第一个(index != 0),需要转换价格
val targetBid = if (outcomeIndex > 0) {
// 第二个 outcome 的 bestBid = 1 - 第一个 outcome 的 bestAsk
BigDecimal.ONE.subtract(BigDecimal.valueOf(bestAsk))
} else {
BigDecimal.valueOf(bestBid)
}
// 如果 bestBid >= 0.99,认为赢了
if (targetBid >= BigDecimal("0.99")) {
return OutcomeResult.WON
}
// 如果 bestBid <= 0.01,认为输了
if (targetBid <= BigDecimal("0.01")) {
return OutcomeResult.LOST
}
// 其他情况,无法判断
OutcomeResult.UNKNOWN
} catch (e: Exception) {
logger.warn("检查 outcome 结果失败: marketId=${market.conditionId}, outcomeIndex=$outcomeIndex, error=${e.message}", e)
OutcomeResult.UNKNOWN
}
return marketPriceService.getCurrentMarketPrice(marketId, outcomeIndex)
}
@@ -31,22 +31,44 @@ class AuthService(
private lateinit var resetPasswordKey: String
/**
* 登录
* 登录(带IP限速保护)
*/
fun login(username: String, password: String): Result<LoginResponse> {
fun login(username: String, password: String, ipAddress: String): Result<LoginResponse> {
return try {
// 检查登录频率限制
rateLimitService.checkLoginRateLimit(ipAddress).fold(
onSuccess = { },
onFailure = { e ->
return Result.failure(IllegalStateException(e.message ?: "登录频率限制"))
}
)
val user = userRepository.findByUsername(username)
?: return Result.failure(IllegalArgumentException(ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR.message))
// 验证密码
if (!passwordEncoder.matches(password, user.password)) {
logger.warn("登录失败:密码错误,username=$username")
if (user == null) {
// 记录失败尝试
val lockoutMsg = rateLimitService.recordLoginFailure(ipAddress)
if (lockoutMsg != null) {
return Result.failure(IllegalStateException(lockoutMsg))
}
return Result.failure(IllegalArgumentException(ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR.message))
}
// 验证密码
if (!passwordEncoder.matches(password, user.password)) {
// 记录失败尝试
val lockoutMsg = rateLimitService.recordLoginFailure(ipAddress)
if (lockoutMsg != null) {
return Result.failure(IllegalStateException(lockoutMsg))
}
return Result.failure(IllegalArgumentException(ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR.message))
}
// 登录成功,清除失败记录
rateLimitService.clearLoginFailures(ipAddress)
// 生成JWT token(包含tokenVersion,用于使修改密码后的旧token失效)
val token = jwtUtils.generateToken(username, user.tokenVersion)
logger.info("用户登录成功:username=$username")
Result.success(LoginResponse(token = token))
} catch (e: Exception) {
@@ -0,0 +1,93 @@
package com.wrbug.polymarketbot.service.auth
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import java.security.SecureRandom
import java.util.concurrent.ConcurrentHashMap
/**
* WebSocket 票据服务
* 用于生成短期有效的一次性票据,避免在 WebSocket URL 中暴露 JWT
*/
@Service
class WebSocketTicketService {
companion object {
// 票据有效期(30秒)
private const val TICKET_VALIDITY_MS = 30_000L
// 票据长度(32字节 = 64个十六进制字符)
private const val TICKET_LENGTH = 32
}
private val secureRandom = SecureRandom()
// 存储票据:ticket -> TicketInfo
private val tickets = ConcurrentHashMap<String, TicketInfo>()
/**
* 票据信息
*/
data class TicketInfo(
val username: String,
val createdAt: Long,
val expiresAt: Long
)
/**
* 为用户生成 WebSocket 连接票据
* @param username 用户名
* @return 一次性票据
*/
fun generateTicket(username: String): String {
// 清理过期票据
cleanupExpiredTickets()
// 生成随机票据
val bytes = ByteArray(TICKET_LENGTH)
secureRandom.nextBytes(bytes)
val ticket = bytes.joinToString("") { "%02x".format(it) }
val now = System.currentTimeMillis()
tickets[ticket] = TicketInfo(
username = username,
createdAt = now,
expiresAt = now + TICKET_VALIDITY_MS
)
return ticket
}
/**
* 验证并消费票据(一次性使用)
* @param ticket 票据
* @return 用户名,如果票据无效则返回 null
*/
fun validateAndConsumeTicket(ticket: String): String? {
val ticketInfo = tickets.remove(ticket) ?: return null
// 检查是否过期
if (System.currentTimeMillis() > ticketInfo.expiresAt) {
return null
}
return ticketInfo.username
}
/**
* 清理过期票据
*/
private fun cleanupExpiredTickets() {
val now = System.currentTimeMillis()
tickets.entries.removeIf { it.value.expiresAt < now }
}
/**
* 定时清理过期票据(每分钟执行一次)
* 防止过期票据长时间占用内存
*/
@Scheduled(fixedRate = 60_000) // 60秒 = 60000毫秒
fun scheduledCleanup() {
cleanupExpiredTickets()
}
}
@@ -1,5 +1,6 @@
package com.wrbug.polymarketbot.service.common
import com.google.gson.Gson
import com.wrbug.polymarketbot.api.EthereumRpcApi
import com.wrbug.polymarketbot.api.JsonRpcRequest
import com.wrbug.polymarketbot.api.JsonRpcResponse
@@ -29,7 +30,8 @@ class BlockchainService(
private val dataApiBaseUrl: String,
private val retrofitFactory: RetrofitFactory,
private val relayClientService: RelayClientService,
private val rpcNodeService: RpcNodeService
private val rpcNodeService: RpcNodeService,
private val gson: Gson
) {
private val logger = LoggerFactory.getLogger(BlockchainService::class.java)
@@ -37,9 +39,16 @@ class BlockchainService(
// USDC 合约地址(Polygon 主网,Polymarket 使用 Polygon
private val usdcContractAddress = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
// Polymarket 代理工厂合约地址(Polygon 主网)
// Polymarket Safe 代理工厂合约地址(Polygon 主网,用于 MetaMask 用户
// 合约地址: 0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b
private val proxyFactoryContractAddress = "0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b"
private val safeProxyFactoryAddress = "0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b"
// Polymarket Magic 代理工厂合约地址(Polygon 主网,用于邮箱/OAuth 登录用户)
// 合约地址: 0xaB45c5A4B0c941a2F231C04C3f49182e1A254052
private val magicProxyFactoryAddress = "0xaB45c5A4B0c941a2F231C04C3f49182e1A254052"
// Magic Proxy 的 init code hash(用于 CREATE2 计算)
private val magicProxyInitCodeHash = "0xd21df8dc65880a8606f09fe0ce3df9b8869287ab0b058be05aa9e8af6330a00b"
// ConditionalTokens 合约地址(Polygon 主网)
private val conditionalTokensAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"
@@ -64,7 +73,7 @@ class BlockchainService(
Retrofit.Builder()
.baseUrl("$baseUrl/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(PolymarketDataApi::class.java)
}
@@ -76,60 +85,162 @@ class BlockchainService(
/**
* 获取 Polymarket 代理钱包地址
* 通过 RPC 调用代理工厂合约获取用户的代理钱包地址
* 根据指定的钱包类型返回对应的代理地址
*
* Polymarket 有两种代理钱包类型:
* 1. Magic Proxy(邮箱/OAuth 登录用户)- 使用 CREATE2 计算地址
* 2. Safe ProxyMetaMask 钱包用户)- 通过合约调用获取地址
*
* @param walletAddress 用户的钱包地址(EOA
* @param walletType 钱包类型:"magic"(默认)或 "safe"
* @return 代理钱包地址
*/
suspend fun getProxyAddress(walletAddress: String, walletType: String = "magic"): Result<String> {
return try {
when (walletType.lowercase()) {
"safe" -> {
// Safe ProxyMetaMask 用户)
val safeProxyResult = getSafeProxyAddress(walletAddress)
if (safeProxyResult.isSuccess) {
val safeProxyAddress = safeProxyResult.getOrNull()!!
logger.debug("使用 Safe Proxy 地址: $safeProxyAddress")
Result.success(safeProxyAddress)
} else {
Result.failure(safeProxyResult.exceptionOrNull() ?: Exception("获取 Safe Proxy 地址失败"))
}
}
else -> {
// Magic Proxy(邮箱/OAuth 登录用户)- 默认
val magicProxyAddress = calculateMagicProxyAddress(walletAddress)
logger.debug("使用 Magic Proxy 地址: $magicProxyAddress")
Result.success(magicProxyAddress)
}
}
} catch (e: Exception) {
logger.error("获取代理地址失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 计算 Magic Proxy 地址(使用 CREATE2
* 用于邮箱/OAuth 登录的用户
*
* CREATE2 地址计算公式:
* address = keccak256(0xff ++ factory ++ salt ++ initCodeHash)[12:]
* salt = keccak256(eoaAddress)
*
* @param walletAddress 用户的钱包地址(EOA
* @return Magic 代理钱包地址
*/
fun calculateMagicProxyAddress(walletAddress: String): String {
// 计算 salt = keccak256(eoaAddress)
val eoaBytes = EthereumUtils.hexToBytes(walletAddress.lowercase())
val salt = EthereumUtils.keccak256(eoaBytes)
// 计算 CREATE2 地址
// data = 0xff ++ factory ++ salt ++ initCodeHash
val prefix = byteArrayOf(0xff.toByte())
val factoryBytes = EthereumUtils.hexToBytes(magicProxyFactoryAddress)
val initCodeHashBytes = EthereumUtils.hexToBytes(magicProxyInitCodeHash)
val data = prefix + factoryBytes + salt + initCodeHashBytes
val hash = EthereumUtils.keccak256(data)
// 取后 20 字节作为地址
return "0x" + hash.copyOfRange(12, 32).joinToString("") { "%02x".format(it) }
}
/**
* 获取 Safe Proxy 地址
* 通过 RPC 调用 Safe 代理工厂合约获取用户的代理钱包地址
* 用于 MetaMask 钱包用户
*
* @param walletAddress 用户的钱包地址
* @return 代理钱包地址
*/
suspend fun getProxyAddress(walletAddress: String): Result<String> {
private suspend fun getSafeProxyAddress(walletAddress: String): Result<String> {
return try {
val rpcApi = polygonRpcApi
// 计算函数选择器
val functionSelector = EthereumUtils.getFunctionSelector(computeProxyAddressFunctionSignature)
// 编码地址参数
val encodedAddress = EthereumUtils.encodeAddress(walletAddress)
// 构建调用数据
val data = functionSelector + encodedAddress
// 构建 JSON-RPC 请求
val rpcRequest = JsonRpcRequest(
method = "eth_call",
params = listOf(
mapOf(
"to" to proxyFactoryContractAddress,
"to" to safeProxyFactoryAddress,
"data" to data
),
"latest"
)
)
// 发送 RPC 请求
val response = rpcApi.call(rpcRequest)
if (!response.isSuccessful || response.body() == null) {
throw Exception("RPC 请求失败: ${response.code()} ${response.message()}")
return Result.failure(Exception("RPC 请求失败: ${response.code()} ${response.message()}"))
}
val rpcResponse = response.body()!!
// 检查错误
if (rpcResponse.error != null) {
throw Exception("RPC 错误: ${rpcResponse.error.message}")
return Result.failure(Exception("RPC 错误: ${rpcResponse.error.message}"))
}
// 使用 Gson 解析 resultJsonElement
val hexResult = rpcResponse.result?.asString
?: throw Exception("RPC 响应格式错误: result 为空")
val hexResult = rpcResponse.result?.asString
?: return Result.failure(Exception("RPC 响应格式错误: result 为空"))
// 解析代理地址
val proxyAddress = EthereumUtils.decodeAddress(hexResult)
Result.success(proxyAddress)
} catch (e: Exception) {
logger.error("获取代理地址失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 检查地址是否是合约
* @param address 地址
* @return 如果地址有代码(是合约)返回 true
*/
private suspend fun isContract(address: String): Boolean {
return try {
val rpcApi = polygonRpcApi
val rpcRequest = JsonRpcRequest(
method = "eth_getCode",
params = listOf(address, "latest")
)
val response = rpcApi.call(rpcRequest)
if (!response.isSuccessful || response.body() == null) {
return false
}
val rpcResponse = response.body()!!
if (rpcResponse.error != null) {
return false
}
val code = rpcResponse.result?.asString ?: "0x"
// 如果代码不是 "0x" 或 "0x0",则是合约
code != "0x" && code != "0x0"
} catch (e: Exception) {
logger.warn("检查合约地址失败: ${e.message}")
false
}
}
/**
* 查询账户 USDC 余额
@@ -604,7 +715,7 @@ class BlockchainService(
/**
* 从链上查询市场条件(Condition)的结算结果
* 通过调用 ConditionalTokens 合约的 getCondition 函数获取 payouts
* 通过调用 ConditionalTokens 合约的 conditions mapping 和 payoutNumerators mapping
*
* @param conditionId 市场条件IDbytes32,必须是 0x 开头的 66 位十六进制字符串)
* @return Result<Pair<payoutDenominator, payouts>>
@@ -623,44 +734,131 @@ class BlockchainService(
val rpcApi = polygonRpcApi
// 构建 getCondition(bytes32) 函数调用
// 函数签名: getCondition(bytes32)
val functionSelector = EthereumUtils.getFunctionSelector("getCondition(bytes32)")
// 1. 调用 getOutcomeSlotCount(bytes32) 获取结果槽位数量
// 函数签名: getOutcomeSlotCount(bytes32) returns (uint)
val getOutcomeSlotCountSelector = EthereumUtils.getFunctionSelector("getOutcomeSlotCount(bytes32)")
val encodedConditionId = EthereumUtils.encodeBytes32(conditionId)
val data = functionSelector + encodedConditionId
val outcomeSlotCountData = getOutcomeSlotCountSelector + encodedConditionId
// 构建 JSON-RPC 请求
val rpcRequest = JsonRpcRequest(
val outcomeSlotCountRequest = JsonRpcRequest(
method = "eth_call",
params = listOf(
mapOf(
"to" to conditionalTokensAddress,
"data" to data
"data" to outcomeSlotCountData
),
"latest"
)
)
// 发送 RPC 请求
val response = rpcApi.call(rpcRequest)
val outcomeSlotCountResponse = rpcApi.call(outcomeSlotCountRequest)
if (!response.isSuccessful || response.body() == null) {
return Result.failure(Exception("RPC 请求失败: ${response.code()} ${response.message()}"))
if (!outcomeSlotCountResponse.isSuccessful || outcomeSlotCountResponse.body() == null) {
return Result.failure(Exception("RPC 请求失败 (getOutcomeSlotCount): ${outcomeSlotCountResponse.code()} ${outcomeSlotCountResponse.message()}"))
}
val rpcResponse = response.body()!!
val outcomeSlotCountRpcResponse = outcomeSlotCountResponse.body()!!
// 检查错误
if (rpcResponse.error != null) {
return Result.failure(Exception("RPC 错误: ${rpcResponse.error.message}"))
if (outcomeSlotCountRpcResponse.error != null) {
val errorMsg = "RPC 错误 (code=${outcomeSlotCountRpcResponse.error.code}): ${outcomeSlotCountRpcResponse.error.message}, data=${outcomeSlotCountRpcResponse.error.data}"
logger.warn("查询市场条件(getOutcomeSlotCount)出现RPC错误: conditionId=$conditionId, $errorMsg")
logger.debug("RPC 请求详情: to=$conditionalTokensAddress, data=$outcomeSlotCountData")
return Result.failure(Exception(errorMsg))
}
// 使用 Gson 解析 resultJsonElement
val hexResult = rpcResponse.result?.asString
val outcomeSlotCountHex = outcomeSlotCountRpcResponse.result?.asString
?: return Result.failure(Exception("RPC 响应格式错误: result 为空"))
// 解析 ABI 编码的返回结果
val (payoutDenominator, payouts) = EthereumUtils.decodeConditionResult(hexResult)
val outcomeSlotCount = EthereumUtils.decodeUint256(outcomeSlotCountHex).toInt()
// 如果 outcomeSlotCount 为 0,说明市场尚未创建或不存在
if (outcomeSlotCount <= 0) {
logger.debug("市场尚未创建或不存在: conditionId=$conditionId, outcomeSlotCount=$outcomeSlotCount")
return Result.success(Pair(BigInteger.ZERO, emptyList()))
}
// 2. 调用 payoutDenominator(bytes32) 获取分母
// 函数签名: payoutDenominator(bytes32) returns (uint)
val payoutDenominatorSelector = EthereumUtils.getFunctionSelector("payoutDenominator(bytes32)")
val payoutDenominatorData = payoutDenominatorSelector + encodedConditionId
val payoutDenominatorRequest = JsonRpcRequest(
method = "eth_call",
params = listOf(
mapOf(
"to" to conditionalTokensAddress,
"data" to payoutDenominatorData
),
"latest"
)
)
val payoutDenominatorResponse = rpcApi.call(payoutDenominatorRequest)
if (!payoutDenominatorResponse.isSuccessful || payoutDenominatorResponse.body() == null) {
return Result.failure(Exception("RPC 请求失败 (payoutDenominator): ${payoutDenominatorResponse.code()} ${payoutDenominatorResponse.message()}"))
}
val payoutDenominatorRpcResponse = payoutDenominatorResponse.body()!!
if (payoutDenominatorRpcResponse.error != null) {
val errorMsg = "RPC 错误 (code=${payoutDenominatorRpcResponse.error.code}): ${payoutDenominatorRpcResponse.error.message}, data=${payoutDenominatorRpcResponse.error.data}"
logger.warn("查询市场条件(payoutDenominator)出现RPC错误: conditionId=$conditionId, $errorMsg")
logger.debug("RPC 请求详情: to=$conditionalTokensAddress, data=$payoutDenominatorData")
return Result.failure(Exception(errorMsg))
}
val payoutDenominatorHex = payoutDenominatorRpcResponse.result?.asString
?: return Result.failure(Exception("RPC 响应格式错误: result 为空"))
val payoutDenominator = EthereumUtils.decodeUint256(payoutDenominatorHex)
// 如果 outcomeSlotCount 为 0,说明市场尚未创建或不存在
if (outcomeSlotCount <= 0) {
logger.debug("市场尚未创建或不存在: conditionId=$conditionId, outcomeSlotCount=$outcomeSlotCount")
return Result.success(Pair(BigInteger.ZERO, emptyList()))
}
// 如果 payoutDenominator 为 0,说明市场尚未结算
if (payoutDenominator == BigInteger.ZERO) {
logger.debug("市场尚未结算: conditionId=$conditionId, payoutDenominator=$payoutDenominator")
return Result.success(Pair(BigInteger.ZERO, emptyList()))
}
// 2. 查询每个 outcome 的 payoutNumerators
val payouts = mutableListOf<BigInteger>()
for (i in 0 until outcomeSlotCount) {
val payoutNumeratorsFunctionSelector = EthereumUtils.getFunctionSelector("payoutNumerators(bytes32,uint256)")
val encodedIndex = EthereumUtils.encodeUint256(BigInteger.valueOf(i.toLong()))
val payoutNumeratorsData = payoutNumeratorsFunctionSelector + encodedConditionId + encodedIndex
val payoutRequest = JsonRpcRequest(
method = "eth_call",
params = listOf(
mapOf(
"to" to conditionalTokensAddress,
"data" to payoutNumeratorsData
),
"latest"
)
)
val payoutResponse = rpcApi.call(payoutRequest)
if (!payoutResponse.isSuccessful || payoutResponse.body() == null) {
logger.warn("查询 payoutNumerators 失败: index=$i")
continue
}
val payoutRpcResponse = payoutResponse.body()!!
if (payoutRpcResponse.error != null) {
logger.warn("查询 payoutNumerators 错误: index=$i, error=${payoutRpcResponse.error.message}")
continue
}
val payoutHex = payoutRpcResponse.result?.asString ?: "0x0"
val payout = EthereumUtils.decodeUint256(payoutHex)
payouts.add(payout)
}
Result.success(Pair(payoutDenominator, payouts))
} catch (e: Exception) {
@@ -0,0 +1,173 @@
package com.wrbug.polymarketbot.service.common
import com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository
import jakarta.annotation.PostConstruct
import jakarta.annotation.PreDestroy
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
/**
* 市场信息轮询服务
* 定期检查买入订单的市场信息是否缺失,如果缺失则从API获取并更新
*/
@Service
class MarketPollingService(
private val copyOrderTrackingRepository: CopyOrderTrackingRepository,
private val marketService: MarketService
) {
private val logger = LoggerFactory.getLogger(MarketPollingService::class.java)
@Value("\${market.polling.interval:30000}")
private var pollingInterval: Long = 30000 // 轮询间隔(毫秒),默认30秒
@Value("\${market.polling.batch.size:50}")
private var batchSize: Int = 50 // 批量处理大小,每次最多处理50个市场
// 协程作用域和任务
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private var pollingJob: Job? = null
// 同步锁,确保轮询任务的启动和停止是线程安全的
private val lock = Any()
/**
* 初始化服务(后端启动时直接启动轮询)
*/
@PostConstruct
fun init() {
logger.info("MarketPollingService 初始化,启动市场信息轮询任务,轮询间隔: ${pollingInterval}ms (${pollingInterval / 1000 / 60}分钟)")
startPolling()
}
/**
* 清理资源
*/
@PreDestroy
fun destroy() {
synchronized(lock) {
pollingJob?.cancel()
pollingJob = null
}
scope.cancel()
}
/**
* 启动轮询任务
*/
private fun startPolling() {
synchronized(lock) {
// 如果已经有轮询任务在运行,先取消
pollingJob?.cancel()
// 启动新的轮询任务
pollingJob = scope.launch(Dispatchers.IO) {
// 启动时立即执行一次
try {
checkAndUpdateMissingMarkets()
} catch (e: Exception) {
logger.error("初始检查市场信息失败: ${e.message}", e)
}
// 然后按间隔定期执行
while (isActive) {
try {
delay(pollingInterval)
checkAndUpdateMissingMarkets()
} catch (e: Exception) {
logger.error("轮询市场信息失败: ${e.message}", e)
}
}
}
}
}
/**
* 检查并更新缺失的市场信息
*/
private suspend fun checkAndUpdateMissingMarkets() {
try {
logger.debug("开始检查缺失的市场信息...")
// 1. 获取所有买入订单的市场ID(去重)
val allOrders = copyOrderTrackingRepository.findAll()
val marketIds = allOrders.map { it.marketId }.distinct()
if (marketIds.isEmpty()) {
logger.debug("没有找到任何订单,跳过市场信息检查")
return
}
logger.debug("找到 ${marketIds.size} 个不同的市场ID")
// 2. 检查哪些市场信息在数据库中缺失
val existingMarkets = marketService.marketRepository.findByMarketIdIn(marketIds)
val existingMarketIds = existingMarkets.map { it.marketId }.toSet()
val missingMarketIds = marketIds.filter { it !in existingMarketIds }
// 过滤掉空字符串和无效的市场ID
val validMissingMarketIds = missingMarketIds.filter {
it.isNotBlank() && it.startsWith("0x")
}
if (validMissingMarketIds.isEmpty()) {
logger.debug("所有市场信息都已存在,无需更新")
return
}
logger.info("发现 ${validMissingMarketIds.size} 个缺失的市场信息,开始批量更新...")
// 3. 批量从API获取缺失的市场信息(分批处理,避免一次性请求过多)
val batches = validMissingMarketIds.chunked(batchSize)
var successCount = 0
var failCount = 0
for ((index, batch) in batches.withIndex()) {
try {
logger.debug("处理第 ${index + 1}/${batches.size} 批,包含 ${batch.size} 个市场: ${batch.take(3).joinToString(", ")}${if (batch.size > 3) "..." else ""}")
// 使用 MarketService 的批量获取方法
// 这个方法会尝试从API获取并保存到数据库
val markets = marketService.getMarkets(batch)
val batchSuccessCount = markets.size
val batchFailCount = batch.size - batchSuccessCount
successCount += batchSuccessCount
failCount += batchFailCount
if (batchFailCount > 0) {
logger.warn("${index + 1} 批中有 ${batchFailCount} 个市场信息获取失败")
}
// 避免请求过于频繁,每批之间稍作延迟
if (index < batches.size - 1) {
delay(1000) // 延迟1秒,避免API限流
}
} catch (e: Exception) {
logger.error("批量获取市场信息失败: batch=${batch.take(5).joinToString(", ")}..., error=${e.message}", e)
failCount += batch.size
}
}
logger.info("市场信息更新完成: 成功=${successCount}, 失败=${failCount}, 总计=${validMissingMarketIds.size}")
} catch (e: Exception) {
logger.error("检查并更新市场信息异常: ${e.message}", e)
}
}
/**
* 手动触发检查(用于测试或手动刷新)
*/
fun triggerCheck() {
scope.launch(Dispatchers.IO) {
try {
checkAndUpdateMissingMarkets()
} catch (e: Exception) {
logger.error("手动触发检查市场信息失败: ${e.message}", e)
}
}
}
}
@@ -0,0 +1,294 @@
package com.wrbug.polymarketbot.service.common
import com.wrbug.polymarketbot.api.PolymarketClobApi
import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.util.CryptoUtils
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.math.BigInteger
/**
* 市场价格服务
* 统一封装从不同数据源获取市场价格的逻辑
* 数据源包括:
* 1. 链上 RPC 查询(市场结算结果)
* 2. CLOB API(订单簿价格)
*/
@Service
class MarketPriceService(
private val blockchainService: BlockchainService,
private val retrofitFactory: RetrofitFactory,
private val accountRepository: AccountRepository,
private val cryptoUtils: CryptoUtils
) {
private val logger = LoggerFactory.getLogger(MarketPriceService::class.java)
/**
* 获取当前市场最新价
* 优先级:
* 1. 链上查询市场结算结果(如果已结算,返回 1.0 或 0.0)
* 2. CLOB API 查询订单簿价格(最准确,优先使用,使用 bestBid)
* 3. Gamma Market API 查询市场价格(快速,作为备选)
*
* 价格会被截位到 4 位小数(向下截断,不四舍五入),用于显示和后续计算
*
* @param marketId 市场ID
* @param outcomeIndex 结果索引
* @return 市场价格(已截位到 4 位小数)
* @throws IllegalStateException 如果所有数据源都失败
*/
suspend fun getCurrentMarketPrice(marketId: String, outcomeIndex: Int): BigDecimal {
// 1. 优先从链上查询市场结算结果
val (chainPrice, hasRpcError) = getPriceFromChainCondition(marketId, outcomeIndex)
if (chainPrice != null) {
// 截位到 4 位小数(向下截断,不四舍五入)
return chainPrice.setScale(4, java.math.RoundingMode.DOWN)
}
// 如果链上查询出现 RPC 错误(execution reverted),说明市场可能不存在或尚未创建
// 在这种情况下,降级到其他数据源(CLOB API 或 Gamma API),而不是直接抛出异常
// 因为 marketId 可能在 API 中存在,但在链上尚未创建
if (hasRpcError) {
logger.debug("链上查询市场条件出现 RPC 错误(execution reverted),降级到 API 查询: marketId=$marketId, outcomeIndex=$outcomeIndex")
}
// 2. 从 CLOB API 查询订单簿价格(最准确,优先使用)
val orderbookPrice = getPriceFromClobOrderbook(marketId, outcomeIndex)
if (orderbookPrice != null) {
// 截位到 4 位小数(向下截断,不四舍五入)
return orderbookPrice.setScale(4, java.math.RoundingMode.DOWN)
}
// 3. 从 Gamma Market API 查询市场价格(作为备选)
val marketPrice = getPriceFromGammaMarket(marketId, outcomeIndex)
if (marketPrice != null) {
// 截位到 4 位小数(向下截断,不四舍五入)
return marketPrice.setScale(4, java.math.RoundingMode.DOWN)
}
// 如果所有数据源都失败,抛出异常
val errorMsg = "无法获取市场价格: marketId=$marketId, outcomeIndex=$outcomeIndex (链上查询、订单簿查询和 Market API 均失败)"
logger.error(errorMsg)
throw IllegalStateException(errorMsg)
}
/**
* 从链上查询市场结算结果获取价格
* 如果市场已结算:
* - payout > 0(赢了)→ 返回 1.0
* - payout == 0(输了)→ 返回 0.0
* 如果市场未结算或查询失败,返回 null
*
* @return Pair<BigDecimal?, Boolean> 第一个值是价格(如果已结算),第二个值表示是否发生了 RPC 错误(execution reverted
*/
private suspend fun getPriceFromChainCondition(marketId: String, outcomeIndex: Int): Pair<BigDecimal?, Boolean> {
return try {
val chainResult = blockchainService.getCondition(marketId)
chainResult.fold(
onSuccess = { (_, payouts) ->
// 如果 payouts 不为空,说明市场已结算
if (payouts.isNotEmpty() && outcomeIndex < payouts.size) {
val payout = payouts[outcomeIndex]
when {
payout > BigInteger.ZERO -> {
logger.info("从链上查询到市场已结算,该 outcome 赢了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
return Pair(BigDecimal.ONE, false)
}
payout == BigInteger.ZERO -> {
logger.info("从链上查询到市场已结算,该 outcome 输了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
return Pair(BigDecimal.ZERO, false)
}
else -> {
logger.warn("从链上查询到异常的 payout 值: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
Pair(null, false)
}
}
} else {
logger.debug("从链上查询到市场尚未结算: marketId=$marketId, payouts=${payouts.size}")
Pair(null, false)
}
},
onFailure = { e ->
// 检查是否是 execution reverted 错误
val isRpcError = e.message?.contains("execution reverted", ignoreCase = true) == true
if (isRpcError) {
logger.warn("链上查询市场条件出现 RPC 错误(execution reverted),可能市场不存在或尚未创建: marketId=$marketId, error=${e.message}")
} else {
logger.debug("链上查询市场条件失败,降级到 API 查询: marketId=$marketId, error=${e.message}")
}
Pair(null, isRpcError)
}
)
} catch (e: Exception) {
val isRpcError = e.message?.contains("execution reverted", ignoreCase = true) == true
if (isRpcError) {
logger.warn("链上查询市场条件异常(execution reverted: marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}")
} else {
logger.debug("链上查询市场条件异常: marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}")
}
Pair(null, isRpcError)
}
}
/**
* 从 Gamma Market API 获取价格
* 使用 outcomePrices 字段,格式通常为 JSON 字符串 "[\"0.5\", \"0.5\"]"
* 如果查询失败或 outcomePrices 为空,返回 null
*/
private suspend fun getPriceFromGammaMarket(marketId: String, outcomeIndex: Int): BigDecimal? {
return try {
val gammaApi = retrofitFactory.createGammaApi()
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(marketId))
if (!marketResponse.isSuccessful || marketResponse.body() == null) {
logger.debug("Gamma Market API 查询失败: marketId=$marketId, code=${marketResponse.code()}")
return null
}
val markets = marketResponse.body()!!
if (markets.isEmpty()) {
logger.debug("Gamma Market API 未找到市场: marketId=$marketId")
return null
}
val market = markets.first()
// 尝试从 outcomePrices 字段获取价格
val outcomePricesStr = market.outcomePrices
if (outcomePricesStr.isNullOrBlank()) {
logger.debug("Market outcomePrices 为空: marketId=$marketId")
return null
}
// 解析 outcomePrices(通常是 JSON 数组字符串)
val outcomePrices = try {
// 移除首尾的方括号和引号,按逗号分割
val cleanStr = outcomePricesStr.trim().removeSurrounding("[", "]")
cleanStr.split(",").map {
it.trim().removeSurrounding("\"").toSafeBigDecimal()
}
} catch (e: Exception) {
logger.warn("解析 outcomePrices 失败: marketId=$marketId, outcomePrices=$outcomePricesStr, error=${e.message}")
null
}
if (outcomePrices != null && outcomeIndex < outcomePrices.size) {
val price = outcomePrices[outcomeIndex]
logger.debug("从 Gamma Market API 获取价格: marketId=$marketId, outcomeIndex=$outcomeIndex, price=$price")
return price
}
null
} catch (e: Exception) {
logger.debug("Gamma Market API 查询异常: marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}")
null
}
}
/**
* 从 CLOB API 查询订单簿价格
* 获取订单簿的 bestBid 和 bestAsk,计算 midpoint = (bestBid + bestAsk) / 2
* 订单簿数据最准确,反映当前市场真实价格
* 如果查询失败,返回 null
*/
private suspend fun getPriceFromClobOrderbook(marketId: String, outcomeIndex: Int): BigDecimal? {
return try {
// 获取 tokenId(用于查询特定 outcome 的订单簿)
val tokenIdResult = blockchainService.getTokenId(marketId, outcomeIndex)
if (!tokenIdResult.isSuccess) {
return null
}
val tokenId = tokenIdResult.getOrNull() ?: return null
// 尝试使用带鉴权的 CLOB API,如果没有则使用不带鉴权的 API
val clobApi = try {
getAuthenticatedClobApi() ?: retrofitFactory.createClobApiWithoutAuth()
} catch (e: Exception) {
logger.debug("获取带鉴权的 CLOB API 失败,使用不带鉴权的 API: ${e.message}")
retrofitFactory.createClobApiWithoutAuth()
}
val orderbookResponse = clobApi.getOrderbook(tokenId = tokenId, market = null)
if (!orderbookResponse.isSuccessful || orderbookResponse.body() == null) {
return null
}
val orderbook = orderbookResponse.body()!!
// 获取 bestBid(最高买入价):从 bids 中找到价格最大的
// bids 表示买入订单列表,价格越高表示愿意出的价格越高
val bestBid = orderbook.bids
.mapNotNull { it.price.toSafeBigDecimal() }
.maxOrNull()
// 获取 bestAsk(最低卖出价):从 asks 中找到价格最小的
// asks 表示卖出订单列表,价格越低表示愿意卖的价格越低
val bestAsk = orderbook.asks
.mapNotNull { it.price.toSafeBigDecimal() }
.minOrNull()
// 由于主要用于卖出场景,优先使用 bestBid(最高买入价,卖给愿意买入的人)
// 如果没有 bestBid,则使用 midpoint 或 bestAsk
if (bestBid != null) {
logger.debug("从订单簿获取价格(bestBid: marketId=$marketId, outcomeIndex=$outcomeIndex, bestBid=$bestBid, bestAsk=$bestAsk")
return bestBid
} else if (bestAsk != null && bestAsk > BigDecimal.ZERO) {
// 如果没有 bestBid,使用 bestAsk 作为备选
logger.debug("从订单簿获取价格(bestAsk: marketId=$marketId, outcomeIndex=$outcomeIndex, bestAsk=$bestAsk")
return bestAsk
}
null
} catch (e: Exception) {
logger.debug("CLOB API 查询订单簿失败: marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}")
null
}
}
/**
* 获取带鉴权的 CLOB API 客户端
* 使用第一个有 API 凭证的账户
* 如果都没有,返回 null
*/
private fun getAuthenticatedClobApi(): PolymarketClobApi? {
return try {
// 使用第一个有 API 凭证的账户
val account = accountRepository.findAllByOrderByCreatedAtAsc()
.firstOrNull { it.apiKey != null && it.apiSecret != null && it.apiPassphrase != null }
if (account == null || account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
return null
}
// 解密 API 凭证
val apiKey = account.apiKey
val apiSecret = try {
cryptoUtils.decrypt(account.apiSecret)
} catch (e: Exception) {
logger.debug("解密 API Secret 失败: ${e.message}")
return null
}
val apiPassphrase = try {
cryptoUtils.decrypt(account.apiPassphrase)
} catch (e: Exception) {
logger.debug("解密 API Passphrase 失败: ${e.message}")
return null
}
// 创建带鉴权的 CLOB API 客户端
retrofitFactory.createClobApi(apiKey, apiSecret, apiPassphrase, account.walletAddress)
} catch (e: Exception) {
logger.debug("获取带鉴权的 CLOB API 失败: ${e.message}")
null
}
}
}
@@ -0,0 +1,234 @@
package com.wrbug.polymarketbot.service.common
import com.github.benmanes.caffeine.cache.Cache
import com.github.benmanes.caffeine.cache.Caffeine
import com.wrbug.polymarketbot.api.MarketResponse
import com.wrbug.polymarketbot.api.PolymarketGammaApi
import com.wrbug.polymarketbot.entity.Market
import com.wrbug.polymarketbot.repository.MarketRepository
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.getEventSlug
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.time.Instant
import java.time.format.DateTimeFormatter
/**
* 市场信息服务
* 负责缓存和管理市场信息
*/
@Service
class MarketService(
val marketRepository: MarketRepository, // 改为 public,供 MarketPollingService 使用
private val retrofitFactory: RetrofitFactory
) {
private val logger = LoggerFactory.getLogger(MarketService::class.java)
// LRU 缓存(避免频繁查询数据库),最多缓存 200 条记录
private val marketCache: Cache<String, Market> = Caffeine.newBuilder()
.maximumSize(200) // 最多缓存 200 条记录
.build()
/**
* 根据市场ID获取市场信息
* 优先从缓存获取,如果不存在则从数据库查询,如果数据库也没有则从API获取并保存
*/
fun getMarket(marketId: String): Market? {
// 1. 从缓存获取
marketCache.getIfPresent(marketId)?.let { return it }
// 2. 从数据库查询
val market = marketRepository.findByMarketId(marketId)
if (market != null) {
marketCache.put(marketId, market)
return market
}
// 3. 从API获取(异步,不阻塞)
runBlocking {
try {
fetchAndSaveMarket(marketId)
} catch (e: Exception) {
logger.warn("获取市场信息失败: marketId=$marketId, error=${e.message}")
}
}
// 再次从数据库查询(API可能已经保存)
return marketRepository.findByMarketId(marketId)?.also {
marketCache.put(marketId, it)
}
}
/**
* 批量获取市场信息
*/
fun getMarkets(marketIds: List<String>): Map<String, Market> {
val result = mutableMapOf<String, Market>()
val missingIds = mutableListOf<String>()
// 1. 从缓存和数据库获取
for (marketId in marketIds) {
val market = getMarket(marketId)
if (market != null) {
result[marketId] = market
} else {
missingIds.add(marketId)
}
}
// 2. 批量从API获取缺失的市场信息
if (missingIds.isNotEmpty()) {
runBlocking {
try {
fetchAndSaveMarkets(missingIds)
} catch (e: Exception) {
logger.warn("批量获取市场信息失败: marketIds=$missingIds, error=${e.message}")
}
}
// 再次从数据库查询
val savedMarkets = marketRepository.findByMarketIdIn(missingIds)
for (market in savedMarkets) {
result[market.marketId] = market
marketCache.put(market.marketId, market)
}
}
return result
}
/**
* 从API获取市场信息并保存到数据库
*/
private suspend fun fetchAndSaveMarket(marketId: String): Market? {
return try {
val gammaApi = retrofitFactory.createGammaApi()
val response = gammaApi.listMarkets(conditionIds = listOf(marketId))
if (response.isSuccessful && response.body() != null) {
val markets = response.body()!!
if (markets.isNotEmpty()) {
val marketResponse = markets.first()
saveMarketFromResponse(marketId, marketResponse)
} else {
null
}
} else {
null
}
} catch (e: Exception) {
logger.error("从API获取市场信息失败: marketId=$marketId, error=${e.message}", e)
null
}
}
/**
* 批量从API获取市场信息并保存到数据库
*/
private suspend fun fetchAndSaveMarkets(marketIds: List<String>) {
if (marketIds.isEmpty()) return
try {
val gammaApi = retrofitFactory.createGammaApi()
val response = gammaApi.listMarkets(conditionIds = marketIds)
if (response.isSuccessful && response.body() != null) {
val markets = response.body()!!
val marketMap = markets.associateBy { it.conditionId ?: "" }
for (marketId in marketIds) {
val marketResponse = marketMap[marketId]
if (marketResponse != null) {
saveMarketFromResponse(marketId, marketResponse)
}
}
}
} catch (e: Exception) {
logger.error("批量从API获取市场信息失败: marketIds=$marketIds, error=${e.message}", e)
}
}
/**
* 从API响应保存市场信息到数据库
*/
private fun saveMarketFromResponse(marketId: String, marketResponse: MarketResponse): Market? {
return try {
val existingMarket = marketRepository.findByMarketId(marketId)
// 保存原来的 slug(用于显示)
val slug = marketResponse.slug
// 保存跳转用的 slug(从 events[0].slug 获取)
val eventSlug = marketResponse.getEventSlug()
val market = if (existingMarket != null) {
// 更新现有市场信息
existingMarket.copy(
title = marketResponse.question ?: existingMarket.title,
slug = slug ?: existingMarket.slug,
eventSlug = eventSlug ?: existingMarket.eventSlug,
category = marketResponse.category ?: existingMarket.category,
icon = marketResponse.icon ?: existingMarket.icon,
image = marketResponse.image ?: existingMarket.image,
description = marketResponse.description ?: existingMarket.description,
active = marketResponse.active ?: existingMarket.active,
closed = marketResponse.closed ?: existingMarket.closed,
archived = marketResponse.archived ?: existingMarket.archived,
endDate = parseEndDate(marketResponse.endDate),
updatedAt = System.currentTimeMillis()
)
} else {
// 创建新市场信息
Market(
marketId = marketId,
title = marketResponse.question ?: marketId,
slug = slug,
eventSlug = eventSlug,
category = marketResponse.category,
icon = marketResponse.icon,
image = marketResponse.image,
description = marketResponse.description,
active = marketResponse.active ?: true,
closed = marketResponse.closed ?: false,
archived = marketResponse.archived ?: false,
endDate = parseEndDate(marketResponse.endDate),
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis()
)
}
val savedMarket = marketRepository.save(market)
marketCache.put(marketId, savedMarket)
savedMarket
} catch (e: Exception) {
logger.error("保存市场信息失败: marketId=$marketId, error=${e.message}", e)
null
}
}
/**
* 清除缓存(用于测试或手动刷新)
*/
fun clearCache() {
marketCache.invalidateAll()
}
/**
* 解析市场截止时间(ISO 8601 格式)
*/
private fun parseEndDate(endDate: String?): Long? {
if (endDate.isNullOrBlank()) {
return null
}
return try {
// ISO 8601 格式,例如:2025-03-15T12:00:00Z
Instant.parse(endDate).toEpochMilli()
} catch (e: Exception) {
logger.warn("解析市场截止时间失败: endDate=$endDate, error=${e.message}")
null
}
}
}
@@ -1,5 +1,6 @@
package com.wrbug.polymarketbot.service.common
import com.google.gson.Gson
import com.wrbug.polymarketbot.api.ApiKeyResponse
import com.wrbug.polymarketbot.api.PolymarketClobApi
import com.wrbug.polymarketbot.util.PolymarketL1AuthInterceptor
@@ -19,7 +20,8 @@ import retrofit2.converter.gson.GsonConverterFactory
@Service
class PolymarketApiKeyService(
@Value("\${polymarket.clob.base-url}")
private val clobBaseUrl: String
private val clobBaseUrl: String,
private val gson: Gson
) {
private val logger = LoggerFactory.getLogger(PolymarketApiKeyService::class.java)
@@ -52,34 +54,36 @@ class PolymarketApiKeyService(
try {
// 先尝试获取现有的 API Keyderive
val deriveResult = deriveApiKey(privateKey, walletAddress, chainId)
val maskedAddress = "${walletAddress.take(6)}...${walletAddress.takeLast(4)}"
if (deriveResult.isSuccess) {
val creds = deriveResult.getOrNull()
if (creds != null && isApiCreds(creds)) {
logger.info("成功获取现有 API Key: ${walletAddress}")
logger.debug("成功获取现有 API Key: $maskedAddress")
return@runBlocking Result.success(creds)
}
}
// 如果获取失败或返回无效,尝试创建新的
logger.info("获取现有 API Key 失败,尝试创建新的: ${walletAddress}")
logger.debug("获取现有 API Key 失败,尝试创建新的: $maskedAddress")
val createResult = createApiKey(privateKey, walletAddress, chainId)
if (createResult.isSuccess) {
val creds = createResult.getOrNull()
if (creds != null && isApiCreds(creds)) {
logger.info("成功创建新 API Key: ${walletAddress}")
logger.debug("成功创建新 API Key: $maskedAddress")
return@runBlocking Result.success(creds)
}
}
// 两个都失败
val error = createResult.exceptionOrNull() ?: deriveResult.exceptionOrNull()
val errorMsg = error?.message ?: "未知错误"
logger.error("获取和创建 API Key 都失败: ${walletAddress}", error)
logger.error("获取和创建 API Key 都失败: $maskedAddress", error)
Result.failure(
IllegalStateException("无法获取或创建 API Key: $errorMsg")
)
} catch (e: Exception) {
logger.error("创建或获取 API Key 异常: ${walletAddress}", e)
val maskedAddress = "${walletAddress.take(6)}...${walletAddress.takeLast(4)}"
logger.error("创建或获取 API Key 异常: $maskedAddress", e)
Result.failure(e)
}
}
@@ -222,7 +226,7 @@ class PolymarketApiKeyService(
return Retrofit.Builder()
.baseUrl(clobBaseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(PolymarketClobApi::class.java)
}
@@ -236,7 +240,7 @@ class PolymarketApiKeyService(
return Retrofit.Builder()
.baseUrl(clobBaseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(PolymarketClobApi::class.java)
}
@@ -300,13 +300,23 @@ class PolymarketClobService(
)
val response = authenticatedClobApi.getOrder(orderId)
if (response.isSuccessful && response.body() != null) {
Result.success(response.body()!!)
if (response.isSuccessful) {
val body = response.body()
if (body != null) {
Result.success(body)
} else {
// 响应体为空,可能是订单不存在或已过期
logger.warn("获取订单详情失败: 响应体为空, orderId=$orderId, code=${response.code()}")
Result.failure(Exception("订单不存在或已过期: orderId=$orderId"))
}
} else {
Result.failure(Exception("获取订单详情失败: ${response.code()} ${response.message()}"))
// HTTP 状态码不是 2xx
val errorBody = response.errorBody()?.string()?.take(200) ?: "无错误详情"
logger.warn("获取订单详情失败: HTTP ${response.code()}, orderId=$orderId, errorBody=$errorBody")
Result.failure(Exception("获取订单详情失败: HTTP ${response.code()} ${response.message()}"))
}
} catch (e: Exception) {
logger.error("获取订单详情异常: ${e.message}", e)
logger.error("获取订单详情异常: orderId=$orderId, ${e.message}", e)
Result.failure(e)
}
}
@@ -391,5 +401,37 @@ class PolymarketClobService(
Result.failure(e)
}
}
/**
* 获取费率
* 文档: https://docs.polymarket.com/developers/market-makers/maker-rebates-program#1-fetch-the-fee-rate
*
* 注意:根据 TypeScript clob-client 源码,API 返回的字段名是 base_fee,而不是文档中的 fee_rate_bps
* 参考: clob-client/src/client.ts:312
*
* @param tokenId Token ID
* @return 费率基点(0 表示无费率,1000 表示 10%)
*/
suspend fun getFeeRate(tokenId: String): Result<Int> {
return try {
val response = clobApi.getFeeRate(tokenId)
if (response.isSuccessful && response.body() != null) {
val baseFee = response.body()!!.baseFee
logger.debug("获取费率成功: tokenId=$tokenId, baseFee=$baseFee")
Result.success(baseFee)
} else {
val errorBody = try {
response.errorBody()?.string()
} catch (e: Exception) {
null
}
logger.error("获取费率失败: tokenId=$tokenId, code=${response.code()}, errorBody=$errorBody")
Result.failure(Exception("获取费率失败: ${response.code()} ${response.message()}"))
}
} catch (e: Exception) {
logger.error("获取费率异常: tokenId=$tokenId, error=${e.message}", e)
Result.failure(e)
}
}
}
@@ -3,50 +3,133 @@ package com.wrbug.polymarketbot.service.common
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicReference
/**
* 频率限制服务(使用内存缓存,全局限制
* 频率限制服务(使用内存缓存)
*/
@Service
class RateLimitService {
private val logger = LoggerFactory.getLogger(RateLimitService::class.java)
// 重置密码限速配置
@Value("\${rate-limit.reset-password.max-attempts:3}")
private var maxAttempts: Int = 3
private var resetPasswordMaxAttempts: Int = 3
@Value("\${rate-limit.reset-password.window-seconds:60}")
private var windowSeconds: Long = 60
private var resetPasswordWindowSeconds: Long = 60
// 登录限速配置
@Value("\${rate-limit.login.max-attempts:5}")
private var loginMaxAttempts: Int = 5
@Value("\${rate-limit.login.window-seconds:300}")
private var loginWindowSeconds: Long = 300 // 5分钟
@Value("\${rate-limit.login.lockout-seconds:900}")
private var loginLockoutSeconds: Long = 900 // 15分钟
// 全局尝试记录列表(时间戳),所有请求共享
private val resetPasswordAttempts = AtomicReference<MutableList<Long>>(mutableListOf())
// 登录失败尝试记录(IP -> 时间戳列表)
private val loginFailedAttempts = ConcurrentHashMap<String, MutableList<Long>>()
// 登录锁定记录(IP -> 锁定结束时间)
private val loginLockouts = ConcurrentHashMap<String, Long>()
/**
* 检查重置密码频率限制(全局限制,不按IP)
* @return Result,如果超过限制则返回失败
*/
fun checkResetPasswordRateLimit(): Result<Unit> {
val now = System.currentTimeMillis()
val windowStart = now - (windowSeconds * 1000)
val windowStart = now - (resetPasswordWindowSeconds * 1000)
// 获取当前尝试记录列表
val attempts = resetPasswordAttempts.get()
// 清理过期记录(超过时间窗口的记录)
val validAttempts = attempts.filter { it >= windowStart }.toMutableList()
// 检查是否超过限制
if (validAttempts.size >= maxAttempts) {
logger.warn("重置密码频率限制触发: attempts=${validAttempts.size}/$maxAttempts")
return Result.failure(IllegalStateException("频率限制:1分钟内最多尝试${maxAttempts}次,请稍后再试"))
if (validAttempts.size >= resetPasswordMaxAttempts) {
logger.warn("重置密码频率限制触发: attempts=${validAttempts.size}/$resetPasswordMaxAttempts")
return Result.failure(IllegalStateException("频率限制:1分钟内最多尝试${resetPasswordMaxAttempts}次,请稍后再试"))
}
// 记录本次尝试
validAttempts.add(now)
resetPasswordAttempts.set(validAttempts)
return Result.success(Unit)
}
/**
* 检查登录频率限制(按IP限制)
* @param ipAddress 客户端IP地址
* @return Result,如果被锁定或超过限制则返回失败
*/
fun checkLoginRateLimit(ipAddress: String): Result<Unit> {
val now = System.currentTimeMillis()
// 检查是否被锁定
val lockoutEndTime = loginLockouts[ipAddress]
if (lockoutEndTime != null) {
if (now < lockoutEndTime) {
val remainingSeconds = (lockoutEndTime - now) / 1000
logger.warn("登录锁定中: ip=$ipAddress, remainingSeconds=$remainingSeconds")
return Result.failure(IllegalStateException("账户已被锁定,请${remainingSeconds}秒后再试"))
} else {
// 锁定已过期,清除锁定记录
loginLockouts.remove(ipAddress)
loginFailedAttempts.remove(ipAddress)
}
}
return Result.success(Unit)
}
/**
* 记录登录失败尝试
* @param ipAddress 客户端IP地址
* @return 如果触发锁定返回锁定信息,否则返回 null
*/
fun recordLoginFailure(ipAddress: String): String? {
val now = System.currentTimeMillis()
val windowStart = now - (loginWindowSeconds * 1000)
// 获取或创建该IP的尝试记录
val attempts = loginFailedAttempts.computeIfAbsent(ipAddress) { mutableListOf() }
// 清理过期记录并添加新记录
synchronized(attempts) {
attempts.removeIf { it < windowStart }
attempts.add(now)
// 检查是否需要锁定
if (attempts.size >= loginMaxAttempts) {
val lockoutEndTime = now + (loginLockoutSeconds * 1000)
loginLockouts[ipAddress] = lockoutEndTime
logger.warn("登录锁定触发: ip=$ipAddress, attempts=${attempts.size}, lockoutSeconds=$loginLockoutSeconds")
return "登录失败次数过多,账户已被锁定${loginLockoutSeconds / 60}分钟"
}
}
val remainingAttempts = loginMaxAttempts - attempts.size
logger.warn("登录失败: ip=$ipAddress, attempts=${attempts.size}/$loginMaxAttempts, remainingAttempts=$remainingAttempts")
return null
}
/**
* 登录成功时清除失败记录
* @param ipAddress 客户端IP地址
*/
fun clearLoginFailures(ipAddress: String) {
loginFailedAttempts.remove(ipAddress)
loginLockouts.remove(ipAddress)
}
}
@@ -6,9 +6,11 @@ import com.wrbug.polymarketbot.util.gt
import com.wrbug.polymarketbot.util.lt
import com.wrbug.polymarketbot.util.multi
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import com.wrbug.polymarketbot.util.JsonUtils
import org.slf4j.LoggerFactory
import com.wrbug.polymarketbot.service.common.PolymarketClobService
import com.wrbug.polymarketbot.service.accounts.AccountService
import com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository
import org.springframework.stereotype.Service
import java.math.BigDecimal
@@ -18,7 +20,9 @@ import java.math.BigDecimal
@Service
class CopyTradingFilterService(
private val clobService: PolymarketClobService,
private val accountService: AccountService
private val accountService: AccountService,
private val copyOrderTrackingRepository: CopyOrderTrackingRepository,
private val jsonUtils: JsonUtils
) {
private val logger = LoggerFactory.getLogger(CopyTradingFilterService::class.java)
@@ -30,6 +34,8 @@ class CopyTradingFilterService(
* @param tradePrice Leader 交易价格,用于价格区间检查
* @param copyOrderAmount 跟单金额(USDC),用于仓位检查,如果为null则不进行仓位检查
* @param marketId 市场ID,用于仓位检查(按市场过滤仓位)
* @param marketTitle 市场标题,用于关键字过滤
* @param marketEndDate 市场截止时间,用于市场截止时间检查
* @return 过滤结果
*/
suspend fun checkFilters(
@@ -37,9 +43,27 @@ class CopyTradingFilterService(
tokenId: String,
tradePrice: BigDecimal? = null, // Leader 交易价格,用于价格区间检查
copyOrderAmount: BigDecimal? = null, // 跟单金额(USDC),用于仓位检查
marketId: String? = null // 市场ID,用于仓位检查(按市场过滤仓位)
marketId: String? = null, // 市场ID,用于仓位检查(按市场过滤仓位)
marketTitle: String? = null, // 市场标题,用于关键字过滤
marketEndDate: Long? = null // 市场截止时间,用于市场截止时间检查
): FilterResult {
// 1. 价格区间检查(如果配置了价格区间
// 1. 关键字过滤检查(如果配置了关键字过滤
if (copyTrading.keywordFilterMode != null && copyTrading.keywordFilterMode != "DISABLED") {
val keywordCheck = checkKeywordFilter(copyTrading, marketTitle)
if (!keywordCheck.isPassed) {
return keywordCheck
}
}
// 1.5. 市场截止时间检查(如果配置了市场截止时间限制)
if (copyTrading.maxMarketEndDate != null) {
val marketEndDateCheck = checkMarketEndDate(copyTrading, marketEndDate)
if (!marketEndDateCheck.isPassed) {
return marketEndDateCheck
}
}
// 2. 价格区间检查(如果配置了价格区间)
if (tradePrice != null) {
val priceRangeCheck = checkPriceRange(copyTrading, tradePrice)
if (!priceRangeCheck.isPassed) {
@@ -47,7 +71,7 @@ class CopyTradingFilterService(
}
}
// 2. 检查是否需要获取订单簿
// 3. 检查是否需要获取订单簿
// 只有在配置了需要订单簿的过滤条件时才获取
val needOrderbook = copyTrading.maxSpread != null || copyTrading.minOrderDepth != null
@@ -56,7 +80,7 @@ class CopyTradingFilterService(
return FilterResult.passed()
}
// 3. 获取订单簿(仅在需要时,只请求一次)
// 4. 获取订单簿(仅在需要时,只请求一次)
val orderbookResult = clobService.getOrderbookByTokenId(tokenId)
if (!orderbookResult.isSuccess) {
val error = orderbookResult.exceptionOrNull()
@@ -66,7 +90,7 @@ class CopyTradingFilterService(
val orderbook = orderbookResult.getOrNull()
?: return FilterResult.orderbookEmpty()
// 4. 买一卖一价差过滤(如果配置了)
// 5. 买一卖一价差过滤(如果配置了)
if (copyTrading.maxSpread != null) {
val spreadCheck = checkSpread(copyTrading, orderbook)
if (!spreadCheck.isPassed) {
@@ -74,7 +98,7 @@ class CopyTradingFilterService(
}
}
// 5. 订单深度过滤(如果配置了,检查所有方向)
// 6. 订单深度过滤(如果配置了,检查所有方向)
if (copyTrading.minOrderDepth != null) {
val depthCheck = checkOrderDepth(copyTrading, orderbook)
if (!depthCheck.isPassed) {
@@ -82,7 +106,7 @@ class CopyTradingFilterService(
}
}
// 6. 仓位检查(如果配置了最大仓位限制且提供了跟单金额和市场ID)
// 7. 仓位检查(如果配置了最大仓位限制且提供了跟单金额和市场ID)
if (copyOrderAmount != null && marketId != null) {
val positionCheck = checkPositionLimits(copyTrading, copyOrderAmount, marketId)
if (!positionCheck.isPassed) {
@@ -93,6 +117,65 @@ class CopyTradingFilterService(
return FilterResult.passed(orderbook)
}
/**
* 检查关键字过滤
* @param copyTrading 跟单配置
* @param marketTitle 市场标题
* @return 过滤结果
*/
private fun checkKeywordFilter(
copyTrading: CopyTrading,
marketTitle: String?
): FilterResult {
// 如果未启用关键字过滤,直接通过
if (copyTrading.keywordFilterMode == null || copyTrading.keywordFilterMode == "DISABLED") {
return FilterResult.passed()
}
// 如果没有市场标题,无法进行关键字过滤,为了安全起见,不通过
if (marketTitle.isNullOrBlank()) {
return FilterResult.keywordFilterFailed("市场标题为空,无法进行关键字过滤")
}
// 解析关键字列表
val keywords = jsonUtils.parseStringArray(copyTrading.keywords)
if (keywords.isEmpty()) {
// 如果关键字列表为空,白名单模式不通过,黑名单模式通过
return if (copyTrading.keywordFilterMode == "WHITELIST") {
FilterResult.keywordFilterFailed("白名单模式但关键字列表为空")
} else {
FilterResult.passed()
}
}
// 将市场标题转换为小写,用于不区分大小写的匹配
val titleLower = marketTitle.lowercase()
// 检查市场标题是否包含任意关键字
val containsKeyword = keywords.any { keyword ->
titleLower.contains(keyword.lowercase())
}
// 根据过滤模式决定是否通过
return when (copyTrading.keywordFilterMode) {
"WHITELIST" -> {
if (containsKeyword) {
FilterResult.passed()
} else {
FilterResult.keywordFilterFailed("白名单模式:市场标题不包含任何关键字。市场标题:$marketTitle,关键字列表:${keywords.joinToString(", ")}")
}
}
"BLACKLIST" -> {
if (containsKeyword) {
FilterResult.keywordFilterFailed("黑名单模式:市场标题包含关键字。市场标题:$marketTitle,匹配的关键字:${keywords.filter { titleLower.contains(it.lowercase()) }.joinToString(", ")}")
} else {
FilterResult.passed()
}
}
else -> FilterResult.passed()
}
}
/**
* 检查价格区间
* @param copyTrading 跟单配置
@@ -234,32 +317,46 @@ class CopyTradingFilterService(
// 检查最大仓位金额(如果配置了)
if (copyTrading.maxPositionValue != null) {
// 计算该市场的当前仓位总价值(累加该市场所有仓位的 currentValue
val currentPositionValue = marketPositions.sumOf { position ->
position.currentValue.toSafeBigDecimal()
}
// 比较数据库成本价(本地订单记录)和外部持仓市值(可能来自其他终端的操作),取最大值
val dbValue = copyOrderTrackingRepository.sumCurrentPositionValueByMarket(copyTrading.id!!, marketId) ?: BigDecimal.ZERO
val extValue = marketPositions.sumOf { it.currentValue.toSafeBigDecimal() }
val currentPositionValue = dbValue.max(extValue)
// 检查:该市场的当前仓位 + 跟单金额 <= 最大仓位金额
val totalValueAfterOrder = currentPositionValue.add(copyOrderAmount)
if (totalValueAfterOrder.gt(copyTrading.maxPositionValue)) {
return FilterResult.maxPositionValueFailed(
"超过最大仓位金额限制: 当前该市场仓位=${currentPositionValue} USDC, 跟单金额=${copyOrderAmount} USDC, 总计=${totalValueAfterOrder} USDC > 最大限制=${copyTrading.maxPositionValue} USDC"
"超过最大仓位金额限制: 当前该市场仓位(取最大值)=${currentPositionValue} USDC (DB=${dbValue}, Ext=${extValue}), 跟单金额=${copyOrderAmount} USDC, 总计=${totalValueAfterOrder} USDC > 最大限制=${copyTrading.maxPositionValue} USDC"
)
}
}
// 检查最大仓位数量(如果配置了)
if (copyTrading.maxPositionCount != null) {
// 计算该市场的当前仓位数量(该市场不同方向的仓位算不同仓位
val currentPositionCount = marketPositions.size
// 使用数据库中的订单记录计算活跃仓位数量(解决延迟问题
val dbCount = copyOrderTrackingRepository.countActivePositions(copyTrading.id!!)
// 检查:该市场的当前仓位数量 <= 最大仓位数量
// 注意:如果该市场已有仓位,跟单可能会增加新的仓位(不同方向)或增加现有仓位
// 为了简化,我们检查当前该市场的仓位数量是否已经达到或超过限制
if (currentPositionCount >= copyTrading.maxPositionCount) {
// 计算外部持仓中的唯一市场数量(防止遗漏非本项目创建的仓位)
val extCount = positions.currentPositions
.filter { it.accountId == copyTrading.accountId }
.map { it.marketId }
.distinct()
.size
val currentPositionCount = maxOf(dbCount, extCount)
// 检查:如果当前没有该市场的活跃仓位,且总仓位数量已达到限制,则不允许开新仓
// 判断当前市场是否已有活跃仓位(数据库或外部持仓)
val hasDbPosition = copyOrderTrackingRepository.existsByCopyTradingIdAndMarketIdAndRemainingQuantityGreaterThan(
copyTrading.id, marketId, BigDecimal.ZERO
)
val hasExtPosition = marketPositions.isNotEmpty()
val hasCurrentMarketPosition = hasDbPosition || hasExtPosition
if (!hasCurrentMarketPosition && currentPositionCount >= copyTrading.maxPositionCount) {
return FilterResult.maxPositionCountFailed(
"超过最大仓位数量限制: 当前该市场仓位数量=${currentPositionCount} >= 最大限制=${copyTrading.maxPositionCount}"
"超过最大仓位数量限制: 当前活跃仓位总数(取最大值)=${currentPositionCount} (DB=${dbCount}, Ext=${extCount}) >= 最大限制=${copyTrading.maxPositionCount}"
)
}
}
@@ -271,5 +368,38 @@ class CopyTradingFilterService(
return FilterResult.maxPositionValueFailed("仓位检查异常: ${e.message}")
}
}
/**
* 检查市场截止时间
* @param copyTrading 跟单配置
* @param marketEndDate 市场截止时间(毫秒时间戳)
* @return 过滤结果
*/
private fun checkMarketEndDate(
copyTrading: CopyTrading,
marketEndDate: Long?
): FilterResult {
// 如果未配置市场截止时间限制,直接通过
if (copyTrading.maxMarketEndDate == null) {
return FilterResult.passed()
}
// 如果没有市场截止时间,无法检查,为了安全起见,不通过
if (marketEndDate == null) {
return FilterResult.marketEndDateFailed("市场缺少截止时间信息,无法进行市场截止时间检查")
}
// 检查:市场截止时间 - 当前时间 <= 最大限制时间
val currentTime = System.currentTimeMillis()
val remainingTime = marketEndDate - currentTime
if (remainingTime > copyTrading.maxMarketEndDate) {
return FilterResult.marketEndDateFailed(
"市场截止时间超出限制: 剩余时间=${remainingTime}ms (${remainingTime / (1000 * 60 * 60)}小时) > 最大限制=${copyTrading.maxMarketEndDate}ms (${copyTrading.maxMarketEndDate / (1000 * 60 * 60)}小时)"
)
}
return FilterResult.passed()
}
}
@@ -9,6 +9,9 @@ import com.wrbug.polymarketbot.repository.CopyTradingRepository
import com.wrbug.polymarketbot.repository.CopyTradingTemplateRepository
import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.service.copytrading.monitor.CopyTradingMonitorService
import com.google.gson.Gson
import com.wrbug.polymarketbot.util.IllegalBigDecimal
import com.wrbug.polymarketbot.util.JsonUtils
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
@@ -24,7 +27,9 @@ class CopyTradingService(
private val accountRepository: AccountRepository,
private val templateRepository: CopyTradingTemplateRepository,
private val leaderRepository: LeaderRepository,
private val monitorService: CopyTradingMonitorService
private val monitorService: CopyTradingMonitorService,
private val jsonUtils: JsonUtils,
private val gson: Gson
) {
private val logger = LoggerFactory.getLogger(CopyTradingService::class.java)
@@ -46,16 +51,7 @@ class CopyTradingService(
val leader = leaderRepository.findById(request.leaderId).orElse(null)
?: return Result.failure(IllegalArgumentException("Leader 不存在"))
// 3. 检查是否已存在相同的跟单关系(accountId + leaderId
val existing = copyTradingRepository.findByAccountIdAndLeaderId(
request.accountId,
request.leaderId
)
if (existing != null) {
return Result.failure(IllegalArgumentException("该跟单关系已存在"))
}
// 4. 验证配置名(强校验:不能为空字符串)
// 3. 验证配置名(强校验:不能为空字符串
val configName = request.configName?.trim()
if (configName.isNullOrBlank()) {
return Result.failure(IllegalArgumentException("配置名不能为空"))
@@ -88,7 +84,11 @@ class CopyTradingService(
minPrice = request.minPrice?.toSafeBigDecimal() ?: template.minPrice,
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice,
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
maxPositionCount = request.maxPositionCount
maxPositionCount = request.maxPositionCount,
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
keywords = convertKeywordsToJson(request.keywords),
maxMarketEndDate = request.maxMarketEndDate,
pushFilteredOrders = request.pushFilteredOrders ?: template.pushFilteredOrders
)
} else {
// 手动输入(所有字段必须提供)
@@ -116,7 +116,11 @@ class CopyTradingService(
minPrice = request.minPrice?.toSafeBigDecimal(),
maxPrice = request.maxPrice?.toSafeBigDecimal(),
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
maxPositionCount = request.maxPositionCount
maxPositionCount = request.maxPositionCount,
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
keywords = convertKeywordsToJson(request.keywords),
maxMarketEndDate = request.maxMarketEndDate,
pushFilteredOrders = request.pushFilteredOrders ?: false // 手动输入时使用请求中的值,默认为 false
)
}
@@ -145,8 +149,12 @@ class CopyTradingService(
maxPrice = config.maxPrice,
maxPositionValue = config.maxPositionValue,
maxPositionCount = config.maxPositionCount,
keywordFilterMode = config.keywordFilterMode,
keywords = config.keywords,
configName = configName,
pushFailedOrders = request.pushFailedOrders ?: false
pushFailedOrders = request.pushFailedOrders ?: false,
maxMarketEndDate = config.maxMarketEndDate,
pushFilteredOrders = config.pushFilteredOrders
)
val saved = copyTradingRepository.save(copyTrading)
@@ -207,14 +215,88 @@ class CopyTradingService(
websocketReconnectInterval = request.websocketReconnectInterval ?: copyTrading.websocketReconnectInterval,
websocketMaxRetries = request.websocketMaxRetries ?: copyTrading.websocketMaxRetries,
supportSell = request.supportSell ?: copyTrading.supportSell,
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: copyTrading.minOrderDepth,
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: copyTrading.maxSpread,
minPrice = request.minPrice?.toSafeBigDecimal() ?: copyTrading.minPrice,
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: copyTrading.maxPrice,
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal() ?: copyTrading.maxPositionValue,
maxPositionCount = request.maxPositionCount ?: copyTrading.maxPositionCount,
// 处理可选字段:空字符串表示要清空(设置为 null),null 表示不更新,转换失败保留旧值
minOrderDepth = if (request.minOrderDepth != null) {
if (request.minOrderDepth.isBlank()) {
null
} else {
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,
keywords = if (request.keywords != null) {
convertKeywordsToJson(request.keywords)
} else if (request.keywordFilterMode != null && request.keywordFilterMode == "DISABLED") {
null
} else {
copyTrading.keywords
},
configName = configName,
pushFailedOrders = request.pushFailedOrders ?: copyTrading.pushFailedOrders,
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()
)
@@ -264,11 +346,10 @@ class CopyTradingService(
return try {
val copyTradings = when {
request.accountId != null && request.leaderId != null -> {
val found = copyTradingRepository.findByAccountIdAndLeaderId(
copyTradingRepository.findByAccountIdAndLeaderId(
request.accountId,
request.leaderId
)
if (found != null) listOf(found) else emptyList()
}
request.accountId != null -> {
copyTradingRepository.findByAccountId(request.accountId)
@@ -424,13 +505,47 @@ class CopyTradingService(
maxPrice = copyTrading.maxPrice?.toPlainString(),
maxPositionValue = copyTrading.maxPositionValue?.toPlainString(),
maxPositionCount = copyTrading.maxPositionCount,
keywordFilterMode = copyTrading.keywordFilterMode,
keywords = convertJsonToKeywords(copyTrading.keywords),
configName = copyTrading.configName,
pushFailedOrders = copyTrading.pushFailedOrders,
pushFilteredOrders = copyTrading.pushFilteredOrders,
maxMarketEndDate = copyTrading.maxMarketEndDate,
createdAt = copyTrading.createdAt,
updatedAt = copyTrading.updatedAt
)
}
/**
* 将关键字列表转换为 JSON 字符串
*/
private fun convertKeywordsToJson(keywords: List<String>?): String? {
if (keywords == null || keywords.isEmpty()) {
return null
}
return try {
gson.toJson(keywords)
} catch (e: Exception) {
logger.error("转换关键字列表为 JSON 失败", e)
null
}
}
/**
* JSON 字符串转换为关键字列表
*/
private fun convertJsonToKeywords(jsonString: String?): List<String>? {
if (jsonString.isNullOrBlank()) {
return null
}
return try {
jsonUtils.parseStringArray(jsonString)
} catch (e: Exception) {
logger.error("解析关键字 JSON 失败", e)
null
}
}
/**
* 内部配置类用于构建 CopyTrading 实体
*/
@@ -454,6 +569,10 @@ class CopyTradingService(
val minPrice: BigDecimal?,
val maxPrice: BigDecimal?,
val maxPositionValue: BigDecimal?,
val maxPositionCount: Int?
val maxPositionCount: Int?,
val keywordFilterMode: String,
val keywords: String?, // JSON 字符串
val maxMarketEndDate: Long?, // 市场截止时间限制(毫秒时间戳)
val pushFilteredOrders: Boolean // 推送已过滤订单(默认关闭)
)
}
@@ -21,7 +21,11 @@ enum class FilterStatus {
/** 失败:超过最大仓位金额 */
FAILED_MAX_POSITION_VALUE,
/** 失败:超过最大仓位数量 */
FAILED_MAX_POSITION_COUNT
FAILED_MAX_POSITION_COUNT,
/** 失败:关键字过滤 */
FAILED_KEYWORD_FILTER,
/** 失败:市场截止时间超出限制 */
FAILED_MARKET_END_DATE
}
/**
@@ -89,6 +93,18 @@ data class FilterResult(
status = FilterStatus.FAILED_MAX_POSITION_COUNT,
reason = reason
)
/** 关键字过滤失败 */
fun keywordFilterFailed(reason: String) = FilterResult(
status = FilterStatus.FAILED_KEYWORD_FILTER,
reason = reason
)
/** 市场截止时间超出限制 */
fun marketEndDateFailed(reason: String) = FilterResult(
status = FilterStatus.FAILED_MARKET_END_DATE,
reason = reason
)
}
}
@@ -14,7 +14,9 @@ import org.springframework.stereotype.Service
/**
* 跟单监听服务主服务
* 管理所有Leader的交易监听
* 同时运行链上 WebSocket 监听和轮询监听并行处理
* 使用双重监听机制
* 1. Activity WebSocket - 低延迟< 100ms优先使用
* 2. OnChain WebSocket - 高可靠性~2-3s作为兜底
* 同时监听跟单账户的卖出/赎回事件通过链上 WebSocket
*/
@Service
@@ -22,7 +24,7 @@ class CopyTradingMonitorService(
private val copyTradingRepository: CopyTradingRepository,
private val leaderRepository: LeaderRepository,
private val accountRepository: AccountRepository,
private val pollingService: CopyTradingPollingService,
private val activityWsService: PolymarketActivityWsService,
private val onChainWsService: OnChainWsService,
private val accountOnChainMonitorService: AccountOnChainMonitorService
) {
@@ -51,15 +53,17 @@ class CopyTradingMonitorService(
@PreDestroy
fun destroy() {
scope.cancel()
// 停止轮询和链上 WS 监听
pollingService.stop()
// 停止所有监听
activityWsService.stop()
onChainWsService.stop()
accountOnChainMonitorService.stop()
}
/**
* 启动监听
* 同时启动链上 WebSocket 监听和轮询监听并行运行
* 启动双重监听机制
* 1. Activity WebSocket - 低延迟< 100ms优先使用
* 2. OnChain WebSocket - 高可靠性~2-3s作为兜底
* 同时启动跟单账户的链上 WebSocket 监听用于检测卖出/赎回事件
*/
suspend fun startMonitoring() {
@@ -82,14 +86,13 @@ class CopyTradingMonitorService(
accountRepository.findById(accountId).orElse(null)
}
// 4. 同时启动链上 WebSocket 监听和轮询监听(并行运行
// 链上 WS 监听 Leader 的交易(实时,秒级延迟)
// 4. 启动 Activity WebSocket 监听(优先,低延迟
activityWsService.start(leaders)
// 5. 启动链上 WebSocket 监听(兜底,高可靠性)
onChainWsService.start(leaders)
// 轮询监听 Leader 的交易(延迟,2秒间隔,作为备份
pollingService.start(leaders)
// 5. 启动跟单账户的链上 WebSocket 监听(用于检测卖出/赎回事件)
// 6. 启动跟单账户的链上 WebSocket 监听(用于检测卖出/赎回事件
accountOnChainMonitorService.start(accounts)
}
@@ -106,9 +109,9 @@ class CopyTradingMonitorService(
return
}
// 同时添加到链上 WS 监听和轮询监听(如果不在列表中才添加)
// 同时添加到两种监听
activityWsService.addLeader(leader)
onChainWsService.addLeader(leader)
pollingService.addLeader(leader)
}
/**
@@ -122,9 +125,9 @@ class CopyTradingMonitorService(
return
}
// 没有启用的跟单配置了,移除监听
// 没有启用的跟单配置了,同时从两种监听移除
activityWsService.removeLeader(leaderId)
onChainWsService.removeLeader(leaderId)
pollingService.removeLeader(leaderId)
}
/**
@@ -138,8 +141,8 @@ class CopyTradingMonitorService(
if (copyTradings.isNotEmpty()) {
// 有启用的跟单配置,确保在监听列表中
activityWsService.addLeader(leader)
onChainWsService.addLeader(leader)
pollingService.addLeader(leader)
// 更新账户监听(添加该配置关联的账户)
val accountIds = copyTradings.map { it.accountId }.distinct()
@@ -150,9 +153,9 @@ class CopyTradingMonitorService(
}
}
} else {
// 没有启用的跟单配置,移除监听
// 没有启用的跟单配置,同时从两种监听移除
activityWsService.removeLeader(leaderId)
onChainWsService.removeLeader(leaderId)
pollingService.removeLeader(leaderId)
}
}
@@ -181,8 +184,8 @@ class CopyTradingMonitorService(
*/
suspend fun restartMonitoring() {
// 停止所有监听
activityWsService.stop()
onChainWsService.stop()
pollingService.stop()
delay(1000) // 等待1秒
startMonitoring()
}
@@ -1,279 +0,0 @@
package com.wrbug.polymarketbot.service.copytrading.monitor
import com.wrbug.polymarketbot.api.TradeResponse
import com.wrbug.polymarketbot.api.UserActivityResponse
import com.wrbug.polymarketbot.entity.Leader
import com.wrbug.polymarketbot.repository.CopyTradingTemplateRepository
import com.wrbug.polymarketbot.util.RetrofitFactory
import jakarta.annotation.PreDestroy
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
import org.springframework.stereotype.Service
import retrofit2.Response
import java.util.concurrent.ConcurrentHashMap
/**
* 跟单轮询监听服务
* 通过定期轮询 Polymarket Data API /activity 接口获取Leader的交易记录
* 使用 /activity 接口可以查询用户的链上活动包括交易
*/
@Service
class CopyTradingPollingService(
private val copyOrderTrackingService: CopyOrderTrackingService,
private val retrofitFactory: RetrofitFactory,
private val templateRepository: CopyTradingTemplateRepository
) {
private val logger = LoggerFactory.getLogger(CopyTradingPollingService::class.java)
@Value("\${copy.trading.polling.interval:2000}")
private var pollingInterval: Long = 2000 // 轮询间隔(毫秒),默认2秒
@Value("\${copy.trading.polling.enabled:true}")
private var pollingEnabled: Boolean = true // 是否启用轮询
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
// 存储需要监听的LeaderleaderId -> Leader
private val monitoredLeaders = ConcurrentHashMap<Long, Leader>()
// 存储每个Leader已缓存的交易ID集合:leaderId -> Set<tradeId>
private val cachedTradeIds = ConcurrentHashMap<Long, MutableSet<String>>()
// 存储每个Leader是否首次轮询:leaderId -> isFirstPoll
private val isFirstPoll = ConcurrentHashMap<Long, Boolean>()
// 轮询任务
private var pollingJob: Job? = null
/**
* 启动轮询监听
*/
fun start(leaders: List<Leader>) {
if (!pollingEnabled) {
return
}
leaders.forEach { leader ->
addLeader(leader)
}
// 启动轮询任务
startPolling()
}
/**
* 添加Leader监听
*/
fun addLeader(leader: Leader) {
if (leader.id == null) {
logger.warn("Leader ID为空,跳过: ${leader.leaderAddress}")
return
}
val leaderId = leader.id
monitoredLeaders[leaderId] = leader
// 初始化缓存的交易ID集合
cachedTradeIds[leaderId] = mutableSetOf()
// 首次轮询标志,用于缓存数据而不处理
isFirstPoll[leaderId] = true
// 如果轮询任务没有运行,启动它
startPolling()
}
/**
* 移除Leader监听
*/
fun removeLeader(leaderId: Long) {
monitoredLeaders.remove(leaderId)
cachedTradeIds.remove(leaderId)
isFirstPoll.remove(leaderId)
// 如果没有需要监听的Leader了,停止轮询任务
if (monitoredLeaders.isEmpty()) {
stopPolling()
}
}
/**
* 停止所有监听
*/
fun stop() {
stopPolling()
monitoredLeaders.clear()
cachedTradeIds.clear()
isFirstPoll.clear()
}
/**
* 启动轮询任务
*/
private fun startPolling() {
if (pollingJob != null && pollingJob!!.isActive) {
return
}
if (monitoredLeaders.isEmpty()) {
return
}
pollingJob = scope.launch {
while (isActive) {
try {
// 轮询所有Leader的交易
pollAllLeaders()
// 等待下一次轮询
delay(pollingInterval)
} catch (e: Exception) {
logger.error("轮询任务异常", e)
delay(pollingInterval) // 异常后继续等待
}
}
}
}
/**
* 停止轮询任务
*/
private fun stopPolling() {
pollingJob?.cancel()
pollingJob = null
}
/**
* 轮询所有Leader的交易
*/
private suspend fun pollAllLeaders() {
val leaders = monitoredLeaders.values.toList()
if (leaders.isEmpty()) {
return
}
// 并发轮询所有Leader(限制并发数)
leaders.chunked(10).forEach { chunk ->
chunk.forEach { leader ->
try {
pollLeaderTrades(leader)
} catch (e: Exception) {
logger.error("轮询Leader交易失败: leaderId=${leader.id}, address=${leader.leaderAddress}", e)
}
}
// 每个chunk之间稍作延迟,避免API限流
delay(100)
}
}
/**
* 轮询单个Leader的交易
* 使用 Polymarket Data API /activity 接口
* 通过 diff 分析增量数据不使用 start 字段
*/
private suspend fun pollLeaderTrades(leader: Leader) {
if (leader.id == null) {
return
}
val leaderId = leader.id
val leaderAddress = leader.leaderAddress
try {
val firstPoll = isFirstPoll[leaderId] == true
val cachedIds = cachedTradeIds[leaderId] ?: mutableSetOf()
// 创建 Data API 客户端(不需要认证)
val dataApi = retrofitFactory.createDataApi()
// 查询用户活动(只查询交易类型,不使用 start 字段)
// 查询最近的数据(limit=100),通过 diff 找出增量
val response: Response<List<UserActivityResponse>> = dataApi.getUserActivity(
user = leaderAddress,
limit = 100, // 每次最多查询100条
offset = 0,
type = listOf("TRADE"), // 只查询交易类型
start = null, // 不使用 start 字段
sortBy = "TIMESTAMP",
sortDirection = "DESC" // 按时间戳降序,最新的在前
)
if (!response.isSuccessful || response.body() == null) {
logger.warn("获取Leader活动失败: leaderId=$leaderId, address=$leaderAddress, code=${response.code()}, message=${response.message()}")
return
}
val activities = response.body()!!
// 将 UserActivityResponse 转换为 TradeResponse
val allTrades = activities.mapNotNull { activity ->
// 只处理交易类型
if (activity.type != "TRADE" || activity.side == null || activity.price == null || activity.size == null) {
return@mapNotNull null
}
// 转换为 TradeResponse
TradeResponse(
id = activity.transactionHash ?: "${activity.timestamp}_${activity.conditionId}",
market = activity.conditionId,
side = activity.side, // BUY 或 SELL
price = activity.price.toString(),
size = activity.size.toString(),
timestamp = activity.timestamp.toString(), // 时间戳(秒)
user = activity.proxyWallet,
outcomeIndex = activity.outcomeIndex, // 结果索引(0=YES, 1=NO
outcome = activity.outcome // 结果名称
)
}
if (firstPoll) {
// 首次轮询:缓存所有查询到的交易ID,不处理
val tradeIds = allTrades.map { it.id }.toSet()
cachedIds.addAll(tradeIds)
cachedTradeIds[leaderId] = cachedIds
// 标记首次轮询完成
isFirstPoll[leaderId] = false
} else {
// 后续轮询:通过 diff 找出新增的交易
val newTradeIds = allTrades.map { it.id }.toSet()
val incrementalTradeIds = newTradeIds - cachedIds
if (incrementalTradeIds.isNotEmpty()) {
// 找出新增的交易
val incrementalTrades = allTrades.filter { it.id in incrementalTradeIds }
// 处理新增的交易
incrementalTrades.forEach { trade ->
try {
// 检查是否已处理(去重由processTrade内部处理)
copyOrderTrackingService.processTrade(leaderId, trade, "polling")
} catch (e: Exception) {
logger.error("处理交易失败: leaderId=$leaderId, tradeId=${trade.id}", e)
}
}
// 更新缓存:添加新增的交易ID
cachedIds.addAll(incrementalTradeIds)
cachedTradeIds[leaderId] = cachedIds
} else {
}
// 限制缓存大小,避免内存溢出(只保留最近1000条)
if (cachedIds.size > 1000) {
// 保留最新的1000条(由于查询是按时间戳降序,保留前1000条即可)
val sortedTradeIds = allTrades.map { it.id }.take(1000).toSet()
cachedTradeIds[leaderId] = sortedTradeIds.toMutableSet()
}
}
} catch (e: Exception) {
logger.error("轮询Leader交易异常: leaderId=$leaderId, address=$leaderAddress", e)
}
}
}
@@ -22,15 +22,14 @@ import java.util.concurrent.ConcurrentHashMap
@Service
class CopyTradingWebSocketService(
private val copyOrderTrackingService: CopyOrderTrackingService,
private val templateRepository: CopyTradingTemplateRepository
private val templateRepository: CopyTradingTemplateRepository,
private val gson: Gson
) {
private val logger = LoggerFactory.getLogger(CopyTradingWebSocketService::class.java)
@Value("\${polymarket.websocket.url:wss://ws-live-data.polymarket.com}")
private var websocketUrl: String = "wss://ws-live-data.polymarket.com"
private val gson = Gson()
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
// 存储每个Leader的WebSocket客户端:leaderId -> WebSocketClient
@@ -89,6 +89,8 @@ class OnChainWsService(
private suspend fun handleLeaderTransaction(leaderId: Long, txHash: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
val leader = monitoredLeaders[leaderId] ?: return
logger.debug("开始处理 Leader 交易: leaderId=$leaderId, txHash=$txHash, leaderAddress=${leader.leaderAddress}")
try {
// 获取交易 receipt
val receiptRequest = JsonRpcRequest(
@@ -98,11 +100,13 @@ class OnChainWsService(
val receiptResponse = rpcApi.call(receiptRequest)
if (!receiptResponse.isSuccessful || receiptResponse.body() == null) {
logger.warn("获取交易 receipt 失败: leaderId=$leaderId, txHash=$txHash, code=${receiptResponse.code()}")
return
}
val receiptRpcResponse = receiptResponse.body()!!
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null) {
logger.warn("交易 receipt 错误: leaderId=$leaderId, txHash=$txHash, error=${receiptRpcResponse.error}")
return
}
@@ -118,8 +122,12 @@ class OnChainWsService(
}
// 解析 receipt 中的 Transfer 日志
val logs = receiptJson.getAsJsonArray("logs") ?: return
val logs = receiptJson.getAsJsonArray("logs") ?: run {
logger.warn("交易 receipt 中没有日志: leaderId=$leaderId, txHash=$txHash")
return
}
val (erc20Transfers, erc1155Transfers) = OnChainWsUtils.parseReceiptTransfers(logs)
logger.debug("解析交易日志: leaderId=$leaderId, txHash=$txHash, erc20Transfers=${erc20Transfers.size}, erc1155Transfers=${erc1155Transfers.size}")
// 解析交易信息
val trade = OnChainWsUtils.parseTradeFromTransfers(
@@ -132,12 +140,15 @@ class OnChainWsService(
)
if (trade != null) {
logger.info("成功解析交易: leaderId=$leaderId, txHash=$txHash, side=${trade.side}, market=${trade.market}, size=${trade.size}")
// 调用 processTrade 处理交易
copyOrderTrackingService.processTrade(
leaderId = leaderId,
trade = trade,
source = "onchain-ws"
)
} else {
logger.warn("无法解析交易(返回 null: leaderId=$leaderId, txHash=$txHash, erc20Transfers=${erc20Transfers.size}, erc1155Transfers=${erc1155Transfers.size}")
}
} catch (e: Exception) {
logger.error("处理 Leader 交易失败: leaderId=$leaderId, txHash=$txHash, ${e.message}", e)
@@ -1,5 +1,7 @@
package com.wrbug.polymarketbot.service.copytrading.monitor
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.JsonArray
import com.google.gson.reflect.TypeToken
import com.wrbug.polymarketbot.api.*
@@ -19,6 +21,29 @@ object OnChainWsUtils {
private val logger = LoggerFactory.getLogger(OnChainWsUtils::class.java)
// 创建 Gson 实例(与 GsonConfig 中的配置一致,使用 lenient 模式)
private val gson: Gson = GsonBuilder()
.setLenient()
.create()
/**
* 解析 JSON 字符串数组
* @param jsonString JSON 字符串 "[\"Yes\", \"No\"]"
* @return 字符串列表如果解析失败返回空列表
*/
private fun parseStringArray(jsonString: String?): List<String> {
if (jsonString.isNullOrBlank()) {
return emptyList()
}
return try {
val listType = object : TypeToken<List<String>>() {}.type
gson.fromJson<List<String>>(jsonString, listType) ?: emptyList()
} catch (e: Exception) {
emptyList()
}
}
// 合约地址
const val USDC_CONTRACT = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
const val ERC1155_CONTRACT = "0x4d97dcd97ec945f40cf65f87097ace5ea0476045"
@@ -182,6 +207,7 @@ object OnChainWsUtils {
usdcRaw = usdcIn
} else {
// 无法判断交易方向
logger.debug("无法判断交易方向: txHash=$txHash, bestInId=$bestInId, bestInVal=$bestInVal, bestOutId=$bestOutId, bestOutVal=$bestOutVal, usdcOut=$usdcOut, usdcIn=$usdcIn")
return null
}
@@ -240,24 +266,17 @@ object OnChainWsUtils {
val clobTokenIds = when {
clobTokenIdsRaw == null -> null
else -> {
try {
// 尝试解析 JSON 字符串
val gson = com.google.gson.Gson()
val listType = object : com.google.gson.reflect.TypeToken<List<String>>() {}.type
gson.fromJson<List<String>>(clobTokenIdsRaw, listType)
} catch (e: Exception) {
// 如果不是 JSON 字符串,可能是其他格式,返回 null
null
}
// 解析 JSON 字符串
parseStringArray(clobTokenIdsRaw)
}
}
// 解析 outcomes(可能是 JSON 字符串或数组)
val outcomes = com.wrbug.polymarketbot.util.JsonUtils.parseStringArray(market.outcomes)
val outcomes = parseStringArray(market.outcomes)
// 查找 tokenId 在 clobTokenIds 中的索引
val outcomeIndex = clobTokenIds?.indexOfFirst {
it.equals(tokenId, ignoreCase = true)
val outcomeIndex = clobTokenIds?.indexOfFirst { token ->
token.equals(tokenId, ignoreCase = true)
}?.takeIf { it >= 0 }
// 获取 outcome 名称
@@ -0,0 +1,412 @@
package com.wrbug.polymarketbot.service.copytrading.monitor
import com.wrbug.polymarketbot.api.TradeResponse
import com.wrbug.polymarketbot.dto.ActivityTradeMessage
import com.wrbug.polymarketbot.dto.ActivityTradePayload
import com.wrbug.polymarketbot.entity.Leader
import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
import com.wrbug.polymarketbot.util.fromJson
import com.wrbug.polymarketbot.websocket.PolymarketWebSocketClient
import jakarta.annotation.PreDestroy
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.util.concurrent.ConcurrentHashMap
/**
* Polymarket Activity WebSocket 监听服务
* 通过订阅全局 activity 交易流客户端过滤 Leader 地址实现实时交易检测
* 延迟 < 100ms适合快速跟单场景
*/
@Service
class PolymarketActivityWsService(
private val copyOrderTrackingService: CopyOrderTrackingService,
private val leaderRepository: LeaderRepository
) {
private val logger = LoggerFactory.getLogger(PolymarketActivityWsService::class.java)
@Value("\${polymarket.websocket.activity.url:wss://ws-live-data.polymarket.com}")
private var websocketUrl: String = "wss://ws-live-data.polymarket.com"
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
// 单例 WebSocket 客户端
private var wsClient: PolymarketWebSocketClient? = null
// 要监听的 Leader 地址集合(小写地址 -> leaderId
private val monitoredAddresses = ConcurrentHashMap<String, Long>()
// 是否已订阅
@Volatile
private var isSubscribed = false
/**
* 启动监听
*/
fun start(leaders: List<Leader>) {
monitoredAddresses.clear()
leaders.forEach { leader ->
val leaderId = leader.id
if (leaderId != null) {
monitoredAddresses[leader.leaderAddress.lowercase()] = leaderId
}
}
if (monitoredAddresses.isEmpty()) {
logger.info("没有需要监听的 Leader,停止 Activity WebSocket")
stop()
return
}
logger.info("启动 Activity WebSocket 监听,监控 ${monitoredAddresses.size} 个 Leader 地址")
connectAndSubscribe()
}
/**
* 添加 Leader
*/
fun addLeader(leader: Leader) {
if (leader.id == null) {
logger.warn("Leader ID 为空,跳过: ${leader.leaderAddress}")
return
}
val address = leader.leaderAddress.lowercase()
val existingLeaderId = monitoredAddresses[address]
if (existingLeaderId != null && existingLeaderId == leader.id) {
logger.debug("Leader 已在监听列表中: ${leader.leaderName} (${address})")
return
}
val leaderId = leader.id
if (leaderId != null) {
monitoredAddresses[address] = leaderId
logger.info("添加 Leader 到 Activity WS 监听: ${leader.leaderName} (${address})")
// 如果 WebSocket 未连接,连接
val client = wsClient
if (client == null || !client.isConnected()) {
connectAndSubscribe()
}
}
}
/**
* 移除 Leader
*/
fun removeLeader(leaderId: Long) {
val addressToRemove = monitoredAddresses.entries
.find { it.value == leaderId }?.key
if (addressToRemove != null) {
monitoredAddresses.remove(addressToRemove)
logger.info("从 Activity WS 监听移除 Leader: leaderId=$leaderId, address=$addressToRemove")
}
// 如果没有 Leader 了,停止监听
if (monitoredAddresses.isEmpty()) {
logger.info("没有 Leader 需要监听了,停止 Activity WebSocket")
stop()
}
}
/**
* 连接并订阅
*/
private fun connectAndSubscribe() {
val existingClient = wsClient
if (existingClient != null && existingClient.isConnected()) {
// 如果已连接但未订阅,发送订阅
if (!isSubscribed) {
subscribeAllActivity()
}
return
}
logger.info("连接 Activity WebSocket: $websocketUrl")
val newClient = PolymarketWebSocketClient(
url = websocketUrl,
sessionId = "copy-trading-activity",
onMessage = { message -> handleMessage(message) },
onOpen = {
logger.info("Activity WebSocket 连接成功")
subscribeAllActivity()
},
onReconnect = {
logger.info("Activity WebSocket 重连成功,重新订阅")
subscribeAllActivity()
}
)
wsClient = newClient
scope.launch {
try {
newClient.connect()
} catch (e: Exception) {
logger.error("连接 Activity WebSocket 失败", e)
}
}
}
/**
* 订阅全局 activity
* 根据 @polymarket/real-time-data-client 的协议格式
* 使用 "action": "subscribe" 而不是 "type": "subscribe"
*/
private fun subscribeAllActivity() {
val client = wsClient
if (client == null || !client.isConnected()) {
logger.warn("WebSocket 未连接,无法订阅")
return
}
try {
// 根据 real-time-data-client 的协议格式
// 订阅消息应包含 "action": "subscribe" 和 "subscriptions" 数组
val subscribeMessage = """
{
"action": "subscribe",
"subscriptions": [
{
"topic": "activity",
"type": "trades"
}
]
}
""".trimIndent()
client.sendMessage(subscribeMessage)
isSubscribed = true
logger.info("Activity WebSocket 订阅成功(全局交易流)")
} catch (e: Exception) {
logger.error("订阅 Activity WebSocket 失败", e)
isSubscribed = false
}
}
/**
* 处理消息
*/
private fun handleMessage(message: String) {
try {
// 处理 PONG 响应
if (message.trim() == "PONG" || message.trim() == "pong") {
return
}
// 使用扩展函数解析消息
val tradeMessage = message.fromJson<ActivityTradeMessage>() ?: run {
// 不是有效的 JSON 或格式不匹配,跳过
logger.warn("无法解析为 ActivityTradeMessage,可能不是 activity 消息: ${message.take(200)}")
return
}
// 检查是否是 activity trade 消息
if (tradeMessage.topic != "activity" || tradeMessage.type != "trades") {
// 不是我们关心的消息,直接返回
return
}
val payload = tradeMessage.payload
// 提取交易者地址
val traderAddress = extractTraderAddress(payload) ?: run {
// 没有交易者地址,跳过
logger.warn("Activity Trade 消息中没有交易者地址: trader=${payload.trader}, proxyWallet=${payload.proxyWallet}, asset=${payload.asset}")
return
}
// 检查是否是我们监听的 Leader
val normalizedAddress = traderAddress.lowercase()
val leaderId = monitoredAddresses[normalizedAddress] ?: run {
return
}
// 解析交易数据
val trade = parseActivityTrade(payload, leaderId)
if (trade != null) {
logger.info("✅ 检测到 Leader 交易: leaderId=$leaderId, address=$traderAddress, side=${trade.side}, market=${trade.market}, size=${trade.size}")
// 异步处理交易(避免阻塞消息处理)
scope.launch {
try {
copyOrderTrackingService.processTrade(
leaderId = leaderId,
trade = trade,
source = "activity-ws"
)
} catch (e: Exception) {
logger.error("处理 Activity WS 交易失败: leaderId=$leaderId, tradeId=${trade.id}", e)
}
}
} else {
logger.warn("解析交易数据失败: leaderId=$leaderId, address=$traderAddress, asset=${payload.asset}, side=${payload.side}")
}
} catch (e: Exception) {
logger.error("处理 Activity WebSocket 消息失败: ${e.message}", e)
}
}
/**
* 提取交易者地址
* 优先检查 trader.addressfallback proxyWallet
*/
private fun extractTraderAddress(payload: ActivityTradePayload): String? {
// 优先检查 trader.address
val address = payload.trader?.address
?: payload.proxyWallet
return address
}
/**
* 解析 Activity Trade TradeResponse
*/
private fun parseActivityTrade(payload: ActivityTradePayload, leaderId: Long): TradeResponse? {
return try {
// 提取必需字段并验证
val asset = payload.asset
val conditionId = payload.conditionId
val sideRaw = payload.side
if (asset.isBlank() || conditionId.isBlank() || sideRaw.isBlank()) {
logger.warn("Activity Trade 消息缺少必需字段: asset=$asset, conditionId=$conditionId, side=$sideRaw")
return null
}
val side = sideRaw.uppercase()
// 验证 side 必须是 BUY 或 SELL
if (side != "BUY" && side != "SELL") {
logger.warn("Activity Trade 消息 side 字段无效: side=$side")
return null
}
// price 和 size 可能是数字或字符串,统一转换为字符串
val price = convertToString(payload.price) ?: run {
logger.warn("Activity Trade 消息 price 字段无效: ${payload.price}")
return null
}
val size = convertToString(payload.size) ?: run {
logger.warn("Activity Trade 消息 size 字段无效: ${payload.size}")
return null
}
// 时间戳处理:可能是秒或毫秒,可能是数字或字符串
val timestamp = when {
payload.timestamp == null -> System.currentTimeMillis().toString()
payload.timestamp is Number -> {
val ts = (payload.timestamp as Number).toLong()
// 如果时间戳小于 1e12(秒级),转换为毫秒
if (ts < 1e12) (ts * 1000).toString() else ts.toString()
}
payload.timestamp is String -> {
val tsStr = payload.timestamp as String
val ts = tsStr.toLongOrNull() ?: System.currentTimeMillis()
if (ts < 1e12) (ts * 1000).toString() else tsStr
}
else -> System.currentTimeMillis().toString()
}
// 解析 outcome 和 outcomeIndex
// 优先使用消息中的 outcomeIndex,如果没有则从 outcome 字符串解析
val outcome = payload.outcome
val outcomeIndex = payload.outcomeIndex
?: parseOutcomeIndex(outcome)
// 使用 transactionHash 作为 trade ID,如果没有则生成 fallback ID
val tradeId = payload.transactionHash ?: "${leaderId}_${System.currentTimeMillis()}_${asset.take(10)}"
TradeResponse(
id = tradeId,
market = conditionId,
side = side,
price = price,
size = size,
timestamp = timestamp,
user = null, // Activity WS 中不需要
outcomeIndex = outcomeIndex,
outcome = outcome
)
} catch (e: Exception) {
logger.error("解析 Activity Trade 失败: ${e.message}", e)
null
}
}
/**
* Any 类型的值转换为 String
* 支持 NumberStringBigDecimal 等类型
*/
private fun convertToString(value: Any?): String? {
if (value == null) return null
return when (value) {
is String -> value
is Number -> {
// 如果是数字,转换为 BigDecimal 再转为字符串(保持精度)
try {
BigDecimal(value.toString()).toPlainString()
} catch (e: Exception) {
value.toString()
}
}
is BigDecimal -> value.toPlainString()
else -> value.toString()
}
}
/**
* 解析 outcome outcomeIndex
*/
private fun parseOutcomeIndex(outcome: String?): Int? {
return when (outcome?.uppercase()) {
"YES", "UP", "TRUE" -> 0
"NO", "DOWN", "FALSE" -> 1
else -> null
}
}
/**
* 停止监听
*/
fun stop() {
logger.info("停止 Activity WebSocket 监听")
wsClient?.closeConnection()
wsClient = null
isSubscribed = false
monitoredAddresses.clear()
}
/**
* 获取连接状态
*/
fun isConnected(): Boolean {
return wsClient?.isConnected() ?: false
}
/**
* 获取监听的 Leader 数量
*/
fun getMonitoredCount(): Int {
return monitoredAddresses.size
}
@PreDestroy
fun destroy() {
stop()
scope.cancel()
}
}
@@ -1,6 +1,7 @@
package com.wrbug.polymarketbot.service.copytrading.monitor
import com.google.gson.Gson
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.wrbug.polymarketbot.api.*
import com.wrbug.polymarketbot.service.system.RpcNodeService
@@ -19,6 +20,7 @@ import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
/**
* 统一的链上 WebSocket 服务
@@ -27,40 +29,19 @@ import java.util.concurrent.ConcurrentHashMap
@Service
class UnifiedOnChainWsService(
private val rpcNodeService: RpcNodeService,
private val retrofitFactory: RetrofitFactory
private val retrofitFactory: RetrofitFactory,
private val gson: Gson
) {
private val logger = LoggerFactory.getLogger(UnifiedOnChainWsService::class.java)
// Gson 实例,用于解析 JSON
private val gson = Gson()
@Value("\${copy.trading.onchain.ws.reconnect.delay:3000}")
private var reconnectDelay: Long = 3000 // 重连延迟(毫秒),默认3秒
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
// WebSocket 连接(唯一)
private var webSocket: WebSocket? = null
@Volatile
private var isConnected = false
// 订阅ID计数器(用于请求 ID
private var requestIdCounter = 0
// 连接任务(确保只有一个连接任务在运行)
private var connectionJob: Job? = null
// 存储所有订阅:subscriptionId -> 订阅信息
private val subscriptions = ConcurrentHashMap<String, SubscriptionInfo>()
// 存储请求 ID 到订阅 ID 的映射:requestId -> subscriptionId
// 用于在收到订阅响应时,将 subscription ID 关联到对应的订阅
private val requestIdToSubscriptionId = ConcurrentHashMap<Int, String>()
// 存储 RPC subscriptionId 到订阅 ID 的映射:rpcSubscriptionId -> subscriptionId
// 用于在收到日志通知时,知道是哪个订阅
private val rpcSubscriptionIdToSubscriptionId = ConcurrentHashMap<String, String>()
// 存储所有地址的连接:address -> AddressWsConnection
private val addressConnections = ConcurrentHashMap<String, AddressWsConnection>()
/**
* 订阅信息
@@ -90,31 +71,24 @@ class UnifiedOnChainWsService(
callback: suspend (String, OkHttpClient, EthereumRpcApi) -> Unit
): Boolean {
try {
// 如果已经订阅,先取消
if (subscriptions.containsKey(subscriptionId)) {
unsubscribe(subscriptionId)
val lowerAddress = address.lowercase()
// 找到或创建该地址的连接
val connection = addressConnections.computeIfAbsent(lowerAddress) {
AddressWsConnection(it).apply { start() }
}
// 创建订阅信息
val subscription = SubscriptionInfo(
subscriptionId = subscriptionId,
address = address.lowercase(),
address = lowerAddress,
entityType = entityType,
entityId = entityId,
callback = callback
)
subscriptions[subscriptionId] = subscription
// 如果已连接,立即订阅
if (isConnected) {
scope.launch {
subscribeAddress(subscription)
}
} else {
// 如果未连接,启动连接
startConnection()
}
// 添加订阅
connection.addSubscription(subscription)
logger.info("订阅地址监听: subscriptionId=$subscriptionId, address=$address, entityType=$entityType, entityId=$entityId")
return true
@@ -128,434 +102,37 @@ class UnifiedOnChainWsService(
* 取消订阅
*/
fun unsubscribe(subscriptionId: String) {
val subscription = subscriptions.remove(subscriptionId)
if (subscription != null && isConnected) {
// 取消该订阅的所有 RPC 订阅
scope.launch {
// 查找该订阅的所有 RPC subscriptionId
val rpcSubscriptionIds = rpcSubscriptionIdToSubscriptionId.entries
.filter { it.value == subscriptionId }
.map { it.key }
// 遍历所有连接找到含有该订阅的连接
for (connection in addressConnections.values) {
if (connection.hasSubscription(subscriptionId)) {
connection.removeSubscription(subscriptionId)
for (rpcSubId in rpcSubscriptionIds) {
unsubscribeRpc(rpcSubId)
rpcSubscriptionIdToSubscriptionId.remove(rpcSubId)
}
}
logger.info("取消订阅: subscriptionId=$subscriptionId")
}
// 如果没有订阅了,停止连接
if (subscriptions.isEmpty()) {
stop()
}
}
/**
* 启动连接如果还没有连接
*/
private fun startConnection() {
// 如果没有订阅,不启动连接
if (subscriptions.isEmpty()) {
return
}
// 如果连接任务已经在运行,不重复启动
if (connectionJob != null && connectionJob!!.isActive) {
return
}
// 启动连接任务
connectionJob = scope.launch {
startConnectionLoop()
}
}
/**
* 启动连接循环
*/
private suspend fun startConnectionLoop() {
while (scope.isActive) {
try {
// 如果没有订阅,停止连接
if (subscriptions.isEmpty()) {
logger.info("没有订阅,停止连接")
stop()
break
// 如果该连接没有订阅了,停止并移除
if (connection.isSubscriptionsEmpty()) {
connection.stop()
addressConnections.remove(connection.address)
logger.info("连接已无订阅,关闭连接: address=${connection.address}")
}
// 如果已经连接,等待断开
if (isConnected && webSocket != null) {
waitForDisconnect()
continue
}
// 获取可用的 RPC 节点
val wsUrl = rpcNodeService.getWsUrl()
val httpUrl = rpcNodeService.getHttpUrl()
if (wsUrl.isBlank() || httpUrl.isBlank()) {
logger.warn("没有可用的 RPC 节点,等待重试...")
delay(reconnectDelay)
continue
}
logger.info("连接链上 WebSocket: $wsUrl (${subscriptions.size} 个订阅)")
// 创建 HTTP 客户端(用于 RPC 调用)
val httpClient = createHttpClient()
// 创建 RPC API 客户端
val rpcApi = retrofitFactory.createEthereumRpcApi(httpUrl)
// 连接 WebSocket
connectWebSocket(wsUrl, httpClient, rpcApi)
// 等待连接建立
waitForConnect()
// 如果连接成功,订阅所有地址
if (isConnected) {
logger.info("WebSocket 连接已建立,开始订阅")
for (subscription in subscriptions.values) {
subscribeAddress(subscription)
}
// 等待连接断开
waitForDisconnect()
}
// 连接断开后,如果没有订阅了,不再重连
if (subscriptions.isEmpty()) {
logger.info("没有订阅,停止重连")
break
}
// 等待后重连
logger.info("WebSocket 连接断开,等待 ${reconnectDelay}ms 后重连")
delay(reconnectDelay)
} catch (e: Exception) {
logger.error("连接异常: ${e.message}", e)
delay(reconnectDelay)
}
}
}
/**
* 创建 HTTP 客户端
*/
private fun createHttpClient(): OkHttpClient {
val proxy = getProxyConfig()
val builder = createClient()
if (proxy != null) {
builder.proxy(proxy)
}
return builder.build()
}
/**
* 连接 WebSocket
*/
private fun connectWebSocket(wsUrl: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
// 先关闭旧连接
webSocket?.close(1000, "重新连接")
webSocket = null
isConnected = false
val request = Request.Builder()
.url(wsUrl)
.build()
webSocket = httpClient.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
isConnected = true
logger.info("链上 WebSocket 连接成功")
}
override fun onMessage(webSocket: WebSocket, text: String) {
scope.launch {
handleMessage(text, httpClient, rpcApi)
}
}
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
scope.launch {
handleMessage(bytes.utf8(), httpClient, rpcApi)
}
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
isConnected = false
logger.warn("链上 WebSocket 连接关闭: code=$code, reason=$reason")
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
isConnected = false
logger.warn("链上 WebSocket 连接已关闭: code=$code, reason=$reason")
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
logger.error("链上 WebSocket 连接失败: ${t.message}", t)
isConnected = false
}
})
}
/**
* 等待连接建立
*/
private suspend fun waitForConnect() {
var waited = 0L
val timeout = 15000L // 15秒超时
while (!isConnected && waited < timeout) {
delay(100)
waited += 100
}
if (!isConnected) {
logger.warn("WebSocket 连接超时,等待重连")
}
}
/**
* 等待连接断开
*/
private suspend fun waitForDisconnect() {
while (isConnected && scope.isActive) {
delay(1000)
}
}
/**
* 订阅地址为每个地址订阅 6 个事件
*/
private suspend fun subscribeAddress(subscription: SubscriptionInfo) {
if (webSocket == null || !isConnected) {
return
}
val address = subscription.address
val walletTopic = OnChainWsUtils.addressToTopic32(address)
val subscriptionId = subscription.subscriptionId
try {
// 订阅 USDC Transfer (from wallet)
subscribeLogs(OnChainWsUtils.USDC_CONTRACT, listOf(OnChainWsUtils.ERC20_TRANSFER_TOPIC, walletTopic), subscriptionId)
// 订阅 USDC Transfer (to wallet)
subscribeLogs(OnChainWsUtils.USDC_CONTRACT, listOf(OnChainWsUtils.ERC20_TRANSFER_TOPIC, null, walletTopic), subscriptionId)
// 订阅 ERC1155 TransferSingle (from wallet)
subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_SINGLE_TOPIC, null, walletTopic), subscriptionId)
// 订阅 ERC1155 TransferSingle (to wallet)
subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_SINGLE_TOPIC, null, null, walletTopic), subscriptionId)
// 订阅 ERC1155 TransferBatch (from wallet)
subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_BATCH_TOPIC, null, walletTopic), subscriptionId)
// 订阅 ERC1155 TransferBatch (to wallet)
subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_BATCH_TOPIC, null, null, walletTopic), subscriptionId)
logger.debug("已订阅地址: subscriptionId=$subscriptionId, address=$address")
} catch (e: Exception) {
logger.error("订阅地址失败: subscriptionId=$subscriptionId, address=$address, error=${e.message}", e)
}
}
/**
* 订阅日志
*/
private fun subscribeLogs(address: String, topics: List<String?>, subscriptionId: String) {
val ws = webSocket ?: return
val params = mapOf(
"address" to address.lowercase(),
"topics" to topics.filterNotNull()
)
val requestId = ++requestIdCounter
requestIdToSubscriptionId[requestId] = subscriptionId
val request = mapOf(
"jsonrpc" to "2.0",
"id" to requestId,
"method" to "eth_subscribe",
"params" to listOf("logs", params)
)
val message = gson.toJson(request)
ws.send(message)
}
/**
* 取消 RPC 订阅
*/
private fun unsubscribeRpc(rpcSubscriptionId: String) {
val ws = webSocket ?: return
val requestId = ++requestIdCounter
val request = mapOf(
"jsonrpc" to "2.0",
"id" to requestId,
"method" to "eth_unsubscribe",
"params" to listOf(rpcSubscriptionId)
)
val message = gson.toJson(request)
ws.send(message)
}
/**
* 处理 WebSocket 消息
*/
private suspend fun handleMessage(text: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
try {
val message = gson.fromJson(text, JsonObject::class.java)
// 处理订阅响应
if (message.has("result") && message.has("id")) {
val requestId = message.get("id")?.asInt
val rpcSubscriptionId = message.get("result")?.asString
if (requestId != null && rpcSubscriptionId != null) {
val subscriptionId = requestIdToSubscriptionId.remove(requestId)
if (subscriptionId != null) {
// 保存 RPC subscriptionId 到订阅的映射
rpcSubscriptionIdToSubscriptionId[rpcSubscriptionId] = subscriptionId
logger.debug("订阅成功: subscriptionId=$subscriptionId, rpcSubscriptionId=$rpcSubscriptionId")
}
}
logger.info("取消订阅: subscriptionId=$subscriptionId")
return
}
// 处理日志通知
if (message.has("params")) {
val params = message.getAsJsonObject("params")
val subscriptionIdParam = params.get("subscription")?.asString
val result = params.getAsJsonObject("result")
if (result != null) {
val txHash = result.get("transactionHash")?.asString
if (txHash != null && subscriptionIdParam != null) {
// 根据 RPC subscriptionId 找到对应的订阅
val subscriptionId = rpcSubscriptionIdToSubscriptionId[subscriptionIdParam]
if (subscriptionId != null) {
// 处理交易,分发给对应的订阅者
processTransactionForSubscription(txHash, subscriptionId, httpClient, rpcApi)
} else {
// 如果没有找到订阅,可能是新订阅还未建立映射,尝试处理所有订阅
processTransaction(txHash, httpClient, rpcApi)
}
}
}
}
} catch (e: Exception) {
logger.error("处理 WebSocket 消息失败: ${e.message}", e)
}
}
/**
* 处理交易为特定订阅
* 直接调用订阅的回调
*/
private suspend fun processTransactionForSubscription(
txHash: String,
subscriptionId: String,
httpClient: OkHttpClient,
rpcApi: EthereumRpcApi
) {
val subscription = subscriptions[subscriptionId] ?: return
try {
subscription.callback(txHash, httpClient, rpcApi)
} catch (e: Exception) {
logger.error("调用订阅回调失败: subscriptionId=$subscriptionId, txHash=$txHash, error=${e.message}", e)
}
}
/**
* 处理交易为所有订阅用于兼容
* 解析交易中的 Transfer 事件分发给所有订阅者
*/
private suspend fun processTransaction(txHash: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
try {
// 获取交易 receipt
val receiptRequest = JsonRpcRequest(
method = "eth_getTransactionReceipt",
params = listOf(txHash)
)
val receiptResponse = rpcApi.call(receiptRequest)
if (!receiptResponse.isSuccessful || receiptResponse.body() == null) {
return
}
val receiptRpcResponse = receiptResponse.body()!!
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null) {
return
}
// 使用 Gson 解析 receipt JSON
val receiptJson = receiptRpcResponse.result.asJsonObject
// 解析 receipt 中的 Transfer 日志
val logs = receiptJson.getAsJsonArray("logs") ?: return
val (erc20Transfers, erc1155Transfers) = OnChainWsUtils.parseReceiptTransfers(logs)
// 为每个订阅检查是否匹配,如果匹配则调用回调
for (subscription in subscriptions.values) {
val address = subscription.address
// 检查该地址是否参与了交易(通过检查 Transfer 日志)
val isInvolved = erc20Transfers.any {
it.from.lowercase() == address || it.to.lowercase() == address
} || erc1155Transfers.any {
it.from.lowercase() == address || it.to.lowercase() == address
}
if (isInvolved) {
// 该地址参与了交易,调用回调
try {
subscription.callback(txHash, httpClient, rpcApi)
} catch (e: Exception) {
logger.error("调用订阅回调失败: subscriptionId=${subscription.subscriptionId}, txHash=$txHash, error=${e.message}", e)
}
}
}
} catch (e: Exception) {
logger.error("处理交易失败: txHash=$txHash, ${e.message}", e)
}
}
/**
* 停止连接
* 停止所有服务
*/
fun stop() {
connectionJob?.cancel()
connectionJob = null
// 关闭 WebSocket 连接
webSocket?.close(1000, "停止监听")
webSocket = null
isConnected = false
// 清空订阅信息
subscriptions.clear()
requestIdToSubscriptionId.clear()
rpcSubscriptionIdToSubscriptionId.clear()
for (connection in addressConnections.values) {
connection.stop()
}
addressConnections.clear()
}
@PostConstruct
fun init() {
// 服务启动时不自动连接,等待有订阅时再连接
logger.info("统一链上 WebSocket 服务已初始化")
logger.info("统一链上 WebSocket 服务已初始化 (独立连接模式)")
}
@PreDestroy
@@ -563,5 +140,295 @@ class UnifiedOnChainWsService(
stop()
scope.cancel()
}
/**
* 单个地址的 WebSocket 连接管理
*/
inner class AddressWsConnection(val address: String) {
private var webSocket: WebSocket? = null
@Volatile
private var isConnected = false
// 订阅ID计数器(用于请求 ID
private var requestIdCounter = AtomicInteger(0)
// 连接任务
private var connectionJob: Job? = null
// 该连接下的所有订阅:subscriptionId -> SubscriptionInfo
// 理论上一个地址可能被多个业务订阅(如:既是被跟单者又是普通监控),虽然业务上通常只有一个
private val subscriptions = ConcurrentHashMap<String, SubscriptionInfo>()
// 存储请求 ID 到订阅 ID 的映射:requestId -> subscriptionId
private val requestIdToSubscriptionId = ConcurrentHashMap<Int, String>()
// 存储 RPC subscriptionId 到订阅 ID 的映射:rpcSubscriptionId -> subscriptionId
private val rpcSubscriptionIdToSubscriptionId = ConcurrentHashMap<String, String>()
fun start() {
if (connectionJob != null && connectionJob!!.isActive) return
connectionJob = scope.launch {
startConnectionLoop()
}
}
fun stop() {
connectionJob?.cancel()
connectionJob = null
webSocket?.close(1000, "停止监听")
webSocket = null
isConnected = false
subscriptions.clear()
requestIdToSubscriptionId.clear()
rpcSubscriptionIdToSubscriptionId.clear()
}
fun addSubscription(subscription: SubscriptionInfo) {
// 如果已经存在,先移除旧的
removeSubscription(subscription.subscriptionId)
subscriptions[subscription.subscriptionId] = subscription
// 如果已经连接,立即发送链上订阅请求
if (isConnected) {
scope.launch {
subscribeAddressOnChain(subscription)
}
}
}
fun removeSubscription(subscriptionId: String) {
subscriptions.remove(subscriptionId)
// 不需要显式发送 eth_unsubscribe,因为连接是 per-address 的,
// 只要只要连接还在,就保持该地址相关的所有 logs 订阅。
// 只有当所有 subscription 都移除了,连接才会关闭。
}
fun hasSubscription(subscriptionId: String): Boolean {
return subscriptions.containsKey(subscriptionId)
}
fun isSubscriptionsEmpty(): Boolean {
return subscriptions.isEmpty()
}
private suspend fun startConnectionLoop() {
while (scope.isActive) {
try {
if (subscriptions.isEmpty()) {
// 如果启动循环时还没订阅(不太可能,通常是先 addSubscription 再 start,或者是 start 后 addSubscription
// 或者订阅被清空了,外部应当掉 stop,但这里作为防守
delay(1000)
continue
}
if (isConnected && webSocket != null) {
waitForDisconnect()
continue
}
// 获取可用的 RPC 节点
val wsUrl = rpcNodeService.getWsUrl()
val httpUrl = rpcNodeService.getHttpUrl()
logger.info("[$address] 连接链上 WebSocket: $wsUrl")
val httpClient = createHttpClient()
val rpcApi = retrofitFactory.createEthereumRpcApi(httpUrl)
connectWebSocket(wsUrl, httpClient, rpcApi)
waitForConnect()
if (isConnected) {
logger.info("[$address] WebSocket 连接已建立,开始注册订阅")
// 重新为所有订阅注册链上监听
for (subscription in subscriptions.values) {
subscribeAddressOnChain(subscription)
}
waitForDisconnect()
}
logger.info("[$address] WebSocket 连接断开,等待 ${reconnectDelay}ms 后重连")
delay(reconnectDelay)
} catch (e: Exception) {
logger.error("[$address] 连接异常: ${e.message}", e)
delay(reconnectDelay)
}
}
}
private fun connectWebSocket(wsUrl: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
webSocket?.close(1000, "重新连接")
webSocket = null
isConnected = false
val request = Request.Builder().url(wsUrl).build()
webSocket = httpClient.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
isConnected = true
logger.info("[$address] 链上 WebSocket 连接成功")
}
override fun onMessage(webSocket: WebSocket, text: String) {
scope.launch { handleMessage(text, httpClient, rpcApi) }
}
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
scope.launch { handleMessage(bytes.utf8(), httpClient, rpcApi) }
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
isConnected = false
logger.warn("[$address] 链上 WebSocket 连接关闭: code=$code, reason=$reason")
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
isConnected = false
logger.warn("[$address] 链上 WebSocket 连接已关闭: code=$code, reason=$reason")
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
logger.error("[$address] 链上 WebSocket 连接失败: ${t.message}", t)
isConnected = false
}
})
}
private suspend fun subscribeAddressOnChain(subscription: SubscriptionInfo) {
if (webSocket == null || !isConnected) return
val walletTopic = OnChainWsUtils.addressToTopic32(address)
val subId = subscription.subscriptionId
try {
// 订阅该地址相关的所有事件
// USDC Transfer (from/to)
subscribeLogs(OnChainWsUtils.USDC_CONTRACT, listOf(OnChainWsUtils.ERC20_TRANSFER_TOPIC, walletTopic), subId)
subscribeLogs(OnChainWsUtils.USDC_CONTRACT, listOf(OnChainWsUtils.ERC20_TRANSFER_TOPIC, null, walletTopic), subId)
// ERC1155 TransferSingle (from/to)
subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_SINGLE_TOPIC, null, walletTopic), subId)
subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_SINGLE_TOPIC, null, null, walletTopic), subId)
// ERC1155 TransferBatch (from/to)
subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_BATCH_TOPIC, null, walletTopic), subId)
subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_BATCH_TOPIC, null, null, walletTopic), subId)
logger.debug("[$address] 已发送链上订阅请求: subscriptionId=$subId")
} catch (e: Exception) {
logger.error("[$address] 发送链上订阅请求失败: error=${e.message}", e)
}
}
private fun subscribeLogs(contractAddress: String, topics: List<String?>, subscriptionId: String) {
val ws = webSocket ?: return
val topicsArray = gson.toJsonTree(topics).asJsonArray
val logParams = JsonObject()
logParams.addProperty("address", contractAddress.lowercase())
logParams.add("topics", topicsArray)
val requestId = requestIdCounter.incrementAndGet()
requestIdToSubscriptionId[requestId] = subscriptionId
val request = JsonObject()
request.addProperty("jsonrpc", "2.0")
request.addProperty("id", requestId)
request.addProperty("method", "eth_subscribe")
val paramsArray = JsonArray()
paramsArray.add("logs")
paramsArray.add(logParams)
request.add("params", paramsArray)
ws.send(gson.toJson(request))
}
private suspend fun handleMessage(text: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
try {
val message = gson.fromJson(text, JsonObject::class.java)
// 1. 处理订阅响应 (eth_subscribe response)
if (message.has("result") && message.has("id")) {
val requestId = message.get("id")?.asInt
val rpcSubscriptionId = message.get("result")?.asString
if (requestId != null && rpcSubscriptionId != null) {
val subId = requestIdToSubscriptionId.remove(requestId)
if (subId != null) {
rpcSubscriptionIdToSubscriptionId[rpcSubscriptionId] = subId
logger.debug("[$address] 链上订阅成功: mapped connection rpcSubId=$rpcSubscriptionId to localSubId=$subId")
}
}
return
}
// 2. 处理日志通知 (eth_subscription)
val method = message.get("method")?.asString
if (method == "eth_subscription") {
val params = message.getAsJsonObject("params") ?: return
val rpcSubParam = params.get("subscription")?.asString
val result = params.getAsJsonObject("result") ?: return
val txHash = result.get("transactionHash")?.asString
if (txHash != null && rpcSubParam != null) {
// 找到触发此通知的本地订阅 ID
// 因为我们在这个连接里只订阅了 this.address,所以理论上所有通知都跟这个 address 有关
// 但我们需要找到对应的 callback
val localSubId = rpcSubscriptionIdToSubscriptionId[rpcSubParam]
if (localSubId != null) {
val subscription = subscriptions[localSubId]
if (subscription != null) {
logger.info("[$address] 收到交易通知: txHash=$txHash, subId=$localSubId")
runCatching {
subscription.callback(txHash, httpClient, rpcApi)
}.onFailure { e ->
logger.error("[$address] 回调执行失败: ${e.message}", e)
}
}
} else {
// 找不到具体是哪个订阅请求触发的(可能是重启后之前的订阅残留?或者映射丢失?)
// 在单地址单连接模式下,只要是这个 connection 收到的,肯定是关于这个 address 的
// 我们可以尝试通知所有订阅者(通常一个地址只有一个订阅者,除非此地址既是Leader又是User)
logger.warn("[$address] 未找到映射的订阅ID: rpcSubId=$rpcSubParam. 广播给所有订阅者.")
subscriptions.values.forEach { sub ->
runCatching {
sub.callback(txHash, httpClient, rpcApi)
}.onFailure { e ->
logger.error("[$address] 广播回调执行失败: ${e.message}", e)
}
}
}
}
}
} catch (e: Exception) {
logger.error("[$address] 处理消息失败: ${e.message}", e)
}
}
private suspend fun waitForConnect() {
var waited = 0L
val timeout = 15000L
while (!isConnected && waited < timeout) {
delay(100)
waited += 100
}
if (!isConnected) logger.warn("[$address] WebSocket 连接超时")
}
private suspend fun waitForDisconnect() {
while (isConnected && scope.isActive) {
delay(1000)
}
}
private fun createHttpClient(): OkHttpClient {
val proxy = getProxyConfig()
val builder = createClient()
if (proxy != null) builder.proxy(proxy)
return builder.build()
}
}
}
@@ -1,7 +1,6 @@
package com.wrbug.polymarketbot.service.copytrading.orders
import com.fasterxml.jackson.databind.ObjectMapper
import com.wrbug.polymarketbot.api.MarketResponse
import com.wrbug.polymarketbot.dto.OrderDetailDto
import com.wrbug.polymarketbot.dto.OrderMessageDto
import com.wrbug.polymarketbot.dto.OrderPushMessage
@@ -19,6 +18,7 @@ import com.wrbug.polymarketbot.util.CryptoUtils
import com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository
import com.wrbug.polymarketbot.repository.CopyTradingRepository
import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.service.common.MarketService
import org.springframework.stereotype.Service
import java.util.concurrent.ConcurrentHashMap
@@ -35,7 +35,8 @@ class OrderPushService(
private val cryptoUtils: CryptoUtils,
private val copyOrderTrackingRepository: CopyOrderTrackingRepository? = null, // 可选,避免循环依赖
private val copyTradingRepository: CopyTradingRepository? = null, // 可选,避免循环依赖
private val leaderRepository: LeaderRepository? = null // 可选,避免循环依赖
private val leaderRepository: LeaderRepository? = null, // 可选,避免循环依赖
private val marketService: MarketService // 市场信息服务
) {
private val logger = LoggerFactory.getLogger(OrderPushService::class.java)
@@ -422,8 +423,8 @@ class OrderPushService(
result.fold(
onSuccess = { openOrder ->
// 获取市场信息(通过 Gamma API
val marketInfo = fetchMarketInfo(conditionId ?: openOrder.market)
// 获取市场信息(使用 MarketService,优先从数据库/缓存获取
val market = marketService.getMarket(conditionId ?: openOrder.market)
// 转换为 DTO
// 注意:createdAt 是 unix timestamp (Long),需要转换为字符串
@@ -436,9 +437,9 @@ class OrderPushService(
filled = openOrder.sizeMatched, // 使用 size_matched
status = openOrder.status,
createdAt = openOrder.createdAt.toString(), // unix timestamp 转换为字符串
marketName = marketInfo?.question,
marketSlug = marketInfo?.slug,
marketIcon = marketInfo?.icon
marketName = market?.title,
marketSlug = market?.slug, // 显示用的 slug
marketIcon = market?.icon
)
},
onFailure = { e ->
@@ -452,40 +453,6 @@ class OrderPushService(
}
}
/**
* 获取市场信息通过 Gamma API
* 文档: https://docs.polymarket.com/api-reference/markets/list-markets
*
* 使用 /markets 接口通过 condition_ids 查询参数获取市场信息
* 订单返回的 market 字段是 16 进制的 condition ID "0x..."
*/
private suspend fun fetchMarketInfo(conditionId: String): MarketResponse? {
return try {
// 创建 Gamma API 客户端(公开 API,不需要认证)
val gammaApi = retrofitFactory.createGammaApi()
// 调用 Gamma API 获取市场信息
// 使用 /markets 接口,通过 condition_ids 查询参数
val response = gammaApi.listMarkets(
conditionIds = listOf(conditionId),
includeTag = null
)
if (response.isSuccessful && response.body() != null) {
val markets = response.body()!!
if (markets.isNotEmpty()) {
val market = markets.first()
return market
} else {
return null
}
} else {
null
}
} catch (e: Exception) {
null
}
}
/**
* 订阅账户的订单推送保留用于向后兼容
*/
@@ -172,25 +172,15 @@ class OrderSigningService {
// 5. 确保 maker 地址也是小写格式
val makerAddressLower = makerAddress.lowercase()
// 打印签名前的订单参数
logger.info("========== 订单签名前参数 ==========")
logger.info("订单方向: $side")
logger.info("价格: $price")
logger.info("数量: $size")
logger.info("Token ID: $tokenId")
logger.info("Maker 地址: $makerAddressLower")
logger.info("Signer 地址: $signerAddress")
logger.info("Taker 地址: $taker")
logger.info("Maker Amount (wei): ${amounts.makerAmount}")
logger.info("Taker Amount (wei): ${amounts.takerAmount}")
logger.info("Salt: $salt")
logger.info("Expiration: $expiration")
logger.info("Nonce: $nonce")
logger.info("Fee Rate BPS: $feeRateBps")
logger.info("Signature Type: $signatureType")
logger.info("Exchange Contract: $EXCHANGE_CONTRACT")
logger.info("Chain ID: $CHAIN_ID")
logger.info("====================================")
// 打印签名前的订单参数DEBUG 级别,避免敏感信息泄露)
logger.debug("========== 订单签名前参数 ==========")
logger.debug("订单方向: $side, 价格: $price, 数量: $size")
logger.debug("Token ID: $tokenId")
logger.debug("Maker: ${makerAddressLower.take(10)}...${makerAddressLower.takeLast(6)}")
logger.debug("Signer: ${signerAddress.take(10)}...${signerAddress.takeLast(6)}")
logger.debug("Amounts - Maker: ${amounts.makerAmount}, Taker: ${amounts.takerAmount}")
logger.debug("Salt: $salt, Expiration: $expiration, Nonce: $nonce, FeeRateBPS: $feeRateBps")
logger.debug("Signature Type: $signatureType, Chain ID: $CHAIN_ID")
// 6. 构建订单数据并签名
val signature = signOrder(
@@ -19,12 +19,14 @@ import com.wrbug.polymarketbot.service.copytrading.configs.CopyTradingFilterServ
import com.wrbug.polymarketbot.service.copytrading.configs.FilterStatus
import com.wrbug.polymarketbot.service.copytrading.orders.OrderSigningService
import com.wrbug.polymarketbot.service.common.BlockchainService
import com.wrbug.polymarketbot.service.common.MarketService
import com.wrbug.polymarketbot.service.common.PolymarketClobService
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
import com.wrbug.polymarketbot.util.CryptoUtils
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
import kotlin.math.max
/**
* 订单跟踪服务
@@ -37,7 +39,6 @@ open class CopyOrderTrackingService(
private val sellMatchRecordRepository: SellMatchRecordRepository,
private val sellMatchDetailRepository: SellMatchDetailRepository,
private val processedTradeRepository: ProcessedTradeRepository,
private val failedTradeRepository: FailedTradeRepository,
private val filteredOrderRepository: FilteredOrderRepository,
private val copyTradingRepository: CopyTradingRepository,
private val accountRepository: AccountRepository,
@@ -48,6 +49,7 @@ open class CopyOrderTrackingService(
private val clobService: PolymarketClobService,
private val retrofitFactory: RetrofitFactory,
private val cryptoUtils: CryptoUtils,
private val marketService: MarketService, // 市场信息服务
private val telegramNotificationService: TelegramNotificationService? = null // 可选,避免循环依赖
) {
@@ -55,16 +57,16 @@ open class CopyOrderTrackingService(
// 协程作用域(用于异步发送通知)
private val notificationScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// 使用 Mutex 保证线程安全(按交易ID锁定)
private val tradeMutexMap = ConcurrentHashMap<String, Mutex>()
// 订单创建重试配置
companion object {
private const val MAX_RETRY_ATTEMPTS = 2 // 最多重试次数(首次 + 1次重试)
private const val RETRY_DELAY_MS = 3000L // 重试前等待时间(毫秒,3秒)
}
/**
* 获取或创建 Mutex按交易ID
*/
@@ -122,91 +124,86 @@ open class CopyOrderTrackingService(
suspend fun processTrade(leaderId: Long, trade: TradeResponse, source: String): Result<Unit> {
// 获取该交易的 Mutex(按交易ID锁定,不同交易可以并行处理)
val mutex = getMutex(leaderId, trade.id)
logger.debug("processTrade: ${trade.id}, $source")
return mutex.withLock {
try {
// 1. 检查是否已处理(去重,包括失败状态)
val existingProcessed = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
// 1. 检查是否已处理(去重,包括失败状态)
val existingProcessed = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
if (existingProcessed != null) {
if (existingProcessed.status == "FAILED") {
if (existingProcessed != null) {
logger.debug("processTrade: 重复 ${trade.id}, $source")
if (existingProcessed.status == "FAILED") {
return@withLock Result.success(Unit)
}
return@withLock Result.success(Unit)
}
// 检查是否已记录为失败交易
val failedTrade = failedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
if (failedTrade != null) {
return@withLock Result.success(Unit)
}
// 2. 处理交易逻辑
val result = when (trade.side.uppercase()) {
"BUY" -> processBuyTrade(leaderId, trade)
"SELL" -> processSellTrade(leaderId, trade)
else -> {
logger.warn("未知的交易方向: ${trade.side}")
Result.failure(IllegalArgumentException("未知的交易方向: ${trade.side}"))
}
}
if (result.isFailure) {
logger.error(
"处理交易失败: leaderId=$leaderId, tradeId=${trade.id}, side=${trade.side}",
result.exceptionOrNull()
)
return@withLock result
}
// 3. 标记为已处理(成功状态)
// 由于使用了 Mutex,这里理论上不会出现并发冲突,但保留异常处理作为兜底
try {
val processed = ProcessedTrade(
leaderId = leaderId,
leaderTradeId = trade.id,
tradeType = trade.side.uppercase(),
source = source,
status = "SUCCESS",
processedAt = System.currentTimeMillis()
)
processedTradeRepository.save(processed)
} catch (e: Exception) {
// 检查是否是唯一键冲突异常(理论上不会发生,但保留作为兜底)
if (isUniqueConstraintViolation(e)) {
val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
if (existing != null) {
if (existing.status == "FAILED") {
logger.debug("交易已标记为失败,跳过处理: leaderId=$leaderId, tradeId=${trade.id}")
return@withLock Result.success(Unit)
}
logger.debug("交易已处理(并发检测): leaderId=$leaderId, tradeId=${trade.id}, status=${existing.status}")
return@withLock Result.success(Unit)
} else {
// 如果检查不到,可能是事务隔离级别问题,等待一下再查询
delay(100)
val existingAfterDelay =
processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
if (existingAfterDelay != null) {
logger.debug("延迟查询到记录(并发检测): leaderId=$leaderId, tradeId=${trade.id}, status=${existingAfterDelay.status}")
return@withLock Result.success(Unit)
}
logger.warn(
"保存ProcessedTrade时发生唯一约束冲突,但查询不到记录: leaderId=$leaderId, tradeId=${trade.id}",
e
)
return@withLock Result.success(Unit)
}
} else {
// 其他类型的异常,重新抛出
throw e
return@withLock Result.success(Unit)
}
}
Result.success(Unit)
} catch (e: Exception) {
logger.error("处理交易异常: leaderId=$leaderId, tradeId=${trade.id}", e)
Result.failure(e)
// 2. 处理交易逻辑
val result = when (trade.side.uppercase()) {
"BUY" -> processBuyTrade(leaderId, trade)
"SELL" -> processSellTrade(leaderId, trade)
else -> {
logger.warn("未知的交易方向: ${trade.side}")
Result.failure(IllegalArgumentException("未知的交易方向: ${trade.side}"))
}
}
if (result.isFailure) {
logger.error(
"处理交易失败: leaderId=$leaderId, tradeId=${trade.id}, side=${trade.side}",
result.exceptionOrNull()
)
return@withLock result
}
// 3. 标记为已处理(成功状态)
// 由于使用了 Mutex,这里理论上不会出现并发冲突,但保留异常处理作为兜底
try {
val processed = ProcessedTrade(
leaderId = leaderId,
leaderTradeId = trade.id,
tradeType = trade.side.uppercase(),
source = source,
status = "SUCCESS",
processedAt = System.currentTimeMillis()
)
processedTradeRepository.save(processed)
} catch (e: Exception) {
// 检查是否是唯一键冲突异常(理论上不会发生,但保留作为兜底)
if (isUniqueConstraintViolation(e)) {
val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
if (existing != null) {
if (existing.status == "FAILED") {
logger.debug("交易已标记为失败,跳过处理: leaderId=$leaderId, tradeId=${trade.id}")
return@withLock Result.success(Unit)
}
logger.debug("交易已处理(并发检测): leaderId=$leaderId, tradeId=${trade.id}, status=${existing.status}")
return@withLock Result.success(Unit)
} else {
// 如果检查不到,可能是事务隔离级别问题,等待一下再查询
delay(100)
val existingAfterDelay =
processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
if (existingAfterDelay != null) {
logger.debug("延迟查询到记录(并发检测): leaderId=$leaderId, tradeId=${trade.id}, status=${existingAfterDelay.status}")
return@withLock Result.success(Unit)
}
logger.warn(
"保存ProcessedTrade时发生唯一约束冲突,但查询不到记录: leaderId=$leaderId, tradeId=${trade.id}",
e
)
return@withLock Result.success(Unit)
}
} else {
// 其他类型的异常,重新抛出
throw e
}
}
Result.success(Unit)
} catch (e: Exception) {
logger.error("处理交易异常: leaderId=$leaderId, tradeId=${trade.id}", e)
Result.failure(e)
}
}
}
@@ -260,26 +257,46 @@ open class CopyOrderTrackingService(
// 先计算跟单金额(用于仓位检查)
// 注意:这里先计算金额,即使后续被过滤也会记录
val tradePrice = trade.price.toSafeBigDecimal()
val buyQuantity = try {
var buyQuantity = try {
calculateBuyQuantity(trade, copyTrading)
} catch (e: Exception) {
logger.warn("计算买入数量失败: ${e.message}", e)
continue
}
// 计算跟单金额(USDC)= 买入数量 × 价格
val copyOrderAmount = buyQuantity.multi(tradePrice)
// 如果启用了关键字过滤或市场截止时间过滤,需要先获取市场信息
var marketTitle: String? = null
var marketEndDate: Long? = null
val needMarketInfo =
copyTrading.keywordFilterMode != "DISABLED" || copyTrading.maxMarketEndDate != null
if (needMarketInfo) {
try {
val market = marketService.getMarket(trade.market)
marketTitle = market?.title
marketEndDate = market?.endDate
} catch (e: Exception) {
logger.warn("获取市场信息失败(关键字过滤/市场截止时间检查需要): ${e.message}", e)
}
}
// 过滤条件检查(在计算订单参数之前)
// 传入 Leader 交易价格,用于价格区间检查
// 传入跟单金额和市场ID,用于仓位检查(按市场检查仓位)
// 传入市场标题,用于关键字过滤
// 传入市场截止时间,用于市场截止时间检查
// 订单簿只请求一次,返回给后续逻辑使用
val filterResult = filterService.checkFilters(
copyTrading,
tokenId,
copyTrading,
tokenId,
tradePrice = tradePrice,
copyOrderAmount = copyOrderAmount,
marketId = trade.market
marketId = trade.market,
marketTitle = marketTitle,
marketEndDate = marketEndDate
)
val orderbook = filterResult.orderbook // 获取订单簿(如果需要)
if (!filterResult.isPassed) {
@@ -289,23 +306,9 @@ open class CopyOrderTrackingService(
notificationScope.launch {
try {
// 获取市场信息(标题和slug
val marketInfo = withContext(Dispatchers.IO) {
try {
val gammaApi = retrofitFactory.createGammaApi()
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(trade.market))
if (marketResponse.isSuccessful && marketResponse.body() != null) {
marketResponse.body()!!.firstOrNull()
} else {
null
}
} catch (e: Exception) {
logger.warn("获取市场信息失败: ${e.message}", e)
null
}
}
val marketTitle = marketInfo?.question ?: trade.market
val marketSlug = marketInfo?.slug
val market = marketService.getMarket(trade.market)
val marketTitle = market?.title ?: trade.market
val marketSlug = market?.slug // 显示用的 slug
// 从过滤结果中提取 filterType
val filterType = extractFilterType(filterResult.status, filterResult.reason)
@@ -344,27 +347,29 @@ open class CopyOrderTrackingService(
logger.error("保存被过滤订单失败: ${e.message}", e)
}
// 发送 Telegram 通知
val locale = try {
org.springframework.context.i18n.LocaleContextHolder.getLocale()
} catch (e: Exception) {
java.util.Locale("zh", "CN") // 默认简体中文
}
// 发送 Telegram 通知(仅在 pushFilteredOrders 为 true 时发送)
if (copyTrading.pushFilteredOrders) {
val locale = try {
org.springframework.context.i18n.LocaleContextHolder.getLocale()
} catch (e: Exception) {
java.util.Locale("zh", "CN") // 默认简体中文
}
telegramNotificationService?.sendOrderFilteredNotification(
marketTitle = marketTitle,
marketId = trade.market,
marketSlug = marketSlug,
side = "BUY",
outcome = trade.outcome,
price = trade.price,
size = trade.size,
filterReason = filterResult.reason,
filterType = filterType,
accountName = account.accountName,
walletAddress = account.walletAddress,
locale = locale
)
telegramNotificationService?.sendOrderFilteredNotification(
marketTitle = marketTitle,
marketId = trade.market,
marketSlug = marketSlug,
side = "BUY",
outcome = trade.outcome,
price = trade.price,
size = trade.size,
filterReason = filterResult.reason,
filterType = filterType,
accountName = account.accountName,
walletAddress = account.walletAddress,
locale = locale
)
}
} catch (e: Exception) {
logger.error("处理被过滤订单通知失败: ${e.message}", e)
}
@@ -376,22 +381,72 @@ open class CopyOrderTrackingService(
// 买入数量已在过滤检查前计算,这里直接使用
// 如果数量为0或负数,跳过
if (buyQuantity.lte(BigDecimal.ZERO)) {
logger.warn("计算的买入数量为0或负数,跳过: copyTradingId=${copyTrading.id}, tradeId=${trade.id}")
logger.warn("计算得到的买入数量为0,跳过跟单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}")
continue
}
if (buyQuantity.lt(BigDecimal.ONE)) {
logger.warn("计算得到的买入数量小于1,自动调整为1 (Polymarket 最小下单数量): copyTradingId=${copyTrading.id}, tradeId=${trade.id}, originalQuantity=$buyQuantity")
buyQuantity = BigDecimal.ONE
}
// 验证订单数量限制(仅比例模式)
var finalBuyQuantity = buyQuantity
if (copyTrading.copyMode == "RATIO") {
val orderAmount = buyQuantity.multi(trade.price.toSafeBigDecimal())
if (orderAmount.lt(copyTrading.minOrderSize)) {
logger.warn("订单金额低于最小限制,跳过: copyTradingId=${copyTrading.id}, amount=$orderAmount, min=${copyTrading.minOrderSize}")
continue
val tradePrice = trade.price.toSafeBigDecimal()
val rawOrderAmount = buyQuantity.multi(tradePrice)
// 对按比例计算的金额进行向上取整处理(确保满足最小限制)
// 向上取整到 2 位小数(USDC 精度)
val roundedOrderAmount = rawOrderAmount.setScale(2, java.math.RoundingMode.CEILING)
// 如果原始金额或向上取整后的金额小于最小限制,调整 buyQuantity 以满足最小限制
// 这样可以避免精度问题导致订单被错误地跳过
if (roundedOrderAmount.lt(copyTrading.minOrderSize)) {
logger.debug("订单金额(向上取整后)低于最小限制,调整数量以满足最小限制: copyTradingId=${copyTrading.id}, rawAmount=$rawOrderAmount, roundedAmount=$roundedOrderAmount, min=${copyTrading.minOrderSize}")
// 计算满足最小限制所需的数量(向上取整)
val minQuantity =
copyTrading.minOrderSize.div(tradePrice, 8, java.math.RoundingMode.CEILING)
if (minQuantity.lte(BigDecimal.ZERO)) {
logger.warn("计算出的最小数量为0或负数,跳过: copyTradingId=${copyTrading.id}")
continue
}
// 使用调整后的数量
finalBuyQuantity = minQuantity
logger.debug(
"已调整数量以满足最小限制: copyTradingId=${copyTrading.id}, originalQuantity=$buyQuantity, adjustedQuantity=$finalBuyQuantity, adjustedAmount=${
finalBuyQuantity.multi(
tradePrice
)
}"
)
} else if (rawOrderAmount.lt(copyTrading.minOrderSize)) {
// 原始金额小于最小限制,但向上取整后满足,调整数量以满足最小限制
logger.debug("订单金额(精度处理后)低于最小限制,调整数量以满足最小限制: copyTradingId=${copyTrading.id}, rawAmount=$rawOrderAmount, min=${copyTrading.minOrderSize}")
// 计算满足最小限制所需的数量(向上取整)
val minQuantity =
copyTrading.minOrderSize.div(tradePrice, 8, java.math.RoundingMode.CEILING)
if (minQuantity.lte(BigDecimal.ZERO)) {
logger.warn("计算出的最小数量为0或负数,跳过: copyTradingId=${copyTrading.id}")
continue
}
// 使用调整后的数量
finalBuyQuantity = minQuantity
logger.debug(
"已调整数量以满足最小限制: copyTradingId=${copyTrading.id}, originalQuantity=$buyQuantity, adjustedQuantity=$finalBuyQuantity, adjustedAmount=${
finalBuyQuantity.multi(
tradePrice
)
}"
)
}
if (orderAmount.gt(copyTrading.maxOrderSize)) {
logger.warn("订单金额超过最大限制,调整数量: copyTradingId=${copyTrading.id}, amount=$orderAmount, max=${copyTrading.maxOrderSize}")
// 检查最大限制(使用调整后的数量)
val finalOrderAmount = finalBuyQuantity.multi(tradePrice)
if (finalOrderAmount.gt(copyTrading.maxOrderSize)) {
logger.warn("订单金额超过最大限制,调整数量: copyTradingId=${copyTrading.id}, amount=$finalOrderAmount, max=${copyTrading.maxOrderSize}")
// 调整数量到最大值
val adjustedQuantity = copyTrading.maxOrderSize.div(trade.price.toSafeBigDecimal())
val adjustedQuantity =
copyTrading.maxOrderSize.div(tradePrice, 8, java.math.RoundingMode.DOWN)
if (adjustedQuantity.lte(BigDecimal.ZERO)) {
logger.warn("调整后的数量为0或负数,跳过: copyTradingId=${copyTrading.id}")
continue
@@ -416,7 +471,7 @@ open class CopyOrderTrackingService(
// 计算价格(应用价格容忍度)
val buyPrice = calculateAdjustedPrice(trade.price.toSafeBigDecimal(), copyTrading, isBuy = true)
logger.debug("计算价格结果:$buyPrice")
// 在创建订单前,检查订单簿中是否有可匹配的订单(避免 FAK 订单失败)
// 如果过滤检查时已经获取了订单簿,直接使用;否则重新获取
val orderbookForCheck = orderbook ?: run {
@@ -427,21 +482,21 @@ open class CopyOrderTrackingService(
null
}
}
// 检查是否有可匹配的卖单(asks)
if (orderbookForCheck != null) {
val bestAsk = orderbookForCheck.asks
.mapNotNull { it.price.toSafeBigDecimal() }
.minOrNull()
if (bestAsk == null) {
logger.warn("订单簿中没有卖单,跳过创建订单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}")
continue
}
// 如果调整后的买入价格低于最佳卖单价格,无法匹配
if (buyPrice.lt(bestAsk)) {
logger.warn("调整后的买入价格 ($buyPrice) 低于最佳卖单价格 ($bestAsk),无法匹配,跳过创建订单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}, leaderPrice=${trade.price}, tolerance=${copyTrading.priceTolerance}")
logger.info("调整后的买入价格 ($buyPrice) 低于最佳卖单价格 ($bestAsk),无法匹配,跳过创建订单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}, leaderPrice=${trade.price}, tolerance=${copyTrading.priceTolerance}")
continue
}
}
@@ -471,6 +526,17 @@ open class CopyOrderTrackingService(
// 解密私钥
val decryptedPrivateKey = decryptPrivateKey(account)
// 获取费率(根据 Polymarket Maker Rebates Program 要求)
val feeRateResult = clobService.getFeeRate(tokenId)
val feeRateBps = if (feeRateResult.isSuccess) {
feeRateResult.getOrNull()?.toString() ?: "0"
} else {
logger.warn("获取费率失败,使用默认值 0: tokenId=$tokenId, error=${feeRateResult.exceptionOrNull()?.message}")
"0"
}
logger.info("准备创建买入订单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}, leaderPrice=${trade.price}, tolerance=${copyTrading.priceTolerance}, calculatedPrice=$buyPrice, quantity=$finalBuyQuantity, baseFee=$feeRateBps")
// 调用API创建订单(带重试机制)
// 重试策略:最多重试 MAX_RETRY_ATTEMPTS 次,每次重试前等待 RETRY_DELAY_MS 毫秒
// 每次重试都会重新生成salt并重新签名,确保签名唯一性
@@ -484,58 +550,24 @@ open class CopyOrderTrackingService(
size = finalBuyQuantity.toString(),
owner = account.apiKey,
copyTradingId = copyTrading.id!!,
tradeId = trade.id
tradeId = trade.id,
feeRateBps = feeRateBps
)
// 处理订单创建失败
if (createOrderResult.isFailure) {
// 提取错误信息(只保留 code 和 errorBody
val exception = createOrderResult.exceptionOrNull()
val errorMsg = buildFullErrorMessage(
exception,
"BUY",
buyPrice.toString(),
finalBuyQuantity.toString(),
trade.id
)
// 记录失败交易到数据库
// retryCount = MAX_RETRY_ATTEMPTS - 1,表示已重试的次数
recordFailedTrade(
leaderId = leaderId,
trade = trade,
copyTradingId = copyTrading.id!!,
accountId = copyTrading.accountId,
side = "BUY",
price = buyPrice.toString(),
size = finalBuyQuantity.toString(),
errorMessage = errorMsg,
retryCount = MAX_RETRY_ATTEMPTS - 1 // 已重试次数
)
logger.error("创建买入订单失败: copyTradingId=${copyTrading.id}, tradeId=${trade.id}, leaderPrice=${trade.price}, myPrice=$buyPrice, error=${exception?.message}")
// 发送订单失败通知(异步,不阻塞,仅在 pushFailedOrders 为 true 时发送)
if (copyTrading.pushFailedOrders) {
notificationScope.launch {
try {
// 获取市场信息(标题和slug
val marketInfo = withContext(Dispatchers.IO) {
try {
val gammaApi = retrofitFactory.createGammaApi()
val marketResponse =
gammaApi.listMarkets(conditionIds = listOf(trade.market))
if (marketResponse.isSuccessful && marketResponse.body() != null) {
marketResponse.body()!!.firstOrNull()
} else {
null
}
} catch (e: Exception) {
logger.warn("获取市场信息失败: ${e.message}", e)
null
}
}
val marketTitle = marketInfo?.question ?: trade.market
val marketSlug = marketInfo?.slug
val market = marketService.getMarket(trade.market)
val marketTitle = market?.title ?: trade.market
val marketSlug = market?.eventSlug // 跳转用的 slug
// 获取当前语言设置(从 LocaleContextHolder
val locale = try {
@@ -567,7 +599,7 @@ open class CopyOrderTrackingService(
}
val realOrderId = createOrderResult.getOrNull() ?: continue
// 验证 orderId 格式(必须以 0x 开头的 16 进制)
if (!isValidOrderId(realOrderId)) {
logger.warn("买入订单ID格式无效,跳过保存: orderId=$realOrderId")
@@ -653,8 +685,8 @@ open class CopyOrderTrackingService(
private fun calculateBuyQuantity(trade: TradeResponse, copyTrading: CopyTrading): BigDecimal {
return when (copyTrading.copyMode) {
"RATIO" -> {
// 比例模式:Leader 数量 × 比例
trade.size.toSafeBigDecimal().multi(copyTrading.copyRatio)
// 比例模式:Leader 数量 × (比例 / 100)
trade.size.toSafeBigDecimal().multi(copyTrading.copyRatio.div(100))
}
"FIXED" -> {
@@ -686,7 +718,7 @@ open class CopyOrderTrackingService(
val leader = leaderRepository.findById(copyTrading.leaderId).orElse(null)
?: run {
logger.warn("Leader 不存在,使用默认比例: leaderId=${copyTrading.leaderId}")
return leaderSellQuantity.multi(copyTrading.copyRatio)
return leaderSellQuantity.multi(copyTrading.copyRatio.div(100))
}
// 创建不需要认证的 CLOB API 客户端(用于查询公开的交易数据)
@@ -705,7 +737,7 @@ open class CopyOrderTrackingService(
for (order in unmatchedOrders) {
val copyQty = order.quantity.toSafeBigDecimal()
var leaderQty: BigDecimal? = null
// 优先使用存储的 leaderBuyQuantity
if (order.leaderBuyQuantity != null) {
leaderQty = order.leaderBuyQuantity.toSafeBigDecimal()
@@ -716,7 +748,7 @@ open class CopyOrderTrackingService(
logger.debug("Leader 买入数量未存储,尝试查询 API: leaderBuyTradeId=${order.leaderBuyTradeId}, copyOrderId=${order.buyOrderId}")
try {
val tradesResponse = clobApi.getTrades(id = order.leaderBuyTradeId)
if (tradesResponse.isSuccessful && tradesResponse.body() != null) {
val tradesData = tradesResponse.body()!!.data
if (tradesData.isNotEmpty()) {
@@ -742,7 +774,7 @@ open class CopyOrderTrackingService(
failCount++
}
}
// 如果成功获取到 Leader 买入数量,累加
if (leaderQty != null && leaderQty.gt(BigDecimal.ZERO)) {
totalCopyQuantity = totalCopyQuantity.add(copyQty)
@@ -751,23 +783,23 @@ open class CopyOrderTrackingService(
logger.warn("无法获取 Leader 买入数量,跳过该订单: copyOrderId=${order.buyOrderId}, leaderBuyTradeId=${order.leaderBuyTradeId}")
}
}
logger.info("固定金额模式计算结果汇总: copyTradingId=${copyTrading.id}, successCount=$successCount, failCount=$failCount, totalCopyQuantity=$totalCopyQuantity, totalLeaderQuantity=$totalLeaderQuantity")
// 如果无法计算总比例(查询失败),使用默认比例
if (totalLeaderQuantity.lte(BigDecimal.ZERO)) {
logger.warn("无法计算总比例(Leader 买入数量为 0),使用默认比例: copyTradingId=${copyTrading.id}")
return leaderSellQuantity.multi(copyTrading.copyRatio)
return leaderSellQuantity.multi(copyTrading.copyRatio.div(100))
}
// 计算实际比例:跟单买入数量 / Leader 买入数量
val actualRatio = totalCopyQuantity.div(totalLeaderQuantity)
// 计算需要卖出的数量:Leader 卖出数量 × 实际比例
val needMatch = leaderSellQuantity.multi(actualRatio)
logger.debug("固定金额模式卖出数量计算: copyTradingId=${copyTrading.id}, leaderSellQuantity=$leaderSellQuantity, totalCopyQuantity=$totalCopyQuantity, totalLeaderQuantity=$totalLeaderQuantity, actualRatio=$actualRatio, needMatch=$needMatch")
return needMatch
}
@@ -831,16 +863,26 @@ open class CopyOrderTrackingService(
copyTrading = copyTrading
)
}
"RATIO" -> {
// 比例模式:直接使用配置的 copyRatio
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio)
// 比例模式:直接使用配置的 copyRatio (需要除以100)
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio.div(100))
}
else -> {
logger.warn("不支持的 copyMode: ${copyTrading.copyMode},使用默认比例模式")
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio)
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio.div(100))
}
}
// 如果需要卖出的数量小于1(但大于0),自动调整为1(Polymarket 最小下单数量)
// 注意:如果实际持有数量不足1,后续的 totalMatched 检查会拦截
var finalNeedMatch = needMatch
if (finalNeedMatch.gt(BigDecimal.ZERO) && finalNeedMatch.lt(BigDecimal.ONE)) {
logger.warn("计算得到的卖出数量小于1,自动调整为1: copyTradingId=${copyTrading.id}, original=$needMatch")
finalNeedMatch = BigDecimal.ONE
}
// 4. 获取tokenId(直接使用outcomeIndex,支持多元市场)
val tokenIdResult = blockchainService.getTokenId(leaderSellTrade.market, leaderSellTrade.outcomeIndex)
if (tokenIdResult.isFailure) {
@@ -864,7 +906,7 @@ open class CopyOrderTrackingService(
// 6. 按FIFO顺序匹配,计算实际可以卖出的数量
// 使用计算出的实际卖出价格(而不是 Leader 价格)来创建匹配明细
var totalMatched = BigDecimal.ZERO
var remaining = needMatch
var remaining = finalNeedMatch
val matchDetails = mutableListOf<SellMatchDetail>()
for (order in unmatchedOrders) {
@@ -901,6 +943,11 @@ open class CopyOrderTrackingService(
return
}
if (totalMatched.lt(BigDecimal.ONE)) {
logger.warn("卖出数量小于1,跳过卖出 (Polymarket 最小下单数量为 1): copyTradingId=${copyTrading.id}, tradeId=${leaderSellTrade.id}, quantity=$totalMatched")
return
}
// 7. 解密 API 凭证
val apiSecret = try {
decryptApiSecret(account)
@@ -917,7 +964,16 @@ open class CopyOrderTrackingService(
// 8. 解密私钥(在方法开始时解密一次,后续复用)
val decryptedPrivateKey = decryptPrivateKey(account)
// 获取费率(根据 Polymarket Maker Rebates Program 要求)
val feeRateResult = clobService.getFeeRate(tokenId)
val feeRateBps = if (feeRateResult.isSuccess) {
feeRateResult.getOrNull()?.toString() ?: "0"
} else {
logger.warn("获取费率失败,使用默认值 0: tokenId=$tokenId, error=${feeRateResult.exceptionOrNull()?.message}")
"0"
}
// 9. 创建并签名卖出订单
val signedOrder = try {
orderSigningService.createAndSignOrder(
@@ -929,7 +985,7 @@ open class CopyOrderTrackingService(
size = totalMatched.toString(),
signatureType = 2, // Browser Wallet
nonce = "0",
feeRateBps = "0",
feeRateBps = feeRateBps, // 使用动态获取的费率
expiration = "0"
)
} catch (e: Exception) {
@@ -967,30 +1023,14 @@ open class CopyOrderTrackingService(
size = totalMatched.toString(),
owner = account.apiKey,
copyTradingId = copyTrading.id,
tradeId = leaderSellTrade.id
tradeId = leaderSellTrade.id,
feeRateBps = feeRateBps
)
if (createOrderResult.isFailure) {
// 创建订单失败,记录到失败表
// 创建订单失败,记录错误日志
val exception = createOrderResult.exceptionOrNull()
val errorMsg = buildFullErrorMessage(
exception,
"SELL",
sellPrice.toString(),
totalMatched.toString(),
leaderSellTrade.id
)
recordFailedTrade(
leaderId = copyTrading.leaderId,
trade = leaderSellTrade,
copyTradingId = copyTrading.id!!,
accountId = copyTrading.accountId,
side = "SELL", // 订单方向是SELL
price = sellPrice.toString(),
size = totalMatched.toString(),
errorMessage = errorMsg,
retryCount = 1 // 已重试一次
)
logger.error("创建卖出订单失败: copyTradingId=${copyTrading.id}, tradeId=${leaderSellTrade.id}, error=${exception?.message}")
return
}
@@ -1004,7 +1044,7 @@ open class CopyOrderTrackingService(
} else {
logger.debug("卖出订单ID为0x开头,等待定时任务更新价格: orderId=$realSellOrderId")
}
// 使用下单价格,等待定时任务更新实际成交价
val actualSellPrice = sellPrice
@@ -1053,19 +1093,19 @@ open class CopyOrderTrackingService(
val savedDetail = detail.copy(matchRecordId = savedRecord.id!!)
sellMatchDetailRepository.save(savedDetail)
}
logger.info("卖出订单已保存,等待轮询任务获取实际数据后发送通知: orderId=$realSellOrderId, copyTradingId=${copyTrading.id}")
}
/**
* 创建订单带重试机制
*
*
* 重试策略
* - 最多重试 MAX_RETRY_ATTEMPTS 首次尝试 + 重试
* - 每次重试前等待 RETRY_DELAY_MS 毫秒
* - 每次重试都重新生成salt并重新签名确保签名唯一性
*
*
* @param clobApi CLOB API 客户端
* @param privateKey 私钥用于签名
* @param makerAddress 代理钱包地址
@@ -1076,6 +1116,7 @@ open class CopyOrderTrackingService(
* @param owner API Key用于owner字段
* @param copyTradingId 跟单配置ID用于日志
* @param tradeId Leader 交易ID用于日志
* @param feeRateBps 费率基点从API动态获取
* @return 成功返回订单ID失败返回异常
*/
private suspend fun createOrderWithRetry(
@@ -1088,7 +1129,8 @@ open class CopyOrderTrackingService(
size: String,
owner: String,
copyTradingId: Long,
tradeId: String
tradeId: String,
feeRateBps: String
): Result<String> {
var lastError: Exception? = null
@@ -1105,7 +1147,7 @@ open class CopyOrderTrackingService(
size = size,
signatureType = 2, // Browser Wallet
nonce = "0",
feeRateBps = "0",
feeRateBps = feeRateBps, // 使用动态获取的费率
expiration = "0"
)
@@ -1131,10 +1173,10 @@ open class CopyOrderTrackingService(
}
val errorMsg = "code=${orderResponse.code()}, errorBody=${errorBody ?: "null"}"
lastError = Exception(errorMsg)
// 记录错误日志
logger.error("创建订单失败 (尝试 $attempt/$MAX_RETRY_ATTEMPTS): copyTradingId=$copyTradingId, tradeId=$tradeId, $errorMsg")
// 如果不是最后一次尝试,等待后重试
if (attempt < MAX_RETRY_ATTEMPTS) {
delay(RETRY_DELAY_MS)
@@ -1148,10 +1190,10 @@ open class CopyOrderTrackingService(
if (!response.success || response.orderId == null) {
val errorMsg = "errorMsg=${response.errorMsg}"
lastError = Exception(errorMsg)
// 记录错误日志
logger.error("创建订单失败 (尝试 $attempt/$MAX_RETRY_ATTEMPTS): copyTradingId=$copyTradingId, tradeId=$tradeId, $errorMsg")
// 如果不是最后一次尝试,等待后重试
if (attempt < MAX_RETRY_ATTEMPTS) {
delay(RETRY_DELAY_MS)
@@ -1163,14 +1205,17 @@ open class CopyOrderTrackingService(
// 创建订单成功
logger.info("创建订单成功: copyTradingId=$copyTradingId, tradeId=$tradeId, orderId=${response.orderId}, attempt=$attempt")
return Result.success(response.orderId)
} catch (e: Exception) {
val errorMsg = "error=${e.message}"
lastError = Exception(errorMsg, e)
// 记录错误日志(包含堆栈)
logger.error("创建订单异常 (尝试 $attempt/$MAX_RETRY_ATTEMPTS): copyTradingId=$copyTradingId, tradeId=$tradeId, $errorMsg", e)
logger.error(
"创建订单异常 (尝试 $attempt/$MAX_RETRY_ATTEMPTS): copyTradingId=$copyTradingId, tradeId=$tradeId, $errorMsg",
e
)
// 如果不是最后一次尝试,等待后重试
if (attempt < MAX_RETRY_ATTEMPTS) {
delay(RETRY_DELAY_MS)
@@ -1182,7 +1227,10 @@ open class CopyOrderTrackingService(
// 所有重试都失败
val finalError = lastError ?: Exception("error=未知错误")
logger.error("创建订单失败(所有重试都失败): copyTradingId=$copyTradingId, tradeId=$tradeId, side=$side, price=$price, size=$size", finalError)
logger.error(
"创建订单失败(所有重试都失败): copyTradingId=$copyTradingId, tradeId=$tradeId, side=$side, price=$price, size=$size",
finalError
)
return Result.failure(finalError)
}
@@ -1248,98 +1296,6 @@ open class CopyOrderTrackingService(
return "code=$code, errorBody=$errorBody"
}
/**
* 记录失败交易到数据库
* 注意此方法在 @Transactional 方法中被调用会自动继承事务
*/
private suspend fun recordFailedTrade(
leaderId: Long,
trade: TradeResponse,
copyTradingId: Long,
accountId: Long,
side: String,
price: String,
size: String,
errorMessage: String,
retryCount: Int
) {
try {
// 确保错误信息不超过数据库字段限制(TEXT类型通常支持65535字符)
val maxErrorMessageLength = 50000 // 保留一些余量
val finalErrorMessage = if (errorMessage.length > maxErrorMessageLength) {
errorMessage.substring(0, maxErrorMessageLength) + "... (截断)"
} else {
errorMessage
}
val failedTrade = FailedTrade(
leaderId = leaderId,
leaderTradeId = trade.id,
tradeType = trade.side.uppercase(),
copyTradingId = copyTradingId,
accountId = accountId,
marketId = trade.market,
side = side,
price = price,
size = size,
errorMessage = finalErrorMessage,
retryCount = retryCount,
failedAt = System.currentTimeMillis()
)
failedTradeRepository.save(failedTrade)
// 记录日志,确认已保存到数据库
logger.info("失败交易已保存到数据库: leaderId=$leaderId, tradeId=${trade.id}, errorMessageLength=${finalErrorMessage.length}")
// 标记为已处理(失败状态),避免重复处理
// 注意:并发情况下可能多个请求同时处理同一笔交易,需要处理唯一约束冲突
try {
val processed = ProcessedTrade(
leaderId = leaderId,
leaderTradeId = trade.id,
tradeType = trade.side.uppercase(),
source = "polling",
status = "FAILED",
processedAt = System.currentTimeMillis()
)
processedTradeRepository.save(processed)
} catch (e: Exception) {
// 检查是否是唯一键冲突异常
if (isUniqueConstraintViolation(e)) {
// 唯一约束冲突,说明已经处理过了(可能是并发请求)
// 检查现有记录的状态
val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
if (existing != null) {
if (existing.status == "SUCCESS") {
logger.warn("交易已成功处理,但尝试记录为失败(并发冲突): leaderId=$leaderId, tradeId=${trade.id}")
} else {
logger.debug("交易已标记为失败(并发检测): leaderId=$leaderId, tradeId=${trade.id}")
}
} else {
// 如果查询不到,等待一下再查询(可能是事务隔离级别问题)
delay(100)
val existingAfterDelay =
processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
if (existingAfterDelay != null) {
logger.debug("延迟查询到记录(并发检测): leaderId=$leaderId, tradeId=${trade.id}, status=${existingAfterDelay.status}")
} else {
logger.warn(
"保存ProcessedTrade失败记录时发生唯一约束冲突,但查询不到记录: leaderId=$leaderId, tradeId=${trade.id}",
e
)
}
}
} else {
// 其他类型的异常,记录但不抛出(避免影响其他交易的处理)
logger.warn("保存ProcessedTrade失败记录时发生异常: leaderId=$leaderId, tradeId=${trade.id}", e)
}
}
logger.warn("已记录失败交易: leaderId=$leaderId, tradeId=${trade.id}, error=$errorMessage")
} catch (e: Exception) {
logger.error("记录失败交易异常: leaderId=$leaderId, tradeId=${trade.id}", e)
}
}
/**
* 更新订单状态
@@ -1409,7 +1365,7 @@ open class CopyOrderTrackingService(
// 计算价格调整范围(百分比)
val tolerancePercent = tolerance.div(100)
val adjustment = originalPrice.multi(tolerancePercent)
val adjustment = originalPrice.multi(tolerancePercent).max(0.01.toSafeBigDecimal())
return if (isBuy) {
// 买入:可以稍微加价以确保成交(在原价格基础上加容忍度)
@@ -1456,13 +1412,15 @@ open class CopyOrderTrackingService(
FilterStatus.FAILED_ORDER_DEPTH -> "ORDER_DEPTH"
FilterStatus.FAILED_MAX_POSITION_VALUE -> "MAX_POSITION_VALUE"
FilterStatus.FAILED_MAX_POSITION_COUNT -> "MAX_POSITION_COUNT"
FilterStatus.FAILED_KEYWORD_FILTER -> "KEYWORD_FILTER"
FilterStatus.FAILED_MARKET_END_DATE -> "MARKET_END_DATE"
}
}
/**
* 验证订单ID格式
* 订单ID必须以 0x 开头且是有效的 16 进制字符串
*
*
* @param orderId 订单ID
* @return 如果格式有效返回 true否则返回 false
*/
@@ -1478,11 +1436,11 @@ open class CopyOrderTrackingService(
// 检查是否只包含 0-9, a-f, A-F
return hexPart.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }
}
/**
* 获取订单的实际成交价
* 通过查询订单详情和关联的交易记录计算加权平均成交价
*
*
* @param orderId 订单ID
* @param clobApi CLOB API 客户端已认证
* @param fallbackPrice 如果查询失败使用此价格作为默认值
@@ -1496,13 +1454,19 @@ open class CopyOrderTrackingService(
return try {
// 1. 查询订单详情
val orderResponse = clobApi.getOrder(orderId)
if (!orderResponse.isSuccessful || orderResponse.body() == null) {
logger.warn("查询订单详情失败: orderId=$orderId, code=${orderResponse.code()}")
if (!orderResponse.isSuccessful) {
val errorBody = orderResponse.errorBody()?.string()?.take(200) ?: "无错误详情"
logger.warn("查询订单详情失败: orderId=$orderId, code=${orderResponse.code()}, errorBody=$errorBody")
return fallbackPrice
}
val order = orderResponse.body()
if (order == null) {
// 响应体为空,可能是订单不存在或已过期
logger.warn("查询订单详情失败: 响应体为空, orderId=$orderId, code=${orderResponse.code()}")
return fallbackPrice
}
val order = orderResponse.body()!!
// 2. 如果订单未成交,使用下单价格
if (order.status != "FILLED" && order.sizeMatched.toSafeBigDecimal() <= BigDecimal.ZERO) {
logger.debug("订单未成交,使用下单价格: orderId=$orderId, status=${order.status}")
@@ -1543,7 +1507,7 @@ open class CopyOrderTrackingService(
for (trade in trades) {
val tradePrice = trade.price.toSafeBigDecimal()
val tradeSize = trade.size.toSafeBigDecimal()
if (tradeSize > BigDecimal.ZERO) {
totalAmount = totalAmount.add(tradePrice.multiply(tradeSize))
totalSize = totalSize.add(tradeSize)
@@ -31,8 +31,7 @@ class CopyTradingStatisticsService(
private val sellMatchDetailRepository: SellMatchDetailRepository,
private val accountRepository: AccountRepository,
private val leaderRepository: LeaderRepository,
private val accountService: AccountService,
private val blockchainService: BlockchainService
private val marketService: com.wrbug.polymarketbot.service.common.MarketService
) {
private val logger = LoggerFactory.getLogger(CopyTradingStatisticsService::class.java)
@@ -62,19 +61,12 @@ class CopyTradingStatisticsService(
// 6. 计算统计信息
val statistics = calculateStatistics(buyOrders, sellRecords, matchDetails)
// 7. 获取链上实际持仓(用于准确计算未实现盈亏,考虑手动卖出的情况
val actualPositions = getActualPositions(account)
// 7. 不再计算未实现盈亏和持仓价值(优化性能
// 未实现盈亏计算需要查询链上持仓和市场价格,性能开销大
val unrealizedPnl = "0"
val positionValue = "0"
// 8. 获取当前市场价格(用于计算未实现盈亏)
val currentPrice = getCurrentMarketPrice(buyOrders)
// 9. 计算未实现盈亏(使用链上实际持仓,而不是 remainingQuantity
val unrealizedPnl = calculateUnrealizedPnl(buyOrders, currentPrice, actualPositions)
// 10. 计算持仓价值(使用链上实际持仓和当前价格)
val positionValue = calculatePositionValue(buyOrders, currentPrice, actualPositions)
// 11. 构建响应
// 8. 构建响应(总盈亏 = 已实现盈亏)
val response = CopyTradingStatisticsResponse(
copyTradingId = copyTradingId,
accountId = copyTrading.accountId,
@@ -93,8 +85,8 @@ class CopyTradingStatisticsService(
currentPositionValue = positionValue,
totalRealizedPnl = statistics.totalRealizedPnl,
totalUnrealizedPnl = unrealizedPnl,
totalPnl = (statistics.totalRealizedPnl.toSafeBigDecimal().add(unrealizedPnl.toSafeBigDecimal())).toString(),
totalPnlPercent = calculatePnlPercent(statistics.totalBuyAmount, statistics.totalRealizedPnl, unrealizedPnl)
totalPnl = statistics.totalRealizedPnl,
totalPnlPercent = calculatePnlPercentOnlyRealized(statistics.totalBuyAmount, statistics.totalRealizedPnl)
)
Result.success(response)
@@ -142,12 +134,21 @@ class CopyTradingStatisticsService(
private fun getBuyOrderList(request: OrderTrackingRequest): Pair<List<BuyOrderInfo>, Long> {
var orders = copyOrderTrackingRepository.findByCopyTradingId(request.copyTradingId)
// 批量获取市场信息(用于筛选)
val allMarketIds = orders.map { it.marketId }.distinct()
val markets = marketService.getMarkets(allMarketIds)
// 筛选
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()) {
orders = orders.filter { it.side == request.side }
if (!request.marketTitle.isNullOrBlank()) {
// marketTitle 关键字筛选
orders = orders.filter { order ->
val market = markets[order.marketId]
market?.title?.contains(request.marketTitle!!, ignoreCase = true) == true
}
}
if (!request.status.isNullOrBlank()) {
orders = orders.filter { it.status == request.status }
@@ -168,10 +169,15 @@ class CopyTradingStatisticsService(
// 转换为DTO
val list = pagedOrders.map { order ->
val amount = order.quantity.toSafeBigDecimal().multi(order.price)
val market = markets[order.marketId]
BuyOrderInfo(
orderId = order.buyOrderId,
leaderTradeId = order.leaderBuyTradeId,
marketId = order.marketId,
marketTitle = market?.title,
marketSlug = market?.slug, // 显示用的 slug
eventSlug = market?.eventSlug, // 跳转用的 slug(从数据库读取)
marketCategory = market?.category,
side = order.side,
quantity = order.quantity.toString(),
price = order.price.toString(),
@@ -192,12 +198,21 @@ class CopyTradingStatisticsService(
private fun getSellOrderList(request: OrderTrackingRequest): Pair<List<SellOrderInfo>, Long> {
var records = sellMatchRecordRepository.findByCopyTradingId(request.copyTradingId)
// 批量获取市场信息(用于筛选)
val allMarketIds = records.map { it.marketId }.distinct()
val markets = marketService.getMarkets(allMarketIds)
// 筛选
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()) {
records = records.filter { it.side == request.side }
if (!request.marketTitle.isNullOrBlank()) {
// marketTitle 关键字筛选
records = records.filter { record ->
val market = markets[record.marketId]
market?.title?.contains(request.marketTitle!!, ignoreCase = true) == true
}
}
val total = records.size.toLong()
@@ -215,10 +230,15 @@ class CopyTradingStatisticsService(
// 转换为DTO
val list = pagedRecords.map { record ->
val amount = record.totalMatchedQuantity.toSafeBigDecimal().multi(record.sellPrice)
val market = markets[record.marketId]
SellOrderInfo(
orderId = record.sellOrderId,
leaderTradeId = record.leaderSellTradeId,
marketId = record.marketId,
marketTitle = market?.title,
marketSlug = market?.slug, // 显示用的 slug
eventSlug = market?.eventSlug, // 跳转用的 slug(从数据库读取)
marketCategory = market?.category,
side = record.side,
quantity = record.totalMatchedQuantity.toString(),
price = record.sellPrice.toString(),
@@ -237,6 +257,14 @@ class CopyTradingStatisticsService(
private fun getMatchedOrderList(request: OrderTrackingRequest): Pair<List<MatchedOrderInfo>, Long> {
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
if (!request.sellOrderId.isNullOrBlank()) {
@@ -250,6 +278,21 @@ class CopyTradingStatisticsService(
if (!request.buyOrderId.isNullOrBlank()) {
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()
@@ -263,11 +306,26 @@ class CopyTradingStatisticsService(
val end = minOf(start + limit, filtered.size)
val pagedDetails = if (start < filtered.size) filtered.subList(start, end) else emptyList()
// 获取匹配记录以获取市场ID
val pagedMatchRecordIds = pagedDetails.map { it.matchRecordId }.distinct()
val pagedMatchRecords = pagedMatchRecordIds.mapNotNull { id ->
sellMatchRecordRepository.findById(id).orElse(null)
}
val pagedMarketIds = pagedMatchRecords.map { it.marketId }.distinct()
val pagedMarkets = marketService.getMarkets(pagedMarketIds)
// 转换为DTO
val list = pagedDetails.map { detail ->
val matchRecord = pagedMatchRecords.find { it.id == detail.matchRecordId }
val market = matchRecord?.let { pagedMarkets[it.marketId] }
MatchedOrderInfo(
sellOrderId = sellMatchRecordRepository.findById(detail.matchRecordId).orElse(null)?.sellOrderId ?: "",
sellOrderId = matchRecord?.sellOrderId ?: "",
buyOrderId = detail.buyOrderId,
marketId = matchRecord?.marketId,
marketTitle = market?.title,
marketSlug = market?.slug, // 显示用的 slug
eventSlug = market?.eventSlug, // 跳转用的 slug(从数据库读取)
marketCategory = market?.category,
matchedQuantity = detail.matchedQuantity.toString(),
buyPrice = detail.buyPrice.toString(),
sellPrice = detail.sellPrice.toString(),
@@ -324,200 +382,16 @@ class CopyTradingStatisticsService(
}
/**
* 获取当前市场价格
* (marketId, outcomeIndex) 组合获取价格支持多元市场
* 计算盈亏百分比仅基于已实现盈亏
*/
private suspend fun getCurrentMarketPrice(buyOrders: List<CopyOrderTracking>): Map<String, String> {
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.midpoint ?: response.lastPrice
if (price != null) {
// 使用 "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(
private fun calculatePnlPercentOnlyRealized(
totalBuyAmount: String,
totalRealizedPnl: String,
totalUnrealizedPnl: String
totalRealizedPnl: String
): String {
val buyAmount = totalBuyAmount.toSafeBigDecimal()
if (buyAmount.lte(BigDecimal.ZERO)) return "0"
val totalPnl = totalRealizedPnl.toSafeBigDecimal().add(totalUnrealizedPnl.toSafeBigDecimal())
val percent = totalPnl.div(buyAmount).multi(100)
val percent = totalRealizedPnl.toSafeBigDecimal().div(buyAmount).multi(100)
return percent.setScale(2, RoundingMode.HALF_UP).toString()
}
@@ -696,5 +570,250 @@ class CopyTradingStatisticsService(
val currentPositionQuantity: String,
val totalRealizedPnl: String
)
/**
* 获取按市场分组的买入订单列表
*/
fun getBuyOrderListGroupedByMarket(request: MarketGroupedOrdersRequest): Result<MarketGroupedOrdersResponse> {
return try {
// 1. 验证跟单关系
copyTradingRepository.findById(request.copyTradingId).orElse(null)
?: return Result.failure(IllegalArgumentException("跟单关系不存在: ${request.copyTradingId}"))
// 2. 获取所有买入订单
var orders = copyOrderTrackingRepository.findByCopyTradingId(request.copyTradingId)
// 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>>()
orders.forEach { order ->
val marketId = order.marketId
if (!groups.containsKey(marketId)) {
groups[marketId] = mutableListOf()
}
groups[marketId]!!.add(order)
}
// 4. 转换为分组数据并计算统计信息
val marketIds = groups.keys.toList()
val list = marketIds.map { marketId ->
val marketOrders = groups[marketId] ?: mutableListOf()
// 计算统计信息
val count = marketOrders.size.toLong()
val totalAmount = marketOrders.sumOf { order ->
order.quantity.toSafeBigDecimal().multi(order.price)
}
// 计算订单状态统计
val fullyMatchedCount = marketOrders.count { it.status == "fully_matched" }
val partiallyMatchedCount = marketOrders.count { it.status == "partially_matched" }
val filledCount = marketOrders.count { it.status == "filled" }
val fullyMatched = fullyMatchedCount == marketOrders.size
val stats = MarketOrderStats(
count = count,
totalAmount = totalAmount.toString(),
totalPnl = null, // 买入订单没有已实现盈亏
fullyMatched = fullyMatched,
fullyMatchedCount = fullyMatchedCount.toLong(),
partiallyMatchedCount = partiallyMatchedCount.toLong(),
filledCount = filledCount.toLong()
)
// 排序(按创建时间倒序)
marketOrders.sortByDescending { it.createdAt }
// 转换为 DTO
val orderDtos = marketOrders.map { order ->
val amount = order.quantity.toSafeBigDecimal().multi(order.price)
val market = markets[order.marketId]
BuyOrderInfo(
orderId = order.buyOrderId,
leaderTradeId = order.leaderBuyTradeId,
marketId = order.marketId,
marketTitle = market?.title,
marketSlug = market?.slug, // 显示用的 slug
eventSlug = market?.eventSlug, // 跳转用的 slug(从数据库读取)
marketCategory = market?.category,
side = order.side,
quantity = order.quantity.toString(),
price = order.price.toString(),
amount = amount.toString(),
matchedQuantity = order.matchedQuantity.toString(),
remainingQuantity = order.remainingQuantity.toString(),
status = order.status,
createdAt = order.createdAt
)
}
MarketOrderGroup(
marketId = marketId,
marketTitle = markets[marketId]?.title,
marketSlug = markets[marketId]?.slug, // 显示用的 slug
eventSlug = markets[marketId]?.eventSlug, // 跳转用的 slug(从数据库读取)
marketCategory = markets[marketId]?.category,
stats = stats,
orders = orderDtos as List<Any>
)
}.sortedByDescending { it.stats.count }
// 5. 分页
val page = (request.page ?: 1)
val limit = request.limit ?: 20
val total = list.size.toLong()
val start = (page - 1) * limit
val end = minOf(start + limit, list.size)
val pagedList = if (start < list.size) list.subList(start, end) else emptyList()
val response = MarketGroupedOrdersResponse(
list = pagedList,
total = total,
page = page,
limit = limit
)
Result.success(response)
} catch (e: Exception) {
logger.error("获取按市场分组的买入订单列表失败: copyTradingId=${request.copyTradingId}", e)
Result.failure(e)
}
}
/**
* 获取按市场分组的卖出订单列表
*/
fun getSellOrderListGroupedByMarket(request: MarketGroupedOrdersRequest): Result<MarketGroupedOrdersResponse> {
return try {
// 1. 验证跟单关系
copyTradingRepository.findById(request.copyTradingId).orElse(null)
?: return Result.failure(IllegalArgumentException("跟单关系不存在: ${request.copyTradingId}"))
// 2. 获取所有卖出记录
var sellRecords = sellMatchRecordRepository.findByCopyTradingId(request.copyTradingId)
// 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>>()
sellRecords.forEach { record ->
val marketId = record.marketId
if (!groups.containsKey(marketId)) {
groups[marketId] = mutableListOf()
}
groups[marketId]!!.add(record)
}
// 4. 转换为分组数据并计算统计信息
val marketIds = groups.keys.toList()
val list = marketIds.map { marketId ->
val marketRecords = groups[marketId] ?: mutableListOf()
// 计算统计信息
val count = marketRecords.size.toLong()
val totalAmount = marketRecords.sumOf { record ->
record.totalMatchedQuantity.toSafeBigDecimal().multi(record.sellPrice)
}
val totalPnl = marketRecords.sumOf { it.totalRealizedPnl.toSafeBigDecimal() }
val stats = MarketOrderStats(
count = count,
totalAmount = totalAmount.toString(),
totalPnl = totalPnl.toString(),
fullyMatched = true, // 卖出订单都是已成交的
fullyMatchedCount = count, // 所有订单都是已成交的
partiallyMatchedCount = 0L,
filledCount = 0L
)
// 排序(按创建时间倒序)
marketRecords.sortByDescending { it.createdAt }
// 转换为 DTO
val orderDtos = marketRecords.map { record ->
val amount = record.totalMatchedQuantity.toSafeBigDecimal().multi(record.sellPrice)
val market = markets[record.marketId]
SellOrderInfo(
orderId = record.sellOrderId,
leaderTradeId = record.leaderSellTradeId,
marketId = record.marketId,
marketTitle = market?.title,
marketSlug = market?.slug, // 显示用的 slug
eventSlug = market?.eventSlug, // 跳转用的 slug(从数据库读取)
marketCategory = market?.category,
side = record.side,
quantity = record.totalMatchedQuantity.toString(),
price = record.sellPrice.toString(),
amount = amount.toString(),
realizedPnl = record.totalRealizedPnl.toString(),
createdAt = record.createdAt
)
}
MarketOrderGroup(
marketId = marketId,
marketTitle = markets[marketId]?.title,
marketSlug = markets[marketId]?.slug, // 显示用的 slug
eventSlug = markets[marketId]?.eventSlug, // 跳转用的 slug(从数据库读取)
marketCategory = markets[marketId]?.category,
stats = stats,
orders = orderDtos as List<Any>
)
}.sortedByDescending { it.stats.count }
// 5. 分页
val page = (request.page ?: 1)
val limit = request.limit ?: 20
val total = list.size.toLong()
val start = (page - 1) * limit
val end = minOf(start + limit, list.size)
val pagedList = if (start < list.size) list.subList(start, end) else emptyList()
val response = MarketGroupedOrdersResponse(
list = pagedList,
total = total,
page = page,
limit = limit
)
Result.success(response)
} catch (e: Exception) {
logger.error("获取按市场分组的卖出订单列表失败: copyTradingId=${request.copyTradingId}", e)
Result.failure(e)
}
}
}
@@ -3,6 +3,7 @@ package com.wrbug.polymarketbot.service.copytrading.statistics
import com.wrbug.polymarketbot.api.PolymarketClobApi
import com.wrbug.polymarketbot.entity.*
import com.wrbug.polymarketbot.repository.*
import com.wrbug.polymarketbot.service.common.MarketService
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.CryptoUtils
@@ -33,6 +34,7 @@ class OrderStatusUpdateService(
private val retrofitFactory: RetrofitFactory,
private val cryptoUtils: CryptoUtils,
private val trackingService: CopyOrderTrackingService,
private val marketService: MarketService, // 市场信息服务
private val telegramNotificationService: TelegramNotificationService?
) {
@@ -56,10 +58,13 @@ class OrderStatusUpdateService(
// 1. 清理已删除账户的订单
cleanupDeletedAccountOrders()
// 2. 更新卖出订单的实际成交价并发送通知(priceUpdated 共用字段)
// 2. 检查30秒前创建的订单,如果未成交则删除
checkAndDeleteUnfilledOrders()
// 3. 更新卖出订单的实际成交价并发送通知(priceUpdated 共用字段)
updatePendingSellOrderPrices()
// 3. 更新买入订单的实际数据并发送通知
// 4. 更新买入订单的实际数据并发送通知
updatePendingBuyOrders()
} catch (e: Exception) {
logger.error("订单状态更新异常: ${e.message}", e)
@@ -130,6 +135,136 @@ class OrderStatusUpdateService(
}
}
/**
* 检查30秒前创建的订单如果未成交则删除
* 首次检测但加入缓存中30s后还没有成交则删除
*/
@Transactional
private suspend fun checkAndDeleteUnfilledOrders() {
try {
// 计算30秒前的时间戳
val thirtySecondsAgo = System.currentTimeMillis() - 30000
// 查询30秒前创建的订单,并过滤掉已经完全匹配的订单
// 已经完全匹配的订单(status = "fully_matched")不需要再检查
// 使用数据库查询过滤,避免加载过多数据
val ordersToCheck = copyOrderTrackingRepository.findByCreatedAtBeforeAndStatusNot(
thirtySecondsAgo,
"fully_matched"
)
if (ordersToCheck.isEmpty()) {
return
}
logger.debug("检查 ${ordersToCheck.size} 个30秒前创建的订单是否成交")
// 按账户分组,避免重复创建 API 客户端
val ordersByAccount = ordersToCheck.groupBy { it.accountId }
for ((accountId, orders) in ordersByAccount) {
try {
// 获取账户
val account = accountRepository.findById(accountId).orElse(null)
if (account == null) {
logger.warn("账户不存在,跳过检查: accountId=$accountId")
continue
}
// 检查账户是否配置了 API 凭证
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
logger.debug("账户未配置 API 凭证,跳过检查: accountId=${account.id}")
continue
}
// 解密 API 凭证
val apiSecret = try {
cryptoUtils.decrypt(account.apiSecret!!)
} catch (e: Exception) {
logger.warn("解密 API Secret 失败: accountId=${account.id}, error=${e.message}")
continue
}
val apiPassphrase = try {
cryptoUtils.decrypt(account.apiPassphrase!!)
} catch (e: Exception) {
logger.warn("解密 API Passphrase 失败: accountId=${account.id}, error=${e.message}")
continue
}
// 创建带认证的 CLOB API 客户端
val clobApi = retrofitFactory.createClobApi(
account.apiKey!!,
apiSecret,
apiPassphrase,
account.walletAddress
)
// 检查每个订单
for (order in orders) {
try {
// 查询订单详情
val orderResponse = clobApi.getOrder(order.buyOrderId)
// 先检查 HTTP 状态码,非 200 的都跳过
if (orderResponse.code() != 200) {
// HTTP 非 200,记录日志并跳过,等待下次轮询
// 不删除订单,因为可能是临时网络问题或 API 错误
val errorBody = orderResponse.errorBody()?.string()?.take(200) ?: "无错误详情"
logger.debug("订单查询失败(HTTP非200),等待下次轮询: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, code=${orderResponse.code()}, errorBody=$errorBody")
continue
}
// HTTP 200,检查响应体
// 响应体也可能返回字符串 "null"Gson 解析时会返回 null
val orderDetail = orderResponse.body()
if (orderDetail == null) {
// HTTP 200 且响应体为 null(或字符串 "null"),表示订单不存在
// 检查订单是否已部分卖出,如果已部分卖出则保留订单用于统计
val hasMatchedDetails = sellMatchDetailRepository.findByTrackingId(order.id!!).isNotEmpty()
if (hasMatchedDetails || order.matchedQuantity > BigDecimal.ZERO) {
logger.debug("订单不存在但已部分卖出,保留订单用于统计: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}")
continue
}
// 订单不存在且未部分卖出,删除本地订单
logger.info("订单不存在(HTTP 200 但响应体为空),删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
try {
copyOrderTrackingRepository.deleteById(order.id!!)
logger.info("已删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
} catch (e: Exception) {
logger.error("删除本地订单失败: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, error=${e.message}", e)
}
continue
}
// 检查订单是否成交
// 如果订单状态不是 FILLED 且已成交数量为0,说明未成交,删除
val sizeMatched = orderDetail.sizeMatched?.toSafeBigDecimal() ?: BigDecimal.ZERO
if (orderDetail.status != "FILLED" && sizeMatched <= BigDecimal.ZERO) {
logger.info("订单30秒后仍未成交,删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, status=${orderDetail.status}, sizeMatched=$sizeMatched")
try {
copyOrderTrackingRepository.deleteById(order.id!!)
logger.info("已删除未成交订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
} catch (e: Exception) {
logger.error("删除未成交订单失败: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, error=${e.message}", e)
}
} else {
logger.debug("订单已成交或部分成交,保留: orderId=${order.buyOrderId}, status=${orderDetail.status}, sizeMatched=$sizeMatched")
}
} catch (e: Exception) {
logger.error("检查订单失败: orderId=${order.buyOrderId}, error=${e.message}", e)
}
}
} catch (e: Exception) {
logger.error("检查账户订单失败: accountId=$accountId, error=${e.message}", e)
}
}
} catch (e: Exception) {
logger.error("检查未成交订单异常: ${e.message}", e)
}
}
/**
* 更新待更新的卖出订单价格
* 注意priceUpdated 现在同时表示价格已更新和通知已发送共用字段
@@ -209,7 +344,8 @@ class OrderStatusUpdateService(
copyTrading = copyTrading,
clobApi = clobApi,
apiSecret = apiSecret,
apiPassphrase = apiPassphrase
apiPassphrase = apiPassphrase,
orderCreatedAt = record.createdAt
)
} else {
logger.debug("自动生成的订单,跳过发送通知: orderId=${record.sellOrderId}")
@@ -294,19 +430,7 @@ class OrderStatusUpdateService(
totalRealizedPnl = totalRealizedPnl.add(updatedRealizedPnl)
}
// 发送通知(使用实际价格
sendSellOrderNotification(
record = record,
actualPrice = actualSellPrice.toString(),
actualSize = record.totalMatchedQuantity.toString(),
account = account,
copyTrading = copyTrading,
clobApi = clobApi,
apiSecret = apiSecret,
apiPassphrase = apiPassphrase
)
// 更新卖出记录
// 先更新卖出记录,标记 priceUpdated = true(在发送通知之前更新
// 注意:SellMatchRecord 的字段都是 val,需要创建新对象
val updatedRecord = SellMatchRecord(
id = record.id,
@@ -324,20 +448,31 @@ class OrderStatusUpdateService(
)
sellMatchRecordRepository.save(updatedRecord)
logger.info("更新卖出订单价格成功并已发送通知: orderId=${record.sellOrderId}, 原价格=${record.sellPrice}, 新价格=$actualSellPrice")
} else {
// 价格相同,但已经查询过,发送通知并标记为已处理
sendSellOrderNotification(
record = record,
actualPrice = actualSellPrice.toString(),
actualSize = record.totalMatchedQuantity.toString(),
account = account,
copyTrading = copyTrading,
clobApi = clobApi,
apiSecret = apiSecret,
apiPassphrase = apiPassphrase
)
logger.info("更新卖出订单价格成功: orderId=${record.sellOrderId}, 原价格=${record.sellPrice}, 新价格=$actualSellPrice")
// 重新从数据库查询最新的记录状态,检查是否已被其他任务标记为已处理
val latestRecord = sellMatchRecordRepository.findById(record.id!!).orElse(null)
if (latestRecord == null) {
logger.warn("卖出记录已被删除,跳过发送通知: orderId=${record.sellOrderId}, recordId=${record.id}")
} else if (latestRecord.priceUpdated) {
logger.debug("卖出记录已被标记为已处理,跳过重复发送通知: orderId=${record.sellOrderId}, recordId=${record.id}")
} else {
// 发送通知(使用实际价格)
sendSellOrderNotification(
record = updatedRecord,
actualPrice = actualSellPrice.toString(),
actualSize = record.totalMatchedQuantity.toString(),
account = account,
copyTrading = copyTrading,
clobApi = clobApi,
apiSecret = apiSecret,
apiPassphrase = apiPassphrase,
orderCreatedAt = record.createdAt
)
logger.info("卖出订单通知已发送: orderId=${record.sellOrderId}")
}
} else {
// 价格相同,但已经查询过,先标记为已处理再发送通知
val updatedRecord = SellMatchRecord(
id = record.id,
copyTradingId = record.copyTradingId,
@@ -353,7 +488,30 @@ class OrderStatusUpdateService(
createdAt = record.createdAt
)
sellMatchRecordRepository.save(updatedRecord)
logger.debug("卖出订单价格无需更新但已发送通知: orderId=${record.sellOrderId}, price=$actualSellPrice")
logger.debug("卖出订单价格无需更新: orderId=${record.sellOrderId}, price=$actualSellPrice")
// 重新从数据库查询最新的记录状态,检查是否已被其他任务标记为已处理
val latestRecord = sellMatchRecordRepository.findById(record.id!!).orElse(null)
if (latestRecord == null) {
logger.warn("卖出记录已被删除,跳过发送通知: orderId=${record.sellOrderId}, recordId=${record.id}")
} else if (latestRecord.priceUpdated) {
logger.debug("卖出记录已被标记为已处理,跳过重复发送通知: orderId=${record.sellOrderId}, recordId=${record.id}")
} else {
// 发送通知
sendSellOrderNotification(
record = updatedRecord,
actualPrice = actualSellPrice.toString(),
actualSize = record.totalMatchedQuantity.toString(),
account = account,
copyTrading = copyTrading,
clobApi = clobApi,
apiSecret = apiSecret,
apiPassphrase = apiPassphrase,
orderCreatedAt = record.createdAt
)
logger.info("卖出订单通知已发送: orderId=${record.sellOrderId}")
}
}
} catch (e: Exception) {
logger.warn("更新卖出订单价格失败: orderId=${record.sellOrderId}, error=${e.message}", e)
@@ -386,7 +544,7 @@ class OrderStatusUpdateService(
// 验证 orderId 格式(必须以 0x 开头的 16 进制)
if (!isValidOrderId(order.buyOrderId)) {
logger.warn("买入订单ID格式无效,直接标记为已发送通知: orderId=${order.buyOrderId}")
// 对于非 0x 开头的订单ID直接标记为已发送,使用临时数据发送通知
// 对于非 0x 开头的订单ID标记为已发送,使用临时数据发送通知
val updatedOrder = CopyOrderTracking(
id = order.id,
copyTradingId = order.copyTradingId,
@@ -407,7 +565,16 @@ class OrderStatusUpdateService(
updatedAt = System.currentTimeMillis()
)
copyOrderTrackingRepository.save(updatedOrder)
sendBuyOrderNotification(updatedOrder, useTemporaryData = true)
// 重新从数据库查询最新的订单状态,检查是否已被其他任务标记为已发送通知
val latestOrder = copyOrderTrackingRepository.findById(order.id!!).orElse(null)
if (latestOrder == null) {
logger.warn("订单已被删除,跳过发送通知: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
} else if (latestOrder.notificationSent) {
logger.debug("订单已被标记为已发送通知,跳过重复发送: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
} else {
sendBuyOrderNotification(updatedOrder, useTemporaryData = true, orderCreatedAt = order.createdAt)
}
continue
}
@@ -456,12 +623,36 @@ class OrderStatusUpdateService(
// 查询订单详情
val orderResponse = clobApi.getOrder(order.buyOrderId)
if (!orderResponse.isSuccessful || orderResponse.body() == null) {
logger.debug("查询订单详情失败,等待下次轮询: orderId=${order.buyOrderId}, code=${orderResponse.code()}")
// 先检查 HTTP 状态码,非 200 的都跳过
if (orderResponse.code() != 200) {
val errorBody = orderResponse.errorBody()?.string()?.take(200) ?: "无错误详情"
logger.debug("查询订单详情失败(HTTP非200),等待下次轮询: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, code=${orderResponse.code()}, errorBody=$errorBody")
continue
}
val orderDetail = orderResponse.body()!!
// HTTP 200,检查响应体
// 响应体也可能返回字符串 "null"Gson 解析时会返回 null
val orderDetail = orderResponse.body()
if (orderDetail == null) {
// HTTP 200 且响应体为 null(或字符串 "null"),表示订单不存在
// 检查订单是否已部分卖出,如果已部分卖出则保留订单用于统计
val hasMatchedDetails = sellMatchDetailRepository.findByTrackingId(order.id!!).isNotEmpty()
if (hasMatchedDetails || order.matchedQuantity > BigDecimal.ZERO) {
logger.debug("订单不存在但已部分卖出,保留订单用于统计: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}")
continue
}
// 订单不存在且未部分卖出,删除本地订单
logger.info("订单不存在(HTTP 200 但响应体为空),删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
try {
copyOrderTrackingRepository.deleteById(order.id!!)
logger.info("已删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
} catch (e: Exception) {
logger.error("删除本地订单失败: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, error=${e.message}", e)
}
continue
}
// 获取实际价格和数量
val actualPrice = orderDetail.price?.toSafeBigDecimal() ?: order.price
@@ -471,7 +662,8 @@ class OrderStatusUpdateService(
// 更新订单数据(如果实际数据与临时数据不同)
val needUpdate = actualPrice != order.price || actualSize != order.quantity
// 创建更新后的订单对象
// 先保存更新后的订单,标记 notificationSent = true
// 这样可以防止其他并发任务重复发送通知
val updatedOrder = CopyOrderTracking(
id = order.id,
copyTradingId = order.copyTradingId,
@@ -492,7 +684,7 @@ class OrderStatusUpdateService(
updatedAt = System.currentTimeMillis()
)
// 保存更新后的订单
// 保存更新后的订单(在发送通知之前保存)
copyOrderTrackingRepository.save(updatedOrder)
if (needUpdate) {
@@ -501,18 +693,27 @@ class OrderStatusUpdateService(
logger.debug("买入订单数据无需更新: orderId=${order.buyOrderId}")
}
// 发送通知(使用实际数据)
sendBuyOrderNotification(
order = updatedOrder,
actualPrice = actualPrice.toString(),
actualSize = actualSize.toString(),
actualOutcome = actualOutcome,
account = account,
copyTrading = copyTrading,
clobApi = clobApi,
apiSecret = apiSecret,
apiPassphrase = apiPassphrase
)
// 重新从数据库查询最新的订单状态,检查是否已被其他任务标记为已发送通知
val latestOrder = copyOrderTrackingRepository.findById(order.id!!).orElse(null)
if (latestOrder == null) {
logger.warn("订单已被删除,跳过发送通知: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
} else if (latestOrder.notificationSent) {
logger.debug("订单已被标记为已发送通知,跳过重复发送: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
} else {
// 发送通知(使用实际数据)
sendBuyOrderNotification(
order = updatedOrder,
actualPrice = actualPrice.toString(),
actualSize = actualSize.toString(),
actualOutcome = actualOutcome,
account = account,
copyTrading = copyTrading,
clobApi = clobApi,
apiSecret = apiSecret,
apiPassphrase = apiPassphrase,
orderCreatedAt = order.createdAt
)
}
} catch (e: Exception) {
logger.warn("更新买入订单失败: orderId=${order.buyOrderId}, error=${e.message}", e)
// 继续处理下一条记录
@@ -536,7 +737,8 @@ class OrderStatusUpdateService(
copyTrading: CopyTrading? = null,
clobApi: PolymarketClobApi? = null,
apiSecret: String? = null,
apiPassphrase: String? = null
apiPassphrase: String? = null,
orderCreatedAt: Long? = null // 订单创建时间(毫秒时间戳)
) {
if (telegramNotificationService == null) {
return
@@ -555,25 +757,10 @@ class OrderStatusUpdateService(
logger.warn("账户不存在,跳过发送通知: accountId=${order.accountId}")
return
}
// 获取市场信息
val marketInfo = withContext(Dispatchers.IO) {
try {
val gammaApi = retrofitFactory.createGammaApi()
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(order.marketId))
if (marketResponse.isSuccessful && marketResponse.body() != null) {
marketResponse.body()!!.firstOrNull()
} else {
null
}
} catch (e: Exception) {
logger.warn("获取市场信息失败: ${e.message}", e)
null
}
}
val marketTitle = marketInfo?.question ?: order.marketId
val marketSlug = marketInfo?.slug
val market = marketService.getMarket(order.marketId)
val marketTitle = market?.title ?: order.marketId
// 获取 Leader 和跟单配置信息
val leader = leaderRepository.findById(order.leaderId).orElse(null)
@@ -604,7 +791,7 @@ class OrderStatusUpdateService(
orderId = order.buyOrderId,
marketTitle = marketTitle,
marketId = order.marketId,
marketSlug = marketSlug,
marketSlug = market?.eventSlug, // 跳转用的 slug
side = "BUY",
price = actualPrice ?: order.price.toString(), // 使用实际价格或临时价格
size = actualSize ?: order.quantity.toString(), // 使用实际数量或临时数量
@@ -618,7 +805,8 @@ class OrderStatusUpdateService(
walletAddressForApi = finalAccount.walletAddress,
locale = locale,
leaderName = leaderName,
configName = configName
configName = configName,
orderTime = orderCreatedAt // 使用订单创建时间
)
logger.info("买入订单通知已发送: orderId=${order.buyOrderId}, copyTradingId=${order.copyTradingId}")
@@ -641,7 +829,8 @@ class OrderStatusUpdateService(
copyTrading: CopyTrading? = null,
clobApi: PolymarketClobApi? = null,
apiSecret: String? = null,
apiPassphrase: String? = null
apiPassphrase: String? = null,
orderCreatedAt: Long? = null // 订单创建时间(毫秒时间戳)
) {
if (telegramNotificationService == null) {
return
@@ -660,25 +849,10 @@ class OrderStatusUpdateService(
logger.warn("账户不存在,跳过发送通知: accountId=${finalCopyTrading.accountId}")
return
}
// 获取市场信息
val marketInfo = withContext(Dispatchers.IO) {
try {
val gammaApi = retrofitFactory.createGammaApi()
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(record.marketId))
if (marketResponse.isSuccessful && marketResponse.body() != null) {
marketResponse.body()!!.firstOrNull()
} else {
null
}
} catch (e: Exception) {
logger.warn("获取市场信息失败: ${e.message}", e)
null
}
}
val marketTitle = marketInfo?.question ?: record.marketId
val marketSlug = marketInfo?.slug
val market = marketService.getMarket(record.marketId)
val marketTitle = market?.title ?: record.marketId
// 获取 Leader 和跟单配置信息
val leader = leaderRepository.findById(finalCopyTrading.leaderId).orElse(null)
@@ -709,7 +883,7 @@ class OrderStatusUpdateService(
orderId = record.sellOrderId,
marketTitle = marketTitle,
marketId = record.marketId,
marketSlug = marketSlug,
marketSlug = market?.eventSlug, // 跳转用的 slug
side = "SELL",
price = actualPrice ?: record.sellPrice.toString(), // 使用实际价格或临时价格
size = actualSize ?: record.totalMatchedQuantity.toString(), // 使用实际数量或临时数量
@@ -723,7 +897,8 @@ class OrderStatusUpdateService(
walletAddressForApi = finalAccount.walletAddress,
locale = locale,
leaderName = leaderName,
configName = configName
configName = configName,
orderTime = orderCreatedAt // 使用订单创建时间
)
logger.info("卖出订单通知已发送: orderId=${record.sellOrderId}, copyTradingId=${record.copyTradingId}")
@@ -62,7 +62,8 @@ class CopyTradingTemplateService(
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal(),
maxSpread = request.maxSpread?.toSafeBigDecimal(),
minPrice = request.minPrice?.toSafeBigDecimal(),
maxPrice = request.maxPrice?.toSafeBigDecimal()
maxPrice = request.maxPrice?.toSafeBigDecimal(),
pushFilteredOrders = request.pushFilteredOrders ?: false
)
val saved = templateRepository.save(template)
@@ -121,6 +122,7 @@ class CopyTradingTemplateService(
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: template.maxSpread,
minPrice = request.minPrice?.toSafeBigDecimal() ?: template.minPrice,
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice,
pushFilteredOrders = request.pushFilteredOrders ?: template.pushFilteredOrders,
updatedAt = System.currentTimeMillis()
)
@@ -186,7 +188,8 @@ class CopyTradingTemplateService(
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: sourceTemplate.minOrderDepth,
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: sourceTemplate.maxSpread,
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)
@@ -260,6 +263,7 @@ class CopyTradingTemplateService(
maxSpread = template.maxSpread?.toPlainString(),
minPrice = template.minPrice?.toPlainString(),
maxPrice = template.maxPrice?.toPlainString(),
pushFilteredOrders = template.pushFilteredOrders,
createdAt = template.createdAt,
updatedAt = template.updatedAt
)
@@ -333,15 +333,10 @@ class RelayClientService(
// 打包签名(参考 builder-relayer-client/src/utils/index.ts 的 splitAndPackSig
val packedSignature = splitAndPackSig(safeSignature)
// 调试日志
// 调试日志(地址已遮蔽)
logger.debug("=== Builder Relayer 签名调试 ===")
logger.debug("Safe Address: $proxyAddress")
logger.debug("From Address: $fromAddress")
logger.debug("To: ${safeTx.to}")
logger.debug("Data: $redeemCallData")
logger.debug("Nonce: $proxyNonce")
logger.debug("Packed Signature: $packedSignature")
logger.debug("Signature Length: ${packedSignature.length} (expected: 132 with 0x)")
logger.debug("Safe: ${proxyAddress.take(10)}..., From: ${fromAddress.take(10)}..., Nonce: $proxyNonce")
logger.debug("Signature Length: ${packedSignature.length}")
// 构建 TransactionRequest(参考 builder-relayer-client/src/builder/safe.ts
// 注意:根据 TypeScript 实现,data 和 signature 都应该带 0x 前缀
@@ -364,13 +359,7 @@ class RelayClientService(
metadata = "Redeem positions via Builder Relayer"
)
logger.debug("Request Type: ${request.type}")
logger.debug("Request From: ${request.from}")
logger.debug("Request To: ${request.to}")
logger.debug("Request ProxyWallet: ${request.proxyWallet}")
logger.debug("Request Data Length: ${request.data.length}")
logger.debug("Request Signature Length: ${request.signature.length}")
logger.debug("Request Nonce: ${request.nonce}")
logger.debug("Request: type=${request.type}, dataLen=${request.data.length}, sigLen=${request.signature.length}, nonce=${request.nonce}")
// 调用 Builder Relayer API(认证头通过拦截器添加)
val response = relayerApi.submitTransaction(request)
@@ -25,6 +25,19 @@ class SystemConfigService(
const val CONFIG_KEY_BUILDER_SECRET = "builder.secret"
const val CONFIG_KEY_BUILDER_PASSPHRASE = "builder.passphrase"
const val CONFIG_KEY_AUTO_REDEEM = "auto_redeem"
/**
* 遮蔽敏感信息仅显示前4位和后4位
* 例如abcd1234...wxyz5678
*/
fun maskSensitiveValue(value: String?): String? {
if (value == null) return null
return when {
value.length <= 8 -> "****" // 太短则完全遮蔽
value.length <= 16 -> "${value.take(2)}...${value.takeLast(2)}"
else -> "${value.take(4)}...${value.takeLast(4)}"
}
}
}
/**
@@ -36,26 +49,26 @@ class SystemConfigService(
val builderPassphrase = getConfigValue(CONFIG_KEY_BUILDER_PASSPHRASE)
val autoRedeem = isAutoRedeemEnabled()
// 获取完整的 API Key(用于前端展示
val builderApiKeyDisplay = builderApiKey?.let {
// 获取遮蔽后的显示值(仅显示部分字符,用于前端确认配置
val builderApiKeyDisplay = builderApiKey?.let {
try {
cryptoUtils.decrypt(it)
maskSensitiveValue(cryptoUtils.decrypt(it))
} catch (e: Exception) {
null
}
}
val builderSecretDisplay = builderSecret?.let {
try {
cryptoUtils.decrypt(it)
maskSensitiveValue(cryptoUtils.decrypt(it))
} catch (e: Exception) {
null
}
}
val builderPassphraseDisplay = builderPassphrase?.let {
try {
cryptoUtils.decrypt(it)
maskSensitiveValue(cryptoUtils.decrypt(it))
} catch (e: Exception) {
null
}
@@ -59,6 +59,26 @@ class TelegramNotificationService(
* @param walletAddressForApi 钱包地址可选用于查询订单详情
* @param locale 语言设置可选如果提供则使用否则使用 LocaleContextHolder 获取
*/
// 已发送通知的订单ID缓存(key: orderId, value: timestamp
private val sentOrderIds = java.util.concurrent.ConcurrentHashMap<String, Long>()
/**
* 发送订单成功通知
* @param orderId 订单ID用于查询订单详情获取实际价格和数量
* @param marketTitle 市场标题
* @param marketId 市场IDconditionId用于生成链接
* @param marketSlug 市场slug用于生成链接
* @param side 订单方向BUY/SELL用于多语言显示
* @param accountName 账户名称
* @param walletAddress 钱包地址
* @param clobApi CLOB API 客户端可选如果提供则查询订单详情获取实际价格和数量
* @param apiKey API Key可选用于查询订单详情
* @param apiSecret API Secret可选用于查询订单详情
* @param apiPassphrase API Passphrase可选用于查询订单详情
* @param walletAddressForApi 钱包地址可选用于查询订单详情
* @param locale 语言设置可选如果提供则使用否则使用 LocaleContextHolder 获取
* @param orderTime 订单时间可选如果提供则使用订单创建时间否则使用当前通知时间
*/
suspend fun sendOrderSuccessNotification(
orderId: String?,
marketTitle: String,
@@ -77,8 +97,29 @@ class TelegramNotificationService(
walletAddressForApi: String? = null,
locale: java.util.Locale? = null,
leaderName: String? = null, // Leader 名称(备注)
configName: String? = null // 跟单配置名
configName: String? = null, // 跟单配置名
orderTime: Long? = null // 订单创建时间(毫秒时间戳),用于通知中的时间显示
) {
// 1. 如果提供了 orderId,检查是否已发送过通知(去重)
if (orderId != null) {
val lastSentTime = sentOrderIds[orderId]
if (lastSentTime != null) {
// 如果5分钟内已发送过,跳过
if (System.currentTimeMillis() - lastSentTime < 5 * 60 * 1000) {
logger.info("订单通知已发送过(5分钟内),跳过: orderId=$orderId")
return
}
}
// 记录发送时间
sentOrderIds[orderId] = System.currentTimeMillis()
// 简单的清理逻辑:如果缓存过大,清理过期的
if (sentOrderIds.size > 1000) {
val expiryTime = System.currentTimeMillis() - 5 * 60 * 1000
sentOrderIds.entries.removeIf { it.value < expiryTime }
}
}
// 获取语言设置(优先使用传入的 locale,否则从 LocaleContextHolder 获取)
val currentLocale = locale ?: try {
LocaleContextHolder.getLocale()
@@ -97,21 +138,28 @@ class TelegramNotificationService(
if ((actualPrice == null || actualSize == null) && orderId != null && clobApi != null && apiKey != null && apiSecret != null && apiPassphrase != null && walletAddressForApi != null) {
try {
val orderResponse = clobApi.getOrder(orderId)
if (orderResponse.isSuccessful && orderResponse.body() != null) {
val order = orderResponse.body()!!
if (actualPrice == null) {
actualPrice = order.price
}
if (actualSize == null) {
actualSize = order.originalSize // 使用 originalSize 作为订单数量
}
actualSide = order.side // 使用订单详情中的 side
if (actualOutcome == null) {
actualOutcome = order.outcome // 使用订单详情中的 outcome(市场方向)
if (orderResponse.isSuccessful) {
val order = orderResponse.body()
if (order != null) {
if (actualPrice == null) {
actualPrice = order.price
}
if (actualSize == null) {
actualSize = order.originalSize // 使用 originalSize 作为订单数量
}
actualSide = order.side // 使用订单详情中的 side
if (actualOutcome == null) {
actualOutcome = order.outcome // 使用订单详情中的 outcome(市场方向)
}
} else {
logger.debug("查询订单详情失败: 响应体为空, orderId=$orderId")
}
} else {
val errorBody = orderResponse.errorBody()?.string()?.take(200) ?: "无错误详情"
logger.debug("查询订单详情失败: orderId=$orderId, code=${orderResponse.code()}, errorBody=$errorBody")
}
} catch (e: Exception) {
logger.warn("查询订单详情失败: ${e.message}", e)
logger.warn("查询订单详情失败: orderId=$orderId, ${e.message}", e)
}
}
@@ -143,7 +191,8 @@ class TelegramNotificationService(
walletAddress = walletAddress,
locale = currentLocale,
leaderName = leaderName,
configName = configName
configName = configName,
orderTime = orderTime
)
sendMessage(message)
}
@@ -653,7 +702,8 @@ class TelegramNotificationService(
walletAddress: String?,
locale: java.util.Locale,
leaderName: String? = null, // Leader 名称(备注)
configName: String? = null // 跟单配置名
configName: String? = null, // 跟单配置名
orderTime: Long? = null // 订单创建时间(毫秒时间戳)
): String {
// 获取多语言文本
@@ -679,6 +729,13 @@ class TelegramNotificationService(
else -> side
}
// 获取图标
val icon = when (side.uppercase()) {
"BUY" -> "🚀"
"SELL" -> "💰"
else -> "📣"
}
// 构建账户信息(格式:账户名(钱包地址))
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
@@ -696,7 +753,12 @@ class TelegramNotificationService(
""
}
val time = DateUtils.formatDateTime()
// 使用订单时间(如果提供),否则使用当前通知时间
val time = if (orderTime != null) {
DateUtils.formatDateTime(orderTime)
} else {
DateUtils.formatDateTime()
}
// 转义 HTML 特殊字符
val escapedMarketTitle = marketTitle.replace("<", "&lt;").replace(">", "&gt;")
@@ -754,7 +816,7 @@ class TelegramNotificationService(
val priceDisplay = formatPrice(price)
val sizeDisplay = formatQuantity(size)
return """ <b>$orderCreatedSuccess</b>
return """$icon <b>$orderCreatedSuccess</b>
📊 <b>$orderInfo</b>
$orderIdLabel: <code>${orderId ?: unknown}</code>
@@ -982,7 +1044,7 @@ class TelegramNotificationService(
"${position.marketId.substring(0, 8)}... (${position.side}): $quantityDisplay shares = $valueDisplay USDC"
}
return """ <b>$redeemSuccess</b>
return """💸 <b>$redeemSuccess</b>
📊 <b>$redeemInfo</b>
$accountLabel: $escapedAccountInfo
@@ -19,7 +19,7 @@ object EthereumUtils {
* @return 函数选择器例如 "0x12345678"
*/
fun getFunctionSelector(functionSignature: String): String {
val hash = keccak256(functionSignature.toByteArray())
val hash = keccak256Hex(functionSignature.toByteArray())
return "0x" + hash.substring(0, 8)
}
@@ -138,16 +138,48 @@ object EthereumUtils {
return Pair(payoutDenominator, payouts)
}
/**
* 将十六进制字符串转换为字节数组
* @param hex 十六进制字符串带或不带 0x 前缀
* @return 字节数组
*/
fun hexToBytes(hex: String): ByteArray {
val cleanHex = hex.removePrefix("0x")
return ByteArray(cleanHex.length / 2) { i ->
cleanHex.substring(i * 2, i * 2 + 2).toInt(16).toByte()
}
}
/**
* 将字节数组转换为十六进制字符串
* @param bytes 字节数组
* @return 十六进制字符串 0x 前缀
*/
fun bytesToHex(bytes: ByteArray): String {
return "0x" + bytes.joinToString("") { "%02x".format(it) }
}
/**
* 计算 Keccak-256 哈希Ethereum 标准
* 使用 BouncyCastle 库实现真正的 Keccak-256
* @param data 输入数据
* @return 32 字节的哈希值
*/
private fun keccak256(data: ByteArray): String {
fun keccak256(data: ByteArray): ByteArray {
val digest = KeccakDigest(256)
digest.update(data, 0, data.size)
val hash = ByteArray(digest.digestSize)
digest.doFinal(hash, 0)
return hash.joinToString("") { "%02x".format(it) }
return hash
}
/**
* 计算 Keccak-256 哈希并返回十六进制字符串
* @param data 输入数据
* @return 十六进制哈希字符串
*/
fun keccak256Hex(data: ByteArray): String {
return keccak256(data).joinToString("") { "%02x".format(it) }
}
}
@@ -1,32 +1,154 @@
package com.wrbug.polymarketbot.util
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.JsonElement
import com.google.gson.reflect.TypeToken
import jakarta.annotation.PostConstruct
import org.springframework.stereotype.Component
/**
* JSON 工具类
* 用于解析 JSON 字符串
* 用于初始化全局 Gson 实例供扩展函数使用
*
* 使用 Spring 注入的 Gson Bean已在 GsonConfig 中配置为 lenient 模式
* 初始化后设置全局 gson 实例供扩展函数使用
*/
object JsonUtils {
private val gson = Gson()
@Component
class JsonUtils(
private val injectedGson: Gson
) {
@PostConstruct
fun init() {
// 设置全局 Gson 实例,供扩展函数使用
_gson = injectedGson
}
/**
* 解析 JSON 字符串数组
* @param jsonString JSON 字符串 "[\"Yes\", \"No\"]"
* @return 字符串列表如果解析失败返回空列表
*/
fun parseStringArray(jsonString: String?): List<String> {
if (jsonString.isNullOrBlank()) {
return emptyList()
}
return try {
val listType = object : TypeToken<List<String>>() {}.type
gson.fromJson<List<String>>(jsonString, listType) ?: emptyList()
return jsonString?.parseStringArray() ?: emptyList()
}
}
/**
* 全局 Gson 实例用于扩展函数
* JsonUtils 初始化时设置
*/
@Volatile
private var _gson: Gson? = null
/**
* 获取全局 Gson 实例
* 如果尚未初始化创建默认实例
*/
val gson: Gson
get() = _gson ?: GsonBuilder().setLenient().create().also { _gson = it }
// ============================================================================
// 扩展函数:JSON 序列化和反序列化
// ============================================================================
/**
* 将对象转换为 JSON 字符串
*
* @return JSON 字符串如果对象为 null 则返回空字符串
*
* @example
* ```kotlin
* val obj = MyDataClass(name = "test", value = 123)
* val json = obj.toJson() // {"name":"test","value":123}
* ```
*/
fun Any?.toJson(): String {
return if (this == null) {
""
} else {
try {
gson.toJson(this)
} catch (e: Exception) {
emptyList()
""
}
}
}
/**
* JSON 字符串解析为对象支持泛型
*
* @return 解析后的对象如果解析失败返回 null
*
* @example
* ```kotlin
* val json = "{\"name\":\"test\",\"value\":123}"
* val obj = json.fromJson<MyDataClass>() // MyDataClass(name="test", value=123)
*
* // 对于泛型类型,自动使用 TypeToken
* val json = "[\"a\", \"b\", \"c\"]"
* val list = json.fromJson<List<String>>() // ["a", "b", "c"]
* ```
*/
inline fun <reified T> String?.fromJson(): T? {
if (this.isNullOrBlank()) {
return null
}
return try {
val typeToken = object : TypeToken<T>() {}
gson.fromJson(this, typeToken.type)
} catch (e: Exception) {
null
}
}
/**
* JsonElement 解析为对象支持泛型
*
* @return 解析后的对象如果解析失败返回 null
*
* @example
* ```kotlin
* val jsonElement: JsonElement = ...
* val obj = jsonElement.fromJson<MyDataClass>()
* ```
*/
inline fun <reified T> JsonElement?.fromJson(): T? {
if (this == null) {
return null
}
return try {
val typeToken = object : TypeToken<T>() {}
gson.fromJson(this, typeToken.type)
} catch (e: Exception) {
null
}
}
/**
* 解析 JSON 字符串数组
*
* @return 字符串列表如果解析失败返回空列表
*
* @example
* ```kotlin
* val json = "[\"Yes\", \"No\"]"
* val list = json.parseStringArray() // ["Yes", "No"]
* ```
*/
fun String?.parseStringArray(): List<String> {
if (this.isNullOrBlank()) {
return emptyList()
}
return try {
val listType = object : TypeToken<List<String>>() {}.type
gson.fromJson<List<String>>(this, listType) ?: emptyList()
} catch (e: Exception) {
emptyList()
}
}
@@ -0,0 +1,20 @@
package com.wrbug.polymarketbot.util
import com.wrbug.polymarketbot.api.MarketResponse
/**
* MarketResponse 扩展函数
* events[0].slug 获取 slug用于网页跳转
*/
fun MarketResponse?.getEventSlug(): String? {
return this?.events?.firstOrNull()?.slug ?: this?.slug
}
/**
* MarketResponse 扩展函数
* 获取显示用的 slug使用原来的 slug 字段
*/
fun MarketResponse?.getDisplaySlug(): String? {
return this?.slug
}
@@ -26,21 +26,23 @@ fun BigDecimal.multi(value: Any): BigDecimal {
return BigDecimal.ZERO
}
/**
* BigDecimal除法扩展函数
* BigDecimal除法扩展函数带精度和舍入模式
* 安全地将BigDecimal与任意数值类型相除
* @param value 除数支持BigDecimalBigInteger类型或可转换为BigDecimal的字符串
* @return 除法结果精度为18位小数使用四舍五入模式如果转换失败返回IllegalBigDecimal
* @param scale 精度小数位数
* @param roundingMode 舍入模式
* @return 除法结果如果转换失败返回IllegalBigDecimal
*/
fun BigDecimal.div(value: Any): BigDecimal {
fun BigDecimal.div(value: Any, scale: Int = 18, roundingMode: RoundingMode = RoundingMode.HALF_UP): BigDecimal {
kotlin.runCatching {
if (value is BigDecimal) {
return divide(value, 18, RoundingMode.HALF_UP).stripTrailingZeros()
val divisor = when (value) {
is BigDecimal -> value
is BigInteger -> value.toBigDecimal()
else -> BigDecimal(value.toString())
}
if (value is BigInteger) {
return divide(value.toSafeBigDecimal(), 18, RoundingMode.HALF_UP).stripTrailingZeros()
}
return divide(BigDecimal(value.toString()), 18, RoundingMode.HALF_UP).stripTrailingZeros()
return divide(divisor, scale, roundingMode)
}
return IllegalBigDecimal
}
@@ -1,7 +1,6 @@
package com.wrbug.polymarketbot.util
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.wrbug.polymarketbot.api.BuilderRelayerApi
import com.wrbug.polymarketbot.api.EthereumRpcApi
import com.wrbug.polymarketbot.api.GitHubApi
@@ -24,21 +23,116 @@ import org.springframework.stereotype.Component
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
import jakarta.annotation.PreDestroy
/**
* Retrofit 客户端工厂
* 用于创建带认证的 Polymarket CLOB API 客户端和 Ethereum RPC API 客户端
*
* 注意为了避免内存泄漏本类会缓存和复用客户端实例
*/
@Component
class RetrofitFactory(
@Value("\${polymarket.clob.base-url}")
private val clobBaseUrl: String,
@Value("\${polymarket.gamma.base-url}")
private val gammaBaseUrl: String
private val gammaBaseUrl: String,
private val gson: Gson
) {
private val logger = LoggerFactory.getLogger(RetrofitFactory::class.java)
// 共享的 OkHttpClient(用于不需要认证的 API)
private val sharedOkHttpClient: OkHttpClient by lazy {
createClient().build()
}
// 共享的 OkHttpClient(用于需要跟随重定向的 API)
private val sharedOkHttpClientWithRedirect: OkHttpClient by lazy {
createClient()
.followRedirects(true)
.followSslRedirects(true)
.build()
}
// 缓存 Gamma API 客户端(单例)
private val gammaApi: PolymarketGammaApi by lazy {
val baseUrl = if (gammaBaseUrl.endsWith("/")) {
gammaBaseUrl.dropLast(1)
} else {
gammaBaseUrl
}
Retrofit.Builder()
.baseUrl("$baseUrl/")
.client(sharedOkHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(PolymarketGammaApi::class.java)
}
// 缓存 Data API 客户端(单例)
private val dataApi: PolymarketDataApi by lazy {
val baseUrl = "https://data-api.polymarket.com"
Retrofit.Builder()
.baseUrl("$baseUrl/")
.client(sharedOkHttpClientWithRedirect)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(PolymarketDataApi::class.java)
}
// 缓存 GitHub API 客户端(单例)
private val githubApi: GitHubApi by lazy {
val baseUrl = "https://api.github.com"
// 添加拦截器,设置 Accept 头以获取 reactions 数据
val githubInterceptor = object : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().newBuilder()
.header("Accept", "application/vnd.github+json")
.build()
return chain.proceed(request)
}
}
val okHttpClient = createClient()
.addInterceptor(githubInterceptor)
.build()
Retrofit.Builder()
.baseUrl("$baseUrl/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(GitHubApi::class.java)
}
// 缓存不带认证的 CLOB API 客户端(单例)
private val clobApiWithoutAuth: PolymarketClobApi by lazy {
Retrofit.Builder()
.baseUrl(clobBaseUrl)
.client(sharedOkHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(PolymarketClobApi::class.java)
}
// 缓存带认证的 CLOB API 客户端:walletAddress -> PolymarketClobApi
// 注意:每个账户使用不同的 API Key,需要不同的客户端
private val clobApiCache = ConcurrentHashMap<String, PolymarketClobApi>()
// 缓存 RPC API 客户端:rpcUrl -> EthereumRpcApi
private val rpcApiCache = ConcurrentHashMap<String, EthereumRpcApi>()
// 缓存 Builder Relayer API 客户端:relayerUrl -> BuilderRelayerApi
private val builderRelayerApiCache = ConcurrentHashMap<String, BuilderRelayerApi>()
/**
* 创建带认证的 Polymarket CLOB API 客户端
* 按钱包地址缓存避免重复创建
* @param apiKey API Key
* @param apiSecret API Secret
* @param apiPassphrase API Passphrase
@@ -51,67 +145,46 @@ class RetrofitFactory(
apiPassphrase: String,
walletAddress: String
): PolymarketClobApi {
val authInterceptor = PolymarketAuthInterceptor(apiKey, apiSecret, apiPassphrase, walletAddress)
// 添加响应日志拦截器,用于调试 JSON 解析错误
val responseLoggingInterceptor = ResponseLoggingInterceptor()
val okHttpClient = createClient()
.addInterceptor(authInterceptor)
.addInterceptor(responseLoggingInterceptor)
.build()
// 创建 lenient 模式的 Gson,允许解析格式不严格的 JSON
val gson = GsonBuilder()
.setLenient()
.create()
return Retrofit.Builder()
.baseUrl(clobBaseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(PolymarketClobApi::class.java)
// 使用钱包地址作为缓存键(每个账户使用不同的 API Key)
return clobApiCache.computeIfAbsent(walletAddress) {
val authInterceptor = PolymarketAuthInterceptor(apiKey, apiSecret, apiPassphrase, walletAddress)
// 添加响应日志拦截器,用于调试 JSON 解析错误
val responseLoggingInterceptor = ResponseLoggingInterceptor()
val okHttpClient = createClient()
.addInterceptor(authInterceptor)
.addInterceptor(responseLoggingInterceptor)
.build()
Retrofit.Builder()
.baseUrl(clobBaseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(PolymarketClobApi::class.java)
}
}
/**
* 创建不带认证的 Polymarket CLOB API 客户端
* 用于不需要认证的查询接口
* @return PolymarketClobApi 客户端
* @return PolymarketClobApi 客户端单例
*/
fun createClobApiWithoutAuth(): PolymarketClobApi {
// 添加响应日志拦截器,用于调试 JSON 解析错误
val responseLoggingInterceptor = ResponseLoggingInterceptor()
val okHttpClient = createClient()
.addInterceptor(responseLoggingInterceptor)
.build()
// 创建 lenient 模式的 Gson,允许解析格式不严格的 JSON
val gson = GsonBuilder()
.setLenient()
.create()
return Retrofit.Builder()
.baseUrl(clobBaseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(PolymarketClobApi::class.java)
return clobApiWithoutAuth
}
/**
* 创建 Ethereum RPC API 客户端
* 使用固定的 baseUrl通过拦截器动态替换为实际的 RPC URL
* 如果 RPC 不可用将抛出异常
* RPC URL 缓存避免重复创建
* @param rpcUrl RPC 节点 URL
* @return EthereumRpcApi 客户端
* @throws IllegalArgumentException 如果 RPC URL 无效或不可用
*/
fun createEthereumRpcApi(rpcUrl: String): EthereumRpcApi {
// 使用固定的 baseUrlRetrofit 要求 baseUrl 必须以 / 结尾)
val fixedBaseUrl = "https://polyrpc.polyhermes/"
// 确保实际的 RPC URL 以 / 结尾
val actualRpcUrl = if (rpcUrl.endsWith("/")) {
rpcUrl
@@ -119,27 +192,28 @@ class RetrofitFactory(
"$rpcUrl/"
}
// 验证 RPC 是否可用
validateRpcAvailability(actualRpcUrl)
// 创建 URL 替换拦截器
val urlReplaceInterceptor = RpcUrlReplaceInterceptor(fixedBaseUrl, actualRpcUrl)
val okHttpClient = createClient()
.addInterceptor(urlReplaceInterceptor)
.build()
// 创建 lenient 模式的 Gson
val gson = GsonBuilder()
.setLenient()
.create()
return Retrofit.Builder()
.baseUrl(fixedBaseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(EthereumRpcApi::class.java)
// 使用 RPC URL 作为缓存键
return rpcApiCache.computeIfAbsent(actualRpcUrl) {
// 验证 RPC 是否可用(仅在新创建时验证)
validateRpcAvailability(actualRpcUrl)
// 使用固定的 baseUrlRetrofit 要求 baseUrl 必须以 / 结尾)
val fixedBaseUrl = "https://polyrpc.polyhermes/"
// 创建 URL 替换拦截器
val urlReplaceInterceptor = RpcUrlReplaceInterceptor(fixedBaseUrl, actualRpcUrl)
val okHttpClient = createClient()
.addInterceptor(urlReplaceInterceptor)
.build()
Retrofit.Builder()
.baseUrl(fixedBaseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(EthereumRpcApi::class.java)
}
}
/**
@@ -149,8 +223,6 @@ class RetrofitFactory(
* @throws IllegalArgumentException 如果 RPC 不可用
*/
private fun validateRpcAvailability(rpcUrl: String) {
val logger = LoggerFactory.getLogger(RetrofitFactory::class.java)
try {
// 解析 URL
val httpUrl = rpcUrl.toHttpUrlOrNull()
@@ -218,56 +290,24 @@ class RetrofitFactory(
/**
* 创建 Polymarket Gamma API 客户端
* Gamma API 是公开 API不需要认证
* @return PolymarketGammaApi 客户端
* @return PolymarketGammaApi 客户端单例
*/
fun createGammaApi(): PolymarketGammaApi {
val baseUrl = if (gammaBaseUrl.endsWith("/")) {
gammaBaseUrl.dropLast(1)
} else {
gammaBaseUrl
}
val okHttpClient = createClient().build()
// 创建 lenient 模式的 Gson
val gson = GsonBuilder()
.setLenient()
.create()
return Retrofit.Builder()
.baseUrl("$baseUrl/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(PolymarketGammaApi::class.java)
return gammaApi
}
/**
* 创建 Polymarket Data API 客户端
* Data API 是公开 API不需要认证
* @return PolymarketDataApi 客户端
* @return PolymarketDataApi 客户端单例
*/
fun createDataApi(): PolymarketDataApi {
val baseUrl = "https://data-api.polymarket.com"
val okHttpClient = createClient()
.followRedirects(true)
.followSslRedirects(true)
.build()
// 创建 lenient 模式的 Gson
val gson = GsonBuilder()
.setLenient()
.create()
return Retrofit.Builder()
.baseUrl("$baseUrl/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(PolymarketDataApi::class.java)
return dataApi
}
/**
* 创建 Builder Relayer API 客户端
* relayerUrl 缓存避免重复创建
* @param relayerUrl Builder Relayer URL
* @param apiKey Builder API Key
* @param secret Builder Secret
@@ -286,57 +326,61 @@ class RetrofitFactory(
relayerUrl
}
// 添加 Builder 认证拦截器
val builderAuthInterceptor = BuilderAuthInterceptor(apiKey, secret, passphrase)
val okHttpClient = createClient()
.addInterceptor(builderAuthInterceptor)
.build()
val gson = GsonBuilder()
.setLenient()
.create()
return Retrofit.Builder()
.baseUrl("$baseUrl/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(BuilderRelayerApi::class.java)
// 使用 baseUrl 作为缓存键(注意:如果 API Key 变化,需要清理缓存)
return builderRelayerApiCache.computeIfAbsent(baseUrl) {
// 添加 Builder 认证拦截器
val builderAuthInterceptor = BuilderAuthInterceptor(apiKey, secret, passphrase)
val okHttpClient = createClient()
.addInterceptor(builderAuthInterceptor)
.build()
Retrofit.Builder()
.baseUrl("$baseUrl/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(BuilderRelayerApi::class.java)
}
}
/**
* 创建 GitHub API 客户端
* GitHub API 是公开 API不需要认证但建议使用 token 提高速率限制
* 添加 Accept 头以获取 reactions 数据
* @return GitHubApi 客户端
* @return GitHubApi 客户端单例
*/
fun createGitHubApi(): GitHubApi {
val baseUrl = "https://api.github.com"
// 添加拦截器,设置 Accept 头以获取 reactions 数据
val githubInterceptor = object : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().newBuilder()
.header("Accept", "application/vnd.github+json")
.build()
return chain.proceed(request)
}
}
val okHttpClient = createClient()
.addInterceptor(githubInterceptor)
.build()
val gson = GsonBuilder()
.setLenient()
.create()
return Retrofit.Builder()
.baseUrl("$baseUrl/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(GitHubApi::class.java)
return githubApi
}
/**
* 清理缓存用于测试或配置变更时
*/
@PreDestroy
fun destroy() {
logger.info("清理 RetrofitFactory 缓存")
clobApiCache.clear()
rpcApiCache.clear()
builderRelayerApiCache.clear()
}
/**
* 清理指定钱包地址的 CLOB API 缓存
* 用于 API Key 变更时
*/
fun clearClobApiCache(walletAddress: String) {
clobApiCache.remove(walletAddress)
logger.debug("已清理 CLOB API 缓存: $walletAddress")
}
/**
* 清理指定 RPC URL RPC API 缓存
* 用于 RPC 节点变更时
*/
fun clearRpcApiCache(rpcUrl: String) {
val actualRpcUrl = if (rpcUrl.endsWith("/")) rpcUrl else "$rpcUrl/"
rpcApiCache.remove(actualRpcUrl)
logger.debug("已清理 RPC API 缓存: $actualRpcUrl")
}
}
@@ -393,22 +437,35 @@ class ResponseLoggingInterceptor : Interceptor {
val responseBody = response.peekBody(2048)
val responseBodyString = responseBody.string()
// 检查是否是有效的 JSON
val isJson = responseBodyString.trim().startsWith("{") ||
responseBodyString.trim().startsWith("[")
// 检查响应体是否为空
val isEmpty = responseBodyString.isBlank()
if (!isJson || !response.isSuccessful) {
// 检查是否是有效的 JSON
val trimmedBody = responseBodyString.trim()
val isJson = !isEmpty && (
trimmedBody.startsWith("{") ||
trimmedBody.startsWith("[")
)
// 如果响应体为空或不是 JSON,记录警告
if (isEmpty || !isJson) {
val bodyPreview = if (isEmpty) {
"(空响应体)"
} else {
trimmedBody.take(500)
}
logger.warn(
"API 响应异常: method=${request.method}, url=${request.url}, " +
"code=${response.code}, isJson=$isJson, " +
"responseBody=${responseBodyString.take(500)}"
"code=${response.code}, isJson=$isJson, isEmpty=$isEmpty, " +
"responseBody=$bodyPreview"
)
}
} catch (e: Exception) {
// 如果读取响应体失败,记录异常但不影响响应
logger.debug("读取响应体失败: ${e.message}")
}
}
return response
}
}
@@ -139,7 +139,8 @@ class PolymarketWebSocketClient(
/**
* 启动 PING 保活机制
* 根据官方文档 10 秒发送一次 "PING"
* 根据 @polymarket/real-time-data-client发送小写 "ping"
* 10 秒发送一次
*/
private fun startPing() {
stopPing() // 先停止之前的 PING 任务
@@ -149,7 +150,7 @@ class PolymarketWebSocketClient(
delay(10000) // 10 秒
if (isConnected) {
try {
sendMessage("PING")
sendMessage("ping") // 使用小写,与官方客户端保持一致
} catch (e: Exception) {
logger.warn("发送 PING 失败: $sessionId, ${e.message}")
break
@@ -29,14 +29,16 @@ server.port=${SERVER_PORT:8000}
# 默认使用 dev 环境,可通过 --spring.profiles.active=prod 切换
spring.profiles.active=${SPRING_PROFILES_ACTIVE:dev}
# 日志配置
logging.level.root=INFO
logging.level.com.wrbug.polyhermes=DEBUG
# 日志配置(支持通过环境变量配置)
logging.level.root=${LOG_LEVEL_ROOT:WARN}
logging.level.com.wrbug.polymarketbot=${LOG_LEVEL_APP:INFO}
logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} - %msg%n
# Polymarket API 配置
polymarket.clob.base-url=https://clob.polymarket.com
polymarket.rtds.ws-url=wss://ws-subscriptions-clob.polymarket.com
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
@@ -52,6 +54,10 @@ copy.trading.polling.interval=${COPY_TRADING_POLLING_INTERVAL:2000}
# 是否启用轮询,默认true
copy.trading.polling.enabled=${COPY_TRADING_POLLING_ENABLED:true}
# 跟单监听配置
# 链上 WebSocket 重连延迟(毫秒),默认3秒
copy.trading.onchain.ws.reconnect.delay=${COPY_TRADING_ONCHAIN_WS_RECONNECT_DELAY:3000}
# WebSocket 配置
websocket.heartbeat-timeout=${WEBSOCKET_HEARTBEAT_TIMEOUT:60000}
@@ -0,0 +1,8 @@
-- ============================================
-- V16: 删除失败交易记录表
-- 移除下单失败存储到数据库的功能
-- ============================================
-- 删除失败交易记录表
DROP TABLE IF EXISTS failed_trade;
@@ -0,0 +1,35 @@
-- ============================================
-- 添加 wallet_type 字段到 wallet_accounts 表
-- 用于区分 Magic 和 Safe 两种钱包类型
-- ============================================
-- 使用存储过程检查并添加字段(如果不存在)
DELIMITER $$
CREATE PROCEDURE IF NOT EXISTS add_wallet_type_if_not_exists()
BEGIN
DECLARE column_exists INT DEFAULT 0;
SELECT COUNT(*) INTO column_exists
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'wallet_accounts'
AND COLUMN_NAME = 'wallet_type';
IF column_exists = 0 THEN
ALTER TABLE wallet_accounts
ADD COLUMN wallet_type VARCHAR(20) NOT NULL DEFAULT 'magic' COMMENT '钱包类型:magic(邮箱/OAuth登录)或 safeMetaMask浏览器钱包)' AFTER is_enabled;
END IF;
END$$
DELIMITER ;
-- 执行存储过程
CALL add_wallet_type_if_not_exists();
-- 删除存储过程
DROP PROCEDURE IF EXISTS add_wallet_type_if_not_exists;
-- 为现有账户设置默认walletType为magic(如果值为NULL
UPDATE wallet_accounts SET wallet_type = 'magic' WHERE wallet_type IS NULL OR wallet_type = '';
@@ -0,0 +1,14 @@
-- ============================================
-- V18: 增加 copy_ratio 字段的精度,支持更小的小数值
-- ============================================
-- 修改 copy_trading 表的 copy_ratio 字段精度
-- 从 DECIMAL(10, 2) 改为 DECIMAL(20, 8),支持更小的跟单比例值(如 0.001)
ALTER TABLE copy_trading
MODIFY COLUMN copy_ratio DECIMAL(20, 8) NOT NULL DEFAULT 1.00000000 COMMENT '跟单比例(仅在copyMode=RATIO时生效)';
-- 修改 copy_trading_templates 表的 copy_ratio 字段精度
-- 从 DECIMAL(10, 2) 改为 DECIMAL(20, 8),支持更小的跟单比例值(如 0.001)
ALTER TABLE copy_trading_templates
MODIFY COLUMN copy_ratio DECIMAL(20, 8) NOT NULL DEFAULT 1.00000000 COMMENT '跟单比例(仅在copyMode=RATIO时生效)';
@@ -0,0 +1,22 @@
-- ============================================
-- V19: 创建市场信息表
-- ============================================
-- 创建 markets 表
CREATE TABLE IF NOT EXISTS markets (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
market_id VARCHAR(100) NOT NULL UNIQUE COMMENT '市场IDcondition ID',
title VARCHAR(500) NOT NULL COMMENT '市场名称(question',
slug VARCHAR(200) COMMENT '市场slug',
category VARCHAR(50) COMMENT '市场分类',
icon VARCHAR(500) COMMENT '市场图标URL',
image VARCHAR(500) COMMENT '市场图片URL',
description TEXT COMMENT '市场描述',
active BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否活跃',
closed BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否已关闭',
archived BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否已归档',
created_at BIGINT NOT NULL COMMENT '创建时间(时间戳,毫秒)',
updated_at BIGINT NOT NULL COMMENT '更新时间(时间戳,毫秒)',
INDEX idx_market_id (market_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='市场信息表';
@@ -0,0 +1,19 @@
-- ============================================
-- V20: 添加关键字过滤字段
-- 在 copy_trading 表中添加关键字过滤配置
-- 支持白名单和黑名单两种模式
-- ============================================
-- 添加关键字过滤模式字段
-- DISABLED: 不启用关键字过滤(默认)
-- WHITELIST: 白名单模式,只跟单包含关键字的市场
-- BLACKLIST: 黑名单模式,不跟单包含关键字的市场
ALTER TABLE copy_trading
ADD COLUMN keyword_filter_mode VARCHAR(20) NOT NULL DEFAULT 'DISABLED' COMMENT '关键字过滤模式(DISABLED/WHITELIST/BLACKLIST' AFTER max_position_count;
-- 添加关键字列表字段(JSON 数组)
-- 当 keyword_filter_mode 为 DISABLED 时,此字段为 NULL
-- 当 keyword_filter_mode 为 WHITELIST 或 BLACKLIST 时,此字段存储关键字数组,例如:["NBA", "足球", "NBA总决赛"]
ALTER TABLE copy_trading
ADD COLUMN keywords JSON NULL COMMENT '关键字列表(JSON数组),仅在WHITELIST或BLACKLIST模式下使用' AFTER keyword_filter_mode;
@@ -0,0 +1,8 @@
-- ============================================
-- V21: 添加 event_slug 字段到 markets 表
-- 用于存储跳转用的 slug(从 events[0].slug 获取)
-- ============================================
ALTER TABLE markets
ADD COLUMN event_slug VARCHAR(200) NULL COMMENT '跳转用的 slug(从 events[0].slug 获取,用于构建 URL' AFTER slug;
@@ -0,0 +1,20 @@
-- ============================================
-- V22: 添加市场截止时间筛选功能
-- 1. 为 copy_trading 表添加 max_market_end_date 字段
-- 2. 为 markets 表添加 end_date 字段并清空已有数据
-- ============================================
-- 1. 添加市场截止时间筛选字段到 copy_trading 表
-- 仅跟单截止时间小于设置时间的订单
-- 存储毫秒时间戳,NULL 表示不启用此筛选
ALTER TABLE copy_trading
ADD COLUMN max_market_end_date BIGINT NULL COMMENT '市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用';
-- 2. 为 markets 表添加 end_date 字段
-- 删除已有数据,让系统重新从 API 获取,确保所有数据的 end_date 都正确填充
DELETE FROM markets;
-- 添加市场截止时间字段
ALTER TABLE markets
ADD COLUMN end_date BIGINT NULL COMMENT '市场截止时间(毫秒时间戳),NULL表示未设置' AFTER archived;
@@ -0,0 +1,19 @@
-- ============================================
-- V23: 移除跟单关系的唯一约束
-- 允许同一用户创建多个相同 leader 的跟单配置
-- ============================================
-- 删除唯一约束 uk_account_leader
SET @uk_name = (SELECT CONSTRAINT_NAME FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'copy_trading'
AND CONSTRAINT_TYPE = 'UNIQUE'
AND CONSTRAINT_NAME = 'uk_account_leader'
LIMIT 1);
SET @sql = IF(@uk_name IS NOT NULL,
CONCAT('ALTER TABLE copy_trading DROP INDEX ', @uk_name),
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
@@ -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 '推送已过滤订单(默认关闭)';
+20 -2
View File
@@ -76,6 +76,11 @@ JWT_SECRET=${JWT_SECRET}
# 管理员密码重置密钥(已自动生成随机值,生产环境建议修改)
ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_KEY}
# 日志级别配置(可选,默认值:root=WARN, app=INFO
# 可选值:TRACE, DEBUG, INFO, WARN, ERROR, OFF
# LOG_LEVEL_ROOT=WARN
# LOG_LEVEL_APP=INFO
EOF
info ".env 文件已创建,已自动生成随机密码和密钥"
warn "生产环境建议修改以下参数:"
@@ -156,7 +161,18 @@ deploy() {
# 注意:这里需要手动修改 docker-compose.yml,或者使用环境变量
warn "请确保 docker-compose.yml 中已配置使用 image: wrbug/polyhermes:latest"
else
info "构建 Docker 镜像(本地构建,版本号将显示为 dev)..."
# 获取当前分支名作为版本号
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "dev")
# 如果分支名包含 /,替换为 -(Docker tag 不支持 /
DOCKER_VERSION=$(echo "$CURRENT_BRANCH" | tr '/' '-')
info "构建 Docker 镜像(本地构建,版本号: ${DOCKER_VERSION}..."
# 设置构建参数(通过环境变量传递给 docker-compose.yml
export VERSION=${DOCKER_VERSION}
export GIT_TAG=${DOCKER_VERSION}
export GITHUB_REPO_URL=https://github.com/WrBug/PolyHermes
docker-compose build
fi
@@ -196,7 +212,9 @@ main() {
info "访问地址: http://localhost:${SERVER_PORT:-80}"
echo ""
if [ "$USE_DOCKER_HUB" != "true" ]; then
info "提示:本地构建的版本号显示为 'dev'"
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "dev")
DOCKER_VERSION=$(echo "$CURRENT_BRANCH" | tr '/' '-')
info "提示:本地构建的版本号为当前分支名: ${DOCKER_VERSION}"
info "生产环境推荐使用 Docker Hub 镜像:"
info " ./deploy.sh --use-docker-hub"
info " 或修改 docker-compose.yml 使用 image: wrbug/polyhermes:latest"
+17
View File
@@ -58,3 +58,20 @@ ADMIN_RESET_PASSWORD_KEY=change-me-in-production
# 生成方式 / Generate: openssl rand -hex 32
CRYPTO_SECRET_KEY=change-me-in-production
# ============================================
# 日志级别配置 / Logging Level Configuration
# ============================================
# 可选配置,如果不设置则使用默认值 / Optional configuration, uses default values if not set
#
# Root 日志级别,控制所有第三方库的日志输出
# Root logging level, controls log output for all third-party libraries
# 默认值 / Default: WARN
# 可选值 / Options: TRACE, DEBUG, INFO, WARN, ERROR, OFF
# LOG_LEVEL_ROOT=WARN
#
# 应用日志级别,控制应用代码的日志输出
# Application logging level, controls log output for application code
# 默认值 / Default: INFO
# 可选值 / Options: TRACE, DEBUG, INFO, WARN, ERROR, OFF
# LOG_LEVEL_APP=INFO
+9
View File
@@ -19,6 +19,7 @@ services:
ports:
- "${SERVER_PORT:-80}:80"
environment:
- TZ=${TZ:-Asia/Shanghai}
- 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_USERNAME=${DB_USERNAME:-root}
@@ -29,6 +30,12 @@ services:
# 生成随机密钥:openssl rand -hex 32 (ADMIN_RESET_PASSWORD_KEY) 或 openssl rand -hex 64 (JWT_SECRET)
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
- ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY:-change-me-in-production}
# 日志级别配置(可选,默认值:root=WARN, app=INFO
# 可选值:TRACE, DEBUG, INFO, WARN, ERROR, OFF
- LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT:-WARN}
- LOG_LEVEL_APP=${LOG_LEVEL_APP:-INFO}
volumes:
- /etc/localtime:/etc/localtime:ro
depends_on:
mysql:
condition: service_healthy
@@ -42,12 +49,14 @@ services:
ports:
- "${MYSQL_PORT:-3307}:3306"
environment:
- TZ=${TZ:-Asia/Shanghai}
- MYSQL_ROOT_PASSWORD=${DB_PASSWORD:-rootpassword}
- MYSQL_DATABASE=polyhermes
- MYSQL_CHARACTER_SET_SERVER=utf8mb4
- MYSQL_COLLATION_SERVER=utf8mb4_unicode_ci
volumes:
- mysql-data:/var/lib/mysql
- /etc/localtime:/etc/localtime:ro
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_PASSWORD:-rootpassword}"]
interval: 10s
+14 -5
View File
@@ -8,15 +8,16 @@ services:
build:
context: .
dockerfile: Dockerfile
# 本地构建时可以传递版本号参数(可选
# args:
# VERSION: ${VERSION:-dev}
# GIT_TAG: ${GIT_TAG:-}
# GITHUB_REPO_URL: https://github.com/WrBug/PolyHermes
# 本地构建时可以传递版本号参数(自动使用当前分支名
args:
VERSION: ${VERSION:-dev}
GIT_TAG: ${GIT_TAG:-${VERSION:-dev}}
GITHUB_REPO_URL: ${GITHUB_REPO_URL:-https://github.com/WrBug/PolyHermes}
container_name: polyhermes
ports:
- "${SERVER_PORT:-80}:80"
environment:
- TZ=${TZ:-Asia/Shanghai}
- 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_USERNAME=${DB_USERNAME:-root}
@@ -27,6 +28,12 @@ services:
# 生成随机密钥:openssl rand -hex 32 (ADMIN_RESET_PASSWORD_KEY) 或 openssl rand -hex 64 (JWT_SECRET)
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
- ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY:-change-me-in-production}
# 日志级别配置(可选,默认值:root=WARN, app=INFO
# 可选值:TRACE, DEBUG, INFO, WARN, ERROR, OFF
- LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT:-WARN}
- LOG_LEVEL_APP=${LOG_LEVEL_APP:-INFO}
volumes:
- /etc/localtime:/etc/localtime:ro
depends_on:
mysql:
condition: service_healthy
@@ -40,12 +47,14 @@ services:
ports:
- "${MYSQL_PORT:-3307}:3306"
environment:
- TZ=${TZ:-Asia/Shanghai}
- MYSQL_ROOT_PASSWORD=${DB_PASSWORD:-rootpassword}
- MYSQL_DATABASE=polyhermes
- MYSQL_CHARACTER_SET_SERVER=utf8mb4
- MYSQL_COLLATION_SERVER=utf8mb4_unicode_ci
volumes:
- mysql-data:/var/lib/mysql
- /etc/localtime:/etc/localtime:ro
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_PASSWORD:-rootpassword}"]
interval: 10s
@@ -0,0 +1,477 @@
# 跟单双重监听方案(OnChain + Poly Activity WS
## 1. 方案概述
### 1.1 目标
实现 **OnChain WebSocket + Polymarket Activity WebSocket** 双重监听机制,提高跟单系统的实时性和可靠性。
### 1.2 架构图
```
┌─────────────────────────────────────────────────────────────────┐
│ CopyTradingMonitorService │
│ (主监听协调服务) │
└────────────┬──────────────────────────────────┬──────────────────┘
│ │
▼ ▼
┌────────────────────────┐ ┌──────────────────────────────┐
│ PolymarketActivity │ │ OnChainWsService │
│ WsService (新增) │ │ (已存在) │
│ │ │ │
│ - 订阅全局 activity │ │ - 订阅 Leader 地址的链上事件 │
│ - 客户端地址过滤 │ │ - 监听 Transfer 事件 │
│ - 延迟 < 100ms │ │ - 延迟 ~2-3s (出块时间) │
│ - 数据来源:Poly WS │ │ - 数据来源:链上 RPC │
└────────────┬───────────┘ └──────────────┬───────────────┘
│ │
└──────────────┬──────────────────────┘
┌───────────────────────┐
│ CopyOrderTrackingService│
│ (统一处理服务) │
│ │
│ - 去重(leaderId+ │
│ tradeId
│ - 处理买入/卖出 │
│ - 记录处理状态 │
└────────────────────────┘
```
### 1.3 优势对比
| 特性 | Activity WS | OnChain WS | 双重监听 |
|------|-------------|------------|----------|
| **延迟** | < 100ms | ~2-3s | < 100ms (优先) |
| **可靠性** | 中等(依赖 Poly 服务) | 高(链上数据) | 高(双重保障) |
| **数据完整性** | 完整(包含 market、price、size | 需要解析 Transfer | 完整 |
| **兜底机制** | ❌ | ✅ | ✅ |
| **去重** | 需要 | 需要 | 统一去重 |
## 2. 技术方案
### 2.1 新增服务:PolymarketActivityWsService
**职责**
- 订阅 Polymarket Activity WebSocket(全局交易流)
- 根据 Leader 地址列表进行客户端过滤
- 将交易事件转换为 `TradeResponse` 格式
- 调用 `CopyOrderTrackingService.processTrade()` 处理
**实现要点**
1. 使用单个 WebSocket 连接订阅全局 activity(不传 filters
2. 客户端维护 Leader 地址 Set,实时过滤交易
3. 支持动态添加/移除 Leader(无需重连)
4. 自动重连机制
### 2.2 修改 CopyTradingMonitorService
**职责**
- 协调两种监听服务
- 统一管理 Leader 的添加/移除
- 启动时同时启动两种监听
### 2.3 去重机制
**现有机制**CopyOrderTrackingService):
- 使用 `leaderId + trade.id` 作为唯一键
- 基于数据库表 `processed_trade` 去重
- 使用 Mutex 保证线程安全
**双重监听场景**
- Activity WS 和 OnChain WS 可能同时检测到同一笔交易
- 去重机制确保只处理一次(第一个到达的)
- 通过 `source` 字段区分数据源("activity-ws" / "onchain-ws"
## 3. 实现细节
### 3.1 PolymarketActivityWsService 设计
```kotlin
@Service
class PolymarketActivityWsService(
private val copyOrderTrackingService: CopyOrderTrackingService,
private val leaderRepository: LeaderRepository
) {
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
// 单例 WebSocket 客户端
private var wsClient: PolymarketWebSocketClient? = null
// 要监听的 Leader 地址集合(小写)
private val monitoredAddresses = ConcurrentHashMap<String, Long>() // address -> leaderId
/**
* 启动监听
*/
fun start(leaders: List<Leader>) {
monitoredAddresses.clear()
leaders.forEach { leader ->
if (leader.id != null) {
monitoredAddresses[leader.leaderAddress.lowercase()] = leader.id!!
}
}
if (monitoredAddresses.isEmpty()) {
stop()
return
}
connectAndSubscribe()
}
/**
* 添加 Leader
*/
fun addLeader(leader: Leader) {
if (leader.id == null) return
monitoredAddresses[leader.leaderAddress.lowercase()] = leader.id!!
// 如果 WebSocket 未连接,连接
if (wsClient == null) {
connectAndSubscribe()
}
}
/**
* 移除 Leader
*/
fun removeLeader(leaderId: Long) {
val addressToRemove = monitoredAddresses.entries
.find { it.value == leaderId }?.key
addressToRemove?.let { monitoredAddresses.remove(it) }
// 如果没有 Leader 了,停止监听
if (monitoredAddresses.isEmpty()) {
stop()
}
}
/**
* 连接并订阅
*/
private fun connectAndSubscribe() {
if (wsClient != null && wsClient!!.isConnected()) {
return
}
wsClient = PolymarketWebSocketClient(
url = "wss://ws-live-data.polymarket.com",
sessionId = "copy-trading-activity",
onMessage = { message -> handleMessage(message) },
onOpen = {
subscribeAllActivity()
},
onReconnect = {
subscribeAllActivity()
}
)
scope.launch {
try {
wsClient!!.connect()
} catch (e: Exception) {
logger.error("连接 Activity WebSocket 失败", e)
}
}
}
/**
* 订阅全局 activity
*/
private fun subscribeAllActivity() {
val message = """
{
"type": "subscribe",
"channel": "activity",
"topic": "trades"
}
""".trimIndent()
wsClient?.sendMessage(message)
}
/**
* 处理消息
*/
private fun handleMessage(message: String) {
try {
if (message.trim() == "PONG") return
val json = JsonParser.parseString(message).asJsonObject
// 检查是否是 trade 事件
val topic = json.get("topic")?.asString
val type = json.get("type")?.asString
if (topic != "activity" || type != "trades") {
return
}
val payload = json.getAsJsonObject("payload") ?: return
// 提取交易者地址
val traderObj = payload.getAsJsonObject("trader")
val traderAddress = traderObj?.get("address")?.asString?.lowercase()
?: payload.get("proxyWallet")?.asString?.lowercase()
?: return
// 检查是否是我们要监听的 Leader
val leaderId = monitoredAddresses[traderAddress] ?: return
// 解析交易数据
val trade = parseActivityTrade(payload, leaderId)
if (trade != null) {
scope.launch {
copyOrderTrackingService.processTrade(
leaderId = leaderId,
trade = trade,
source = "activity-ws"
)
}
}
} catch (e: Exception) {
logger.error("处理 Activity WebSocket 消息失败", e)
}
}
/**
* 解析 Activity Trade 为 TradeResponse
*/
private fun parseActivityTrade(json: JsonObject, leaderId: Long): TradeResponse? {
return try {
TradeResponse(
id = json.get("transactionHash")?.asString
?: "${leaderId}_${System.currentTimeMillis()}", // fallback
market = json.get("conditionId")?.asString ?: return null,
side = json.get("side")?.asString?.uppercase() ?: return null,
price = json.get("price")?.asString ?: return null,
size = json.get("size")?.asString ?: return null,
timestamp = (json.get("timestamp")?.asLong ?: System.currentTimeMillis()).toString(),
user = null, // Activity WS 中不需要
outcomeIndex = parseOutcomeIndex(json.get("outcome")?.asString),
outcome = json.get("outcome")?.asString
)
} catch (e: Exception) {
logger.error("解析 Activity Trade 失败", e)
null
}
}
private fun parseOutcomeIndex(outcome: String?): Int? {
return when (outcome?.uppercase()) {
"YES", "UP", "TRUE" -> 0
"NO", "DOWN", "FALSE" -> 1
else -> null
}
}
fun stop() {
wsClient?.closeConnection()
wsClient = null
monitoredAddresses.clear()
}
}
```
### 3.2 修改 CopyTradingMonitorService
```kotlin
@Service
class CopyTradingMonitorService(
private val copyTradingRepository: CopyTradingRepository,
private val leaderRepository: LeaderRepository,
private val accountRepository: AccountRepository,
private val onChainWsService: OnChainWsService,
private val accountOnChainMonitorService: AccountOnChainMonitorService,
private val activityWsService: PolymarketActivityWsService // 新增
) {
suspend fun startMonitoring() {
val enabledCopyTradings = copyTradingRepository.findByEnabledTrue()
if (enabledCopyTradings.isEmpty()) {
return
}
val leaderIds = enabledCopyTradings.map { it.leaderId }.distinct()
val leaders = leaderIds.mapNotNull { leaderId ->
leaderRepository.findById(leaderId).orElse(null)
}
val accountIds = enabledCopyTradings.map { it.accountId }.distinct()
val accounts = accountIds.mapNotNull { accountId ->
accountRepository.findById(accountId).orElse(null)
}
// 1. 启动 Activity WebSocket 监听(优先,低延迟)
activityWsService.start(leaders)
// 2. 启动链上 WebSocket 监听(兜底,高可靠性)
onChainWsService.start(leaders)
// 3. 启动账户监听
accountOnChainMonitorService.start(accounts)
}
suspend fun addLeaderMonitoring(leaderId: Long) {
val leader = leaderRepository.findById(leaderId).orElse(null) ?: return
val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId)
if (copyTradings.isEmpty()) {
return
}
// 同时添加到两种监听
activityWsService.addLeader(leader)
onChainWsService.addLeader(leader)
}
suspend fun removeLeaderMonitoring(leaderId: Long) {
val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId)
if (copyTradings.isNotEmpty()) {
return
}
// 同时从两种监听移除
activityWsService.removeLeader(leaderId)
onChainWsService.removeLeader(leaderId)
}
@PreDestroy
fun destroy() {
scope.cancel()
activityWsService.stop()
onChainWsService.stop()
accountOnChainMonitorService.stop()
}
}
```
### 3.3 WebSocket 客户端复用
**检查现有 PolymarketWebSocketClient**
- 如果已存在,检查是否支持 Activity 订阅
- 如果不存在,需要创建或引入依赖
**依赖选项**
1. **使用现有实现**:检查 `PolymarketWebSocketClient` 是否支持 Activity
2. **引入 poly-sdk 的方式**:参考 poly-sdk 的 `RealTimeDataClient`
3. **自行实现**:基于 OkHttp WebSocket 或 Java-WebSocket
## 4. 数据流
### 4.1 Activity WS 数据流
```
Polymarket 服务器
↓ (WebSocket推送 activity trade)
PolymarketActivityWsService.handleMessage()
↓ (提取 trader.address)
客户端过滤(monitoredAddresses.contains(traderAddress)
↓ (匹配到 Leader)
parseActivityTrade() → TradeResponse
CopyOrderTrackingService.processTrade(
leaderId, trade, source="activity-ws"
)
↓ (去重检查)
如果未处理 → 执行跟单逻辑
如果已处理 → 跳过(可能是 OnChain WS 先到达)
```
### 4.2 OnChain WS 数据流(保持不变)
```
链上 RPC WebSocket
↓ (推送 Transfer 事件)
UnifiedOnChainWsService
OnChainWsService.handleLeaderTransaction()
↓ (解析 Transfer → Trade)
CopyOrderTrackingService.processTrade(
leaderId, trade, source="onchain-ws"
)
↓ (去重检查)
如果未处理 → 执行跟单逻辑
如果已处理 → 跳过(可能是 Activity WS 先到达)
```
## 5. 配置项
### 5.1 application.properties
```properties
# Polymarket WebSocket
polymarket.websocket.url=wss://ws-live-data.polymarket.com
# 监听策略
copy.trading.monitor.strategy=dual
# dual: 双重监听(Activity WS + OnChain WS
# activity-only: 仅 Activity WS
# onchain-only: 仅 OnChain WS
# Activity WS 配置
copy.trading.activity.ws.enabled=true
copy.trading.activity.ws.reconnect.delay=3000
# OnChain WS 配置(已存在)
copy.trading.onchain.ws.reconnect.delay=3000
```
## 6. 监控和日志
### 6.1 关键指标
- **Activity WS 连接状态**
- **OnChain WS 连接状态**
- **每种源的交易检测数量**
- **去重次数(重复检测)**
- **处理延迟(Activity WS vs OnChain WS**
### 6.2 日志示例
```
[INFO] Activity WS 连接成功
[INFO] Activity WS 订阅全局 activity 成功
[INFO] 检测到交易: leaderId=1, address=0x1234..., source=activity-ws, latency=85ms
[INFO] 检测到交易: leaderId=1, address=0x1234..., source=onchain-ws, latency=2150ms
[WARN] 交易已处理(去重): leaderId=1, tradeId=xxx, firstSource=activity-ws, duplicateSource=onchain-ws
```
## 7. 实施步骤
### Phase 1: 基础实现
1. ✅ 创建 `PolymarketActivityWsService`
2. ✅ 实现 Activity WebSocket 连接和订阅
3. ✅ 实现客户端地址过滤
4. ✅ 实现交易解析和转换
### Phase 2: 集成
5. ✅ 修改 `CopyTradingMonitorService` 集成 Activity WS
6. ✅ 测试双重监听和去重机制
7. ✅ 添加配置项支持
### Phase 3: 优化
8. ✅ 添加监控指标
9. ✅ 性能优化(连接池、消息批处理等)
10. ✅ 错误处理和降级策略
### Phase 4: 验证
11. ✅ 端到端测试
12. ✅ 压力测试
13. ✅ 生产环境灰度验证
## 8. 风险与缓解
| 风险 | 影响 | 缓解措施 |
|------|------|----------|
| Activity WS 服务不稳定 | 高 | OnChain WS 作为兜底 |
| 消息重复处理 | 中 | 数据库去重 + Mutex 锁 |
| 连接断开频繁 | 中 | 自动重连 + 指数退避 |
| 地址过滤性能 | 低 | 使用 ConcurrentHashMapO(1) 查询 |
## 9. 未来优化
1. **消息批处理**:将多个交易合并处理,减少数据库写入
2. **智能切换**:根据 Activity WS 稳定性自动降级到 OnChain
3. **延迟监控**:实时监控两种源的延迟,选择最优源
4. **缓存优化**:缓存 Leader 地址列表,减少查询
@@ -0,0 +1,389 @@
# Polymarket Activity WebSocket API 格式
## 1. 连接信息
### 1.1 WebSocket URL
```
wss://ws-live-data.polymarket.com
```
这是 Polymarket 官方 RTDS (Real-Time Data Stream) 的 WebSocket 端点。
### 1.2 协议说明
- 使用 **官方 `@polymarket/real-time-data-client`** 的协议格式
- 消息格式为 JSON
- 支持自动重连
- 需要 PING/PONG 保活(每 10 秒发送 PING
## 2. 订阅消息格式
### 2.1 订阅全局 Activity(所有交易)
**订阅消息**
```json
{
"action": "subscribe",
"subscriptions": [
{
"topic": "activity",
"type": "trades"
}
]
}
```
**关键点**
- `action`: `"subscribe"` - 订阅动作(根据 `@polymarket/real-time-data-client` 协议)
- `topic`: `"activity"` - 活动数据频道
- `type`: `"trades"` - 交易类型(在 subscriptions 数组内)
- **不传 `filters` 字段** - 订阅所有市场的交易(空对象 `{}` 会被拒绝)
### 2.2 订阅特定市场的 Activity(可选)
如果需要过滤特定市场:
```json
{
"action": "subscribe",
"subscriptions": [
{
"topic": "activity",
"type": "trades",
"filters": "{\"market_slug\":\"trump-win-2024\"}"
}
]
}
```
或者过滤特定事件:
```json
{
"action": "subscribe",
"subscriptions": [
{
"topic": "activity",
"type": "trades",
"filters": "{\"event_slug\":\"presidential-election-2024\"}"
}
]
}
```
**注意**
- `filters` 是 JSON 字符串(不是对象)
- 使用 `snake_case``market_slug`, `event_slug`
- 对于 Copy Trading,我们**不传 filters**,订阅全局然后客户端过滤
### 2.3 取消订阅
```json
{
"action": "unsubscribe",
"subscriptions": [
{
"topic": "activity",
"type": "trades"
}
]
}
```
## 3. 接收消息格式
### 3.1 Trade 消息结构
当有交易发生时,服务器会推送如下格式的消息:
```json
{
"topic": "activity",
"type": "trades",
"timestamp": 1704067200000,
"payload": {
"asset": "47632033502843656213...", // Token ID (用于下单)
"conditionId": "0xb82c6573...", // Market condition ID
"eventSlug": "aus-mct-per-2025-12-28", // 事件 slug
"slug": "aus-mct-per-draw", // 市场 slug
"outcome": "No", // 结果方向 (Yes/No)
"side": "BUY", // 交易方向 (BUY/SELL)
"size": 15.72, // 交易数量 (shares)
"price": 0.87, // 交易价格
"timestamp": 1766913243, // Unix 时间戳 (秒)
"transactionHash": "0x921936dfc9...", // 交易哈希
"trader": { // 交易者信息对象
"name": "gabagool22", // 交易者用户名(可选)
"address": "0x6031B6eed1C97e..." // 交易者钱包地址 ⭐ 关键字段!
}
}
}
```
**重要字段说明**
- `payload.trader.address` - **交易者钱包地址**(用于过滤 Leader
- `payload.trader.name` - 交易者用户名(可选)
- `payload.asset` - Token ID(用于下单)
- `payload.conditionId` - Market condition ID
- `payload.side` - BUY 或 SELL
- `payload.size` - 交易数量
- `payload.price` - 交易价格
- `payload.timestamp` - Unix 时间戳(秒)
### 3.2 注意:字段命名可能不同
根据实测(poly-sdk 文档),有些情况下字段可能在顶层:
```json
{
"topic": "activity",
"type": "trades",
"payload": {
"asset": "...",
"conditionId": "...",
"side": "BUY",
"price": 0.87,
"size": 15.72,
"transactionHash": "...",
"name": "gabagool22", // 可能在顶层
"proxyWallet": "0x6031..." // 可能在顶层(而不是 trader.address
}
}
```
**建议处理方式**:同时检查 `payload.trader?.address``payload.proxyWallet`
## 4. PING/PONG 保活
### 4.1 发送 PING
每 10 秒发送一次:
```json
"PING"
```
或:
```json
{
"type": "ping"
}
```
### 4.2 接收 PONG
服务器会回复:
```json
"PONG"
```
或:
```json
{
"type": "pong"
}
```
## 5. Kotlin 实现示例
### 5.1 订阅消息
```kotlin
fun subscribeAllActivity() {
val subscribeMessage = """
{
"type": "subscribe",
"subscriptions": [
{
"topic": "activity",
"type": "trades"
}
]
}
""".trimIndent()
wsClient.sendMessage(subscribeMessage)
}
```
### 5.2 解析交易消息
```kotlin
private fun parseActivityTrade(json: JsonObject): ActivityTrade? {
val payload = json.getAsJsonObject("payload") ?: return null
// 提取交易者地址(优先 trader.addressfallback 到 proxyWallet
val traderObj = payload.getAsJsonObject("trader")
val traderAddress = traderObj?.get("address")?.asString
?: payload.get("proxyWallet")?.asString
?: return null
val traderName = traderObj?.get("name")?.asString
?: payload.get("name")?.asString
return ActivityTrade(
asset = payload.get("asset")?.asString ?: return null,
conditionId = payload.get("conditionId")?.asString ?: return null,
eventSlug = payload.get("eventSlug")?.asString,
marketSlug = payload.get("slug")?.asString,
outcome = payload.get("outcome")?.asString,
side = payload.get("side")?.asString?.uppercase() ?: return null,
size = payload.get("size")?.asDouble ?: return null,
price = payload.get("price")?.asDouble ?: return null,
timestamp = payload.get("timestamp")?.asLong
?.let { if (it < 1e12) it * 1000 else it } // 秒转毫秒
?: System.currentTimeMillis(),
transactionHash = payload.get("transactionHash")?.asString,
traderAddress = traderAddress.lowercase(),
traderName = traderName
)
}
```
## 6. 与现有实现的对比
### 6.1 User Channel(当前使用)
**URL**: `wss://ws-subscriptions-clob.polymarket.com/ws/user`
**订阅格式**:
```json
{
"type": "subscribe",
"channel": "user",
"user": "0x1234..."
}
```
**限制**
- ❌ 只能订阅**自己的**交易(需要 API 认证)
- ❌ 无法监听别人的交易
### 6.2 Activity Channel(新方案)
**URL**: `wss://ws-live-data.polymarket.com`
**订阅格式**:
```json
{
"type": "subscribe",
"subscriptions": [
{
"topic": "activity",
"type": "trades"
}
]
}
```
**优势**
- ✅ 可以监听**所有**交易的全局流
- ✅ 包含交易者地址,可以客户端过滤
- ✅ 不需要认证
- ✅ 延迟低(< 100ms
## 7. 完整的消息处理流程
### 7.1 连接和订阅
```kotlin
// 1. 连接到 WebSocket
val client = PolymarketWebSocketClient(
url = "wss://ws-live-data.polymarket.com",
sessionId = "copy-trading-activity",
onMessage = { message -> handleMessage(message) },
onOpen = {
subscribeAllActivity()
},
onReconnect = {
subscribeAllActivity()
}
)
client.connect()
// 2. 发送订阅消息
fun subscribeAllActivity() {
val message = """
{
"type": "subscribe",
"subscriptions": [
{
"topic": "activity",
"type": "trades"
}
]
}
""".trimIndent()
client.sendMessage(message)
}
```
### 7.2 处理消息
```kotlin
private fun handleMessage(message: String) {
if (message.trim() == "PONG") return
val json = JsonParser.parseString(message).asJsonObject
// 检查是否是 activity trade 消息
val topic = json.get("topic")?.asString
val type = json.get("type")?.asString
if (topic == "activity" && type == "trades") {
val payload = json.getAsJsonObject("payload") ?: return
// 提取交易者地址
val traderAddress = extractTraderAddress(payload) ?: return
// 检查是否是我们监听的 Leader
val leaderId = monitoredAddresses[traderAddress.lowercase()] ?: return
// 解析交易
val trade = parseActivityTrade(payload) ?: return
// 处理交易
processTrade(leaderId, trade)
}
}
private fun extractTraderAddress(payload: JsonObject): String? {
// 优先检查 trader.address
val traderObj = payload.getAsJsonObject("trader")
val address = traderObj?.get("address")?.asString
?: payload.get("proxyWallet")?.asString
return address?.lowercase()
}
```
## 8. 错误处理
### 8.1 连接错误
- 连接失败时自动重连(指数退避:3s → 6s → 12s → 最大 60s
- 重连后重新发送订阅消息
### 8.2 订阅错误
如果订阅失败,服务器可能返回:
```json
{
"type": "error",
"message": "Invalid subscription"
}
```
### 8.3 消息解析错误
- 如果字段缺失,记录警告日志并跳过
- 如果地址格式错误,跳过该消息
- 如果解析失败,记录错误但不中断连接
## 9. 性能考虑
### 9.1 消息频率
- Activity WebSocket 会推送**所有市场**的所有交易
- 高频市场可能每秒收到数百条消息
- 需要高效的客户端过滤(使用 `Set``Map`O(1) 查找)
### 9.2 内存管理
- 维护 Leader 地址 Set(通常 < 100 个地址)
- 消息处理使用协程异步处理,避免阻塞
- 定期清理不再监听的地址
+18 -5
View File
@@ -11,7 +11,7 @@
"antd": "^5.12.0",
"antd-mobile": "^5.34.0",
"axios": "^1.6.2",
"ethers": "^6.9.0",
"ethers": "^6.16.0",
"i18next": "^25.7.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@@ -158,6 +158,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -1676,6 +1677,7 @@
"version": "18.3.27",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -1741,6 +1743,7 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz",
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true,
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0",
@@ -1937,6 +1940,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2255,6 +2259,7 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754",
@@ -2466,7 +2471,8 @@
"node_modules/dayjs": {
"version": "1.11.19",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
"peer": true
},
"node_modules/debug": {
"version": "4.4.3",
@@ -2687,6 +2693,7 @@
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -2877,9 +2884,9 @@
}
},
"node_modules/ethers": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ethers/-/ethers-6.15.0.tgz",
"integrity": "sha512-Kf/3ZW54L4UT0pZtsY/rf+EkBU7Qi5nnhonjUb8yTXcxH3cdcWrV2cRyk0Xk/4jK6OoHhxxZHriyhje20If2hQ==",
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/ethers/-/ethers-6.16.0.tgz",
"integrity": "sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==",
"funding": [
{
"type": "individual",
@@ -2890,6 +2897,7 @@
"url": "https://www.buymeacoffee.com/ricmoo"
}
],
"license": "MIT",
"dependencies": {
"@adraffy/ens-normalize": "1.10.1",
"@noble/curves": "1.2.0",
@@ -3364,6 +3372,7 @@
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4"
},
@@ -5432,6 +5441,7 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -5443,6 +5453,7 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -6010,6 +6021,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -6182,6 +6194,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
+1 -1
View File
@@ -12,7 +12,7 @@
"antd": "^5.12.0",
"antd-mobile": "^5.34.0",
"axios": "^1.6.2",
"ethers": "^6.9.0",
"ethers": "^6.16.0",
"i18next": "^25.7.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
-4
View File
@@ -23,8 +23,6 @@ import TemplateList from './pages/TemplateList'
import TemplateAdd from './pages/TemplateAdd'
import TemplateEdit from './pages/TemplateEdit'
import CopyTradingList from './pages/CopyTradingList'
import CopyTradingAdd from './pages/CopyTradingAdd'
import CopyTradingEdit from './pages/CopyTradingEdit'
import CopyTradingStatistics from './pages/CopyTradingStatistics'
import CopyTradingBuyOrders from './pages/CopyTradingBuyOrders'
import CopyTradingSellOrders from './pages/CopyTradingSellOrders'
@@ -250,8 +248,6 @@ function App() {
<Route path="/templates/add" element={<ProtectedRoute><TemplateAdd /></ProtectedRoute>} />
<Route path="/templates/edit/:id" element={<ProtectedRoute><TemplateEdit /></ProtectedRoute>} />
<Route path="/copy-trading" element={<ProtectedRoute><CopyTradingList /></ProtectedRoute>} />
<Route path="/copy-trading/add" element={<ProtectedRoute><CopyTradingAdd /></ProtectedRoute>} />
<Route path="/copy-trading/edit/:id" element={<ProtectedRoute><CopyTradingEdit /></ProtectedRoute>} />
<Route path="/copy-trading/statistics/:copyTradingId" element={<ProtectedRoute><CopyTradingStatistics /></ProtectedRoute>} />
{/* 保留旧路由以保持向后兼容 */}
<Route path="/copy-trading/orders/buy/:copyTradingId" element={<ProtectedRoute><CopyTradingBuyOrders /></ProtectedRoute>} />
+37 -7
View File
@@ -1,18 +1,20 @@
import { useState } from 'react'
import { Form, Input, Button, Radio, Space, Alert } from 'antd'
import { Form, Input, Button, Radio, Space, Alert, Tooltip } from 'antd'
import { QuestionCircleOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import { useAccountStore } from '../store/accountStore'
import {
getAddressFromPrivateKey,
import {
getAddressFromPrivateKey,
getAddressFromMnemonic,
getPrivateKeyFromMnemonic,
isValidWalletAddress,
isValidWalletAddress,
isValidPrivateKey,
isValidMnemonic
} from '../utils'
import { useMediaQuery } from 'react-responsive'
type ImportType = 'privateKey' | 'mnemonic'
type WalletType = 'magic' | 'safe'
interface AccountImportFormProps {
form: any
@@ -33,6 +35,7 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
const isMobile = useMediaQuery({ maxWidth: 768 })
const { importAccount, loading } = useAccountStore()
const [importType, setImportType] = useState<ImportType>('privateKey')
const [walletType, setWalletType] = useState<WalletType>('safe')
const [derivedAddress, setDerivedAddress] = useState<string>('')
const [addressError, setAddressError] = useState<string>('')
@@ -141,7 +144,8 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
await importAccount({
privateKey: privateKey,
walletAddress: walletAddress,
accountName: values.accountName
accountName: values.accountName,
walletType: walletType
})
// 等待store更新
@@ -189,8 +193,8 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
size={isMobile ? 'middle' : 'large'}
>
<Form.Item label={t('accountImport.importMethod')}>
<Radio.Group
value={importType}
<Radio.Group
value={importType}
onChange={(e) => {
setImportType(e.target.value)
setDerivedAddress('')
@@ -202,6 +206,32 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
<Radio value="mnemonic">{t('accountImport.mnemonic')}</Radio>
</Radio.Group>
</Form.Item>
<Form.Item
label={
<span>
{t('accountImport.walletType')}{' '}
<Tooltip
title={t('accountImport.walletTypeHelp')}
overlayInnerStyle={{ whiteSpace: 'pre-line', maxWidth: '300px' }}
>
<QuestionCircleOutlined style={{ color: '#999' }} />
</Tooltip>
</span>
}
>
<Radio.Group
value={walletType}
onChange={(e) => setWalletType(e.target.value)}
>
<Radio value="safe">
{t('accountImport.walletTypeSafe')}
</Radio>
<Radio value="magic" disabled>
{t('accountImport.walletTypeMagic')} {t('accountImport.magicNotSupported')}
</Radio>
</Radio.Group>
</Form.Item>
{importType === 'privateKey' ? (
<>
+2 -2
View File
@@ -252,7 +252,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
<TwitterOutlined />
</a>
<a
href="https://t.me/+5BwdYvvvuf9iZGZl"
href="https://t.me/polyhermes"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#fff', fontSize: '16px', display: 'flex', alignItems: 'center' }}
@@ -376,7 +376,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
<TwitterOutlined />
</a>
<a
href="https://t.me/+5BwdYvvvuf9iZGZl"
href="https://t.me/polyhermes"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#fff', fontSize: '18px', display: 'flex', alignItems: 'center' }}
+69 -2
View File
@@ -30,7 +30,10 @@
"total": "Total",
"items": "items",
"prev": "Previous",
"next": "Next"
"previous": "Previous",
"next": "Next",
"page": "Page",
"pageOf": "Page"
},
"account": {
"title": "Account Management",
@@ -199,7 +202,12 @@
"importFailed": "Failed to import account",
"derivedAddress": "Derived Address",
"addressError": "Cannot derive address from private key",
"addressErrorMnemonic": "Cannot derive address from mnemonic"
"addressErrorMnemonic": "Cannot derive address from mnemonic",
"walletType": "Wallet Type",
"walletTypeHelp": "Web3 Wallet: Polymarket accounts connected via browser wallets like MetaMask\nMagic: Polymarket accounts logged in via email or social accounts (Google, Twitter, etc.)",
"walletTypeMagic": "Magic (Email/Social Login)",
"walletTypeSafe": "Web3 Wallet",
"magicNotSupported": "(Not Supported)"
},
"leader": {
"title": "Leader Management",
@@ -563,6 +571,8 @@
"deleteConfirmDesc": "This action cannot be undone. Please ensure no copy trading relationships are using this template",
"copySuccess": "Template copied successfully",
"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",
"fixedAmountRequired": "Please enter fixed copy trading amount",
"invalidNumber": "Please enter a valid number",
@@ -615,6 +625,8 @@
"maxPricePlaceholder": "Max Price (leave empty for no limit)",
"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.",
"pushFilteredOrders": "Push Filtered Orders",
"pushFilteredOrdersTooltip": "When enabled, filtered orders will be pushed to Telegram",
"create": "Create Template",
"createSuccess": "Template created successfully",
"createFailed": "Failed to create template",
@@ -679,6 +691,8 @@
"maxPositionCountPlaceholder": "For example: 10 (optional, leave empty to disable)",
"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.",
"pushFilteredOrders": "Push Filtered Orders",
"pushFilteredOrdersTooltip": "When enabled, filtered orders will be pushed to Telegram",
"invalidNumber": "Please enter a valid number"
},
"copyTradingAdd": {
@@ -749,6 +763,25 @@
"maxPositionCount": "Max Position Count",
"maxPositionCountTooltip": "Limit the maximum position count for a single market. If the current position count reaches or exceeds this limit, the order will not be placed. Leave empty to disable",
"maxPositionCountPlaceholder": "For example: 10 (optional, leave empty to disable)",
"keywordFilter": "Keyword Filter",
"keywordFilterMode": "Filter Mode",
"keywordFilterModeTooltip": "Select keyword filter mode. Whitelist: only copy markets containing keywords; Blacklist: do not copy markets containing keywords; Disabled: no keyword filtering. Keyword matching is case-insensitive.",
"disabled": "Disabled",
"whitelist": "Whitelist",
"blacklist": "Blacklist",
"keywords": "Keywords",
"keywordPlaceholder": "Enter keyword, press Enter to add",
"keywordExists": "Keyword already exists",
"whitelistTooltip": "💡 Whitelist mode: only copy markets whose titles contain any of the above keywords (case-insensitive)",
"blacklistTooltip": "💡 Blacklist mode: do not copy markets whose titles contain any of the above keywords (case-insensitive)",
"marketEndDateFilter": "Market End Date Limit",
"maxMarketEndDate": "Max Market End Date",
"maxMarketEndDateTooltip": "Only copy orders with market end time less than the set time. For example: 24 hours means only copy markets with less than 24 hours remaining until settlement",
"maxMarketEndDatePlaceholder": "Enter time value (optional)",
"timeUnit": "Unit",
"hour": "Hours",
"day": "Days",
"maxMarketEndDateNote": "💡 Note: Leave blank to disable this filter",
"configName": "Configuration Name",
"configNameRequired": "Please enter configuration name",
"configNamePlaceholder": "e.g., Copy Trading Config 1",
@@ -756,6 +789,8 @@
"advancedSettings": "Advanced Settings",
"pushFailedOrders": "Push Failed Orders",
"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",
"autoRedeemTooltip": "When enabled, the system will automatically redeem redeemable positions. Requires Builder API Key configuration to take effect",
"builderApiKeyNotConfigured": "Builder API Key Not Configured",
@@ -835,6 +870,25 @@
"maxPositionCount": "Max Position Count",
"maxPositionCountTooltip": "Limit the maximum position count for a single market. If the current position count reaches or exceeds this limit, the order will not be placed. Leave empty to disable",
"maxPositionCountPlaceholder": "For example: 10 (optional, leave empty to disable)",
"keywordFilter": "Keyword Filter",
"keywordFilterMode": "Filter Mode",
"keywordFilterModeTooltip": "Select keyword filter mode. Whitelist: only copy markets containing keywords; Blacklist: do not copy markets containing keywords; Disabled: no keyword filtering. Keyword matching is case-insensitive.",
"disabled": "Disabled",
"whitelist": "Whitelist",
"blacklist": "Blacklist",
"keywords": "Keywords",
"keywordPlaceholder": "Enter keyword, press Enter to add",
"keywordExists": "Keyword already exists",
"whitelistTooltip": "💡 Whitelist mode: only copy markets whose titles contain any of the above keywords (case-insensitive)",
"blacklistTooltip": "💡 Blacklist mode: do not copy markets whose titles contain any of the above keywords (case-insensitive)",
"marketEndDateFilter": "Market End Date Limit",
"maxMarketEndDate": "Max Market End Date",
"maxMarketEndDateTooltip": "Only copy orders with market end time less than the set time. For example: 24 hours means only copy markets with less than 24 hours remaining until settlement",
"maxMarketEndDatePlaceholder": "Enter time value (optional)",
"timeUnit": "Unit",
"hour": "Hours",
"day": "Days",
"maxMarketEndDateNote": "💡 Note: Leave blank to disable this filter",
"configName": "Configuration Name",
"configNameRequired": "Please enter configuration name",
"configNamePlaceholder": "e.g., Copy Trading Config 1",
@@ -842,6 +896,8 @@
"advancedSettings": "Advanced Settings",
"pushFailedOrders": "Push Failed Orders",
"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",
"autoRedeemTooltip": "When enabled, the system will automatically redeem redeemable positions. Requires Builder API Key configuration to take effect",
"builderApiKeyNotConfigured": "Builder API Key Not Configured",
@@ -879,6 +935,7 @@
"priceRange": "Price Range Mismatch",
"maxPositionValue": "Exceeds Max Position Value",
"maxPositionCount": "Exceeds Max Position Count",
"marketEndDate": "Market End Date Exceeds Limit",
"unknown": "Unknown Reason"
},
"noData": "No filtered orders"
@@ -1049,6 +1106,16 @@
"noBuyOrders": "No buy orders",
"noSellOrders": "No sell orders",
"noMatchedOrders": "No matched orders",
"groupByMarket": "Group by Market",
"expandAll": "Expand All",
"collapseAll": "Collapse All",
"allFullyMatched": "All Fully Matched",
"partiallyMatched": "Partially Matched",
"orderCount": "Order Count",
"totalAmount": "Total Amount",
"totalPnl": "Total PnL",
"statusBreakdown": "Status",
"markets": "markets",
"fetchBuyOrdersFailed": "Failed to fetch buy orders",
"fetchSellOrdersFailed": "Failed to fetch sell orders",
"fetchMatchedOrdersFailed": "Failed to fetch matched orders",
+69 -2
View File
@@ -27,7 +27,10 @@
"total": "共",
"items": "条",
"prev": "上一页",
"previous": "上一页",
"next": "下一页",
"page": "页",
"pageOf": "第",
"success": "成功",
"failed": "失败",
"close": "关闭"
@@ -199,7 +202,12 @@
"importFailed": "导入账户失败",
"derivedAddress": "推导地址",
"addressError": "无法从私钥推导地址",
"addressErrorMnemonic": "无法从助记词推导地址"
"addressErrorMnemonic": "无法从助记词推导地址",
"walletType": "钱包类型",
"walletTypeHelp": "Web3钱包:使用 MetaMask 等浏览器钱包连接的 Polymarket 账户\nMagic:通过邮箱或社交账号(如 Google、Twitter)登录的 Polymarket 账户",
"walletTypeMagic": "Magic(邮箱/社交账号登录)",
"walletTypeSafe": "Web3钱包",
"magicNotSupported": "(暂不支持)"
},
"leader": {
"title": "Leader 管理",
@@ -563,6 +571,8 @@
"deleteConfirmDesc": "删除后无法恢复,请确保没有跟单关系在使用该模板",
"copySuccess": "复制模板成功",
"copyFailed": "复制模板失败",
"pushFilteredOrders": "推送已过滤订单",
"pushFilteredOrdersTooltip": "开启后,被过滤的订单会推送到 Telegram",
"minAmountError": "最小金额必须 >= 1",
"fixedAmountRequired": "请输入固定跟单金额",
"invalidNumber": "请输入有效的数字",
@@ -615,6 +625,8 @@
"maxPricePlaceholder": "最高价(留空不限制)",
"supportSell": "跟单卖出",
"supportSellTooltip": "是否跟单 Leader 的卖出订单。开启:跟单 Leader 的买入和卖出订单;关闭:只跟单 Leader 的买入订单,忽略卖出订单。",
"pushFilteredOrders": "推送已过滤订单",
"pushFilteredOrdersTooltip": "开启后,被过滤的订单会推送到 Telegram",
"create": "创建模板",
"createSuccess": "创建模板成功",
"createFailed": "创建模板失败",
@@ -679,6 +691,8 @@
"maxPositionCountPlaceholder": "例如:10(可选,不填写表示不启用)",
"supportSell": "跟单卖出",
"supportSellTooltip": "是否跟单 Leader 的卖出订单。开启:跟单 Leader 的买入和卖出订单;关闭:只跟单 Leader 的买入订单,忽略卖出订单。",
"pushFilteredOrders": "推送已过滤订单",
"pushFilteredOrdersTooltip": "开启后,被过滤的订单会推送到 Telegram",
"invalidNumber": "请输入有效的数字"
},
"copyTradingAdd": {
@@ -691,6 +705,8 @@
"advancedSettings": "高级设置",
"pushFailedOrders": "推送失败订单",
"pushFailedOrdersTooltip": "开启后,失败的订单会推送到 Telegram",
"pushFilteredOrders": "推送已过滤订单",
"pushFilteredOrdersTooltip": "开启后,被过滤的订单会推送到 Telegram",
"autoRedeem": "自动赎回",
"autoRedeemTooltip": "开启后,系统会自动赎回可赎回的仓位。需要配置 Builder API Key 才能生效",
"builderApiKeyNotConfigured": "Builder API Key 未配置",
@@ -761,6 +777,25 @@
"maxPositionCount": "最大仓位数量",
"maxPositionCountTooltip": "限制单个市场的最大仓位数量。如果该市场的当前仓位数量达到或超过此限制,则不会下单。不填写则不启用此限制",
"maxPositionCountPlaceholder": "例如:10(可选,不填写表示不启用)",
"keywordFilter": "关键字过滤",
"keywordFilterMode": "过滤模式",
"keywordFilterModeTooltip": "选择关键字过滤模式。白名单:只跟单包含关键字的市场;黑名单:不跟单包含关键字的市场;不启用:不进行关键字过滤。关键字匹配不区分大小写。",
"disabled": "不启用",
"whitelist": "白名单",
"blacklist": "黑名单",
"keywords": "关键字",
"keywordPlaceholder": "输入关键字,按回车添加",
"keywordExists": "关键字已存在",
"whitelistTooltip": "💡 白名单模式:只跟单包含上述任意关键字的市场标题(不区分大小写)",
"blacklistTooltip": "💡 黑名单模式:不跟单包含上述任意关键字的市场标题(不区分大小写)",
"marketEndDateFilter": "市场截止时间限制",
"maxMarketEndDate": "最大市场截止时间",
"maxMarketEndDateTooltip": "仅跟单截止时间小于设定时间的订单。例如:24 小时表示只跟单距离结算还剩24小时以内的市场",
"maxMarketEndDatePlaceholder": "输入时间值(可选)",
"timeUnit": "单位",
"hour": "小时",
"day": "天",
"maxMarketEndDateNote": "💡 说明:不填写表示不启用此限制",
"supportSell": "跟单卖出",
"supportSellTooltip": "是否跟单 Leader 的卖出订单",
"create": "创建跟单配置",
@@ -785,6 +820,8 @@
"advancedSettings": "高级设置",
"pushFailedOrders": "推送失败订单",
"pushFailedOrdersTooltip": "开启后,失败的订单会推送到 Telegram",
"pushFilteredOrders": "推送已过滤订单",
"pushFilteredOrdersTooltip": "开启后,被过滤的订单会推送到 Telegram",
"autoRedeem": "自动赎回",
"autoRedeemTooltip": "开启后,系统会自动赎回可赎回的仓位。需要配置 Builder API Key 才能生效",
"builderApiKeyNotConfigured": "Builder API Key 未配置",
@@ -847,6 +884,25 @@
"maxPositionCount": "最大仓位数量",
"maxPositionCountTooltip": "限制单个市场的最大仓位数量。如果该市场的当前仓位数量达到或超过此限制,则不会下单。不填写则不启用此限制",
"maxPositionCountPlaceholder": "例如:10(可选,不填写表示不启用)",
"keywordFilter": "关键字过滤",
"keywordFilterMode": "过滤模式",
"keywordFilterModeTooltip": "选择关键字过滤模式。白名单:只跟单包含关键字的市场;黑名单:不跟单包含关键字的市场;不启用:不进行关键字过滤。关键字匹配不区分大小写。",
"disabled": "不启用",
"whitelist": "白名单",
"blacklist": "黑名单",
"keywords": "关键字",
"keywordPlaceholder": "输入关键字,按回车添加",
"keywordExists": "关键字已存在",
"whitelistTooltip": "💡 白名单模式:只跟单包含上述任意关键字的市场标题(不区分大小写)",
"blacklistTooltip": "💡 黑名单模式:不跟单包含上述任意关键字的市场标题(不区分大小写)",
"marketEndDateFilter": "市场截止时间限制",
"maxMarketEndDate": "最大市场截止时间",
"maxMarketEndDateTooltip": "仅跟单截止时间小于设定时间的订单。例如:24 小时表示只跟单距离结算还剩24小时以内的市场",
"maxMarketEndDatePlaceholder": "输入时间值(可选)",
"timeUnit": "单位",
"hour": "小时",
"day": "天",
"maxMarketEndDateNote": "💡 说明:不填写表示不启用此限制",
"supportSell": "跟单卖出",
"supportSellTooltip": "是否跟单 Leader 的卖出订单",
"save": "保存",
@@ -879,6 +935,7 @@
"priceRange": "价格区间不符",
"maxPositionValue": "超过最大仓位金额",
"maxPositionCount": "超过最大仓位数量",
"marketEndDate": "市场截止时间超出限制",
"unknown": "未知原因"
},
"noData": "暂无已过滤订单"
@@ -1022,7 +1079,7 @@
"remainingQuantity": "剩余",
"sellStatus": "卖出状态",
"status": "状态",
"statusFilled": "已完成",
"statusFilled": "未成交",
"statusPartiallySold": "部分成交",
"statusFullySold": "全部成交",
"realizedPnl": "已实现盈亏",
@@ -1042,6 +1099,7 @@
"price": "价格",
"amount": "金额",
"filterMarketId": "筛选市场ID",
"filterMarketTitle": "筛选市场标题",
"filterSide": "筛选方向",
"filterStatus": "筛选状态",
"filterSellOrderId": "筛选卖出订单ID",
@@ -1049,6 +1107,15 @@
"noBuyOrders": "暂无买入订单",
"noSellOrders": "暂无卖出订单",
"noMatchedOrders": "暂无匹配关系",
"groupByMarket": "按市场分组",
"expandAll": "展开全部",
"collapseAll": "折叠全部",
"allFullyMatched": "全部成交",
"partiallyMatched": "部分成交",
"orderCount": "订单数",
"totalAmount": "总金额",
"statusBreakdown": "状态",
"markets": "个市场",
"fetchBuyOrdersFailed": "获取买入订单列表失败",
"fetchSellOrdersFailed": "获取卖出订单列表失败",
"fetchMatchedOrdersFailed": "获取匹配关系列表失败",
+69 -2
View File
@@ -30,7 +30,10 @@
"total": "共",
"items": "條",
"prev": "上一頁",
"next": "一頁"
"previous": "一頁",
"next": "下一頁",
"page": "頁",
"pageOf": "第"
},
"account": {
"title": "賬戶管理",
@@ -199,7 +202,12 @@
"importFailed": "導入賬戶失敗",
"derivedAddress": "推導地址",
"addressError": "無法從私鑰推導地址",
"addressErrorMnemonic": "無法從助記詞推導地址"
"addressErrorMnemonic": "無法從助記詞推導地址",
"walletType": "錢包類型",
"walletTypeHelp": "Web3錢包:使用 MetaMask 等瀏覽器錢包連接的 Polymarket 帳戶\nMagic:透過郵箱或社群帳號(如 Google、Twitter)登入的 Polymarket 帳戶",
"walletTypeMagic": "Magic(郵箱/社群帳號登入)",
"walletTypeSafe": "Web3錢包",
"magicNotSupported": "(暫不支持)"
},
"leader": {
"title": "Leader 管理",
@@ -563,6 +571,8 @@
"deleteConfirmDesc": "刪除後無法恢復,請確保沒有跟單關係在使用該模板",
"copySuccess": "複製模板成功",
"copyFailed": "複製模板失敗",
"pushFilteredOrders": "推送已過濾訂單",
"pushFilteredOrdersTooltip": "開啟後,被過濾的訂單會推送到 Telegram",
"minAmountError": "最小金額必須 >= 1",
"fixedAmountRequired": "請輸入固定跟單金額",
"invalidNumber": "請輸入有效的數字",
@@ -615,6 +625,8 @@
"maxPricePlaceholder": "最高價(留空不限制)",
"supportSell": "跟單賣出",
"supportSellTooltip": "是否跟單 Leader 的賣出訂單。開啟:跟單 Leader 的買入和賣出訂單;關閉:只跟單 Leader 的買入訂單,忽略賣出訂單。",
"pushFilteredOrders": "推送已過濾訂單",
"pushFilteredOrdersTooltip": "開啟後,被過濾的訂單會推送到 Telegram",
"create": "創建模板",
"createSuccess": "創建模板成功",
"createFailed": "創建模板失敗",
@@ -679,6 +691,8 @@
"maxPositionCountPlaceholder": "例如:10(可選,不填寫表示不啟用)",
"supportSell": "跟單賣出",
"supportSellTooltip": "是否跟單 Leader 的賣出訂單。開啟:跟單 Leader 的買入和賣出訂單;關閉:只跟單 Leader 的買入訂單,忽略賣出訂單。",
"pushFilteredOrders": "推送已過濾訂單",
"pushFilteredOrdersTooltip": "開啟後,被過濾的訂單會推送到 Telegram",
"invalidNumber": "請輸入有效的數字"
},
"copyTradingAdd": {
@@ -749,6 +763,25 @@
"maxPositionCount": "最大倉位數量",
"maxPositionCountTooltip": "限制單個市場的最大倉位數量。如果該市場的當前倉位數量達到或超過此限制,則不會下單。不填寫則不啟用此限制",
"maxPositionCountPlaceholder": "例如:10(可選,不填寫表示不啟用)",
"keywordFilter": "關鍵字過濾",
"keywordFilterMode": "過濾模式",
"keywordFilterModeTooltip": "選擇關鍵字過濾模式。白名單:只跟單包含關鍵字的市場;黑名單:不跟單包含關鍵字的市場;不啟用:不進行關鍵字過濾。關鍵字匹配不區分大小寫。",
"disabled": "不啟用",
"whitelist": "白名單",
"blacklist": "黑名單",
"keywords": "關鍵字",
"keywordPlaceholder": "輸入關鍵字,按回車添加",
"keywordExists": "關鍵字已存在",
"whitelistTooltip": "💡 白名單模式:只跟單包含上述任意關鍵字的市場標題(不區分大小寫)",
"blacklistTooltip": "💡 黑名單模式:不跟單包含上述任意關鍵字的市場標題(不區分大小寫)",
"marketEndDateFilter": "市場截止時間限制",
"maxMarketEndDate": "最大市場截止時間",
"maxMarketEndDateTooltip": "僅跟單截止時間小於設定時間的訂單。例如:24 小時表示只跟單距離結算還剩24小時內的市場",
"maxMarketEndDatePlaceholder": "輸入時間值(可選)",
"timeUnit": "單位",
"hour": "小時",
"day": "天",
"maxMarketEndDateNote": "💡 說明:不填寫表示不啟用此限制",
"configName": "配置名",
"configNameRequired": "請輸入配置名",
"configNamePlaceholder": "例如:跟單配置1",
@@ -756,6 +789,8 @@
"advancedSettings": "高級設置",
"pushFailedOrders": "推送失敗訂單",
"pushFailedOrdersTooltip": "開啟後,失敗的訂單會推送到 Telegram",
"pushFilteredOrders": "推送已過濾訂單",
"pushFilteredOrdersTooltip": "開啟後,被過濾的訂單會推送到 Telegram",
"autoRedeem": "自動贖回",
"autoRedeemTooltip": "開啟後,系統會自動贖回可贖回的倉位。需要配置 Builder API Key 才能生效",
"builderApiKeyNotConfigured": "Builder API Key 未配置",
@@ -835,6 +870,25 @@
"maxPositionCount": "最大倉位數量",
"maxPositionCountTooltip": "限制單個市場的最大倉位數量。如果該市場的當前倉位數量達到或超過此限制,則不會下單。不填寫則不啟用此限制",
"maxPositionCountPlaceholder": "例如:10(可選,不填寫表示不啟用)",
"keywordFilter": "關鍵字過濾",
"keywordFilterMode": "過濾模式",
"keywordFilterModeTooltip": "選擇關鍵字過濾模式。白名單:只跟單包含關鍵字的市場;黑名單:不跟單包含關鍵字的市場;不啟用:不進行關鍵字過濾。關鍵字匹配不區分大小寫。",
"disabled": "不啟用",
"whitelist": "白名單",
"blacklist": "黑名單",
"keywords": "關鍵字",
"keywordPlaceholder": "輸入關鍵字,按回車添加",
"keywordExists": "關鍵字已存在",
"whitelistTooltip": "💡 白名單模式:只跟單包含上述任意關鍵字的市場標題(不區分大小寫)",
"blacklistTooltip": "💡 黑名單模式:不跟單包含上述任意關鍵字的市場標題(不區分大小寫)",
"marketEndDateFilter": "市場截止時間限制",
"maxMarketEndDate": "最大市場截止時間",
"maxMarketEndDateTooltip": "僅跟單截止時間小於設定時間的訂單。例如:24 小時表示只跟單距離結算還剩24小時內的市場",
"maxMarketEndDatePlaceholder": "輸入時間值(可選)",
"timeUnit": "單位",
"hour": "小時",
"day": "天",
"maxMarketEndDateNote": "💡 說明:不填寫表示不啟用此限制",
"configName": "配置名",
"configNameRequired": "請輸入配置名",
"configNamePlaceholder": "例如:跟單配置1",
@@ -842,6 +896,8 @@
"advancedSettings": "高級設置",
"pushFailedOrders": "推送失敗訂單",
"pushFailedOrdersTooltip": "開啟後,失敗的訂單會推送到 Telegram",
"pushFilteredOrders": "推送已過濾訂單",
"pushFilteredOrdersTooltip": "開啟後,被過濾的訂單會推送到 Telegram",
"autoRedeem": "自動贖回",
"autoRedeemTooltip": "開啟後,系統會自動贖回可贖回的倉位。需要配置 Builder API Key 才能生效",
"builderApiKeyNotConfigured": "Builder API Key 未配置",
@@ -879,6 +935,7 @@
"priceRange": "價格區間不符",
"maxPositionValue": "超過最大倉位金額",
"maxPositionCount": "超過最大倉位數量",
"marketEndDate": "市場截止時間超出限制",
"unknown": "未知原因"
},
"noData": "暫無已過濾訂單"
@@ -1049,6 +1106,16 @@
"noBuyOrders": "暫無買入訂單",
"noSellOrders": "暫無賣出訂單",
"noMatchedOrders": "暫無匹配關係",
"groupByMarket": "按市場分組",
"expandAll": "展開全部",
"collapseAll": "折疊全部",
"allFullyMatched": "全部成交",
"partiallyMatched": "部分成交",
"orderCount": "訂單數",
"totalAmount": "總金額",
"totalPnl": "總盈虧",
"statusBreakdown": "狀態",
"markets": "個市場",
"fetchBuyOrdersFailed": "獲取買入訂單列表失敗",
"fetchSellOrdersFailed": "獲取賣出訂單列表失敗",
"fetchMatchedOrdersFailed": "獲取匹配關係列表失敗",
+39 -3
View File
@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, Table, Button, Space, Tag, Popconfirm, message, Typography, Spin, Modal, Descriptions, Divider, Form, Input, Alert } from 'antd'
import { PlusOutlined, ReloadOutlined, EditOutlined, CopyOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
@@ -7,12 +6,12 @@ import { useAccountStore } from '../store/accountStore'
import type { Account } from '../types'
import { useMediaQuery } from 'react-responsive'
import { formatUSDC } from '../utils'
import AccountImportForm from '../components/AccountImportForm'
const { Title } = Typography
const AccountList: React.FC = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const isMobile = useMediaQuery({ maxWidth: 768 })
const { accounts, loading, fetchAccounts, deleteAccount, fetchAccountBalance, fetchAccountDetail, updateAccount } = useAccountStore()
const [balanceMap, setBalanceMap] = useState<Record<number, { total: string; available: string; position: string }>>({})
@@ -25,11 +24,20 @@ const AccountList: React.FC = () => {
const [editAccount, setEditAccount] = useState<Account | null>(null)
const [editForm] = Form.useForm()
const [editLoading, setEditLoading] = useState(false)
const [accountImportModalVisible, setAccountImportModalVisible] = useState(false)
const [accountImportForm] = Form.useForm()
useEffect(() => {
fetchAccounts()
}, [fetchAccounts])
const handleAccountImportSuccess = async () => {
message.success(t('accountImport.importSuccess'))
setAccountImportModalVisible(false)
accountImportForm.resetFields()
fetchAccounts()
}
// 加载所有账户的余额
useEffect(() => {
const loadBalances = async () => {
@@ -494,7 +502,7 @@ const AccountList: React.FC = () => {
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => navigate('/accounts/import')}
onClick={() => setAccountImportModalVisible(true)}
size={isMobile ? 'middle' : 'large'}
block={isMobile}
style={isMobile ? { minHeight: '44px' } : undefined}
@@ -836,6 +844,34 @@ const AccountList: React.FC = () => {
</div>
)}
</Modal>
{/* 导入账户 Modal */}
<Modal
title={t('accountImport.title')}
open={accountImportModalVisible}
onCancel={() => {
setAccountImportModalVisible(false)
accountImportForm.resetFields()
}}
footer={null}
width={isMobile ? '95%' : 600}
style={{ top: isMobile ? 20 : 50 }}
bodyStyle={{ padding: '24px', maxHeight: 'calc(100vh - 150px)', overflow: 'auto' }}
destroyOnClose
maskClosable
closable
>
<AccountImportForm
form={accountImportForm}
onSuccess={handleAccountImportSuccess}
onCancel={() => {
setAccountImportModalVisible(false)
accountImportForm.resetFields()
}}
showAlert={true}
showCancelButton={true}
/>
</Modal>
</div>
)
}
-516
View File
@@ -1,516 +0,0 @@
import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Card, Form, Button, Switch, message, Typography, Space, Radio, InputNumber, Divider, Spin, Select, Input } from 'antd'
import { ArrowLeftOutlined, SaveOutlined } from '@ant-design/icons'
import { apiService } from '../services/api'
import type { CopyTrading, CopyTradingUpdateRequest } from '../types'
import { useTranslation } from 'react-i18next'
const { Title } = Typography
const { Option } = Select
const CopyTradingEdit: React.FC = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const { id } = useParams<{ id: string }>()
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
const [fetching, setFetching] = useState(true)
const [copyTrading, setCopyTrading] = useState<CopyTrading | null>(null)
const [copyMode, setCopyMode] = useState<'RATIO' | 'FIXED'>('RATIO')
const [originalEnabled, setOriginalEnabled] = useState<boolean>(true)
useEffect(() => {
if (id) {
fetchCopyTrading(parseInt(id))
}
}, [id])
const fetchCopyTrading = async (copyTradingId: number) => {
setFetching(true)
try {
const response = await apiService.copyTrading.list({})
if (response.data.code === 0 && response.data.data) {
const found = response.data.data.list.find((ct: CopyTrading) => ct.id === copyTradingId)
if (found) {
setCopyTrading(found)
setCopyMode(found.copyMode)
setOriginalEnabled(found.enabled) // 保存原始的enabled状态
// 填充表单数据
form.setFieldsValue({
accountId: found.accountId,
leaderId: found.leaderId,
copyMode: found.copyMode,
copyRatio: found.copyRatio ? parseFloat(found.copyRatio) * 100 : 100, // 转换为百分比显示
fixedAmount: found.fixedAmount ? parseFloat(found.fixedAmount) : undefined,
maxOrderSize: found.maxOrderSize ? parseFloat(found.maxOrderSize) : undefined,
minOrderSize: found.minOrderSize ? parseFloat(found.minOrderSize) : undefined,
maxDailyLoss: found.maxDailyLoss ? parseFloat(found.maxDailyLoss) : undefined,
maxDailyOrders: found.maxDailyOrders,
priceTolerance: found.priceTolerance ? parseFloat(found.priceTolerance) : undefined,
delaySeconds: found.delaySeconds,
pollIntervalSeconds: found.pollIntervalSeconds,
useWebSocket: found.useWebSocket,
websocketReconnectInterval: found.websocketReconnectInterval,
websocketMaxRetries: found.websocketMaxRetries,
supportSell: found.supportSell,
minOrderDepth: found.minOrderDepth ? parseFloat(found.minOrderDepth) : undefined,
maxSpread: found.maxSpread ? parseFloat(found.maxSpread) : undefined,
minPrice: found.minPrice ? parseFloat(found.minPrice) : undefined,
maxPrice: found.maxPrice ? parseFloat(found.maxPrice) : undefined,
maxPositionValue: found.maxPositionValue ? parseFloat(found.maxPositionValue) : undefined,
maxPositionCount: found.maxPositionCount,
configName: found.configName || '',
pushFailedOrders: found.pushFailedOrders ?? false
})
} else {
message.error(t('copyTradingEdit.fetchFailed') || '跟单配置不存在')
navigate('/copy-trading')
}
} else {
message.error(response.data.msg || t('copyTradingEdit.fetchFailed') || '获取跟单配置失败')
navigate('/copy-trading')
}
} catch (error: any) {
message.error(error.message || t('copyTradingEdit.fetchFailed') || '获取跟单配置失败')
navigate('/copy-trading')
} finally {
setFetching(false)
}
}
const handleCopyModeChange = (mode: 'RATIO' | 'FIXED') => {
setCopyMode(mode)
}
const handleSubmit = async (values: any) => {
// 前端校验
if (values.copyMode === 'FIXED') {
if (!values.fixedAmount || Number(values.fixedAmount) < 1) {
message.error('固定金额必须 >= 1')
return
}
}
if (values.copyMode === 'RATIO' && values.minOrderSize !== undefined && values.minOrderSize !== null && Number(values.minOrderSize) < 1) {
message.error('最小金额必须 >= 1')
return
}
if (!id) {
message.error('配置ID不存在')
return
}
setLoading(true)
try {
const request: CopyTradingUpdateRequest = {
copyTradingId: parseInt(id),
enabled: originalEnabled, // 保持原有的enabled状态,不修改
copyMode: values.copyMode,
copyRatio: values.copyMode === 'RATIO' && values.copyRatio ? (values.copyRatio / 100).toString() : undefined,
fixedAmount: values.copyMode === 'FIXED' ? values.fixedAmount?.toString() : undefined,
maxOrderSize: values.maxOrderSize?.toString(),
minOrderSize: values.minOrderSize?.toString(),
maxDailyLoss: values.maxDailyLoss?.toString(),
maxDailyOrders: values.maxDailyOrders,
priceTolerance: values.priceTolerance?.toString(),
delaySeconds: values.delaySeconds,
pollIntervalSeconds: values.pollIntervalSeconds,
useWebSocket: values.useWebSocket,
websocketReconnectInterval: values.websocketReconnectInterval,
websocketMaxRetries: values.websocketMaxRetries,
supportSell: values.supportSell,
minOrderDepth: values.minOrderDepth?.toString(),
maxSpread: values.maxSpread?.toString(),
minPrice: values.minPrice?.toString(),
maxPrice: values.maxPrice?.toString(),
maxPositionValue: values.maxPositionValue?.toString(),
maxPositionCount: values.maxPositionCount,
configName: values.configName?.trim() || undefined,
pushFailedOrders: values.pushFailedOrders
}
const response = await apiService.copyTrading.update(request)
if (response.data.code === 0) {
message.success(t('copyTradingEdit.saveSuccess') || '更新跟单配置成功')
navigate('/copy-trading')
} else {
message.error(response.data.msg || t('copyTradingEdit.saveFailed') || '更新跟单配置失败')
}
} catch (error: any) {
message.error(error.message || t('copyTradingEdit.saveFailed') || '更新跟单配置失败')
} finally {
setLoading(false)
}
}
if (fetching) {
return (
<div style={{ textAlign: 'center', padding: '40px' }}>
<Spin size="large" />
</div>
)
}
if (!copyTrading) {
return null
}
return (
<div>
<div style={{ marginBottom: 16 }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/copy-trading')}
>
{t('common.back') || '返回'}
</Button>
</div>
<Card>
<Title level={4}>{t('copyTradingEdit.title') || '编辑跟单配置'}</Title>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
>
{/* 基础信息(只读) */}
<Form.Item
label={t('copyTradingEdit.configName') || '配置名'}
name="configName"
rules={[
{ required: true, message: t('copyTradingEdit.configNameRequired') || '请输入配置名' },
{ whitespace: true, message: t('copyTradingEdit.configNameRequired') || '配置名不能为空' }
]}
tooltip={t('copyTradingEdit.configNameTooltip') || '为跟单配置设置一个名称,便于识别和管理'}
>
<Input
placeholder={t('copyTradingEdit.configNamePlaceholder') || '例如:跟单配置1'}
maxLength={255}
/>
</Form.Item>
<Form.Item
label={t('copyTradingAdd.selectWallet') || t('copyTradingEdit.selectWallet') || '钱包'}
name="accountId"
>
<Select disabled>
<Option value={copyTrading.accountId}>
{copyTrading.accountName || `账户 ${copyTrading.accountId}`} ({copyTrading.walletAddress.slice(0, 6)}...{copyTrading.walletAddress.slice(-4)})
</Option>
</Select>
</Form.Item>
<Form.Item
label={t('copyTradingAdd.selectLeader') || t('copyTradingEdit.selectLeader') || 'Leader'}
name="leaderId"
>
<Select disabled>
<Option value={copyTrading.leaderId}>
{copyTrading.leaderName || `Leader ${copyTrading.leaderId}`} ({copyTrading.leaderAddress.slice(0, 6)}...{copyTrading.leaderAddress.slice(-4)})
</Option>
</Select>
</Form.Item>
<Divider>{t('copyTradingEdit.basicConfig') || '基础配置'}</Divider>
{/* 跟单金额模式 */}
<Form.Item
label={t('copyTradingEdit.copyMode') || '跟单金额模式'}
name="copyMode"
tooltip={t('copyTradingEdit.copyModeTooltip') || '选择跟单金额的计算方式'}
rules={[{ required: true }]}
>
<Radio.Group onChange={(e) => handleCopyModeChange(e.target.value)}>
<Radio value="RATIO">{t('copyTradingEdit.ratioMode') || '比例模式'}</Radio>
<Radio value="FIXED">{t('copyTradingEdit.fixedAmountMode') || '固定金额模式'}</Radio>
</Radio.Group>
</Form.Item>
{copyMode === 'RATIO' && (
<Form.Item
label={t('copyTradingEdit.copyRatio') || '跟单比例'}
name="copyRatio"
tooltip={t('copyTradingEdit.copyRatioTooltip') || '跟单比例表示跟单金额相对于 Leader 订单金额的百分比'}
>
<InputNumber
min={10}
max={1000}
step={1}
precision={0}
style={{ width: '100%' }}
addonAfter="%"
placeholder={t('copyTradingEdit.copyRatioPlaceholder') || '例如:100 表示 100%1:1 跟单)'}
/>
</Form.Item>
)}
{copyMode === 'FIXED' && (
<Form.Item
label={t('copyTradingEdit.fixedAmount') || '固定跟单金额 (USDC)'}
name="fixedAmount"
rules={[
{ required: true, message: t('copyTradingEdit.fixedAmountRequired') || '请输入固定跟单金额' },
{
validator: (_, value) => {
if (value !== undefined && value !== null && value !== '') {
const amount = Number(value)
if (isNaN(amount)) {
return Promise.reject(new Error(t('copyTradingEdit.invalidNumber') || '请输入有效的数字'))
}
if (amount < 1) {
return Promise.reject(new Error(t('copyTradingEdit.fixedAmountMin') || '固定金额必须 >= 1'))
}
}
return Promise.resolve()
}
}
]}
>
<InputNumber
min={1}
step={0.0001}
precision={4}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.fixedAmountPlaceholder') || '固定金额,不随 Leader 订单大小变化,必须 >= 1'}
/>
</Form.Item>
)}
{copyMode === 'RATIO' && (
<>
<Form.Item
label={t('copyTradingEdit.maxOrderSize') || '单笔订单最大金额 (USDC)'}
name="maxOrderSize"
tooltip={t('copyTradingEdit.maxOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最大金额上限'}
>
<InputNumber
min={0.0001}
step={0.0001}
precision={4}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.maxOrderSizePlaceholder') || '仅在比例模式下生效(可选)'}
/>
</Form.Item>
<Form.Item
label={t('copyTradingEdit.minOrderSize') || '单笔订单最小金额 (USDC)'}
name="minOrderSize"
tooltip={t('copyTradingEdit.minOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最小金额下限,必须 >= 1'}
rules={[
{
validator: (_, value) => {
if (value === undefined || value === null || value === '') {
return Promise.resolve()
}
if (typeof value === 'number' && value < 1) {
return Promise.reject(new Error(t('copyTradingEdit.minOrderSizeMin') || '最小金额必须 >= 1'))
}
return Promise.resolve()
}
}
]}
>
<InputNumber
min={1}
step={0.0001}
precision={4}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.minOrderSizePlaceholder') || '仅在比例模式下生效,必须 >= 1(可选)'}
/>
</Form.Item>
</>
)}
<Form.Item
label={t('copyTradingEdit.maxDailyLoss') || '每日最大亏损限制 (USDC)'}
name="maxDailyLoss"
tooltip={t('copyTradingEdit.maxDailyLossTooltip') || '限制每日最大亏损金额,用于风险控制'}
>
<InputNumber
min={0}
step={0.0001}
precision={4}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.maxDailyLossPlaceholder') || '默认 10000 USDC(可选)'}
/>
</Form.Item>
<Form.Item
label={t('copyTradingEdit.maxDailyOrders') || '每日最大跟单订单数'}
name="maxDailyOrders"
tooltip={t('copyTradingEdit.maxDailyOrdersTooltip') || '限制每日最多跟单的订单数量'}
>
<InputNumber
min={1}
step={1}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.maxDailyOrdersPlaceholder') || '默认 100(可选)'}
/>
</Form.Item>
<Form.Item
label={t('copyTradingEdit.priceTolerance') || '价格容忍度 (%)'}
name="priceTolerance"
tooltip={t('copyTradingEdit.priceToleranceTooltip') || '允许跟单价格在 Leader 价格基础上的调整范围'}
>
<InputNumber
min={0}
max={100}
step={0.1}
precision={2}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.priceTolerancePlaceholder') || '默认 5%(可选)'}
/>
</Form.Item>
<Form.Item
label={t('copyTradingEdit.delaySeconds') || '跟单延迟 (秒)'}
name="delaySeconds"
tooltip={t('copyTradingEdit.delaySecondsTooltip') || '跟单延迟时间,0 表示立即跟单'}
>
<InputNumber
min={0}
step={1}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.delaySecondsPlaceholder') || '默认 0(立即跟单)'}
/>
</Form.Item>
<Form.Item
label={t('copyTradingEdit.minOrderDepth') || '最小订单深度 (USDC)'}
name="minOrderDepth"
tooltip={t('copyTradingEdit.minOrderDepthTooltip') || '检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤'}
>
<InputNumber
min={0}
step={0.0001}
precision={4}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.minOrderDepthPlaceholder') || '例如:100(可选,不填写表示不启用)'}
/>
</Form.Item>
<Form.Item
label={t('copyTradingEdit.maxSpread') || '最大价差(绝对价格)'}
name="maxSpread"
tooltip={t('copyTradingEdit.maxSpreadTooltip') || '最大价差(绝对价格)。避免在价差过大的市场跟单。不填写则不启用此过滤'}
>
<InputNumber
min={0}
step={0.0001}
precision={4}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.maxSpreadPlaceholder') || '例如:0.05(5美分,可选,不填写表示不启用)'}
/>
</Form.Item>
<Divider>{t('copyTradingEdit.priceRangeFilter') || '价格区间过滤'}</Divider>
<Form.Item
label={t('copyTradingEdit.priceRange') || '价格区间'}
name="priceRange"
tooltip={t('copyTradingEdit.priceRangeTooltip') || '配置价格区间,仅在指定价格区间内的订单才会下单。例如:0.11-0.89 表示区间在0.11和0.89之间;-0.89 表示0.89以下都可以;0.11- 表示0.11以上都可以'}
>
<Input.Group compact style={{ display: 'flex' }}>
<Form.Item name="minPrice" noStyle>
<InputNumber
min={0.01}
max={0.99}
step={0.0001}
precision={4}
style={{ width: '50%' }}
placeholder={t('copyTradingEdit.minPricePlaceholder') || '最低价(可选)'}
/>
</Form.Item>
<span style={{ display: 'inline-block', width: '20px', textAlign: 'center', lineHeight: '32px' }}>-</span>
<Form.Item name="maxPrice" noStyle>
<InputNumber
min={0.01}
max={0.99}
step={0.0001}
precision={4}
style={{ width: '50%' }}
placeholder={t('copyTradingEdit.maxPricePlaceholder') || '最高价(可选)'}
/>
</Form.Item>
</Input.Group>
</Form.Item>
<Divider>{t('copyTradingEdit.positionLimitFilter') || '最大仓位限制'}</Divider>
<Form.Item
label={t('copyTradingEdit.maxPositionValue') || '最大仓位金额 (USDC)'}
name="maxPositionValue"
tooltip={t('copyTradingEdit.maxPositionValueTooltip') || '限制单个市场的最大仓位金额。如果该市场的当前仓位金额 + 跟单金额超过此限制,则不会下单。不填写则不启用此限制'}
>
<InputNumber
min={0}
step={0.0001}
precision={4}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.maxPositionValuePlaceholder') || '例如:100(可选,不填写表示不启用)'}
/>
</Form.Item>
<Form.Item
label={t('copyTradingEdit.maxPositionCount') || '最大仓位数量'}
name="maxPositionCount"
tooltip={t('copyTradingEdit.maxPositionCountTooltip') || '限制单个市场的最大仓位数量。如果该市场的当前仓位数量达到或超过此限制,则不会下单。不填写则不启用此限制'}
>
<InputNumber
min={1}
step={1}
style={{ width: '100%' }}
placeholder={t('copyTradingEdit.maxPositionCountPlaceholder') || '例如:10(可选,不填写表示不启用)'}
/>
</Form.Item>
<Divider>{t('copyTradingEdit.advancedSettings') || '高级设置'}</Divider>
{/* 跟单卖出 */}
<Form.Item
label={t('copyTradingEdit.supportSell') || '跟单卖出'}
name="supportSell"
tooltip={t('copyTradingEdit.supportSellTooltip') || '是否跟单 Leader 的卖出订单'}
valuePropName="checked"
>
<Switch />
</Form.Item>
{/* 推送失败订单 */}
<Form.Item
label={t('copyTradingEdit.pushFailedOrders') || '推送失败订单'}
name="pushFailedOrders"
tooltip={t('copyTradingEdit.pushFailedOrdersTooltip') || '开启后,失败的订单会推送到 Telegram'}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item>
<Space>
<Button
type="primary"
htmlType="submit"
icon={<SaveOutlined />}
loading={loading}
>
{t('copyTradingEdit.save') || '保存'}
</Button>
<Button onClick={() => navigate('/copy-trading')}>
{t('common.cancel') || '取消'}
</Button>
</Space>
</Form.Item>
</Form>
</Card>
</div>
)
}
export default CopyTradingEdit
+12 -5
View File
@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, Table, Button, Space, Tag, Popconfirm, Switch, message, Select, Dropdown, Divider, Spin } from 'antd'
import { PlusOutlined, DeleteOutlined, BarChartOutlined, UnorderedListOutlined, ArrowUpOutlined, ArrowDownOutlined, EditOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
@@ -13,12 +12,12 @@ import CopyTradingOrdersModal from './CopyTradingOrders/index'
import StatisticsModal from './CopyTradingOrders/StatisticsModal'
import FilteredOrdersModal from './CopyTradingOrders/FilteredOrdersModal'
import EditModal from './CopyTradingOrders/EditModal'
import AddModal from './CopyTradingOrders/AddModal'
const { Option } = Select
const CopyTradingList: React.FC = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const isMobile = useMediaQuery({ maxWidth: 768 })
const { accounts, fetchAccounts } = useAccountStore()
const [copyTradings, setCopyTradings] = useState<CopyTrading[]>([])
@@ -42,6 +41,7 @@ const CopyTradingList: React.FC = () => {
const [filteredOrdersModalCopyTradingId, setFilteredOrdersModalCopyTradingId] = useState<string>('')
const [editModalOpen, setEditModalOpen] = useState(false)
const [editModalCopyTradingId, setEditModalCopyTradingId] = useState<string>('')
const [addModalOpen, setAddModalOpen] = useState(false)
useEffect(() => {
fetchAccounts()
@@ -196,7 +196,7 @@ const CopyTradingList: React.FC = () => {
render: (_: any, record: CopyTrading) => (
<Tag color={record.copyMode === 'RATIO' ? 'blue' : 'green'}>
{record.copyMode === 'RATIO'
? `${t('copyTradingList.ratioMode') || '比例'} ${record.copyRatio}x`
? `${t('copyTradingList.ratioMode') || '比例'} ${(parseFloat(record.copyRatio || '0') * 100).toFixed(2).replace(/\.0+$/, '')}%`
: `${t('copyTradingList.fixedAmountMode') || '固定'} ${formatUSDC(record.fixedAmount || '0')}`
}
</Tag>
@@ -369,7 +369,7 @@ const CopyTradingList: React.FC = () => {
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => navigate('/copy-trading/add')}
onClick={() => setAddModalOpen(true)}
>
{t('copyTradingList.addCopyTrading') || '新增跟单'}
</Button>
@@ -466,7 +466,7 @@ const CopyTradingList: React.FC = () => {
color: '#666'
}}>
{record.copyMode === 'RATIO'
? `${t('copyTradingList.ratioMode') || '比例'} ${record.copyRatio}x`
? `${t('copyTradingList.ratioMode') || '比例'} ${(parseFloat(record.copyRatio || '0') * 100).toFixed(2).replace(/\.0+$/, '')}%`
: `${t('copyTradingList.fixedAmountMode') || '固定'} ${formatUSDC(record.fixedAmount || '0')}`
}
</div>
@@ -668,6 +668,13 @@ const CopyTradingList: React.FC = () => {
fetchCopyTradings()
}}
/>
<AddModal
open={addModalOpen}
onClose={() => setAddModalOpen(false)}
onSuccess={() => {
fetchCopyTradings()
}}
/>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More