fix: 修复固定金额模式卖出数量计算逻辑

- 删除卖出订单的 notificationSent 字段,使用 priceUpdated 作为共用字段
- 添加 leaderBuyQuantity 字段,在创建 CopyOrderTracking 时存储 Leader 买入数量
- 修复固定金额模式下卖出数量计算:优先使用存储的 leaderBuyQuantity,避免 API 查询失败
- 添加数据库迁移 V14 和 V15
- 优化 calculateSellQuantityForFixedMode 方法,支持新数据和旧数据兼容
This commit is contained in:
WrBug
2025-12-26 05:42:53 +08:00
parent b6d66aa81e
commit 5fac49c6ca
13 changed files with 1006 additions and 122 deletions
+288
View File
@@ -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 中反馈。**
+95
View File
@@ -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()
@@ -50,5 +50,10 @@ interface CopyOrderTrackingRepository : JpaRepository<CopyOrderTracking, Long> {
* 根据买入订单ID查询订单跟踪记录
*/
fun findByBuyOrderId(buyOrderId: String): List<CopyOrderTracking>
/**
* 查询未发送通知的买入订单(用于轮询更新)
*/
fun findByNotificationSentFalse(): List<CopyOrderTracking>
}
@@ -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,
@@ -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}")
}
@@ -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)
}
}
}
@@ -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 × sizeUSDC
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,
@@ -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;
@@ -0,0 +1,7 @@
-- 添加 Leader 买入数量字段,用于固定金额模式计算卖出比例
ALTER TABLE copy_order_tracking
ADD COLUMN leader_buy_quantity DECIMAL(20, 8) DEFAULT NULL COMMENT 'Leader 买入数量(用于固定金额模式计算卖出比例)';
-- 对于已有数据,如果无法从 API 查询,设置为 NULL(不影响现有功能)
-- 新创建的记录会在创建时自动填充此字段
+5 -1
View File
@@ -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)