diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/PolymarketBotApplication.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/PolymarketBotApplication.kt index a8404f8..c48c2f7 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/PolymarketBotApplication.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/PolymarketBotApplication.kt @@ -2,8 +2,10 @@ package com.wrbug.polymarketbot import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication +import org.springframework.scheduling.annotation.EnableScheduling @SpringBootApplication +@EnableScheduling class PolymarketBotApplication fun main(args: Array) { diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/SellMatchRecord.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/SellMatchRecord.kt index 048905c..04e8e2d 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/SellMatchRecord.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/SellMatchRecord.kt @@ -41,6 +41,9 @@ data class SellMatchRecord( @Column(name = "total_realized_pnl", nullable = false, precision = 20, scale = 8) val totalRealizedPnl: BigDecimal, // 总已实现盈亏 + @Column(name = "price_updated", nullable = false) + var priceUpdated: Boolean = false, // 价格是否已更新(从订单详情获取实际成交价) + @Column(name = "created_at", nullable = false) val createdAt: Long = System.currentTimeMillis() ) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/SellMatchRecordRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/SellMatchRecordRepository.kt index 7b8cb17..47a7e24 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/SellMatchRecordRepository.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/SellMatchRecordRepository.kt @@ -24,5 +24,10 @@ interface SellMatchRecordRepository : JpaRepository { * 根据Leader卖出交易ID查询记录 */ fun findByLeaderSellTradeId(leaderSellTradeId: String): SellMatchRecord? + + /** + * 查询所有价格未更新的卖出记录 + */ + fun findByPriceUpdatedFalse(): List } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyOrderTrackingService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyOrderTrackingService.kt index fade4c3..0ff7c86 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyOrderTrackingService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyOrderTrackingService.kt @@ -567,6 +567,12 @@ open class CopyOrderTrackingService( } val realOrderId = createOrderResult.getOrNull() ?: continue + + // 验证 orderId 格式(必须以 0x 开头的 16 进制) + if (!isValidOrderId(realOrderId)) { + logger.warn("买入订单ID格式无效,跳过保存: orderId=$realOrderId") + continue + } // 创建买入订单跟踪记录(使用真实订单ID,使用outcomeIndex) val tracking = CopyOrderTracking( @@ -942,8 +948,20 @@ open class CopyOrderTrackingService( } val realSellOrderId = createOrderResult.getOrNull() ?: return + + // 12. 下单时直接使用下单价格保存,等待定时任务更新实际成交价 + // priceUpdated 统一由定时任务更新,下单时统一设置为 false(非0x开头的除外) + val priceUpdated = !realSellOrderId.startsWith("0x", ignoreCase = true) + if (priceUpdated) { + logger.debug("卖出订单ID非0x开头,标记为已更新: orderId=$realSellOrderId") + } else { + logger.debug("卖出订单ID为0x开头,等待定时任务更新价格: orderId=$realSellOrderId") + } + + // 使用下单价格,等待定时任务更新实际成交价 + val actualSellPrice = sellPrice - // 12. 更新买入订单跟踪状态 + // 13. 更新买入订单跟踪状态 for (order in unmatchedOrders) { val detail = matchDetails.find { it.trackingId == order.id } if (detail != null) { @@ -956,8 +974,17 @@ open class CopyOrderTrackingService( } } - // 13. 创建卖出匹配记录(使用真实订单ID,使用outcomeIndex) - val totalRealizedPnl = matchDetails.sumOf { it.realizedPnl.toSafeBigDecimal() } + // 14. 重新计算盈亏(使用实际成交价) + val updatedMatchDetails = matchDetails.map { detail -> + val updatedRealizedPnl = actualSellPrice.subtract(detail.buyPrice).multi(detail.matchedQuantity) + detail.copy( + sellPrice = actualSellPrice, + realizedPnl = updatedRealizedPnl + ) + } + + // 15. 创建卖出匹配记录(使用真实订单ID和实际成交价) + val totalRealizedPnl = updatedMatchDetails.sumOf { it.realizedPnl.toSafeBigDecimal() } val matchRecord = SellMatchRecord( copyTradingId = copyTrading.id, @@ -967,14 +994,15 @@ open class CopyOrderTrackingService( side = leaderSellTrade.outcomeIndex.toString(), // 使用outcomeIndex作为side(兼容旧数据) outcomeIndex = leaderSellTrade.outcomeIndex, // 新增字段 totalMatchedQuantity = totalMatched, - sellPrice = sellPrice, - totalRealizedPnl = totalRealizedPnl + sellPrice = actualSellPrice, // 使用实际成交价(如果查询失败则为下单价格) + totalRealizedPnl = totalRealizedPnl, + priceUpdated = priceUpdated // 标记价格是否已更新 ) val savedRecord = sellMatchRecordRepository.save(matchRecord) - // 14. 保存匹配明细 - for (detail in matchDetails) { + // 16. 保存匹配明细(使用实际成交价) + for (detail in updatedMatchDetails) { val savedDetail = detail.copy(matchRecordId = savedRecord.id!!) sellMatchDetailRepository.save(savedDetail) } @@ -1382,6 +1410,111 @@ open class CopyOrderTrackingService( } } + /** + * 验证订单ID格式 + * 订单ID必须以 0x 开头,且是有效的 16 进制字符串 + * + * @param orderId 订单ID + * @return 如果格式有效返回 true,否则返回 false + */ + private fun isValidOrderId(orderId: String): Boolean { + if (!orderId.startsWith("0x", ignoreCase = true)) { + return false + } + // 验证是否为有效的 16 进制字符串(去除 0x 前缀后) + val hexPart = orderId.substring(2) + if (hexPart.isEmpty()) { + return false + } + // 检查是否只包含 0-9, a-f, A-F + return hexPart.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' } + } + + /** + * 获取订单的实际成交价 + * 通过查询订单详情和关联的交易记录,计算加权平均成交价 + * + * @param orderId 订单ID + * @param clobApi CLOB API 客户端(已认证) + * @param fallbackPrice 如果查询失败,使用此价格作为默认值 + * @return 实际成交价(加权平均),如果查询失败则返回 fallbackPrice + */ + suspend fun getActualExecutionPrice( + orderId: String, + clobApi: PolymarketClobApi, + fallbackPrice: BigDecimal + ): BigDecimal { + return try { + // 1. 查询订单详情 + val orderResponse = clobApi.getOrder(orderId) + if (!orderResponse.isSuccessful || orderResponse.body() == null) { + logger.warn("查询订单详情失败: orderId=$orderId, code=${orderResponse.code()}") + return fallbackPrice + } + + val order = orderResponse.body()!! + + // 2. 如果订单未成交,使用下单价格 + if (order.status != "FILLED" && order.sizeMatched.toSafeBigDecimal() <= BigDecimal.ZERO) { + logger.debug("订单未成交,使用下单价格: orderId=$orderId, status=${order.status}") + return fallbackPrice + } + + // 3. 如果订单已成交,通过 associateTrades 获取交易记录 + val associateTrades = order.associateTrades + if (associateTrades.isNullOrEmpty()) { + logger.debug("订单无关联交易记录,使用下单价格: orderId=$orderId") + return fallbackPrice + } + + // 4. 查询所有关联的交易记录 + val trades = mutableListOf() + for (tradeId in associateTrades) { + try { + val tradesResponse = clobApi.getTrades(id = tradeId) + if (tradesResponse.isSuccessful && tradesResponse.body() != null) { + val tradesData = tradesResponse.body()!!.data + trades.addAll(tradesData) + } + } catch (e: Exception) { + logger.warn("查询交易记录失败: tradeId=$tradeId, error=${e.message}") + } + } + + if (trades.isEmpty()) { + logger.debug("未找到交易记录,使用下单价格: orderId=$orderId") + return fallbackPrice + } + + // 5. 计算加权平均成交价 + // 加权平均 = Σ(price * size) / Σ(size) + var totalAmount = BigDecimal.ZERO + var totalSize = BigDecimal.ZERO + + for (trade in trades) { + val tradePrice = trade.price.toSafeBigDecimal() + val tradeSize = trade.size.toSafeBigDecimal() + + if (tradeSize > BigDecimal.ZERO) { + totalAmount = totalAmount.add(tradePrice.multiply(tradeSize)) + totalSize = totalSize.add(tradeSize) + } + } + + if (totalSize > BigDecimal.ZERO) { + val weightedAveragePrice = totalAmount.divide(totalSize, 8, java.math.RoundingMode.HALF_UP) + logger.info("计算实际成交价成功: orderId=$orderId, 加权平均价=$weightedAveragePrice, 下单价格=$fallbackPrice, 交易笔数=${trades.size}") + return weightedAveragePrice + } else { + logger.warn("交易记录数量为0,使用下单价格: orderId=$orderId") + return fallbackPrice + } + } catch (e: Exception) { + logger.error("获取实际成交价异常: orderId=$orderId, error=${e.message}", e) + return fallbackPrice + } + } + /** * 从trade中提取side(结果名称) * diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/OrderStatusUpdateService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/OrderStatusUpdateService.kt new file mode 100644 index 0000000..f578dbd --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/OrderStatusUpdateService.kt @@ -0,0 +1,289 @@ +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.util.RetrofitFactory +import com.wrbug.polymarketbot.util.CryptoUtils +import com.wrbug.polymarketbot.util.toSafeBigDecimal +import com.wrbug.polymarketbot.util.multi +import kotlinx.coroutines.* +import org.slf4j.LoggerFactory +import org.springframework.boot.context.event.ApplicationReadyEvent +import org.springframework.context.event.EventListener +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.math.BigDecimal + +/** + * 订单状态更新服务 + * 定时轮询更新卖出订单的实际成交价,并清理已删除账户的订单 + */ +@Service +class OrderStatusUpdateService( + private val sellMatchRecordRepository: SellMatchRecordRepository, + private val sellMatchDetailRepository: SellMatchDetailRepository, + private val copyTradingRepository: CopyTradingRepository, + private val accountRepository: AccountRepository, + private val copyOrderTrackingRepository: CopyOrderTrackingRepository, + private val retrofitFactory: RetrofitFactory, + private val cryptoUtils: CryptoUtils, + private val trackingService: CopyOrderTrackingService +) { + + private val logger = LoggerFactory.getLogger(OrderStatusUpdateService::class.java) + + private val updateScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + @EventListener(ApplicationReadyEvent::class) + fun onApplicationReady() { + logger.info("订单状态更新服务已启动,将每5秒轮询一次") + } + + /** + * 定时更新卖出订单价格 + * 每5秒执行一次 + */ + @Scheduled(fixedDelay = 5000) + fun updateSellOrderPrices() { + updateScope.launch { + try { + // 1. 清理已删除账户的订单 + cleanupDeletedAccountOrders() + + // 2. 更新卖出订单的实际成交价 + updatePendingSellOrderPrices() + } catch (e: Exception) { + logger.error("订单状态更新异常: ${e.message}", e) + } + } + } + + /** + * 验证订单ID格式 + * 订单ID必须以 0x 开头,且是有效的 16 进制字符串 + * + * @param orderId 订单ID + * @return 如果格式有效返回 true,否则返回 false + */ + private fun isValidOrderId(orderId: String): Boolean { + if (!orderId.startsWith("0x", ignoreCase = true)) { + return false + } + // 验证是否为有效的 16 进制字符串(去除 0x 前缀后) + val hexPart = orderId.substring(2) + if (hexPart.isEmpty()) { + return false + } + // 检查是否只包含 0-9, a-f, A-F + return hexPart.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' } + } + + /** + * 清理已删除账户的订单 + */ + @Transactional + private suspend fun cleanupDeletedAccountOrders() { + try { + // 查询所有卖出记录 + val allRecords = sellMatchRecordRepository.findAll() + + // 查询所有有效的账户ID + val validAccountIds = accountRepository.findAll().mapNotNull { it.id }.toSet() + + // 查询所有有效的跟单关系 + val validCopyTradingIds = copyTradingRepository.findAll() + .filter { it.accountId in validAccountIds } + .mapNotNull { it.id } + .toSet() + + // 找出需要删除的记录(关联的跟单关系已不存在或账户已删除) + val recordsToDelete = allRecords.filter { record -> + val copyTrading = copyTradingRepository.findById(record.copyTradingId).orElse(null) + copyTrading == null || copyTrading.accountId !in validAccountIds + } + + if (recordsToDelete.isNotEmpty()) { + logger.info("清理已删除账户的订单: ${recordsToDelete.size} 条记录") + + // 删除匹配明细 + for (record in recordsToDelete) { + val details = sellMatchDetailRepository.findByMatchRecordId(record.id!!) + sellMatchDetailRepository.deleteAll(details) + } + + // 删除卖出记录 + sellMatchRecordRepository.deleteAll(recordsToDelete) + + logger.info("已清理 ${recordsToDelete.size} 条已删除账户的订单记录") + } + } catch (e: Exception) { + logger.error("清理已删除账户订单异常: ${e.message}", e) + } + } + + /** + * 更新待更新的卖出订单价格 + */ + @Transactional + private suspend fun updatePendingSellOrderPrices() { + try { + // 查询所有价格未更新的卖出记录 + val pendingRecords = sellMatchRecordRepository.findByPriceUpdatedFalse() + + if (pendingRecords.isEmpty()) { + return + } + + logger.debug("找到 ${pendingRecords.size} 条待更新价格的卖出订单") + + for (record in pendingRecords) { + try { + // 获取跟单关系 + val copyTrading = copyTradingRepository.findById(record.copyTradingId).orElse(null) + if (copyTrading == null) { + logger.warn("跟单关系不存在,跳过更新: copyTradingId=${record.copyTradingId}") + continue + } + + // 获取账户 + val account = accountRepository.findById(copyTrading.accountId).orElse(null) + if (account == null) { + logger.warn("账户不存在,跳过更新: accountId=${copyTrading.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 + ) + + // 如果 orderId 不是 0x 开头,直接标记为已更新(不需要通过API查询) + if (!record.sellOrderId.startsWith("0x", ignoreCase = true)) { + logger.debug("卖出订单ID非0x开头,直接标记为已更新: 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( + orderId = record.sellOrderId, + clobApi = clobApi, + fallbackPrice = record.sellPrice + ) + + // 如果价格已更新(与当前价格不同),更新数据库 + if (actualSellPrice != record.sellPrice) { + // 重新计算盈亏 + val details = sellMatchDetailRepository.findByMatchRecordId(record.id!!) + var totalRealizedPnl = BigDecimal.ZERO + + for (detail in details) { + val updatedRealizedPnl = actualSellPrice.subtract(detail.buyPrice).multi(detail.matchedQuantity) + + // 更新明细的卖出价格和盈亏 + // 注意:SellMatchDetail 的字段都是 val,需要创建新对象 + val updatedDetail = SellMatchDetail( + id = detail.id, + matchRecordId = detail.matchRecordId, + trackingId = detail.trackingId, + buyOrderId = detail.buyOrderId, + matchedQuantity = detail.matchedQuantity, + buyPrice = detail.buyPrice, + sellPrice = actualSellPrice, // 更新卖出价格 + realizedPnl = updatedRealizedPnl, // 更新盈亏 + createdAt = detail.createdAt + ) + sellMatchDetailRepository.save(updatedDetail) + + totalRealizedPnl = totalRealizedPnl.add(updatedRealizedPnl) + } + + // 更新卖出记录 + // 注意:SellMatchRecord 的字段都是 val,需要创建新对象 + 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 = actualSellPrice, // 更新卖出价格 + totalRealizedPnl = totalRealizedPnl, // 更新总盈亏 + priceUpdated = true, // 标记为已更新 + createdAt = record.createdAt + ) + sellMatchRecordRepository.save(updatedRecord) + + logger.info("更新卖出订单价格成功: orderId=${record.sellOrderId}, 原价格=${record.sellPrice}, 新价格=$actualSellPrice") + } else { + // 价格相同,但可能已经查询过,标记为已更新 + 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) + logger.debug("卖出订单价格无需更新: orderId=${record.sellOrderId}, price=$actualSellPrice") + } + } catch (e: Exception) { + logger.warn("更新卖出订单价格失败: orderId=${record.sellOrderId}, error=${e.message}", e) + // 继续处理下一条记录 + } + } + } catch (e: Exception) { + logger.error("更新待更新卖出订单价格异常: ${e.message}", e) + } + } +} + diff --git a/backend/src/main/resources/db/migration/V13__add_price_updated_to_sell_match_record.sql b/backend/src/main/resources/db/migration/V13__add_price_updated_to_sell_match_record.sql new file mode 100644 index 0000000..c4084fa --- /dev/null +++ b/backend/src/main/resources/db/migration/V13__add_price_updated_to_sell_match_record.sql @@ -0,0 +1,8 @@ +-- 添加 price_updated 字段到 sell_match_record 表 +-- 用于标记卖出价格是否已从订单详情中更新 +ALTER TABLE sell_match_record +ADD COLUMN price_updated BOOLEAN DEFAULT FALSE COMMENT '价格是否已更新(从订单详情获取实际成交价)'; + +-- 为已存在的记录设置默认值 +UPDATE sell_match_record SET price_updated = TRUE WHERE price_updated IS NULL; +