From c520eb777723044e809c4ce1ff61a7c8522f7658 Mon Sep 17 00:00:00 2001 From: WrBug Date: Sat, 14 Feb 2026 07:29:47 +0800 Subject: [PATCH] =?UTF-8?q?fix(cryptotail):=20=E7=BB=93=E7=AE=97=E7=94=A8?= =?UTF-8?q?=20activity=20=E5=8F=96=E6=88=90=E4=BA=A4=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=85=88=20usdcSize=20=E6=9B=B4=E6=96=B0=E6=8A=95=E5=85=A5?= =?UTF-8?q?=E9=87=91=E9=A2=9D=EF=BC=8C=E5=B0=BE=E7=9B=98=E8=B0=83=E5=BA=A6?= =?UTF-8?q?=E4=B8=8E=E6=89=A7=E8=A1=8C=E6=95=B4=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CryptoTailSettlementService: 实际成交从 Data API getUserActivity 获取,优先用 activity.usdcSize 更新 amountUsdc;仅匹配 type=TRADE,排除 REDEEM;先修正 triggerPrice/amountUsdc 再算 realizedPnl 并一次性写库 - CryptoTailStrategyExecutionService: 与结算/调度相关的整理与精简 - CryptoTailStrategyScheduler: 新增策略变更后触发一轮检查的调度 Co-authored-by: Cursor --- .../cryptotail/CryptoTailSettlementService.kt | 153 +++++++++--------- .../CryptoTailStrategyExecutionService.kt | 80 +-------- .../cryptotail/CryptoTailStrategyScheduler.kt | 37 +++++ 3 files changed, 116 insertions(+), 154 deletions(-) create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyScheduler.kt diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailSettlementService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailSettlementService.kt index b997790..241daae 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailSettlementService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailSettlementService.kt @@ -1,14 +1,13 @@ package com.wrbug.polymarketbot.service.cryptotail import com.wrbug.polymarketbot.api.GammaEventBySlugResponse +import com.wrbug.polymarketbot.api.PolymarketDataApi import com.wrbug.polymarketbot.entity.CryptoTailStrategy import com.wrbug.polymarketbot.entity.CryptoTailStrategyTrigger import com.wrbug.polymarketbot.repository.AccountRepository import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository import com.wrbug.polymarketbot.service.common.BlockchainService -import com.wrbug.polymarketbot.service.common.PolymarketClobService -import com.wrbug.polymarketbot.util.CryptoUtils import com.wrbug.polymarketbot.util.RetrofitFactory import com.wrbug.polymarketbot.util.gt import com.wrbug.polymarketbot.util.multi @@ -29,7 +28,7 @@ import java.math.RoundingMode /** * 尾盘策略结算轮询服务 * 定时扫描「状态成功但未结算」的触发记录,通过 Gamma 获取 conditionId、链上查询结算结果,计算收益并回写。 - * 收益优先使用 CLOB API 订单详情的实际成交价(price)与成交量(size_matched)计算;API 失败时回退为触发时的 amountUsdc + 固定价 0.99。 + * 实际成交价与成交量使用 Data API 的 activity 接口获取(getUserActivity),比 CLOB getOrder 更准确;失败时回退为触发时的 amountUsdc + 固定价 0.99。 */ @Service class CryptoTailSettlementService( @@ -37,9 +36,7 @@ class CryptoTailSettlementService( private val strategyRepository: CryptoTailStrategyRepository, private val accountRepository: AccountRepository, private val retrofitFactory: RetrofitFactory, - private val blockchainService: BlockchainService, - private val clobService: PolymarketClobService, - private val cryptoUtils: CryptoUtils + private val blockchainService: BlockchainService ) { private val logger = LoggerFactory.getLogger(CryptoTailSettlementService::class.java) @@ -103,22 +100,22 @@ class CryptoTailSettlementService( /** * 处理单条触发记录:解析 conditionId -> 查链上结算 -> 若已结算则计算 pnl 并更新。 - * 通过 copy() 生成新实体再 save,不直接修改原实体;有订单信息时用实际成交价与投入金额更新 triggerPrice、amountUsdc。 + * 通过 copy() 生成新实体再 save,不直接修改原实体;实际成交价与投入金额从 Data API activity 获取并更新 triggerPrice、amountUsdc。 * @return true 表示本条已结算并更新 */ private suspend fun settleOne(trigger: CryptoTailStrategyTrigger): Boolean { if (trigger.resolved) return false val strategy = strategyRepository.findById(trigger.strategyId).orElse(null) ?: return false - val fill = fetchOrderFill(trigger, strategy) - val (newTriggerPrice, newAmountUsdc) = if (fill != null && fill.first.gt(BigDecimal.ZERO) && fill.second.gt(BigDecimal.ZERO)) { - val price = fill.first - val cost = price.multi(fill.second).setScale(pnlScale, RoundingMode.HALF_UP) - Pair(price, cost) + val conditionId = resolveConditionId(strategy, trigger) ?: return false + val fill = fetchActivityFill(trigger, strategy, conditionId) + val (newTriggerPrice, newAmountUsdc) = if (fill != null && fill.price.gt(BigDecimal.ZERO) && fill.size.gt(BigDecimal.ZERO)) { + val amountUsdc = fill.usdcSize?.takeIf { it.gt(BigDecimal.ZERO) } + ?: fill.price.multi(fill.size).setScale(pnlScale, RoundingMode.HALF_UP) + Pair(fill.price, amountUsdc) } else { Pair(trigger.triggerPrice, trigger.amountUsdc) } - val conditionId = resolveConditionId(strategy, trigger) ?: return false val (_, payouts) = blockchainService.getCondition(conditionId).getOrNull() ?: run { if (fill != null) { val updated = trigger.copy(triggerPrice = newTriggerPrice, amountUsdc = newAmountUsdc) @@ -137,7 +134,12 @@ class CryptoTailSettlementService( if (winnerIndex < 0) return false val won = trigger.outcomeIndex == winnerIndex - val pnl = computePnlFromApiOrFallback(trigger, strategy, won) + val pnl = if (fill != null && fill.price.gt(BigDecimal.ZERO) && fill.size.gt(BigDecimal.ZERO)) { + if (won) newAmountUsdc.let { fill.size.subtract(it).setScale(pnlScale, RoundingMode.HALF_UP) } + else newAmountUsdc.negate().setScale(pnlScale, RoundingMode.HALF_UP) + } else { + computePnlFallback(trigger.amountUsdc, won) + } val now = System.currentTimeMillis() val updated = trigger.copy( @@ -179,82 +181,73 @@ class CryptoTailSettlementService( } /** - * 优先用 CLOB API 订单详情的实际成交价与成交量计算收益;失败则用触发时的 amountUsdc + 固定价 0.99。 + * Activity 匹配到的一条 TRADE 的成交数据:价格、数量、实际投入 USDC(接口 usdcSize)。 */ - private suspend fun computePnlFromApiOrFallback( - trigger: CryptoTailStrategyTrigger, - strategy: CryptoTailStrategy, - won: Boolean - ): BigDecimal { - val fill = fetchOrderFill(trigger, strategy) - return if (fill != null) { - val (price, sizeMatched) = fill - if (price.gt(BigDecimal.ZERO) && sizeMatched.gt(BigDecimal.ZERO)) { - computePnlFromFill(price, sizeMatched, won) - } else { - computePnlFallback(trigger.amountUsdc, won) - } - } else { - computePnlFallback(trigger.amountUsdc, won) - } - } + private data class ActivityFill( + val price: BigDecimal, + val size: BigDecimal, + val usdcSize: BigDecimal? + ) /** - * 通过 CLOB API 获取订单实际成交价与成交量;需 L2 认证(账户 API 凭证)。 - * 只有此接口成功返回有效 price/sizeMatched 时,结算才会更新 triggerPrice、amountUsdc(表现); - * 否则只更新结算字段(resolved、realizedPnl 等),表现仍为触发时的值。 + * 通过 Data API activity 接口获取该触发对应的实际成交价、成交量与投入金额(比 CLOB getOrder 更准确)。 + * 只有此接口返回匹配的 TRADE 且 price/size 有效时,结算才会更新 triggerPrice、amountUsdc(表现);投入金额优先用 activity 的 usdcSize。 */ - private suspend fun fetchOrderFill( + private suspend fun fetchActivityFill( trigger: CryptoTailStrategyTrigger, - strategy: CryptoTailStrategy - ): Pair? { - val orderId = trigger.orderId?.takeIf { it.isNotBlank() } ?: run { - logger.debug("尾盘结算未拉取订单: orderId 为空, triggerId=${trigger.id}") - return null - } + strategy: CryptoTailStrategy, + conditionId: String + ): ActivityFill? { val account = accountRepository.findById(strategy.accountId).orElse(null) ?: run { - logger.warn("尾盘结算未拉取订单: 账户不存在, triggerId=${trigger.id}, accountId=${strategy.accountId}") + logger.warn("尾盘结算未拉取 activity: 账户不存在, triggerId=${trigger.id}, accountId=${strategy.accountId}") return null } - if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) { - logger.warn("尾盘结算未拉取订单: 账户未配置 API 凭证, triggerId=${trigger.id}, accountId=${account.id}") - return null - } - val apiSecret = try { - account.apiSecret?.let { cryptoUtils.decrypt(it) } ?: "" - } catch (e: Exception) { - logger.debug("解密 apiSecret 失败: accountId=${account.id}", e) - return null - } - val apiPassphrase = try { - account.apiPassphrase?.let { cryptoUtils.decrypt(it) } ?: "" - } catch (e: Exception) { - logger.debug("解密 apiPassphrase 失败: accountId=${account.id}", e) - return null - } - val result = clobService.getOrder( - orderId = orderId, - apiKey = account.apiKey!!, - apiSecret = apiSecret, - apiPassphrase = apiPassphrase, - walletAddress = account.walletAddress - ) - return result.fold( - onSuccess = { order -> - val price = order.price.toSafeBigDecimal() - val sizeMatched = order.sizeMatched.toSafeBigDecimal() - if (price.gt(BigDecimal.ZERO) && sizeMatched.gt(BigDecimal.ZERO)) { - Pair(price, sizeMatched) - } else { - logger.debug("尾盘结算订单无有效成交: triggerId=${trigger.id}, orderId=$orderId, price=$price, sizeMatched=$sizeMatched") - null - } - }, - onFailure = { e -> - logger.warn("尾盘结算拉取历史订单失败,触发价/投入金额不会更新: triggerId=${trigger.id}, orderId=$orderId, error=${e.message}") + val user = account.proxyAddress + val triggerTimeSeconds = trigger.createdAt / 1000 + val start = triggerTimeSeconds - 120 + val end = triggerTimeSeconds + 600 + return try { + val dataApi = retrofitFactory.createDataApi() + val response = dataApi.getUserActivity( + user = user, + type = listOf("TRADE"), + start = start, + end = end, + limit = 50, + sortBy = "TIMESTAMP", + sortDirection = "DESC" + ) + if (!response.isSuccessful || response.body() == null) { + logger.warn("尾盘结算拉取 activity 失败: triggerId=${trigger.id}, code=${response.code()}") + return null + } + val activities = response.body()!! + // 只匹配 TRADE:返回里可能混有 REDEEM(outcomeIndex=999、price=0)等,需排除 + val match = activities.firstOrNull { a -> + a.type == "TRADE" && + a.conditionId == conditionId && + a.outcomeIndex != null && a.outcomeIndex!! in 0..1 && + a.outcomeIndex == trigger.outcomeIndex && + a.side?.uppercase() == "BUY" && + a.price != null && a.price!! > 0 && + a.size != null && a.size!! > 0 + } ?: run { + logger.debug("尾盘结算 activity 无匹配成交: triggerId=${trigger.id}, conditionId=$conditionId, outcomeIndex=${trigger.outcomeIndex}, 条数=${activities.size}") + return null + } + val price = match.price!!.toSafeBigDecimal() + val size = match.size!!.toSafeBigDecimal() + val usdcSize = match.usdcSize?.toSafeBigDecimal()?.takeIf { it.gt(BigDecimal.ZERO) } + if (price.gt(BigDecimal.ZERO) && size.gt(BigDecimal.ZERO)) { + ActivityFill(price = price, size = size, usdcSize = usdcSize) + } else { + logger.debug("尾盘结算 activity 成交数据无效: triggerId=${trigger.id}, price=$price, size=$size") null } - ) + } catch (e: Exception) { + logger.warn("尾盘结算拉取 activity 异常,触发价/投入金额不会更新: triggerId=${trigger.id}, error=${e.message}") + null + } } /** diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyExecutionService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyExecutionService.kt index 38c295b..c83881c 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyExecutionService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyExecutionService.kt @@ -15,8 +15,6 @@ import com.wrbug.polymarketbot.service.copytrading.orders.OrderSigningService import com.wrbug.polymarketbot.util.CryptoUtils import com.wrbug.polymarketbot.util.RetrofitFactory import com.wrbug.polymarketbot.util.fromJson -import com.wrbug.polymarketbot.util.gt -import com.wrbug.polymarketbot.util.multi import com.wrbug.polymarketbot.util.toSafeBigDecimal import kotlinx.coroutines.delay import kotlinx.coroutines.sync.Mutex @@ -33,12 +31,6 @@ private const val TRIGGER_FIXED_PRICE = "0.99" /** 数量小数位数,与 OrderSigningService 的 roundConfig.size 一致 */ private const val SIZE_DECIMAL_SCALE = 2 -/** 下单成功后拉取订单成交数据的短暂延迟(毫秒),便于交易所更新订单状态 */ -private const val FETCH_ORDER_AFTER_PLACE_DELAY_MS = 800L - -/** 存库时投入金额/触发价小数精度,与结算服务一致 */ -private const val FILL_AMOUNT_SCALE = 8 - /** * 周期内预置上下文:账户、解密凭证、费率、签名类型、CLOB 客户端;FIXED 模式含预签订单。 * 触发时 RATIO 仅算 size 并签名提交,FIXED 直接提交预签订单。 @@ -258,10 +250,7 @@ class CryptoTailStrategyExecutionService( ctx.preSignedOrderByOutcome != null -> { val orderRequest = ctx.preSignedOrderByOutcome[outcomeIndex] if (orderRequest != null) { - submitOrderAndSaveRecord( - ctx.clobApi, strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, orderRequest, - ctx.account.apiKey, ctx.apiSecretDecrypted, ctx.apiPassphraseDecrypted, ctx.account.walletAddress - ) + submitOrderAndSaveRecord(ctx.clobApi, strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, orderRequest) return } } @@ -287,10 +276,7 @@ class CryptoTailStrategyExecutionService( orderType = "FAK", deferExec = false ) - submitOrderAndSaveRecord( - ctx.clobApi, strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, orderRequest, - ctx.account.apiKey, ctx.apiSecretDecrypted, ctx.apiPassphraseDecrypted, ctx.account.walletAddress - ) + submitOrderAndSaveRecord(ctx.clobApi, strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, orderRequest) return } } @@ -299,9 +285,6 @@ class CryptoTailStrategyExecutionService( placeOrderForTriggerSlowPath(strategy, periodStartUnix, marketTitle, tokenIds, outcomeIndex, triggerPrice) } - /** - * 下单并写触发记录。若传入账户 L2 凭证,下单成功后会拉取订单实际成交价与成交量,用真实触发价与投入金额写库,表现从首条记录起即正确。 - */ private suspend fun submitOrderAndSaveRecord( clobApi: PolymarketClobApi, strategy: CryptoTailStrategy, @@ -310,11 +293,7 @@ class CryptoTailStrategyExecutionService( outcomeIndex: Int, triggerPrice: BigDecimal, amountUsdc: BigDecimal, - orderRequest: NewOrderRequest, - apiKey: String? = null, - apiSecret: String? = null, - apiPassphrase: String? = null, - walletAddress: String? = null + orderRequest: NewOrderRequest ) { var lastError: String? = null for (attempt in 1..maxRetryAttempts) { @@ -323,17 +302,8 @@ class CryptoTailStrategyExecutionService( if (response.isSuccessful && response.body() != null) { val body = response.body()!! if (body.success && body.orderId != null) { - val (savePrice, saveAmount) = resolveFillPriceAndAmount( - orderId = body.orderId, - triggerPrice = triggerPrice, - amountUsdc = amountUsdc, - apiKey = apiKey, - apiSecret = apiSecret, - apiPassphrase = apiPassphrase, - walletAddress = walletAddress - ) - saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, savePrice, saveAmount, body.orderId, "success", null) - logger.info("尾盘策略下单成功: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, outcomeIndex=$outcomeIndex, orderId=${body.orderId}, triggerPrice=$savePrice") + saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, body.orderId, "success", null) + logger.info("尾盘策略下单成功: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, outcomeIndex=$outcomeIndex, orderId=${body.orderId}") return } lastError = body.errorMsg ?: "unknown" @@ -350,41 +320,6 @@ class CryptoTailStrategyExecutionService( logger.warn("尾盘策略下单失败(已重试${maxRetryAttempts}次): strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, reason=$lastError") } - /** - * 下单成功后拉取订单实际成交价与成交量;需 L2 凭证。失败或无效则返回传入的 triggerPrice、amountUsdc。 - */ - private suspend fun resolveFillPriceAndAmount( - orderId: String, - triggerPrice: BigDecimal, - amountUsdc: BigDecimal, - apiKey: String?, - apiSecret: String?, - apiPassphrase: String?, - walletAddress: String? - ): Pair { - if (apiKey.isNullOrBlank() || apiSecret.isNullOrBlank() || apiPassphrase.isNullOrBlank() || walletAddress.isNullOrBlank()) { - return Pair(triggerPrice, amountUsdc) - } - delay(FETCH_ORDER_AFTER_PLACE_DELAY_MS) - val result = clobService.getOrder( - orderId = orderId, - apiKey = apiKey!!, - apiSecret = apiSecret!!, - apiPassphrase = apiPassphrase!!, - walletAddress = walletAddress!! - ) - return result.getOrNull()?.let { order -> - val price = order.price.toSafeBigDecimal() - val sizeMatched = order.sizeMatched.toSafeBigDecimal() - if (price.gt(BigDecimal.ZERO) && sizeMatched.gt(BigDecimal.ZERO)) { - val cost = price.multi(sizeMatched).setScale(FILL_AMOUNT_SCALE, RoundingMode.HALF_UP) - Pair(price, cost) - } else { - Pair(triggerPrice, amountUsdc) - } - } ?: Pair(triggerPrice, amountUsdc) - } - /** 无预置上下文时的完整流程:固定价格 0.99,账户/解密/费率/签名在触发时执行 */ private suspend fun placeOrderForTriggerSlowPath( strategy: CryptoTailStrategy, @@ -458,10 +393,7 @@ class CryptoTailStrategyExecutionService( orderType = "FAK", deferExec = false ) - submitOrderAndSaveRecord( - clobApi, strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, orderRequest, - account.apiKey, apiSecret, apiPassphrase, account.walletAddress - ) + submitOrderAndSaveRecord(clobApi, strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, orderRequest) } private suspend fun fetchEventBySlug(slug: String): Result { diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyScheduler.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyScheduler.kt new file mode 100644 index 0000000..66080e0 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyScheduler.kt @@ -0,0 +1,37 @@ +package com.wrbug.polymarketbot.service.cryptotail + +import com.wrbug.polymarketbot.event.CryptoTailStrategyChangedEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.slf4j.LoggerFactory +import org.springframework.context.event.EventListener +import org.springframework.stereotype.Component + +/** + * 尾盘策略:策略创建/更新/启用后立即触发一轮检查(由 WebSocket 订单簿持续监听,此处仅做创建/更新后的一次补充)。 + */ +@Component +class CryptoTailStrategyScheduler( + private val executionService: CryptoTailStrategyExecutionService +) { + + private val logger = LoggerFactory.getLogger(CryptoTailStrategyScheduler::class.java) + + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + @EventListener + fun onStrategyChanged(event: CryptoTailStrategyChangedEvent) { + scope.launch { + try { + runBlocking { + executionService.runCycle() + } + } catch (e: Exception) { + logger.error("尾盘策略变更后立即执行异常: ${e.message}", e) + } + } + } +}