Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df272156cf | ||
|
|
64f6a2b897 | ||
|
|
f7f2411b9d | ||
|
|
d96eb3e00a | ||
|
|
cc1a732984 | ||
|
|
ec06003157 | ||
|
|
3d05b13298 | ||
|
|
fe2db11b75 | ||
|
|
5c18cbd95d | ||
|
|
8357546f3c | ||
|
|
a62141ea5e | ||
|
|
9157203653 | ||
|
|
23fc20763a | ||
|
|
e96a0b6279 | ||
|
|
85d8619fe7 | ||
|
|
89fb980da7 | ||
|
|
9f0b22fab5 |
@@ -1,133 +0,0 @@
|
|||||||
# 🎉 v1.0.1 版本发布公告
|
|
||||||
|
|
||||||
## 📅 发布日期
|
|
||||||
|
|
||||||
2025年12月(具体日期待定)
|
|
||||||
|
|
||||||
## 🚀 主要更新
|
|
||||||
|
|
||||||
### 性能优化
|
|
||||||
|
|
||||||
#### 📊 订单簿请求优化
|
|
||||||
- 优化订单簿获取逻辑,仅在需要时请求,避免不必要的 API 调用
|
|
||||||
- 当未配置需要订单簿的过滤条件(价差、订单深度)时,跳过订单簿获取
|
|
||||||
- 订单簿只请求一次,在过滤检查中复用,提高性能
|
|
||||||
|
|
||||||
#### 💰 卖出价格计算优化
|
|
||||||
- 卖出改为市价卖出:优先使用订单簿的 bestBid(最高买入价)
|
|
||||||
- 如果 bestBid 获取失败,自动降级使用 Leader 的交易价格
|
|
||||||
- 卖出价格固定按 90% 计算,不再使用价格容忍度配置
|
|
||||||
- 提高卖出订单的成交率
|
|
||||||
|
|
||||||
#### ⚙️ 价格容忍度默认值
|
|
||||||
- 如果价格容忍度配置为 0,自动使用默认值 5%
|
|
||||||
- 确保买入订单能够正常应用价格调整,提高成交率
|
|
||||||
|
|
||||||
### 代码优化
|
|
||||||
|
|
||||||
#### 🎯 使用枚举优化过滤逻辑
|
|
||||||
- 新增 `FilterResult` 数据类和 `FilterStatus` 枚举
|
|
||||||
- 使用类型安全的枚举替代字符串判断,提高代码可维护性
|
|
||||||
- 移除无用字段 `isBuyOrder` 参数
|
|
||||||
- 优化 `checkFilters` 方法返回值,使用数据类封装结果
|
|
||||||
|
|
||||||
#### 📝 代码结构优化
|
|
||||||
- 减少 if-else 嵌套,使用 Kotlin 链式调用和空安全操作符
|
|
||||||
- 优化卖出价格计算逻辑,使用 `runCatching` 和 `?.let` 简化代码
|
|
||||||
- 提取公共方法 `calculateFallbackSellPrice`,提高代码复用性
|
|
||||||
|
|
||||||
### 功能改进
|
|
||||||
|
|
||||||
#### 🗑️ 移除最小订单簿深度功能
|
|
||||||
- 移除 `minOrderbookDepth`(最小订单簿深度)配置项
|
|
||||||
- 简化过滤逻辑,只保留 `minOrderDepth`(最小订单深度)
|
|
||||||
- 更新前端界面,移除相关配置项和提示文案
|
|
||||||
|
|
||||||
#### 📈 优化最小订单深度逻辑
|
|
||||||
- 修改 `minOrderDepth` 检查逻辑,检查所有方向(买盘+卖盘)的总深度
|
|
||||||
- 不再区分买卖方向,提供更全面的市场流动性评估
|
|
||||||
|
|
||||||
#### 🎯 市场结算判断优化
|
|
||||||
- 添加市场已关闭时的结算判断逻辑
|
|
||||||
- 如果市场已关闭且该 outcome 赢了(价格 >= 0.99),返回价格为 1
|
|
||||||
- 如果市场已关闭且该 outcome 输了(价格 <= 0.01),返回价格为 0
|
|
||||||
- 当没有仓位但有未完成订单时,正确判断市场结算状态并设置价格
|
|
||||||
|
|
||||||
### Bug 修复
|
|
||||||
|
|
||||||
- ✅ 修复盈亏和持仓价值计算逻辑未实现的问题
|
|
||||||
- ✅ 修复过滤检查中订单簿可能重复请求的问题
|
|
||||||
- ✅ 修复健康检查路径不一致问题(从 `/api/health` 改为 `/api/system/health`)
|
|
||||||
|
|
||||||
## 📦 如何更新
|
|
||||||
|
|
||||||
### Docker 部署
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 拉取最新镜像
|
|
||||||
docker pull wrbug/polyhermes:v1.0.1
|
|
||||||
|
|
||||||
# 或使用 latest 标签
|
|
||||||
docker pull wrbug/polyhermes:latest
|
|
||||||
|
|
||||||
# 重启容器
|
|
||||||
docker-compose down
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### 源码部署
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 拉取最新代码
|
|
||||||
git fetch origin
|
|
||||||
git checkout v1.0.1
|
|
||||||
|
|
||||||
# 重新构建
|
|
||||||
cd backend
|
|
||||||
./gradlew bootJar
|
|
||||||
|
|
||||||
# 重启服务
|
|
||||||
# 根据您的部署方式重启服务
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📚 文档更新
|
|
||||||
|
|
||||||
- 更新前端多语言文案,优化提示信息
|
|
||||||
- 移除最小订单簿深度相关的文档说明
|
|
||||||
|
|
||||||
## 🔗 相关链接
|
|
||||||
|
|
||||||
- **GitHub Release**: https://github.com/WrBug/PolyHermes/releases/tag/v1.0.1
|
|
||||||
- **完整更新日志**: https://github.com/WrBug/PolyHermes/compare/v1.0.0...v1.0.1
|
|
||||||
- **Docker Hub**: https://hub.docker.com/r/wrbug/polyhermes
|
|
||||||
|
|
||||||
## ⚠️ 重要提醒
|
|
||||||
|
|
||||||
**请务必使用官方 Docker 镜像源,避免财产损失!**
|
|
||||||
|
|
||||||
### ✅ 官方 Docker Hub 镜像
|
|
||||||
|
|
||||||
**官方镜像地址**:`wrbug/polyhermes`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# ✅ 正确:使用官方镜像
|
|
||||||
docker pull wrbug/polyhermes:v1.0.1
|
|
||||||
|
|
||||||
# ❌ 错误:不要使用其他来源的镜像
|
|
||||||
# 任何非官方来源的镜像都可能包含恶意代码,导致您的私钥和资产被盗
|
|
||||||
```
|
|
||||||
|
|
||||||
### 🔗 官方渠道
|
|
||||||
|
|
||||||
请通过以下**唯一官方渠道**获取 PolyHermes:
|
|
||||||
|
|
||||||
* **GitHub 仓库**:https://github.com/WrBug/PolyHermes
|
|
||||||
* **Twitter**:@polyhermes
|
|
||||||
* **Telegram 群组**:加入群组
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**⭐ 如果这个项目对您有帮助,请给个 Star 支持一下!**
|
|
||||||
|
|
||||||
**💬 如有问题或建议,欢迎在 GitHub Issues 中反馈。**
|
|
||||||
|
|
||||||
@@ -1,288 +0,0 @@
|
|||||||
# 🎉 v1.1.0 版本更新指南
|
|
||||||
|
|
||||||
## 📅 发布日期
|
|
||||||
|
|
||||||
2025年12月26日
|
|
||||||
|
|
||||||
## 🎯 这次更新对您意味着什么?
|
|
||||||
|
|
||||||
### ⚡ 更快的跟单响应速度
|
|
||||||
- **实时链上监听**:通过 WebSocket 实时监听链上交易,跟单响应速度提升 50% 以上
|
|
||||||
- **双重保障**:链上监听和轮询同时运行,确保不错过任何交易机会
|
|
||||||
- **自动去重**:系统自动处理重复数据,确保每笔交易只执行一次
|
|
||||||
|
|
||||||
### 💰 更准确的盈亏统计
|
|
||||||
- **实际成交价追踪**:系统会自动更新卖出订单的实际成交价,而不是下单时的价格
|
|
||||||
- **自动价格更新**:每 5 秒自动查询并更新订单的实际成交价
|
|
||||||
- **精确盈亏计算**:基于实际成交价计算盈亏,统计数据更准确
|
|
||||||
|
|
||||||
### 🔧 更灵活的系统配置
|
|
||||||
- **RPC 节点管理**:可以添加、编辑、删除自定义 RPC 节点
|
|
||||||
- **节点启用/禁用**:可以随时启用或禁用节点,无需删除配置
|
|
||||||
- **智能节点选择**:系统自动选择可用的节点,禁用的节点会被忽略
|
|
||||||
|
|
||||||
### 🐛 问题修复
|
|
||||||
- **修复卖出订单失败问题**:修复了部分情况下卖出订单因 API 凭证问题导致的失败
|
|
||||||
- **修复订单精度错误**:修复了卖出订单因精度问题导致的 API 错误
|
|
||||||
|
|
||||||
## 📦 如何更新
|
|
||||||
|
|
||||||
### 方式一:使用 Docker(推荐)
|
|
||||||
|
|
||||||
#### 步骤 1:备份数据(强烈推荐)
|
|
||||||
|
|
||||||
**备份不是必须的,但强烈推荐!**
|
|
||||||
|
|
||||||
**为什么需要备份?**
|
|
||||||
- Docker 更新不会删除数据(数据存储在独立的数据卷中)
|
|
||||||
- 但数据库结构可能会变更(本次更新会添加 `price_updated` 字段)
|
|
||||||
- 如果迁移失败或出现问题,备份可以帮助恢复数据
|
|
||||||
- 生产环境建议必须备份,开发环境可以跳过
|
|
||||||
|
|
||||||
**如何备份?**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 方式 1:使用 mysqldump 备份数据库(推荐)
|
|
||||||
docker exec polyhermes-mysql mysqldump -u root -p polyhermes > backup_$(date +%Y%m%d_%H%M%S).sql
|
|
||||||
|
|
||||||
# 方式 2:备份整个 MySQL 数据卷
|
|
||||||
docker run --rm -v polyhermes_mysql-data:/data -v $(pwd):/backup alpine tar czf /backup/mysql_backup_$(date +%Y%m%d_%H%M%S).tar.gz /data
|
|
||||||
|
|
||||||
# 方式 3:如果已有定期备份,可以跳过此步骤
|
|
||||||
```
|
|
||||||
|
|
||||||
**什么情况下可以跳过备份?**
|
|
||||||
- ✅ 开发环境或测试环境
|
|
||||||
- ✅ 数据不重要或可以重新生成
|
|
||||||
- ✅ 已经有定期自动备份
|
|
||||||
- ✅ 确定迁移不会失败(查看更新日志确认)
|
|
||||||
|
|
||||||
**什么情况下必须备份?**
|
|
||||||
- ⚠️ 生产环境
|
|
||||||
- ⚠️ 包含重要交易数据
|
|
||||||
- ⚠️ 不确定迁移是否安全
|
|
||||||
|
|
||||||
#### 步骤 2:停止当前服务
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 进入部署目录
|
|
||||||
cd /path/to/polyhermes
|
|
||||||
|
|
||||||
# 停止服务
|
|
||||||
docker-compose -f docker-compose.prod.yml down
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 步骤 3:拉取新版本
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 拉取 v1.1.0 版本
|
|
||||||
docker pull wrbug/polyhermes:v1.1.0
|
|
||||||
|
|
||||||
# 或者拉取最新版本(推荐)
|
|
||||||
docker pull wrbug/polyhermes:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 步骤 4:更新配置(如果需要)
|
|
||||||
|
|
||||||
如果您想固定使用 v1.1.0 版本,可以编辑 `docker-compose.prod.yml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
app:
|
|
||||||
image: wrbug/polyhermes:v1.1.0 # 修改这里
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 步骤 5:启动服务
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 启动服务
|
|
||||||
docker-compose -f docker-compose.prod.yml up -d
|
|
||||||
|
|
||||||
# 查看日志,确认服务正常启动
|
|
||||||
docker-compose -f docker-compose.prod.yml logs -f
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 步骤 6:验证更新
|
|
||||||
|
|
||||||
1. 访问系统首页,查看页面标题是否显示 `v1.1.0`
|
|
||||||
2. 检查系统功能是否正常
|
|
||||||
3. 查看日志是否有错误信息
|
|
||||||
|
|
||||||
### 方式二:一键更新脚本
|
|
||||||
|
|
||||||
如果您使用 `latest` 标签,可以使用以下命令一键更新:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 进入部署目录
|
|
||||||
cd /path/to/polyhermes
|
|
||||||
|
|
||||||
# 拉取最新镜像并重启
|
|
||||||
docker-compose -f docker-compose.prod.yml pull
|
|
||||||
docker-compose -f docker-compose.prod.yml up -d
|
|
||||||
|
|
||||||
# 查看日志
|
|
||||||
docker-compose -f docker-compose.prod.yml logs -f
|
|
||||||
```
|
|
||||||
|
|
||||||
## ⚠️ 更新注意事项
|
|
||||||
|
|
||||||
### 1. 数据库迁移
|
|
||||||
- 本次更新会自动执行数据库迁移(添加 `price_updated` 字段)
|
|
||||||
- 迁移过程不会影响现有数据,只是添加新字段
|
|
||||||
- 迁移是安全的,但如果迁移失败,请检查数据库权限和连接
|
|
||||||
- **如果迁移失败,可以使用备份恢复数据**
|
|
||||||
|
|
||||||
### 2. 服务中断时间
|
|
||||||
- 更新过程中服务会短暂中断(通常 1-2 分钟)
|
|
||||||
- 建议在低峰期进行更新
|
|
||||||
- 更新期间正在进行的跟单操作可能会受影响
|
|
||||||
|
|
||||||
### 3. 配置检查
|
|
||||||
- 更新后请检查 RPC 节点配置是否正确
|
|
||||||
- 如果使用自定义 RPC 节点,请确认节点状态正常
|
|
||||||
|
|
||||||
### 4. 功能验证
|
|
||||||
更新后建议验证以下功能:
|
|
||||||
- ✅ 跟单功能是否正常
|
|
||||||
- ✅ 订单价格更新是否正常
|
|
||||||
- ✅ RPC 节点管理是否正常
|
|
||||||
- ✅ 盈亏统计是否准确
|
|
||||||
|
|
||||||
## 🆕 新功能使用指南
|
|
||||||
|
|
||||||
### 1. RPC 节点管理
|
|
||||||
|
|
||||||
#### 如何添加自定义 RPC 节点?
|
|
||||||
|
|
||||||
1. 登录系统,进入 **系统设置** → **RPC 节点设置**
|
|
||||||
2. 点击 **添加节点** 按钮
|
|
||||||
3. 填写节点信息:
|
|
||||||
- **节点名称**:给节点起个名字(如:Alchemy Polygon)
|
|
||||||
- **RPC URL**:节点的 HTTP/HTTPS 地址
|
|
||||||
- **启用状态**:默认启用,可以稍后禁用
|
|
||||||
4. 点击 **保存**
|
|
||||||
|
|
||||||
#### 如何启用/禁用节点?
|
|
||||||
|
|
||||||
1. 在 RPC 节点列表中,找到要操作的节点
|
|
||||||
2. 点击 **启用/禁用** 开关
|
|
||||||
3. 系统会自动更新节点状态
|
|
||||||
|
|
||||||
**提示**:
|
|
||||||
- 禁用的节点不会被使用,但配置会保留
|
|
||||||
- 可以随时重新启用禁用的节点
|
|
||||||
- 建议至少保留一个启用的节点
|
|
||||||
|
|
||||||
### 2. 实际成交价追踪
|
|
||||||
|
|
||||||
#### 这个功能做什么?
|
|
||||||
|
|
||||||
系统会自动追踪卖出订单的实际成交价,而不是使用下单时的价格。这样可以:
|
|
||||||
- 更准确地计算盈亏
|
|
||||||
- 了解订单的实际执行情况
|
|
||||||
- 优化卖出策略
|
|
||||||
|
|
||||||
#### 如何查看实际成交价?
|
|
||||||
|
|
||||||
1. 进入 **跟单统计** → **卖出订单**
|
|
||||||
2. 查看订单列表,**卖出价格** 列显示的是实际成交价
|
|
||||||
3. 如果价格旁边有更新标记,说明价格已从订单详情中获取
|
|
||||||
|
|
||||||
**注意**:
|
|
||||||
- 价格更新是自动的,每 5 秒检查一次
|
|
||||||
- 如果订单还未成交,价格可能还是下单时的价格
|
|
||||||
- 部分成交的订单会使用加权平均价格
|
|
||||||
|
|
||||||
## 🔍 常见问题
|
|
||||||
|
|
||||||
### Q1: 更新后无法启动怎么办?
|
|
||||||
|
|
||||||
**A:** 请按以下步骤排查:
|
|
||||||
|
|
||||||
1. 检查日志:`docker-compose -f docker-compose.prod.yml logs`
|
|
||||||
2. 检查数据库连接是否正常
|
|
||||||
3. 检查端口是否被占用
|
|
||||||
4. 如果问题持续,可以回退到之前的版本:
|
|
||||||
```bash
|
|
||||||
# 修改 docker-compose.prod.yml 中的镜像标签为之前的版本
|
|
||||||
# 例如:image: wrbug/polyhermes:v1.0.3
|
|
||||||
docker-compose -f docker-compose.prod.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### Q2: 更新后跟单不工作怎么办?
|
|
||||||
|
|
||||||
**A:** 请检查:
|
|
||||||
|
|
||||||
1. RPC 节点是否正常(进入 RPC 节点设置查看)
|
|
||||||
2. 跟单配置是否启用
|
|
||||||
3. 账户 API 凭证是否有效
|
|
||||||
4. 查看系统日志是否有错误信息
|
|
||||||
|
|
||||||
### Q3: 如何回退到之前的版本?
|
|
||||||
|
|
||||||
**A:** 按以下步骤操作:
|
|
||||||
|
|
||||||
1. 停止当前服务:`docker-compose -f docker-compose.prod.yml down`
|
|
||||||
2. 修改 `docker-compose.prod.yml` 中的镜像标签为之前的版本
|
|
||||||
3. 启动服务:`docker-compose -f docker-compose.prod.yml up -d`
|
|
||||||
|
|
||||||
**注意**:回退版本时,数据库结构可能不兼容,建议先备份数据库。
|
|
||||||
|
|
||||||
### Q4: 更新后数据会丢失吗?
|
|
||||||
|
|
||||||
**A:** 不会。更新过程不会删除任何数据。但为了安全起见,建议更新前备份数据库。
|
|
||||||
|
|
||||||
### Q5: 如何查看当前版本?
|
|
||||||
|
|
||||||
**A:** 有两种方式:
|
|
||||||
|
|
||||||
1. **页面查看**:登录系统后,页面标题会显示版本号(如:PolyHermes v1.1.0)
|
|
||||||
2. **命令行查看**:
|
|
||||||
```bash
|
|
||||||
docker inspect polyhermes | grep -i version
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📚 相关文档
|
|
||||||
|
|
||||||
- [完整更新日志](https://github.com/WrBug/PolyHermes/compare/v1.0.3...v1.1.0)
|
|
||||||
- [部署文档](docs/zh/DEPLOYMENT.md)
|
|
||||||
- [版本管理文档](docs/zh/VERSION_MANAGEMENT.md)
|
|
||||||
|
|
||||||
## 🔗 获取帮助
|
|
||||||
|
|
||||||
如果更新过程中遇到问题:
|
|
||||||
|
|
||||||
1. 查看 [GitHub Issues](https://github.com/WrBug/PolyHermes/issues)
|
|
||||||
2. 查看系统日志:`docker-compose -f docker-compose.prod.yml logs -f`
|
|
||||||
3. 在 GitHub 上提交 Issue,描述您的问题
|
|
||||||
|
|
||||||
## ⚠️ 安全提醒
|
|
||||||
|
|
||||||
**请务必使用官方 Docker 镜像源,避免财产损失!**
|
|
||||||
|
|
||||||
### ✅ 官方 Docker Hub 镜像
|
|
||||||
|
|
||||||
**官方镜像地址**:`wrbug/polyhermes`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# ✅ 正确:使用官方镜像
|
|
||||||
docker pull wrbug/polyhermes:v1.1.0
|
|
||||||
|
|
||||||
# ❌ 错误:不要使用其他来源的镜像
|
|
||||||
# 任何非官方来源的镜像都可能包含恶意代码,导致您的私钥和资产被盗
|
|
||||||
```
|
|
||||||
|
|
||||||
### 🔗 官方渠道
|
|
||||||
|
|
||||||
请通过以下**唯一官方渠道**获取 PolyHermes:
|
|
||||||
|
|
||||||
* **GitHub 仓库**:https://github.com/WrBug/PolyHermes
|
|
||||||
* **Twitter**:@polyhermes
|
|
||||||
* **Telegram 群组**:加入群组
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**⭐ 如果这个项目对您有帮助,请给个 Star 支持一下!**
|
|
||||||
|
|
||||||
**💬 如有问题或建议,欢迎在 GitHub Issues 中反馈。**
|
|
||||||
|
|
||||||
@@ -270,7 +270,6 @@ DB_USERNAME=root
|
|||||||
DB_PASSWORD=your_password_here
|
DB_PASSWORD=your_password_here
|
||||||
SPRING_PROFILES_ACTIVE=prod
|
SPRING_PROFILES_ACTIVE=prod
|
||||||
SERVER_PORT=80
|
SERVER_PORT=80
|
||||||
POLYGON_RPC_URL=https://polygon-rpc.com
|
|
||||||
JWT_SECRET=your-jwt-secret-key-change-in-production
|
JWT_SECRET=your-jwt-secret-key-change-in-production
|
||||||
ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production
|
ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production
|
||||||
EOF
|
EOF
|
||||||
@@ -337,7 +336,6 @@ cd frontend
|
|||||||
| `DB_USERNAME` | 数据库用户名 | `root` |
|
| `DB_USERNAME` | 数据库用户名 | `root` |
|
||||||
| `DB_PASSWORD` | 数据库密码 | - |
|
| `DB_PASSWORD` | 数据库密码 | - |
|
||||||
| `SERVER_PORT` | 后端服务端口 | `8000` |
|
| `SERVER_PORT` | 后端服务端口 | `8000` |
|
||||||
| `POLYGON_RPC_URL` | Polygon RPC 地址 | `https://polygon-rpc.com` |
|
|
||||||
| `JWT_SECRET` | JWT 密钥 | - |
|
| `JWT_SECRET` | JWT 密钥 | - |
|
||||||
| `ADMIN_RESET_PASSWORD_KEY` | 管理员密码重置密钥 | - |
|
| `ADMIN_RESET_PASSWORD_KEY` | 管理员密码重置密钥 | - |
|
||||||
| `CRYPTO_SECRET_KEY` | 加密密钥(用于加密存储私钥和 API Key) | - |
|
| `CRYPTO_SECRET_KEY` | 加密密钥(用于加密存储私钥和 API Key) | - |
|
||||||
|
|||||||
@@ -270,7 +270,6 @@ DB_USERNAME=root
|
|||||||
DB_PASSWORD=your_password_here
|
DB_PASSWORD=your_password_here
|
||||||
SPRING_PROFILES_ACTIVE=prod
|
SPRING_PROFILES_ACTIVE=prod
|
||||||
SERVER_PORT=80
|
SERVER_PORT=80
|
||||||
POLYGON_RPC_URL=https://polygon-rpc.com
|
|
||||||
JWT_SECRET=your-jwt-secret-key-change-in-production
|
JWT_SECRET=your-jwt-secret-key-change-in-production
|
||||||
ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production
|
ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production
|
||||||
EOF
|
EOF
|
||||||
@@ -337,7 +336,6 @@ cd frontend
|
|||||||
| `DB_USERNAME` | Database username | `root` |
|
| `DB_USERNAME` | Database username | `root` |
|
||||||
| `DB_PASSWORD` | Database password | - |
|
| `DB_PASSWORD` | Database password | - |
|
||||||
| `SERVER_PORT` | Backend service port | `8000` |
|
| `SERVER_PORT` | Backend service port | `8000` |
|
||||||
| `POLYGON_RPC_URL` | Polygon RPC address | `https://polygon-rpc.com` |
|
|
||||||
| `JWT_SECRET` | JWT secret key | - |
|
| `JWT_SECRET` | JWT secret key | - |
|
||||||
| `ADMIN_RESET_PASSWORD_KEY` | Admin password reset key | - |
|
| `ADMIN_RESET_PASSWORD_KEY` | Admin password reset key | - |
|
||||||
| `CRYPTO_SECRET_KEY` | Encryption key (for encrypting stored private keys and API Keys) | - |
|
| `CRYPTO_SECRET_KEY` | Encryption key (for encrypting stored private keys and API Keys) | - |
|
||||||
|
|||||||
+274
@@ -0,0 +1,274 @@
|
|||||||
|
# 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 Bean(lenient 模式)
|
||||||
|
- 所有 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 保持一致
|
||||||
|
|
||||||
|
## 📚 文档更新
|
||||||
|
|
||||||
|
- 新增智能资金分析文档(`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
|
||||||
|
|
||||||
|
## 📊 统计信息
|
||||||
|
|
||||||
|
- **文件变更**: 28 个文件
|
||||||
|
- **代码变更**: +1595 行 / -678 行
|
||||||
|
- **主要提交**: 7 个提交
|
||||||
|
|
||||||
|
## ⚠️ 重要提醒
|
||||||
|
|
||||||
|
**请务必使用官方 Docker 镜像源,避免财产损失!**
|
||||||
|
|
||||||
|
### ✅ 官方 Docker Hub 镜像
|
||||||
|
|
||||||
|
**官方镜像地址**:`wrbug/polyhermes`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ✅ 正确:使用官方镜像
|
||||||
|
docker pull wrbug/polyhermes:v1.1.2
|
||||||
|
|
||||||
|
# ❌ 错误:不要使用其他来源的镜像
|
||||||
|
# 任何非官方来源的镜像都可能包含恶意代码,导致您的私钥和资产被盗
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🔗 官方渠道
|
||||||
|
|
||||||
|
请通过以下**唯一官方渠道**获取 PolyHermes:
|
||||||
|
|
||||||
|
* **GitHub 仓库**:https://github.com/WrBug/PolyHermes
|
||||||
|
* **Twitter**:@polyhermes
|
||||||
|
* **Telegram 群组**:加入群组
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**⭐ 如果这个项目对您有帮助,请给个 Star 支持一下!**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# v1.1.1
|
||||||
|
|
||||||
|
## 🚀 主要功能
|
||||||
|
|
||||||
|
### 🔗 链上 WebSocket 监听优化
|
||||||
|
- 创建 `UnifiedOnChainWsService` 统一管理 WebSocket 连接,所有服务共享同一个连接
|
||||||
|
- 创建 `OnChainWsUtils` 工具类,提取公共的链上 WebSocket 相关功能
|
||||||
|
- 创建 `AccountOnChainMonitorService` 监听账户链上卖出和赎回事件
|
||||||
|
- 优化 `OnChainWsService`,复用公共代码,减少代码重复
|
||||||
|
- 支持通过链上 WebSocket 实时监听账户的卖出和赎回交易,自动更新订单状态
|
||||||
|
|
||||||
|
### 📊 市场状态查询优化
|
||||||
|
- 优化市场结算状态查询,优先使用链上查询 `ConditionalTokens.getCondition`
|
||||||
|
- 如果链上查询失败,自动降级到 Gamma API 查询
|
||||||
|
- 提供更实时和准确的市场结算结果
|
||||||
|
|
||||||
|
### 🔕 自动订单通知优化
|
||||||
|
- 自动生成的订单(AUTO_、AUTO_FIFO_、AUTO_WS_ 前缀)不再发送 Telegram 通知
|
||||||
|
- 优化 `OrderStatusUpdateService`,跳过自动生成订单的通知处理
|
||||||
|
- 减少不必要的通知,提升用户体验
|
||||||
|
|
||||||
|
## 🐛 Bug 修复
|
||||||
|
|
||||||
|
### 修复移动端 API 健康页面缺少数据显示
|
||||||
|
- 移动端添加 URL 地址显示
|
||||||
|
- 移动端添加状态文本显示(正常/异常/未配置)
|
||||||
|
- 移动端添加消息/状态信息显示
|
||||||
|
- 移动端和桌面端显示信息保持一致
|
||||||
|
|
||||||
|
## 🔧 功能优化
|
||||||
|
|
||||||
|
### 优化 Telegram 推送消息格式
|
||||||
|
- 添加价格和数量截位处理:
|
||||||
|
* 价格保留最多4位小数(截断,不四舍五入)
|
||||||
|
* 数量保留最多2位小数(截断,不四舍五入)
|
||||||
|
- 优化账户信息显示格式:
|
||||||
|
* 有账户名和钱包地址时显示:账户名(0x123...123)
|
||||||
|
* 只有账户名时显示账户名
|
||||||
|
* 只有钱包地址时显示脱敏后的地址
|
||||||
|
* 都没有时显示未知账户
|
||||||
|
|
||||||
|
### 配置优化
|
||||||
|
- 移除 `polygon.rpc.url` 配置,使用 RpcNodeService 统一管理 RPC 节点
|
||||||
|
- 删除无用的 `position.push.polling-interval` 和 `position.push.heartbeat-timeout` 配置项
|
||||||
|
- 修正日志配置中的包名(polyhermes -> polymarketbot)
|
||||||
|
- 更新 `ApiHealthCheckService` 直接使用 `RpcNodeService.getHttpUrl()`
|
||||||
|
|
||||||
|
## 📚 文档更新
|
||||||
|
|
||||||
|
- 统一发布说明文件,使用 RELEASE.md 替代版本化文件(RELEASE_v1.0.1.md、RELEASE_v1.1.0.md)
|
||||||
|
- 更新所有部署文档,移除 POLYGON_RPC_URL 相关说明
|
||||||
|
- 更新所有 Docker Compose 配置文件,移除 POLYGON_RPC_URL 环境变量
|
||||||
|
- 更新所有部署脚本,移除 POLYGON_RPC_URL 环境变量定义
|
||||||
|
|
||||||
|
## 🔧 技术改进
|
||||||
|
|
||||||
|
- 重构链上 WebSocket 服务,提取公共代码到 `OnChainWsUtils`
|
||||||
|
- 创建统一的 WebSocket 连接管理服务 `UnifiedOnChainWsService`
|
||||||
|
- 添加链上查询市场结算结果的功能(`BlockchainService.getCondition`)
|
||||||
|
- 添加 ABI 编码/解码工具方法(`EthereumUtils.decodeConditionResult`)
|
||||||
|
- 优化代码结构,减少代码重复,提高可维护性
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# v1.1.0
|
||||||
|
|
||||||
|
## 🚀 主要功能
|
||||||
|
|
||||||
|
### 🔗 链上 WebSocket 实时监听
|
||||||
|
- 实现通过 Polygon RPC `eth_subscribe` 实时监听链上交易
|
||||||
|
- 支持监听 USDC Transfer 和 ERC1155 Transfer 事件
|
||||||
|
- 实现并行监控策略:链上 WebSocket 和轮询同时运行,哪个数据先返回用哪个
|
||||||
|
- 支持通过 `eth_unsubscribe` 取消单个 Leader 的订阅,无需重新连接
|
||||||
|
- 优化 WebSocket 连接管理:只创建一个连接,没有跟单配置时自动取消
|
||||||
|
- 跟单配置生效/失效时及时更新 WebSocket 订阅
|
||||||
|
- 使用 Gson 替换所有 JSON 解析,提高解析稳定性
|
||||||
|
- 添加 Mutex 保证线程安全,防止并发处理导致的数据重复
|
||||||
|
|
||||||
|
### 📊 RPC 节点管理
|
||||||
|
- 实现 RPC 节点管理功能,支持添加、编辑、删除自定义 RPC 节点
|
||||||
|
- 支持 RPC 节点启用/禁用功能,禁用的节点会被自动忽略
|
||||||
|
- 前端添加启用/禁用开关,支持实时切换节点状态
|
||||||
|
- 健康检查只检查启用的节点,提高检查效率
|
||||||
|
- 节点选择时自动过滤禁用的节点
|
||||||
|
|
||||||
|
### 💰 卖出订单价格轮询更新
|
||||||
|
- 添加 `price_updated` 字段到 `sell_match_record` 表,用于标记价格是否已更新
|
||||||
|
- 创建 `OrderStatusUpdateService` 定时任务服务,每 5 秒轮询一次:
|
||||||
|
- 更新卖出订单的实际成交价(通过 orderId 查询订单详情)
|
||||||
|
- 清理已删除账户的订单记录
|
||||||
|
- 支持加权平均价格计算,处理部分成交的订单
|
||||||
|
- 添加 orderId 格式验证:非 0x 开头的直接标记为已更新,0x 开头的等待定时任务更新
|
||||||
|
- 下单完成后不再立即查询价格,直接保存,等待定时任务更新
|
||||||
|
|
||||||
|
## 🐛 Bug 修复
|
||||||
|
|
||||||
|
### 修复跟单卖出订单的 API 凭证解密问题
|
||||||
|
- 修复 `processSellTrade` 中 API 凭证未解密的问题,与 `processBuyTrade` 保持一致
|
||||||
|
- 确保卖出订单能够正常使用 API 凭证进行认证
|
||||||
|
|
||||||
|
### 修复 SELL 订单精度问题
|
||||||
|
- 修复 SELL 订单的 `makerAmount` 和 `takerAmount` 精度问题:
|
||||||
|
- `makerAmount` (shares) 最多 2 位小数(符合 API 要求)
|
||||||
|
- `takerAmount` (USDC) 最多 4 位小数(符合 API 要求)
|
||||||
|
- 确保订单能够正常提交到 Polymarket API
|
||||||
|
|
||||||
|
## 📚 文档更新
|
||||||
|
|
||||||
|
- 添加 Docker 版本更新说明(中英文)
|
||||||
|
- 添加链上 WebSocket 监听策略文档
|
||||||
|
- 添加跟单逻辑总结文档
|
||||||
|
- 更新部署文档,包含详细的版本更新步骤
|
||||||
|
|
||||||
|
## 🔧 技术改进
|
||||||
|
|
||||||
|
- 使用 Gson 替换 ObjectMapper,提高 JSON 解析稳定性
|
||||||
|
- `JsonRpcResponse.result` 使用 `JsonElement` 类型,支持灵活的 JSON 结构
|
||||||
|
- 优化 WebSocket 连接管理,减少不必要的连接
|
||||||
|
- 添加线程安全机制,使用 Kotlin Coroutines Mutex
|
||||||
|
- 启用 Spring 定时任务功能(`@EnableScheduling`)
|
||||||
|
|
||||||
|
## 📦 数据库变更
|
||||||
|
|
||||||
|
- 新增 `price_updated` 字段到 `sell_match_record` 表(Migration: V13)
|
||||||
|
|
||||||
|
## 🔗 相关链接
|
||||||
|
|
||||||
|
- **GitHub Release**: https://github.com/WrBug/PolyHermes/releases/tag/v1.1.1
|
||||||
|
- **完整更新日志**: https://github.com/WrBug/PolyHermes/compare/v1.1.0...v1.1.1
|
||||||
|
- **Docker Hub**: https://hub.docker.com/r/wrbug/polyhermes
|
||||||
|
|
||||||
|
## 📊 统计信息
|
||||||
|
|
||||||
|
- **文件变更**: 32 个文件
|
||||||
|
- **代码变更**: +1872 行 / -1503 行
|
||||||
|
- **主要提交**: 7 个提交
|
||||||
|
|
||||||
|
## ⚠️ 重要提醒
|
||||||
|
|
||||||
|
**请务必使用官方 Docker 镜像源,避免财产损失!**
|
||||||
|
|
||||||
|
### ✅ 官方 Docker Hub 镜像
|
||||||
|
|
||||||
|
**官方镜像地址**:`wrbug/polyhermes`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ✅ 正确:使用官方镜像
|
||||||
|
docker pull wrbug/polyhermes:v1.1.1
|
||||||
|
|
||||||
|
# ❌ 错误:不要使用其他来源的镜像
|
||||||
|
# 任何非官方来源的镜像都可能包含恶意代码,导致您的私钥和资产被盗
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🔗 官方渠道
|
||||||
|
|
||||||
|
请通过以下**唯一官方渠道**获取 PolyHermes:
|
||||||
|
|
||||||
|
* **GitHub 仓库**:https://github.com/WrBug/PolyHermes
|
||||||
|
* **Twitter**:@polyhermes
|
||||||
|
* **Telegram 群组**:加入群组
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**⭐ 如果这个项目对您有帮助,请给个 Star 支持一下!**
|
||||||
|
|
||||||
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
# v1.0.1
|
|
||||||
|
|
||||||
## 🚀 性能优化
|
|
||||||
|
|
||||||
### 📊 订单簿请求优化
|
|
||||||
* 优化订单簿获取逻辑,仅在需要时请求,避免不必要的 API 调用
|
|
||||||
* 当未配置需要订单簿的过滤条件(价差、订单深度)时,跳过订单簿获取
|
|
||||||
* 订单簿只请求一次,在过滤检查中复用,提高性能
|
|
||||||
|
|
||||||
### 💰 卖出价格计算优化
|
|
||||||
* 卖出改为市价卖出:优先使用订单簿的 bestBid(最高买入价)
|
|
||||||
* 如果 bestBid 获取失败,自动降级使用 Leader 的交易价格
|
|
||||||
* 卖出价格固定按 90% 计算,不再使用价格容忍度配置
|
|
||||||
* 提高卖出订单的成交率
|
|
||||||
|
|
||||||
### ⚙️ 价格容忍度默认值
|
|
||||||
* 如果价格容忍度配置为 0,自动使用默认值 5%
|
|
||||||
* 确保买入订单能够正常应用价格调整,提高成交率
|
|
||||||
|
|
||||||
## 🔧 代码优化
|
|
||||||
|
|
||||||
### 🎯 使用枚举优化过滤逻辑
|
|
||||||
* 新增 `FilterResult` 数据类和 `FilterStatus` 枚举
|
|
||||||
* 使用类型安全的枚举替代字符串判断,提高代码可维护性
|
|
||||||
* 移除无用字段 `isBuyOrder` 参数
|
|
||||||
* 优化 `checkFilters` 方法返回值,使用数据类封装结果
|
|
||||||
|
|
||||||
### 📝 代码结构优化
|
|
||||||
* 减少 if-else 嵌套,使用 Kotlin 链式调用和空安全操作符
|
|
||||||
* 优化卖出价格计算逻辑,使用 `runCatching` 和 `?.let` 简化代码
|
|
||||||
* 提取公共方法 `calculateFallbackSellPrice`,提高代码复用性
|
|
||||||
|
|
||||||
## 🗑️ 功能移除
|
|
||||||
|
|
||||||
### 移除最小订单簿深度功能
|
|
||||||
* 移除 `minOrderbookDepth`(最小订单簿深度)配置项
|
|
||||||
* 简化过滤逻辑,只保留 `minOrderDepth`(最小订单深度)
|
|
||||||
* 更新前端界面,移除相关配置项和提示文案
|
|
||||||
|
|
||||||
### 优化最小订单深度逻辑
|
|
||||||
* 修改 `minOrderDepth` 检查逻辑,检查所有方向(买盘+卖盘)的总深度
|
|
||||||
* 不再区分买卖方向,提供更全面的市场流动性评估
|
|
||||||
|
|
||||||
## 🐛 Bug 修复
|
|
||||||
|
|
||||||
* 修复盈亏和持仓价值计算逻辑未实现的问题
|
|
||||||
* 修复过滤检查中订单簿可能重复请求的问题
|
|
||||||
|
|
||||||
## 📚 文档更新
|
|
||||||
|
|
||||||
* 更新前端多语言文案,优化提示信息
|
|
||||||
* 移除最小订单簿深度相关的文档说明
|
|
||||||
|
|
||||||
**Full Changelog**: https://github.com/WrBug/polymarket-bot/compare/v1.0.0...v1.0.1
|
|
||||||
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
# v1.1.0
|
|
||||||
|
|
||||||
## 🚀 主要功能
|
|
||||||
|
|
||||||
### 🔗 链上 WebSocket 实时监听
|
|
||||||
- 实现通过 Polygon RPC `eth_subscribe` 实时监听链上交易
|
|
||||||
- 支持监听 USDC Transfer 和 ERC1155 Transfer 事件
|
|
||||||
- 实现并行监控策略:链上 WebSocket 和轮询同时运行,哪个数据先返回用哪个
|
|
||||||
- 支持通过 `eth_unsubscribe` 取消单个 Leader 的订阅,无需重新连接
|
|
||||||
- 优化 WebSocket 连接管理:只创建一个连接,没有跟单配置时自动取消
|
|
||||||
- 跟单配置生效/失效时及时更新 WebSocket 订阅
|
|
||||||
- 使用 Gson 替换所有 JSON 解析,提高解析稳定性
|
|
||||||
- 添加 Mutex 保证线程安全,防止并发处理导致的数据重复
|
|
||||||
|
|
||||||
### 📊 RPC 节点管理
|
|
||||||
- 实现 RPC 节点管理功能,支持添加、编辑、删除自定义 RPC 节点
|
|
||||||
- 支持 RPC 节点启用/禁用功能,禁用的节点会被自动忽略
|
|
||||||
- 前端添加启用/禁用开关,支持实时切换节点状态
|
|
||||||
- 健康检查只检查启用的节点,提高检查效率
|
|
||||||
- 节点选择时自动过滤禁用的节点
|
|
||||||
|
|
||||||
### 💰 卖出订单价格轮询更新
|
|
||||||
- 添加 `price_updated` 字段到 `sell_match_record` 表,用于标记价格是否已更新
|
|
||||||
- 创建 `OrderStatusUpdateService` 定时任务服务,每 5 秒轮询一次:
|
|
||||||
- 更新卖出订单的实际成交价(通过 orderId 查询订单详情)
|
|
||||||
- 清理已删除账户的订单记录
|
|
||||||
- 支持加权平均价格计算,处理部分成交的订单
|
|
||||||
- 添加 orderId 格式验证:非 0x 开头的直接标记为已更新,0x 开头的等待定时任务更新
|
|
||||||
- 下单完成后不再立即查询价格,直接保存,等待定时任务更新
|
|
||||||
|
|
||||||
## 🐛 Bug 修复
|
|
||||||
|
|
||||||
### 修复跟单卖出订单的 API 凭证解密问题
|
|
||||||
- 修复 `processSellTrade` 中 API 凭证未解密的问题,与 `processBuyTrade` 保持一致
|
|
||||||
- 确保卖出订单能够正常使用 API 凭证进行认证
|
|
||||||
|
|
||||||
### 修复 SELL 订单精度问题
|
|
||||||
- 修复 SELL 订单的 `makerAmount` 和 `takerAmount` 精度问题:
|
|
||||||
- `makerAmount` (shares) 最多 2 位小数(符合 API 要求)
|
|
||||||
- `takerAmount` (USDC) 最多 4 位小数(符合 API 要求)
|
|
||||||
- 确保订单能够正常提交到 Polymarket API
|
|
||||||
|
|
||||||
## 📚 文档更新
|
|
||||||
|
|
||||||
- 添加 Docker 版本更新说明(中英文)
|
|
||||||
- 添加链上 WebSocket 监听策略文档
|
|
||||||
- 添加跟单逻辑总结文档
|
|
||||||
- 更新部署文档,包含详细的版本更新步骤
|
|
||||||
|
|
||||||
## 🔧 技术改进
|
|
||||||
|
|
||||||
- 使用 Gson 替换 ObjectMapper,提高 JSON 解析稳定性
|
|
||||||
- `JsonRpcResponse.result` 使用 `JsonElement` 类型,支持灵活的 JSON 结构
|
|
||||||
- 优化 WebSocket 连接管理,减少不必要的连接
|
|
||||||
- 添加线程安全机制,使用 Kotlin Coroutines Mutex
|
|
||||||
- 启用 Spring 定时任务功能(`@EnableScheduling`)
|
|
||||||
|
|
||||||
## 📦 数据库变更
|
|
||||||
|
|
||||||
- 新增 `price_updated` 字段到 `sell_match_record` 表(Migration: V13)
|
|
||||||
|
|
||||||
## 🔗 相关链接
|
|
||||||
|
|
||||||
- **GitHub Release**: https://github.com/WrBug/PolyHermes/releases/tag/v1.1.0
|
|
||||||
- **完整更新日志**: https://github.com/WrBug/PolyHermes/compare/v1.0.3...v1.1.0
|
|
||||||
- **Docker Hub**: https://hub.docker.com/r/wrbug/polyhermes
|
|
||||||
|
|
||||||
## ⚠️ 重要提醒
|
|
||||||
|
|
||||||
**请务必使用官方 Docker 镜像源,避免财产损失!**
|
|
||||||
|
|
||||||
### ✅ 官方 Docker Hub 镜像
|
|
||||||
|
|
||||||
**官方镜像地址**:`wrbug/polyhermes`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# ✅ 正确:使用官方镜像
|
|
||||||
docker pull wrbug/polyhermes:v1.1.0
|
|
||||||
|
|
||||||
# ❌ 错误:不要使用其他来源的镜像
|
|
||||||
# 任何非官方来源的镜像都可能包含恶意代码,导致您的私钥和资产被盗
|
|
||||||
```
|
|
||||||
|
|
||||||
### 🔗 官方渠道
|
|
||||||
|
|
||||||
请通过以下**唯一官方渠道**获取 PolyHermes:
|
|
||||||
|
|
||||||
* **GitHub 仓库**:https://github.com/WrBug/PolyHermes
|
|
||||||
* **Twitter**:@polyhermes
|
|
||||||
* **Telegram 群组**:加入群组
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**⭐ 如果这个项目对您有帮助,请给个 Star 支持一下!**
|
|
||||||
|
|
||||||
@@ -154,9 +154,6 @@ SPRING_PROFILES_ACTIVE=prod
|
|||||||
# 服务器端口
|
# 服务器端口
|
||||||
SERVER_PORT=8000
|
SERVER_PORT=8000
|
||||||
|
|
||||||
# Polygon RPC
|
|
||||||
POLYGON_RPC_URL=https://polygon-rpc.com
|
|
||||||
|
|
||||||
# JWT 密钥(已自动生成随机值,生产环境建议修改)
|
# JWT 密钥(已自动生成随机值,生产环境建议修改)
|
||||||
JWT_SECRET=${JWT_SECRET}
|
JWT_SECRET=${JWT_SECRET}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ services:
|
|||||||
- DB_USERNAME=${DB_USERNAME:-root}
|
- DB_USERNAME=${DB_USERNAME:-root}
|
||||||
- DB_PASSWORD=${DB_PASSWORD:-}
|
- DB_PASSWORD=${DB_PASSWORD:-}
|
||||||
- SERVER_PORT=8000
|
- SERVER_PORT=8000
|
||||||
- POLYGON_RPC_URL=${POLYGON_RPC_URL:-https://polygon-rpc.com}
|
|
||||||
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
||||||
- ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY:-change-me-in-production}
|
- ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY:-change-me-in-production}
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -16,12 +16,14 @@ interface PolymarketGammaApi {
|
|||||||
* 根据 condition ID 列表获取市场信息
|
* 根据 condition ID 列表获取市场信息
|
||||||
* 文档: https://docs.polymarket.com/api-reference/markets/list-markets
|
* 文档: https://docs.polymarket.com/api-reference/markets/list-markets
|
||||||
* @param conditionIds condition ID 数组(16 进制字符串,如 "0x...")
|
* @param conditionIds condition ID 数组(16 进制字符串,如 "0x...")
|
||||||
|
* @param clobTokenIds CLOB token ID 数组(用于通过 tokenId 查询市场)
|
||||||
* @param includeTag 是否包含标签信息
|
* @param includeTag 是否包含标签信息
|
||||||
* @return 市场信息数组
|
* @return 市场信息数组
|
||||||
*/
|
*/
|
||||||
@GET("/markets")
|
@GET("/markets")
|
||||||
suspend fun listMarkets(
|
suspend fun listMarkets(
|
||||||
@Query("condition_ids") conditionIds: List<String>? = null,
|
@Query("condition_ids") conditionIds: List<String>? = null,
|
||||||
|
@Query("clob_token_ids") clobTokenIds: List<String>? = null,
|
||||||
@Query("include_tag") includeTag: Boolean? = null
|
@Query("include_tag") includeTag: Boolean? = null
|
||||||
): Response<List<MarketResponse>>
|
): Response<List<MarketResponse>>
|
||||||
}
|
}
|
||||||
@@ -51,6 +53,9 @@ data class MarketResponse(
|
|||||||
val liquidityNum: Double? = null,
|
val liquidityNum: Double? = null,
|
||||||
val lastTradePrice: Double? = null,
|
val lastTradePrice: Double? = null,
|
||||||
val bestBid: Double? = null,
|
val bestBid: Double? = null,
|
||||||
val bestAsk: Double? = null
|
val bestAsk: Double? = null,
|
||||||
|
// 以下字段可能存在于响应中,但不在标准文档中
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.wrbug.polymarketbot.config
|
package com.wrbug.polymarketbot.config
|
||||||
|
|
||||||
|
import com.google.gson.Gson
|
||||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||||
import com.wrbug.polymarketbot.util.createClient
|
import com.wrbug.polymarketbot.util.createClient
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
@@ -18,7 +19,9 @@ import retrofit2.converter.gson.GsonConverterFactory
|
|||||||
* - 账户 API Key 在调用时动态设置,不在此处配置
|
* - 账户 API Key 在调用时动态设置,不在此处配置
|
||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
class RetrofitConfig {
|
class RetrofitConfig(
|
||||||
|
private val gson: Gson
|
||||||
|
) {
|
||||||
|
|
||||||
@Value("\${polymarket.clob.base-url}")
|
@Value("\${polymarket.clob.base-url}")
|
||||||
private lateinit var clobBaseUrl: String
|
private lateinit var clobBaseUrl: String
|
||||||
@@ -37,7 +40,7 @@ class RetrofitConfig {
|
|||||||
return Retrofit.Builder()
|
return Retrofit.Builder()
|
||||||
.baseUrl(clobBaseUrl)
|
.baseUrl(clobBaseUrl)
|
||||||
.client(okHttpClient)
|
.client(okHttpClient)
|
||||||
.addConverterFactory(GsonConverterFactory.create())
|
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||||
.build()
|
.build()
|
||||||
.create(PolymarketClobApi::class.java)
|
.create(PolymarketClobApi::class.java)
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-11
@@ -4,8 +4,10 @@ import com.wrbug.polymarketbot.api.LatestPriceResponse
|
|||||||
import com.wrbug.polymarketbot.dto.*
|
import com.wrbug.polymarketbot.dto.*
|
||||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||||
import com.wrbug.polymarketbot.service.accounts.AccountService
|
import com.wrbug.polymarketbot.service.accounts.AccountService
|
||||||
|
import com.wrbug.polymarketbot.service.common.MarketPriceService
|
||||||
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import java.math.BigDecimal
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.context.MessageSource
|
import org.springframework.context.MessageSource
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
@@ -20,14 +22,16 @@ import org.springframework.web.bind.annotation.*
|
|||||||
class MarketController(
|
class MarketController(
|
||||||
private val accountService: AccountService,
|
private val accountService: AccountService,
|
||||||
private val clobService: PolymarketClobService,
|
private val clobService: PolymarketClobService,
|
||||||
|
private val marketPriceService: MarketPriceService,
|
||||||
private val messageSource: MessageSource
|
private val messageSource: MessageSource
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(MarketController::class.java)
|
private val logger = LoggerFactory.getLogger(MarketController::class.java)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取市场价格(通过 Gamma API)
|
* 获取市场价格
|
||||||
* 使用 Gamma API 获取价格信息,因为 Gamma API 支持 condition_ids 参数
|
* 使用 MarketPriceService 获取当前市场价格(支持多数据源降级)
|
||||||
|
* 返回当前价格,前端接收后自行填充到 bestBid 字段
|
||||||
*/
|
*/
|
||||||
@PostMapping("/price")
|
@PostMapping("/price")
|
||||||
fun getMarketPrice(@RequestBody request: MarketPriceRequest): ResponseEntity<ApiResponse<MarketPriceResponse>> {
|
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))
|
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_MARKET_ID_EMPTY, messageSource = messageSource))
|
||||||
}
|
}
|
||||||
|
|
||||||
val result = runBlocking { accountService.getMarketPrice(request.marketId, request.outcomeIndex) }
|
val outcomeIndex = request.outcomeIndex ?: 0
|
||||||
result.fold(
|
val price = runBlocking {
|
||||||
onSuccess = { response ->
|
marketPriceService.getCurrentMarketPrice(request.marketId, outcomeIndex)
|
||||||
ResponseEntity.ok(ApiResponse.success(response))
|
}
|
||||||
},
|
|
||||||
onFailure = { e ->
|
val response = MarketPriceResponse(
|
||||||
logger.error("获取市场价格失败: ${e.message}", e)
|
marketId = request.marketId,
|
||||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_MARKET_PRICE_FETCH_FAILED, e.message, messageSource))
|
currentPrice = price.toString()
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
ResponseEntity.ok(ApiResponse.success(response))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("获取市场价格异常: ${e.message}", e)
|
logger.error("获取市场价格异常: ${e.message}", e)
|
||||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_MARKET_PRICE_FETCH_FAILED, e.message, messageSource))
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_MARKET_PRICE_FETCH_FAILED, e.message, messageSource))
|
||||||
|
|||||||
@@ -195,14 +195,11 @@ data class LatestPriceRequest(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 市场价格响应
|
* 市场当前价格响应
|
||||||
*/
|
*/
|
||||||
data class MarketPriceResponse(
|
data class MarketPriceResponse(
|
||||||
val marketId: String,
|
val marketId: String,
|
||||||
val lastPrice: String?, // 最新成交价
|
val currentPrice: String // 当前价格(通过 MarketPriceService 获取,支持多数据源降级)
|
||||||
val bestBid: String?, // 最优买价(用于卖出参考)
|
|
||||||
val bestAsk: String?, // 最优卖价(用于买入参考)
|
|
||||||
val midpoint: String? // 中间价
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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()
|
|
||||||
)
|
|
||||||
|
|
||||||
+6
@@ -55,5 +55,11 @@ interface CopyOrderTrackingRepository : JpaRepository<CopyOrderTracking, Long> {
|
|||||||
* 查询未发送通知的买入订单(用于轮询更新)
|
* 查询未发送通知的买入订单(用于轮询更新)
|
||||||
*/
|
*/
|
||||||
fun findByNotificationSentFalse(): List<CopyOrderTracking>
|
fun findByNotificationSentFalse(): List<CopyOrderTracking>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询指定时间之前创建的订单(用于检查30秒后未成交的订单)
|
||||||
|
*/
|
||||||
|
@Query("SELECT t FROM CopyOrderTracking t WHERE t.createdAt <= :beforeTime")
|
||||||
|
fun findByCreatedAtBefore(beforeTime: Long): List<CopyOrderTracking>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -37,7 +37,8 @@ class AccountService(
|
|||||||
private val orderSigningService: OrderSigningService,
|
private val orderSigningService: OrderSigningService,
|
||||||
private val cryptoUtils: CryptoUtils,
|
private val cryptoUtils: CryptoUtils,
|
||||||
private val telegramNotificationService: TelegramNotificationService? = null, // 可选,避免循环依赖
|
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)
|
private val logger = LoggerFactory.getLogger(AccountService::class.java)
|
||||||
@@ -1121,7 +1122,7 @@ class AccountService(
|
|||||||
// 如果目标 outcome 不是第一个(index != 0),需要转换价格
|
// 如果目标 outcome 不是第一个(index != 0),需要转换价格
|
||||||
// 对于二元市场:第二个 outcome 的价格 = 1 - 第一个 outcome 的价格
|
// 对于二元市场:第二个 outcome 的价格 = 1 - 第一个 outcome 的价格
|
||||||
if (outcomeIndex != null && outcomeIndex > 0) {
|
if (outcomeIndex != null && outcomeIndex > 0) {
|
||||||
val outcomes = JsonUtils.parseStringArray(market.outcomes)
|
val outcomes = jsonUtils.parseStringArray(market.outcomes)
|
||||||
// 只对二元市场进行价格转换
|
// 只对二元市场进行价格转换
|
||||||
if (outcomes.size == 2) {
|
if (outcomes.size == 2) {
|
||||||
// 保存原始第一个 outcome 的价格
|
// 保存原始第一个 outcome 的价格
|
||||||
@@ -1153,13 +1154,13 @@ class AccountService(
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 优先使用 lastPrice(最近成交价),如果没有则使用 bestBid,最后使用 midpoint
|
||||||
|
val currentPrice = lastPrice ?: bestBid ?: midpoint ?: "0"
|
||||||
|
|
||||||
Result.success(
|
Result.success(
|
||||||
MarketPriceResponse(
|
MarketPriceResponse(
|
||||||
marketId = marketId,
|
marketId = marketId,
|
||||||
lastPrice = lastPrice,
|
currentPrice = currentPrice
|
||||||
bestBid = bestBid,
|
|
||||||
bestAsk = bestAsk,
|
|
||||||
midpoint = midpoint
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+8
-115
@@ -21,8 +21,7 @@ import org.springframework.context.i18n.LocaleContextHolder
|
|||||||
import com.wrbug.polymarketbot.service.system.SystemConfigService
|
import com.wrbug.polymarketbot.service.system.SystemConfigService
|
||||||
import com.wrbug.polymarketbot.service.system.RelayClientService
|
import com.wrbug.polymarketbot.service.system.RelayClientService
|
||||||
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
|
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
|
||||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
import com.wrbug.polymarketbot.service.common.MarketPriceService
|
||||||
import com.wrbug.polymarketbot.util.JsonUtils
|
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
@@ -45,7 +44,7 @@ class PositionCheckService(
|
|||||||
private val telegramNotificationService: TelegramNotificationService?,
|
private val telegramNotificationService: TelegramNotificationService?,
|
||||||
private val accountRepository: AccountRepository,
|
private val accountRepository: AccountRepository,
|
||||||
private val messageSource: MessageSource,
|
private val messageSource: MessageSource,
|
||||||
private val retrofitFactory: RetrofitFactory
|
private val marketPriceService: MarketPriceService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(PositionCheckService::class.java)
|
private val logger = LoggerFactory.getLogger(PositionCheckService::class.java)
|
||||||
@@ -427,118 +426,10 @@ class PositionCheckService(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取当前市场最新价(用于更新订单卖出价)
|
* 获取当前市场最新价(用于更新订单卖出价)
|
||||||
* 优先使用 bestBid(最优买价),如果没有则使用 midpoint(中间价)
|
* 委托给 MarketPriceService 处理
|
||||||
* 如果市场已关闭:
|
|
||||||
* - 该 outcome 赢了,返回 1
|
|
||||||
* - 该 outcome 输了,返回 0
|
|
||||||
*/
|
*/
|
||||||
private suspend fun getCurrentMarketPrice(marketId: String, outcomeIndex: Int): BigDecimal {
|
private suspend fun getCurrentMarketPrice(marketId: String, outcomeIndex: Int): BigDecimal {
|
||||||
return try {
|
return marketPriceService.getCurrentMarketPrice(marketId, outcomeIndex)
|
||||||
// 先获取市场信息,检查市场是否已关闭
|
|
||||||
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 && market.closed == true) {
|
|
||||||
// 市场已关闭,检查该 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 -> {
|
|
||||||
// 无法判断,继续使用正常价格逻辑
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果市场未关闭或无法判断输赢,获取正常价格
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -628,7 +519,8 @@ class PositionCheckService(
|
|||||||
outcomeIndex = outcomeIndex,
|
outcomeIndex = outcomeIndex,
|
||||||
totalMatchedQuantity = totalMatchedQuantity,
|
totalMatchedQuantity = totalMatchedQuantity,
|
||||||
sellPrice = sellPrice,
|
sellPrice = sellPrice,
|
||||||
totalRealizedPnl = totalRealizedPnl
|
totalRealizedPnl = totalRealizedPnl,
|
||||||
|
priceUpdated = true // 自动生成的订单,直接标记为已处理,不发送通知
|
||||||
)
|
)
|
||||||
|
|
||||||
val savedRecord = sellMatchRecordRepository.save(matchRecord)
|
val savedRecord = sellMatchRecordRepository.save(matchRecord)
|
||||||
@@ -742,7 +634,8 @@ class PositionCheckService(
|
|||||||
outcomeIndex = outcomeIndex,
|
outcomeIndex = outcomeIndex,
|
||||||
totalMatchedQuantity = totalMatchedQuantity,
|
totalMatchedQuantity = totalMatchedQuantity,
|
||||||
sellPrice = sellPrice,
|
sellPrice = sellPrice,
|
||||||
totalRealizedPnl = totalRealizedPnl
|
totalRealizedPnl = totalRealizedPnl,
|
||||||
|
priceUpdated = true // 自动生成的订单,直接标记为已处理,不发送通知
|
||||||
)
|
)
|
||||||
|
|
||||||
val savedRecord = sellMatchRecordRepository.save(matchRecord)
|
val savedRecord = sellMatchRecordRepository.save(matchRecord)
|
||||||
|
|||||||
+71
-2
@@ -1,5 +1,6 @@
|
|||||||
package com.wrbug.polymarketbot.service.common
|
package com.wrbug.polymarketbot.service.common
|
||||||
|
|
||||||
|
import com.google.gson.Gson
|
||||||
import com.wrbug.polymarketbot.api.EthereumRpcApi
|
import com.wrbug.polymarketbot.api.EthereumRpcApi
|
||||||
import com.wrbug.polymarketbot.api.JsonRpcRequest
|
import com.wrbug.polymarketbot.api.JsonRpcRequest
|
||||||
import com.wrbug.polymarketbot.api.JsonRpcResponse
|
import com.wrbug.polymarketbot.api.JsonRpcResponse
|
||||||
@@ -29,7 +30,8 @@ class BlockchainService(
|
|||||||
private val dataApiBaseUrl: String,
|
private val dataApiBaseUrl: String,
|
||||||
private val retrofitFactory: RetrofitFactory,
|
private val retrofitFactory: RetrofitFactory,
|
||||||
private val relayClientService: RelayClientService,
|
private val relayClientService: RelayClientService,
|
||||||
private val rpcNodeService: RpcNodeService
|
private val rpcNodeService: RpcNodeService,
|
||||||
|
private val gson: Gson
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(BlockchainService::class.java)
|
private val logger = LoggerFactory.getLogger(BlockchainService::class.java)
|
||||||
@@ -64,7 +66,7 @@ class BlockchainService(
|
|||||||
Retrofit.Builder()
|
Retrofit.Builder()
|
||||||
.baseUrl("$baseUrl/")
|
.baseUrl("$baseUrl/")
|
||||||
.client(okHttpClient)
|
.client(okHttpClient)
|
||||||
.addConverterFactory(GsonConverterFactory.create())
|
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||||
.build()
|
.build()
|
||||||
.create(PolymarketDataApi::class.java)
|
.create(PolymarketDataApi::class.java)
|
||||||
}
|
}
|
||||||
@@ -602,6 +604,73 @@ class BlockchainService(
|
|||||||
return Result.success(txHash)
|
return Result.success(txHash)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从链上查询市场条件(Condition)的结算结果
|
||||||
|
* 通过调用 ConditionalTokens 合约的 getCondition 函数获取 payouts
|
||||||
|
*
|
||||||
|
* @param conditionId 市场条件ID(bytes32,必须是 0x 开头的 66 位十六进制字符串)
|
||||||
|
* @return Result<Pair<payoutDenominator, payouts>>
|
||||||
|
* - payoutDenominator: 支付分母(通常为 1)
|
||||||
|
* - payouts: 每个 outcome 的支付金额数组(0 或 1)
|
||||||
|
* - 如果 payouts[outcomeIndex] == 1,表示该 outcome 赢了
|
||||||
|
* - 如果 payouts[outcomeIndex] == 0,表示该 outcome 输了
|
||||||
|
* - 如果 payouts 为空,表示市场尚未结算
|
||||||
|
*/
|
||||||
|
suspend fun getCondition(conditionId: String): Result<Pair<BigInteger, List<BigInteger>>> {
|
||||||
|
return try {
|
||||||
|
// 验证 conditionId 格式
|
||||||
|
if (conditionId.isBlank() || !conditionId.startsWith("0x") || conditionId.length != 66) {
|
||||||
|
return Result.failure(IllegalArgumentException("conditionId 格式错误,必须是 0x 开头的 66 位十六进制字符串"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val rpcApi = polygonRpcApi
|
||||||
|
|
||||||
|
// 构建 getCondition(bytes32) 函数调用
|
||||||
|
// 函数签名: getCondition(bytes32)
|
||||||
|
val functionSelector = EthereumUtils.getFunctionSelector("getCondition(bytes32)")
|
||||||
|
val encodedConditionId = EthereumUtils.encodeBytes32(conditionId)
|
||||||
|
val data = functionSelector + encodedConditionId
|
||||||
|
|
||||||
|
// 构建 JSON-RPC 请求
|
||||||
|
val rpcRequest = JsonRpcRequest(
|
||||||
|
method = "eth_call",
|
||||||
|
params = listOf(
|
||||||
|
mapOf(
|
||||||
|
"to" to conditionalTokensAddress,
|
||||||
|
"data" to data
|
||||||
|
),
|
||||||
|
"latest"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 发送 RPC 请求
|
||||||
|
val response = rpcApi.call(rpcRequest)
|
||||||
|
|
||||||
|
if (!response.isSuccessful || response.body() == null) {
|
||||||
|
return Result.failure(Exception("RPC 请求失败: ${response.code()} ${response.message()}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val rpcResponse = response.body()!!
|
||||||
|
|
||||||
|
// 检查错误
|
||||||
|
if (rpcResponse.error != null) {
|
||||||
|
return Result.failure(Exception("RPC 错误: ${rpcResponse.error.message}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 Gson 解析 result(JsonElement)
|
||||||
|
val hexResult = rpcResponse.result?.asString
|
||||||
|
?: return Result.failure(Exception("RPC 响应格式错误: result 为空"))
|
||||||
|
|
||||||
|
// 解析 ABI 编码的返回结果
|
||||||
|
val (payoutDenominator, payouts) = EthereumUtils.decodeConditionResult(hexResult)
|
||||||
|
|
||||||
|
Result.success(Pair(payoutDenominator, payouts))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("查询市场条件失败: conditionId=$conditionId, ${e.message}", e)
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询交易详情(用于调试和分析)
|
* 查询交易详情(用于调试和分析)
|
||||||
* @param txHash 交易哈希
|
* @param txHash 交易哈希
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
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)
|
||||||
|
*
|
||||||
|
* 价格会被截位到 4 位小数(向下截断,不四舍五入),用于显示和后续计算
|
||||||
|
*
|
||||||
|
* @param marketId 市场ID
|
||||||
|
* @param outcomeIndex 结果索引
|
||||||
|
* @return 市场价格(已截位到 4 位小数)
|
||||||
|
* @throws IllegalStateException 如果所有数据源都失败
|
||||||
|
*/
|
||||||
|
suspend fun getCurrentMarketPrice(marketId: String, outcomeIndex: Int): BigDecimal {
|
||||||
|
// 1. 优先从链上查询市场结算结果
|
||||||
|
val chainPrice = getPriceFromChainCondition(marketId, outcomeIndex)
|
||||||
|
if (chainPrice != null) {
|
||||||
|
// 截位到 4 位小数(向下截断,不四舍五入)
|
||||||
|
return chainPrice.setScale(4, java.math.RoundingMode.DOWN)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 从 CLOB API 查询订单簿价格(最准确)
|
||||||
|
val orderbookPrice = getPriceFromClobOrderbook(marketId, outcomeIndex)
|
||||||
|
if (orderbookPrice != null) {
|
||||||
|
// 截位到 4 位小数(向下截断,不四舍五入)
|
||||||
|
return orderbookPrice.setScale(4, java.math.RoundingMode.DOWN)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果所有数据源都失败,抛出异常
|
||||||
|
val errorMsg = "无法获取市场价格: marketId=$marketId, outcomeIndex=$outcomeIndex (链上查询和订单簿查询均失败)"
|
||||||
|
logger.error(errorMsg)
|
||||||
|
throw IllegalStateException(errorMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从链上查询市场结算结果获取价格
|
||||||
|
* 如果市场已结算:
|
||||||
|
* - payout > 0(赢了)→ 返回 1.0
|
||||||
|
* - payout == 0(输了)→ 返回 0.0
|
||||||
|
* 如果市场未结算或查询失败,返回 null
|
||||||
|
*/
|
||||||
|
private suspend fun getPriceFromChainCondition(marketId: String, outcomeIndex: Int): BigDecimal? {
|
||||||
|
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 BigDecimal.ONE
|
||||||
|
}
|
||||||
|
payout == BigInteger.ZERO -> {
|
||||||
|
logger.info("从链上查询到市场已结算,该 outcome 输了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
|
||||||
|
return BigDecimal.ZERO
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
logger.warn("从链上查询到异常的 payout 值: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.debug("从链上查询到市场尚未结算: marketId=$marketId, payouts=${payouts.size}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
logger.debug("链上查询市场条件失败,降级到 API 查询: marketId=$marketId, error=${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.debug("链上查询市场条件异常: 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
+5
-3
@@ -1,5 +1,6 @@
|
|||||||
package com.wrbug.polymarketbot.service.common
|
package com.wrbug.polymarketbot.service.common
|
||||||
|
|
||||||
|
import com.google.gson.Gson
|
||||||
import com.wrbug.polymarketbot.api.ApiKeyResponse
|
import com.wrbug.polymarketbot.api.ApiKeyResponse
|
||||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||||
import com.wrbug.polymarketbot.util.PolymarketL1AuthInterceptor
|
import com.wrbug.polymarketbot.util.PolymarketL1AuthInterceptor
|
||||||
@@ -19,7 +20,8 @@ import retrofit2.converter.gson.GsonConverterFactory
|
|||||||
@Service
|
@Service
|
||||||
class PolymarketApiKeyService(
|
class PolymarketApiKeyService(
|
||||||
@Value("\${polymarket.clob.base-url}")
|
@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)
|
private val logger = LoggerFactory.getLogger(PolymarketApiKeyService::class.java)
|
||||||
@@ -222,7 +224,7 @@ class PolymarketApiKeyService(
|
|||||||
return Retrofit.Builder()
|
return Retrofit.Builder()
|
||||||
.baseUrl(clobBaseUrl)
|
.baseUrl(clobBaseUrl)
|
||||||
.client(okHttpClient)
|
.client(okHttpClient)
|
||||||
.addConverterFactory(GsonConverterFactory.create())
|
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||||
.build()
|
.build()
|
||||||
.create(PolymarketClobApi::class.java)
|
.create(PolymarketClobApi::class.java)
|
||||||
}
|
}
|
||||||
@@ -236,7 +238,7 @@ class PolymarketApiKeyService(
|
|||||||
return Retrofit.Builder()
|
return Retrofit.Builder()
|
||||||
.baseUrl(clobBaseUrl)
|
.baseUrl(clobBaseUrl)
|
||||||
.client(okHttpClient)
|
.client(okHttpClient)
|
||||||
.addConverterFactory(GsonConverterFactory.create())
|
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||||
.build()
|
.build()
|
||||||
.create(PolymarketClobApi::class.java)
|
.create(PolymarketClobApi::class.java)
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-4
@@ -300,13 +300,23 @@ class PolymarketClobService(
|
|||||||
)
|
)
|
||||||
|
|
||||||
val response = authenticatedClobApi.getOrder(orderId)
|
val response = authenticatedClobApi.getOrder(orderId)
|
||||||
if (response.isSuccessful && response.body() != null) {
|
if (response.isSuccessful) {
|
||||||
Result.success(response.body()!!)
|
val body = response.body()
|
||||||
|
if (body != null) {
|
||||||
|
Result.success(body)
|
||||||
|
} else {
|
||||||
|
// 响应体为空,可能是订单不存在或已过期
|
||||||
|
logger.warn("获取订单详情失败: 响应体为空, orderId=$orderId, code=${response.code()}")
|
||||||
|
Result.failure(Exception("订单不存在或已过期: orderId=$orderId"))
|
||||||
|
}
|
||||||
} else {
|
} 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) {
|
} catch (e: Exception) {
|
||||||
logger.error("获取订单详情异常: ${e.message}", e)
|
logger.error("获取订单详情异常: orderId=$orderId, ${e.message}", e)
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-6
@@ -151,13 +151,14 @@ class CopyTradingService(
|
|||||||
|
|
||||||
val saved = copyTradingRepository.save(copyTrading)
|
val saved = copyTradingRepository.save(copyTrading)
|
||||||
|
|
||||||
// 如果跟单已启用,更新 Leader 监听(增量更新,不重启所有监听)
|
// 如果跟单已启用,更新 Leader 监听和账户监听(增量更新,不重启所有监听)
|
||||||
if (saved.enabled) {
|
if (saved.enabled) {
|
||||||
kotlinx.coroutines.runBlocking {
|
kotlinx.coroutines.runBlocking {
|
||||||
try {
|
try {
|
||||||
monitorService.updateLeaderMonitoring(saved.leaderId)
|
monitorService.updateLeaderMonitoring(saved.leaderId)
|
||||||
|
monitorService.updateAccountMonitoring(saved.accountId)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("更新 Leader 监听失败", e)
|
logger.error("更新监听失败", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -219,12 +220,13 @@ class CopyTradingService(
|
|||||||
|
|
||||||
val saved = copyTradingRepository.save(updated)
|
val saved = copyTradingRepository.save(updated)
|
||||||
|
|
||||||
// 更新 Leader 监听(增量更新,根据 enabled 状态决定添加或移除)
|
// 更新 Leader 监听和账户监听(增量更新,根据 enabled 状态决定添加或移除)
|
||||||
kotlinx.coroutines.runBlocking {
|
kotlinx.coroutines.runBlocking {
|
||||||
try {
|
try {
|
||||||
monitorService.updateLeaderMonitoring(saved.leaderId)
|
monitorService.updateLeaderMonitoring(saved.leaderId)
|
||||||
|
monitorService.updateAccountMonitoring(saved.accountId)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("更新 Leader 监听失败", e)
|
logger.error("更新监听失败", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,14 +325,16 @@ class CopyTradingService(
|
|||||||
?: return Result.failure(IllegalArgumentException("跟单配置不存在"))
|
?: return Result.failure(IllegalArgumentException("跟单配置不存在"))
|
||||||
|
|
||||||
val leaderId = copyTrading.leaderId
|
val leaderId = copyTrading.leaderId
|
||||||
|
val accountId = copyTrading.accountId
|
||||||
copyTradingRepository.delete(copyTrading)
|
copyTradingRepository.delete(copyTrading)
|
||||||
|
|
||||||
// 更新 Leader 监听(检查该 Leader 是否还有其他启用的跟单配置)
|
// 更新 Leader 监听和账户监听(检查是否还有其他启用的跟单配置)
|
||||||
kotlinx.coroutines.runBlocking {
|
kotlinx.coroutines.runBlocking {
|
||||||
try {
|
try {
|
||||||
monitorService.removeLeaderMonitoring(leaderId)
|
monitorService.removeLeaderMonitoring(leaderId)
|
||||||
|
monitorService.updateAccountMonitoring(accountId)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("更新 Leader 监听失败", e)
|
logger.error("更新监听失败", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+341
@@ -0,0 +1,341 @@
|
|||||||
|
package com.wrbug.polymarketbot.service.copytrading.monitor
|
||||||
|
|
||||||
|
import com.wrbug.polymarketbot.api.*
|
||||||
|
import com.wrbug.polymarketbot.entity.Account
|
||||||
|
import com.wrbug.polymarketbot.entity.CopyOrderTracking
|
||||||
|
import com.wrbug.polymarketbot.entity.SellMatchDetail
|
||||||
|
import com.wrbug.polymarketbot.entity.SellMatchRecord
|
||||||
|
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||||
|
import com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository
|
||||||
|
import com.wrbug.polymarketbot.repository.CopyTradingRepository
|
||||||
|
import com.wrbug.polymarketbot.repository.SellMatchDetailRepository
|
||||||
|
import com.wrbug.polymarketbot.repository.SellMatchRecordRepository
|
||||||
|
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||||
|
import com.wrbug.polymarketbot.util.multi
|
||||||
|
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||||
|
import jakarta.annotation.PreDestroy
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跟单账户链上 WebSocket 监听服务
|
||||||
|
* 通过统一服务订阅跟单账户的卖出和赎回事件
|
||||||
|
* 用于更新订单状态,不再依赖轮询
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
class AccountOnChainMonitorService(
|
||||||
|
private val unifiedOnChainWsService: UnifiedOnChainWsService,
|
||||||
|
private val retrofitFactory: RetrofitFactory,
|
||||||
|
private val accountRepository: AccountRepository,
|
||||||
|
private val copyTradingRepository: CopyTradingRepository,
|
||||||
|
private val copyOrderTrackingRepository: CopyOrderTrackingRepository,
|
||||||
|
private val sellMatchRecordRepository: SellMatchRecordRepository,
|
||||||
|
private val sellMatchDetailRepository: SellMatchDetailRepository
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val logger = LoggerFactory.getLogger(AccountOnChainMonitorService::class.java)
|
||||||
|
|
||||||
|
// 存储需要监听的账户:accountId -> Account
|
||||||
|
private val monitoredAccounts = ConcurrentHashMap<Long, Account>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动链上 WebSocket 监听
|
||||||
|
* 通过统一服务订阅所有跟单账户
|
||||||
|
*/
|
||||||
|
fun start(accounts: List<Account>) {
|
||||||
|
// 如果没有账户,取消所有订阅
|
||||||
|
if (accounts.isEmpty()) {
|
||||||
|
logger.info("没有需要监听的跟单账户,取消所有订阅")
|
||||||
|
stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新账户列表
|
||||||
|
monitoredAccounts.clear()
|
||||||
|
accounts.forEach { account ->
|
||||||
|
addAccount(account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加账户监听
|
||||||
|
* 通过统一服务订阅该账户的地址
|
||||||
|
*/
|
||||||
|
fun addAccount(account: Account) {
|
||||||
|
if (account.id == null) {
|
||||||
|
logger.warn("账户 ID 为空,跳过: ${account.proxyAddress}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val accountId = account.id!!
|
||||||
|
|
||||||
|
// 如果已经在监听列表中,不重复添加
|
||||||
|
if (monitoredAccounts.containsKey(accountId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
monitoredAccounts[accountId] = account
|
||||||
|
|
||||||
|
// 通过统一服务订阅
|
||||||
|
val subscriptionId = "ACCOUNT_$accountId"
|
||||||
|
unifiedOnChainWsService.subscribe(
|
||||||
|
subscriptionId = subscriptionId,
|
||||||
|
address = account.proxyAddress,
|
||||||
|
entityType = "ACCOUNT",
|
||||||
|
entityId = accountId,
|
||||||
|
callback = { txHash, httpClient, rpcApi ->
|
||||||
|
handleAccountTransaction(accountId, txHash, httpClient, rpcApi)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("已添加跟单账户进行链上监听: accountId=${accountId}, address=${account.proxyAddress}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理账户的交易
|
||||||
|
*/
|
||||||
|
private suspend fun handleAccountTransaction(accountId: Long, txHash: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
|
||||||
|
val account = monitoredAccounts[accountId] ?: return
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
// 获取区块号和时间戳
|
||||||
|
val blockNumber = receiptJson.get("blockNumber")?.asString
|
||||||
|
val blockTimestamp = if (blockNumber != null) {
|
||||||
|
OnChainWsUtils.getBlockTimestamp(blockNumber, rpcApi)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 receipt 中的 Transfer 日志
|
||||||
|
val logs = receiptJson.getAsJsonArray("logs") ?: return
|
||||||
|
val (erc20Transfers, erc1155Transfers) = OnChainWsUtils.parseReceiptTransfers(logs)
|
||||||
|
|
||||||
|
// 解析交易信息
|
||||||
|
val trade = OnChainWsUtils.parseTradeFromTransfers(
|
||||||
|
txHash = txHash,
|
||||||
|
timestamp = blockTimestamp,
|
||||||
|
walletAddress = account.proxyAddress,
|
||||||
|
erc20Transfers = erc20Transfers,
|
||||||
|
erc1155Transfers = erc1155Transfers,
|
||||||
|
retrofitFactory = retrofitFactory
|
||||||
|
)
|
||||||
|
|
||||||
|
if (trade != null && trade.side == "SELL") {
|
||||||
|
// 检测到卖出或赎回事件,更新订单状态
|
||||||
|
handleAccountSellOrRedeem(account, trade)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("处理账户交易失败: accountId=$accountId, txHash=$txHash, ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理账户的卖出或赎回事件
|
||||||
|
* 更新对应的订单状态
|
||||||
|
*/
|
||||||
|
private suspend fun handleAccountSellOrRedeem(account: Account, trade: TradeResponse) {
|
||||||
|
try {
|
||||||
|
// 获取该账户的所有启用的跟单配置
|
||||||
|
val copyTradings = copyTradingRepository.findByAccountId(account.id!!)
|
||||||
|
.filter { it.enabled }
|
||||||
|
|
||||||
|
if (copyTradings.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 trade 中已有的市场信息
|
||||||
|
val marketId = trade.market // conditionId
|
||||||
|
val outcomeIndex = trade.outcomeIndex ?: 0
|
||||||
|
|
||||||
|
// 计算卖出价格
|
||||||
|
val sellPrice = trade.price.toSafeBigDecimal()
|
||||||
|
|
||||||
|
// 为每个跟单配置更新订单状态
|
||||||
|
for (copyTrading in copyTradings) {
|
||||||
|
// 查找该跟单配置下所有未卖出的订单(remaining_quantity > 0)
|
||||||
|
val unmatchedOrders = copyOrderTrackingRepository.findByCopyTradingId(copyTrading.id!!)
|
||||||
|
.filter {
|
||||||
|
it.remainingQuantity > BigDecimal.ZERO &&
|
||||||
|
it.marketId == marketId &&
|
||||||
|
it.outcomeIndex == outcomeIndex
|
||||||
|
}
|
||||||
|
.sortedBy { it.createdAt } // 按创建时间排序(FIFO)
|
||||||
|
|
||||||
|
if (unmatchedOrders.isEmpty()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 卖出数量就是交易的 size
|
||||||
|
val soldQuantity = trade.size.toSafeBigDecimal()
|
||||||
|
|
||||||
|
// 更新订单状态为已卖出
|
||||||
|
updateOrdersAsSoldByFIFO(
|
||||||
|
unmatchedOrders,
|
||||||
|
soldQuantity,
|
||||||
|
sellPrice,
|
||||||
|
copyTrading.id!!,
|
||||||
|
marketId,
|
||||||
|
outcomeIndex
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("跟单账户卖出/赎回事件处理完成: accountId=${account.id}, copyTradingId=${copyTrading.id}, txHash=${trade.id}, soldQuantity=$soldQuantity, sellPrice=$sellPrice")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("处理账户卖出/赎回事件失败: accountId=${account.id}, txHash=${trade.id}, error=${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 FIFO 顺序更新订单为已卖出
|
||||||
|
*/
|
||||||
|
private suspend fun updateOrdersAsSoldByFIFO(
|
||||||
|
orders: List<CopyOrderTracking>,
|
||||||
|
soldQuantity: BigDecimal,
|
||||||
|
sellPrice: BigDecimal,
|
||||||
|
copyTradingId: Long,
|
||||||
|
marketId: String,
|
||||||
|
outcomeIndex: Int
|
||||||
|
) {
|
||||||
|
var remainingSoldQuantity = soldQuantity
|
||||||
|
val matchDetails = mutableListOf<SellMatchDetail>()
|
||||||
|
var totalMatchedQuantity = BigDecimal.ZERO
|
||||||
|
var totalRealizedPnl = BigDecimal.ZERO
|
||||||
|
|
||||||
|
for (order in orders) {
|
||||||
|
if (remainingSoldQuantity <= BigDecimal.ZERO) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentOrderRemaining = order.remainingQuantity.toSafeBigDecimal()
|
||||||
|
val matchedQty = minOf(currentOrderRemaining, remainingSoldQuantity)
|
||||||
|
|
||||||
|
if (matchedQty <= BigDecimal.ZERO) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算盈亏
|
||||||
|
val buyPrice = order.price.toSafeBigDecimal()
|
||||||
|
val realizedPnl = sellPrice.subtract(buyPrice).multi(matchedQty)
|
||||||
|
|
||||||
|
// 创建匹配明细
|
||||||
|
val detail = SellMatchDetail(
|
||||||
|
matchRecordId = 0, // 稍后设置
|
||||||
|
trackingId = order.id!!,
|
||||||
|
buyOrderId = order.buyOrderId,
|
||||||
|
matchedQuantity = matchedQty,
|
||||||
|
buyPrice = buyPrice,
|
||||||
|
sellPrice = sellPrice,
|
||||||
|
realizedPnl = realizedPnl
|
||||||
|
)
|
||||||
|
matchDetails.add(detail)
|
||||||
|
|
||||||
|
totalMatchedQuantity = totalMatchedQuantity.add(matchedQty)
|
||||||
|
totalRealizedPnl = totalRealizedPnl.add(realizedPnl)
|
||||||
|
|
||||||
|
// 更新订单状态
|
||||||
|
order.matchedQuantity = order.matchedQuantity.add(matchedQty)
|
||||||
|
order.remainingQuantity = currentOrderRemaining.subtract(matchedQty)
|
||||||
|
order.status = if (order.remainingQuantity <= BigDecimal.ZERO) "fully_matched" else "partially_matched"
|
||||||
|
order.updatedAt = System.currentTimeMillis()
|
||||||
|
copyOrderTrackingRepository.save(order)
|
||||||
|
|
||||||
|
remainingSoldQuantity = remainingSoldQuantity.subtract(matchedQty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有匹配的订单,创建卖出记录
|
||||||
|
if (totalMatchedQuantity > BigDecimal.ZERO && matchDetails.isNotEmpty()) {
|
||||||
|
val timestamp = System.currentTimeMillis()
|
||||||
|
val sellOrderId = "AUTO_WS_${timestamp}_${copyTradingId}" // 区分 WS 自动卖出
|
||||||
|
val leaderSellTradeId = "AUTO_WS_${timestamp}"
|
||||||
|
|
||||||
|
val matchRecord = SellMatchRecord(
|
||||||
|
copyTradingId = copyTradingId,
|
||||||
|
sellOrderId = sellOrderId,
|
||||||
|
leaderSellTradeId = leaderSellTradeId,
|
||||||
|
marketId = marketId,
|
||||||
|
side = outcomeIndex.toString(),
|
||||||
|
outcomeIndex = outcomeIndex,
|
||||||
|
totalMatchedQuantity = totalMatchedQuantity,
|
||||||
|
sellPrice = sellPrice,
|
||||||
|
totalRealizedPnl = totalRealizedPnl,
|
||||||
|
priceUpdated = true // WS 实时获取,直接标记为已更新
|
||||||
|
)
|
||||||
|
|
||||||
|
val savedRecord = sellMatchRecordRepository.save(matchRecord)
|
||||||
|
|
||||||
|
// 保存匹配明细
|
||||||
|
for (detail in matchDetails) {
|
||||||
|
val savedDetail = detail.copy(matchRecordId = savedRecord.id!!)
|
||||||
|
sellMatchDetailRepository.save(savedDetail)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("创建跟单账户链上自动卖出记录: copyTradingId=$copyTradingId, marketId=$marketId, totalMatched=$totalMatchedQuantity, totalPnl=$totalRealizedPnl")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除账户监听
|
||||||
|
* 取消该账户的订阅
|
||||||
|
*/
|
||||||
|
fun removeAccount(accountId: Long) {
|
||||||
|
monitoredAccounts.remove(accountId)
|
||||||
|
|
||||||
|
// 通过统一服务取消订阅
|
||||||
|
val subscriptionId = "ACCOUNT_$accountId"
|
||||||
|
unifiedOnChainWsService.unsubscribe(subscriptionId)
|
||||||
|
|
||||||
|
logger.info("已移除跟单账户的链上监听: accountId=$accountId")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新账户监听状态
|
||||||
|
*/
|
||||||
|
fun updateAccountMonitoring(accountId: Long) {
|
||||||
|
val account = accountRepository.findById(accountId).orElse(null)
|
||||||
|
if (account != null && account.isEnabled) {
|
||||||
|
addAccount(account)
|
||||||
|
} else {
|
||||||
|
removeAccount(accountId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止监听
|
||||||
|
*/
|
||||||
|
fun stop() {
|
||||||
|
// 取消所有账户的订阅
|
||||||
|
val accountIds = monitoredAccounts.keys.toList()
|
||||||
|
for (accountId in accountIds) {
|
||||||
|
removeAccount(accountId)
|
||||||
|
}
|
||||||
|
monitoredAccounts.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
fun destroy() {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
-4
@@ -2,6 +2,7 @@ package com.wrbug.polymarketbot.service.copytrading.monitor
|
|||||||
|
|
||||||
import com.wrbug.polymarketbot.entity.CopyTrading
|
import com.wrbug.polymarketbot.entity.CopyTrading
|
||||||
import com.wrbug.polymarketbot.entity.Leader
|
import com.wrbug.polymarketbot.entity.Leader
|
||||||
|
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||||
import com.wrbug.polymarketbot.repository.CopyTradingRepository
|
import com.wrbug.polymarketbot.repository.CopyTradingRepository
|
||||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||||
import jakarta.annotation.PostConstruct
|
import jakarta.annotation.PostConstruct
|
||||||
@@ -14,13 +15,16 @@ import org.springframework.stereotype.Service
|
|||||||
* 跟单监听服务(主服务)
|
* 跟单监听服务(主服务)
|
||||||
* 管理所有Leader的交易监听
|
* 管理所有Leader的交易监听
|
||||||
* 同时运行链上 WebSocket 监听和轮询监听(并行处理)
|
* 同时运行链上 WebSocket 监听和轮询监听(并行处理)
|
||||||
|
* 同时监听跟单账户的卖出/赎回事件(通过链上 WebSocket)
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
class CopyTradingMonitorService(
|
class CopyTradingMonitorService(
|
||||||
private val copyTradingRepository: CopyTradingRepository,
|
private val copyTradingRepository: CopyTradingRepository,
|
||||||
private val leaderRepository: LeaderRepository,
|
private val leaderRepository: LeaderRepository,
|
||||||
|
private val accountRepository: AccountRepository,
|
||||||
private val pollingService: CopyTradingPollingService,
|
private val pollingService: CopyTradingPollingService,
|
||||||
private val onChainWsService: OnChainWsService
|
private val onChainWsService: OnChainWsService,
|
||||||
|
private val accountOnChainMonitorService: AccountOnChainMonitorService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(CopyTradingMonitorService::class.java)
|
private val logger = LoggerFactory.getLogger(CopyTradingMonitorService::class.java)
|
||||||
@@ -50,11 +54,13 @@ class CopyTradingMonitorService(
|
|||||||
// 停止轮询和链上 WS 监听
|
// 停止轮询和链上 WS 监听
|
||||||
pollingService.stop()
|
pollingService.stop()
|
||||||
onChainWsService.stop()
|
onChainWsService.stop()
|
||||||
|
accountOnChainMonitorService.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动监听
|
* 启动监听
|
||||||
* 同时启动链上 WebSocket 监听和轮询监听(并行运行)
|
* 同时启动链上 WebSocket 监听和轮询监听(并行运行)
|
||||||
|
* 同时启动跟单账户的链上 WebSocket 监听(用于检测卖出/赎回事件)
|
||||||
*/
|
*/
|
||||||
suspend fun startMonitoring() {
|
suspend fun startMonitoring() {
|
||||||
// 1. 获取所有启用的跟单关系
|
// 1. 获取所有启用的跟单关系
|
||||||
@@ -70,12 +76,21 @@ class CopyTradingMonitorService(
|
|||||||
leaderRepository.findById(leaderId).orElse(null)
|
leaderRepository.findById(leaderId).orElse(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 同时启动链上 WebSocket 监听和轮询监听(并行运行)
|
// 3. 获取所有需要监听的跟单账户(去重)
|
||||||
// 链上 WS 监听(实时,秒级延迟)
|
val accountIds = enabledCopyTradings.map { it.accountId }.distinct()
|
||||||
|
val accounts = accountIds.mapNotNull { accountId ->
|
||||||
|
accountRepository.findById(accountId).orElse(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 同时启动链上 WebSocket 监听和轮询监听(并行运行)
|
||||||
|
// 链上 WS 监听 Leader 的交易(实时,秒级延迟)
|
||||||
onChainWsService.start(leaders)
|
onChainWsService.start(leaders)
|
||||||
|
|
||||||
// 轮询监听(延迟,2秒间隔,作为备份)
|
// 轮询监听 Leader 的交易(延迟,2秒间隔,作为备份)
|
||||||
pollingService.start(leaders)
|
pollingService.start(leaders)
|
||||||
|
|
||||||
|
// 5. 启动跟单账户的链上 WebSocket 监听(用于检测卖出/赎回事件)
|
||||||
|
accountOnChainMonitorService.start(accounts)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -125,6 +140,15 @@ class CopyTradingMonitorService(
|
|||||||
// 有启用的跟单配置,确保在监听列表中
|
// 有启用的跟单配置,确保在监听列表中
|
||||||
onChainWsService.addLeader(leader)
|
onChainWsService.addLeader(leader)
|
||||||
pollingService.addLeader(leader)
|
pollingService.addLeader(leader)
|
||||||
|
|
||||||
|
// 更新账户监听(添加该配置关联的账户)
|
||||||
|
val accountIds = copyTradings.map { it.accountId }.distinct()
|
||||||
|
accountIds.forEach { accountId ->
|
||||||
|
val account = accountRepository.findById(accountId).orElse(null)
|
||||||
|
if (account != null) {
|
||||||
|
accountOnChainMonitorService.addAccount(account)
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 没有启用的跟单配置,移除监听
|
// 没有启用的跟单配置,移除监听
|
||||||
onChainWsService.removeLeader(leaderId)
|
onChainWsService.removeLeader(leaderId)
|
||||||
@@ -132,6 +156,25 @@ class CopyTradingMonitorService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新账户监听(当跟单配置状态改变时调用)
|
||||||
|
* 根据当前状态决定添加或移除账户监听
|
||||||
|
*/
|
||||||
|
suspend fun updateAccountMonitoring(accountId: Long) {
|
||||||
|
val copyTradings = copyTradingRepository.findByAccountId(accountId)
|
||||||
|
.filter { it.enabled }
|
||||||
|
val account = accountRepository.findById(accountId).orElse(null)
|
||||||
|
?: return
|
||||||
|
|
||||||
|
if (copyTradings.isNotEmpty()) {
|
||||||
|
// 有启用的跟单配置,确保账户在监听列表中
|
||||||
|
accountOnChainMonitorService.addAccount(account)
|
||||||
|
} else {
|
||||||
|
// 没有启用的跟单配置,移除账户监听
|
||||||
|
accountOnChainMonitorService.removeAccount(accountId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 重新启动监听(当跟单关系状态改变时调用)
|
* 重新启动监听(当跟单关系状态改变时调用)
|
||||||
* 注意:这个方法会停止所有监听并重新启动,建议使用 updateLeaderMonitoring 进行增量更新
|
* 注意:这个方法会停止所有监听并重新启动,建议使用 updateLeaderMonitoring 进行增量更新
|
||||||
|
|||||||
+2
-3
@@ -22,15 +22,14 @@ import java.util.concurrent.ConcurrentHashMap
|
|||||||
@Service
|
@Service
|
||||||
class CopyTradingWebSocketService(
|
class CopyTradingWebSocketService(
|
||||||
private val copyOrderTrackingService: CopyOrderTrackingService,
|
private val copyOrderTrackingService: CopyOrderTrackingService,
|
||||||
private val templateRepository: CopyTradingTemplateRepository
|
private val templateRepository: CopyTradingTemplateRepository,
|
||||||
|
private val gson: Gson
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(CopyTradingWebSocketService::class.java)
|
private val logger = LoggerFactory.getLogger(CopyTradingWebSocketService::class.java)
|
||||||
|
|
||||||
@Value("\${polymarket.websocket.url:wss://ws-live-data.polymarket.com}")
|
@Value("\${polymarket.websocket.url:wss://ws-live-data.polymarket.com}")
|
||||||
private var websocketUrl: String = "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())
|
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||||
|
|
||||||
// 存储每个Leader的WebSocket客户端:leaderId -> WebSocketClient
|
// 存储每个Leader的WebSocket客户端:leaderId -> WebSocketClient
|
||||||
|
|||||||
+57
-805
@@ -1,37 +1,24 @@
|
|||||||
package com.wrbug.polymarketbot.service.copytrading.monitor
|
package com.wrbug.polymarketbot.service.copytrading.monitor
|
||||||
|
|
||||||
import com.google.gson.Gson
|
|
||||||
import com.google.gson.JsonArray
|
|
||||||
import com.google.gson.JsonObject
|
|
||||||
import com.google.gson.reflect.TypeToken
|
|
||||||
import com.wrbug.polymarketbot.api.*
|
import com.wrbug.polymarketbot.api.*
|
||||||
import com.wrbug.polymarketbot.entity.Leader
|
import com.wrbug.polymarketbot.entity.Leader
|
||||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||||
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
|
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
|
||||||
import com.wrbug.polymarketbot.service.system.RpcNodeService
|
|
||||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||||
import com.wrbug.polymarketbot.util.createClient
|
|
||||||
import com.wrbug.polymarketbot.util.getProxyConfig
|
|
||||||
import jakarta.annotation.PreDestroy
|
import jakarta.annotation.PreDestroy
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.WebSocket
|
|
||||||
import okhttp3.WebSocketListener
|
|
||||||
import okio.ByteString
|
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.beans.factory.annotation.Value
|
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.math.BigInteger
|
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 链上 WebSocket 监听服务
|
* 链上 WebSocket 监听服务
|
||||||
* 通过 Polygon RPC 的 eth_subscribe 实时监听链上交易
|
* 通过统一服务订阅 Leader 的链上交易
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
class OnChainWsService(
|
class OnChainWsService(
|
||||||
private val rpcNodeService: RpcNodeService,
|
private val unifiedOnChainWsService: UnifiedOnChainWsService,
|
||||||
private val retrofitFactory: RetrofitFactory,
|
private val retrofitFactory: RetrofitFactory,
|
||||||
private val copyOrderTrackingService: CopyOrderTrackingService,
|
private val copyOrderTrackingService: CopyOrderTrackingService,
|
||||||
private val leaderRepository: LeaderRepository
|
private val leaderRepository: LeaderRepository
|
||||||
@@ -39,84 +26,31 @@ class OnChainWsService(
|
|||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(OnChainWsService::class.java)
|
private val logger = LoggerFactory.getLogger(OnChainWsService::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())
|
|
||||||
|
|
||||||
// 存储需要监听的Leader:leaderId -> Leader
|
// 存储需要监听的Leader:leaderId -> Leader
|
||||||
private val monitoredLeaders = ConcurrentHashMap<Long, Leader>()
|
private val monitoredLeaders = ConcurrentHashMap<Long, Leader>()
|
||||||
|
|
||||||
// 存储每个 Leader 的订阅 ID:leaderId -> List<subscriptionId>
|
|
||||||
// 每个 Leader 有 6 个订阅:USDC from/to, ERC1155 TransferSingle from/to, ERC1155 TransferBatch from/to
|
|
||||||
private val leaderSubscriptions = ConcurrentHashMap<Long, MutableList<String>>()
|
|
||||||
|
|
||||||
// 存储请求 ID 到 Leader ID 的映射:requestId -> leaderId
|
|
||||||
// 用于在收到订阅响应时,将 subscription ID 关联到对应的 Leader
|
|
||||||
private val requestIdToLeaderId = ConcurrentHashMap<Int, Long>()
|
|
||||||
|
|
||||||
// WebSocket 连接
|
|
||||||
private var webSocket: WebSocket? = null
|
|
||||||
@Volatile
|
|
||||||
private var isConnected = false
|
|
||||||
|
|
||||||
// 订阅ID计数器(用于请求 ID)
|
|
||||||
private var requestIdCounter = 0
|
|
||||||
|
|
||||||
// 合约地址
|
|
||||||
companion object {
|
|
||||||
private const val USDC_CONTRACT = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
|
|
||||||
private const val ERC1155_CONTRACT = "0x4d97dcd97ec945f40cf65f87097ace5ea0476045"
|
|
||||||
private const val ERC20_TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
|
|
||||||
private const val ERC1155_TRANSFER_SINGLE_TOPIC = "0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62"
|
|
||||||
private const val ERC1155_TRANSFER_BATCH_TOPIC = "0x4a39dc06d4c0dbc64b70af90fd698a233a518aa5d07e595d983b8c0526c8f7fb"
|
|
||||||
}
|
|
||||||
|
|
||||||
// 连接任务(确保只有一个连接任务在运行)
|
|
||||||
private var connectionJob: Job? = null
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动链上 WebSocket 监听
|
* 启动链上 WebSocket 监听
|
||||||
* 只创建一个 WebSocket 连接,为所有 Leader 订阅
|
* 通过统一服务订阅所有 Leader
|
||||||
*/
|
*/
|
||||||
fun start(leaders: List<Leader>) {
|
fun start(leaders: List<Leader>) {
|
||||||
// 如果没有 Leader,不启动连接
|
// 如果没有 Leader,取消所有订阅
|
||||||
if (leaders.isEmpty()) {
|
if (leaders.isEmpty()) {
|
||||||
logger.info("没有需要监听的 Leader,不启动链上 WebSocket 连接")
|
logger.info("没有需要监听的 Leader,取消所有订阅")
|
||||||
stop()
|
stop()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果连接任务已经在运行,先停止旧任务
|
|
||||||
if (connectionJob != null && connectionJob!!.isActive) {
|
|
||||||
logger.info("停止旧的连接任务,准备重新启动")
|
|
||||||
connectionJob?.cancel()
|
|
||||||
connectionJob = null
|
|
||||||
// 关闭旧连接
|
|
||||||
webSocket?.close(1000, "重新启动")
|
|
||||||
webSocket = null
|
|
||||||
isConnected = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新 Leader 列表
|
// 更新 Leader 列表
|
||||||
monitoredLeaders.clear()
|
monitoredLeaders.clear()
|
||||||
leaders.forEach { leader ->
|
leaders.forEach { leader ->
|
||||||
addLeader(leader)
|
addLeader(leader)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启动连接任务(只创建一个)
|
|
||||||
connectionJob = scope.launch {
|
|
||||||
startConnection()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 添加Leader监听
|
* 添加Leader监听
|
||||||
* 如果 Leader 已经在监听列表中,不重复添加
|
* 通过统一服务订阅该 Leader 的地址
|
||||||
* 如果已连接,立即订阅
|
|
||||||
*/
|
*/
|
||||||
fun addLeader(leader: Leader) {
|
fun addLeader(leader: Leader) {
|
||||||
if (leader.id == null) {
|
if (leader.id == null) {
|
||||||
@@ -133,406 +67,28 @@ class OnChainWsService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
monitoredLeaders[leaderId] = leader
|
monitoredLeaders[leaderId] = leader
|
||||||
|
|
||||||
|
// 通过统一服务订阅
|
||||||
|
val subscriptionId = "LEADER_$leaderId"
|
||||||
|
unifiedOnChainWsService.subscribe(
|
||||||
|
subscriptionId = subscriptionId,
|
||||||
|
address = leader.leaderAddress,
|
||||||
|
entityType = "LEADER",
|
||||||
|
entityId = leaderId,
|
||||||
|
callback = { txHash, httpClient, rpcApi ->
|
||||||
|
handleLeaderTransaction(leaderId, txHash, httpClient, rpcApi)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
logger.info("添加 Leader 监听: ${leader.leaderName} (${leader.leaderAddress})")
|
logger.info("添加 Leader 监听: ${leader.leaderName} (${leader.leaderAddress})")
|
||||||
|
|
||||||
// 如果已连接,立即订阅
|
|
||||||
if (isConnected && webSocket != null) {
|
|
||||||
scope.launch {
|
|
||||||
subscribeLeader(leader)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 如果未连接,启动连接(如果连接任务未运行)
|
|
||||||
if (connectionJob == null || !connectionJob!!.isActive) {
|
|
||||||
connectionJob = scope.launch {
|
|
||||||
startConnection()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 移除Leader监听
|
* 处理 Leader 的交易
|
||||||
* 通过 eth_unsubscribe 取消该 Leader 的所有订阅
|
|
||||||
* 如果没有 Leader 了,关闭 WebSocket 连接
|
|
||||||
*/
|
*/
|
||||||
fun removeLeader(leaderId: Long) {
|
private suspend fun handleLeaderTransaction(leaderId: Long, txHash: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
|
||||||
val leader = monitoredLeaders.remove(leaderId)
|
val leader = monitoredLeaders[leaderId] ?: return
|
||||||
if (leader != null) {
|
|
||||||
logger.info("移除 Leader 监听: ${leader.leaderName} (${leader.leaderAddress})")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 取消该 Leader 的所有订阅
|
|
||||||
val subscriptions = leaderSubscriptions.remove(leaderId)
|
|
||||||
if (subscriptions != null && subscriptions.isNotEmpty() && isConnected && webSocket != null) {
|
|
||||||
logger.info("取消 Leader ${leader?.leaderName} 的 ${subscriptions.size} 个订阅")
|
|
||||||
subscriptions.forEach { subscriptionId ->
|
|
||||||
unsubscribe(subscriptionId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果没有 Leader 了,关闭连接
|
|
||||||
if (monitoredLeaders.isEmpty()) {
|
|
||||||
logger.info("没有需要监听的 Leader,关闭链上 WebSocket 连接")
|
|
||||||
stop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 停止所有监听
|
|
||||||
*/
|
|
||||||
fun stop() {
|
|
||||||
connectionJob?.cancel()
|
|
||||||
connectionJob = null
|
|
||||||
|
|
||||||
// 取消所有订阅
|
|
||||||
if (isConnected && webSocket != null) {
|
|
||||||
leaderSubscriptions.values.flatten().forEach { subscriptionId ->
|
|
||||||
unsubscribe(subscriptionId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
webSocket?.close(1000, "正常关闭")
|
|
||||||
webSocket = null
|
|
||||||
isConnected = false
|
|
||||||
monitoredLeaders.clear()
|
|
||||||
leaderSubscriptions.clear()
|
|
||||||
requestIdToLeaderId.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 启动连接(带重连机制)
|
|
||||||
* 只创建一个 WebSocket 连接
|
|
||||||
*/
|
|
||||||
private suspend fun startConnection() {
|
|
||||||
while (scope.isActive) {
|
|
||||||
try {
|
|
||||||
// 检查是否有需要监听的 Leader
|
|
||||||
if (monitoredLeaders.isEmpty()) {
|
|
||||||
logger.info("没有需要监听的 Leader,停止连接")
|
|
||||||
// 确保关闭连接
|
|
||||||
webSocket?.close(1000, "没有 Leader")
|
|
||||||
webSocket = null
|
|
||||||
isConnected = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果已经连接,不需要重新连接
|
|
||||||
if (isConnected && webSocket != null) {
|
|
||||||
// 等待连接断开
|
|
||||||
waitForDisconnect()
|
|
||||||
// 连接断开后继续重连循环
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 从后台配置获取 WS RPC URL
|
|
||||||
val wsUrl = rpcNodeService.getWsUrl()
|
|
||||||
val httpUrl = rpcNodeService.getHttpUrl()
|
|
||||||
|
|
||||||
logger.info("连接链上 WebSocket: $wsUrl (监听 ${monitoredLeaders.size} 个 Leader)")
|
|
||||||
|
|
||||||
// 创建 HTTP 客户端(用于 RPC 调用)
|
|
||||||
val httpClient = createHttpClient()
|
|
||||||
|
|
||||||
// 创建 RPC API 客户端
|
|
||||||
val rpcApi = retrofitFactory.createEthereumRpcApi(httpUrl)
|
|
||||||
|
|
||||||
// 连接 WebSocket(只创建一个连接,会先关闭旧连接)
|
|
||||||
connectWebSocket(wsUrl, httpClient, rpcApi)
|
|
||||||
|
|
||||||
// 等待连接建立(最多等待 15 秒)
|
|
||||||
// 注意:onOpen 回调是异步的,需要等待一段时间
|
|
||||||
var waitCount = 0
|
|
||||||
val maxWait = 15 // 最多等待 15 秒
|
|
||||||
while (!isConnected && waitCount < maxWait && scope.isActive) {
|
|
||||||
delay(1000)
|
|
||||||
waitCount++
|
|
||||||
// 每 3 秒打印一次日志,方便调试
|
|
||||||
if (waitCount % 3 == 0) {
|
|
||||||
logger.debug("等待 WebSocket 连接建立... (${waitCount}/${maxWait}秒)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查连接状态(同时检查 isConnected 和 webSocket 状态)
|
|
||||||
val actuallyConnected = isConnected && webSocket != null
|
|
||||||
|
|
||||||
// 如果连接失败,等待重连延迟后继续
|
|
||||||
if (!actuallyConnected) {
|
|
||||||
logger.warn("WebSocket 连接超时或失败: isConnected=$isConnected, webSocket=${webSocket != null}, 等待重连")
|
|
||||||
delay(reconnectDelay)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("WebSocket 连接已建立,开始监听")
|
|
||||||
|
|
||||||
// 连接成功后持续监听
|
|
||||||
waitForDisconnect()
|
|
||||||
|
|
||||||
// 连接断开后,如果没有 Leader 了,不再重连
|
|
||||||
if (monitoredLeaders.isEmpty()) {
|
|
||||||
logger.info("没有需要监听的 Leader,停止重连")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// 连接断开后,等待一下再重连(避免立即重连)
|
|
||||||
logger.info("WebSocket 连接断开,等待 ${reconnectDelay}ms 后重连")
|
|
||||||
delay(reconnectDelay)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// 如果没有 Leader 了,不再重连
|
|
||||||
if (monitoredLeaders.isEmpty()) {
|
|
||||||
logger.info("没有需要监听的 Leader,停止重连")
|
|
||||||
// 确保关闭连接
|
|
||||||
webSocket?.close(1000, "没有 Leader")
|
|
||||||
webSocket = null
|
|
||||||
isConnected = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
logger.warn("链上 WebSocket 连接失败,等待重连: ${e.message}")
|
|
||||||
// 确保关闭旧连接
|
|
||||||
webSocket?.close(1000, "重连前关闭")
|
|
||||||
webSocket = null
|
|
||||||
isConnected = false
|
|
||||||
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) {
|
|
||||||
// 先关闭旧连接(如果存在)
|
|
||||||
val oldWebSocket = webSocket
|
|
||||||
if (oldWebSocket != null) {
|
|
||||||
try {
|
|
||||||
oldWebSocket.close(1000, "重新连接")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
logger.debug("关闭旧 WebSocket 连接时出错: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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 连接成功")
|
|
||||||
|
|
||||||
// 订阅所有 Leader
|
|
||||||
scope.launch {
|
|
||||||
monitoredLeaders.values.forEach { leader ->
|
|
||||||
subscribeLeader(leader)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
// 注意:这里不直接重连,由 startConnection 循环处理重连逻辑
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 等待连接断开
|
|
||||||
*/
|
|
||||||
private suspend fun waitForDisconnect() {
|
|
||||||
while (isConnected && scope.isActive) {
|
|
||||||
delay(1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 订阅 Leader 钱包地址
|
|
||||||
* 每个 Leader 有 6 个订阅,保存订阅 ID 以便后续取消
|
|
||||||
*/
|
|
||||||
private suspend fun subscribeLeader(leader: Leader) {
|
|
||||||
if (webSocket == null || !isConnected || leader.id == null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val walletAddress = leader.leaderAddress.lowercase()
|
|
||||||
val walletTopic = addressToTopic32(walletAddress)
|
|
||||||
val leaderId = leader.id!!
|
|
||||||
|
|
||||||
// 初始化该 Leader 的订阅列表
|
|
||||||
if (!leaderSubscriptions.containsKey(leaderId)) {
|
|
||||||
leaderSubscriptions[leaderId] = mutableListOf()
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 订阅 USDC Transfer (from wallet)
|
|
||||||
subscribeLogs(USDC_CONTRACT, listOf(ERC20_TRANSFER_TOPIC, walletTopic), leaderId)
|
|
||||||
|
|
||||||
// 订阅 USDC Transfer (to wallet)
|
|
||||||
subscribeLogs(USDC_CONTRACT, listOf(ERC20_TRANSFER_TOPIC, null, walletTopic), leaderId)
|
|
||||||
|
|
||||||
// 订阅 ERC1155 TransferSingle (from wallet)
|
|
||||||
subscribeLogs(ERC1155_CONTRACT, listOf(ERC1155_TRANSFER_SINGLE_TOPIC, null, walletTopic), leaderId)
|
|
||||||
|
|
||||||
// 订阅 ERC1155 TransferSingle (to wallet)
|
|
||||||
subscribeLogs(ERC1155_CONTRACT, listOf(ERC1155_TRANSFER_SINGLE_TOPIC, null, null, walletTopic), leaderId)
|
|
||||||
|
|
||||||
// 订阅 ERC1155 TransferBatch (from wallet)
|
|
||||||
subscribeLogs(ERC1155_CONTRACT, listOf(ERC1155_TRANSFER_BATCH_TOPIC, null, walletTopic), leaderId)
|
|
||||||
|
|
||||||
// 订阅 ERC1155 TransferBatch (to wallet)
|
|
||||||
subscribeLogs(ERC1155_CONTRACT, listOf(ERC1155_TRANSFER_BATCH_TOPIC, null, null, walletTopic), leaderId)
|
|
||||||
|
|
||||||
logger.debug("已订阅 Leader 钱包地址: ${leader.leaderName} (${walletAddress})")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
logger.error("订阅 Leader 失败: leaderId=$leaderId, address=$walletAddress", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 订阅日志
|
|
||||||
* @param address 合约地址
|
|
||||||
* @param topics 主题列表
|
|
||||||
* @param leaderId Leader ID,用于关联订阅响应
|
|
||||||
*/
|
|
||||||
private fun subscribeLogs(address: String, topics: List<String?>, leaderId: Long) {
|
|
||||||
val ws = webSocket ?: return
|
|
||||||
|
|
||||||
val params = mapOf(
|
|
||||||
"address" to address.lowercase(),
|
|
||||||
"topics" to topics
|
|
||||||
)
|
|
||||||
|
|
||||||
val subscribeParams = listOf("logs", params)
|
|
||||||
|
|
||||||
val requestId = ++requestIdCounter
|
|
||||||
|
|
||||||
// 保存请求 ID 到 Leader ID 的映射
|
|
||||||
requestIdToLeaderId[requestId] = leaderId
|
|
||||||
|
|
||||||
val request = mapOf(
|
|
||||||
"jsonrpc" to "2.0",
|
|
||||||
"id" to requestId,
|
|
||||||
"method" to "eth_subscribe",
|
|
||||||
"params" to subscribeParams
|
|
||||||
)
|
|
||||||
|
|
||||||
val json = gson.toJson(request)
|
|
||||||
ws.send(json)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 取消订阅
|
|
||||||
* @param subscriptionId 订阅 ID
|
|
||||||
*/
|
|
||||||
private fun unsubscribe(subscriptionId: String) {
|
|
||||||
val ws = webSocket ?: return
|
|
||||||
|
|
||||||
val request = mapOf(
|
|
||||||
"jsonrpc" to "2.0",
|
|
||||||
"id" to (++requestIdCounter),
|
|
||||||
"method" to "eth_unsubscribe",
|
|
||||||
"params" to listOf(subscriptionId)
|
|
||||||
)
|
|
||||||
|
|
||||||
val json = gson.toJson(request)
|
|
||||||
ws.send(json)
|
|
||||||
logger.debug("已发送取消订阅请求: subscriptionId=$subscriptionId")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理 WebSocket 消息
|
|
||||||
*/
|
|
||||||
private suspend fun handleMessage(message: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
|
|
||||||
try {
|
|
||||||
// 使用 Gson 解析消息
|
|
||||||
val messageJson = gson.fromJson(message, JsonObject::class.java)
|
|
||||||
|
|
||||||
// 处理订阅响应(包含 subscription ID)
|
|
||||||
val id = messageJson.get("id")
|
|
||||||
if (id != null && !id.isJsonNull) {
|
|
||||||
val result = messageJson.get("result")
|
|
||||||
if (result != null && result.isJsonPrimitive && result.asJsonPrimitive.isString) {
|
|
||||||
// 这是订阅响应,result 是 subscription ID(字符串)
|
|
||||||
val requestId = id.asInt
|
|
||||||
val subscriptionId = result.asString
|
|
||||||
val leaderId = requestIdToLeaderId.remove(requestId)
|
|
||||||
if (leaderId != null) {
|
|
||||||
// 保存订阅 ID 到 Leader
|
|
||||||
leaderSubscriptions.getOrPut(leaderId) { mutableListOf() }.add(subscriptionId)
|
|
||||||
logger.debug("收到订阅响应: leaderId=$leaderId, subscriptionId=$subscriptionId")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理订阅通知(交易日志)
|
|
||||||
val method = messageJson.get("method")?.asString
|
|
||||||
if (method != "eth_subscription") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val params = messageJson.getAsJsonObject("params") ?: return
|
|
||||||
// result 是一个对象,包含日志信息
|
|
||||||
val result = params.getAsJsonObject("result") ?: return
|
|
||||||
|
|
||||||
// 从 result 对象中获取 transactionHash(关键数据)
|
|
||||||
val txHash = result.get("transactionHash")?.asString
|
|
||||||
if (txHash.isNullOrEmpty()) {
|
|
||||||
logger.debug("订阅通知中缺少 transactionHash,跳过处理")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理交易
|
|
||||||
processTransaction(txHash, httpClient, rpcApi)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
logger.error("处理 WebSocket 消息失败: ${e.message}", e)
|
|
||||||
logger.debug("消息内容: $message", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理交易
|
|
||||||
*/
|
|
||||||
private suspend fun processTransaction(txHash: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
|
|
||||||
try {
|
try {
|
||||||
// 获取交易 receipt
|
// 获取交易 receipt
|
||||||
val receiptRequest = JsonRpcRequest(
|
val receiptRequest = JsonRpcRequest(
|
||||||
@@ -550,376 +106,72 @@ class OnChainWsService(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 Gson 解析 receipt JSON(result 是 JsonElement)
|
// 使用 Gson 解析 receipt JSON
|
||||||
val receiptJson = receiptRpcResponse.result.asJsonObject
|
val receiptJson = receiptRpcResponse.result.asJsonObject
|
||||||
|
|
||||||
// 获取区块号和时间戳
|
// 获取区块号和时间戳
|
||||||
val blockNumber = receiptJson.get("blockNumber")?.asString
|
val blockNumber = receiptJson.get("blockNumber")?.asString
|
||||||
val blockTimestamp = if (blockNumber != null) {
|
val blockTimestamp = if (blockNumber != null) {
|
||||||
getBlockTimestamp(blockNumber, rpcApi)
|
OnChainWsUtils.getBlockTimestamp(blockNumber, rpcApi)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析 receipt 中的 Transfer 日志
|
// 解析 receipt 中的 Transfer 日志
|
||||||
val logs = receiptJson.getAsJsonArray("logs") ?: return
|
val logs = receiptJson.getAsJsonArray("logs") ?: return
|
||||||
val (erc20Transfers, erc1155Transfers) = parseReceiptTransfers(logs)
|
val (erc20Transfers, erc1155Transfers) = OnChainWsUtils.parseReceiptTransfers(logs)
|
||||||
|
|
||||||
// 为每个 Leader 处理交易
|
// 解析交易信息
|
||||||
for (leader in monitoredLeaders.values) {
|
val trade = OnChainWsUtils.parseTradeFromTransfers(
|
||||||
val trade = parseTradeFromTransfers(
|
txHash = txHash,
|
||||||
txHash = txHash,
|
timestamp = blockTimestamp,
|
||||||
timestamp = blockTimestamp,
|
walletAddress = leader.leaderAddress,
|
||||||
walletAddress = leader.leaderAddress,
|
erc20Transfers = erc20Transfers,
|
||||||
erc20Transfers = erc20Transfers,
|
erc1155Transfers = erc1155Transfers,
|
||||||
erc1155Transfers = erc1155Transfers
|
retrofitFactory = retrofitFactory
|
||||||
|
)
|
||||||
|
|
||||||
|
if (trade != null) {
|
||||||
|
// 调用 processTrade 处理交易
|
||||||
|
copyOrderTrackingService.processTrade(
|
||||||
|
leaderId = leaderId,
|
||||||
|
trade = trade,
|
||||||
|
source = "onchain-ws"
|
||||||
)
|
)
|
||||||
|
|
||||||
if (trade != null) {
|
|
||||||
// 调用 processTrade 处理交易(元数据已在 parseTradeFromTransfers 中补齐)
|
|
||||||
copyOrderTrackingService.processTrade(
|
|
||||||
leaderId = leader.id!!,
|
|
||||||
trade = trade,
|
|
||||||
source = "onchain-ws"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("处理交易失败: txHash=$txHash, ${e.message}", e)
|
logger.error("处理 Leader 交易失败: leaderId=$leaderId, txHash=$txHash, ${e.message}", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析 receipt 中的 Transfer 日志
|
* 移除Leader监听
|
||||||
|
* 取消该 Leader 的订阅
|
||||||
*/
|
*/
|
||||||
private fun parseReceiptTransfers(logs: com.google.gson.JsonArray): Pair<List<Erc20Transfer>, List<Erc1155Transfer>> {
|
fun removeLeader(leaderId: Long) {
|
||||||
val erc20 = mutableListOf<Erc20Transfer>()
|
monitoredLeaders.remove(leaderId)
|
||||||
val erc1155 = mutableListOf<Erc1155Transfer>()
|
|
||||||
|
|
||||||
for (logElement in logs) {
|
// 通过统一服务取消订阅
|
||||||
val log = logElement.asJsonObject
|
val subscriptionId = "LEADER_$leaderId"
|
||||||
val address = log.get("address")?.asString?.lowercase() ?: continue
|
unifiedOnChainWsService.unsubscribe(subscriptionId)
|
||||||
val topicsArray = log.getAsJsonArray("topics") ?: continue
|
|
||||||
val topics = topicsArray.mapNotNull { it.asString }
|
|
||||||
if (topics.isEmpty()) continue
|
|
||||||
|
|
||||||
val t0 = topics[0].lowercase()
|
|
||||||
val data = log.get("data")?.asString ?: "0x"
|
|
||||||
|
|
||||||
// USDC ERC20 Transfer
|
|
||||||
if (address == USDC_CONTRACT.lowercase() && t0 == ERC20_TRANSFER_TOPIC && topics.size >= 3) {
|
|
||||||
val from = topicToAddress(topics[1])
|
|
||||||
val to = topicToAddress(topics[2])
|
|
||||||
val value = hexToBigInt(data)
|
|
||||||
erc20.add(Erc20Transfer(from, to, value))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// ERC1155 TransferSingle
|
|
||||||
if (t0 == ERC1155_TRANSFER_SINGLE_TOPIC && topics.size >= 4) {
|
|
||||||
val from = topicToAddress(topics[2])
|
|
||||||
val to = topicToAddress(topics[3])
|
|
||||||
val bytes = bytesFromHex(data)
|
|
||||||
if (bytes.size >= 64) {
|
|
||||||
val tokenId = sliceBigInt32(bytes, 0)
|
|
||||||
val value = sliceBigInt32(bytes, 32)
|
|
||||||
erc1155.add(Erc1155Transfer(from, to, tokenId, value))
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// ERC1155 TransferBatch
|
|
||||||
if (t0 == ERC1155_TRANSFER_BATCH_TOPIC && topics.size >= 4) {
|
|
||||||
val from = topicToAddress(topics[2])
|
|
||||||
val to = topicToAddress(topics[3])
|
|
||||||
val bytes = bytesFromHex(data)
|
|
||||||
if (bytes.size < 64) continue
|
|
||||||
|
|
||||||
val offIds = sliceBigInt32(bytes, 0).toInt()
|
|
||||||
val offVals = sliceBigInt32(bytes, 32).toInt()
|
|
||||||
if (offIds + 32 > bytes.size || offVals + 32 > bytes.size) continue
|
|
||||||
|
|
||||||
val nIds = sliceBigInt32(bytes, offIds).toInt()
|
|
||||||
val nVals = sliceBigInt32(bytes, offVals).toInt()
|
|
||||||
if (nIds != nVals) continue
|
|
||||||
|
|
||||||
val idsStart = offIds + 32
|
|
||||||
val valsStart = offVals + 32
|
|
||||||
for (i in 0 until nIds) {
|
|
||||||
val ib = idsStart + i * 32
|
|
||||||
val vb = valsStart + i * 32
|
|
||||||
if (ib + 32 > bytes.size || vb + 32 > bytes.size) break
|
|
||||||
val tokenId = sliceBigInt32(bytes, ib)
|
|
||||||
val value = sliceBigInt32(bytes, vb)
|
|
||||||
erc1155.add(Erc1155Transfer(from, to, tokenId, value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Pair(erc20, erc1155)
|
logger.info("移除 Leader 监听: leaderId=$leaderId")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从 Transfer 日志解析交易信息
|
* 停止监听
|
||||||
*/
|
*/
|
||||||
private suspend fun parseTradeFromTransfers(
|
fun stop() {
|
||||||
txHash: String,
|
// 取消所有 Leader 的订阅
|
||||||
timestamp: Long?,
|
val leaderIds = monitoredLeaders.keys.toList()
|
||||||
walletAddress: String,
|
for (leaderId in leaderIds) {
|
||||||
erc20Transfers: List<Erc20Transfer>,
|
removeLeader(leaderId)
|
||||||
erc1155Transfers: List<Erc1155Transfer>
|
|
||||||
): TradeResponse? {
|
|
||||||
val wallet = walletAddress.lowercase()
|
|
||||||
|
|
||||||
// 计算 USDC 流入和流出
|
|
||||||
val usdcOut = erc20Transfers.filter { it.from.lowercase() == wallet }
|
|
||||||
.fold(BigInteger.ZERO) { acc, t -> acc + t.value }
|
|
||||||
val usdcIn = erc20Transfers.filter { it.to.lowercase() == wallet }
|
|
||||||
.fold(BigInteger.ZERO) { acc, t -> acc + t.value }
|
|
||||||
|
|
||||||
// 计算 ERC1155 流入和流出(按 tokenId 聚合)
|
|
||||||
val inById = mutableMapOf<BigInteger, BigInteger>()
|
|
||||||
val outById = mutableMapOf<BigInteger, BigInteger>()
|
|
||||||
for (t in erc1155Transfers) {
|
|
||||||
if (t.to.lowercase() == wallet) {
|
|
||||||
inById[t.tokenId] = (inById[t.tokenId] ?: BigInteger.ZERO) + t.value
|
|
||||||
}
|
|
||||||
if (t.from.lowercase() == wallet) {
|
|
||||||
outById[t.tokenId] = (outById[t.tokenId] ?: BigInteger.ZERO) + t.value
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
monitoredLeaders.clear()
|
||||||
// 找到最大的流入和流出 tokenId
|
|
||||||
fun best(map: Map<BigInteger, BigInteger>): Pair<BigInteger?, BigInteger> =
|
|
||||||
map.entries.maxByOrNull { it.value }?.let { it.key to it.value } ?: (null to BigInteger.ZERO)
|
|
||||||
|
|
||||||
val (bestInId, bestInVal) = best(inById)
|
|
||||||
val (bestOutId, bestOutVal) = best(outById)
|
|
||||||
|
|
||||||
// 判断交易方向
|
|
||||||
var side: String? = null
|
|
||||||
var asset: BigInteger? = null
|
|
||||||
var sizeRaw = BigInteger.ZERO
|
|
||||||
var usdcRaw = BigInteger.ZERO
|
|
||||||
|
|
||||||
if (bestInId != null && bestInVal > BigInteger.ZERO && usdcOut > BigInteger.ZERO) {
|
|
||||||
// BUY: 收到 token,支付 USDC
|
|
||||||
side = "BUY"
|
|
||||||
asset = bestInId
|
|
||||||
sizeRaw = bestInVal
|
|
||||||
usdcRaw = usdcOut
|
|
||||||
} else if (bestOutId != null && bestOutVal > BigInteger.ZERO && usdcIn > BigInteger.ZERO) {
|
|
||||||
// SELL: 卖出 token,收到 USDC
|
|
||||||
side = "SELL"
|
|
||||||
asset = bestOutId
|
|
||||||
sizeRaw = bestOutVal
|
|
||||||
usdcRaw = usdcIn
|
|
||||||
} else {
|
|
||||||
// 无法判断交易方向
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算价格和数量(USDC 有 6 位小数,shares 也有 6 位小数)
|
|
||||||
val usdcSize = usdcRaw.toBigDecimal().divide(BigInteger("1000000").toBigDecimal(), 8, java.math.RoundingMode.DOWN)
|
|
||||||
val size = sizeRaw.toBigDecimal().divide(BigInteger("1000000").toBigDecimal(), 8, java.math.RoundingMode.DOWN)
|
|
||||||
val price = if (size.signum() > 0) {
|
|
||||||
usdcSize.divide(size, 8, java.math.RoundingMode.DOWN)
|
|
||||||
} else {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// 尝试通过 Gamma API 查询市场信息(通过 tokenId)
|
|
||||||
val marketInfo = fetchMarketByTokenId(asset.toString())
|
|
||||||
|
|
||||||
// 创建 TradeResponse
|
|
||||||
return TradeResponse(
|
|
||||||
id = txHash,
|
|
||||||
market = marketInfo?.conditionId ?: "",
|
|
||||||
side = side,
|
|
||||||
price = price.toPlainString(),
|
|
||||||
size = size.toPlainString(),
|
|
||||||
timestamp = (timestamp ?: System.currentTimeMillis() / 1000).toString(),
|
|
||||||
user = walletAddress,
|
|
||||||
outcomeIndex = marketInfo?.outcomeIndex,
|
|
||||||
outcome = marketInfo?.outcome
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 通过 Gamma API 查询市场信息(通过 tokenId)
|
|
||||||
*/
|
|
||||||
private suspend fun fetchMarketByTokenId(tokenId: String): MarketInfo? {
|
|
||||||
return try {
|
|
||||||
// 使用 HTTP 请求直接调用 Gamma API(因为 Retrofit 接口可能不支持 clob_token_ids 参数)
|
|
||||||
val httpClient = createHttpClient()
|
|
||||||
val url = "https://gamma-api.polymarket.com/markets?clob_token_ids=$tokenId"
|
|
||||||
|
|
||||||
val request = okhttp3.Request.Builder()
|
|
||||||
.url(url)
|
|
||||||
.get()
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val response = httpClient.newCall(request).execute()
|
|
||||||
if (!response.isSuccessful || response.body == null) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
val responseBody = response.body!!.string()
|
|
||||||
// 使用 Gson 解析市场列表
|
|
||||||
val marketsType = object : TypeToken<List<JsonObject>>() {}.type
|
|
||||||
val markets = gson.fromJson<List<JsonObject>>(responseBody, marketsType)
|
|
||||||
|
|
||||||
if (markets.isEmpty()) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
val market = markets.first()
|
|
||||||
|
|
||||||
// 解析 clob_token_ids(可能是 JSON 字符串或数组)
|
|
||||||
val clobTokenIdsRaw = market.get("clobTokenIds") ?: market.get("clob_token_ids")
|
|
||||||
val clobTokenIds = when {
|
|
||||||
clobTokenIdsRaw == null || clobTokenIdsRaw.isJsonNull -> null
|
|
||||||
clobTokenIdsRaw.isJsonPrimitive && clobTokenIdsRaw.asJsonPrimitive.isString -> {
|
|
||||||
// 尝试解析 JSON 字符串
|
|
||||||
try {
|
|
||||||
val listType = object : TypeToken<List<String>>() {}.type
|
|
||||||
gson.fromJson<List<String>>(clobTokenIdsRaw.asString, listType)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
clobTokenIdsRaw.isJsonArray -> {
|
|
||||||
clobTokenIdsRaw.asJsonArray.mapNotNull { it.asString }
|
|
||||||
}
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解析 outcomes(可能是 JSON 字符串或数组)
|
|
||||||
val outcomesRaw = market.get("outcomes")
|
|
||||||
val outcomes = when {
|
|
||||||
outcomesRaw == null || outcomesRaw.isJsonNull -> null
|
|
||||||
outcomesRaw.isJsonPrimitive && outcomesRaw.asJsonPrimitive.isString -> {
|
|
||||||
try {
|
|
||||||
val listType = object : TypeToken<List<String>>() {}.type
|
|
||||||
gson.fromJson<List<String>>(outcomesRaw.asString, listType)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
outcomesRaw.isJsonArray -> {
|
|
||||||
outcomesRaw.asJsonArray.mapNotNull { it.asString }
|
|
||||||
}
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查找 tokenId 在 clobTokenIds 中的索引
|
|
||||||
val outcomeIndex = clobTokenIds?.indexOfFirst {
|
|
||||||
it.equals(tokenId, ignoreCase = true)
|
|
||||||
}?.takeIf { it >= 0 }
|
|
||||||
|
|
||||||
// 获取 outcome 名称
|
|
||||||
val outcome = if (outcomeIndex != null && outcomes != null && outcomeIndex < outcomes.size) {
|
|
||||||
outcomes[outcomeIndex]
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
val conditionId = market.get("conditionId")?.asString ?: return null
|
|
||||||
|
|
||||||
MarketInfo(
|
|
||||||
conditionId = conditionId,
|
|
||||||
outcomeIndex = outcomeIndex,
|
|
||||||
outcome = outcome
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
logger.warn("通过 Gamma API 查询市场信息失败: tokenId=$tokenId, ${e.message}")
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 市场信息(从 Gamma API 获取)
|
|
||||||
*/
|
|
||||||
private data class MarketInfo(
|
|
||||||
val conditionId: String,
|
|
||||||
val outcomeIndex: Int?,
|
|
||||||
val outcome: String?
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取区块时间戳
|
|
||||||
*/
|
|
||||||
private suspend fun getBlockTimestamp(blockNumber: String, rpcApi: EthereumRpcApi): Long? {
|
|
||||||
return try {
|
|
||||||
val blockRequest = JsonRpcRequest(
|
|
||||||
method = "eth_getBlockByNumber",
|
|
||||||
params = listOf(blockNumber, false)
|
|
||||||
)
|
|
||||||
|
|
||||||
val blockResponse = rpcApi.call(blockRequest)
|
|
||||||
if (!blockResponse.isSuccessful || blockResponse.body() == null) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
val blockRpcResponse = blockResponse.body()!!
|
|
||||||
if (blockRpcResponse.error != null || blockRpcResponse.result == null) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用 Gson 解析 block JSON(result 是 JsonElement)
|
|
||||||
val blockJson = blockRpcResponse.result.asJsonObject
|
|
||||||
val timestampHex = blockJson.get("timestamp")?.asString ?: return null
|
|
||||||
hexToBigInt(timestampHex).toLong()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
logger.warn("获取区块时间戳失败: ${e.message}")
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 辅助函数
|
|
||||||
private data class Erc20Transfer(val from: String, val to: String, val value: BigInteger)
|
|
||||||
private data class Erc1155Transfer(val from: String, val to: String, val tokenId: BigInteger, val value: BigInteger)
|
|
||||||
|
|
||||||
private fun topicToAddress(topic: String): String {
|
|
||||||
val t = topic.removePrefix("0x").lowercase()
|
|
||||||
return "0x" + t.takeLast(40)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hexToBigInt(hex: String): BigInteger {
|
|
||||||
val h = hex.removePrefix("0x")
|
|
||||||
if (h.isEmpty()) return BigInteger.ZERO
|
|
||||||
return BigInteger(h, 16)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun bytesFromHex(hex: String): ByteArray {
|
|
||||||
val s = hex.removePrefix("0x")
|
|
||||||
if (s.isEmpty()) return ByteArray(0)
|
|
||||||
val out = ByteArray(s.length / 2)
|
|
||||||
var i = 0
|
|
||||||
while (i < s.length) {
|
|
||||||
out[i / 2] = s.substring(i, i + 2).toInt(16).toByte()
|
|
||||||
i += 2
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sliceBigInt32(b: ByteArray, offset: Int): BigInteger {
|
|
||||||
val sub = b.copyOfRange(offset, offset + 32)
|
|
||||||
return BigInteger(1, sub)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 地址转换为 32 字节 topic(前 24 字节为 0,后 8 字节为地址)
|
|
||||||
*/
|
|
||||||
private fun addressToTopic32(address: String): String {
|
|
||||||
val addr = address.removePrefix("0x").lowercase()
|
|
||||||
return "0x" + "0".repeat(24) + addr
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreDestroy
|
@PreDestroy
|
||||||
fun destroy() {
|
fun destroy() {
|
||||||
stop()
|
stop()
|
||||||
scope.cancel()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+377
@@ -0,0 +1,377 @@
|
|||||||
|
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.*
|
||||||
|
import com.wrbug.polymarketbot.service.system.RpcNodeService
|
||||||
|
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import java.math.BigInteger
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 链上 WebSocket 工具类
|
||||||
|
* 提取公共的交易解析、工具函数等逻辑
|
||||||
|
*/
|
||||||
|
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"
|
||||||
|
const val ERC20_TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
|
||||||
|
const val ERC1155_TRANSFER_SINGLE_TOPIC = "0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62"
|
||||||
|
const val ERC1155_TRANSFER_BATCH_TOPIC = "0x4a39dc06d4c0dbc64b70af90fd698a233a518aa5d07e595d983b8c0526c8f7fb"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ERC20 Transfer 数据类
|
||||||
|
*/
|
||||||
|
data class Erc20Transfer(
|
||||||
|
val from: String,
|
||||||
|
val to: String,
|
||||||
|
val value: BigInteger
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ERC1155 Transfer 数据类
|
||||||
|
*/
|
||||||
|
data class Erc1155Transfer(
|
||||||
|
val from: String,
|
||||||
|
val to: String,
|
||||||
|
val tokenId: BigInteger,
|
||||||
|
val value: BigInteger
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 市场信息数据类
|
||||||
|
*/
|
||||||
|
data class MarketInfo(
|
||||||
|
val conditionId: String,
|
||||||
|
val outcomeIndex: Int?, // 可空,因为可能找不到对应的 tokenId
|
||||||
|
val outcome: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 receipt 中的 Transfer 日志
|
||||||
|
*/
|
||||||
|
fun parseReceiptTransfers(logs: JsonArray): Pair<List<Erc20Transfer>, List<Erc1155Transfer>> {
|
||||||
|
val erc20 = mutableListOf<Erc20Transfer>()
|
||||||
|
val erc1155 = mutableListOf<Erc1155Transfer>()
|
||||||
|
|
||||||
|
for (logElement in logs) {
|
||||||
|
val log = logElement.asJsonObject
|
||||||
|
val address = log.get("address")?.asString?.lowercase() ?: continue
|
||||||
|
val topicsArray = log.getAsJsonArray("topics") ?: continue
|
||||||
|
val topics = topicsArray.mapNotNull { it.asString }
|
||||||
|
if (topics.isEmpty()) continue
|
||||||
|
|
||||||
|
val t0 = topics[0].lowercase()
|
||||||
|
val data = log.get("data")?.asString ?: "0x"
|
||||||
|
|
||||||
|
// USDC ERC20 Transfer
|
||||||
|
if (address == USDC_CONTRACT.lowercase() && t0 == ERC20_TRANSFER_TOPIC && topics.size >= 3) {
|
||||||
|
val from = topicToAddress(topics[1])
|
||||||
|
val to = topicToAddress(topics[2])
|
||||||
|
val value = hexToBigInt(data)
|
||||||
|
erc20.add(Erc20Transfer(from, to, value))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// ERC1155 TransferSingle
|
||||||
|
if (t0 == ERC1155_TRANSFER_SINGLE_TOPIC && topics.size >= 4) {
|
||||||
|
val from = topicToAddress(topics[2])
|
||||||
|
val to = topicToAddress(topics[3])
|
||||||
|
val bytes = bytesFromHex(data)
|
||||||
|
if (bytes.size >= 64) {
|
||||||
|
val tokenId = sliceBigInt32(bytes, 0)
|
||||||
|
val value = sliceBigInt32(bytes, 32)
|
||||||
|
erc1155.add(Erc1155Transfer(from, to, tokenId, value))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// ERC1155 TransferBatch
|
||||||
|
if (t0 == ERC1155_TRANSFER_BATCH_TOPIC && topics.size >= 4) {
|
||||||
|
val from = topicToAddress(topics[2])
|
||||||
|
val to = topicToAddress(topics[3])
|
||||||
|
val bytes = bytesFromHex(data)
|
||||||
|
if (bytes.size < 64) continue
|
||||||
|
|
||||||
|
val offIds = sliceBigInt32(bytes, 0).toInt()
|
||||||
|
val offVals = sliceBigInt32(bytes, 32).toInt()
|
||||||
|
if (offIds + 32 > bytes.size || offVals + 32 > bytes.size) continue
|
||||||
|
|
||||||
|
val nIds = sliceBigInt32(bytes, offIds).toInt()
|
||||||
|
val nVals = sliceBigInt32(bytes, offVals).toInt()
|
||||||
|
if (nIds != nVals) continue
|
||||||
|
|
||||||
|
val idsStart = offIds + 32
|
||||||
|
val valsStart = offVals + 32
|
||||||
|
for (i in 0 until nIds) {
|
||||||
|
val ib = idsStart + i * 32
|
||||||
|
val vb = valsStart + i * 32
|
||||||
|
if (ib + 32 > bytes.size || vb + 32 > bytes.size) break
|
||||||
|
val tokenId = sliceBigInt32(bytes, ib)
|
||||||
|
val value = sliceBigInt32(bytes, vb)
|
||||||
|
erc1155.add(Erc1155Transfer(from, to, tokenId, value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Pair(erc20, erc1155)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 Transfer 日志解析交易信息
|
||||||
|
*/
|
||||||
|
suspend fun parseTradeFromTransfers(
|
||||||
|
txHash: String,
|
||||||
|
timestamp: Long?,
|
||||||
|
walletAddress: String,
|
||||||
|
erc20Transfers: List<Erc20Transfer>,
|
||||||
|
erc1155Transfers: List<Erc1155Transfer>,
|
||||||
|
retrofitFactory: RetrofitFactory
|
||||||
|
): TradeResponse? {
|
||||||
|
val wallet = walletAddress.lowercase()
|
||||||
|
|
||||||
|
// 计算 USDC 流入和流出
|
||||||
|
val usdcOut = erc20Transfers.filter { it.from.lowercase() == wallet }
|
||||||
|
.fold(BigInteger.ZERO) { acc, t -> acc + t.value }
|
||||||
|
val usdcIn = erc20Transfers.filter { it.to.lowercase() == wallet }
|
||||||
|
.fold(BigInteger.ZERO) { acc, t -> acc + t.value }
|
||||||
|
|
||||||
|
// 计算 ERC1155 流入和流出(按 tokenId 聚合)
|
||||||
|
val inById = mutableMapOf<BigInteger, BigInteger>()
|
||||||
|
val outById = mutableMapOf<BigInteger, BigInteger>()
|
||||||
|
for (t in erc1155Transfers) {
|
||||||
|
if (t.to.lowercase() == wallet) {
|
||||||
|
inById[t.tokenId] = (inById[t.tokenId] ?: BigInteger.ZERO) + t.value
|
||||||
|
}
|
||||||
|
if (t.from.lowercase() == wallet) {
|
||||||
|
outById[t.tokenId] = (outById[t.tokenId] ?: BigInteger.ZERO) + t.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 找到最大的流入和流出 tokenId
|
||||||
|
fun best(map: Map<BigInteger, BigInteger>): Pair<BigInteger?, BigInteger> =
|
||||||
|
map.entries.maxByOrNull { it.value }?.let { it.key to it.value } ?: (null to BigInteger.ZERO)
|
||||||
|
|
||||||
|
val (bestInId, bestInVal) = best(inById)
|
||||||
|
val (bestOutId, bestOutVal) = best(outById)
|
||||||
|
|
||||||
|
// 判断交易方向
|
||||||
|
var side: String? = null
|
||||||
|
var asset: BigInteger? = null
|
||||||
|
var sizeRaw = BigInteger.ZERO
|
||||||
|
var usdcRaw = BigInteger.ZERO
|
||||||
|
|
||||||
|
if (bestInId != null && bestInVal > BigInteger.ZERO && usdcOut > BigInteger.ZERO) {
|
||||||
|
// BUY: 收到 token,支付 USDC
|
||||||
|
side = "BUY"
|
||||||
|
asset = bestInId
|
||||||
|
sizeRaw = bestInVal
|
||||||
|
usdcRaw = usdcOut
|
||||||
|
} else if (bestOutId != null && bestOutVal > BigInteger.ZERO && usdcIn > BigInteger.ZERO) {
|
||||||
|
// SELL: 卖出 token,收到 USDC
|
||||||
|
side = "SELL"
|
||||||
|
asset = bestOutId
|
||||||
|
sizeRaw = bestOutVal
|
||||||
|
usdcRaw = usdcIn
|
||||||
|
} else {
|
||||||
|
// 无法判断交易方向
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算价格和数量(USDC 有 6 位小数,shares 也有 6 位小数)
|
||||||
|
val usdcSize = usdcRaw.toBigDecimal().divide(BigInteger("1000000").toBigDecimal(), 8, java.math.RoundingMode.DOWN)
|
||||||
|
val size = sizeRaw.toBigDecimal().divide(BigInteger("1000000").toBigDecimal(), 8, java.math.RoundingMode.DOWN)
|
||||||
|
val price = if (size.signum() > 0) {
|
||||||
|
usdcSize.divide(size, 8, java.math.RoundingMode.DOWN)
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试通过 Gamma API 查询市场信息(通过 tokenId)
|
||||||
|
val marketInfo = fetchMarketByTokenId(asset.toString(), retrofitFactory)
|
||||||
|
|
||||||
|
// 创建 TradeResponse
|
||||||
|
return TradeResponse(
|
||||||
|
id = txHash,
|
||||||
|
market = marketInfo?.conditionId ?: "",
|
||||||
|
side = side,
|
||||||
|
price = price.toPlainString(),
|
||||||
|
size = size.toPlainString(),
|
||||||
|
timestamp = (timestamp ?: System.currentTimeMillis() / 1000).toString(),
|
||||||
|
user = walletAddress,
|
||||||
|
outcomeIndex = marketInfo?.outcomeIndex,
|
||||||
|
outcome = marketInfo?.outcome
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过 Gamma API 查询市场信息(通过 tokenId)
|
||||||
|
* 使用 Retrofit 接口,支持 clob_token_ids 参数
|
||||||
|
*/
|
||||||
|
suspend fun fetchMarketByTokenId(tokenId: String, retrofitFactory: RetrofitFactory): MarketInfo? {
|
||||||
|
return try {
|
||||||
|
val gammaApi = retrofitFactory.createGammaApi()
|
||||||
|
val marketsResponse = gammaApi.listMarkets(
|
||||||
|
conditionIds = null,
|
||||||
|
clobTokenIds = listOf(tokenId),
|
||||||
|
includeTag = null
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!marketsResponse.isSuccessful || marketsResponse.body() == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val markets = marketsResponse.body()!!
|
||||||
|
val market = markets.firstOrNull()
|
||||||
|
|
||||||
|
if (market == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 clobTokenIds(可能是 JSON 字符串或数组)
|
||||||
|
val clobTokenIdsRaw = market.clobTokenIds ?: market.clob_token_ids
|
||||||
|
val clobTokenIds = when {
|
||||||
|
clobTokenIdsRaw == null -> null
|
||||||
|
else -> {
|
||||||
|
// 解析 JSON 字符串
|
||||||
|
parseStringArray(clobTokenIdsRaw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 outcomes(可能是 JSON 字符串或数组)
|
||||||
|
val outcomes = parseStringArray(market.outcomes)
|
||||||
|
|
||||||
|
// 查找 tokenId 在 clobTokenIds 中的索引
|
||||||
|
val outcomeIndex = clobTokenIds?.indexOfFirst { token ->
|
||||||
|
token.equals(tokenId, ignoreCase = true)
|
||||||
|
}?.takeIf { it >= 0 }
|
||||||
|
|
||||||
|
// 获取 outcome 名称
|
||||||
|
val outcome = if (outcomeIndex != null && outcomes.isNotEmpty() && outcomeIndex < outcomes.size) {
|
||||||
|
outcomes[outcomeIndex]
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
val conditionId = market.conditionId ?: return null
|
||||||
|
|
||||||
|
MarketInfo(
|
||||||
|
conditionId = conditionId,
|
||||||
|
outcomeIndex = outcomeIndex,
|
||||||
|
outcome = outcome
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.warn("查询市场信息失败: tokenId=$tokenId, error=${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取区块时间戳
|
||||||
|
*/
|
||||||
|
suspend fun getBlockTimestamp(blockNumber: String, rpcApi: EthereumRpcApi): Long? {
|
||||||
|
return try {
|
||||||
|
val blockRequest = JsonRpcRequest(
|
||||||
|
method = "eth_getBlockByNumber",
|
||||||
|
params = listOf(blockNumber, false)
|
||||||
|
)
|
||||||
|
|
||||||
|
val blockResponse = rpcApi.call(blockRequest)
|
||||||
|
if (blockResponse.isSuccessful && blockResponse.body() != null) {
|
||||||
|
val blockRpcResponse = blockResponse.body()!!
|
||||||
|
if (blockRpcResponse.error == null && blockRpcResponse.result != null) {
|
||||||
|
val blockJson = blockRpcResponse.result.asJsonObject
|
||||||
|
val timestampHex = blockJson.get("timestamp")?.asString
|
||||||
|
if (timestampHex != null) {
|
||||||
|
BigInteger(timestampHex.removePrefix("0x"), 16).toLong() * 1000 // 转换为毫秒
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.warn("获取区块时间戳失败: blockNumber=$blockNumber, error=${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工具函数:地址转 topic(32字节,左对齐)
|
||||||
|
*/
|
||||||
|
fun addressToTopic32(address: String): String {
|
||||||
|
val clean = address.removePrefix("0x").lowercase()
|
||||||
|
return "0x" + clean.padStart(64, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工具函数:topic 转地址
|
||||||
|
*/
|
||||||
|
fun topicToAddress(topic: String): String {
|
||||||
|
val clean = topic.removePrefix("0x").lowercase()
|
||||||
|
return "0x" + clean.takeLast(40)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工具函数:十六进制转 BigInteger
|
||||||
|
*/
|
||||||
|
fun hexToBigInt(hex: String): BigInteger {
|
||||||
|
val clean = hex.removePrefix("0x")
|
||||||
|
return if (clean.isBlank()) BigInteger.ZERO else BigInteger(clean, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工具函数:十六进制转字节数组
|
||||||
|
*/
|
||||||
|
fun bytesFromHex(hex: String): ByteArray {
|
||||||
|
val clean = hex.removePrefix("0x")
|
||||||
|
return ByteArray(clean.length / 2) { i ->
|
||||||
|
clean.substring(i * 2, i * 2 + 2).toInt(16).toByte()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工具函数:从字节数组切片 BigInteger(32字节)
|
||||||
|
*/
|
||||||
|
fun sliceBigInt32(bytes: ByteArray, offset: Int): BigInteger {
|
||||||
|
if (offset + 32 > bytes.size) return BigInteger.ZERO
|
||||||
|
val slice = bytes.sliceArray(offset until offset + 32)
|
||||||
|
return BigInteger(1, slice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+565
@@ -0,0 +1,565 @@
|
|||||||
|
package com.wrbug.polymarketbot.service.copytrading.monitor
|
||||||
|
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.JsonObject
|
||||||
|
import com.wrbug.polymarketbot.api.*
|
||||||
|
import com.wrbug.polymarketbot.service.system.RpcNodeService
|
||||||
|
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||||
|
import com.wrbug.polymarketbot.util.createClient
|
||||||
|
import com.wrbug.polymarketbot.util.getProxyConfig
|
||||||
|
import jakarta.annotation.PostConstruct
|
||||||
|
import jakarta.annotation.PreDestroy
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.WebSocket
|
||||||
|
import okhttp3.WebSocketListener
|
||||||
|
import okio.ByteString
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一的链上 WebSocket 服务
|
||||||
|
* 管理唯一的 WebSocket 连接,其他服务通过订阅的方式接收链上事件
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
class UnifiedOnChainWsService(
|
||||||
|
private val rpcNodeService: RpcNodeService,
|
||||||
|
private val retrofitFactory: RetrofitFactory,
|
||||||
|
private val gson: Gson
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val logger = LoggerFactory.getLogger(UnifiedOnChainWsService::class.java)
|
||||||
|
|
||||||
|
@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>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订阅信息
|
||||||
|
*/
|
||||||
|
data class SubscriptionInfo(
|
||||||
|
val subscriptionId: String, // 订阅的唯一标识
|
||||||
|
val address: String, // 要监听的地址(Leader 地址或账户代理地址)
|
||||||
|
val entityType: String, // 实体类型:LEADER 或 ACCOUNT
|
||||||
|
val entityId: Long, // 实体 ID(Leader ID 或 Account ID)
|
||||||
|
val callback: suspend (String, OkHttpClient, EthereumRpcApi) -> Unit // 回调函数
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订阅地址监听
|
||||||
|
* @param subscriptionId 订阅的唯一标识(建议格式:"{entityType}_{entityId}")
|
||||||
|
* @param address 要监听的地址(Leader 地址或账户代理地址)
|
||||||
|
* @param entityType 实体类型:LEADER 或 ACCOUNT
|
||||||
|
* @param entityId 实体 ID(Leader ID 或 Account ID)
|
||||||
|
* @param callback 回调函数,当检测到该地址的交易时调用
|
||||||
|
* @return 是否订阅成功
|
||||||
|
*/
|
||||||
|
fun subscribe(
|
||||||
|
subscriptionId: String,
|
||||||
|
address: String,
|
||||||
|
entityType: String,
|
||||||
|
entityId: Long,
|
||||||
|
callback: suspend (String, OkHttpClient, EthereumRpcApi) -> Unit
|
||||||
|
): Boolean {
|
||||||
|
try {
|
||||||
|
// 如果已经订阅,先取消
|
||||||
|
if (subscriptions.containsKey(subscriptionId)) {
|
||||||
|
unsubscribe(subscriptionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建订阅信息
|
||||||
|
val subscription = SubscriptionInfo(
|
||||||
|
subscriptionId = subscriptionId,
|
||||||
|
address = address.lowercase(),
|
||||||
|
entityType = entityType,
|
||||||
|
entityId = entityId,
|
||||||
|
callback = callback
|
||||||
|
)
|
||||||
|
|
||||||
|
subscriptions[subscriptionId] = subscription
|
||||||
|
|
||||||
|
// 如果已连接,立即订阅
|
||||||
|
if (isConnected) {
|
||||||
|
scope.launch {
|
||||||
|
subscribeAddress(subscription)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 如果未连接,启动连接
|
||||||
|
startConnection()
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("订阅地址监听: subscriptionId=$subscriptionId, address=$address, entityType=$entityType, entityId=$entityId")
|
||||||
|
return true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("订阅地址监听失败: subscriptionId=$subscriptionId, address=$address, error=${e.message}", e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消订阅
|
||||||
|
*/
|
||||||
|
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 (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 (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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
fun init() {
|
||||||
|
// 服务启动时不自动连接,等待有订阅时再连接
|
||||||
|
logger.info("统一链上 WebSocket 服务已初始化")
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
fun destroy() {
|
||||||
|
stop()
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+80
-211
@@ -37,7 +37,6 @@ open class CopyOrderTrackingService(
|
|||||||
private val sellMatchRecordRepository: SellMatchRecordRepository,
|
private val sellMatchRecordRepository: SellMatchRecordRepository,
|
||||||
private val sellMatchDetailRepository: SellMatchDetailRepository,
|
private val sellMatchDetailRepository: SellMatchDetailRepository,
|
||||||
private val processedTradeRepository: ProcessedTradeRepository,
|
private val processedTradeRepository: ProcessedTradeRepository,
|
||||||
private val failedTradeRepository: FailedTradeRepository,
|
|
||||||
private val filteredOrderRepository: FilteredOrderRepository,
|
private val filteredOrderRepository: FilteredOrderRepository,
|
||||||
private val copyTradingRepository: CopyTradingRepository,
|
private val copyTradingRepository: CopyTradingRepository,
|
||||||
private val accountRepository: AccountRepository,
|
private val accountRepository: AccountRepository,
|
||||||
@@ -125,88 +124,82 @@ open class CopyOrderTrackingService(
|
|||||||
|
|
||||||
return mutex.withLock {
|
return mutex.withLock {
|
||||||
try {
|
try {
|
||||||
// 1. 检查是否已处理(去重,包括失败状态)
|
// 1. 检查是否已处理(去重,包括失败状态)
|
||||||
val existingProcessed = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
|
val existingProcessed = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
|
||||||
|
|
||||||
if (existingProcessed != null) {
|
if (existingProcessed != null) {
|
||||||
if (existingProcessed.status == "FAILED") {
|
if (existingProcessed.status == "FAILED") {
|
||||||
return@withLock Result.success(Unit)
|
return@withLock Result.success(Unit)
|
||||||
}
|
}
|
||||||
return@withLock Result.success(Unit)
|
return@withLock Result.success(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否已记录为失败交易
|
// 2. 处理交易逻辑
|
||||||
val failedTrade = failedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
|
val result = when (trade.side.uppercase()) {
|
||||||
if (failedTrade != null) {
|
"BUY" -> processBuyTrade(leaderId, trade)
|
||||||
return@withLock Result.success(Unit)
|
"SELL" -> processSellTrade(leaderId, trade)
|
||||||
|
else -> {
|
||||||
|
logger.warn("未知的交易方向: ${trade.side}")
|
||||||
|
Result.failure(IllegalArgumentException("未知的交易方向: ${trade.side}"))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 2. 处理交易逻辑
|
if (result.isFailure) {
|
||||||
val result = when (trade.side.uppercase()) {
|
logger.error(
|
||||||
"BUY" -> processBuyTrade(leaderId, trade)
|
"处理交易失败: leaderId=$leaderId, tradeId=${trade.id}, side=${trade.side}",
|
||||||
"SELL" -> processSellTrade(leaderId, trade)
|
result.exceptionOrNull()
|
||||||
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
|
return@withLock result
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 标记为已处理(成功状态)
|
// 3. 标记为已处理(成功状态)
|
||||||
// 由于使用了 Mutex,这里理论上不会出现并发冲突,但保留异常处理作为兜底
|
// 由于使用了 Mutex,这里理论上不会出现并发冲突,但保留异常处理作为兜底
|
||||||
try {
|
try {
|
||||||
val processed = ProcessedTrade(
|
val processed = ProcessedTrade(
|
||||||
leaderId = leaderId,
|
leaderId = leaderId,
|
||||||
leaderTradeId = trade.id,
|
leaderTradeId = trade.id,
|
||||||
tradeType = trade.side.uppercase(),
|
tradeType = trade.side.uppercase(),
|
||||||
source = source,
|
source = source,
|
||||||
status = "SUCCESS",
|
status = "SUCCESS",
|
||||||
processedAt = System.currentTimeMillis()
|
processedAt = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
processedTradeRepository.save(processed)
|
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) {
|
} catch (e: Exception) {
|
||||||
logger.error("处理交易异常: leaderId=$leaderId, tradeId=${trade.id}", e)
|
// 检查是否是唯一键冲突异常(理论上不会发生,但保留作为兜底)
|
||||||
Result.failure(e)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -491,27 +484,6 @@ open class CopyOrderTrackingService(
|
|||||||
if (createOrderResult.isFailure) {
|
if (createOrderResult.isFailure) {
|
||||||
// 提取错误信息(只保留 code 和 errorBody)
|
// 提取错误信息(只保留 code 和 errorBody)
|
||||||
val exception = createOrderResult.exceptionOrNull()
|
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 // 已重试次数
|
|
||||||
)
|
|
||||||
|
|
||||||
// 发送订单失败通知(异步,不阻塞,仅在 pushFailedOrders 为 true 时发送)
|
// 发送订单失败通知(异步,不阻塞,仅在 pushFailedOrders 为 true 时发送)
|
||||||
if (copyTrading.pushFailedOrders) {
|
if (copyTrading.pushFailedOrders) {
|
||||||
@@ -594,7 +566,7 @@ open class CopyOrderTrackingService(
|
|||||||
)
|
)
|
||||||
|
|
||||||
copyOrderTrackingRepository.save(tracking)
|
copyOrderTrackingRepository.save(tracking)
|
||||||
|
|
||||||
logger.info("买入订单已保存,等待轮询任务获取实际数据后发送通知: orderId=$realOrderId, copyTradingId=${copyTrading.id}")
|
logger.info("买入订单已保存,等待轮询任务获取实际数据后发送通知: orderId=$realOrderId, copyTradingId=${copyTrading.id}")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("处理买入交易失败: copyTradingId=${copyTrading.id}, tradeId=${trade.id}", e)
|
logger.error("处理买入交易失败: copyTradingId=${copyTrading.id}, tradeId=${trade.id}", e)
|
||||||
@@ -971,31 +943,14 @@ open class CopyOrderTrackingService(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (createOrderResult.isFailure) {
|
if (createOrderResult.isFailure) {
|
||||||
// 创建订单失败,记录到失败表
|
// 创建订单失败,记录错误日志
|
||||||
val exception = createOrderResult.exceptionOrNull()
|
val exception = createOrderResult.exceptionOrNull()
|
||||||
val errorMsg = buildFullErrorMessage(
|
logger.error("创建卖出订单失败: copyTradingId=${copyTrading.id}, tradeId=${leaderSellTrade.id}, error=${exception?.message}")
|
||||||
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 // 已重试一次
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val realSellOrderId = createOrderResult.getOrNull() ?: return
|
val realSellOrderId = createOrderResult.getOrNull() ?: return
|
||||||
|
|
||||||
// 12. 下单时直接使用下单价格保存,等待定时任务更新实际成交价
|
// 12. 下单时直接使用下单价格保存,等待定时任务更新实际成交价
|
||||||
// priceUpdated 统一由定时任务更新,下单时统一设置为 false(非0x开头的除外)
|
// priceUpdated 统一由定时任务更新,下单时统一设置为 false(非0x开头的除外)
|
||||||
val priceUpdated = !realSellOrderId.startsWith("0x", ignoreCase = true)
|
val priceUpdated = !realSellOrderId.startsWith("0x", ignoreCase = true)
|
||||||
@@ -1248,98 +1203,6 @@ open class CopyOrderTrackingService(
|
|||||||
return "code=$code, errorBody=$errorBody"
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新订单状态
|
* 更新订单状态
|
||||||
@@ -1496,12 +1359,18 @@ open class CopyOrderTrackingService(
|
|||||||
return try {
|
return try {
|
||||||
// 1. 查询订单详情
|
// 1. 查询订单详情
|
||||||
val orderResponse = clobApi.getOrder(orderId)
|
val orderResponse = clobApi.getOrder(orderId)
|
||||||
if (!orderResponse.isSuccessful || orderResponse.body() == null) {
|
if (!orderResponse.isSuccessful) {
|
||||||
logger.warn("查询订单详情失败: orderId=$orderId, code=${orderResponse.code()}")
|
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
|
return fallbackPrice
|
||||||
}
|
}
|
||||||
|
|
||||||
val order = orderResponse.body()!!
|
|
||||||
|
|
||||||
// 2. 如果订单未成交,使用下单价格
|
// 2. 如果订单未成交,使用下单价格
|
||||||
if (order.status != "FILLED" && order.sizeMatched.toSafeBigDecimal() <= BigDecimal.ZERO) {
|
if (order.status != "FILLED" && order.sizeMatched.toSafeBigDecimal() <= BigDecimal.ZERO) {
|
||||||
|
|||||||
+3
-3
@@ -341,9 +341,9 @@ class CopyTradingStatisticsService(
|
|||||||
// 传递 outcomeIndex 参数,确保获取对应 outcome 的价格
|
// 传递 outcomeIndex 参数,确保获取对应 outcome 的价格
|
||||||
val result = accountService.getMarketPrice(marketId, outcomeIndex)
|
val result = accountService.getMarketPrice(marketId, outcomeIndex)
|
||||||
result.onSuccess { response ->
|
result.onSuccess { response ->
|
||||||
// 使用中间价,如果没有则使用最后价格
|
// 使用当前价格
|
||||||
val price = response.midpoint ?: response.lastPrice
|
val price = response.currentPrice
|
||||||
if (price != null) {
|
if (price.isNotBlank() && price != "0") {
|
||||||
// 使用 "marketId:outcomeIndex" 作为 key
|
// 使用 "marketId:outcomeIndex" 作为 key
|
||||||
val key = "$marketId:$outcomeIndex"
|
val key = "$marketId:$outcomeIndex"
|
||||||
prices[key] = price
|
prices[key] = price
|
||||||
|
|||||||
+185
-15
@@ -56,10 +56,13 @@ class OrderStatusUpdateService(
|
|||||||
// 1. 清理已删除账户的订单
|
// 1. 清理已删除账户的订单
|
||||||
cleanupDeletedAccountOrders()
|
cleanupDeletedAccountOrders()
|
||||||
|
|
||||||
// 2. 更新卖出订单的实际成交价并发送通知(priceUpdated 共用字段)
|
// 2. 检查30秒前创建的订单,如果未成交则删除
|
||||||
|
checkAndDeleteUnfilledOrders()
|
||||||
|
|
||||||
|
// 3. 更新卖出订单的实际成交价并发送通知(priceUpdated 共用字段)
|
||||||
updatePendingSellOrderPrices()
|
updatePendingSellOrderPrices()
|
||||||
|
|
||||||
// 3. 更新买入订单的实际数据并发送通知
|
// 4. 更新买入订单的实际数据并发送通知
|
||||||
updatePendingBuyOrders()
|
updatePendingBuyOrders()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("订单状态更新异常: ${e.message}", e)
|
logger.error("订单状态更新异常: ${e.message}", e)
|
||||||
@@ -130,6 +133,124 @@ class OrderStatusUpdateService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查30秒前创建的订单,如果未成交则删除
|
||||||
|
* 首次检测但加入缓存中30s后还没有成交,则删除
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
private suspend fun checkAndDeleteUnfilledOrders() {
|
||||||
|
try {
|
||||||
|
// 计算30秒前的时间戳
|
||||||
|
val thirtySecondsAgo = System.currentTimeMillis() - 30000
|
||||||
|
|
||||||
|
// 查询30秒前创建的订单
|
||||||
|
val ordersToCheck = copyOrderTrackingRepository.findByCreatedAtBefore(thirtySecondsAgo)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
if (!orderResponse.isSuccessful) {
|
||||||
|
// HTTP 错误,可能是订单不存在,删除
|
||||||
|
logger.info("订单查询失败(HTTP错误),删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, code=${orderResponse.code()}")
|
||||||
|
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 orderDetail = orderResponse.body()
|
||||||
|
if (orderDetail == null) {
|
||||||
|
// HTTP 200 但响应体为空,表示订单不存在,删除
|
||||||
|
logger.info("订单不存在(响应体为空),删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, code=${orderResponse.code()}")
|
||||||
|
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 现在同时表示价格已更新和通知已发送(共用字段)
|
* 注意:priceUpdated 现在同时表示价格已更新和通知已发送(共用字段)
|
||||||
@@ -194,16 +315,27 @@ class OrderStatusUpdateService(
|
|||||||
// 如果 orderId 不是 0x 开头,直接标记为已处理(priceUpdated = true 表示已处理,包括价格更新和通知发送)
|
// 如果 orderId 不是 0x 开头,直接标记为已处理(priceUpdated = true 表示已处理,包括价格更新和通知发送)
|
||||||
if (!record.sellOrderId.startsWith("0x", ignoreCase = true)) {
|
if (!record.sellOrderId.startsWith("0x", ignoreCase = true)) {
|
||||||
logger.debug("卖出订单ID非0x开头,直接标记为已处理: orderId=${record.sellOrderId}")
|
logger.debug("卖出订单ID非0x开头,直接标记为已处理: orderId=${record.sellOrderId}")
|
||||||
// 发送通知(使用临时数据)
|
|
||||||
sendSellOrderNotification(
|
// 检查是否为自动生成的订单(AUTO_ 或 AUTO_FIFO_ 开头),如果是则不发送通知
|
||||||
record = record,
|
val isAutoOrder = record.sellOrderId.startsWith("AUTO_", ignoreCase = true) ||
|
||||||
useTemporaryData = true,
|
record.sellOrderId.startsWith("AUTO_FIFO_", ignoreCase = true) ||
|
||||||
account = account,
|
record.sellOrderId.startsWith("AUTO_WS_", ignoreCase = true)
|
||||||
copyTrading = copyTrading,
|
|
||||||
clobApi = clobApi,
|
if (!isAutoOrder) {
|
||||||
apiSecret = apiSecret,
|
// 非自动订单,发送通知(使用临时数据)
|
||||||
apiPassphrase = apiPassphrase
|
sendSellOrderNotification(
|
||||||
)
|
record = record,
|
||||||
|
useTemporaryData = true,
|
||||||
|
account = account,
|
||||||
|
copyTrading = copyTrading,
|
||||||
|
clobApi = clobApi,
|
||||||
|
apiSecret = apiSecret,
|
||||||
|
apiPassphrase = apiPassphrase
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
logger.debug("自动生成的订单,跳过发送通知: orderId=${record.sellOrderId}")
|
||||||
|
}
|
||||||
|
|
||||||
// 标记为已处理(priceUpdated = true 同时表示价格已更新和通知已发送)
|
// 标记为已处理(priceUpdated = true 同时表示价格已更新和通知已发送)
|
||||||
val updatedRecord = SellMatchRecord(
|
val updatedRecord = SellMatchRecord(
|
||||||
id = record.id,
|
id = record.id,
|
||||||
@@ -223,6 +355,32 @@ class OrderStatusUpdateService(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查是否为自动生成的订单(AUTO_ 或 AUTO_FIFO_ 开头),如果是则跳过发送通知
|
||||||
|
val isAutoOrder = record.sellOrderId.startsWith("AUTO_", ignoreCase = true) ||
|
||||||
|
record.sellOrderId.startsWith("AUTO_FIFO_", ignoreCase = true) ||
|
||||||
|
record.sellOrderId.startsWith("AUTO_WS_", ignoreCase = true)
|
||||||
|
|
||||||
|
if (isAutoOrder) {
|
||||||
|
logger.debug("自动生成的订单,跳过发送通知并直接标记为已处理: orderId=${record.sellOrderId}")
|
||||||
|
// 直接标记为已处理,不发送通知
|
||||||
|
val updatedRecord = SellMatchRecord(
|
||||||
|
id = record.id,
|
||||||
|
copyTradingId = record.copyTradingId,
|
||||||
|
sellOrderId = record.sellOrderId,
|
||||||
|
leaderSellTradeId = record.leaderSellTradeId,
|
||||||
|
marketId = record.marketId,
|
||||||
|
side = record.side,
|
||||||
|
outcomeIndex = record.outcomeIndex,
|
||||||
|
totalMatchedQuantity = record.totalMatchedQuantity,
|
||||||
|
sellPrice = record.sellPrice,
|
||||||
|
totalRealizedPnl = record.totalRealizedPnl,
|
||||||
|
priceUpdated = true, // 标记为已处理
|
||||||
|
createdAt = record.createdAt
|
||||||
|
)
|
||||||
|
sellMatchRecordRepository.save(updatedRecord)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// 查询订单详情,获取实际成交价
|
// 查询订单详情,获取实际成交价
|
||||||
val actualSellPrice = trackingService.getActualExecutionPrice(
|
val actualSellPrice = trackingService.getActualExecutionPrice(
|
||||||
orderId = record.sellOrderId,
|
orderId = record.sellOrderId,
|
||||||
@@ -419,12 +577,24 @@ class OrderStatusUpdateService(
|
|||||||
|
|
||||||
// 查询订单详情
|
// 查询订单详情
|
||||||
val orderResponse = clobApi.getOrder(order.buyOrderId)
|
val orderResponse = clobApi.getOrder(order.buyOrderId)
|
||||||
if (!orderResponse.isSuccessful || orderResponse.body() == null) {
|
if (!orderResponse.isSuccessful) {
|
||||||
logger.debug("查询订单详情失败,等待下次轮询: orderId=${order.buyOrderId}, code=${orderResponse.code()}")
|
val errorBody = orderResponse.errorBody()?.string()?.take(200) ?: "无错误详情"
|
||||||
|
logger.debug("查询订单详情失败,等待下次轮询: orderId=${order.buyOrderId}, code=${orderResponse.code()}, errorBody=$errorBody")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
val orderDetail = orderResponse.body()!!
|
val orderDetail = orderResponse.body()
|
||||||
|
if (orderDetail == null) {
|
||||||
|
// HTTP 200 但响应体为空,表示订单不存在(没有交易成功),删除本地订单
|
||||||
|
logger.info("订单不存在(响应体为空),删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, code=${orderResponse.code()}")
|
||||||
|
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
|
val actualPrice = orderDetail.price?.toSafeBigDecimal() ?: order.price
|
||||||
|
|||||||
+3
-21
@@ -28,8 +28,6 @@ class ApiHealthCheckService(
|
|||||||
private val dataApiBaseUrl: String,
|
private val dataApiBaseUrl: String,
|
||||||
@Value("\${polymarket.gamma.base-url}")
|
@Value("\${polymarket.gamma.base-url}")
|
||||||
private val gammaBaseUrl: String,
|
private val gammaBaseUrl: String,
|
||||||
@Value("\${polygon.rpc.url:}")
|
|
||||||
private val polygonRpcUrl: String,
|
|
||||||
@Value("\${polymarket.rtds.ws-url}")
|
@Value("\${polymarket.rtds.ws-url}")
|
||||||
private val polymarketWsUrl: String,
|
private val polymarketWsUrl: String,
|
||||||
@Value("\${polymarket.builder.relayer-url:}")
|
@Value("\${polymarket.builder.relayer-url:}")
|
||||||
@@ -187,27 +185,11 @@ class ApiHealthCheckService(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查 Polygon RPC
|
* 检查 Polygon RPC
|
||||||
* 使用动态获取的可用节点,而不是固定的配置
|
* 使用动态获取的可用节点(RpcNodeService 总是返回一个有效的 URL,包括默认节点)
|
||||||
*/
|
*/
|
||||||
private suspend fun checkPolygonRpc(): ApiHealthCheckDto = withContext(Dispatchers.IO) {
|
private suspend fun checkPolygonRpc(): ApiHealthCheckDto = withContext(Dispatchers.IO) {
|
||||||
// 优先使用动态获取的可用节点
|
// 使用 RpcNodeService 获取可用节点(总是返回有效值,包括默认节点)
|
||||||
val rpcUrl = try {
|
val rpcUrl = rpcNodeService.getHttpUrl()
|
||||||
rpcNodeService.getHttpUrl()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
logger.debug("获取可用 RPC 节点失败,使用配置的默认值: ${e.message}")
|
|
||||||
// 如果获取失败,使用配置的默认值作为兜底
|
|
||||||
if (polygonRpcUrl.isNotBlank()) {
|
|
||||||
polygonRpcUrl
|
|
||||||
} else {
|
|
||||||
return@withContext ApiHealthCheckDto(
|
|
||||||
name = "Polygon RPC",
|
|
||||||
url = "未配置",
|
|
||||||
status = "skipped",
|
|
||||||
message = "未配置 Polygon RPC URL 且没有可用的节点"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
checkJsonRpcApi("Polygon RPC", rpcUrl)
|
checkJsonRpcApi("Polygon RPC", rpcUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+108
-72
@@ -97,21 +97,28 @@ class TelegramNotificationService(
|
|||||||
if ((actualPrice == null || actualSize == null) && orderId != null && clobApi != null && apiKey != null && apiSecret != null && apiPassphrase != null && walletAddressForApi != null) {
|
if ((actualPrice == null || actualSize == null) && orderId != null && clobApi != null && apiKey != null && apiSecret != null && apiPassphrase != null && walletAddressForApi != null) {
|
||||||
try {
|
try {
|
||||||
val orderResponse = clobApi.getOrder(orderId)
|
val orderResponse = clobApi.getOrder(orderId)
|
||||||
if (orderResponse.isSuccessful && orderResponse.body() != null) {
|
if (orderResponse.isSuccessful) {
|
||||||
val order = orderResponse.body()!!
|
val order = orderResponse.body()
|
||||||
if (actualPrice == null) {
|
if (order != null) {
|
||||||
actualPrice = order.price
|
if (actualPrice == null) {
|
||||||
}
|
actualPrice = order.price
|
||||||
if (actualSize == null) {
|
}
|
||||||
actualSize = order.originalSize // 使用 originalSize 作为订单数量
|
if (actualSize == null) {
|
||||||
}
|
actualSize = order.originalSize // 使用 originalSize 作为订单数量
|
||||||
actualSide = order.side // 使用订单详情中的 side
|
}
|
||||||
if (actualOutcome == null) {
|
actualSide = order.side // 使用订单详情中的 side
|
||||||
actualOutcome = order.outcome // 使用订单详情中的 outcome(市场方向)
|
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) {
|
} catch (e: Exception) {
|
||||||
logger.warn("查询订单详情失败: ${e.message}", e)
|
logger.warn("查询订单详情失败: orderId=$orderId, ${e.message}", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,18 +313,8 @@ class TelegramNotificationService(
|
|||||||
else -> filterType
|
else -> filterType
|
||||||
}
|
}
|
||||||
|
|
||||||
// 优先使用账户名称,如果没有账户名称才显示钱包地址
|
// 构建账户信息(格式:账户名(钱包地址))
|
||||||
val accountInfo: String = when {
|
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
|
||||||
!accountName.isNullOrBlank() -> {
|
|
||||||
accountName!!
|
|
||||||
}
|
|
||||||
!walletAddress.isNullOrBlank() -> {
|
|
||||||
maskAddress(walletAddress!!)
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
unknownAccount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val time = DateUtils.formatDateTime()
|
val time = DateUtils.formatDateTime()
|
||||||
|
|
||||||
@@ -369,13 +366,17 @@ class TelegramNotificationService(
|
|||||||
""
|
""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 格式化价格和数量
|
||||||
|
val priceDisplay = formatPrice(price)
|
||||||
|
val sizeDisplay = formatQuantity(size)
|
||||||
|
|
||||||
return """🚫 <b>$orderFiltered</b>
|
return """🚫 <b>$orderFiltered</b>
|
||||||
|
|
||||||
📊 <b>$orderInfo:</b>
|
📊 <b>$orderInfo:</b>
|
||||||
• $marketLabel: $marketDisplay$outcomeDisplay
|
• $marketLabel: $marketDisplay$outcomeDisplay
|
||||||
• $sideLabel: <b>$sideDisplay</b>
|
• $sideLabel: <b>$sideDisplay</b>
|
||||||
• $priceLabel: <code>$price</code>
|
• $priceLabel: <code>$priceDisplay</code>
|
||||||
• $quantityLabel: <code>$size</code> shares
|
• $quantityLabel: <code>$sizeDisplay</code> shares
|
||||||
• $amountLabel: <code>$amountDisplay</code> USDC
|
• $amountLabel: <code>$amountDisplay</code> USDC
|
||||||
• $accountLabel: $escapedAccountInfo
|
• $accountLabel: $escapedAccountInfo
|
||||||
|
|
||||||
@@ -580,6 +581,68 @@ class TelegramNotificationService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化价格显示(保留最多4位小数,截断不四舍五入)
|
||||||
|
*/
|
||||||
|
private fun formatPrice(price: String): String {
|
||||||
|
return try {
|
||||||
|
val priceDecimal = price.toSafeBigDecimal()
|
||||||
|
val formatted = if (priceDecimal.scale() > 4) {
|
||||||
|
priceDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros()
|
||||||
|
} else {
|
||||||
|
priceDecimal.stripTrailingZeros()
|
||||||
|
}
|
||||||
|
formatted.toPlainString()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
price
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化数量显示(保留最多2位小数,截断不四舍五入)
|
||||||
|
*/
|
||||||
|
private fun formatQuantity(quantity: String): String {
|
||||||
|
return try {
|
||||||
|
val quantityDecimal = quantity.toSafeBigDecimal()
|
||||||
|
val formatted = if (quantityDecimal.scale() > 2) {
|
||||||
|
quantityDecimal.setScale(2, java.math.RoundingMode.DOWN).stripTrailingZeros()
|
||||||
|
} else {
|
||||||
|
quantityDecimal.stripTrailingZeros()
|
||||||
|
}
|
||||||
|
formatted.toPlainString()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
quantity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建账户信息显示(格式:账户名(钱包地址))
|
||||||
|
*/
|
||||||
|
private fun buildAccountInfo(
|
||||||
|
accountName: String?,
|
||||||
|
walletAddress: String?,
|
||||||
|
unknownAccount: String
|
||||||
|
): String {
|
||||||
|
return when {
|
||||||
|
!accountName.isNullOrBlank() && !walletAddress.isNullOrBlank() -> {
|
||||||
|
// 有账户名和钱包地址:账户名(钱包地址)
|
||||||
|
"${accountName}(${maskAddress(walletAddress)})"
|
||||||
|
}
|
||||||
|
!accountName.isNullOrBlank() -> {
|
||||||
|
// 只有账户名
|
||||||
|
accountName
|
||||||
|
}
|
||||||
|
!walletAddress.isNullOrBlank() -> {
|
||||||
|
// 只有钱包地址
|
||||||
|
maskAddress(walletAddress)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// 都没有
|
||||||
|
unknownAccount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构建订单成功消息
|
* 构建订单成功消息
|
||||||
*/
|
*/
|
||||||
@@ -623,18 +686,8 @@ class TelegramNotificationService(
|
|||||||
else -> side
|
else -> side
|
||||||
}
|
}
|
||||||
|
|
||||||
// 优先使用账户名称,如果没有账户名称才显示钱包地址
|
// 构建账户信息(格式:账户名(钱包地址))
|
||||||
val accountInfo: String = when {
|
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
|
||||||
!accountName.isNullOrBlank() -> {
|
|
||||||
accountName!!
|
|
||||||
}
|
|
||||||
!walletAddress.isNullOrBlank() -> {
|
|
||||||
maskAddress(walletAddress!!)
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
unknownAccount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构建跟单信息(如果有)
|
// 构建跟单信息(如果有)
|
||||||
val copyTradingInfo = mutableListOf<String>()
|
val copyTradingInfo = mutableListOf<String>()
|
||||||
@@ -704,14 +757,18 @@ class TelegramNotificationService(
|
|||||||
""
|
""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 格式化价格和数量
|
||||||
|
val priceDisplay = formatPrice(price)
|
||||||
|
val sizeDisplay = formatQuantity(size)
|
||||||
|
|
||||||
return """✅ <b>$orderCreatedSuccess</b>
|
return """✅ <b>$orderCreatedSuccess</b>
|
||||||
|
|
||||||
📊 <b>$orderInfo:</b>
|
📊 <b>$orderInfo:</b>
|
||||||
• $orderIdLabel: <code>${orderId ?: unknown}</code>
|
• $orderIdLabel: <code>${orderId ?: unknown}</code>
|
||||||
• $marketLabel: $marketDisplay$outcomeDisplay
|
• $marketLabel: $marketDisplay$outcomeDisplay
|
||||||
• $sideLabel: <b>$sideDisplay</b>
|
• $sideLabel: <b>$sideDisplay</b>
|
||||||
• $priceLabel: <code>$price</code>
|
• $priceLabel: <code>$priceDisplay</code>
|
||||||
• $quantityLabel: <code>$size</code> shares
|
• $quantityLabel: <code>$sizeDisplay</code> shares
|
||||||
• $amountLabel: <code>$amountDisplay</code> USDC
|
• $amountLabel: <code>$amountDisplay</code> USDC
|
||||||
• $accountLabel: $escapedAccountInfo$escapedCopyTradingInfo
|
• $accountLabel: $escapedAccountInfo$escapedCopyTradingInfo
|
||||||
|
|
||||||
@@ -758,18 +815,8 @@ class TelegramNotificationService(
|
|||||||
else -> side
|
else -> side
|
||||||
}
|
}
|
||||||
|
|
||||||
// 优先使用账户名称,如果没有账户名称才显示钱包地址
|
// 构建账户信息(格式:账户名(钱包地址))
|
||||||
val accountInfo: String = when {
|
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
|
||||||
!accountName.isNullOrBlank() -> {
|
|
||||||
accountName!!
|
|
||||||
}
|
|
||||||
!walletAddress.isNullOrBlank() -> {
|
|
||||||
maskAddress(walletAddress!!)
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
unknownAccount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val time = DateUtils.formatDateTime()
|
val time = DateUtils.formatDateTime()
|
||||||
|
|
||||||
@@ -828,13 +875,17 @@ class TelegramNotificationService(
|
|||||||
""
|
""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 格式化价格和数量
|
||||||
|
val priceDisplay = formatPrice(price)
|
||||||
|
val sizeDisplay = formatQuantity(size)
|
||||||
|
|
||||||
return """❌ <b>$orderCreatedFailed</b>
|
return """❌ <b>$orderCreatedFailed</b>
|
||||||
|
|
||||||
📊 <b>$orderInfo:</b>
|
📊 <b>$orderInfo:</b>
|
||||||
• $marketLabel: $marketDisplay$outcomeDisplay
|
• $marketLabel: $marketDisplay$outcomeDisplay
|
||||||
• $sideLabel: <b>$sideDisplay</b>
|
• $sideLabel: <b>$sideDisplay</b>
|
||||||
• $priceLabel: <code>$price</code>
|
• $priceLabel: <code>$priceDisplay</code>
|
||||||
• $quantityLabel: <code>$size</code> shares
|
• $quantityLabel: <code>$sizeDisplay</code> shares
|
||||||
• $amountLabel: <code>$amountDisplay</code> USDC
|
• $amountLabel: <code>$amountDisplay</code> USDC
|
||||||
• $accountLabel: $escapedAccountInfo
|
• $accountLabel: $escapedAccountInfo
|
||||||
|
|
||||||
@@ -899,18 +950,8 @@ class TelegramNotificationService(
|
|||||||
val timeLabel = messageSource.getMessage("notification.order.time", null, "时间", locale)
|
val timeLabel = messageSource.getMessage("notification.order.time", null, "时间", locale)
|
||||||
val unknownAccount: String = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", locale) ?: "未知账户"
|
val unknownAccount: String = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", locale) ?: "未知账户"
|
||||||
|
|
||||||
// 优先使用账户名称,如果没有账户名称才显示钱包地址
|
// 构建账户信息(格式:账户名(钱包地址))
|
||||||
val accountInfo: String = when {
|
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
|
||||||
!accountName.isNullOrBlank() -> {
|
|
||||||
accountName!!
|
|
||||||
}
|
|
||||||
!walletAddress.isNullOrBlank() -> {
|
|
||||||
maskAddress(walletAddress!!)
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
unknownAccount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val time = DateUtils.formatDateTime()
|
val time = DateUtils.formatDateTime()
|
||||||
|
|
||||||
@@ -933,12 +974,7 @@ class TelegramNotificationService(
|
|||||||
|
|
||||||
// 构建仓位列表
|
// 构建仓位列表
|
||||||
val positionsText = positions.joinToString("\n") { position ->
|
val positionsText = positions.joinToString("\n") { position ->
|
||||||
val quantityDisplay = try {
|
val quantityDisplay = formatQuantity(position.quantity)
|
||||||
val quantityDecimal = position.quantity.toSafeBigDecimal()
|
|
||||||
quantityDecimal.stripTrailingZeros().toPlainString()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
position.quantity
|
|
||||||
}
|
|
||||||
val valueDisplay = try {
|
val valueDisplay = try {
|
||||||
val valueDecimal = position.value.toSafeBigDecimal()
|
val valueDecimal = position.value.toSafeBigDecimal()
|
||||||
val formatted = if (valueDecimal.scale() > 4) {
|
val formatted = if (valueDecimal.scale() > 4) {
|
||||||
|
|||||||
@@ -77,6 +77,67 @@ object EthereumUtils {
|
|||||||
return BigInteger(cleanHex, 16)
|
return BigInteger(cleanHex, 16)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 ABI 编码的响应中解析 uint256 数组
|
||||||
|
* ABI 编码格式:
|
||||||
|
* - offset (32 bytes): 数组数据的位置偏移量
|
||||||
|
* - length (32 bytes): 数组长度
|
||||||
|
* - data: 每个元素 32 字节
|
||||||
|
* @param hexResult 十六进制结果(完整的 ABI 编码响应)
|
||||||
|
* @param offset 数组数据的偏移位置(字节数,从 offset 位置开始读取)
|
||||||
|
* @return BigInteger 数组
|
||||||
|
*/
|
||||||
|
fun decodeUint256Array(hexResult: String, offset: Int = 0): List<BigInteger> {
|
||||||
|
val cleanHex = hexResult.removePrefix("0x")
|
||||||
|
if (cleanHex.length < (offset + 1) * 64) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 offset 位置开始读取
|
||||||
|
val startPos = offset * 64 // 每个 uint256 是 64 个十六进制字符
|
||||||
|
val lengthHex = cleanHex.substring(startPos, startPos + 64)
|
||||||
|
val length = BigInteger(lengthHex, 16).toInt()
|
||||||
|
|
||||||
|
if (length <= 0 || length > 100) { // 防止异常数据
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = mutableListOf<BigInteger>()
|
||||||
|
for (i in 0 until length) {
|
||||||
|
val elementStart = startPos + 64 + (i * 64) // 跳过长度字段
|
||||||
|
if (elementStart + 64 > cleanHex.length) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
val elementHex = cleanHex.substring(elementStart, elementStart + 64)
|
||||||
|
result.add(BigInteger(elementHex, 16))
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 ABI 编码的元组响应中解析数据
|
||||||
|
* 用于解析 getCondition 返回的 (uint256 payoutDenominator, uint256[] payouts)
|
||||||
|
* @param hexResult 十六进制结果
|
||||||
|
* @return Pair<payoutDenominator, payouts>
|
||||||
|
*/
|
||||||
|
fun decodeConditionResult(hexResult: String): Pair<BigInteger, List<BigInteger>> {
|
||||||
|
val cleanHex = hexResult.removePrefix("0x")
|
||||||
|
|
||||||
|
// 第一个 32 字节:payoutDenominator
|
||||||
|
val payoutDenominatorHex = cleanHex.substring(0, 64)
|
||||||
|
val payoutDenominator = BigInteger(payoutDenominatorHex, 16)
|
||||||
|
|
||||||
|
// 第二个 32 字节:payouts 数组的偏移量(通常是 0x40 = 64 字节)
|
||||||
|
val offsetHex = cleanHex.substring(64, 128)
|
||||||
|
val offset = BigInteger(offsetHex, 16).toInt() / 32 // 转换为 32 字节单位
|
||||||
|
|
||||||
|
// 从 offset 位置解析数组
|
||||||
|
val payouts = decodeUint256Array(hexResult, offset)
|
||||||
|
|
||||||
|
return Pair(payoutDenominator, payouts)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 计算 Keccak-256 哈希(Ethereum 标准)
|
* 计算 Keccak-256 哈希(Ethereum 标准)
|
||||||
* 使用 BouncyCastle 库实现真正的 Keccak-256
|
* 使用 BouncyCastle 库实现真正的 Keccak-256
|
||||||
|
|||||||
@@ -2,14 +2,16 @@ package com.wrbug.polymarketbot.util
|
|||||||
|
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.google.gson.reflect.TypeToken
|
import com.google.gson.reflect.TypeToken
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JSON 工具类
|
* JSON 工具类
|
||||||
* 用于解析 JSON 字符串
|
* 用于解析 JSON 字符串
|
||||||
*/
|
*/
|
||||||
object JsonUtils {
|
@Component
|
||||||
|
class JsonUtils(
|
||||||
private val gson = Gson()
|
private val gson: Gson
|
||||||
|
) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析 JSON 字符串数组
|
* 解析 JSON 字符串数组
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.wrbug.polymarketbot.util
|
package com.wrbug.polymarketbot.util
|
||||||
|
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.google.gson.GsonBuilder
|
|
||||||
import com.wrbug.polymarketbot.api.BuilderRelayerApi
|
import com.wrbug.polymarketbot.api.BuilderRelayerApi
|
||||||
import com.wrbug.polymarketbot.api.EthereumRpcApi
|
import com.wrbug.polymarketbot.api.EthereumRpcApi
|
||||||
import com.wrbug.polymarketbot.api.GitHubApi
|
import com.wrbug.polymarketbot.api.GitHubApi
|
||||||
@@ -24,21 +23,116 @@ import org.springframework.stereotype.Component
|
|||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.converter.gson.GsonConverterFactory
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import jakarta.annotation.PreDestroy
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrofit 客户端工厂
|
* Retrofit 客户端工厂
|
||||||
* 用于创建带认证的 Polymarket CLOB API 客户端和 Ethereum RPC API 客户端
|
* 用于创建带认证的 Polymarket CLOB API 客户端和 Ethereum RPC API 客户端
|
||||||
|
*
|
||||||
|
* 注意:为了避免内存泄漏,本类会缓存和复用客户端实例
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
class RetrofitFactory(
|
class RetrofitFactory(
|
||||||
@Value("\${polymarket.clob.base-url}")
|
@Value("\${polymarket.clob.base-url}")
|
||||||
private val clobBaseUrl: String,
|
private val clobBaseUrl: String,
|
||||||
@Value("\${polymarket.gamma.base-url}")
|
@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 客户端
|
* 创建带认证的 Polymarket CLOB API 客户端
|
||||||
|
* 按钱包地址缓存,避免重复创建
|
||||||
* @param apiKey API Key
|
* @param apiKey API Key
|
||||||
* @param apiSecret API Secret
|
* @param apiSecret API Secret
|
||||||
* @param apiPassphrase API Passphrase
|
* @param apiPassphrase API Passphrase
|
||||||
@@ -51,67 +145,46 @@ class RetrofitFactory(
|
|||||||
apiPassphrase: String,
|
apiPassphrase: String,
|
||||||
walletAddress: String
|
walletAddress: String
|
||||||
): PolymarketClobApi {
|
): PolymarketClobApi {
|
||||||
val authInterceptor = PolymarketAuthInterceptor(apiKey, apiSecret, apiPassphrase, walletAddress)
|
// 使用钱包地址作为缓存键(每个账户使用不同的 API Key)
|
||||||
|
return clobApiCache.computeIfAbsent(walletAddress) {
|
||||||
// 添加响应日志拦截器,用于调试 JSON 解析错误
|
val authInterceptor = PolymarketAuthInterceptor(apiKey, apiSecret, apiPassphrase, walletAddress)
|
||||||
val responseLoggingInterceptor = ResponseLoggingInterceptor()
|
|
||||||
|
// 添加响应日志拦截器,用于调试 JSON 解析错误
|
||||||
val okHttpClient = createClient()
|
val responseLoggingInterceptor = ResponseLoggingInterceptor()
|
||||||
.addInterceptor(authInterceptor)
|
|
||||||
.addInterceptor(responseLoggingInterceptor)
|
val okHttpClient = createClient()
|
||||||
.build()
|
.addInterceptor(authInterceptor)
|
||||||
|
.addInterceptor(responseLoggingInterceptor)
|
||||||
// 创建 lenient 模式的 Gson,允许解析格式不严格的 JSON
|
.build()
|
||||||
val gson = GsonBuilder()
|
|
||||||
.setLenient()
|
Retrofit.Builder()
|
||||||
.create()
|
.baseUrl(clobBaseUrl)
|
||||||
|
.client(okHttpClient)
|
||||||
return Retrofit.Builder()
|
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||||
.baseUrl(clobBaseUrl)
|
.build()
|
||||||
.client(okHttpClient)
|
.create(PolymarketClobApi::class.java)
|
||||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
}
|
||||||
.build()
|
|
||||||
.create(PolymarketClobApi::class.java)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建不带认证的 Polymarket CLOB API 客户端
|
* 创建不带认证的 Polymarket CLOB API 客户端
|
||||||
* 用于不需要认证的查询接口
|
* 用于不需要认证的查询接口
|
||||||
* @return PolymarketClobApi 客户端
|
* @return PolymarketClobApi 客户端(单例)
|
||||||
*/
|
*/
|
||||||
fun createClobApiWithoutAuth(): PolymarketClobApi {
|
fun createClobApiWithoutAuth(): PolymarketClobApi {
|
||||||
// 添加响应日志拦截器,用于调试 JSON 解析错误
|
return clobApiWithoutAuth
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建 Ethereum RPC API 客户端
|
* 创建 Ethereum RPC API 客户端
|
||||||
* 使用固定的 baseUrl,通过拦截器动态替换为实际的 RPC URL
|
* 使用固定的 baseUrl,通过拦截器动态替换为实际的 RPC URL
|
||||||
* 如果 RPC 不可用,将抛出异常
|
* 如果 RPC 不可用,将抛出异常
|
||||||
|
* 按 RPC URL 缓存,避免重复创建
|
||||||
* @param rpcUrl RPC 节点 URL
|
* @param rpcUrl RPC 节点 URL
|
||||||
* @return EthereumRpcApi 客户端
|
* @return EthereumRpcApi 客户端
|
||||||
* @throws IllegalArgumentException 如果 RPC URL 无效或不可用
|
* @throws IllegalArgumentException 如果 RPC URL 无效或不可用
|
||||||
*/
|
*/
|
||||||
fun createEthereumRpcApi(rpcUrl: String): EthereumRpcApi {
|
fun createEthereumRpcApi(rpcUrl: String): EthereumRpcApi {
|
||||||
// 使用固定的 baseUrl(Retrofit 要求 baseUrl 必须以 / 结尾)
|
|
||||||
val fixedBaseUrl = "https://polyrpc.polyhermes/"
|
|
||||||
|
|
||||||
// 确保实际的 RPC URL 以 / 结尾
|
// 确保实际的 RPC URL 以 / 结尾
|
||||||
val actualRpcUrl = if (rpcUrl.endsWith("/")) {
|
val actualRpcUrl = if (rpcUrl.endsWith("/")) {
|
||||||
rpcUrl
|
rpcUrl
|
||||||
@@ -119,27 +192,28 @@ class RetrofitFactory(
|
|||||||
"$rpcUrl/"
|
"$rpcUrl/"
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证 RPC 是否可用
|
// 使用 RPC URL 作为缓存键
|
||||||
validateRpcAvailability(actualRpcUrl)
|
return rpcApiCache.computeIfAbsent(actualRpcUrl) {
|
||||||
|
// 验证 RPC 是否可用(仅在新创建时验证)
|
||||||
// 创建 URL 替换拦截器
|
validateRpcAvailability(actualRpcUrl)
|
||||||
val urlReplaceInterceptor = RpcUrlReplaceInterceptor(fixedBaseUrl, actualRpcUrl)
|
|
||||||
|
// 使用固定的 baseUrl(Retrofit 要求 baseUrl 必须以 / 结尾)
|
||||||
val okHttpClient = createClient()
|
val fixedBaseUrl = "https://polyrpc.polyhermes/"
|
||||||
.addInterceptor(urlReplaceInterceptor)
|
|
||||||
.build()
|
// 创建 URL 替换拦截器
|
||||||
|
val urlReplaceInterceptor = RpcUrlReplaceInterceptor(fixedBaseUrl, actualRpcUrl)
|
||||||
// 创建 lenient 模式的 Gson
|
|
||||||
val gson = GsonBuilder()
|
val okHttpClient = createClient()
|
||||||
.setLenient()
|
.addInterceptor(urlReplaceInterceptor)
|
||||||
.create()
|
.build()
|
||||||
|
|
||||||
return Retrofit.Builder()
|
Retrofit.Builder()
|
||||||
.baseUrl(fixedBaseUrl)
|
.baseUrl(fixedBaseUrl)
|
||||||
.client(okHttpClient)
|
.client(okHttpClient)
|
||||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||||
.build()
|
.build()
|
||||||
.create(EthereumRpcApi::class.java)
|
.create(EthereumRpcApi::class.java)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -149,8 +223,6 @@ class RetrofitFactory(
|
|||||||
* @throws IllegalArgumentException 如果 RPC 不可用
|
* @throws IllegalArgumentException 如果 RPC 不可用
|
||||||
*/
|
*/
|
||||||
private fun validateRpcAvailability(rpcUrl: String) {
|
private fun validateRpcAvailability(rpcUrl: String) {
|
||||||
val logger = LoggerFactory.getLogger(RetrofitFactory::class.java)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 解析 URL
|
// 解析 URL
|
||||||
val httpUrl = rpcUrl.toHttpUrlOrNull()
|
val httpUrl = rpcUrl.toHttpUrlOrNull()
|
||||||
@@ -218,56 +290,24 @@ class RetrofitFactory(
|
|||||||
/**
|
/**
|
||||||
* 创建 Polymarket Gamma API 客户端
|
* 创建 Polymarket Gamma API 客户端
|
||||||
* Gamma API 是公开 API,不需要认证
|
* Gamma API 是公开 API,不需要认证
|
||||||
* @return PolymarketGammaApi 客户端
|
* @return PolymarketGammaApi 客户端(单例)
|
||||||
*/
|
*/
|
||||||
fun createGammaApi(): PolymarketGammaApi {
|
fun createGammaApi(): PolymarketGammaApi {
|
||||||
val baseUrl = if (gammaBaseUrl.endsWith("/")) {
|
return gammaApi
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建 Polymarket Data API 客户端
|
* 创建 Polymarket Data API 客户端
|
||||||
* Data API 是公开 API,不需要认证
|
* Data API 是公开 API,不需要认证
|
||||||
* @return PolymarketDataApi 客户端
|
* @return PolymarketDataApi 客户端(单例)
|
||||||
*/
|
*/
|
||||||
fun createDataApi(): PolymarketDataApi {
|
fun createDataApi(): PolymarketDataApi {
|
||||||
val baseUrl = "https://data-api.polymarket.com"
|
return dataApi
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建 Builder Relayer API 客户端
|
* 创建 Builder Relayer API 客户端
|
||||||
|
* 按 relayerUrl 缓存,避免重复创建
|
||||||
* @param relayerUrl Builder Relayer URL
|
* @param relayerUrl Builder Relayer URL
|
||||||
* @param apiKey Builder API Key
|
* @param apiKey Builder API Key
|
||||||
* @param secret Builder Secret
|
* @param secret Builder Secret
|
||||||
@@ -286,57 +326,61 @@ class RetrofitFactory(
|
|||||||
relayerUrl
|
relayerUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加 Builder 认证拦截器
|
// 使用 baseUrl 作为缓存键(注意:如果 API Key 变化,需要清理缓存)
|
||||||
val builderAuthInterceptor = BuilderAuthInterceptor(apiKey, secret, passphrase)
|
return builderRelayerApiCache.computeIfAbsent(baseUrl) {
|
||||||
val okHttpClient = createClient()
|
// 添加 Builder 认证拦截器
|
||||||
.addInterceptor(builderAuthInterceptor)
|
val builderAuthInterceptor = BuilderAuthInterceptor(apiKey, secret, passphrase)
|
||||||
.build()
|
val okHttpClient = createClient()
|
||||||
|
.addInterceptor(builderAuthInterceptor)
|
||||||
val gson = GsonBuilder()
|
.build()
|
||||||
.setLenient()
|
|
||||||
.create()
|
Retrofit.Builder()
|
||||||
|
.baseUrl("$baseUrl/")
|
||||||
return Retrofit.Builder()
|
.client(okHttpClient)
|
||||||
.baseUrl("$baseUrl/")
|
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||||
.client(okHttpClient)
|
.build()
|
||||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
.create(BuilderRelayerApi::class.java)
|
||||||
.build()
|
}
|
||||||
.create(BuilderRelayerApi::class.java)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建 GitHub API 客户端
|
* 创建 GitHub API 客户端
|
||||||
* GitHub API 是公开 API,不需要认证(但建议使用 token 提高速率限制)
|
* GitHub API 是公开 API,不需要认证(但建议使用 token 提高速率限制)
|
||||||
* 添加 Accept 头以获取 reactions 数据
|
* 添加 Accept 头以获取 reactions 数据
|
||||||
* @return GitHubApi 客户端
|
* @return GitHubApi 客户端(单例)
|
||||||
*/
|
*/
|
||||||
fun createGitHubApi(): GitHubApi {
|
fun createGitHubApi(): GitHubApi {
|
||||||
val baseUrl = "https://api.github.com"
|
return githubApi
|
||||||
|
}
|
||||||
// 添加拦截器,设置 Accept 头以获取 reactions 数据
|
|
||||||
val githubInterceptor = object : Interceptor {
|
/**
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
* 清理缓存(用于测试或配置变更时)
|
||||||
val request = chain.request().newBuilder()
|
*/
|
||||||
.header("Accept", "application/vnd.github+json")
|
@PreDestroy
|
||||||
.build()
|
fun destroy() {
|
||||||
return chain.proceed(request)
|
logger.info("清理 RetrofitFactory 缓存")
|
||||||
}
|
clobApiCache.clear()
|
||||||
}
|
rpcApiCache.clear()
|
||||||
|
builderRelayerApiCache.clear()
|
||||||
val okHttpClient = createClient()
|
}
|
||||||
.addInterceptor(githubInterceptor)
|
|
||||||
.build()
|
/**
|
||||||
|
* 清理指定钱包地址的 CLOB API 缓存
|
||||||
val gson = GsonBuilder()
|
* 用于 API Key 变更时
|
||||||
.setLenient()
|
*/
|
||||||
.create()
|
fun clearClobApiCache(walletAddress: String) {
|
||||||
|
clobApiCache.remove(walletAddress)
|
||||||
return Retrofit.Builder()
|
logger.debug("已清理 CLOB API 缓存: $walletAddress")
|
||||||
.baseUrl("$baseUrl/")
|
}
|
||||||
.client(okHttpClient)
|
|
||||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
/**
|
||||||
.build()
|
* 清理指定 RPC URL 的 RPC API 缓存
|
||||||
.create(GitHubApi::class.java)
|
* 用于 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 responseBody = response.peekBody(2048)
|
||||||
val responseBodyString = responseBody.string()
|
val responseBodyString = responseBody.string()
|
||||||
|
|
||||||
// 检查是否是有效的 JSON
|
// 检查响应体是否为空
|
||||||
val isJson = responseBodyString.trim().startsWith("{") ||
|
val isEmpty = responseBodyString.isBlank()
|
||||||
responseBodyString.trim().startsWith("[")
|
|
||||||
|
|
||||||
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(
|
logger.warn(
|
||||||
"API 响应异常: method=${request.method}, url=${request.url}, " +
|
"API 响应异常: method=${request.method}, url=${request.url}, " +
|
||||||
"code=${response.code}, isJson=$isJson, " +
|
"code=${response.code}, isJson=$isJson, isEmpty=$isEmpty, " +
|
||||||
"responseBody=${responseBodyString.take(500)}"
|
"responseBody=$bodyPreview"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
// 如果读取响应体失败,记录异常但不影响响应
|
||||||
|
logger.debug("读取响应体失败: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,23 +40,12 @@ polymarket.rtds.ws-url=wss://ws-subscriptions-clob.polymarket.com
|
|||||||
polymarket.data-api.base-url=https://data-api.polymarket.com
|
polymarket.data-api.base-url=https://data-api.polymarket.com
|
||||||
polymarket.gamma.base-url=https://gamma-api.polymarket.com
|
polymarket.gamma.base-url=https://gamma-api.polymarket.com
|
||||||
|
|
||||||
# Polygon RPC 配置(用于查询链上余额)
|
|
||||||
# 可选:如果未配置,将无acc法查询 USDC 余额,但仍可通过 Subgraph API 查询持仓
|
|
||||||
# 示例:https://polygon-rpc.com 或 https://polygon-mainnet.infura.io/v3/YOUR_PROJECT_ID
|
|
||||||
polygon.rpc.url=${POLYGON_RPC_URL:https://polygon-rpc.com}
|
|
||||||
|
|
||||||
# Builder Relayer 配置(用于 Gasless 交易)
|
# Builder Relayer 配置(用于 Gasless 交易)
|
||||||
# 从 polymarket.com/settings?tab=builder 获取 Builder API 凭证
|
# 从 polymarket.com/settings?tab=builder 获取 Builder API 凭证
|
||||||
# Builder API Key、Secret、Passphrase 现在通过系统设置页面配置,存储在数据库中
|
# Builder API Key、Secret、Passphrase 现在通过系统设置页面配置,存储在数据库中
|
||||||
# 如果未配置,将使用手动发送交易的方式(需要用户支付 gas)
|
# 如果未配置,将使用手动发送交易的方式(需要用户支付 gas)
|
||||||
polymarket.builder.relayer-url=${POLYMARKET_BUILDER_RELAYER_URL:https://relayer-v2.polymarket.com/}
|
polymarket.builder.relayer-url=${POLYMARKET_BUILDER_RELAYER_URL:https://relayer-v2.polymarket.com/}
|
||||||
|
|
||||||
# 仓位推送配置
|
|
||||||
# 轮询间隔(毫秒),默认3秒
|
|
||||||
position.push.polling-interval=${POSITION_PUSH_POLLING_INTERVAL:3000}
|
|
||||||
# 心跳超时时间(毫秒),默认60秒,超过此时间未收到心跳则清理连接
|
|
||||||
position.push.heartbeat-timeout=${POSITION_PUSH_HEARTBEAT_TIMEOUT:60000}
|
|
||||||
|
|
||||||
# 跟单轮询配置
|
# 跟单轮询配置
|
||||||
# 轮询间隔(毫秒),默认2秒
|
# 轮询间隔(毫秒),默认2秒
|
||||||
copy.trading.polling.interval=${COPY_TRADING_POLLING_INTERVAL:2000}
|
copy.trading.polling.interval=${COPY_TRADING_POLLING_INTERVAL:2000}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- V16: 删除失败交易记录表
|
||||||
|
-- 移除下单失败存储到数据库的功能
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 删除失败交易记录表
|
||||||
|
DROP TABLE IF EXISTS failed_trade;
|
||||||
|
|
||||||
@@ -71,9 +71,6 @@ SERVER_PORT=80
|
|||||||
# MySQL 端口(可选,用于外部连接,默认 3307 避免与本地 MySQL 冲突)
|
# MySQL 端口(可选,用于外部连接,默认 3307 避免与本地 MySQL 冲突)
|
||||||
MYSQL_PORT=3307
|
MYSQL_PORT=3307
|
||||||
|
|
||||||
# Polygon RPC
|
|
||||||
POLYGON_RPC_URL=https://polygon-rpc.com
|
|
||||||
|
|
||||||
# JWT 密钥(已自动生成随机值,生产环境建议修改)
|
# JWT 密钥(已自动生成随机值,生产环境建议修改)
|
||||||
JWT_SECRET=${JWT_SECRET}
|
JWT_SECRET=${JWT_SECRET}
|
||||||
|
|
||||||
@@ -159,7 +156,18 @@ deploy() {
|
|||||||
# 注意:这里需要手动修改 docker-compose.yml,或者使用环境变量
|
# 注意:这里需要手动修改 docker-compose.yml,或者使用环境变量
|
||||||
warn "请确保 docker-compose.yml 中已配置使用 image: wrbug/polyhermes:latest"
|
warn "请确保 docker-compose.yml 中已配置使用 image: wrbug/polyhermes:latest"
|
||||||
else
|
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
|
docker-compose build
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -199,7 +207,9 @@ main() {
|
|||||||
info "访问地址: http://localhost:${SERVER_PORT:-80}"
|
info "访问地址: http://localhost:${SERVER_PORT:-80}"
|
||||||
echo ""
|
echo ""
|
||||||
if [ "$USE_DOCKER_HUB" != "true" ]; then
|
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 "生产环境推荐使用 Docker Hub 镜像:"
|
||||||
info " ./deploy.sh --use-docker-hub"
|
info " ./deploy.sh --use-docker-hub"
|
||||||
info " 或修改 docker-compose.yml 使用 image: wrbug/polyhermes:latest"
|
info " 或修改 docker-compose.yml 使用 image: wrbug/polyhermes:latest"
|
||||||
|
|||||||
@@ -38,12 +38,6 @@ SERVER_PORT=80
|
|||||||
# Optional, for external connections, default 3307 to avoid conflicts with local MySQL
|
# Optional, for external connections, default 3307 to avoid conflicts with local MySQL
|
||||||
MYSQL_PORT=3307
|
MYSQL_PORT=3307
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# Polygon RPC
|
|
||||||
# ============================================
|
|
||||||
# Polygon 网络 RPC 地址 / Polygon network RPC address
|
|
||||||
POLYGON_RPC_URL=https://polygon-rpc.com
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# 安全配置 / Security Configuration
|
# 安全配置 / Security Configuration
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ services:
|
|||||||
- DB_USERNAME=${DB_USERNAME:-root}
|
- DB_USERNAME=${DB_USERNAME:-root}
|
||||||
- DB_PASSWORD=${DB_PASSWORD:-}
|
- DB_PASSWORD=${DB_PASSWORD:-}
|
||||||
- SERVER_PORT=8000
|
- SERVER_PORT=8000
|
||||||
- POLYGON_RPC_URL=${POLYGON_RPC_URL:-https://polygon-rpc.com}
|
|
||||||
# ⚠️ 安全警告:以下两个环境变量不能使用默认值,否则容器启动会失败
|
# ⚠️ 安全警告:以下两个环境变量不能使用默认值,否则容器启动会失败
|
||||||
# 请在 .env 文件中设置,或通过环境变量传入
|
# 请在 .env 文件中设置,或通过环境变量传入
|
||||||
# 生成随机密钥:openssl rand -hex 32 (ADMIN_RESET_PASSWORD_KEY) 或 openssl rand -hex 64 (JWT_SECRET)
|
# 生成随机密钥:openssl rand -hex 32 (ADMIN_RESET_PASSWORD_KEY) 或 openssl rand -hex 64 (JWT_SECRET)
|
||||||
|
|||||||
+5
-6
@@ -8,11 +8,11 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
# 本地构建时可以传递版本号参数(可选)
|
# 本地构建时可以传递版本号参数(自动使用当前分支名)
|
||||||
# args:
|
args:
|
||||||
# VERSION: ${VERSION:-dev}
|
VERSION: ${VERSION:-dev}
|
||||||
# GIT_TAG: ${GIT_TAG:-}
|
GIT_TAG: ${GIT_TAG:-${VERSION:-dev}}
|
||||||
# GITHUB_REPO_URL: https://github.com/WrBug/PolyHermes
|
GITHUB_REPO_URL: ${GITHUB_REPO_URL:-https://github.com/WrBug/PolyHermes}
|
||||||
container_name: polyhermes
|
container_name: polyhermes
|
||||||
ports:
|
ports:
|
||||||
- "${SERVER_PORT:-80}:80"
|
- "${SERVER_PORT:-80}:80"
|
||||||
@@ -22,7 +22,6 @@ services:
|
|||||||
- DB_USERNAME=${DB_USERNAME:-root}
|
- DB_USERNAME=${DB_USERNAME:-root}
|
||||||
- DB_PASSWORD=${DB_PASSWORD:-}
|
- DB_PASSWORD=${DB_PASSWORD:-}
|
||||||
- SERVER_PORT=8000
|
- SERVER_PORT=8000
|
||||||
- POLYGON_RPC_URL=${POLYGON_RPC_URL:-https://polygon-rpc.com}
|
|
||||||
# ⚠️ 安全警告:以下两个环境变量不能使用默认值,否则容器启动会失败
|
# ⚠️ 安全警告:以下两个环境变量不能使用默认值,否则容器启动会失败
|
||||||
# 请在 .env 文件中设置,或通过环境变量传入
|
# 请在 .env 文件中设置,或通过环境变量传入
|
||||||
# 生成随机密钥:openssl rand -hex 32 (ADMIN_RESET_PASSWORD_KEY) 或 openssl rand -hex 64 (JWT_SECRET)
|
# 生成随机密钥:openssl rand -hex 32 (ADMIN_RESET_PASSWORD_KEY) 或 openssl rand -hex 64 (JWT_SECRET)
|
||||||
|
|||||||
@@ -163,7 +163,6 @@ DB_USERNAME=root
|
|||||||
DB_PASSWORD=your_password_here
|
DB_PASSWORD=your_password_here
|
||||||
SPRING_PROFILES_ACTIVE=prod
|
SPRING_PROFILES_ACTIVE=prod
|
||||||
SERVER_PORT=80
|
SERVER_PORT=80
|
||||||
POLYGON_RPC_URL=https://polygon-rpc.com
|
|
||||||
JWT_SECRET=your-jwt-secret-key-change-in-production
|
JWT_SECRET=your-jwt-secret-key-change-in-production
|
||||||
ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production
|
ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production
|
||||||
EOF
|
EOF
|
||||||
@@ -393,7 +392,6 @@ DB_USERNAME=root
|
|||||||
DB_PASSWORD=your_password_here
|
DB_PASSWORD=your_password_here
|
||||||
SPRING_PROFILES_ACTIVE=prod
|
SPRING_PROFILES_ACTIVE=prod
|
||||||
SERVER_PORT=8000
|
SERVER_PORT=8000
|
||||||
POLYGON_RPC_URL=https://polygon-rpc.com
|
|
||||||
JWT_SECRET=your-jwt-secret-key-change-in-production
|
JWT_SECRET=your-jwt-secret-key-change-in-production
|
||||||
ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production
|
ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production
|
||||||
EOF
|
EOF
|
||||||
@@ -554,7 +552,6 @@ serve -s dist -l 3000
|
|||||||
| `DB_USERNAME` | Database username | `root` | Yes (Production) |
|
| `DB_USERNAME` | Database username | `root` | Yes (Production) |
|
||||||
| `DB_PASSWORD` | Database password | - | Yes (Production) |
|
| `DB_PASSWORD` | Database password | - | Yes (Production) |
|
||||||
| `SERVER_PORT` | Server port | `8000` | No |
|
| `SERVER_PORT` | Server port | `8000` | No |
|
||||||
| `POLYGON_RPC_URL` | Polygon RPC address | `https://polygon-rpc.com` | No |
|
|
||||||
| `JWT_SECRET` | JWT secret key | - | Yes (Production) |
|
| `JWT_SECRET` | JWT secret key | - | Yes (Production) |
|
||||||
| `ADMIN_RESET_PASSWORD_KEY` | Admin password reset key | - | Yes (Production) |
|
| `ADMIN_RESET_PASSWORD_KEY` | Admin password reset key | - | Yes (Production) |
|
||||||
|
|
||||||
|
|||||||
@@ -100,9 +100,6 @@ spring.datasource.password=${DB_PASSWORD:password}
|
|||||||
# Server port
|
# Server port
|
||||||
server.port=${SERVER_PORT:8000}
|
server.port=${SERVER_PORT:8000}
|
||||||
|
|
||||||
# Polygon RPC
|
|
||||||
polygon.rpc.url=${POLYGON_RPC_URL:https://polygon-rpc.com}
|
|
||||||
|
|
||||||
# JWT secret
|
# JWT secret
|
||||||
jwt.secret=${JWT_SECRET:change-me-in-production}
|
jwt.secret=${JWT_SECRET:change-me-in-production}
|
||||||
|
|
||||||
|
|||||||
@@ -165,7 +165,6 @@ DB_USERNAME=root
|
|||||||
DB_PASSWORD=your_password_here
|
DB_PASSWORD=your_password_here
|
||||||
SPRING_PROFILES_ACTIVE=prod
|
SPRING_PROFILES_ACTIVE=prod
|
||||||
SERVER_PORT=80
|
SERVER_PORT=80
|
||||||
POLYGON_RPC_URL=https://polygon-rpc.com
|
|
||||||
JWT_SECRET=your-jwt-secret-key-change-in-production
|
JWT_SECRET=your-jwt-secret-key-change-in-production
|
||||||
ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production
|
ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production
|
||||||
EOF
|
EOF
|
||||||
@@ -397,7 +396,6 @@ DB_USERNAME=root
|
|||||||
DB_PASSWORD=your_password_here
|
DB_PASSWORD=your_password_here
|
||||||
SPRING_PROFILES_ACTIVE=prod
|
SPRING_PROFILES_ACTIVE=prod
|
||||||
SERVER_PORT=8000
|
SERVER_PORT=8000
|
||||||
POLYGON_RPC_URL=https://polygon-rpc.com
|
|
||||||
JWT_SECRET=your-jwt-secret-key-change-in-production
|
JWT_SECRET=your-jwt-secret-key-change-in-production
|
||||||
ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production
|
ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production
|
||||||
EOF
|
EOF
|
||||||
@@ -558,7 +556,6 @@ serve -s dist -l 3000
|
|||||||
| `DB_USERNAME` | 数据库用户名 | `root` | 是(生产) |
|
| `DB_USERNAME` | 数据库用户名 | `root` | 是(生产) |
|
||||||
| `DB_PASSWORD` | 数据库密码 | - | 是(生产) |
|
| `DB_PASSWORD` | 数据库密码 | - | 是(生产) |
|
||||||
| `SERVER_PORT` | 服务器端口 | `8000` | 否 |
|
| `SERVER_PORT` | 服务器端口 | `8000` | 否 |
|
||||||
| `POLYGON_RPC_URL` | Polygon RPC 地址 | `https://polygon-rpc.com` | 否 |
|
|
||||||
| `JWT_SECRET` | JWT 密钥 | - | 是(生产) |
|
| `JWT_SECRET` | JWT 密钥 | - | 是(生产) |
|
||||||
| `ADMIN_RESET_PASSWORD_KEY` | 管理员密码重置密钥 | - | 是(生产) |
|
| `ADMIN_RESET_PASSWORD_KEY` | 管理员密码重置密钥 | - | 是(生产) |
|
||||||
|
|
||||||
|
|||||||
@@ -93,9 +93,6 @@ spring.datasource.password=${DB_PASSWORD:password}
|
|||||||
# 服务器端口
|
# 服务器端口
|
||||||
server.port=${SERVER_PORT:8000}
|
server.port=${SERVER_PORT:8000}
|
||||||
|
|
||||||
# Polygon RPC
|
|
||||||
polygon.rpc.url=${POLYGON_RPC_URL:https://polygon-rpc.com}
|
|
||||||
|
|
||||||
# JWT 密钥
|
# JWT 密钥
|
||||||
jwt.secret=${JWT_SECRET:change-me-in-production}
|
jwt.secret=${JWT_SECRET:change-me-in-production}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,835 @@
|
|||||||
|
# Polymarket 聪明钱分析方案
|
||||||
|
|
||||||
|
## 1. 概述
|
||||||
|
|
||||||
|
聪明钱(Smart Money)分析是指识别和跟踪在 Polymarket 平台上表现优异的交易者,通过分析他们的交易行为、持仓和盈亏表现,来辅助投资决策。
|
||||||
|
|
||||||
|
## 2. 核心分析维度
|
||||||
|
|
||||||
|
### 2.1 交易表现指标
|
||||||
|
|
||||||
|
#### 2.1.1 胜率(Win Rate)
|
||||||
|
- **定义**:盈利交易数 / 总交易数
|
||||||
|
- **计算方式**:
|
||||||
|
- 通过 `getUserActivity` API 获取用户历史交易
|
||||||
|
- 筛选 `type = "TRADE"` 的活动
|
||||||
|
- 计算每笔交易的盈亏(通过买入价和卖出价)
|
||||||
|
- 统计盈利交易数和总交易数
|
||||||
|
|
||||||
|
#### 2.1.2 平均盈亏比(Average PnL Ratio)
|
||||||
|
- **定义**:平均盈利金额 / 平均亏损金额
|
||||||
|
- **计算方式**:
|
||||||
|
- 分别计算盈利交易和亏损交易的平均金额
|
||||||
|
- 计算比值
|
||||||
|
|
||||||
|
#### 2.1.3 总盈亏(Total PnL)
|
||||||
|
- **定义**:所有已实现盈亏的总和
|
||||||
|
- **数据来源**:
|
||||||
|
- 通过 `getPositions` API 获取 `realizedPnl`
|
||||||
|
- 或通过 `getUserActivity` API 计算历史交易的累计盈亏
|
||||||
|
|
||||||
|
#### 2.1.4 未实现盈亏(Unrealized PnL)
|
||||||
|
- **定义**:当前持仓的浮动盈亏
|
||||||
|
- **数据来源**:
|
||||||
|
- 通过 `getPositions` API 获取 `cashPnl`(当前盈亏)
|
||||||
|
- 或通过 `currentValue - initialValue` 计算
|
||||||
|
|
||||||
|
#### 2.1.5 收益率(Return Rate)
|
||||||
|
- **定义**:总盈亏 / 总投入
|
||||||
|
- **计算方式**:
|
||||||
|
- 总投入 = 所有买入交易的总金额
|
||||||
|
- 总盈亏 = 已实现盈亏 + 未实现盈亏
|
||||||
|
- 收益率 = 总盈亏 / 总投入
|
||||||
|
|
||||||
|
### 2.2 交易行为指标
|
||||||
|
|
||||||
|
#### 2.2.1 交易频率(Trading Frequency)
|
||||||
|
- **定义**:单位时间内的交易次数
|
||||||
|
- **计算方式**:
|
||||||
|
- 通过 `getUserActivity` API 获取指定时间范围内的交易数
|
||||||
|
- 计算日均/周均交易次数
|
||||||
|
|
||||||
|
#### 2.2.2 持仓周期(Holding Period)
|
||||||
|
- **定义**:平均持仓时间
|
||||||
|
- **计算方式**:
|
||||||
|
- 跟踪每笔买入和对应的卖出时间
|
||||||
|
- 计算平均持仓天数
|
||||||
|
|
||||||
|
#### 2.2.3 市场偏好(Market Preference)
|
||||||
|
- **定义**:交易者偏好的市场类型
|
||||||
|
- **计算方式**:
|
||||||
|
- 统计交易者在不同分类(sports、crypto)的交易分布
|
||||||
|
- 统计交易者偏好的市场主题
|
||||||
|
|
||||||
|
#### 2.2.4 仓位规模(Position Size)
|
||||||
|
- **定义**:平均单笔交易金额
|
||||||
|
- **计算方式**:
|
||||||
|
- 通过 `getUserActivity` API 获取 `usdcSize`
|
||||||
|
- 计算平均交易金额
|
||||||
|
|
||||||
|
### 2.3 风险指标
|
||||||
|
|
||||||
|
#### 2.3.1 最大回撤(Maximum Drawdown)
|
||||||
|
- **定义**:从峰值到谷值的最大跌幅
|
||||||
|
- **计算方式**:
|
||||||
|
- 跟踪账户价值的时序变化
|
||||||
|
- 计算每个峰值的回撤幅度
|
||||||
|
- 取最大值
|
||||||
|
|
||||||
|
#### 2.3.2 夏普比率(Sharpe Ratio)
|
||||||
|
- **定义**:风险调整后的收益率
|
||||||
|
- **计算方式**:
|
||||||
|
- 收益率标准差 / 平均收益率
|
||||||
|
- 需要足够的历史数据
|
||||||
|
|
||||||
|
#### 2.3.3 胜率稳定性(Win Rate Stability)
|
||||||
|
- **定义**:不同时间段胜率的一致性
|
||||||
|
- **计算方式**:
|
||||||
|
- 按时间段(如每月)计算胜率
|
||||||
|
- 计算胜率的方差或标准差
|
||||||
|
|
||||||
|
## 3. 数据收集方法
|
||||||
|
|
||||||
|
### 3.1 使用 Polymarket Data API
|
||||||
|
|
||||||
|
#### 3.1.1 获取用户仓位
|
||||||
|
```kotlin
|
||||||
|
// 接口:GET /positions
|
||||||
|
// 参数:
|
||||||
|
// - user: 用户钱包地址(必需)
|
||||||
|
// - market: 市场ID(可选)
|
||||||
|
// - limit: 限制数量(可选)
|
||||||
|
// - offset: 偏移量(可选)
|
||||||
|
// - sortBy: 排序字段(可选,如 "currentValue")
|
||||||
|
// - sortDirection: 排序方向(可选,如 "desc")
|
||||||
|
|
||||||
|
val positions = dataApi.getPositions(
|
||||||
|
user = walletAddress,
|
||||||
|
limit = 100,
|
||||||
|
sortBy = "currentValue",
|
||||||
|
sortDirection = "desc"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**返回数据包含**:
|
||||||
|
- `currentValue`: 当前仓位价值
|
||||||
|
- `cashPnl`: 当前盈亏(未实现)
|
||||||
|
- `realizedPnl`: 已实现盈亏
|
||||||
|
- `percentPnl`: 盈亏百分比
|
||||||
|
- `avgPrice`: 平均买入价
|
||||||
|
- `curPrice`: 当前价格
|
||||||
|
|
||||||
|
#### 3.1.2 获取用户活动(交易历史)
|
||||||
|
```kotlin
|
||||||
|
// 接口:GET /activity
|
||||||
|
// 参数:
|
||||||
|
// - user: 用户钱包地址(必需)
|
||||||
|
// - type: 活动类型(可选,如 ["TRADE"])
|
||||||
|
// - side: 交易方向(可选,如 "BUY" 或 "SELL")
|
||||||
|
// - start: 开始时间戳(可选)
|
||||||
|
// - end: 结束时间戳(可选)
|
||||||
|
// - limit: 限制数量(可选)
|
||||||
|
// - offset: 偏移量(可选)
|
||||||
|
|
||||||
|
val activities = dataApi.getUserActivity(
|
||||||
|
user = walletAddress,
|
||||||
|
type = listOf("TRADE"),
|
||||||
|
side = "BUY",
|
||||||
|
start = startTimestamp,
|
||||||
|
end = endTimestamp,
|
||||||
|
limit = 1000
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**返回数据包含**:
|
||||||
|
- `type`: 活动类型(TRADE、SPLIT、MERGE、REDEEM等)
|
||||||
|
- `side`: 交易方向(BUY、SELL)
|
||||||
|
- `size`: 交易数量
|
||||||
|
- `usdcSize`: 交易金额(USDC)
|
||||||
|
- `price`: 交易价格
|
||||||
|
- `timestamp`: 交易时间戳
|
||||||
|
- `title`: 市场标题
|
||||||
|
- `slug`: 市场标识
|
||||||
|
|
||||||
|
#### 3.1.3 获取仓位总价值
|
||||||
|
```kotlin
|
||||||
|
// 接口:GET /value
|
||||||
|
// 参数:
|
||||||
|
// - user: 用户钱包地址(必需)
|
||||||
|
// - market: 市场ID列表(可选)
|
||||||
|
|
||||||
|
val totalValue = dataApi.getTotalValue(
|
||||||
|
user = walletAddress,
|
||||||
|
market = listOf("market1", "market2")
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 使用 Polymarket CLOB API
|
||||||
|
|
||||||
|
#### 3.2.1 获取交易记录
|
||||||
|
```kotlin
|
||||||
|
// 接口:GET /data/trades
|
||||||
|
// 参数:
|
||||||
|
// - maker_address: 交易者地址(可选)
|
||||||
|
// - market: 市场ID(可选)
|
||||||
|
// - before: 之前的时间戳(可选,用于分页)
|
||||||
|
// - after: 之后的时间戳(可选,用于分页)
|
||||||
|
// - next_cursor: 分页游标(可选)
|
||||||
|
|
||||||
|
val trades = clobApi.getTrades(
|
||||||
|
maker_address = walletAddress,
|
||||||
|
market = marketId,
|
||||||
|
after = startTimestamp.toString()
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**返回数据包含**:
|
||||||
|
- `id`: 交易ID
|
||||||
|
- `market`: 市场ID
|
||||||
|
- `side`: 交易方向(BUY、SELL)
|
||||||
|
- `price`: 交易价格
|
||||||
|
- `size`: 交易数量
|
||||||
|
- `timestamp`: 交易时间戳
|
||||||
|
- `user`: 交易者地址
|
||||||
|
|
||||||
|
## 4. 聪明钱识别算法
|
||||||
|
|
||||||
|
### 4.1 基础筛选条件
|
||||||
|
|
||||||
|
#### 4.1.1 最低交易次数
|
||||||
|
- **条件**:总交易数 >= 50
|
||||||
|
- **目的**:确保有足够的数据进行统计分析
|
||||||
|
|
||||||
|
#### 4.1.2 最低胜率
|
||||||
|
- **条件**:胜率 >= 55%
|
||||||
|
- **目的**:筛选出表现优于随机交易者
|
||||||
|
|
||||||
|
#### 4.1.3 最低总盈亏
|
||||||
|
- **条件**:总盈亏 >= 1000 USDC
|
||||||
|
- **目的**:筛选出有实际盈利能力的交易者
|
||||||
|
|
||||||
|
#### 4.1.4 最低收益率
|
||||||
|
- **条件**:收益率 >= 20%
|
||||||
|
- **目的**:筛选出有良好回报的交易者
|
||||||
|
|
||||||
|
### 4.2 综合评分算法
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// 聪明钱评分算法
|
||||||
|
fun calculateSmartMoneyScore(
|
||||||
|
winRate: Double, // 胜率(0-1)
|
||||||
|
totalPnl: Double, // 总盈亏(USDC)
|
||||||
|
returnRate: Double, // 收益率(0-1)
|
||||||
|
tradeCount: Int, // 交易次数
|
||||||
|
avgPnlRatio: Double // 平均盈亏比
|
||||||
|
): Double {
|
||||||
|
// 权重配置
|
||||||
|
val winRateWeight = 0.3
|
||||||
|
val totalPnlWeight = 0.25
|
||||||
|
val returnRateWeight = 0.25
|
||||||
|
val tradeCountWeight = 0.1
|
||||||
|
val avgPnlRatioWeight = 0.1
|
||||||
|
|
||||||
|
// 归一化处理
|
||||||
|
val normalizedWinRate = winRate * 100 // 转换为百分比
|
||||||
|
val normalizedTotalPnl = min(totalPnl / 10000, 1.0) * 100 // 归一化到0-100
|
||||||
|
val normalizedReturnRate = returnRate * 100 // 转换为百分比
|
||||||
|
val normalizedTradeCount = min(tradeCount / 200, 1.0) * 100 // 归一化到0-100
|
||||||
|
val normalizedAvgPnlRatio = min(avgPnlRatio / 3.0, 1.0) * 100 // 归一化到0-100
|
||||||
|
|
||||||
|
// 加权求和
|
||||||
|
val score = normalizedWinRate * winRateWeight +
|
||||||
|
normalizedTotalPnl * totalPnlWeight +
|
||||||
|
normalizedReturnRate * returnRateWeight +
|
||||||
|
normalizedTradeCount * tradeCountWeight +
|
||||||
|
normalizedAvgPnlRatio * avgPnlRatioWeight
|
||||||
|
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 排名算法
|
||||||
|
|
||||||
|
1. **按综合评分排序**:计算所有候选交易者的综合评分,按降序排列
|
||||||
|
2. **按分类排名**:分别计算 sports 和 crypto 分类的排名
|
||||||
|
3. **按时间段排名**:分别计算最近7天、30天、90天的排名
|
||||||
|
|
||||||
|
## 5. 实时监控方案
|
||||||
|
|
||||||
|
### 5.1 监控目标
|
||||||
|
|
||||||
|
1. **新交易**:监控聪明钱交易者的新买入/卖出交易
|
||||||
|
2. **持仓变化**:监控聪明钱交易者的持仓变化
|
||||||
|
3. **市场关注**:监控聪明钱交易者关注的新市场
|
||||||
|
|
||||||
|
### 5.2 实现方式
|
||||||
|
|
||||||
|
#### 5.2.1 使用 WebSocket(推荐)
|
||||||
|
- 订阅 Polymarket WebSocket 的 User Channel
|
||||||
|
- 监听 `event_type = "trade"` 事件
|
||||||
|
- 过滤出聪明钱交易者的交易
|
||||||
|
|
||||||
|
#### 5.2.2 使用轮询
|
||||||
|
- 定期调用 `getUserActivity` API(如每5分钟)
|
||||||
|
- 比较时间戳,识别新交易
|
||||||
|
- 使用 `after` 参数只获取新数据
|
||||||
|
|
||||||
|
### 5.3 跟单集成
|
||||||
|
|
||||||
|
聪明钱分析可以与现有的跟单系统集成:
|
||||||
|
|
||||||
|
1. **自动添加 Leader**:识别到聪明钱交易者后,自动添加到 Leader 列表
|
||||||
|
2. **智能跟单**:根据聪明钱交易者的表现,动态调整跟单比例
|
||||||
|
3. **风险控制**:根据聪明钱交易者的风险指标,设置跟单限制
|
||||||
|
|
||||||
|
## 6. 实现示例
|
||||||
|
|
||||||
|
### 6.1 聪明钱分析服务
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Service
|
||||||
|
class SmartMoneyAnalysisService(
|
||||||
|
private val retrofitFactory: RetrofitFactory,
|
||||||
|
private val blockchainService: BlockchainService
|
||||||
|
) {
|
||||||
|
private val logger = LoggerFactory.getLogger(SmartMoneyAnalysisService::class.java)
|
||||||
|
private val dataApi = retrofitFactory.createDataApi()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分析单个交易者的表现
|
||||||
|
*/
|
||||||
|
suspend fun analyzeTrader(walletAddress: String, days: Int = 90): Result<TraderAnalysis> {
|
||||||
|
return try {
|
||||||
|
val endTime = System.currentTimeMillis()
|
||||||
|
val startTime = endTime - (days * 24 * 60 * 60 * 1000L)
|
||||||
|
|
||||||
|
// 1. 获取交易历史
|
||||||
|
val activitiesResult = getTradeActivities(walletAddress, startTime, endTime)
|
||||||
|
if (activitiesResult.isFailure) {
|
||||||
|
return Result.failure(activitiesResult.exceptionOrNull() ?: Exception("获取交易历史失败"))
|
||||||
|
}
|
||||||
|
val activities = activitiesResult.getOrNull() ?: emptyList()
|
||||||
|
|
||||||
|
// 2. 获取当前仓位
|
||||||
|
val positionsResult = blockchainService.getPositions(walletAddress)
|
||||||
|
val positions = if (positionsResult.isSuccess) {
|
||||||
|
positionsResult.getOrNull() ?: emptyList()
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 计算指标
|
||||||
|
val metrics = calculateMetrics(activities, positions)
|
||||||
|
|
||||||
|
// 4. 计算综合评分
|
||||||
|
val score = calculateSmartMoneyScore(
|
||||||
|
winRate = metrics.winRate,
|
||||||
|
totalPnl = metrics.totalPnl,
|
||||||
|
returnRate = metrics.returnRate,
|
||||||
|
tradeCount = metrics.tradeCount,
|
||||||
|
avgPnlRatio = metrics.avgPnlRatio
|
||||||
|
)
|
||||||
|
|
||||||
|
Result.success(
|
||||||
|
TraderAnalysis(
|
||||||
|
walletAddress = walletAddress,
|
||||||
|
metrics = metrics,
|
||||||
|
score = score,
|
||||||
|
positions = positions.size,
|
||||||
|
lastTradeTime = activities.maxByOrNull { it.timestamp }?.timestamp
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("分析交易者失败: ${e.message}", e)
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取交易活动
|
||||||
|
*/
|
||||||
|
private suspend fun getTradeActivities(
|
||||||
|
walletAddress: String,
|
||||||
|
startTime: Long,
|
||||||
|
endTime: Long
|
||||||
|
): Result<List<UserActivityResponse>> {
|
||||||
|
return try {
|
||||||
|
val response = dataApi.getUserActivity(
|
||||||
|
user = walletAddress,
|
||||||
|
type = listOf("TRADE"),
|
||||||
|
start = startTime,
|
||||||
|
end = endTime,
|
||||||
|
limit = 1000,
|
||||||
|
sortBy = "timestamp",
|
||||||
|
sortDirection = "desc"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (response.isSuccessful && response.body() != null) {
|
||||||
|
Result.success(response.body()!!)
|
||||||
|
} else {
|
||||||
|
Result.failure(Exception("获取交易活动失败: ${response.code()} ${response.message()}"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("获取交易活动异常: ${e.message}", e)
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算交易指标
|
||||||
|
*/
|
||||||
|
private fun calculateMetrics(
|
||||||
|
activities: List<UserActivityResponse>,
|
||||||
|
positions: List<PositionResponse>
|
||||||
|
): TraderMetrics {
|
||||||
|
// 分离买入和卖出交易
|
||||||
|
val buyTrades = activities.filter { it.side == "BUY" }
|
||||||
|
val sellTrades = activities.filter { it.side == "SELL" }
|
||||||
|
|
||||||
|
// 计算总交易数
|
||||||
|
val tradeCount = activities.size
|
||||||
|
|
||||||
|
// 计算总投入(买入金额总和)
|
||||||
|
val totalInvested = buyTrades.sumOf { it.usdcSize ?: 0.0 }
|
||||||
|
|
||||||
|
// 计算已实现盈亏(从仓位数据)
|
||||||
|
val realizedPnl = positions.sumOf { it.realizedPnl ?: 0.0 }
|
||||||
|
|
||||||
|
// 计算未实现盈亏(从仓位数据)
|
||||||
|
val unrealizedPnl = positions.sumOf { it.cashPnl ?: 0.0 }
|
||||||
|
|
||||||
|
// 计算总盈亏
|
||||||
|
val totalPnl = realizedPnl + unrealizedPnl
|
||||||
|
|
||||||
|
// 计算收益率
|
||||||
|
val returnRate = if (totalInvested > 0) {
|
||||||
|
totalPnl / totalInvested
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算胜率(需要匹配买入和卖出交易)
|
||||||
|
val winRate = calculateWinRate(buyTrades, sellTrades)
|
||||||
|
|
||||||
|
// 计算平均盈亏比
|
||||||
|
val avgPnlRatio = calculateAvgPnlRatio(buyTrades, sellTrades)
|
||||||
|
|
||||||
|
return TraderMetrics(
|
||||||
|
tradeCount = tradeCount,
|
||||||
|
totalInvested = totalInvested,
|
||||||
|
totalPnl = totalPnl,
|
||||||
|
realizedPnl = realizedPnl,
|
||||||
|
unrealizedPnl = unrealizedPnl,
|
||||||
|
returnRate = returnRate,
|
||||||
|
winRate = winRate,
|
||||||
|
avgPnlRatio = avgPnlRatio
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算胜率
|
||||||
|
* 通过匹配买入和卖出交易来计算
|
||||||
|
*/
|
||||||
|
private fun calculateWinRate(
|
||||||
|
buyTrades: List<UserActivityResponse>,
|
||||||
|
sellTrades: List<UserActivityResponse>
|
||||||
|
): Double {
|
||||||
|
// 按市场分组买入和卖出交易
|
||||||
|
val buyByMarket = buyTrades.groupBy { it.conditionId }
|
||||||
|
val sellByMarket = sellTrades.groupBy { it.conditionId }
|
||||||
|
|
||||||
|
var winCount = 0
|
||||||
|
var totalCount = 0
|
||||||
|
|
||||||
|
// 遍历每个市场
|
||||||
|
buyByMarket.forEach { (marketId, buys) ->
|
||||||
|
val sells = sellByMarket[marketId] ?: emptyList()
|
||||||
|
|
||||||
|
// 简单匹配:按时间顺序匹配买入和卖出
|
||||||
|
// 实际应该使用更精确的匹配算法(如 FIFO)
|
||||||
|
var buyIndex = 0
|
||||||
|
var sellIndex = 0
|
||||||
|
|
||||||
|
while (buyIndex < buys.size && sellIndex < sells.size) {
|
||||||
|
val buy = buys[buyIndex]
|
||||||
|
val sell = sells[sellIndex]
|
||||||
|
|
||||||
|
// 计算盈亏
|
||||||
|
val buyPrice = buy.price ?: 0.0
|
||||||
|
val sellPrice = sell.price ?: 0.0
|
||||||
|
val pnl = (sellPrice - buyPrice) * (buy.size ?: 0.0)
|
||||||
|
|
||||||
|
if (pnl > 0) {
|
||||||
|
winCount++
|
||||||
|
}
|
||||||
|
totalCount++
|
||||||
|
|
||||||
|
buyIndex++
|
||||||
|
sellIndex++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (totalCount > 0) {
|
||||||
|
winCount.toDouble() / totalCount
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算平均盈亏比
|
||||||
|
*/
|
||||||
|
private fun calculateAvgPnlRatio(
|
||||||
|
buyTrades: List<UserActivityResponse>,
|
||||||
|
sellTrades: List<UserActivityResponse>
|
||||||
|
): Double {
|
||||||
|
// 类似胜率计算,分别计算盈利和亏损的平均金额
|
||||||
|
val buyByMarket = buyTrades.groupBy { it.conditionId }
|
||||||
|
val sellByMarket = sellTrades.groupBy { it.conditionId }
|
||||||
|
|
||||||
|
val profits = mutableListOf<Double>()
|
||||||
|
val losses = mutableListOf<Double>()
|
||||||
|
|
||||||
|
buyByMarket.forEach { (marketId, buys) ->
|
||||||
|
val sells = sellByMarket[marketId] ?: emptyList()
|
||||||
|
|
||||||
|
var buyIndex = 0
|
||||||
|
var sellIndex = 0
|
||||||
|
|
||||||
|
while (buyIndex < buys.size && sellIndex < sells.size) {
|
||||||
|
val buy = buys[buyIndex]
|
||||||
|
val sell = sells[sellIndex]
|
||||||
|
|
||||||
|
val buyPrice = buy.price ?: 0.0
|
||||||
|
val sellPrice = sell.price ?: 0.0
|
||||||
|
val pnl = (sellPrice - buyPrice) * (buy.size ?: 0.0)
|
||||||
|
|
||||||
|
if (pnl > 0) {
|
||||||
|
profits.add(pnl)
|
||||||
|
} else if (pnl < 0) {
|
||||||
|
losses.add(-pnl)
|
||||||
|
}
|
||||||
|
|
||||||
|
buyIndex++
|
||||||
|
sellIndex++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val avgProfit = if (profits.isNotEmpty()) {
|
||||||
|
profits.average()
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
val avgLoss = if (losses.isNotEmpty()) {
|
||||||
|
losses.average()
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (avgLoss > 0) {
|
||||||
|
avgProfit / avgLoss
|
||||||
|
} else {
|
||||||
|
if (avgProfit > 0) Double.MAX_VALUE else 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算聪明钱评分
|
||||||
|
*/
|
||||||
|
private fun calculateSmartMoneyScore(
|
||||||
|
winRate: Double,
|
||||||
|
totalPnl: Double,
|
||||||
|
returnRate: Double,
|
||||||
|
tradeCount: Int,
|
||||||
|
avgPnlRatio: Double
|
||||||
|
): Double {
|
||||||
|
val winRateWeight = 0.3
|
||||||
|
val totalPnlWeight = 0.25
|
||||||
|
val returnRateWeight = 0.25
|
||||||
|
val tradeCountWeight = 0.1
|
||||||
|
val avgPnlRatioWeight = 0.1
|
||||||
|
|
||||||
|
val normalizedWinRate = winRate * 100
|
||||||
|
val normalizedTotalPnl = min(totalPnl / 10000, 1.0) * 100
|
||||||
|
val normalizedReturnRate = returnRate * 100
|
||||||
|
val normalizedTradeCount = min(tradeCount / 200.0, 1.0) * 100
|
||||||
|
val normalizedAvgPnlRatio = min(avgPnlRatio / 3.0, 1.0) * 100
|
||||||
|
|
||||||
|
val score = normalizedWinRate * winRateWeight +
|
||||||
|
normalizedTotalPnl * totalPnlWeight +
|
||||||
|
normalizedReturnRate * returnRateWeight +
|
||||||
|
normalizedTradeCount * tradeCountWeight +
|
||||||
|
normalizedAvgPnlRatio * avgPnlRatioWeight
|
||||||
|
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量分析交易者
|
||||||
|
*/
|
||||||
|
suspend fun analyzeTraders(
|
||||||
|
walletAddresses: List<String>,
|
||||||
|
days: Int = 90
|
||||||
|
): Result<List<TraderAnalysis>> {
|
||||||
|
return try {
|
||||||
|
val analyses = walletAddresses.mapNotNull { address ->
|
||||||
|
analyzeTrader(address, days).getOrNull()
|
||||||
|
}
|
||||||
|
Result.success(analyses.sortedByDescending { it.score })
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("批量分析交易者失败: ${e.message}", e)
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 交易者分析结果
|
||||||
|
*/
|
||||||
|
data class TraderAnalysis(
|
||||||
|
val walletAddress: String,
|
||||||
|
val metrics: TraderMetrics,
|
||||||
|
val score: Double,
|
||||||
|
val positions: Int,
|
||||||
|
val lastTradeTime: Long?
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 交易者指标
|
||||||
|
*/
|
||||||
|
data class TraderMetrics(
|
||||||
|
val tradeCount: Int,
|
||||||
|
val totalInvested: Double,
|
||||||
|
val totalPnl: Double,
|
||||||
|
val realizedPnl: Double,
|
||||||
|
val unrealizedPnl: Double,
|
||||||
|
val returnRate: Double,
|
||||||
|
val winRate: Double,
|
||||||
|
val avgPnlRatio: Double
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 聪明钱排名服务
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Service
|
||||||
|
class SmartMoneyRankingService(
|
||||||
|
private val smartMoneyAnalysisService: SmartMoneyAnalysisService
|
||||||
|
) {
|
||||||
|
private val logger = LoggerFactory.getLogger(SmartMoneyRankingService::class.java)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取聪明钱排名
|
||||||
|
*/
|
||||||
|
suspend fun getRankings(
|
||||||
|
category: String? = null, // sports 或 crypto
|
||||||
|
days: Int = 90,
|
||||||
|
limit: Int = 100
|
||||||
|
): Result<List<TraderRanking>> {
|
||||||
|
return try {
|
||||||
|
// 1. 获取候选交易者列表
|
||||||
|
// 这里需要从某个数据源获取(如数据库、API等)
|
||||||
|
val candidates = getCandidateTraders(category)
|
||||||
|
|
||||||
|
// 2. 批量分析交易者
|
||||||
|
val analysesResult = smartMoneyAnalysisService.analyzeTraders(candidates, days)
|
||||||
|
if (analysesResult.isFailure) {
|
||||||
|
return Result.failure(analysesResult.exceptionOrNull() ?: Exception("分析失败"))
|
||||||
|
}
|
||||||
|
val analyses = analysesResult.getOrNull() ?: emptyList()
|
||||||
|
|
||||||
|
// 3. 筛选和排序
|
||||||
|
val rankings = analyses
|
||||||
|
.filter { it.metrics.tradeCount >= 50 } // 最低交易次数
|
||||||
|
.filter { it.metrics.winRate >= 0.55 } // 最低胜率
|
||||||
|
.filter { it.metrics.totalPnl >= 1000 } // 最低总盈亏
|
||||||
|
.sortedByDescending { it.score }
|
||||||
|
.take(limit)
|
||||||
|
.mapIndexed { index, analysis ->
|
||||||
|
TraderRanking(
|
||||||
|
rank = index + 1,
|
||||||
|
walletAddress = analysis.walletAddress,
|
||||||
|
score = analysis.score,
|
||||||
|
metrics = analysis.metrics,
|
||||||
|
positions = analysis.positions,
|
||||||
|
lastTradeTime = analysis.lastTradeTime
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Result.success(rankings)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("获取排名失败: ${e.message}", e)
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取候选交易者列表
|
||||||
|
* 这里需要实现具体的获取逻辑(如从数据库、API等)
|
||||||
|
*/
|
||||||
|
private suspend fun getCandidateTraders(category: String?): List<String> {
|
||||||
|
// TODO: 实现获取候选交易者的逻辑
|
||||||
|
// 可以从以下来源获取:
|
||||||
|
// 1. 数据库中的 Leader 列表
|
||||||
|
// 2. Polymarket 的公开数据
|
||||||
|
// 3. 用户提交的交易者地址
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 交易者排名
|
||||||
|
*/
|
||||||
|
data class TraderRanking(
|
||||||
|
val rank: Int,
|
||||||
|
val walletAddress: String,
|
||||||
|
val score: Double,
|
||||||
|
val metrics: TraderMetrics,
|
||||||
|
val positions: Int,
|
||||||
|
val lastTradeTime: Long?
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 数据存储建议
|
||||||
|
|
||||||
|
### 7.1 数据库表设计
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 聪明钱交易者表
|
||||||
|
CREATE TABLE smart_money_traders (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
wallet_address VARCHAR(42) NOT NULL UNIQUE,
|
||||||
|
score DOUBLE NOT NULL,
|
||||||
|
win_rate DOUBLE NOT NULL,
|
||||||
|
total_pnl DECIMAL(20, 8) NOT NULL,
|
||||||
|
return_rate DOUBLE NOT NULL,
|
||||||
|
trade_count INT NOT NULL,
|
||||||
|
category VARCHAR(20), -- sports 或 crypto
|
||||||
|
last_analysis_time BIGINT NOT NULL,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
updated_at BIGINT NOT NULL,
|
||||||
|
INDEX idx_score (score DESC),
|
||||||
|
INDEX idx_category (category),
|
||||||
|
INDEX idx_last_analysis_time (last_analysis_time)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 交易者历史指标表(用于追踪指标变化)
|
||||||
|
CREATE TABLE trader_metrics_history (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
wallet_address VARCHAR(42) NOT NULL,
|
||||||
|
win_rate DOUBLE NOT NULL,
|
||||||
|
total_pnl DECIMAL(20, 8) NOT NULL,
|
||||||
|
return_rate DOUBLE NOT NULL,
|
||||||
|
trade_count INT NOT NULL,
|
||||||
|
recorded_at BIGINT NOT NULL,
|
||||||
|
INDEX idx_wallet_address (wallet_address),
|
||||||
|
INDEX idx_recorded_at (recorded_at)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 缓存策略
|
||||||
|
|
||||||
|
- **Redis 缓存**:缓存聪明钱排名列表,减少数据库查询
|
||||||
|
- **缓存过期时间**:建议 1 小时
|
||||||
|
- **缓存键**:`smart_money:rankings:{category}:{days}`
|
||||||
|
|
||||||
|
## 8. API 接口设计
|
||||||
|
|
||||||
|
### 8.1 获取聪明钱排名
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@PostMapping("/smart-money/rankings")
|
||||||
|
fun getRankings(@RequestBody request: SmartMoneyRankingsRequest): ResponseEntity<ApiResponse<SmartMoneyRankingsResponse>> {
|
||||||
|
// 实现逻辑
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求参数**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"category": "sports", // 可选:sports 或 crypto
|
||||||
|
"days": 90, // 可选:分析时间范围(天)
|
||||||
|
"limit": 100, // 可选:返回数量
|
||||||
|
"minScore": 50 // 可选:最低评分
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应数据**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"rankings": [
|
||||||
|
{
|
||||||
|
"rank": 1,
|
||||||
|
"walletAddress": "0x...",
|
||||||
|
"score": 85.5,
|
||||||
|
"metrics": {
|
||||||
|
"tradeCount": 150,
|
||||||
|
"winRate": 0.65,
|
||||||
|
"totalPnl": 5000.0,
|
||||||
|
"returnRate": 0.35,
|
||||||
|
"avgPnlRatio": 2.5
|
||||||
|
},
|
||||||
|
"positions": 10,
|
||||||
|
"lastTradeTime": 1234567890
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 100
|
||||||
|
},
|
||||||
|
"msg": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 分析单个交易者
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@PostMapping("/smart-money/analyze")
|
||||||
|
fun analyzeTrader(@RequestBody request: SmartMoneyAnalyzeRequest): ResponseEntity<ApiResponse<TraderAnalysisDto>> {
|
||||||
|
// 实现逻辑
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求参数**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"walletAddress": "0x...",
|
||||||
|
"days": 90
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. 注意事项
|
||||||
|
|
||||||
|
### 9.1 API 限制
|
||||||
|
|
||||||
|
- **Data API 速率限制**:注意 API 调用频率,避免触发限流
|
||||||
|
- **数据延迟**:Data API 的数据可能有延迟,不是实时的
|
||||||
|
- **数据完整性**:某些历史数据可能不完整,需要处理缺失数据
|
||||||
|
|
||||||
|
### 9.2 计算精度
|
||||||
|
|
||||||
|
- **价格精度**:Polymarket 使用 0.01-0.99 的价格范围,注意精度问题
|
||||||
|
- **金额精度**:使用 `BigDecimal` 进行金额计算,避免浮点数误差
|
||||||
|
- **时间精度**:注意时间戳的精度(毫秒 vs 秒)
|
||||||
|
|
||||||
|
### 9.3 性能优化
|
||||||
|
|
||||||
|
- **批量查询**:尽量批量查询多个交易者的数据
|
||||||
|
- **缓存策略**:缓存分析结果,避免重复计算
|
||||||
|
- **异步处理**:使用异步任务处理大量数据分析
|
||||||
|
|
||||||
|
### 9.4 数据质量
|
||||||
|
|
||||||
|
- **数据验证**:验证 API 返回的数据完整性
|
||||||
|
- **异常处理**:处理 API 调用失败的情况
|
||||||
|
- **数据清洗**:清洗异常数据(如价格为 0、数量为负数等)
|
||||||
|
|
||||||
|
## 10. 后续优化方向
|
||||||
|
|
||||||
|
1. **机器学习模型**:使用机器学习模型预测交易者未来表现
|
||||||
|
2. **实时监控**:集成 WebSocket 实现实时监控聪明钱交易
|
||||||
|
3. **跟单推荐**:根据聪明钱分析结果,推荐适合跟单的交易者
|
||||||
|
4. **风险预警**:监控聪明钱交易者的风险指标,及时预警
|
||||||
|
5. **多维度分析**:增加更多分析维度(如市场类型、时间分布等)
|
||||||
|
|
||||||
@@ -98,6 +98,7 @@ const ApiHealthStatus: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
bodyStyle={{ padding: '12px' }}
|
bodyStyle={{ padding: '12px' }}
|
||||||
>
|
>
|
||||||
|
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '8px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '8px' }}>
|
||||||
<Text strong style={{ fontSize: '14px' }}>
|
<Text strong style={{ fontSize: '14px' }}>
|
||||||
{item.name}
|
{item.name}
|
||||||
@@ -110,9 +111,28 @@ const ApiHealthStatus: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
<Badge
|
<Badge
|
||||||
status={item.status === 'success' ? 'success' : item.status === 'skipped' ? 'default' : 'error'}
|
status={item.status === 'success' ? 'success' : item.status === 'skipped' ? 'default' : 'error'}
|
||||||
|
text={getStatusText(item.status)}
|
||||||
/>
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Text type="secondary" style={{ fontSize: '12px', wordBreak: 'break-all' }}>
|
||||||
|
{item.url}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.message && item.message !== '连接成功' && (
|
||||||
|
<div>
|
||||||
|
<Text
|
||||||
|
type={item.status === 'success' ? 'success' : item.status === 'skipped' ? 'secondary' : 'danger'}
|
||||||
|
style={{ fontSize: '12px' }}
|
||||||
|
>
|
||||||
|
{item.message}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card
|
<Card
|
||||||
|
|||||||
@@ -399,10 +399,10 @@ const PositionList: React.FC = () => {
|
|||||||
})
|
})
|
||||||
if (response.data.code === 0 && response.data.data) {
|
if (response.data.code === 0 && response.data.data) {
|
||||||
setMarketPrice(response.data.data)
|
setMarketPrice(response.data.data)
|
||||||
// 默认使用最优买价作为限价
|
// 默认使用当前价格作为限价
|
||||||
if (response.data.data.bestBid) {
|
if (response.data.data.currentPrice) {
|
||||||
setLimitPrice(response.data.data.bestBid)
|
setLimitPrice(response.data.data.currentPrice)
|
||||||
form.setFieldsValue({ limitPrice: response.data.data.bestBid })
|
form.setFieldsValue({ limitPrice: response.data.data.currentPrice })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -449,12 +449,10 @@ const PositionList: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取当前卖出价格(市价或限价)
|
// 获取当前卖出价格(市价或限价)
|
||||||
// 卖出操作应该使用 bestBid(最优买价),因为你要卖给愿意买入的人
|
|
||||||
const getCurrentSellPrice = (): string => {
|
const getCurrentSellPrice = (): string => {
|
||||||
if (orderType === 'MARKET') {
|
if (orderType === 'MARKET') {
|
||||||
// 市价订单(卖出):优先使用最优买价(bestBid),因为卖出是卖给买单
|
// 市价订单(卖出):使用当前价格
|
||||||
// 如果没有 bestBid,则使用当前价格,最后使用最新成交价
|
return marketPrice?.currentPrice || selectedPosition?.currentPrice || '0'
|
||||||
return marketPrice?.bestBid || selectedPosition?.currentPrice || marketPrice?.lastPrice || '0'
|
|
||||||
}
|
}
|
||||||
return limitPrice || '0'
|
return limitPrice || '0'
|
||||||
}
|
}
|
||||||
@@ -1377,7 +1375,7 @@ const PositionList: React.FC = () => {
|
|||||||
// 切换订单类型时重新计算收益
|
// 切换订单类型时重新计算收益
|
||||||
if (sellQuantity) {
|
if (sellQuantity) {
|
||||||
const price = e.target.value === 'MARKET'
|
const price = e.target.value === 'MARKET'
|
||||||
? (marketPrice?.bestBid || selectedPosition?.currentPrice || marketPrice?.lastPrice || '0')
|
? (marketPrice?.currentPrice || selectedPosition?.currentPrice || '0')
|
||||||
: limitPrice || '0'
|
: limitPrice || '0'
|
||||||
calculatePnl(sellQuantity, price)
|
calculatePnl(sellQuantity, price)
|
||||||
}
|
}
|
||||||
@@ -1457,9 +1455,9 @@ const PositionList: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
placeholder="请输入限价价格"
|
placeholder="请输入限价价格"
|
||||||
/>
|
/>
|
||||||
{marketPrice?.bestBid && (
|
{marketPrice?.currentPrice && (
|
||||||
<div style={{ marginTop: '4px', fontSize: '12px', color: '#999' }}>
|
<div style={{ marginTop: '4px', fontSize: '12px', color: '#999' }}>
|
||||||
参考价格(最优买价,卖出参考): {formatNumber(marketPrice.bestBid, 4)}
|
参考价格(卖出参考): {formatNumber(marketPrice.currentPrice, 4)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -1469,21 +1467,14 @@ const PositionList: React.FC = () => {
|
|||||||
<div style={{ marginBottom: '16px', padding: '12px', background: '#f0f7ff', borderRadius: '8px' }}>
|
<div style={{ marginBottom: '16px', padding: '12px', background: '#f0f7ff', borderRadius: '8px' }}>
|
||||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>市价参考(卖出)</div>
|
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>市价参考(卖出)</div>
|
||||||
<div style={{ fontSize: '14px' }}>
|
<div style={{ fontSize: '14px' }}>
|
||||||
{marketPrice?.bestBid ? (
|
{marketPrice?.currentPrice ? (
|
||||||
<>最优买价(卖出参考): <span style={{ fontWeight: '500' }}>{formatNumber(marketPrice.bestBid, 4)}</span></>
|
<>当前价格: <span style={{ fontWeight: '500' }}>{formatNumber(marketPrice.currentPrice, 4)}</span></>
|
||||||
) : selectedPosition?.currentPrice ? (
|
) : selectedPosition?.currentPrice ? (
|
||||||
<>当前价格: <span style={{ fontWeight: '500' }}>{formatNumber(selectedPosition.currentPrice, 4)}</span></>
|
<>当前价格: <span style={{ fontWeight: '500' }}>{formatNumber(selectedPosition.currentPrice, 4)}</span></>
|
||||||
) : marketPrice?.lastPrice ? (
|
|
||||||
<>最新成交价: <span style={{ fontWeight: '500' }}>{formatNumber(marketPrice.lastPrice, 4)}</span></>
|
|
||||||
) : (
|
) : (
|
||||||
<span style={{ color: '#999' }}>暂无价格数据</span>
|
<span style={{ color: '#999' }}>暂无价格数据</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{marketPrice?.bestAsk && (
|
|
||||||
<div style={{ fontSize: '12px', color: '#999', marginTop: '4px' }}>
|
|
||||||
最优卖价(买入参考): {formatNumber(marketPrice.bestAsk, 4)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -434,14 +434,11 @@ export interface MarketPriceRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 市场价格响应
|
* 市场当前价格响应
|
||||||
*/
|
*/
|
||||||
export interface MarketPriceResponse {
|
export interface MarketPriceResponse {
|
||||||
marketId: string
|
marketId: string
|
||||||
lastPrice?: string
|
currentPrice: string
|
||||||
bestBid?: string
|
|
||||||
bestAsk?: string
|
|
||||||
midpoint?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user