fix: 修复固定金额模式卖出数量计算逻辑
- 删除卖出订单的 notificationSent 字段,使用 priceUpdated 作为共用字段 - 添加 leaderBuyQuantity 字段,在创建 CopyOrderTracking 时存储 Leader 买入数量 - 修复固定金额模式下卖出数量计算:优先使用存储的 leaderBuyQuantity,避免 API 查询失败 - 添加数据库迁移 V14 和 V15 - 优化 calculateSellQuantityForFixedMode 方法,支持新数据和旧数据兼容
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
# 🎉 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 中反馈。**
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# 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 支持一下!**
|
||||
|
||||
@@ -38,6 +38,9 @@ data class CopyOrderTracking(
|
||||
@Column(name = "leader_buy_trade_id", nullable = false, length = 100)
|
||||
val leaderBuyTradeId: String, // Leader 买入交易ID
|
||||
|
||||
@Column(name = "leader_buy_quantity", nullable = true, precision = 20, scale = 8)
|
||||
val leaderBuyQuantity: BigDecimal? = null, // Leader 买入数量(用于固定金额模式计算卖出比例)
|
||||
|
||||
@Column(name = "quantity", nullable = false, precision = 20, scale = 8)
|
||||
val quantity: BigDecimal, // 买入数量
|
||||
|
||||
@@ -53,6 +56,9 @@ data class CopyOrderTracking(
|
||||
@Column(name = "status", nullable = false, length = 20)
|
||||
var status: String = "filled", // filled, fully_matched, partially_matched
|
||||
|
||||
@Column(name = "notification_sent", nullable = false)
|
||||
var notificationSent: Boolean = false, // 是否已发送通知(从订单详情获取实际数据后发送)
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ data class SellMatchRecord(
|
||||
val totalRealizedPnl: BigDecimal, // 总已实现盈亏
|
||||
|
||||
@Column(name = "price_updated", nullable = false)
|
||||
var priceUpdated: Boolean = false, // 价格是否已更新(从订单详情获取实际成交价)
|
||||
var priceUpdated: Boolean = false, // 共用字段:false 表示未处理(未查询订单详情,未发送通知),true 表示已处理(已查询订单详情,已发送通知)
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis()
|
||||
|
||||
+5
@@ -50,5 +50,10 @@ interface CopyOrderTrackingRepository : JpaRepository<CopyOrderTracking, Long> {
|
||||
* 根据买入订单ID查询订单跟踪记录
|
||||
*/
|
||||
fun findByBuyOrderId(buyOrderId: String): List<CopyOrderTracking>
|
||||
|
||||
/**
|
||||
* 查询未发送通知的买入订单(用于轮询更新)
|
||||
*/
|
||||
fun findByNotificationSentFalse(): List<CopyOrderTracking>
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -27,6 +27,7 @@ interface SellMatchRecordRepository : JpaRepository<SellMatchRecord, Long> {
|
||||
|
||||
/**
|
||||
* 查询所有价格未更新的卖出记录
|
||||
* 注意:priceUpdated 现在同时表示价格已更新和通知已发送(共用字段)
|
||||
*/
|
||||
fun findByPriceUpdatedFalse(): List<SellMatchRecord>
|
||||
}
|
||||
|
||||
@@ -927,6 +927,8 @@ class AccountService(
|
||||
marketId = request.marketId,
|
||||
marketSlug = marketSlug,
|
||||
side = request.side,
|
||||
price = sellPrice, // 直接传递卖出价格
|
||||
size = sellQuantity.toPlainString(), // 直接传递卖出数量
|
||||
accountName = account.accountName,
|
||||
walletAddress = account.walletAddress,
|
||||
clobApi = clobApi,
|
||||
|
||||
+139
-90
@@ -575,6 +575,7 @@ open class CopyOrderTrackingService(
|
||||
}
|
||||
|
||||
// 创建买入订单跟踪记录(使用真实订单ID,使用outcomeIndex)
|
||||
// 先使用下单时的价格和数量作为临时值,等待轮询任务获取实际数据后再发送通知
|
||||
val tracking = CopyOrderTracking(
|
||||
copyTradingId = copyTrading.id,
|
||||
accountId = copyTrading.accountId,
|
||||
@@ -584,94 +585,17 @@ open class CopyOrderTrackingService(
|
||||
outcomeIndex = trade.outcomeIndex, // 新增字段
|
||||
buyOrderId = realOrderId, // 使用真实订单ID
|
||||
leaderBuyTradeId = trade.id,
|
||||
quantity = finalBuyQuantity, // 使用最终数量(可能已调整)
|
||||
price = buyPrice,
|
||||
leaderBuyQuantity = trade.size.toSafeBigDecimal(), // 存储 Leader 买入数量(用于固定金额模式计算卖出比例)
|
||||
quantity = finalBuyQuantity, // 使用最终数量(可能已调整),临时值
|
||||
price = buyPrice, // 使用下单价格,临时值
|
||||
remainingQuantity = finalBuyQuantity,
|
||||
status = "filled"
|
||||
status = "filled",
|
||||
notificationSent = false // 标记为未发送通知,等待轮询任务获取实际数据后发送
|
||||
)
|
||||
|
||||
copyOrderTrackingRepository.save(tracking)
|
||||
|
||||
// 发送订单成功通知(异步,不阻塞)
|
||||
notificationScope.launch {
|
||||
try {
|
||||
// 获取市场信息(标题和slug)
|
||||
val marketInfo = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val gammaApi = retrofitFactory.createGammaApi()
|
||||
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(trade.market))
|
||||
if (marketResponse.isSuccessful && marketResponse.body() != null) {
|
||||
marketResponse.body()!!.firstOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取市场信息失败: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val marketTitle = marketInfo?.question ?: trade.market
|
||||
val marketSlug = marketInfo?.slug
|
||||
|
||||
// 重新创建 CLOB API 客户端用于查询订单详情
|
||||
val apiSecret = try {
|
||||
decryptApiSecret(account)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("解密 API Secret 失败: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
val apiPassphrase = try {
|
||||
decryptApiPassphrase(account)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("解密 API Passphrase 失败: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
|
||||
val clobApiForQuery = if (apiSecret != null && apiPassphrase != null) {
|
||||
retrofitFactory.createClobApi(
|
||||
account.apiKey,
|
||||
apiSecret,
|
||||
apiPassphrase,
|
||||
account.walletAddress
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
// 获取当前语言设置(从 LocaleContextHolder)
|
||||
val locale = try {
|
||||
org.springframework.context.i18n.LocaleContextHolder.getLocale()
|
||||
} catch (e: Exception) {
|
||||
java.util.Locale("zh", "CN") // 默认简体中文
|
||||
}
|
||||
|
||||
// 获取 Leader 和跟单配置信息
|
||||
val leader = leaderRepository.findById(copyTrading.leaderId).orElse(null)
|
||||
val leaderName = leader?.leaderName
|
||||
val configName = copyTrading.configName
|
||||
|
||||
telegramNotificationService?.sendOrderSuccessNotification(
|
||||
orderId = realOrderId,
|
||||
marketTitle = marketTitle,
|
||||
marketId = trade.market,
|
||||
marketSlug = marketSlug,
|
||||
side = "BUY",
|
||||
accountName = account.accountName,
|
||||
walletAddress = account.walletAddress,
|
||||
clobApi = clobApiForQuery,
|
||||
apiKey = account.apiKey,
|
||||
apiSecret = apiSecret,
|
||||
apiPassphrase = apiPassphrase,
|
||||
walletAddressForApi = account.walletAddress,
|
||||
locale = locale,
|
||||
leaderName = leaderName,
|
||||
configName = configName
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("发送订单成功通知失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("买入订单已保存,等待轮询任务获取实际数据后发送通知: orderId=$realOrderId, copyTradingId=${copyTrading.id}")
|
||||
} catch (e: Exception) {
|
||||
logger.error("处理买入交易失败: copyTradingId=${copyTrading.id}, tradeId=${trade.id}", e)
|
||||
// 继续处理下一个跟单关系
|
||||
@@ -745,9 +669,113 @@ open class CopyOrderTrackingService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算固定金额模式下的卖出数量
|
||||
* 根据未匹配订单的实际买入比例计算
|
||||
*/
|
||||
private suspend fun calculateSellQuantityForFixedMode(
|
||||
unmatchedOrders: List<CopyOrderTracking>,
|
||||
leaderSellQuantity: BigDecimal,
|
||||
copyTrading: CopyTrading
|
||||
): BigDecimal {
|
||||
if (unmatchedOrders.isEmpty()) {
|
||||
return BigDecimal.ZERO
|
||||
}
|
||||
|
||||
// 获取 Leader 信息(用于查询 Leader 买入交易)
|
||||
val leader = leaderRepository.findById(copyTrading.leaderId).orElse(null)
|
||||
?: run {
|
||||
logger.warn("Leader 不存在,使用默认比例: leaderId=${copyTrading.leaderId}")
|
||||
return leaderSellQuantity.multi(copyTrading.copyRatio)
|
||||
}
|
||||
|
||||
// 创建不需要认证的 CLOB API 客户端(用于查询公开的交易数据)
|
||||
// 注意:Polymarket CLOB API 的 /data/trades 接口是公开的,不需要认证
|
||||
val clobApi = retrofitFactory.createClobApiWithoutAuth()
|
||||
|
||||
// 计算总比例:sum(跟单买入数量) / sum(Leader 买入数量)
|
||||
// 优先使用存储的 leaderBuyQuantity,如果不存在则尝试查询 API(兼容旧数据)
|
||||
var totalCopyQuantity = BigDecimal.ZERO
|
||||
var totalLeaderQuantity = BigDecimal.ZERO
|
||||
var successCount = 0
|
||||
var failCount = 0
|
||||
|
||||
logger.debug("开始计算固定金额模式卖出数量: copyTradingId=${copyTrading.id}, unmatchedOrdersCount=${unmatchedOrders.size}, leaderSellQuantity=$leaderSellQuantity")
|
||||
|
||||
for (order in unmatchedOrders) {
|
||||
val copyQty = order.quantity.toSafeBigDecimal()
|
||||
var leaderQty: BigDecimal? = null
|
||||
|
||||
// 优先使用存储的 leaderBuyQuantity
|
||||
if (order.leaderBuyQuantity != null) {
|
||||
leaderQty = order.leaderBuyQuantity.toSafeBigDecimal()
|
||||
logger.debug("使用存储的 Leader 买入数量: copyOrderId=${order.buyOrderId}, copyQty=$copyQty, leaderQty=$leaderQty")
|
||||
successCount++
|
||||
} else {
|
||||
// 兼容旧数据:如果 leaderBuyQuantity 为空,尝试查询 API
|
||||
logger.debug("Leader 买入数量未存储,尝试查询 API: leaderBuyTradeId=${order.leaderBuyTradeId}, copyOrderId=${order.buyOrderId}")
|
||||
try {
|
||||
val tradesResponse = clobApi.getTrades(id = order.leaderBuyTradeId)
|
||||
|
||||
if (tradesResponse.isSuccessful && tradesResponse.body() != null) {
|
||||
val tradesData = tradesResponse.body()!!.data
|
||||
if (tradesData.isNotEmpty()) {
|
||||
val leaderBuyTrade = tradesData.firstOrNull()
|
||||
if (leaderBuyTrade != null) {
|
||||
leaderQty = leaderBuyTrade.size.toSafeBigDecimal()
|
||||
logger.debug("从 API 查询到 Leader 买入数量: leaderBuyTradeId=${order.leaderBuyTradeId}, leaderQty=$leaderQty")
|
||||
successCount++
|
||||
} else {
|
||||
logger.warn("未找到 Leader 买入交易: leaderBuyTradeId=${order.leaderBuyTradeId}")
|
||||
failCount++
|
||||
}
|
||||
} else {
|
||||
logger.warn("Leader 买入交易数据为空: leaderBuyTradeId=${order.leaderBuyTradeId}")
|
||||
failCount++
|
||||
}
|
||||
} else {
|
||||
logger.warn("查询 Leader 买入交易失败: leaderBuyTradeId=${order.leaderBuyTradeId}, code=${tradesResponse.code()}")
|
||||
failCount++
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("查询 Leader 买入交易异常: leaderBuyTradeId=${order.leaderBuyTradeId}, error=${e.message}")
|
||||
failCount++
|
||||
}
|
||||
}
|
||||
|
||||
// 如果成功获取到 Leader 买入数量,累加
|
||||
if (leaderQty != null && leaderQty.gt(BigDecimal.ZERO)) {
|
||||
totalCopyQuantity = totalCopyQuantity.add(copyQty)
|
||||
totalLeaderQuantity = totalLeaderQuantity.add(leaderQty)
|
||||
} else {
|
||||
logger.warn("无法获取 Leader 买入数量,跳过该订单: copyOrderId=${order.buyOrderId}, leaderBuyTradeId=${order.leaderBuyTradeId}")
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("固定金额模式计算结果汇总: copyTradingId=${copyTrading.id}, successCount=$successCount, failCount=$failCount, totalCopyQuantity=$totalCopyQuantity, totalLeaderQuantity=$totalLeaderQuantity")
|
||||
|
||||
// 如果无法计算总比例(查询失败),使用默认比例
|
||||
if (totalLeaderQuantity.lte(BigDecimal.ZERO)) {
|
||||
logger.warn("无法计算总比例(Leader 买入数量为 0),使用默认比例: copyTradingId=${copyTrading.id}")
|
||||
return leaderSellQuantity.multi(copyTrading.copyRatio)
|
||||
}
|
||||
|
||||
// 计算实际比例:跟单买入数量 / Leader 买入数量
|
||||
val actualRatio = totalCopyQuantity.div(totalLeaderQuantity)
|
||||
|
||||
// 计算需要卖出的数量:Leader 卖出数量 × 实际比例
|
||||
val needMatch = leaderSellQuantity.multi(actualRatio)
|
||||
|
||||
logger.debug("固定金额模式卖出数量计算: copyTradingId=${copyTrading.id}, leaderSellQuantity=$leaderSellQuantity, totalCopyQuantity=$totalCopyQuantity, totalLeaderQuantity=$totalLeaderQuantity, actualRatio=$actualRatio, needMatch=$needMatch")
|
||||
|
||||
return needMatch
|
||||
}
|
||||
|
||||
/**
|
||||
* 卖出订单匹配
|
||||
* 统一按比例计算,不区分RATIO或FIXED模式
|
||||
* 根据 copyMode 计算卖出数量:
|
||||
* - RATIO 模式:使用配置的 copyRatio
|
||||
* - FIXED 模式:根据实际买入比例计算
|
||||
* 实际创建卖出订单并记录匹配关系
|
||||
* 注意:此方法在 @Transactional 方法中被调用,会自动继承事务
|
||||
*/
|
||||
@@ -773,10 +801,7 @@ open class CopyOrderTrackingService(
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 计算需要匹配的数量(统一按比例计算)
|
||||
val needMatch = leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio)
|
||||
|
||||
// 3. 查找未匹配的买入订单(FIFO顺序)
|
||||
// 2. 查找未匹配的买入订单(FIFO顺序)
|
||||
// 直接使用outcomeIndex匹配,而不是转换为YES/NO
|
||||
if (leaderSellTrade.outcomeIndex == null) {
|
||||
logger.warn("卖出交易缺少outcomeIndex,无法匹配: tradeId=${leaderSellTrade.id}, market=${leaderSellTrade.market}")
|
||||
@@ -794,6 +819,28 @@ open class CopyOrderTrackingService(
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 计算需要匹配的数量
|
||||
// 对于 FIXED 模式,需要根据实际买入比例计算;对于 RATIO 模式,使用配置的 copyRatio
|
||||
val needMatch = when (copyTrading.copyMode) {
|
||||
"FIXED" -> {
|
||||
// 固定金额模式:根据未匹配订单的实际比例计算
|
||||
// 需要查询每个订单对应的 Leader 买入交易,计算实际比例
|
||||
calculateSellQuantityForFixedMode(
|
||||
unmatchedOrders = unmatchedOrders,
|
||||
leaderSellQuantity = leaderSellTrade.size.toSafeBigDecimal(),
|
||||
copyTrading = copyTrading
|
||||
)
|
||||
}
|
||||
"RATIO" -> {
|
||||
// 比例模式:直接使用配置的 copyRatio
|
||||
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio)
|
||||
}
|
||||
else -> {
|
||||
logger.warn("不支持的 copyMode: ${copyTrading.copyMode},使用默认比例模式")
|
||||
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 获取tokenId(直接使用outcomeIndex,支持多元市场)
|
||||
val tokenIdResult = blockchainService.getTokenId(leaderSellTrade.market, leaderSellTrade.outcomeIndex)
|
||||
if (tokenIdResult.isFailure) {
|
||||
@@ -996,7 +1043,7 @@ open class CopyOrderTrackingService(
|
||||
totalMatchedQuantity = totalMatched,
|
||||
sellPrice = actualSellPrice, // 使用实际成交价(如果查询失败则为下单价格)
|
||||
totalRealizedPnl = totalRealizedPnl,
|
||||
priceUpdated = priceUpdated // 标记价格是否已更新
|
||||
priceUpdated = priceUpdated // 共用字段:false 表示未处理(未查询订单详情,未发送通知),true 表示已处理(已查询订单详情,已发送通知)
|
||||
)
|
||||
|
||||
val savedRecord = sellMatchRecordRepository.save(matchRecord)
|
||||
@@ -1006,6 +1053,8 @@ open class CopyOrderTrackingService(
|
||||
val savedDetail = detail.copy(matchRecordId = savedRecord.id!!)
|
||||
sellMatchDetailRepository.save(savedDetail)
|
||||
}
|
||||
|
||||
logger.info("卖出订单已保存,等待轮询任务获取实际数据后发送通知: orderId=$realSellOrderId, copyTradingId=${copyTrading.id}")
|
||||
|
||||
}
|
||||
|
||||
|
||||
+423
-14
@@ -3,6 +3,7 @@ package com.wrbug.polymarketbot.service.copytrading.statistics
|
||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||
import com.wrbug.polymarketbot.entity.*
|
||||
import com.wrbug.polymarketbot.repository.*
|
||||
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.CryptoUtils
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
@@ -11,6 +12,7 @@ import kotlinx.coroutines.*
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent
|
||||
import org.springframework.context.event.EventListener
|
||||
import org.springframework.context.i18n.LocaleContextHolder
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
@@ -18,7 +20,7 @@ import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* 订单状态更新服务
|
||||
* 定时轮询更新卖出订单的实际成交价,并清理已删除账户的订单
|
||||
* 定时轮询更新卖出订单的实际成交价,并更新买入订单的实际数据并发送通知
|
||||
*/
|
||||
@Service
|
||||
class OrderStatusUpdateService(
|
||||
@@ -27,9 +29,11 @@ class OrderStatusUpdateService(
|
||||
private val copyTradingRepository: CopyTradingRepository,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val copyOrderTrackingRepository: CopyOrderTrackingRepository,
|
||||
private val leaderRepository: LeaderRepository,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val cryptoUtils: CryptoUtils,
|
||||
private val trackingService: CopyOrderTrackingService
|
||||
private val trackingService: CopyOrderTrackingService,
|
||||
private val telegramNotificationService: TelegramNotificationService?
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(OrderStatusUpdateService::class.java)
|
||||
@@ -42,18 +46,21 @@ class OrderStatusUpdateService(
|
||||
}
|
||||
|
||||
/**
|
||||
* 定时更新卖出订单价格
|
||||
* 定时更新订单状态
|
||||
* 每5秒执行一次
|
||||
*/
|
||||
@Scheduled(fixedDelay = 5000)
|
||||
fun updateSellOrderPrices() {
|
||||
fun updateOrderStatus() {
|
||||
updateScope.launch {
|
||||
try {
|
||||
// 1. 清理已删除账户的订单
|
||||
cleanupDeletedAccountOrders()
|
||||
|
||||
// 2. 更新卖出订单的实际成交价
|
||||
// 2. 更新卖出订单的实际成交价并发送通知(priceUpdated 共用字段)
|
||||
updatePendingSellOrderPrices()
|
||||
|
||||
// 3. 更新买入订单的实际数据并发送通知
|
||||
updatePendingBuyOrders()
|
||||
} catch (e: Exception) {
|
||||
logger.error("订单状态更新异常: ${e.message}", e)
|
||||
}
|
||||
@@ -125,11 +132,12 @@ class OrderStatusUpdateService(
|
||||
|
||||
/**
|
||||
* 更新待更新的卖出订单价格
|
||||
* 注意:priceUpdated 现在同时表示价格已更新和通知已发送(共用字段)
|
||||
*/
|
||||
@Transactional
|
||||
private suspend fun updatePendingSellOrderPrices() {
|
||||
try {
|
||||
// 查询所有价格未更新的卖出记录
|
||||
// 查询所有价格未更新的卖出记录(priceUpdated = false 表示未处理)
|
||||
val pendingRecords = sellMatchRecordRepository.findByPriceUpdatedFalse()
|
||||
|
||||
if (pendingRecords.isEmpty()) {
|
||||
@@ -183,9 +191,20 @@ class OrderStatusUpdateService(
|
||||
account.walletAddress
|
||||
)
|
||||
|
||||
// 如果 orderId 不是 0x 开头,直接标记为已更新(不需要通过API查询)
|
||||
// 如果 orderId 不是 0x 开头,直接标记为已处理(priceUpdated = true 表示已处理,包括价格更新和通知发送)
|
||||
if (!record.sellOrderId.startsWith("0x", ignoreCase = true)) {
|
||||
logger.debug("卖出订单ID非0x开头,直接标记为已更新: orderId=${record.sellOrderId}")
|
||||
logger.debug("卖出订单ID非0x开头,直接标记为已处理: orderId=${record.sellOrderId}")
|
||||
// 发送通知(使用临时数据)
|
||||
sendSellOrderNotification(
|
||||
record = record,
|
||||
useTemporaryData = true,
|
||||
account = account,
|
||||
copyTrading = copyTrading,
|
||||
clobApi = clobApi,
|
||||
apiSecret = apiSecret,
|
||||
apiPassphrase = apiPassphrase
|
||||
)
|
||||
// 标记为已处理(priceUpdated = true 同时表示价格已更新和通知已发送)
|
||||
val updatedRecord = SellMatchRecord(
|
||||
id = record.id,
|
||||
copyTradingId = record.copyTradingId,
|
||||
@@ -197,7 +216,7 @@ class OrderStatusUpdateService(
|
||||
totalMatchedQuantity = record.totalMatchedQuantity,
|
||||
sellPrice = record.sellPrice,
|
||||
totalRealizedPnl = record.totalRealizedPnl,
|
||||
priceUpdated = true, // 标记为已更新
|
||||
priceUpdated = true, // 标记为已处理(价格已更新和通知已发送)
|
||||
createdAt = record.createdAt
|
||||
)
|
||||
sellMatchRecordRepository.save(updatedRecord)
|
||||
@@ -238,6 +257,18 @@ class OrderStatusUpdateService(
|
||||
totalRealizedPnl = totalRealizedPnl.add(updatedRealizedPnl)
|
||||
}
|
||||
|
||||
// 发送通知(使用实际价格)
|
||||
sendSellOrderNotification(
|
||||
record = record,
|
||||
actualPrice = actualSellPrice.toString(),
|
||||
actualSize = record.totalMatchedQuantity.toString(),
|
||||
account = account,
|
||||
copyTrading = copyTrading,
|
||||
clobApi = clobApi,
|
||||
apiSecret = apiSecret,
|
||||
apiPassphrase = apiPassphrase
|
||||
)
|
||||
|
||||
// 更新卖出记录
|
||||
// 注意:SellMatchRecord 的字段都是 val,需要创建新对象
|
||||
val updatedRecord = SellMatchRecord(
|
||||
@@ -251,14 +282,25 @@ class OrderStatusUpdateService(
|
||||
totalMatchedQuantity = record.totalMatchedQuantity,
|
||||
sellPrice = actualSellPrice, // 更新卖出价格
|
||||
totalRealizedPnl = totalRealizedPnl, // 更新总盈亏
|
||||
priceUpdated = true, // 标记为已更新
|
||||
priceUpdated = true, // 标记为已处理(价格已更新和通知已发送)
|
||||
createdAt = record.createdAt
|
||||
)
|
||||
sellMatchRecordRepository.save(updatedRecord)
|
||||
|
||||
logger.info("更新卖出订单价格成功: orderId=${record.sellOrderId}, 原价格=${record.sellPrice}, 新价格=$actualSellPrice")
|
||||
logger.info("更新卖出订单价格成功并已发送通知: orderId=${record.sellOrderId}, 原价格=${record.sellPrice}, 新价格=$actualSellPrice")
|
||||
} else {
|
||||
// 价格相同,但可能已经查询过,标记为已更新
|
||||
// 价格相同,但已经查询过,发送通知并标记为已处理
|
||||
sendSellOrderNotification(
|
||||
record = record,
|
||||
actualPrice = actualSellPrice.toString(),
|
||||
actualSize = record.totalMatchedQuantity.toString(),
|
||||
account = account,
|
||||
copyTrading = copyTrading,
|
||||
clobApi = clobApi,
|
||||
apiSecret = apiSecret,
|
||||
apiPassphrase = apiPassphrase
|
||||
)
|
||||
|
||||
val updatedRecord = SellMatchRecord(
|
||||
id = record.id,
|
||||
copyTradingId = record.copyTradingId,
|
||||
@@ -270,11 +312,11 @@ class OrderStatusUpdateService(
|
||||
totalMatchedQuantity = record.totalMatchedQuantity,
|
||||
sellPrice = record.sellPrice,
|
||||
totalRealizedPnl = record.totalRealizedPnl,
|
||||
priceUpdated = true, // 标记为已更新
|
||||
priceUpdated = true, // 标记为已处理(价格已更新和通知已发送)
|
||||
createdAt = record.createdAt
|
||||
)
|
||||
sellMatchRecordRepository.save(updatedRecord)
|
||||
logger.debug("卖出订单价格无需更新: orderId=${record.sellOrderId}, price=$actualSellPrice")
|
||||
logger.debug("卖出订单价格无需更新但已发送通知: orderId=${record.sellOrderId}, price=$actualSellPrice")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("更新卖出订单价格失败: orderId=${record.sellOrderId}, error=${e.message}", e)
|
||||
@@ -285,5 +327,372 @@ class OrderStatusUpdateService(
|
||||
logger.error("更新待更新卖出订单价格异常: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新待发送通知的买入订单
|
||||
* 查询订单详情获取实际价格和数量,然后发送通知并更新数据库
|
||||
*/
|
||||
@Transactional
|
||||
private suspend fun updatePendingBuyOrders() {
|
||||
try {
|
||||
// 查询所有未发送通知的买入订单
|
||||
val pendingOrders = copyOrderTrackingRepository.findByNotificationSentFalse()
|
||||
|
||||
if (pendingOrders.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.debug("找到 ${pendingOrders.size} 条待发送通知的买入订单")
|
||||
|
||||
for (order in pendingOrders) {
|
||||
try {
|
||||
// 验证 orderId 格式(必须以 0x 开头的 16 进制)
|
||||
if (!isValidOrderId(order.buyOrderId)) {
|
||||
logger.warn("买入订单ID格式无效,直接标记为已发送通知: orderId=${order.buyOrderId}")
|
||||
// 对于非 0x 开头的订单ID,直接标记为已发送,使用临时数据发送通知
|
||||
val updatedOrder = CopyOrderTracking(
|
||||
id = order.id,
|
||||
copyTradingId = order.copyTradingId,
|
||||
accountId = order.accountId,
|
||||
leaderId = order.leaderId,
|
||||
marketId = order.marketId,
|
||||
side = order.side,
|
||||
outcomeIndex = order.outcomeIndex,
|
||||
buyOrderId = order.buyOrderId,
|
||||
leaderBuyTradeId = order.leaderBuyTradeId,
|
||||
quantity = order.quantity,
|
||||
price = order.price,
|
||||
matchedQuantity = order.matchedQuantity,
|
||||
remainingQuantity = order.remainingQuantity,
|
||||
status = order.status,
|
||||
notificationSent = true, // 标记为已发送通知
|
||||
createdAt = order.createdAt,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
copyOrderTrackingRepository.save(updatedOrder)
|
||||
sendBuyOrderNotification(updatedOrder, useTemporaryData = true)
|
||||
continue
|
||||
}
|
||||
|
||||
// 获取跟单关系
|
||||
val copyTrading = copyTradingRepository.findById(order.copyTradingId).orElse(null)
|
||||
if (copyTrading == null) {
|
||||
logger.warn("跟单关系不存在,跳过更新: copyTradingId=${order.copyTradingId}")
|
||||
continue
|
||||
}
|
||||
|
||||
// 获取账户
|
||||
val account = accountRepository.findById(order.accountId).orElse(null)
|
||||
if (account == null) {
|
||||
logger.warn("账户不存在,跳过更新: accountId=${order.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
|
||||
)
|
||||
|
||||
// 查询订单详情
|
||||
val orderResponse = clobApi.getOrder(order.buyOrderId)
|
||||
if (!orderResponse.isSuccessful || orderResponse.body() == null) {
|
||||
logger.debug("查询订单详情失败,等待下次轮询: orderId=${order.buyOrderId}, code=${orderResponse.code()}")
|
||||
continue
|
||||
}
|
||||
|
||||
val orderDetail = orderResponse.body()!!
|
||||
|
||||
// 获取实际价格和数量
|
||||
val actualPrice = orderDetail.price?.toSafeBigDecimal() ?: order.price
|
||||
val actualSize = orderDetail.originalSize?.toSafeBigDecimal() ?: order.quantity
|
||||
val actualOutcome = orderDetail.outcome
|
||||
|
||||
// 更新订单数据(如果实际数据与临时数据不同)
|
||||
val needUpdate = actualPrice != order.price || actualSize != order.quantity
|
||||
|
||||
// 创建更新后的订单对象
|
||||
val updatedOrder = CopyOrderTracking(
|
||||
id = order.id,
|
||||
copyTradingId = order.copyTradingId,
|
||||
accountId = order.accountId,
|
||||
leaderId = order.leaderId,
|
||||
marketId = order.marketId,
|
||||
side = order.side,
|
||||
outcomeIndex = order.outcomeIndex,
|
||||
buyOrderId = order.buyOrderId,
|
||||
leaderBuyTradeId = order.leaderBuyTradeId,
|
||||
quantity = actualSize, // 使用实际数量
|
||||
price = actualPrice, // 使用实际价格
|
||||
matchedQuantity = order.matchedQuantity,
|
||||
remainingQuantity = order.remainingQuantity,
|
||||
status = order.status,
|
||||
notificationSent = true, // 标记为已发送通知
|
||||
createdAt = order.createdAt,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
// 保存更新后的订单
|
||||
copyOrderTrackingRepository.save(updatedOrder)
|
||||
|
||||
if (needUpdate) {
|
||||
logger.info("更新买入订单数据成功: orderId=${order.buyOrderId}, 原价格=${order.price}, 新价格=$actualPrice, 原数量=${order.quantity}, 新数量=$actualSize")
|
||||
} else {
|
||||
logger.debug("买入订单数据无需更新: orderId=${order.buyOrderId}")
|
||||
}
|
||||
|
||||
// 发送通知(使用实际数据)
|
||||
sendBuyOrderNotification(
|
||||
order = updatedOrder,
|
||||
actualPrice = actualPrice.toString(),
|
||||
actualSize = actualSize.toString(),
|
||||
actualOutcome = actualOutcome,
|
||||
account = account,
|
||||
copyTrading = copyTrading,
|
||||
clobApi = clobApi,
|
||||
apiSecret = apiSecret,
|
||||
apiPassphrase = apiPassphrase
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("更新买入订单失败: orderId=${order.buyOrderId}, error=${e.message}", e)
|
||||
// 继续处理下一条记录
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("更新待发送通知买入订单异常: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送买入订单通知
|
||||
*/
|
||||
private suspend fun sendBuyOrderNotification(
|
||||
order: CopyOrderTracking,
|
||||
useTemporaryData: Boolean = false,
|
||||
actualPrice: String? = null,
|
||||
actualSize: String? = null,
|
||||
actualOutcome: String? = null,
|
||||
account: Account? = null,
|
||||
copyTrading: CopyTrading? = null,
|
||||
clobApi: PolymarketClobApi? = null,
|
||||
apiSecret: String? = null,
|
||||
apiPassphrase: String? = null
|
||||
) {
|
||||
if (telegramNotificationService == null) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取跟单关系和账户信息(如果未提供)
|
||||
val finalCopyTrading = copyTrading ?: copyTradingRepository.findById(order.copyTradingId).orElse(null)
|
||||
if (finalCopyTrading == null) {
|
||||
logger.warn("跟单关系不存在,跳过发送通知: copyTradingId=${order.copyTradingId}")
|
||||
return
|
||||
}
|
||||
|
||||
val finalAccount = account ?: accountRepository.findById(order.accountId).orElse(null)
|
||||
if (finalAccount == null) {
|
||||
logger.warn("账户不存在,跳过发送通知: accountId=${order.accountId}")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取市场信息
|
||||
val marketInfo = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val gammaApi = retrofitFactory.createGammaApi()
|
||||
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(order.marketId))
|
||||
if (marketResponse.isSuccessful && marketResponse.body() != null) {
|
||||
marketResponse.body()!!.firstOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取市场信息失败: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val marketTitle = marketInfo?.question ?: order.marketId
|
||||
val marketSlug = marketInfo?.slug
|
||||
|
||||
// 获取 Leader 和跟单配置信息
|
||||
val leader = leaderRepository.findById(order.leaderId).orElse(null)
|
||||
val leaderName = leader?.leaderName
|
||||
val configName = finalCopyTrading.configName
|
||||
|
||||
// 获取当前语言设置
|
||||
val locale = try {
|
||||
LocaleContextHolder.getLocale()
|
||||
} catch (e: Exception) {
|
||||
java.util.Locale("zh", "CN") // 默认简体中文
|
||||
}
|
||||
|
||||
// 创建 CLOB API 客户端(如果未提供)
|
||||
val finalClobApi = clobApi ?: if (finalAccount.apiKey != null && apiSecret != null && apiPassphrase != null) {
|
||||
retrofitFactory.createClobApi(
|
||||
finalAccount.apiKey!!,
|
||||
apiSecret,
|
||||
apiPassphrase,
|
||||
finalAccount.walletAddress
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
// 发送通知
|
||||
telegramNotificationService.sendOrderSuccessNotification(
|
||||
orderId = order.buyOrderId,
|
||||
marketTitle = marketTitle,
|
||||
marketId = order.marketId,
|
||||
marketSlug = marketSlug,
|
||||
side = "BUY",
|
||||
price = actualPrice ?: order.price.toString(), // 使用实际价格或临时价格
|
||||
size = actualSize ?: order.quantity.toString(), // 使用实际数量或临时数量
|
||||
outcome = actualOutcome, // 使用实际 outcome
|
||||
accountName = finalAccount.accountName,
|
||||
walletAddress = finalAccount.walletAddress,
|
||||
clobApi = finalClobApi,
|
||||
apiKey = finalAccount.apiKey,
|
||||
apiSecret = apiSecret,
|
||||
apiPassphrase = apiPassphrase,
|
||||
walletAddressForApi = finalAccount.walletAddress,
|
||||
locale = locale,
|
||||
leaderName = leaderName,
|
||||
configName = configName
|
||||
)
|
||||
|
||||
logger.info("买入订单通知已发送: orderId=${order.buyOrderId}, copyTradingId=${order.copyTradingId}")
|
||||
} catch (e: Exception) {
|
||||
logger.warn("发送买入订单通知失败: orderId=${order.buyOrderId}, error=${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 发送卖出订单通知
|
||||
*/
|
||||
private suspend fun sendSellOrderNotification(
|
||||
record: SellMatchRecord,
|
||||
useTemporaryData: Boolean = false,
|
||||
actualPrice: String? = null,
|
||||
actualSize: String? = null,
|
||||
actualOutcome: String? = null,
|
||||
account: Account? = null,
|
||||
copyTrading: CopyTrading? = null,
|
||||
clobApi: PolymarketClobApi? = null,
|
||||
apiSecret: String? = null,
|
||||
apiPassphrase: String? = null
|
||||
) {
|
||||
if (telegramNotificationService == null) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取跟单关系和账户信息(如果未提供)
|
||||
val finalCopyTrading = copyTrading ?: copyTradingRepository.findById(record.copyTradingId).orElse(null)
|
||||
if (finalCopyTrading == null) {
|
||||
logger.warn("跟单关系不存在,跳过发送通知: copyTradingId=${record.copyTradingId}")
|
||||
return
|
||||
}
|
||||
|
||||
val finalAccount = account ?: accountRepository.findById(finalCopyTrading.accountId).orElse(null)
|
||||
if (finalAccount == null) {
|
||||
logger.warn("账户不存在,跳过发送通知: accountId=${finalCopyTrading.accountId}")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取市场信息
|
||||
val marketInfo = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val gammaApi = retrofitFactory.createGammaApi()
|
||||
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(record.marketId))
|
||||
if (marketResponse.isSuccessful && marketResponse.body() != null) {
|
||||
marketResponse.body()!!.firstOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取市场信息失败: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val marketTitle = marketInfo?.question ?: record.marketId
|
||||
val marketSlug = marketInfo?.slug
|
||||
|
||||
// 获取 Leader 和跟单配置信息
|
||||
val leader = leaderRepository.findById(finalCopyTrading.leaderId).orElse(null)
|
||||
val leaderName = leader?.leaderName
|
||||
val configName = finalCopyTrading.configName
|
||||
|
||||
// 获取当前语言设置
|
||||
val locale = try {
|
||||
LocaleContextHolder.getLocale()
|
||||
} catch (e: Exception) {
|
||||
java.util.Locale("zh", "CN") // 默认简体中文
|
||||
}
|
||||
|
||||
// 创建 CLOB API 客户端(如果未提供)
|
||||
val finalClobApi = clobApi ?: if (finalAccount.apiKey != null && apiSecret != null && apiPassphrase != null) {
|
||||
retrofitFactory.createClobApi(
|
||||
finalAccount.apiKey!!,
|
||||
apiSecret,
|
||||
apiPassphrase,
|
||||
finalAccount.walletAddress
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
// 发送通知
|
||||
telegramNotificationService.sendOrderSuccessNotification(
|
||||
orderId = record.sellOrderId,
|
||||
marketTitle = marketTitle,
|
||||
marketId = record.marketId,
|
||||
marketSlug = marketSlug,
|
||||
side = "SELL",
|
||||
price = actualPrice ?: record.sellPrice.toString(), // 使用实际价格或临时价格
|
||||
size = actualSize ?: record.totalMatchedQuantity.toString(), // 使用实际数量或临时数量
|
||||
outcome = actualOutcome, // 使用实际 outcome
|
||||
accountName = finalAccount.accountName,
|
||||
walletAddress = finalAccount.walletAddress,
|
||||
clobApi = finalClobApi,
|
||||
apiKey = finalAccount.apiKey,
|
||||
apiSecret = apiSecret,
|
||||
apiPassphrase = apiPassphrase,
|
||||
walletAddressForApi = finalAccount.walletAddress,
|
||||
locale = locale,
|
||||
leaderName = leaderName,
|
||||
configName = configName
|
||||
)
|
||||
|
||||
logger.info("卖出订单通知已发送: orderId=${record.sellOrderId}, copyTradingId=${record.copyTradingId}")
|
||||
} catch (e: Exception) {
|
||||
logger.warn("发送卖出订单通知失败: orderId=${record.sellOrderId}, error=${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+26
-16
@@ -65,6 +65,9 @@ class TelegramNotificationService(
|
||||
marketId: String? = null,
|
||||
marketSlug: String? = null,
|
||||
side: String,
|
||||
price: String? = null, // 订单价格(可选,如果提供则直接使用)
|
||||
size: String? = null, // 订单数量(可选,如果提供则直接使用)
|
||||
outcome: String? = null, // 市场方向(可选,如果提供则直接使用)
|
||||
accountName: String? = null,
|
||||
walletAddress: String? = null,
|
||||
clobApi: PolymarketClobApi? = null,
|
||||
@@ -84,35 +87,42 @@ class TelegramNotificationService(
|
||||
java.util.Locale("zh", "CN") // 默认简体中文
|
||||
}
|
||||
|
||||
// 尝试从订单详情获取实际价格和数量
|
||||
var actualPrice: String? = null
|
||||
var actualSize: String? = null
|
||||
// 优先使用传入的价格和数量,如果没有提供则尝试从订单详情获取
|
||||
var actualPrice: String? = price
|
||||
var actualSize: String? = size
|
||||
var actualSide: String = side
|
||||
var actualOutcome: String? = null // 市场方向(outcome)
|
||||
var actualOutcome: String? = outcome
|
||||
|
||||
if (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 {
|
||||
val orderResponse = clobApi.getOrder(orderId)
|
||||
if (orderResponse.isSuccessful && orderResponse.body() != null) {
|
||||
val order = orderResponse.body()!!
|
||||
actualPrice = order.price
|
||||
actualSize = order.originalSize // 使用 originalSize 作为订单数量
|
||||
if (actualPrice == null) {
|
||||
actualPrice = order.price
|
||||
}
|
||||
if (actualSize == null) {
|
||||
actualSize = order.originalSize // 使用 originalSize 作为订单数量
|
||||
}
|
||||
actualSide = order.side // 使用订单详情中的 side
|
||||
actualOutcome = order.outcome // 使用订单详情中的 outcome(市场方向)
|
||||
if (actualOutcome == null) {
|
||||
actualOutcome = order.outcome // 使用订单详情中的 outcome(市场方向)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("查询订单详情失败,使用默认值: ${e.message}", e)
|
||||
logger.warn("查询订单详情失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有获取到实际值,使用默认值(这种情况不应该发生,但为了兼容性保留)
|
||||
val price = actualPrice ?: "0"
|
||||
val size = actualSize ?: "0"
|
||||
// 如果仍然没有获取到实际值,使用默认值(这种情况不应该发生,但为了兼容性保留)
|
||||
val finalPrice = actualPrice ?: "0"
|
||||
val finalSize = actualSize ?: "0"
|
||||
|
||||
// 计算订单金额 = price × size(USDC)
|
||||
val amount = try {
|
||||
val priceDecimal = price.toSafeBigDecimal()
|
||||
val sizeDecimal = size.toSafeBigDecimal()
|
||||
val priceDecimal = finalPrice.toSafeBigDecimal()
|
||||
val sizeDecimal = finalSize.toSafeBigDecimal()
|
||||
priceDecimal.multiply(sizeDecimal).toString()
|
||||
} catch (e: Exception) {
|
||||
logger.warn("计算订单金额失败: ${e.message}", e)
|
||||
@@ -126,8 +136,8 @@ class TelegramNotificationService(
|
||||
marketSlug = marketSlug,
|
||||
side = actualSide,
|
||||
outcome = actualOutcome,
|
||||
price = price,
|
||||
size = size,
|
||||
price = finalPrice,
|
||||
size = finalSize,
|
||||
amount = amount,
|
||||
accountName = accountName,
|
||||
walletAddress = walletAddress,
|
||||
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
-- 添加 notification_sent 字段到 copy_order_tracking 表
|
||||
-- 用于标记买入订单是否已发送通知
|
||||
ALTER TABLE copy_order_tracking
|
||||
ADD COLUMN notification_sent BOOLEAN DEFAULT FALSE COMMENT '是否已发送通知(从订单详情获取实际数据后发送)';
|
||||
|
||||
-- 为已存在的记录设置默认值(已存在的订单视为已发送通知)
|
||||
UPDATE copy_order_tracking SET notification_sent = TRUE WHERE notification_sent IS NULL;
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
-- 添加 Leader 买入数量字段,用于固定金额模式计算卖出比例
|
||||
ALTER TABLE copy_order_tracking
|
||||
ADD COLUMN leader_buy_quantity DECIMAL(20, 8) DEFAULT NULL COMMENT 'Leader 买入数量(用于固定金额模式计算卖出比例)';
|
||||
|
||||
-- 对于已有数据,如果无法从 API 查询,设置为 NULL(不影响现有功能)
|
||||
-- 新创建的记录会在创建时自动填充此字段
|
||||
|
||||
@@ -129,7 +129,11 @@ docker-compose -f docker-compose.prod.yml logs -f
|
||||
```
|
||||
|
||||
**注意事项**:
|
||||
- ⚠️ 更新前建议备份数据库(如果使用 Docker Compose 中的 MySQL)
|
||||
- ⚠️ **备份数据库(强烈推荐)**:
|
||||
- 备份不是必须的,但强烈推荐,特别是生产环境
|
||||
- Docker 更新不会删除数据(数据存储在独立的数据卷中)
|
||||
- 但数据库结构可能会变更,如果迁移失败,备份可以帮助恢复
|
||||
- 备份命令:`docker exec polyhermes-mysql mysqldump -u root -p polyhermes > backup_$(date +%Y%m%d_%H%M%S).sql`
|
||||
- ⚠️ 更新过程中服务会短暂中断,建议在低峰期进行
|
||||
- ✅ 使用 `docker-compose pull` 可以自动拉取最新镜像并更新(如果使用 `latest` 标签)
|
||||
- ✅ 查看可用版本:访问 [Docker Hub](https://hub.docker.com/r/wrbug/polyhermes/tags) 或 [GitHub Releases](https://github.com/WrBug/PolyHermes/releases)
|
||||
|
||||
Reference in New Issue
Block a user