diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/config/MonitorServiceConfig.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/config/MonitorServiceConfig.kt new file mode 100644 index 0000000..9bc3047 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/config/MonitorServiceConfig.kt @@ -0,0 +1,23 @@ +package com.wrbug.polymarketbot.config + +import com.wrbug.polymarketbot.service.common.WebSocketSubscriptionService +import com.wrbug.polymarketbot.service.cryptotail.CryptoTailMonitorService +import jakarta.annotation.PostConstruct +import org.springframework.context.annotation.Configuration + +/** + * 加密价差策略监控服务配置 + * 处理 WebSocketSubscriptionService 和 CryptoTailMonitorService 之间的循环依赖 + */ +@Configuration +class MonitorServiceConfig( + private val webSocketSubscriptionService: WebSocketSubscriptionService, + private val cryptoTailMonitorService: CryptoTailMonitorService +) { + + @PostConstruct + fun init() { + // 在所有 Bean 初始化后设置引用 + webSocketSubscriptionService.setCryptoTailMonitorService(cryptoTailMonitorService) + } +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/cryptotail/CryptoTailStrategyController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/cryptotail/CryptoTailStrategyController.kt index 25217b9..40c30e9 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/cryptotail/CryptoTailStrategyController.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/cryptotail/CryptoTailStrategyController.kt @@ -11,9 +11,12 @@ import com.wrbug.polymarketbot.dto.CryptoTailStrategyTriggerListResponse import com.wrbug.polymarketbot.dto.CryptoTailStrategyUpdateRequest import com.wrbug.polymarketbot.dto.CryptoTailMarketOptionDto import com.wrbug.polymarketbot.dto.CryptoTailAutoMinSpreadResponse +import com.wrbug.polymarketbot.dto.CryptoTailMonitorInitRequest +import com.wrbug.polymarketbot.dto.CryptoTailMonitorInitResponse import com.wrbug.polymarketbot.enums.ErrorCode import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService import com.wrbug.polymarketbot.service.cryptotail.CryptoTailStrategyService +import com.wrbug.polymarketbot.service.cryptotail.CryptoTailMonitorService import org.slf4j.LoggerFactory import org.springframework.context.MessageSource import org.springframework.http.ResponseEntity @@ -26,6 +29,7 @@ import org.springframework.web.bind.annotation.RestController @RequestMapping("/api/crypto-tail-strategy") class CryptoTailStrategyController( private val cryptoTailStrategyService: CryptoTailStrategyService, + private val cryptoTailMonitorService: CryptoTailMonitorService, private val binanceKlineAutoSpreadService: BinanceKlineAutoSpreadService, private val messageSource: MessageSource ) { @@ -39,12 +43,12 @@ class CryptoTailStrategyController( result.fold( onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) }, onFailure = { e -> - logger.error("查询尾盘策略列表失败: ${e.message}", e) + logger.error("查询加密价差策略列表失败: ${e.message}", e) ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_LIST_FETCH_FAILED, e.message, messageSource)) } ) } catch (e: Exception) { - logger.error("查询尾盘策略列表异常: ${e.message}", e) + logger.error("查询加密价差策略列表异常: ${e.message}", e) ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_LIST_FETCH_FAILED, e.message, messageSource)) } } @@ -56,7 +60,7 @@ class CryptoTailStrategyController( result.fold( onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) }, onFailure = { e -> - logger.error("创建尾盘策略失败: ${e.message}", e) + logger.error("创建加密价差策略失败: ${e.message}", e) val code = when (e.message) { ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED @@ -68,7 +72,7 @@ class CryptoTailStrategyController( } ) } catch (e: Exception) { - logger.error("创建尾盘策略异常: ${e.message}", e) + logger.error("创建加密价差策略异常: ${e.message}", e) ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_CREATE_FAILED, e.message, messageSource)) } } @@ -83,7 +87,7 @@ class CryptoTailStrategyController( result.fold( onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) }, onFailure = { e -> - logger.error("更新尾盘策略失败: ${e.message}", e) + logger.error("更新加密价差策略失败: ${e.message}", e) val code = when (e.message) { ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID @@ -95,7 +99,7 @@ class CryptoTailStrategyController( } ) } catch (e: Exception) { - logger.error("更新尾盘策略异常: ${e.message}", e) + logger.error("更新加密价差策略异常: ${e.message}", e) ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_UPDATE_FAILED, e.message, messageSource)) } } @@ -111,12 +115,12 @@ class CryptoTailStrategyController( result.fold( onSuccess = { ResponseEntity.ok(ApiResponse.success(Unit)) }, onFailure = { e -> - logger.error("删除尾盘策略失败: ${e.message}", e) + logger.error("删除加密价差策略失败: ${e.message}", e) ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_DELETE_FAILED, e.message, messageSource)) } ) } catch (e: Exception) { - logger.error("删除尾盘策略异常: ${e.message}", e) + logger.error("删除加密价差策略异常: ${e.message}", e) ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_DELETE_FAILED, e.message, messageSource)) } } @@ -173,7 +177,7 @@ class CryptoTailStrategyController( return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, messageSource = messageSource)) } val periodStartUnix = (request["periodStartUnix"] as? Number)?.toLong() - ?: (System.currentTimeMillis() / 1000 / intervalSeconds) * intervalSeconds + ?: ((System.currentTimeMillis() / 1000 / intervalSeconds) * intervalSeconds) // 默认使用 BTC 市场(向后兼容) val marketSlugPrefix = (request["marketSlugPrefix"] as? String) ?: "btc-updown" val pair = binanceKlineAutoSpreadService.computeAndCache(marketSlugPrefix, intervalSeconds, periodStartUnix) @@ -188,4 +192,28 @@ class CryptoTailStrategyController( ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource)) } } + + /** + * 初始化加密价差策略监控 + * 返回策略信息、开盘价、tokenIds等初始化数据 + */ + @PostMapping("/monitor/init") + fun initMonitor(@RequestBody request: CryptoTailMonitorInitRequest): ResponseEntity> { + return try { + if (request.strategyId <= 0) { + return ResponseEntity.ok(ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource)) + } + val result = cryptoTailMonitorService.initMonitor(request) + result.fold( + onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) }, + onFailure = { e -> + logger.error("初始化加密价差策略监控失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource)) + } + ) + } catch (e: Exception) { + logger.error("初始化加密价差策略监控异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource)) + } + } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailMonitorDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailMonitorDto.kt new file mode 100644 index 0000000..3761a2e --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailMonitorDto.kt @@ -0,0 +1,105 @@ +package com.wrbug.polymarketbot.dto + +/** + * 加密价差策略监控初始化请求 + */ +data class CryptoTailMonitorInitRequest( + /** 策略ID */ + val strategyId: Long = 0L +) + +/** + * 加密价差策略监控初始化响应 + */ +data class CryptoTailMonitorInitResponse( + /** 策略ID */ + val strategyId: Long = 0L, + /** 策略名称 */ + val name: String = "", + /** 账户ID */ + val accountId: Long = 0L, + /** 账户名称 */ + val accountName: String = "", + /** 市场 slug 前缀 */ + val marketSlugPrefix: String = "", + /** 市场标题 */ + val marketTitle: String = "", + /** 周期秒数 (300=5m, 900=15m) */ + val intervalSeconds: Int = 300, + /** 当前周期开始时间 (Unix 秒) */ + val periodStartUnix: Long = 0L, + /** 时间窗口开始秒数 */ + val windowStartSeconds: Int = 0, + /** 时间窗口结束秒数 */ + val windowEndSeconds: Int = 0, + /** 最低价格 */ + val minPrice: String = "0", + /** 最高价格 */ + val maxPrice: String = "1", + /** 最小价差模式: NONE, FIXED, AUTO */ + val minSpreadMode: String = "NONE", + /** 价差方向: MIN(显示周期内最小价差), MAX(显示周期内最大价差) */ + val spreadDirection: String = "MIN", + /** 最小价差数值 (FIXED 时有值) */ + val minSpreadValue: String? = null, + /** 自动计算的最小价差 (Up方向) */ + val autoMinSpreadUp: String? = null, + /** 自动计算的最小价差 (Down方向) */ + val autoMinSpreadDown: String? = null, + /** BTC 开盘价 USDC(来自币安 K 线 open) */ + val openPriceBtc: String? = null, + /** Up tokenId */ + val tokenIdUp: String? = null, + /** Down tokenId */ + val tokenIdDown: String? = null, + /** 当前时间 (毫秒时间戳) */ + val currentTimestamp: Long = System.currentTimeMillis(), + /** 是否启用 */ + val enabled: Boolean = true +) + +/** + * 加密价差策略监控实时推送数据 + */ +data class CryptoTailMonitorPushData( + /** 策略ID */ + val strategyId: Long = 0L, + /** 推送时间 (毫秒时间戳) */ + val timestamp: Long = System.currentTimeMillis(), + /** 当前周期开始时间 (Unix 秒) */ + val periodStartUnix: Long = 0L, + /** 当前周期市场标题(周期切换时更新) */ + val marketTitle: String = "", + /** 当前价格 (Up方向,来自订单簿) */ + val currentPriceUp: String? = null, + /** 当前价格 (Down方向,来自订单簿) */ + val currentPriceDown: String? = null, + /** 当前价差 (Up方向: 1 - currentPriceUp) */ + val spreadUp: String? = null, + /** 当前价差 (Down方向: currentPriceUp) */ + val spreadDown: String? = null, + /** 最小价差线 (Up方向) */ + val minSpreadLineUp: String? = null, + /** 最小价差线 (Down方向,USDC 价差) */ + val minSpreadLineDown: String? = null, + /** BTC 开盘价 USDC(币安 K 线 open) */ + val openPriceBtc: String? = null, + /** BTC 最新价 USDC(币安 K 线 close,当前周期实时) */ + val currentPriceBtc: String? = null, + /** BTC 价差 USDC(currentPriceBtc - openPriceBtc) */ + val spreadBtc: String? = null, + /** 周期剩余秒数 */ + val remainingSeconds: Int = 0, + /** 是否在时间窗口内 */ + val inTimeWindow: Boolean = false, + /** 是否在价格区间内 (Up方向) */ + val inPriceRangeUp: Boolean = false, + /** 是否在价格区间内 (Down方向) */ + val inPriceRangeDown: Boolean = false, + /** 是否已触发 */ + val triggered: Boolean = false, + /** 触发方向: UP, DOWN, null */ + val triggerDirection: String? = null, + /** 周期是否已结束 */ + val periodEnded: Boolean = false +) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailStrategyDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailStrategyDto.kt index c1e6bf4..1ed4c24 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailStrategyDto.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailStrategyDto.kt @@ -1,7 +1,7 @@ package com.wrbug.polymarketbot.dto /** - * 尾盘策略创建请求 + * 加密价差策略创建请求 * 金额与价格使用 String,后端转为 BigDecimal */ data class CryptoTailStrategyCreateRequest( @@ -25,7 +25,7 @@ data class CryptoTailStrategyCreateRequest( ) /** - * 尾盘策略更新请求 + * 加密价差策略更新请求 */ data class CryptoTailStrategyUpdateRequest( val strategyId: Long = 0L, @@ -46,7 +46,7 @@ data class CryptoTailStrategyUpdateRequest( ) /** - * 尾盘策略列表请求 + * 加密价差策略列表请求 */ data class CryptoTailStrategyListRequest( val accountId: Long? = null, @@ -54,7 +54,7 @@ data class CryptoTailStrategyListRequest( ) /** - * 尾盘策略 DTO(列表与详情) + * 加密价差策略 DTO(列表与详情) */ data class CryptoTailStrategyDto( val id: Long = 0L, @@ -90,14 +90,14 @@ data class CryptoTailStrategyDto( ) /** - * 尾盘策略列表响应 + * 加密价差策略列表响应 */ data class CryptoTailStrategyListResponse( val list: List = emptyList() ) /** - * 尾盘策略删除请求 + * 加密价差策略删除请求 */ data class CryptoTailStrategyDeleteRequest( val strategyId: Long = 0L diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CryptoTailStrategy.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CryptoTailStrategy.kt index 6a200a0..780987d 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CryptoTailStrategy.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CryptoTailStrategy.kt @@ -8,7 +8,7 @@ import jakarta.persistence.* import java.math.BigDecimal /** - * 加密市场尾盘策略实体 + * 加密价差策略实体 * 5/15 分钟 Up or Down 市场,在周期内时间窗口、价格进入区间时市价买入 */ @Entity diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CryptoTailStrategyTrigger.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CryptoTailStrategyTrigger.kt index 9444770..e8020df 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CryptoTailStrategyTrigger.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CryptoTailStrategyTrigger.kt @@ -5,7 +5,7 @@ import java.math.BigDecimal import com.wrbug.polymarketbot.util.toSafeBigDecimal /** - * 尾盘策略触发记录 + * 加密价差策略触发记录 */ @Entity @Table(name = "crypto_tail_strategy_trigger") diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/ErrorCode.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/ErrorCode.kt index 5d1c80f..b4f6dcd 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/ErrorCode.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/ErrorCode.kt @@ -158,8 +158,8 @@ enum class ErrorCode( ACCOUNT_BALANCE_FETCH_FAILED(4707, "查询账户余额失败", "error.account_balance_fetch_failed"), ACCOUNT_POSITIONS_FETCH_FAILED(4708, "查询仓位列表失败", "error.account_positions_fetch_failed"), - // 尾盘策略 (4710-4729) - CRYPTO_TAIL_STRATEGY_NOT_FOUND(4710, "尾盘策略不存在", "error.crypto_tail_strategy_not_found"), + // 加密价差策略 (4710-4729) + CRYPTO_TAIL_STRATEGY_NOT_FOUND(4710, "加密价差策略不存在", "error.crypto_tail_strategy_not_found"), CRYPTO_TAIL_STRATEGY_WINDOW_INVALID(4711, "时间区间开始不能大于结束", "error.crypto_tail_strategy_window_invalid"), CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED(4712, "时间区间不能超过周期长度", "error.crypto_tail_strategy_window_exceed"), CRYPTO_TAIL_STRATEGY_INTERVAL_INVALID(4713, "周期仅支持 300 或 900 秒", "error.crypto_tail_strategy_interval_invalid"), @@ -259,11 +259,11 @@ enum class ErrorCode( SERVER_BACKTEST_RETRY_FAILED(5612, "重试回测任务失败", "error.server.backtest_retry_failed"), SERVER_BACKTEST_RERUN_FAILED(5613, "按配置重新测试失败", "error.server.backtest_rerun_failed"), - // 尾盘策略服务 (5620-5629) - SERVER_CRYPTO_TAIL_STRATEGY_CREATE_FAILED(5620, "创建尾盘策略失败", "error.server.crypto_tail_strategy_create_failed"), - SERVER_CRYPTO_TAIL_STRATEGY_UPDATE_FAILED(5621, "更新尾盘策略失败", "error.server.crypto_tail_strategy_update_failed"), - SERVER_CRYPTO_TAIL_STRATEGY_DELETE_FAILED(5622, "删除尾盘策略失败", "error.server.crypto_tail_strategy_delete_failed"), - SERVER_CRYPTO_TAIL_STRATEGY_LIST_FETCH_FAILED(5623, "查询尾盘策略列表失败", "error.server.crypto_tail_strategy_list_fetch_failed"), + // 加密价差策略服务 (5620-5629) + SERVER_CRYPTO_TAIL_STRATEGY_CREATE_FAILED(5620, "创建加密价差策略失败", "error.server.crypto_tail_strategy_create_failed"), + SERVER_CRYPTO_TAIL_STRATEGY_UPDATE_FAILED(5621, "更新加密价差策略失败", "error.server.crypto_tail_strategy_update_failed"), + SERVER_CRYPTO_TAIL_STRATEGY_DELETE_FAILED(5622, "删除加密价差策略失败", "error.server.crypto_tail_strategy_delete_failed"), + SERVER_CRYPTO_TAIL_STRATEGY_LIST_FETCH_FAILED(5623, "查询加密价差策略列表失败", "error.server.crypto_tail_strategy_list_fetch_failed"), SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED(5624, "查询触发记录失败", "error.server.crypto_tail_strategy_triggers_fetch_failed"); companion object { diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/event/CryptoTailStrategyChangedEvent.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/event/CryptoTailStrategyChangedEvent.kt index 9496905..1e8f3f7 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/event/CryptoTailStrategyChangedEvent.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/event/CryptoTailStrategyChangedEvent.kt @@ -3,6 +3,6 @@ package com.wrbug.polymarketbot.event import org.springframework.context.ApplicationEvent /** - * 尾盘策略创建/更新/启用状态变更后发布,用于立即触发一轮执行检查。 + * 加密价差策略创建/更新/启用状态变更后发布,用于立即触发一轮执行检查。 */ class CryptoTailStrategyChangedEvent(source: Any) : ApplicationEvent(source) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CryptoTailStrategyTriggerRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CryptoTailStrategyTriggerRepository.kt index 8419eb6..b248ff7 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CryptoTailStrategyTriggerRepository.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CryptoTailStrategyTriggerRepository.kt @@ -23,7 +23,7 @@ interface CryptoTailStrategyTriggerRepository : JpaRepository - /** 根据订单 ID 查询尾盘触发记录 */ + /** 根据订单 ID 查询加密价差策略触发记录 */ fun findByOrderId(orderId: String): CryptoTailStrategyTrigger? /** 轮询发 TG:status=success、orderId 非空、未发过通知,按创建时间正序 */ diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt index f2d3219..d0c8cb5 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt @@ -125,8 +125,8 @@ class AccountService( // 7. 加密敏感信息 val encryptedPrivateKey = cryptoUtils.encrypt(request.privateKey) - val encryptedApiSecret = apiKeyCreds.secret?.let { cryptoUtils.encrypt(it) } - val encryptedApiPassphrase = apiKeyCreds.passphrase?.let { cryptoUtils.encrypt(it) } + val encryptedApiSecret = apiKeyCreds.secret.let { cryptoUtils.encrypt(it) } + val encryptedApiPassphrase = apiKeyCreds.passphrase.let { cryptoUtils.encrypt(it) } // 8. 生成账户名称(如果未提供,使用 SAFE/MAGIC-代理地址后4位) val accountName = if (request.accountName.isNullOrBlank()) { @@ -518,8 +518,8 @@ class AccountService( } val creds = result.getOrNull() ?: return Result.failure(IllegalStateException("API Key 返回为空")) - val encryptedSecret = creds.secret?.let { cryptoUtils.encrypt(it) } - val encryptedPassphrase = creds.passphrase?.let { cryptoUtils.encrypt(it) } + val encryptedSecret = creds.secret.let { cryptoUtils.encrypt(it) } + val encryptedPassphrase = creds.passphrase.let { cryptoUtils.encrypt(it) } val updated = account.copy( apiKey = creds.apiKey, apiSecret = encryptedSecret, @@ -1128,7 +1128,7 @@ class AccountService( // 3. 验证仓位是否存在并获取原始数量 val positionsResult = getAllPositions() - val (position, originalQuantity) = positionsResult.fold( + val (_, originalQuantity) = positionsResult.fold( onSuccess = { positionListResponse -> val position = positionListResponse.currentPositions.find { it.accountId == request.accountId && @@ -1161,7 +1161,7 @@ class AccountService( onFailure = { e -> return Result.failure(Exception("查询仓位失败: ${e.message}")) } - ) ?: return Result.failure(IllegalArgumentException("仓位不存在")) + ) // 4. 计算实际卖出数量 val sellQuantity = if (percentDecimal != null) { @@ -1280,7 +1280,7 @@ class AccountService( val newOrderRequest = com.wrbug.polymarketbot.api.NewOrderRequest( order = signedOrder, - owner = account.apiKey!!, // API Key + owner = account.apiKey, // API Key orderType = orderType, deferExec = false ) @@ -1300,7 +1300,7 @@ class AccountService( } val clobApi = retrofitFactory.createClobApi( - account.apiKey!!, + account.apiKey, apiSecret, apiPassphrase, account.walletAddress diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/PositionCheckService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/PositionCheckService.kt index 6217462..d64fd39 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/PositionCheckService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/PositionCheckService.kt @@ -396,7 +396,7 @@ class PositionCheckService( val positionsByAccount = redeemablePositions.groupBy { it.accountId } for ((accountId, positions) in positionsByAccount) { - // 查找该账户下所有启用的跟单配置(仅用于赎回成功后更新跟单订单状态;无跟单配置的账户如尾盘策略账户也会执行赎回) + // 查找该账户下所有启用的跟单配置(仅用于赎回成功后更新跟单订单状态;无跟单配置的账户如加密价差策略账户也会执行赎回) val copyTradings = copyTradingRepository.findByAccountId(accountId) .filter { it.enabled } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/binance/BinanceKlineAutoSpreadService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/binance/BinanceKlineAutoSpreadService.kt index dd80681..b24e9ac 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/binance/BinanceKlineAutoSpreadService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/binance/BinanceKlineAutoSpreadService.kt @@ -94,7 +94,7 @@ class BinanceKlineAutoSpreadService( val baseDown = averageAfterIqr(spreadsDown).setScale(8, RoundingMode.HALF_UP) cache[cacheKey(marketSlugPrefix, intervalSeconds, periodStartUnix)] = baseUp to baseDown logger.info( - "尾盘自动价差已计算并缓存(100%基准): market=$marketSlugPrefix symbol=$symbol interval=${intervalSeconds}s periodStartUnix=$periodStartUnix | " + + "加密价差策略自动价差已计算并缓存(100%基准): market=$marketSlugPrefix symbol=$symbol interval=${intervalSeconds}s periodStartUnix=$periodStartUnix | " + "Up方向: 样本数=${spreadsUp.size}, baseSpreadUp=${baseUp.toPlainString()} | " + "Down方向: 样本数=${spreadsDown.size}, baseSpreadDown=${baseDown.toPlainString()}" ) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/binance/BinanceKlineService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/binance/BinanceKlineService.kt index a469948..c47fe53 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/binance/BinanceKlineService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/binance/BinanceKlineService.kt @@ -19,7 +19,7 @@ import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicReference /** - * 币安 K 线 WebSocket:按需订阅尾盘策略使用的币种 5m/15m,维护当前周期 (open, close),供价差校验使用。 + * 币安 K 线 WebSocket:按需订阅加密价差策略使用的币种 5m/15m,维护当前周期 (open, close),供价差校验使用。 * 仅当存在启用策略且策略使用到某市场时才订阅对应币种,无策略时不建立连接。 */ @Service @@ -29,7 +29,9 @@ class BinanceKlineService { private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) private val wsBase = "wss://stream.binance.com:9443" - private val client = createClient().build() + private val client by lazy { + createClient().build() + } /** (marketSlugPrefix, intervalSeconds, periodStartUnix) -> (open, close) */ private val openCloseByPeriod = ConcurrentHashMap>() @@ -44,7 +46,7 @@ class BinanceKlineService { /** 已连接的 WebSocket: wsKey (symbol-interval) -> WebSocket */ private val connectedWebSockets = ConcurrentHashMap() - /** 当前需要订阅的完整市场集合(如 btc-updown-5m、btc-updown-15m),由尾盘策略刷新时更新 */ + /** 当前需要订阅的完整市场集合(如 btc-updown-5m、btc-updown-15m),由加密价差策略刷新时更新 */ private val requiredMarketPrefixes = AtomicReference>(emptySet()) private val subscriptionLock = Any() private var reconnectJob: Job? = null @@ -82,6 +84,7 @@ class BinanceKlineService { */ fun updateSubscriptions(marketPrefixes: Set) { val normalized = marketPrefixes.map { it.lowercase() }.toSet() + val parsed = normalized.mapNotNull { full -> parseMarketSlug(full)?.let { (base, interval) -> getSymbol(base)?.let { symbol -> Triple(full, symbol, interval) } @@ -123,14 +126,14 @@ class BinanceKlineService { else -> 300 } val request = Request.Builder().url(url).build() - val ws = client.newWebSocket(request, object : WebSocketListener() { + client.newWebSocket(request, object : WebSocketListener() { override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) { connectedWebSockets[wsKey] = webSocket logger.info("币安 K 线 WS 已连接: $streamName") } override fun onMessage(webSocket: WebSocket, text: String) { - parseKlineMessage(text, intervalSeconds)?.let { (tMs, o, c) -> + parseKlineMessage(text)?.let { (tMs, o, c) -> onKline(marketPrefix, intervalSeconds, tMs, o, c) } } @@ -152,7 +155,7 @@ class BinanceKlineService { }) } - private fun parseKlineMessage(text: String, intervalSeconds: Int): Triple? { + private fun parseKlineMessage(text: String): Triple? { return try { val json = com.google.gson.JsonParser.parseString(text).asJsonObject if (json.get("e")?.asString != "kline") return null @@ -176,6 +179,8 @@ class BinanceKlineService { connectedWebSockets.values.forEach { it.close(1000, "reconnect") } connectedWebSockets.clear() logger.info("币安 K 线 WS 尝试重连") + // 清空 requiredMarketPrefixes,否则 updateSubscriptions(current) 内会因 normalized == requiredMarketPrefixes.get() 直接 return,不会重新 connectStream + requiredMarketPrefixes.set(emptySet()) updateSubscriptions(current) } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/WebSocketSubscriptionService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/WebSocketSubscriptionService.kt index 187a91b..69a6180 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/WebSocketSubscriptionService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/WebSocketSubscriptionService.kt @@ -1,11 +1,13 @@ package com.wrbug.polymarketbot.service.common +import com.wrbug.polymarketbot.dto.CryptoTailMonitorPushData import com.wrbug.polymarketbot.dto.OrderPushMessage import com.wrbug.polymarketbot.dto.PositionPushMessage import com.wrbug.polymarketbot.dto.WebSocketMessage as WsMessage import com.wrbug.polymarketbot.dto.WebSocketMessageType import com.wrbug.polymarketbot.service.accounts.PositionPushService import com.wrbug.polymarketbot.service.copytrading.orders.OrderPushService +import com.wrbug.polymarketbot.service.cryptotail.CryptoTailMonitorService import kotlinx.coroutines.* import org.slf4j.LoggerFactory import org.springframework.stereotype.Service @@ -38,28 +40,47 @@ class WebSocketSubscriptionService( // 存储 order 频道的订阅回调:sessionId -> callback(用于取消订阅) private val orderChannelCallbacks = ConcurrentHashMap Unit>() + // 存储加密价差策略监控频道的订阅回调:sessionId -> (strategyId -> callback) + private val monitorChannelCallbacks = ConcurrentHashMap Unit>>() + + // 加密价差策略监控服务(延迟注入,避免循环依赖) + private var cryptoTailMonitorService: CryptoTailMonitorService? = null + + /** + * 设置加密价差策略监控服务(由 Spring 在初始化后调用) + */ + fun setCryptoTailMonitorService(service: CryptoTailMonitorService) { + cryptoTailMonitorService = service + } + /** * 注册会话 */ fun registerSession(sessionId: String, callback: (WsMessage) -> Unit) { sessionCallbacks[sessionId] = callback sessionSubscriptions[sessionId] = mutableSetOf() + monitorChannelCallbacks[sessionId] = mutableMapOf() } /** * 注销会话 */ fun unregisterSession(sessionId: String) { - // 取消所有订阅 val channels = sessionSubscriptions.remove(sessionId) ?: emptySet() channels.forEach { channel -> unsubscribe(sessionId, channel) } - + // 清理 order 频道的回调 orderChannelCallbacks.remove(sessionId) - + + // 清理加密价差策略监控频道的回调 + val monitorCallbacks = monitorChannelCallbacks.remove(sessionId) + monitorCallbacks?.keys?.forEach { strategyId -> + cryptoTailMonitorService?.unsubscribe(sessionId, strategyId) + } + sessionCallbacks.remove(sessionId) } @@ -83,8 +104,8 @@ class WebSocketSubscriptionService( sendSubscribeAck(sessionId, channel, true) // 根据频道类型启动推送服务 - when (channel) { - "position" -> { + when { + channel == "position" -> { positionPushService.subscribe(sessionId) { message -> pushData(sessionId, channel, message) } @@ -97,7 +118,7 @@ class WebSocketSubscriptionService( } } } - "order" -> { + channel == "order" -> { // 订单推送:自动订阅所有启用的账户 val callback: (OrderPushMessage) -> Unit = { message -> pushData(sessionId, channel, message) @@ -105,6 +126,20 @@ class WebSocketSubscriptionService( orderChannelCallbacks[sessionId] = callback orderPushService.subscribeAllEnabled(callback) } + channel.startsWith("crypto_tail_monitor_") -> { + // 加密价差策略监控频道 + val strategyId = channel.removePrefix("crypto_tail_monitor_").toLongOrNull() + if (strategyId != null && cryptoTailMonitorService != null) { + val callback: (CryptoTailMonitorPushData) -> Unit = { message -> + pushData(sessionId, channel, message) + } + monitorChannelCallbacks.getOrPut(sessionId) { mutableMapOf() }[strategyId] = callback + cryptoTailMonitorService!!.subscribe(sessionId, strategyId, callback) + } else { + logger.warn("无效的加密价差策略监控频道或服务未初始化: $channel") + sendSubscribeAck(sessionId, channel, false, "无效的策略ID") + } + } else -> { logger.warn("未知的频道: $channel") sendSubscribeAck(sessionId, channel, false, "未知的频道") @@ -122,15 +157,58 @@ class WebSocketSubscriptionService( channelSubscriptions[channel]?.remove(sessionId) // 取消推送服务的订阅(推送服务内部会处理是否停止轮询) - when (channel) { - "position" -> positionPushService.unsubscribe(sessionId) - "order" -> { + when { + channel == "position" -> positionPushService.unsubscribe(sessionId) + channel == "order" -> { // 取消订阅所有账户的订单推送 val callback = orderChannelCallbacks.remove(sessionId) if (callback != null) { orderPushService.unsubscribeAll(callback) } } + channel.startsWith("crypto_tail_monitor_") -> { + // 取消加密价差策略监控订阅 + val strategyId = channel.removePrefix("crypto_tail_monitor_").toLongOrNull() + if (strategyId != null) { + monitorChannelCallbacks[sessionId]?.remove(strategyId) + cryptoTailMonitorService?.unsubscribe(sessionId, strategyId) + } + } + } + } + + /** + * 注册加密价差策略监控回调(由 CryptoTailMonitorService 调用) + */ + fun registerMonitorCallback(sessionId: String, strategyId: Long, callback: (CryptoTailMonitorPushData) -> Unit) { + monitorChannelCallbacks.getOrPut(sessionId) { mutableMapOf() }[strategyId] = callback + } + + /** + * 注销加密价差策略监控回调(由 CryptoTailMonitorService 调用) + */ + fun unregisterMonitorCallback(sessionId: String, strategyId: Long) { + monitorChannelCallbacks[sessionId]?.remove(strategyId) + } + + /** + * 推送加密价差策略监控数据(由 CryptoTailMonitorService 调用) + */ + fun pushMonitorData(strategyId: Long, data: CryptoTailMonitorPushData) { + val channel = "crypto_tail_monitor_$strategyId" + val sessionIds = channelSubscriptions[channel] ?: return + + for (sessionId in sessionIds) { + val callback = sessionCallbacks[sessionId] + if (callback != null) { + val message = WsMessage( + type = WebSocketMessageType.DATA.value, + channel = channel, + payload = data, + timestamp = System.currentTimeMillis() + ) + callback(message) + } } } @@ -168,4 +246,3 @@ class WebSocketSubscriptionService( } } } - diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailMonitorService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailMonitorService.kt new file mode 100644 index 0000000..d713be1 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailMonitorService.kt @@ -0,0 +1,840 @@ +package com.wrbug.polymarketbot.service.cryptotail + +import com.wrbug.polymarketbot.api.GammaEventBySlugResponse +import com.wrbug.polymarketbot.constants.PolymarketConstants +import com.wrbug.polymarketbot.dto.CryptoTailMonitorInitRequest +import com.wrbug.polymarketbot.dto.CryptoTailMonitorInitResponse +import com.wrbug.polymarketbot.dto.CryptoTailMonitorPushData +import com.wrbug.polymarketbot.entity.CryptoTailStrategy +import com.wrbug.polymarketbot.repository.AccountRepository +import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository +import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService +import com.wrbug.polymarketbot.service.binance.BinanceKlineService +import com.wrbug.polymarketbot.service.common.WebSocketSubscriptionService +import com.wrbug.polymarketbot.util.RetrofitFactory +import com.wrbug.polymarketbot.util.createClient +import com.wrbug.polymarketbot.util.fromJson +import com.wrbug.polymarketbot.util.toJson +import com.wrbug.polymarketbot.util.toSafeBigDecimal +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.sync.Mutex +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import org.slf4j.LoggerFactory +import org.springframework.context.event.EventListener +import org.springframework.stereotype.Service +import jakarta.annotation.PostConstruct +import jakarta.annotation.PreDestroy +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import java.math.BigDecimal +import java.math.RoundingMode +import java.util.Collections +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference + +/** + * 加密价差策略监控服务 + * 负责实时推送监控数据到前端 + */ +@Service +class CryptoTailMonitorService( + private val strategyRepository: CryptoTailStrategyRepository, + private val accountRepository: AccountRepository, + private val retrofitFactory: RetrofitFactory, + private val binanceKlineService: BinanceKlineService, + private val binanceKlineAutoSpreadService: BinanceKlineAutoSpreadService, + private val webSocketSubscriptionService: WebSocketSubscriptionService +) { + + private val logger = LoggerFactory.getLogger(CryptoTailMonitorService::class.java) + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + /** 当前周期 token 映射 */ + private val currentPeriodTokenToStrategy = AtomicReference>>(emptyMap()) + + /** 下一周期 token 映射 */ + private val nextPeriodTokenToStrategy = AtomicReference>>(emptyMap()) + + /** strategyId -> 当前价格数据 */ + private val strategyPriceData = ConcurrentHashMap() + + /** strategyId -> 订阅者数量 */ + private val strategySubscribers = ConcurrentHashMap() + + private var currentPeriodWebSocket: WebSocket? = null + private var nextPeriodWebSocket: WebSocket? = null + private val wsUrl = PolymarketConstants.RTDS_WS_URL + "/ws/market" + + private val client by lazy { + createClient().build() + } + + private val reconnectDelayMs = 3_000L + private var reconnectJob: Job? = null + private val closedForNoSubscribers = AtomicBoolean(false) + private val connectLock = Any() + + /** 防止 refreshSubscription 并发执行(周期结束时定时器与消息可能同时触发) */ + private val refreshSubscriptionMutex = Mutex() + + /** 周期结束倒计时 Job */ + private var periodEndCountdownJob: Job? = null + + /** 定时推送 Job(每 1.5 秒推送一次,保证 BTC 价格和分时图持续更新) */ + private var periodicPushJob: Job? = null + private val pushIntervalMs = 1_500L + + /** 策略推送历史(用于中途进入时补全分时图,最多保留 300 条) */ + private val strategyPushHistory = ConcurrentHashMap>() + private val strategyHistoryPeriod = ConcurrentHashMap() + private val maxHistorySize = 300 + + /** price_change 推送节流:每策略最近一次推送时间,1s 内不重复推送 */ + private val lastPriceChangePushTime = ConcurrentHashMap() + private val priceChangePushThrottleMs = 1_000L + + /** 当前周期/下一周期构建时缓存的市场标题,key = "strategyId-periodStartUnix",供推送携带 */ + private val marketTitleByStrategyPeriod = ConcurrentHashMap() + + data class MonitorEntry( + val strategyId: Long, + val strategy: CryptoTailStrategy, + val periodStartUnix: Long, + val outcomeIndex: Int, + val tokenId: String, + /** 是否为下一个周期(用于预先订阅) */ + val isNextPeriod: Boolean = false + ) + + data class StrategyPriceData( + val currentPriceUp: BigDecimal? = null, + val currentPriceDown: BigDecimal? = null, + /** BTC 开盘价 USDC(币安 K 线 open) */ + val openPriceBtc: BigDecimal? = null, + val spreadUp: BigDecimal? = null, + val spreadDown: BigDecimal? = null, + val minSpreadLineUp: BigDecimal? = null, + val minSpreadLineDown: BigDecimal? = null, + val triggered: Boolean = false, + val triggerDirection: String? = null, + val lastUpdateTime: Long = System.currentTimeMillis(), + /** 当前周期开始时间(用于双连接周期切换) */ + val periodStartUnix: Long? = null + ) + + @PostConstruct + fun init() { + // 服务启动时不主动连接,等待前端订阅 + } + + /** + * 初始化监控数据 + */ + fun initMonitor(request: CryptoTailMonitorInitRequest): Result { + return try { + val strategy = strategyRepository.findById(request.strategyId).orElse(null) + if (strategy == null) { + return Result.failure(IllegalArgumentException("策略不存在")) + } + + val account = accountRepository.findById(strategy.accountId).orElse(null) + val nowSeconds = System.currentTimeMillis() / 1000 + val periodStartUnix = (nowSeconds / strategy.intervalSeconds) * strategy.intervalSeconds + + // 获取市场信息 + val slug = "${strategy.marketSlugPrefix}-$periodStartUnix" + val event = fetchEventBySlug(slug).getOrNull() + val market = event?.markets?.firstOrNull() + val tokenIds = parseClobTokenIds(market?.clobTokenIds) + + // 获取开盘价(币安 K 线 open = BTC 价格 USDC) + val openClose = binanceKlineService.getCurrentOpenClose( + strategy.marketSlugPrefix, + strategy.intervalSeconds, + periodStartUnix + ) + val openPriceBtc = openClose?.first + + // 获取自动计算的最小价差 + var autoMinSpreadUp: BigDecimal? = null + var autoMinSpreadDown: BigDecimal? = null + if (strategy.spreadMode.name.uppercase() == "AUTO") { + val autoSpreads = binanceKlineAutoSpreadService.computeAndCache( + strategy.marketSlugPrefix, + strategy.intervalSeconds, + periodStartUnix + ) + autoMinSpreadUp = autoSpreads?.first + autoMinSpreadDown = autoSpreads?.second + } + + // 保存价格数据到缓存 + val priceData = StrategyPriceData( + openPriceBtc = openPriceBtc, + minSpreadLineUp = autoMinSpreadUp ?: strategy.spreadValue?.toSafeBigDecimal(), + minSpreadLineDown = autoMinSpreadDown ?: strategy.spreadValue?.toSafeBigDecimal(), + periodStartUnix = periodStartUnix + ) + strategyPriceData[strategy.id!!] = priceData + + val response = CryptoTailMonitorInitResponse( + strategyId = strategy.id!!, + name = strategy.name ?: "", + accountId = strategy.accountId, + accountName = account?.accountName ?: "", + marketSlugPrefix = strategy.marketSlugPrefix, + marketTitle = event?.title ?: strategy.marketSlugPrefix, + intervalSeconds = strategy.intervalSeconds, + periodStartUnix = periodStartUnix, + windowStartSeconds = strategy.windowStartSeconds, + windowEndSeconds = strategy.windowEndSeconds, + minPrice = strategy.minPrice.toPlainString(), + maxPrice = strategy.maxPrice.toPlainString(), + minSpreadMode = strategy.spreadMode.name, + spreadDirection = strategy.spreadDirection.name, + minSpreadValue = strategy.spreadValue?.toPlainString(), + autoMinSpreadUp = autoMinSpreadUp?.toPlainString(), + autoMinSpreadDown = autoMinSpreadDown?.toPlainString(), + openPriceBtc = openPriceBtc?.setScale(2, RoundingMode.HALF_UP)?.toPlainString(), + tokenIdUp = tokenIds.getOrNull(0), + tokenIdDown = tokenIds.getOrNull(1), + currentTimestamp = System.currentTimeMillis(), + enabled = strategy.enabled + ) + + Result.success(response) + } catch (e: Exception) { + logger.error("初始化监控失败: ${e.message}", e) + Result.failure(e) + } + } + + /** + * 订阅策略监控 + */ + fun subscribe(sessionId: String, strategyId: Long, callback: (CryptoTailMonitorPushData) -> Unit) { + // 增加订阅计数 + val count = strategySubscribers.merge(strategyId, 1) { old, inc -> old + inc } ?: 1 + + // 注册推送回调 + webSocketSubscriptionService.registerMonitorCallback(sessionId, strategyId, callback) + + // 如果是第一个订阅者,启动 WebSocket 和定时推送 + if (count == 1) { + scope.launch { + refreshSubscription() + } + startPeriodicPush() + } + + // 立即发送当前数据 + scope.launch { + try { + sendCurrentData(sessionId, strategyId, callback) + } catch (e: Exception) { + logger.error("发送当前监控数据失败: $sessionId, ${e.message}") + } + } + } + + /** + * 取消订阅策略监控 + */ + fun unsubscribe(sessionId: String, strategyId: Long) { + // 减少订阅计数 + val currentCount = strategySubscribers[strategyId] ?: 0 + val newCount = (currentCount - 1).coerceAtLeast(0) + + if (newCount == 0) { + strategySubscribers.remove(strategyId) + } else { + strategySubscribers[strategyId] = newCount + } + + // 移除回调 + webSocketSubscriptionService.unregisterMonitorCallback(sessionId, strategyId) + + // 如果没有订阅者,关闭 WebSocket 和定时推送 + if (newCount == 0) { + scope.launch { + refreshSubscription() + } + stopPeriodicPush() + } + } + + private fun startPeriodicPush() { + if (periodicPushJob?.isActive == true) return + periodicPushJob = scope.launch { + while (strategySubscribers.isNotEmpty() && strategySubscribers.values.any { (it ?: 0) > 0 }) { + delay(pushIntervalMs) + if (closedForNoSubscribers.get()) continue + val ids = strategySubscribers.filter { (it.value ?: 0) > 0 }.keys.toList() + for (strategyId in ids) { + try { + val strategy = strategyRepository.findById(strategyId).orElse(null) ?: continue + val priceData = strategyPriceData[strategyId] ?: continue + val pushData = buildPushData(strategy, priceData) + addToHistoryAndPush(strategyId, pushData) + } catch (e: Exception) { + logger.debug("定时推送失败 strategyId=$strategyId: ${e.message}") + } + } + } + } + } + + private fun stopPeriodicPush() { + if (strategySubscribers.isEmpty() || strategySubscribers.values.all { (it ?: 0) <= 0 }) { + periodicPushJob?.cancel() + periodicPushJob = null + } + } + + /** + * 发送当前数据(含历史补全,用于中途进入时填充分时图) + */ + private suspend fun sendCurrentData( + sessionId: String, + strategyId: Long, + callback: (CryptoTailMonitorPushData) -> Unit + ) { + val strategy = strategyRepository.findById(strategyId).orElse(null) ?: return + val priceData = strategyPriceData[strategyId] ?: StrategyPriceData() + + val history = strategyPushHistory[strategyId]?.let { list -> + synchronized(list) { list.toList() } + } ?: emptyList() + for (item in history) { + callback(item) + } + + val pushData = buildPushData(strategy, priceData) + callback(pushData) + } + + /** + * 刷新订阅:双连接模式。当前周期连接 + 下一周期连接;周期切换时关闭过期连接,下一连接晋升为当前,并新建下一周期连接。 + * 使用 Mutex 防止周期结束时 scheduleRefreshAtPeriodEnd 与 maybeRefreshSubscriptionIfPeriodChanged 同时触发导致重复执行。 + */ + private suspend fun refreshSubscription() { + if (!refreshSubscriptionMutex.tryLock()) { + return + } + try { + refreshSubscriptionInternal() + } finally { + refreshSubscriptionMutex.unlock() + } + } + + private suspend fun refreshSubscriptionInternal() { + periodEndCountdownJob?.cancel() + periodEndCountdownJob = null + + val subscribedStrategyIds = strategySubscribers.keys.filter { (strategySubscribers[it] ?: 0) > 0 } + if (subscribedStrategyIds.isEmpty()) { + closeAllWebSockets() + return + } + + val strategies = strategyRepository.findAllById(subscribedStrategyIds).filter { it.enabled && it.id != null } + if (strategies.isEmpty()) { + closeAllWebSockets() + return + } + + val nowSeconds = System.currentTimeMillis() / 1000 + val isSwitch = currentPeriodWebSocket != null + + if (isSwitch) { + // 周期切换:关闭当前周期连接,下一晋升为当前,新建下一周期连接 + closeCurrentPeriodWebSocket() + currentPeriodWebSocket = nextPeriodWebSocket + nextPeriodWebSocket = null + val nextMap = nextPeriodTokenToStrategy.get() + currentPeriodTokenToStrategy.set(nextMap) + val nextPeriodByStrategy = + nextMap.values.flatten().distinctBy { it.strategyId }.associate { it.strategyId to it.periodStartUnix } + logger.info("周期切换:下一周期连接晋升为当前") + for ((strategyId, periodStartUnix) in nextPeriodByStrategy) { + updateStrategyPriceDataForPeriod(listOf(strategyId), periodStartUnix, pushDefault = true) + } + val (newNextTokenIds, newNextMap) = buildSubscriptionMapForNextPeriod(subscribedStrategyIds) + nextPeriodTokenToStrategy.set(newNextMap) + if (newNextTokenIds.isNotEmpty()) { + connectNextPeriod(newNextTokenIds, newNextMap) + } else { + logger.info("下一周期市场尚未创建,仅建立空连接以便周期切换时复用") + connectNextPeriod(emptyList(), emptyMap()) + } + scheduleRefreshAtPeriodEnd(if (newNextMap.isNotEmpty()) newNextMap else nextMap) + } else { + // 首次:建立当前周期连接 + 下一周期连接 + val (currentTokenIds, currentMap) = buildSubscriptionMapForCurrentPeriod(subscribedStrategyIds) + currentPeriodTokenToStrategy.set(currentMap) + for (entry in currentMap.values.flatten().distinctBy { it.strategyId }) { + updateStrategyPriceDataForPeriod(listOf(entry.strategyId), entry.periodStartUnix, pushDefault = false) + } + if (currentTokenIds.isEmpty()) { + closeAllWebSockets() + return + } + connectCurrentPeriod(currentTokenIds, currentMap) + val (nextTokenIds, nextMap) = buildSubscriptionMapForNextPeriod(subscribedStrategyIds) + nextPeriodTokenToStrategy.set(nextMap) + if (nextTokenIds.isNotEmpty()) { + connectNextPeriod(nextTokenIds, nextMap) + } else { + logger.info("下一周期市场尚未创建,先建立空连接,周期切换时会重新订阅") + connectNextPeriod(emptyList(), emptyMap()) + } + scheduleRefreshAtPeriodEnd(currentMap) + } + } + + /** 构建当前周期订阅(每个策略按自己的 interval 算当前周期) */ + private suspend fun buildSubscriptionMapForCurrentPeriod(strategyIds: List): Pair, Map>> { + val strategies = strategyRepository.findAllById(strategyIds) + val nowSeconds = System.currentTimeMillis() / 1000 + val tokenIdSet = mutableSetOf() + val map = mutableMapOf>() + + for (strategy in strategies) { + if (!strategy.enabled || strategy.id == null) continue + val strategyPeriod = (nowSeconds / strategy.intervalSeconds) * strategy.intervalSeconds + val slug = "${strategy.marketSlugPrefix}-$strategyPeriod" + val event = fetchEventBySlug(slug).getOrNull() ?: continue + marketTitleByStrategyPeriod["${strategy.id!!}-$strategyPeriod"] = event.title ?: strategy.marketSlugPrefix + val market = event.markets?.firstOrNull() ?: continue + val tokenIds = parseClobTokenIds(market.clobTokenIds) + if (tokenIds.size < 2) continue + for (i in tokenIds.indices) { + tokenIdSet.add(tokenIds[i]) + map.getOrPut(tokenIds[i]) { mutableListOf() }.add( + MonitorEntry(strategy.id!!, strategy, strategyPeriod, i, tokenIds[i], false) + ) + } + } + return Pair(tokenIdSet.toList(), map) + } + + /** 构建下一周期订阅(每个策略按自己的 interval 算下一周期) */ + private suspend fun buildSubscriptionMapForNextPeriod(strategyIds: List): Pair, Map>> { + val strategies = strategyRepository.findAllById(strategyIds) + val nowSeconds = System.currentTimeMillis() / 1000 + val tokenIdSet = mutableSetOf() + val map = mutableMapOf>() + + for (strategy in strategies) { + if (!strategy.enabled || strategy.id == null) { + continue + } + val currentPeriod = (nowSeconds / strategy.intervalSeconds) * strategy.intervalSeconds + val nextPeriod = currentPeriod + strategy.intervalSeconds + val slug = "${strategy.marketSlugPrefix}-$nextPeriod" + val event = fetchEventBySlug(slug).getOrNull() + if (event == null) { + continue + } + marketTitleByStrategyPeriod["${strategy.id!!}-$nextPeriod"] = event.title ?: strategy.marketSlugPrefix + val market = event.markets?.firstOrNull() + if (market == null) { + continue + } + val tokenIds = parseClobTokenIds(market.clobTokenIds) + if (tokenIds.size < 2) { + continue + } + for (i in tokenIds.indices) { + tokenIdSet.add(tokenIds[i]) + map.getOrPut(tokenIds[i]) { mutableListOf() }.add( + MonitorEntry(strategy.id!!, strategy, nextPeriod, i, tokenIds[i], true) + ) + } + } + return Pair(tokenIdSet.toList(), map) + } + + /** 更新策略价格数据为指定周期(开盘价、价差线等),可选是否推送默认 0.5 */ + private suspend fun updateStrategyPriceDataForPeriod( + strategyIds: List, + periodStartUnix: Long, + pushDefault: Boolean + ) { + val strategies = strategyRepository.findAllById(strategyIds) + for (strategy in strategies) { + if (strategy.id == null) continue + val openClose = binanceKlineService.getCurrentOpenClose( + strategy.marketSlugPrefix, + strategy.intervalSeconds, + periodStartUnix + ) + val openPriceBtc = openClose?.first + var minSpreadLineUp: BigDecimal? = null + var minSpreadLineDown: BigDecimal? = null + when (strategy.spreadMode.name.uppercase()) { + "FIXED" -> { + minSpreadLineUp = strategy.spreadValue?.toSafeBigDecimal() + minSpreadLineDown = strategy.spreadValue?.toSafeBigDecimal() + } + + "AUTO" -> { + val autoSpreads = binanceKlineAutoSpreadService.computeAndCache( + strategy.marketSlugPrefix, + strategy.intervalSeconds, + periodStartUnix + ) + minSpreadLineUp = autoSpreads?.first + minSpreadLineDown = autoSpreads?.second + } + } + val existingData = strategyPriceData[strategy.id] ?: StrategyPriceData() + val periodChanged = existingData.periodStartUnix != null && existingData.periodStartUnix != periodStartUnix + val newData = StrategyPriceData( + currentPriceUp = if (periodChanged && pushDefault) BigDecimal("0.5") else existingData.currentPriceUp, + currentPriceDown = if (periodChanged && pushDefault) BigDecimal("0.5") else existingData.currentPriceDown, + spreadUp = if (periodChanged && pushDefault) BigDecimal("0.5") else existingData.spreadUp, + spreadDown = if (periodChanged && pushDefault) BigDecimal("0.5") else existingData.spreadDown, + openPriceBtc = openPriceBtc, + minSpreadLineUp = minSpreadLineUp, + minSpreadLineDown = minSpreadLineDown, + periodStartUnix = periodStartUnix + ) + strategyPriceData[strategy.id!!] = newData + if (periodChanged && pushDefault) { + val pushData = buildPushData(strategy, newData) + addToHistoryAndPush(strategy.id!!, pushData) + } + } + } + + private fun connectCurrentPeriod(tokenIds: List, map: Map>) { + if (currentPeriodWebSocket != null) return + val request = Request.Builder().url(wsUrl).build() + currentPeriodWebSocket = client.newWebSocket(request, object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) { + closedForNoSubscribers.set(false) + val msg = """{"type":"MARKET","assets_ids":${tokenIds.toJson()}}""" + try { + webSocket.send(msg) + logger.info("加密价差策略监控 WebSocket(当前周期)已连接并订阅: ${tokenIds.size} 个 token") + } catch (e: Exception) { + logger.warn("发送当前周期订阅失败: ${e.message}") + } + } + + override fun onMessage(webSocket: WebSocket, text: String) { + handleMessage(webSocket, text) + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + if (this@CryptoTailMonitorService.currentPeriodWebSocket == webSocket) { + this@CryptoTailMonitorService.currentPeriodWebSocket = null + if (!closedForNoSubscribers.get()) scheduleReconnect() + } + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) { + if (this@CryptoTailMonitorService.currentPeriodWebSocket == webSocket) { + this@CryptoTailMonitorService.currentPeriodWebSocket = null + scheduleReconnect() + } + } + }) + } + + private fun connectNextPeriod(tokenIds: List, map: Map>) { + if (nextPeriodWebSocket != null) { + return + } + val request = Request.Builder().url(wsUrl).build() + nextPeriodWebSocket = client.newWebSocket(request, object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) { + val msg = """{"type":"MARKET","assets_ids":${tokenIds.toJson()}}""" + try { + webSocket.send(msg) + if (tokenIds.isEmpty()) { + logger.info("加密价差策略监控 WebSocket(下一周期)已连接,暂无 token 订阅,等待周期切换后更新") + } else { + logger.info("加密价差策略监控 WebSocket(下一周期)已连接并订阅: ${tokenIds.size} 个 token") + } + } catch (e: Exception) { + logger.warn("发送下一周期订阅失败: ${e.message}") + } + } + + override fun onMessage(webSocket: WebSocket, text: String) { + handleMessage(webSocket, text) + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + if (this@CryptoTailMonitorService.nextPeriodWebSocket == webSocket) { + this@CryptoTailMonitorService.nextPeriodWebSocket = null + } + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) { + if (this@CryptoTailMonitorService.nextPeriodWebSocket == webSocket) { + this@CryptoTailMonitorService.nextPeriodWebSocket = null + } + } + }) + } + + private fun closeCurrentPeriodWebSocket() { + currentPeriodWebSocket?.close(1000, "period_ended") + currentPeriodWebSocket = null + logger.info("加密价差策略监控 WebSocket(当前周期)已关闭") + } + + private fun closeAllWebSockets() { + reconnectJob?.cancel() + reconnectJob = null + closedForNoSubscribers.set(true) + currentPeriodWebSocket?.close(1000, "no_subscribers") + currentPeriodWebSocket = null + nextPeriodWebSocket?.close(1000, "no_subscribers") + nextPeriodWebSocket = null + logger.info("加密价差策略监控 WebSocket 已全部关闭(无订阅者)") + } + + private fun handleMessage(webSocket: WebSocket, text: String) { + if (text == "pong" || text.isEmpty()) return + if (closedForNoSubscribers.get()) return + + maybeRefreshSubscriptionIfPeriodChanged() + + val json = text.fromJson() ?: return + val eventType = (json.get("event_type") as? com.google.gson.JsonPrimitive)?.asString ?: return + val map = currentPeriodTokenToStrategy.get() + + when (eventType) { + "price_change" -> { + val priceChanges = json.get("price_changes") as? com.google.gson.JsonArray ?: return + for (i in 0 until priceChanges.size()) { + val pc = priceChanges.get(i) as? com.google.gson.JsonObject ?: continue + val assetId = (pc.get("asset_id") as? com.google.gson.JsonPrimitive)?.asString ?: continue + val bestBidStr = (pc.get("best_bid") as? com.google.gson.JsonPrimitive)?.asString + val bestBid = bestBidStr?.toSafeBigDecimal() + if (bestBid != null) onPriceUpdate(assetId, bestBid, map) + } + } + } + } + + private fun onPriceUpdate(tokenId: String, bestBid: BigDecimal, map: Map>) { + if (closedForNoSubscribers.get()) return + val entries = map[tokenId] ?: return + + for (entry in entries) { + val strategy = entry.strategy + val priceData = strategyPriceData[strategy.id!!] ?: StrategyPriceData() + + // 根据方向更新价格 + val newPriceData = if (entry.outcomeIndex == 0) { + // Up 方向 + priceData.copy( + currentPriceUp = bestBid, + currentPriceDown = BigDecimal.ONE.subtract(bestBid), + spreadUp = BigDecimal.ONE.subtract(bestBid), + spreadDown = bestBid, + lastUpdateTime = System.currentTimeMillis() + ) + } else { + // Down 方向 + priceData.copy( + currentPriceDown = bestBid, + currentPriceUp = BigDecimal.ONE.subtract(bestBid), + spreadUp = bestBid, + spreadDown = BigDecimal.ONE.subtract(bestBid), + lastUpdateTime = System.currentTimeMillis() + ) + } + + strategyPriceData[strategy.id!!] = newPriceData + + val now = System.currentTimeMillis() + val last = lastPriceChangePushTime[strategy.id!!] ?: 0L + if (now - last >= priceChangePushThrottleMs) { + lastPriceChangePushTime[strategy.id!!] = now + val pushData = buildPushData(strategy, newPriceData) + addToHistoryAndPush(strategy.id!!, pushData) + } + } + } + + private fun addToHistoryAndPush(strategyId: Long, pushData: CryptoTailMonitorPushData) { + addToHistory(strategyId, pushData) + webSocketSubscriptionService.pushMonitorData(strategyId, pushData) + } + + private fun addToHistory(strategyId: Long, pushData: CryptoTailMonitorPushData) { + val list = strategyPushHistory.getOrPut(strategyId) { + Collections.synchronizedList(mutableListOf()) + } + synchronized(list) { + val lastPeriod = strategyHistoryPeriod[strategyId] + if (lastPeriod != null && lastPeriod != pushData.periodStartUnix) { + list.clear() + } + strategyHistoryPeriod[strategyId] = pushData.periodStartUnix + list.add(pushData) + while (list.size > maxHistorySize) { + list.removeAt(0) + } + } + } + + /** + * 构建推送数据 + * 最新价、价差使用币安 K 线的 BTC 价格(open/close) + */ + private fun buildPushData(strategy: CryptoTailStrategy, priceData: StrategyPriceData): CryptoTailMonitorPushData { + val nowSeconds = System.currentTimeMillis() / 1000 + val periodStartUnix = (nowSeconds / strategy.intervalSeconds) * strategy.intervalSeconds + val periodEndUnix = periodStartUnix + strategy.intervalSeconds + val remainingSeconds = (periodEndUnix - nowSeconds).toInt().coerceAtLeast(0) + + val windowStart = periodStartUnix + strategy.windowStartSeconds + val windowEnd = periodStartUnix + strategy.windowEndSeconds + val inTimeWindow = nowSeconds >= windowStart && nowSeconds < windowEnd + + // 币安 K 线:open = 周期开盘价,close = 当前最新价(实时更新) + val openClose = binanceKlineService.getCurrentOpenClose( + strategy.marketSlugPrefix, + strategy.intervalSeconds, + periodStartUnix + ) + val openPriceBtc = priceData.openPriceBtc ?: openClose?.first + val currentPriceBtc = openClose?.second + // K 线数据回来后更新缓存,供后续使用 + if (openPriceBtc != null && priceData.openPriceBtc == null && strategy.id != null) { + strategyPriceData[strategy.id] = priceData.copy(openPriceBtc = openPriceBtc) + } + val spreadBtc = if (openPriceBtc != null && currentPriceBtc != null) { + currentPriceBtc.subtract(openPriceBtc) + } else null + + // 判断价格区间(Polymarket 0-1) + val currentUp = priceData.currentPriceUp + val currentDown = priceData.currentPriceDown + val inPriceRangeUp = currentUp != null && + currentUp >= strategy.minPrice && currentUp <= strategy.maxPrice + val inPriceRangeDown = currentDown != null && + currentDown >= strategy.minPrice && currentDown <= strategy.maxPrice + + val marketTitle = marketTitleByStrategyPeriod["${strategy.id!!}-$periodStartUnix"] ?: strategy.marketSlugPrefix + + return CryptoTailMonitorPushData( + strategyId = strategy.id!!, + timestamp = System.currentTimeMillis(), + periodStartUnix = periodStartUnix, + marketTitle = marketTitle, + currentPriceUp = priceData.currentPriceUp?.setScale(4, RoundingMode.HALF_UP)?.toPlainString(), + currentPriceDown = priceData.currentPriceDown?.setScale(4, RoundingMode.HALF_UP)?.toPlainString(), + spreadUp = priceData.spreadUp?.setScale(4, RoundingMode.HALF_UP)?.toPlainString(), + spreadDown = priceData.spreadDown?.setScale(4, RoundingMode.HALF_UP)?.toPlainString(), + minSpreadLineUp = priceData.minSpreadLineUp?.setScale(2, RoundingMode.HALF_UP)?.toPlainString(), + minSpreadLineDown = priceData.minSpreadLineDown?.setScale(2, RoundingMode.HALF_UP)?.toPlainString(), + openPriceBtc = openPriceBtc?.setScale(2, RoundingMode.HALF_UP)?.toPlainString(), + currentPriceBtc = currentPriceBtc?.setScale(2, RoundingMode.HALF_UP)?.toPlainString(), + spreadBtc = spreadBtc?.setScale(2, RoundingMode.HALF_UP)?.toPlainString(), + remainingSeconds = remainingSeconds, + inTimeWindow = inTimeWindow, + inPriceRangeUp = inPriceRangeUp, + inPriceRangeDown = inPriceRangeDown, + triggered = priceData.triggered, + triggerDirection = priceData.triggerDirection, + periodEnded = remainingSeconds <= 0 + ) + } + + private fun maybeRefreshSubscriptionIfPeriodChanged() { + val subscribed = currentPeriodTokenToStrategy.get().values.flatten().distinctBy { it.strategyId } + .associate { it.strategyId to it.periodStartUnix } + if (subscribed.isEmpty()) return + + val strategies = strategyRepository.findAllById(subscribed.keys) + val nowSeconds = System.currentTimeMillis() / 1000 + + for (s in strategies) { + if (s.id == null) continue + val currentPeriod = (nowSeconds / s.intervalSeconds) * s.intervalSeconds + val subPeriod = subscribed[s.id] ?: continue + if (currentPeriod != subPeriod) { + scope.launch { refreshSubscription() } + return + } + } + } + + private fun scheduleRefreshAtPeriodEnd(newMap: Map>) { + val entries = newMap.values.flatten() + if (entries.isEmpty()) return + + val nextPeriodEndSeconds = entries.minOf { it.periodStartUnix + it.strategy.intervalSeconds } + val delayMs = (nextPeriodEndSeconds * 1000) - System.currentTimeMillis() + 2000 + if (delayMs <= 0) return + + periodEndCountdownJob = scope.launch { + delay(delayMs) + periodEndCountdownJob = null + refreshSubscription() + } + } + + private fun closeWebSocketForNoSubscribers() { + closeAllWebSockets() + } + + private fun scheduleReconnect() { + if (reconnectJob?.isActive == true) return + reconnectJob = scope.launch { + delay(reconnectDelayMs) + reconnectJob = null + if (strategySubscribers.isNotEmpty()) { + logger.info("加密价差策略监控 WebSocket 尝试重连") + refreshSubscription() + } + } + } + + private fun fetchEventBySlug(slug: String): Result { + return try { + val api = retrofitFactory.createGammaApi() + val response = runBlocking { api.getEventBySlug(slug) } + if (response.isSuccessful && response.body() != null) { + Result.success(response.body()!!) + } else { + Result.failure(Exception("${response.code()}")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + private fun parseClobTokenIds(clobTokenIds: String?): List { + if (clobTokenIds.isNullOrBlank()) return emptyList() + return clobTokenIds.fromJson>() ?: emptyList() + } + + @PreDestroy + fun destroy() { + reconnectJob?.cancel() + periodEndCountdownJob?.cancel() + periodicPushJob?.cancel() + currentPeriodWebSocket?.close(1000, "shutdown") + currentPeriodWebSocket = null + nextPeriodWebSocket?.close(1000, "shutdown") + nextPeriodWebSocket = null + } +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailOrderNotificationPollingService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailOrderNotificationPollingService.kt index 93d3d26..faba9f6 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailOrderNotificationPollingService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailOrderNotificationPollingService.kt @@ -22,7 +22,7 @@ import org.springframework.transaction.annotation.Transactional import jakarta.annotation.PreDestroy /** - * 尾盘策略订单 TG 通知轮询服务(与跟单一致) + * 加密价差策略订单 TG 通知轮询服务(与跟单一致) * 定时查询「下单成功且未发 TG」的触发记录,通过 CLOB getOrder 获取订单详情后发送 TG 并标记已发。 */ @Service @@ -57,14 +57,14 @@ class CryptoTailOrderNotificationPollingService( @Scheduled(fixedDelay = 5000) fun scheduledSendPendingNotifications() { if (notificationJob != null && notificationJob!!.isActive) { - logger.debug("上一轮尾盘 TG 通知任务仍在执行,跳过本次") + logger.debug("上一轮加密价差策略 TG 通知任务仍在执行,跳过本次") return } notificationJob = scope.launch { try { getSelf().sendPendingNotifications() } catch (e: Exception) { - logger.error("尾盘 TG 通知轮询异常: ${e.message}", e) + logger.error("加密价差策略 TG 通知轮询异常: ${e.message}", e) } finally { notificationJob = null } @@ -88,7 +88,7 @@ class CryptoTailOrderNotificationPollingService( triggerRepository.save(trigger) } } catch (e: Exception) { - logger.warn("尾盘 TG 通知单条失败: triggerId=${trigger.id}, orderId=${trigger.orderId}, ${e.message}", e) + logger.warn("加密价差策略 TG 通知单条失败: triggerId=${trigger.id}, orderId=${trigger.orderId}, ${e.message}", e) } } } @@ -102,16 +102,16 @@ class CryptoTailOrderNotificationPollingService( return false } val apiSecret = try { - cryptoUtils.decrypt(account.apiSecret) ?: return false + cryptoUtils.decrypt(account.apiSecret) } catch (e: Exception) { logger.warn("解密 API Secret 失败: accountId=${account.id}", e) return false } val apiPassphrase = try { - cryptoUtils.decrypt(account.apiPassphrase) ?: "" + cryptoUtils.decrypt(account.apiPassphrase) } catch (e: Exception) { "" } val clobApi = retrofitFactory.createClobApi( - account.apiKey!!, + account.apiKey, apiSecret, apiPassphrase, account.walletAddress @@ -142,7 +142,7 @@ class CryptoTailOrderNotificationPollingService( walletAddress = account.walletAddress, orderTime = orderTimeMs ) - logger.info("尾盘订单 TG 通知已发送: orderId=$orderId, strategyId=${strategy.id}, triggerId=${trigger.id}") + logger.info("加密价差策略订单 TG 通知已发送: orderId=$orderId, strategyId=${strategy.id}, triggerId=${trigger.id}") return true } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailOrderbookWsService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailOrderbookWsService.kt index 84db01c..3eaefe9 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailOrderbookWsService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailOrderbookWsService.kt @@ -35,7 +35,7 @@ import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference /** - * 尾盘策略订单簿 WebSocket 监听:订阅 CLOB Market 频道,收到订单簿/价格变更时若满足条件立即触发下单。 + * 加密价差策略订单簿 WebSocket 监听:订阅 CLOB Market 频道,收到订单簿/价格变更时若满足条件立即触发下单。 */ @Service class CryptoTailOrderbookWsService( @@ -103,7 +103,7 @@ class CryptoTailOrderbookWsService( try { webSocket?.close(1000, "shutdown") } catch (e: Exception) { - logger.debug("关闭尾盘策略 WebSocket 时异常: ${e.message}") + logger.debug("关闭加密价差策略 WebSocket 时异常: ${e.message}") } webSocket = null scopeJob.cancel() @@ -116,7 +116,7 @@ class CryptoTailOrderbookWsService( val request = Request.Builder().url(wsUrl).build() webSocket = client.newWebSocket(request, object : WebSocketListener() { override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) { - logger.info("尾盘策略订单簿 WebSocket 已连接") + logger.info("加密价差策略订单簿 WebSocket 已连接") refreshAndSubscribe(fromConnect = true) } @@ -130,13 +130,13 @@ class CryptoTailOrderbookWsService( } override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) { - logger.warn("尾盘策略订单簿 WebSocket 异常: ${t.message}") + logger.warn("加密价差策略订单簿 WebSocket 异常: ${t.message}") this@CryptoTailOrderbookWsService.webSocket = null scheduleReconnect() } }) } catch (e: Exception) { - logger.error("尾盘策略订单簿 WebSocket 连接失败: ${e.message}", e) + logger.error("加密价差策略订单簿 WebSocket 连接失败: ${e.message}", e) scheduleReconnect() } } @@ -150,7 +150,7 @@ class CryptoTailOrderbookWsService( delay(reconnectDelayMs) reconnectJob = null if (strategyRepository.findAllByEnabledTrue().isEmpty()) return@launch - logger.info("尾盘策略订单簿 WebSocket 尝试重连") + logger.info("加密价差策略订单簿 WebSocket 尝试重连") connect() } } @@ -244,7 +244,7 @@ class CryptoTailOrderbookWsService( synchronized(refreshLock) { // 如果正在刷新,直接返回,避免重复调用 if (isRefreshing.get()) { - logger.debug("尾盘策略订阅刷新已在进行中,跳过本次调用") + logger.debug("加密价差策略订阅刷新已在进行中,跳过本次调用") return } isRefreshing.set(true) @@ -281,7 +281,7 @@ class CryptoTailOrderbookWsService( val msg = """{"type":"MARKET","assets_ids":${tokenIds.toJson()}}""" try { webSocket?.send(msg) - logger.info("尾盘策略订单簿订阅: ${tokenIds.size} 个 token, 市场: $marketSlugs") + logger.info("加密价差策略订单簿订阅: ${tokenIds.size} 个 token, 市场: $marketSlugs") } catch (e: Exception) { logger.warn("发送订阅失败: ${e.message}") return @@ -303,9 +303,9 @@ class CryptoTailOrderbookWsService( try { ws.close(1000, "subscription_change") } catch (e: Exception) { - logger.debug("关闭尾盘策略 WebSocket 时异常: ${e.message}") + logger.debug("关闭加密价差策略 WebSocket 时异常: ${e.message}") } - logger.info("尾盘策略订单簿 WebSocket 已关闭(订阅更新,将重连)") + logger.info("加密价差策略订单簿 WebSocket 已关闭(订阅更新,将重连)") } } @@ -357,9 +357,9 @@ class CryptoTailOrderbookWsService( try { ws.close(1000, "no_enabled_strategies") } catch (e: Exception) { - logger.debug("关闭尾盘策略 WebSocket 时异常: ${e.message}") + logger.debug("关闭加密价差策略 WebSocket 时异常: ${e.message}") } - logger.info("尾盘策略订单簿 WebSocket 已关闭(无启用策略)") + logger.info("加密价差策略订单簿 WebSocket 已关闭(无启用策略)") } } @@ -377,7 +377,7 @@ class CryptoTailOrderbookWsService( periodEndCountdownJob = null refreshAndSubscribe() } - logger.debug("尾盘策略订单簿订阅倒计时: ${delayMs / 1000}s 后刷新") + logger.debug("加密价差策略订单簿订阅倒计时: ${delayMs / 1000}s 后刷新") } private fun buildSubscriptionMap(): Pair, Map>> { @@ -391,23 +391,23 @@ class CryptoTailOrderbookWsService( val periodStartUnix = (nowSeconds / interval) * interval val windowEnd = periodStartUnix + strategy.windowEndSeconds if (nowSeconds >= windowEnd) { - logger.debug("尾盘策略跳过(已过时间窗口): strategyId=${strategy.id}, slug=${strategy.marketSlugPrefix}, windowEnd=$windowEnd") + logger.debug("加密价差策略跳过(已过时间窗口): strategyId=${strategy.id}, slug=${strategy.marketSlugPrefix}, windowEnd=$windowEnd") continue } val slug = "${strategy.marketSlugPrefix}-$periodStartUnix" val event = runBlocking { fetchEventBySlugWithRetry(slug).getOrNull() } if (event == null) { - logger.warn("尾盘策略跳过(拉取事件失败): strategyId=${strategy.id}, slug=$slug,请确认 Gamma 是否存在该 slug 或稍后重试") + logger.warn("加密价差策略跳过(拉取事件失败): strategyId=${strategy.id}, slug=$slug,请确认 Gamma 是否存在该 slug 或稍后重试") continue } val market = event.markets?.firstOrNull() if (market == null) { - logger.warn("尾盘策略跳过(事件无市场): strategyId=${strategy.id}, slug=$slug") + logger.warn("加密价差策略跳过(事件无市场): strategyId=${strategy.id}, slug=$slug") continue } val tokenIds = parseClobTokenIds(market.clobTokenIds) if (tokenIds.size < 2) { - logger.warn("尾盘策略跳过(token 数量不足): strategyId=${strategy.id}, slug=$slug, tokenCount=${tokenIds.size}") + logger.warn("加密价差策略跳过(token 数量不足): strategyId=${strategy.id}, slug=$slug, tokenCount=${tokenIds.size}") continue } tokenIdSet.addAll(tokenIds) 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 068ae91..a5550bc 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 @@ -27,7 +27,7 @@ import java.math.BigDecimal import java.math.RoundingMode /** - * 尾盘策略结算轮询服务 + * 加密价差策略结算轮询服务 * 定时扫描「状态成功但未结算」的触发记录,通过 Gamma 获取 conditionId、链上查询结算结果,计算收益并回写。 * 实际成交价与成交量使用 Data API 的 activity 接口获取(getUserActivity),比 CLOB getOrder 更准确;失败时回退为触发时的 amountUsdc + 固定价 0.99。 */ @@ -60,14 +60,14 @@ class CryptoTailSettlementService( fun scheduledPollAndSettle() { val previousJob = settlementJob if (previousJob != null && previousJob.isActive) { - logger.debug("上一轮尾盘结算任务仍在执行,跳过本次调度") + logger.debug("上一轮加密价差策略结算任务仍在执行,跳过本次调度") return } settlementJob = settlementScope.launch { try { doPollAndSettle() } catch (e: Exception) { - logger.error("尾盘策略结算定时任务异常: ${e.message}", e) + logger.error("加密价差策略结算定时任务异常: ${e.message}", e) } finally { settlementJob = null } @@ -91,11 +91,11 @@ class CryptoTailSettlementService( try { if (settleOne(trigger)) settledCount++ } catch (e: Exception) { - logger.warn("尾盘结算单条失败: triggerId=${trigger.id}, ${e.message}", e) + logger.warn("加密价差策略结算单条失败: triggerId=${trigger.id}, ${e.message}", e) } } if (settledCount > 0) { - logger.info("尾盘策略结算轮询完成: 处理=${pending.size}, 新结算=$settledCount") + logger.info("加密价差策略结算轮询完成: 处理=${pending.size}, 新结算=$settledCount") } return settledCount } @@ -154,7 +154,7 @@ class CryptoTailSettlementService( settledAt = now ) triggerRepository.save(updated) - logger.debug("尾盘结算已更新: triggerId=${trigger.id}, winnerOutcomeIndex=$winnerIndex, won=$won, pnl=$pnl") + logger.debug("加密价差策略结算已更新: triggerId=${trigger.id}, winnerOutcomeIndex=$winnerIndex, won=$won, pnl=$pnl") return true } @@ -201,7 +201,7 @@ class CryptoTailSettlementService( conditionId: String ): ActivityFill? { val account = accountRepository.findById(strategy.accountId).orElse(null) ?: run { - logger.warn("尾盘结算未拉取 activity: 账户不存在, triggerId=${trigger.id}, accountId=${strategy.accountId}") + logger.warn("加密价差策略结算未拉取 activity: 账户不存在, triggerId=${trigger.id}, accountId=${strategy.accountId}") return null } val user = account.proxyAddress @@ -220,7 +220,7 @@ class CryptoTailSettlementService( sortDirection = "DESC" ) if (!response.isSuccessful || response.body() == null) { - logger.warn("尾盘结算拉取 activity 失败: triggerId=${trigger.id}, code=${response.code()}") + logger.warn("加密价差策略结算拉取 activity 失败: triggerId=${trigger.id}, code=${response.code()}") return null } val activities = response.body()!! @@ -228,13 +228,13 @@ class CryptoTailSettlementService( val match = activities.firstOrNull { a -> a.type == "TRADE" && a.conditionId == conditionId && - a.outcomeIndex != null && a.outcomeIndex!! in 0..1 && + 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 + 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}") + logger.debug("加密价差策略结算 activity 无匹配成交: triggerId=${trigger.id}, conditionId=$conditionId, outcomeIndex=${trigger.outcomeIndex}, 条数=${activities.size}") return null } val price = match.price!!.toSafeBigDecimal() @@ -243,11 +243,11 @@ class CryptoTailSettlementService( 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") + logger.debug("加密价差策略结算 activity 成交数据无效: triggerId=${trigger.id}, price=$price, size=$size") null } } catch (e: Exception) { - logger.warn("尾盘结算拉取 activity 异常,触发价/投入金额不会更新: triggerId=${trigger.id}, error=${e.message}") + 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 32c0543..9119b1a 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 @@ -34,7 +34,7 @@ import java.math.RoundingMode import java.util.concurrent.ConcurrentHashMap import java.util.regex.Pattern -/** 尾盘策略固定下单价格(最高价 0.99),不再在触发时拉取最优价 */ +/** 加密价差策略固定下单价格(最高价 0.99),不再在触发时拉取最优价 */ private const val TRIGGER_FIXED_PRICE = "0.99" /** 最大价差模式(MAX)时,买入价格调整系数(加在触发价格上) */ @@ -62,7 +62,7 @@ private data class PeriodContext( ) /** - * 尾盘策略执行服务:按周期与时间窗口检查价格并下单,每周期最多触发一次。 + * 加密价差策略执行服务:按周期与时间窗口检查价格并下单,每周期最多触发一次。 * 周期开始预置账户、解密、费率、签名类型、CLOB 客户端;触发时按 outcomeIndex 计算 size 并签名提交。 */ @Service @@ -131,17 +131,21 @@ class CryptoTailStrategyExecutionService( val decryptedKey = try { cryptoUtils.decrypt(account.privateKey) ?: return null } catch (e: Exception) { - logger.warn("尾盘策略周期上下文解密私钥失败: accountId=${account.id}", e) + logger.warn("加密价差策略周期上下文解密私钥失败: accountId=${account.id}", e) return null } val apiSecret = try { - account.apiSecret?.let { cryptoUtils.decrypt(it) } ?: "" - } catch (e: Exception) { "" } + account.apiSecret.let { cryptoUtils.decrypt(it) } + } catch (e: Exception) { + "" + } val apiPassphrase = try { - account.apiPassphrase?.let { cryptoUtils.decrypt(it) } ?: "" - } catch (e: Exception) { "" } + account.apiPassphrase.let { cryptoUtils.decrypt(it) } + } catch (e: Exception) { + "" + } - val clobApi = retrofitFactory.createClobApi(account.apiKey!!, apiSecret, apiPassphrase, account.walletAddress) + val clobApi = retrofitFactory.createClobApi(account.apiKey, apiSecret, apiPassphrase, account.walletAddress) val feeRateByTokenId = tokenIds.associate { tokenId -> tokenId to (clobService.getFeeRate(tokenId).getOrNull()?.toString() ?: "0") } @@ -211,20 +215,28 @@ class CryptoTailStrategyExecutionService( val mutex = getTriggerMutex(strategy.id!!, periodStartUnix) mutex.withLock { - if (triggerRepository.findByStrategyIdAndPeriodStartUnix(strategy.id!!, periodStartUnix) != null) return@withLock + if (triggerRepository.findByStrategyIdAndPeriodStartUnix( + strategy.id!!, + periodStartUnix + ) != null + ) return@withLock val logKey = triggerLockKey(strategy.id!!, periodStartUnix) if (conditionLoggedCache.getIfPresent(logKey) == null) { conditionLoggedCache.put(logKey, periodStartUnix + strategy.intervalSeconds) - val oc = binanceKlineService.getCurrentOpenClose(strategy.marketSlugPrefix, strategy.intervalSeconds, periodStartUnix) + val oc = binanceKlineService.getCurrentOpenClose( + strategy.marketSlugPrefix, + strategy.intervalSeconds, + periodStartUnix + ) val openPrice = oc?.first?.toPlainString() ?: "-" val closePrice = oc?.second?.toPlainString() ?: "-" - val strategyName = strategy.name?.takeIf { it.isNotBlank() } ?: "尾盘策略-${strategy.marketSlugPrefix}" + val strategyName = strategy.name?.takeIf { it.isNotBlank() } ?: "加密价差策略-${strategy.marketSlugPrefix}" val direction = if (outcomeIndex == 0) "Up" else "Down" val modeStr = if (strategy.spreadDirection == SpreadDirection.MAX) "最大价差" else "最小价差" logger.info( - "尾盘策略首次满足条件: strategyName=$strategyName, strategyId=${strategy.id}, " + - "openPrice=$openPrice, closePrice=$closePrice, marketPrice=${bestBid.toPlainString()}, " + - "direction=$direction, outcomeIndex=$outcomeIndex, spreadMode=$modeStr" + "加密价差策略首次满足条件: strategyName=$strategyName, strategyId=${strategy.id}, " + + "openPrice=$openPrice, closePrice=$closePrice, marketPrice=${bestBid.toPlainString()}, " + + "direction=$direction, outcomeIndex=$outcomeIndex, spreadMode=$modeStr" ) } if (!passSpreadCheck(strategy, periodStartUnix, outcomeIndex)) return@withLock @@ -235,23 +247,29 @@ class CryptoTailStrategyExecutionService( private fun passSpreadCheck(strategy: CryptoTailStrategy, periodStartUnix: Long, outcomeIndex: Int): Boolean { if (strategy.spreadMode == SpreadMode.NONE) return true - val oc = binanceKlineService.getCurrentOpenClose(strategy.marketSlugPrefix, strategy.intervalSeconds, periodStartUnix) + val oc = binanceKlineService.getCurrentOpenClose( + strategy.marketSlugPrefix, + strategy.intervalSeconds, + periodStartUnix + ) ?: return false val (openP, closeP) = oc val spreadAbs = closeP.subtract(openP).abs() - + // 获取有效价差 val effectiveSpread = when (strategy.spreadMode) { SpreadMode.FIXED -> { strategy.spreadValue?.takeIf { it > BigDecimal.ZERO } ?: return true } + SpreadMode.AUTO -> { val result = computeAutoEffectiveSpread(strategy, periodStartUnix, outcomeIndex) ?: return true result.effectiveSpread.takeIf { it > BigDecimal.ZERO } ?: return true } + SpreadMode.NONE -> return true } - + // 根据价差方向判断 return if (strategy.spreadDirection == SpreadDirection.MAX) { // 最大价差模式:价差 <= 配置值时触发 @@ -271,9 +289,22 @@ class CryptoTailStrategyExecutionService( val effectiveSpread: BigDecimal ) - private fun computeAutoEffectiveSpread(strategy: CryptoTailStrategy, periodStartUnix: Long, outcomeIndex: Int): AutoSpreadResult? { - val baseSpread = binanceKlineAutoSpreadService.getAutoMinSpreadBase(strategy.marketSlugPrefix, strategy.intervalSeconds, periodStartUnix, outcomeIndex) - ?: binanceKlineAutoSpreadService.computeAndCache(strategy.marketSlugPrefix, strategy.intervalSeconds, periodStartUnix)?.let { if (outcomeIndex == 0) it.first else it.second } + private fun computeAutoEffectiveSpread( + strategy: CryptoTailStrategy, + periodStartUnix: Long, + outcomeIndex: Int + ): AutoSpreadResult? { + val baseSpread = binanceKlineAutoSpreadService.getAutoMinSpreadBase( + strategy.marketSlugPrefix, + strategy.intervalSeconds, + periodStartUnix, + outcomeIndex + ) + ?: binanceKlineAutoSpreadService.computeAndCache( + strategy.marketSlugPrefix, + strategy.intervalSeconds, + periodStartUnix + )?.let { if (outcomeIndex == 0) it.first else it.second } ?: return null if (baseSpread <= BigDecimal.ZERO) return null val windowStartMs = (periodStartUnix + strategy.windowStartSeconds) * 1000L @@ -306,18 +337,40 @@ class CryptoTailStrategyExecutionService( val amountUsdc = when (strategy.amountMode.uppercase()) { "RATIO" -> { val balanceResult = accountService.getAccountBalance(ctx.account.id) - val availableBalance = balanceResult.getOrNull()?.availableBalance?.toSafeBigDecimal() ?: BigDecimal.ZERO + val availableBalance = + balanceResult.getOrNull()?.availableBalance?.toSafeBigDecimal() ?: BigDecimal.ZERO availableBalance.multiply(strategy.amountValue).divide(BigDecimal("100"), 18, RoundingMode.DOWN) } + else -> strategy.amountValue } if (amountUsdc < BigDecimal("1")) { - saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", "投入金额不足") + saveTriggerRecord( + strategy, + periodStartUnix, + marketTitle, + outcomeIndex, + triggerPrice, + amountUsdc, + null, + "fail", + "投入金额不足" + ) return } val tokenId = tokenIds.getOrNull(outcomeIndex) ?: run { - saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", "tokenIds 越界") + saveTriggerRecord( + strategy, + periodStartUnix, + marketTitle, + outcomeIndex, + triggerPrice, + amountUsdc, + null, + "fail", + "tokenIds 越界" + ) return } @@ -350,7 +403,16 @@ class CryptoTailStrategyExecutionService( orderType = "FAK", deferExec = false ) - submitOrderAndSaveRecord(ctx.clobApi, strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, orderRequest) + submitOrderAndSaveRecord( + ctx.clobApi, + strategy, + periodStartUnix, + marketTitle, + outcomeIndex, + triggerPrice, + amountUsdc, + orderRequest + ) return } @@ -373,8 +435,18 @@ class CryptoTailStrategyExecutionService( if (response.isSuccessful && response.body() != null) { val body = response.body()!! if (body.success && body.orderId != null) { - saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, body.orderId, "success", null) - logger.info("尾盘策略下单成功: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, outcomeIndex=$outcomeIndex, orderId=${body.orderId}") + saveTriggerRecord( + strategy, + periodStartUnix, + marketTitle, + outcomeIndex, + triggerPrice, + amountUsdc, + body.orderId, + "success", + null + ) + logger.info("加密价差策略下单成功: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, outcomeIndex=$outcomeIndex, orderId=${body.orderId}") return } failReason = body.errorMsg ?: "unknown" @@ -384,10 +456,20 @@ class CryptoTailStrategyExecutionService( } } catch (e: Exception) { failReason = e.message ?: e.toString() - logger.error("尾盘策略下单异常: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix", e) + logger.error("加密价差策略下单异常: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix", e) } - saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", failReason) - logger.error("尾盘策略下单失败: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, reason=$failReason") + saveTriggerRecord( + strategy, + periodStartUnix, + marketTitle, + outcomeIndex, + triggerPrice, + amountUsdc, + null, + "fail", + failReason + ) + logger.error("加密价差策略下单失败: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, reason=$failReason") } /** 无预置上下文时的完整流程:固定价格 0.99,账户/解密/费率/签名在触发时执行 */ @@ -401,12 +483,32 @@ class CryptoTailStrategyExecutionService( ) { val account = accountRepository.findById(strategy.accountId).orElse(null) ?: run { logger.warn("账户不存在: accountId=${strategy.accountId}") - saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, BigDecimal.ZERO, null, "fail", "账户不存在") + saveTriggerRecord( + strategy, + periodStartUnix, + marketTitle, + outcomeIndex, + triggerPrice, + BigDecimal.ZERO, + null, + "fail", + "账户不存在" + ) return } if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) { logger.warn("账户未配置 API 凭证: accountId=${account.id}") - saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, BigDecimal.ZERO, null, "fail", "账户未配置API凭证") + saveTriggerRecord( + strategy, + periodStartUnix, + marketTitle, + outcomeIndex, + triggerPrice, + BigDecimal.ZERO, + null, + "fail", + "账户未配置API凭证" + ) return } @@ -417,12 +519,32 @@ class CryptoTailStrategyExecutionService( else -> strategy.amountValue } if (amountUsdc < BigDecimal("1")) { - saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", "投入金额不足") + saveTriggerRecord( + strategy, + periodStartUnix, + marketTitle, + outcomeIndex, + triggerPrice, + amountUsdc, + null, + "fail", + "投入金额不足" + ) return } val tokenId = tokenIds.getOrNull(outcomeIndex) ?: run { - saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", "tokenIds 越界") + saveTriggerRecord( + strategy, + periodStartUnix, + marketTitle, + outcomeIndex, + triggerPrice, + amountUsdc, + null, + "fail", + "tokenIds 越界" + ) return } @@ -441,16 +563,30 @@ class CryptoTailStrategyExecutionService( cryptoUtils.decrypt(account.privateKey) ?: "" } catch (e: Exception) { logger.error("解密私钥失败: accountId=${account.id}", e) - saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", "解密私钥失败") + saveTriggerRecord( + strategy, + periodStartUnix, + marketTitle, + outcomeIndex, + triggerPrice, + amountUsdc, + null, + "fail", + "解密私钥失败" + ) return } val apiSecret = try { - account.apiSecret?.let { cryptoUtils.decrypt(it) } ?: "" - } catch (e: Exception) { "" } + account.apiSecret.let { cryptoUtils.decrypt(it) } + } catch (e: Exception) { + "" + } val apiPassphrase = try { - account.apiPassphrase?.let { cryptoUtils.decrypt(it) } ?: "" - } catch (e: Exception) { "" } - val clobApi = retrofitFactory.createClobApi(account.apiKey!!, apiSecret, apiPassphrase, account.walletAddress) + account.apiPassphrase.let { cryptoUtils.decrypt(it) } + } catch (e: Exception) { + "" + } + val clobApi = retrofitFactory.createClobApi(account.apiKey, apiSecret, apiPassphrase, account.walletAddress) val feeRateBps = clobService.getFeeRate(tokenId).getOrNull()?.toString() ?: "0" val signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType) @@ -472,7 +608,16 @@ class CryptoTailStrategyExecutionService( orderType = "FAK", deferExec = false ) - submitOrderAndSaveRecord(clobApi, strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, orderRequest) + submitOrderAndSaveRecord( + clobApi, + strategy, + periodStartUnix, + marketTitle, + outcomeIndex, + triggerPrice, + amountUsdc, + orderRequest + ) } private suspend fun fetchEventBySlug(slug: String): Result { @@ -527,6 +672,6 @@ class CryptoTailStrategyExecutionService( periodContextCache.clear() // 清理所有锁,避免内存泄漏 triggerMutexMap.clear() - logger.debug("尾盘策略执行服务已清理缓存和锁") + logger.debug("加密价差策略执行服务已清理缓存和锁") } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyService.kt index 2d9eafc..aaac15f 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyService.kt @@ -104,7 +104,7 @@ class CryptoTailStrategyService( } catch (e: IllegalArgumentException) { Result.failure(e) } catch (e: Exception) { - logger.error("创建尾盘策略失败: ${e.message}", e) + logger.error("创建加密价差策略失败: ${e.message}", e) Result.failure(e) } } @@ -179,7 +179,7 @@ class CryptoTailStrategyService( } catch (e: IllegalArgumentException) { Result.failure(e) } catch (e: Exception) { - logger.error("更新尾盘策略失败: ${e.message}", e) + logger.error("更新加密价差策略失败: ${e.message}", e) Result.failure(e) } } @@ -194,7 +194,7 @@ class CryptoTailStrategyService( eventPublisher.publishEvent(CryptoTailStrategyChangedEvent(this)) Result.success(Unit) } catch (e: Exception) { - logger.error("删除尾盘策略失败: ${e.message}", e) + logger.error("删除加密价差策略失败: ${e.message}", e) Result.failure(e) } } @@ -215,7 +215,7 @@ class CryptoTailStrategyService( val dtos = list.map { entityToDto(it, lastTriggerMap[it.id]) } Result.success(CryptoTailStrategyListResponse(list = dtos)) } catch (e: Exception) { - logger.error("查询尾盘策略列表失败: ${e.message}", e) + logger.error("查询加密价差策略列表失败: ${e.message}", e) Result.failure(e) } } @@ -263,7 +263,7 @@ class CryptoTailStrategyService( private fun generateStrategyName(marketSlugPrefix: String): String { val suffix = Instant.now().atZone(ZoneId.systemDefault()) .format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")) - return "尾盘策略-${marketSlugPrefix}-$suffix" + return "加密价差策略-${marketSlugPrefix}-$suffix" } private fun entityToDto(e: CryptoTailStrategy, lastTriggerAt: Long?): CryptoTailStrategyDto { diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/ApiHealthCheckService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/ApiHealthCheckService.kt index 426c8b2..2afc61f 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/ApiHealthCheckService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/ApiHealthCheckService.kt @@ -250,7 +250,7 @@ class ApiHealthCheckService( name = "币安 WebSocket", url = binanceWsUrl, status = "success", - message = "无尾盘策略,未订阅" + message = "无加密价差策略,未订阅" ) } else if (connected > 0) { val which = statuses.filter { it.value }.keys.joinToString("、") diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/TelegramNotificationService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/TelegramNotificationService.kt index 46ca2e7..49dba41 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/TelegramNotificationService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/TelegramNotificationService.kt @@ -304,7 +304,7 @@ class TelegramNotificationService( } /** - * 发送尾盘策略下单成功通知(与跟单一致:在收到 WS 订单推送时匹配尾盘订单后调用) + * 发送加密价差策略下单成功通知(与跟单一致:在收到 WS 订单推送时匹配价差策略订单后调用) */ suspend fun sendCryptoTailOrderSuccessNotification( orderId: String?, @@ -324,7 +324,7 @@ class TelegramNotificationService( if (orderId != null) { val lastSentTime = sentOrderIds[orderId] if (lastSentTime != null && System.currentTimeMillis() - lastSentTime < 5 * 60 * 1000) { - logger.info("尾盘订单通知已发送过(5分钟内),跳过: orderId=$orderId") + logger.info("加密价差策略订单通知已发送过(5分钟内),跳过: orderId=$orderId") return } sentOrderIds[orderId] = System.currentTimeMillis() @@ -894,7 +894,7 @@ class TelegramNotificationService( } /** - * 构建尾盘策略下单成功消息(与订单成功格式一致,增加「尾盘策略」标题与策略名) + * 构建加密价差策略下单成功消息(与订单成功格式一致,增加「加密价差策略」标题与策略名) */ private fun buildCryptoTailOrderSuccessMessage( orderId: String?, @@ -912,7 +912,7 @@ class TelegramNotificationService( locale: java.util.Locale, orderTime: Long? ): String { - val tailOrderSuccess = messageSource.getMessage("notification.tail.order.success", null, "尾盘策略下单成功", locale) + val tailOrderSuccess = messageSource.getMessage("notification.tail.order.success", null, "加密价差策略下单成功", locale) val strategyLabel = messageSource.getMessage("notification.tail.strategy", null, "策略", locale) val orderInfo = messageSource.getMessage("notification.order.info", null, "订单信息", locale) val orderIdLabel = messageSource.getMessage("notification.order.id", null, "订单ID", locale) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/websocket/UnifiedWebSocketHandler.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/websocket/UnifiedWebSocketHandler.kt index d002764..b37e8de 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/websocket/UnifiedWebSocketHandler.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/websocket/UnifiedWebSocketHandler.kt @@ -193,7 +193,7 @@ class UnifiedWebSocketHandler( lastActivityTime.remove(sessionId) sessionLocks.remove(sessionId) // 清理同步锁 subscriptionService.unregisterSession(sessionId) - + if (session != null && session.isOpen) { try { session.close(CloseStatus.NORMAL) @@ -201,7 +201,6 @@ class UnifiedWebSocketHandler( // 忽略关闭时的异常 } } - } catch (e: Exception) { logger.error("清理 WebSocket 资源时发生错误: $sessionId, ${e.message}", e) } diff --git a/backend/src/main/resources/i18n/messages_en.properties b/backend/src/main/resources/i18n/messages_en.properties index 484dd47..3cebc68 100644 --- a/backend/src/main/resources/i18n/messages_en.properties +++ b/backend/src/main/resources/i18n/messages_en.properties @@ -16,7 +16,7 @@ notification.order.time=Time notification.order.error_info=Error Information notification.order.unknown_account=Unknown Account notification.order.calculate_failed=Calculation Failed -notification.tail.order.success=Tail Session Order Success +notification.tail.order.success=Crypto spread strategy order success notification.tail.strategy=Strategy notification.redeem.success=Position Redeemed Successfully notification.redeem.info=Redeem Information @@ -277,16 +277,16 @@ error.server.backtest_stop_failed=Failed to stop backtest task error.server.backtest_retry_failed=Failed to retry backtest task error.server.backtest_rerun_failed=Failed to re-run backtest with same config -# Crypto tail strategy -error.crypto_tail_strategy_not_found=Crypto tail strategy not found +# Crypto spread strategy +error.crypto_tail_strategy_not_found=Crypto spread strategy not found error.crypto_tail_strategy_window_invalid=Window start must not be greater than window end error.crypto_tail_strategy_window_exceed=Time window must not exceed period length error.crypto_tail_strategy_interval_invalid=Interval must be 300 or 900 seconds error.crypto_tail_strategy_amount_mode_invalid=Amount mode must be RATIO or FIXED -error.server.crypto_tail_strategy_create_failed=Failed to create crypto tail strategy -error.server.crypto_tail_strategy_update_failed=Failed to update crypto tail strategy -error.server.crypto_tail_strategy_delete_failed=Failed to delete crypto tail strategy -error.server.crypto_tail_strategy_list_fetch_failed=Failed to fetch crypto tail strategy list +error.server.crypto_tail_strategy_create_failed=Failed to create crypto spread strategy +error.server.crypto_tail_strategy_update_failed=Failed to update crypto spread strategy +error.server.crypto_tail_strategy_delete_failed=Failed to delete crypto spread strategy +error.server.crypto_tail_strategy_list_fetch_failed=Failed to fetch crypto spread strategy list error.server.crypto_tail_strategy_triggers_fetch_failed=Failed to fetch trigger records # Backtest Management backtest.title=Backtest Management diff --git a/backend/src/main/resources/i18n/messages_zh_CN.properties b/backend/src/main/resources/i18n/messages_zh_CN.properties index c3c9d6c..8d9d961 100644 --- a/backend/src/main/resources/i18n/messages_zh_CN.properties +++ b/backend/src/main/resources/i18n/messages_zh_CN.properties @@ -16,7 +16,7 @@ notification.order.time=时间 notification.order.error_info=错误信息 notification.order.unknown_account=未知账户 notification.order.calculate_failed=计算失败 -notification.tail.order.success=尾盘策略下单成功 +notification.tail.order.success=加密价差策略下单成功 notification.tail.strategy=策略 notification.redeem.success=仓位赎回成功 notification.redeem.info=赎回信息 @@ -277,16 +277,16 @@ error.server.backtest_stop_failed=停止回测任务失败 error.server.backtest_retry_failed=重试回测任务失败 error.server.backtest_rerun_failed=按配置重新测试失败 -# 尾盘策略 -error.crypto_tail_strategy_not_found=尾盘策略不存在 +# 加密价差策略 +error.crypto_tail_strategy_not_found=加密价差策略不存在 error.crypto_tail_strategy_window_invalid=时间区间开始不能大于结束 error.crypto_tail_strategy_window_exceed=时间区间不能超过周期长度 error.crypto_tail_strategy_interval_invalid=周期仅支持 300 或 900 秒 error.crypto_tail_strategy_amount_mode_invalid=投入方式仅支持 RATIO 或 FIXED -error.server.crypto_tail_strategy_create_failed=创建尾盘策略失败 -error.server.crypto_tail_strategy_update_failed=更新尾盘策略失败 -error.server.crypto_tail_strategy_delete_failed=删除尾盘策略失败 -error.server.crypto_tail_strategy_list_fetch_failed=查询尾盘策略列表失败 +error.server.crypto_tail_strategy_create_failed=创建加密价差策略失败 +error.server.crypto_tail_strategy_update_failed=更新加密价差策略失败 +error.server.crypto_tail_strategy_delete_failed=删除加密价差策略失败 +error.server.crypto_tail_strategy_list_fetch_failed=查询加密价差策略列表失败 error.server.crypto_tail_strategy_triggers_fetch_failed=查询触发记录失败 # 回测管理 backtest.title=回测管理 diff --git a/backend/src/main/resources/i18n/messages_zh_TW.properties b/backend/src/main/resources/i18n/messages_zh_TW.properties index 0547847..5d06e6c 100644 --- a/backend/src/main/resources/i18n/messages_zh_TW.properties +++ b/backend/src/main/resources/i18n/messages_zh_TW.properties @@ -16,7 +16,7 @@ notification.order.time=時間 notification.order.error_info=錯誤信息 notification.order.unknown_account=未知賬戶 notification.order.calculate_failed=計算失敗 -notification.tail.order.success=尾盤策略下單成功 +notification.tail.order.success=加密價差策略下單成功 notification.tail.strategy=策略 notification.redeem.success=倉位贖回成功 notification.redeem.info=贖回信息 @@ -277,16 +277,16 @@ error.server.backtest_stop_failed=停止回測任務失敗 error.server.backtest_retry_failed=重試回測任務失敗 error.server.backtest_rerun_failed=依配置重新測試失敗 -# 尾盤策略 -error.crypto_tail_strategy_not_found=尾盤策略不存在 +# 加密價差策略 +error.crypto_tail_strategy_not_found=加密價差策略不存在 error.crypto_tail_strategy_window_invalid=時間區間開始不能大於結束 error.crypto_tail_strategy_window_exceed=時間區間不能超過週期長度 error.crypto_tail_strategy_interval_invalid=週期僅支援 300 或 900 秒 error.crypto_tail_strategy_amount_mode_invalid=投入方式僅支援 RATIO 或 FIXED -error.server.crypto_tail_strategy_create_failed=創建尾盤策略失敗 -error.server.crypto_tail_strategy_update_failed=更新尾盤策略失敗 -error.server.crypto_tail_strategy_delete_failed=刪除尾盤策略失敗 -error.server.crypto_tail_strategy_list_fetch_failed=查詢尾盤策略列表失敗 +error.server.crypto_tail_strategy_create_failed=創建加密價差策略失敗 +error.server.crypto_tail_strategy_update_failed=更新加密價差策略失敗 +error.server.crypto_tail_strategy_delete_failed=刪除加密價差策略失敗 +error.server.crypto_tail_strategy_list_fetch_failed=查詢加密價差策略列表失敗 error.server.crypto_tail_strategy_triggers_fetch_failed=查詢觸發記錄失敗 # 回測管理 backtest.title=回測管理 diff --git a/docs/crypto-tail-strategy/README.md b/docs/crypto-tail-strategy/README.md index 0d3c267..9807931 100644 --- a/docs/crypto-tail-strategy/README.md +++ b/docs/crypto-tail-strategy/README.md @@ -1,6 +1,6 @@ -# 尾盘策略文档 (Crypto Tail Strategy) +# 加密价差策略文档 (Crypto Spread Strategy) -本目录集中存放与 Polymarket 加密市场尾盘策略相关的文档。 +本目录集中存放与 Polymarket 加密市场加密价差策略相关的文档。 ## 目录结构 diff --git a/docs/crypto-tail-strategy/crypto-tail-auto-spread-dynamic-coefficient.md b/docs/crypto-tail-strategy/crypto-tail-auto-spread-dynamic-coefficient.md index f9a1f30..9dcf815 100644 --- a/docs/crypto-tail-strategy/crypto-tail-auto-spread-dynamic-coefficient.md +++ b/docs/crypto-tail-strategy/crypto-tail-auto-spread-dynamic-coefficient.md @@ -55,7 +55,7 @@ effectiveMinSpread = baseSpread × coefficient - 需要策略的 `windowStartSeconds`、`windowEndSeconds` 传入计算处;若窗口长度为 0,可退化为系数 = 1.0 或 0.5(需约定)。 -**优点**:与「尾盘只在窗口内触发」一致,时间语义清晰;毫秒级 progress 更精确。 +**优点**:与「加密价差策略只在窗口内触发」一致,时间语义清晰;毫秒级 progress 更精确。 **缺点**:`getAutoMinSpread` 需要增加当前时间(毫秒)和窗口参数(或传整个 strategy)。 --- diff --git a/docs/crypto-tail-strategy/en/crypto-tail-strategy-user-guide.md b/docs/crypto-tail-strategy/en/crypto-tail-strategy-user-guide.md index afa5ccc..066f48f 100644 --- a/docs/crypto-tail-strategy/en/crypto-tail-strategy-user-guide.md +++ b/docs/crypto-tail-strategy/en/crypto-tail-strategy-user-guide.md @@ -1,8 +1,8 @@ -# Crypto Tail Strategy Configuration Guide +# Crypto Spread Strategy Configuration Guide -## Part 1: What is Crypto Tail Strategy? +## Part 1: What is Crypto Spread Strategy? -Crypto Tail Strategy is an automated trading strategy designed specifically for Polymarket crypto markets' **5-minute** or **15-minute** "Up or Down" markets. +Crypto Spread Strategy is an automated trading strategy designed specifically for Polymarket crypto markets' **5-minute** or **15-minute** "Up or Down" markets. **Core Logic**: Within a specified time window, when the market price enters your set price range, the system will automatically buy at a fixed price (0.99) without manual operation. @@ -39,7 +39,7 @@ Cycle Start → Within Time Window → Price Enters Range → Auto Order - **Maximum one trigger per cycle**: Within the same cycle, even if conditions are met multiple times, only one order is placed - **Fixed order price**: All orders are submitted at price 0.99 -- **Requires separate wallet**: It's recommended to use a dedicated wallet for tail strategies to avoid conflicts with other operations (manual trading, copy trading, etc.) +- **Requires separate wallet**: It's recommended to use a dedicated wallet for crypto spread strategies to avoid conflicts with other operations (manual trading, copy trading, etc.) --- @@ -50,7 +50,7 @@ Cycle Start → Within Time Window → Price Enters Range → Auto Order | Parameter | Description | Required | Example | |-----------|-------------|----------|---------| | **Account** | Select the wallet account for trading | ✅ | Account A | -| **Strategy Name** | Name your strategy for easy identification | ❌ | "BTC 15min Tail Strategy" | +| **Strategy Name** | Name your strategy for easy identification | ❌ | "BTC 15min Crypto Spread Strategy" | | **Market** | Select the market to trade (5-minute or 15-minute) | ✅ | btc-updown-15m | ### 3.2 Cycle Settings @@ -341,7 +341,7 @@ Enabled: On 2. **Position conflicts**: Manual trading and strategy trading may conflict 3. **Management confusion**: Difficult to distinguish which orders are from strategy vs manual -**Recommendation**: Create a dedicated wallet, only for tail strategies. +**Recommendation**: Create a dedicated wallet, only for crypto spread strategies. ### Q7: Why is the order price fixed at 0.99? @@ -352,7 +352,7 @@ Enabled: On ### Q8: Does the strategy depend on auto-redeem functionality? -**A**: Yes, tail strategy depends on auto-redeem functionality. +**A**: Yes, crypto spread strategy depends on auto-redeem functionality. **Reasons**: - Strategy orders create positions after execution @@ -411,7 +411,7 @@ Enabled: On ### 7.1 View Strategy List -On the "Crypto Tail Strategy" page, you can view all strategies: +On the "Crypto Spread Strategy" page, you can view all strategies: - Strategy name - Market information - Time window @@ -453,7 +453,7 @@ After deleting a strategy: ## Part 8: Summary -Crypto Tail Strategy is a powerful automated trading tool that can help you: +Crypto Spread Strategy is a powerful automated trading tool that can help you: 1. **Automated Trading**: No need for manual monitoring, system executes automatically 2. **Precise Control**: Precisely control trigger conditions through time windows and price ranges diff --git a/docs/crypto-tail-strategy/zh/crypto-tail-strategy-flow.md b/docs/crypto-tail-strategy/zh/crypto-tail-strategy-flow.md index 789de21..91b9e2d 100644 --- a/docs/crypto-tail-strategy/zh/crypto-tail-strategy-flow.md +++ b/docs/crypto-tail-strategy/zh/crypto-tail-strategy-flow.md @@ -1,4 +1,4 @@ -# 加密市场尾盘策略 - 流程图 +# 加密价差策略 - 流程图 ## 一、整体架构 diff --git a/docs/crypto-tail-strategy/zh/crypto-tail-strategy-market-data.md b/docs/crypto-tail-strategy/zh/crypto-tail-strategy-market-data.md index fb96d9f..67ec78d 100644 --- a/docs/crypto-tail-strategy/zh/crypto-tail-strategy-market-data.md +++ b/docs/crypto-tail-strategy/zh/crypto-tail-strategy-market-data.md @@ -1,4 +1,4 @@ -# 加密市场尾盘策略 - 5/15 分钟市场数据获取说明 +# 加密价差策略 - 5/15 分钟市场数据获取说明 > 前端 UI 与交互详见 `crypto-tail-strategy-ui-spec.md`。 diff --git a/docs/crypto-tail-strategy/zh/crypto-tail-strategy-min-spread-flow.md b/docs/crypto-tail-strategy/zh/crypto-tail-strategy-min-spread-flow.md index c4f9f3f..f0d61e3 100644 --- a/docs/crypto-tail-strategy/zh/crypto-tail-strategy-min-spread-flow.md +++ b/docs/crypto-tail-strategy/zh/crypto-tail-strategy-min-spread-flow.md @@ -1,8 +1,8 @@ -# 尾盘策略 - 最小价差参数流程分析 +# 加密价差策略 - 最小价差参数流程分析 ## 一、需求摘要 -在现有尾盘策略上增加**最小价差**参数:当策略条件(时间窗、价格区间)满足时,再判断**当前周期 Binance K 线的开盘价与收盘价价差**是否满足最小价差;满足才下单,不满足则等待,直到价差满足再下单。 +在现有加密价差策略上增加**最小价差**参数:当策略条件(时间窗、价格区间)满足时,再判断**当前周期 Binance K 线的开盘价与收盘价价差**是否满足最小价差;满足才下单,不满足则等待,直到价差满足再下单。 - **后端**:需订阅币安对应币对(如 BTC/USDC)的 K 线,维护当前周期的**开盘价**与**实时收盘价**,并在触发时做价差校验。 - **前端**:可配置三种场景——无、固定、自动(见下)。 @@ -244,4 +244,4 @@ sequenceDiagram 3. **下单与去重** - 仍保持「每周期最多触发一次」;价差不满足时不写触发记录,直到某次同时满足价格与价差后才下单并写记录。 -按上述流程即可在现有尾盘策略上接入「最小价差」参数,并由后端订阅币安 K 线、在触发前做价差校验;固定与自动的时序差异见**第六节时序图**。 +按上述流程即可在现有加密价差策略上接入「最小价差」参数,并由后端订阅币安 K 线、在触发前做价差校验;固定与自动的时序差异见**第六节时序图**。 diff --git a/docs/crypto-tail-strategy/zh/crypto-tail-strategy-tasks.md b/docs/crypto-tail-strategy/zh/crypto-tail-strategy-tasks.md index c2f3ebc..945ac50 100644 --- a/docs/crypto-tail-strategy/zh/crypto-tail-strategy-tasks.md +++ b/docs/crypto-tail-strategy/zh/crypto-tail-strategy-tasks.md @@ -1,4 +1,4 @@ -# 加密市场尾盘策略 - 任务梳理 +# 加密价差策略 - 任务梳理 > 需求与 UI 见 `crypto-tail-strategy-ui-spec.md`,市场数据与执行规则见 `crypto-tail-strategy-market-data.md`。 @@ -66,7 +66,7 @@ | 序号 | 任务 | 说明 | |------|------|------| -| B16 | 自动赎回包含尾盘策略仓位 | 尾盘策略产生的仓位与跟单/手动一视同仁,纳入现有自动赎回逻辑,不排除(见 UI 规格附录 A)。 | +| B16 | 自动赎回包含加密价差策略仓位 | 加密价差策略产生的仓位与跟单/手动一视同仁,纳入现有自动赎回逻辑,不排除(见 UI 规格附录 A)。 | | B17 | 调度/定时或常驻 | 对已启用策略按周期(如每 10–30 秒)检查:当前周期、是否在时间窗口内、是否已触发、价格是否进区间;满足则执行下单并写触发记录。 | --- @@ -78,7 +78,7 @@ | 序号 | 任务 | 说明 | |------|------|------| | F1 | 路由 | App.tsx 增加 `/crypto-tail-strategy`、可选 `/crypto-tail-strategy/records/:id`。 | -| F2 | 菜单 | Layout 中增加「尾盘策略」菜单项,与跟单同级或在其下;key 与路由一致。 | +| F2 | 菜单 | Layout 中增加「加密价差策略」菜单项,与跟单同级或在其下;key 与路由一致。 | ### 4.2 列表页 @@ -146,5 +146,5 @@ F10 触发记录 - **时间区间**:仅当周期内当前时间落在 [windowStartSeconds, windowEndSeconds] 时才判断价格并下单;前端区间开始 ≤ 结束,且不超出 5min/15min。 - **每周期一次**:同一策略同一周期只触发一次(先满足价格的 outcome 买入,反方向不买)。 - **重试**:下单失败最多重试 2 次,共 3 次;仍失败记入触发记录为失败。 -- **自动赎回**:尾盘策略产生的仓位可被自动赎回,无排除逻辑。 +- **自动赎回**:加密价差策略产生的仓位可被自动赎回,无排除逻辑。 - **创建前检查**:未配置自动赎回时点击新增策略弹出「去配置」弹窗,不打开表单。 diff --git a/docs/crypto-tail-strategy/zh/crypto-tail-strategy-ui-spec.md b/docs/crypto-tail-strategy/zh/crypto-tail-strategy-ui-spec.md index bf66e43..0e85e57 100644 --- a/docs/crypto-tail-strategy/zh/crypto-tail-strategy-ui-spec.md +++ b/docs/crypto-tail-strategy/zh/crypto-tail-strategy-ui-spec.md @@ -1,4 +1,4 @@ -# 加密市场尾盘策略 - 前端 UI 规格 +# 加密价差策略 - 前端 UI 规格 > 周期推导与市场数据获取详见 `crypto-tail-strategy-market-data.md`。 @@ -10,7 +10,7 @@ | 项目 | 说明 | |------|------| -| **菜单** | 在「跟单管理」同级或其下增加一项,如「尾盘策略」,key 建议 `/crypto-tail-strategy`。 | +| **菜单** | 在「跟单管理」同级或其下增加一项,如「加密价差策略」,key 建议 `/crypto-tail-strategy`。 | | **路由** | 列表页 `/crypto-tail-strategy`;可选详情/触发记录 `/crypto-tail-strategy/records/:id`。 | 参考:`Layout.tsx` 中 `/copy-trading`、`/backtest` 的配置;`App.tsx` 中对应 `Route`。 @@ -26,7 +26,7 @@ | 元素 | 类型 | 说明 | |------|------|------| -| 页面标题 | 标题文案 | 如「加密尾盘策略」,用 `t('cryptoTailStrategy.list.title')`。 | +| 页面标题 | 标题文案 | 如「加密价差策略」,用 `t('cryptoTailStrategy.list.title')`。 | | **钱包使用提示** | **Alert(Warning)** | **必须**在页面顶部或标题下方展示:提示用户**使用单独/专用钱包**运行本策略,避免该钱包用于手动交易、跟单等其他操作,否则可能导致余额或仓位变化,进而造成策略执行异常(如余额不足、下单失败等)。文案走多语言 `t('cryptoTailStrategy.list.walletTip')`,可带 `showIcon`。 | | 新增策略 | Button(Primary) | 点击时**先检查自动赎回相关配置**(见 2.4);若未配置则弹出「去配置」简易弹窗,若已配置则打开「新增策略」表单弹窗。图标可用 `PlusOutlined`。 | | 筛选(可选) | Select / 筛选项 | 按账户、启用状态筛选;移动端可收起到抽屉或折叠。 | @@ -59,7 +59,7 @@ 1. **检查**:请求系统配置(如 `apiService.systemConfig.getConfig()` 或已有接口),判断是否已配置 Builder API Key(及可选:自动赎回已开启)。若 `builderApiKeyConfigured === false`(或后端约定之「未配置」状态),视为未配置。 2. **未配置时**:不打开新增策略表单,改为弹出**简易弹窗**(Modal),内容建议: - **标题**:如「请先配置自动赎回」,`t('cryptoTailStrategy.redeemRequiredModal.title')`。 - - **正文**:简短说明尾盘策略依赖自动赎回,需要先在「系统设置」中配置 Builder API Key 及自动赎回。文案 `t('cryptoTailStrategy.redeemRequiredModal.description')`。 + - **正文**:简短说明加密价差策略依赖自动赎回,需要先在「系统设置」中配置 Builder API Key 及自动赎回。文案 `t('cryptoTailStrategy.redeemRequiredModal.description')`。 - **操作**: - **去配置**:主按钮,点击后关闭弹窗并跳转到系统设置页(如 `/system-settings`,该页含 Relayer 配置与自动赎回开关)。 - **取消**:次按钮或关闭图标,仅关闭弹窗。 @@ -133,7 +133,7 @@ |------|------| | **钱包提示** | 列表页与新增/编辑表单**必须**包含「使用单独钱包」的 Alert 提示,避免用户用混用钱包导致异常;文案走多语言。 | | **创建前检查** | 点击「新增策略」时先检查自动赎回/Builder API 是否已配置;未配置则弹出简易「去配置」弹窗,引导用户到系统设置配置 API Key 与自动赎回,不打开策略表单。 | -| 多语言 | 所有文案 `t('cryptoTailStrategy.xxx')`,在 `locales/zh-CN`、`zh-TW`、`en` 的 `common.json` 中增加键。需包含:`cryptoTailStrategy.list.walletTip`、`cryptoTailStrategy.form.walletTip`,以及 `cryptoTailStrategy.redeemRequiredModal.title`、`cryptoTailStrategy.redeemRequiredModal.description`、`cryptoTailStrategy.redeemRequiredModal.goToSettings`、`cryptoTailStrategy.redeemRequiredModal.cancel`。文案示例:列表页 `walletTip`:「请使用单独的钱包运行尾盘策略,避免该钱包用于手动交易、跟单等其他操作,否则可能导致余额或仓位变化,造成策略执行异常。」表单内 `walletTip`:「建议使用专用钱包,避免手动操作等导致余额或下单异常。」未配置赎回弹窗 `title`:「请先配置自动赎回」;`description`:「尾盘策略依赖自动赎回功能,请先在系统设置中配置 Builder API Key 并开启自动赎回。」;`goToSettings`:「去配置」;`cancel`:「取消」。 | +| 多语言 | 所有文案 `t('cryptoTailStrategy.xxx')`,在 `locales/zh-CN`、`zh-TW`、`en` 的 `common.json` 中增加键。需包含:`cryptoTailStrategy.list.walletTip`、`cryptoTailStrategy.form.walletTip`,以及 `cryptoTailStrategy.redeemRequiredModal.title`、`cryptoTailStrategy.redeemRequiredModal.description`、`cryptoTailStrategy.redeemRequiredModal.goToSettings`、`cryptoTailStrategy.redeemRequiredModal.cancel`。文案示例:列表页 `walletTip`:「请使用单独的钱包运行加密价差策略,避免该钱包用于手动交易、跟单等其他操作,否则可能导致余额或仓位变化,造成策略执行异常。」表单内 `walletTip`:「建议使用专用钱包,避免手动操作等导致余额或下单异常。」未配置赎回弹窗 `title`:「请先配置自动赎回」;`description`:「加密价差策略依赖自动赎回功能,请先在系统设置中配置 Builder API Key 并开启自动赎回。」;`goToSettings`:「去配置」;`cancel`:「取消」。 | | 金额 | 统一 `formatUSDC`(见 frontend.mdc)。 | | 响应式 | `useMediaQuery`;按钮触摸目标 ≥ 44px;移动端主操作突出。 | | 类型 | 不用 `any`;为策略、触发记录定义 TypeScript 类型。 | @@ -150,7 +150,7 @@ | 新增/编辑弹窗 | `frontend/src/pages/CryptoTailStrategyList/FormModal.tsx` 或内嵌 Modal | | 触发记录 | `frontend/src/pages/CryptoTailStrategyList/TriggerRecordsModal.tsx` 或 `CryptoTailStrategyRecords.tsx` | | 路由 | `App.tsx` 中 `/crypto-tail-strategy`、可选 `/crypto-tail-strategy/records/:id` | -| 菜单 | `Layout.tsx` 中增加「尾盘策略」菜单项 | +| 菜单 | `Layout.tsx` 中增加「加密价差策略」菜单项 | | 类型 | `frontend/src/types/index.ts` 或 `types/cryptoTailStrategy.ts` 中增加策略与触发记录类型 | | 多语言 | `frontend/src/locales/{zh-CN,zh-TW,en}/common.json` 中增加 `cryptoTailStrategy.*` | @@ -158,7 +158,7 @@ ## 7. 小结:UI 包含的主要元素 -- **导航**:主导航中「尾盘策略」入口。 +- **导航**:主导航中「加密价差策略」入口。 - **列表页**:标题、钱包提示 Alert、新增按钮(点击前先检查赎回配置,未配置则弹「去配置」简易弹窗)、筛选、表格/卡片(策略名、市场、价格区间、投入方式、状态、最近触发、操作)、加载与空状态。 - **未配置赎回弹窗**:简易 Modal,提示依赖自动赎回、需先配置 Builder API Key 与自动赎回;按钮「去配置」(跳转 `/system-settings`)、「取消」。 - **表单弹窗**:策略名、账户、市场选择、minPrice/maxPrice、投入方式(比例/固定)、启用开关、提交/取消。 @@ -169,9 +169,9 @@ ## 附录 A 后端/产品要求:自动赎回须支持本策略仓位 -自动赎回逻辑**必须支持赎回由尾盘策略产生的订单所对应的仓位**。即:本策略触发的市价买入会形成仓位,这些仓位在满足「可赎回」条件时,应被纳入现有自动赎回流程并正常发起赎回,不得因来源为「尾盘策略」而被排除。后端实现时需保证: +自动赎回逻辑**必须支持赎回由加密价差策略产生的订单所对应的仓位**。即:本策略触发的市价买入会形成仓位,这些仓位在满足「可赎回」条件时,应被纳入现有自动赎回流程并正常发起赎回,不得因来源为「加密价差策略」而被排除。后端实现时需保证: -- 尾盘策略下单产生的仓位,与跟单/手动下单等来源的仓位一视同仁,参与可赎回查询与批量赎回; -- 若当前自动赎回按账户或仓位类型过滤,需将「尾盘策略订单产生的仓位」包含在内。 +- 加密价差策略下单产生的仓位,与跟单/手动下单等来源的仓位一视同仁,参与可赎回查询与批量赎回; +- 若当前自动赎回按账户或仓位类型过滤,需将「加密价差策略订单产生的仓位」包含在内。 这样前端所依赖的「自动赎回」对该策略才完整有效。 diff --git a/docs/crypto-tail-strategy/zh/crypto-tail-strategy-user-guide.md b/docs/crypto-tail-strategy/zh/crypto-tail-strategy-user-guide.md index b4eac4e..4998d2e 100644 --- a/docs/crypto-tail-strategy/zh/crypto-tail-strategy-user-guide.md +++ b/docs/crypto-tail-strategy/zh/crypto-tail-strategy-user-guide.md @@ -1,8 +1,8 @@ -# 尾盘策略配置指南 +# 加密价差策略配置指南 -## 一、什么是尾盘策略? +## 一、什么是加密价差策略? -尾盘策略是一种自动化交易策略,专门用于 Polymarket 加密市场的 **5分钟** 或 **15分钟** "Up or Down" 市场。 +加密价差策略是一种自动化交易策略,专门用于 Polymarket 加密市场的 **5分钟** 或 **15分钟** "Up or Down" 市场。 **核心逻辑**:在指定时间窗口内,当市场价格进入您设定的价格区间时,系统会自动以固定价格(0.99)买入,无需手动操作。 @@ -39,7 +39,7 @@ - **每周期最多触发一次**:同一个周期内,即使多次满足条件,也只下单一次 - **固定下单价格**:所有订单都以 0.99 的价格提交 -- **需要单独钱包**:建议使用专门的钱包运行尾盘策略,避免与其他操作(手动交易、跟单等)冲突 +- **需要单独钱包**:建议使用专门的钱包运行加密价差策略,避免与其他操作(手动交易、跟单等)冲突 --- @@ -50,7 +50,7 @@ | 参数 | 说明 | 必填 | 示例 | |------|------|------|------| | **账户** | 选择用于交易的钱包账户 | ✅ | 账户A | -| **策略名称** | 给策略起个名字,方便识别 | ❌ | "BTC 15分钟尾盘策略" | +| **策略名称** | 给策略起个名字,方便识别 | ❌ | "BTC 15分钟加密价差策略" | | **市场** | 选择要交易的市场(5分钟或15分钟) | ✅ | btc-updown-15m | ### 3.2 周期设置 @@ -341,7 +341,7 @@ 2. **仓位冲突**:手动交易和策略交易可能产生冲突 3. **管理混乱**:难以区分哪些订单是策略产生的,哪些是手动产生的 -**建议**:创建一个专门的钱包,只用于尾盘策略。 +**建议**:创建一个专门的钱包,只用于加密价差策略。 ### Q7:下单价格为什么是固定的 0.99? @@ -352,7 +352,7 @@ ### Q8:策略需要依赖自动赎回功能吗? -**A**:是的,尾盘策略依赖自动赎回功能。 +**A**:是的,加密价差策略依赖自动赎回功能。 **原因**: - 策略下单后会形成仓位 @@ -411,7 +411,7 @@ ### 7.1 查看策略列表 -在「尾盘策略」页面可以查看所有策略: +在「加密价差策略」页面可以查看所有策略: - 策略名称 - 市场信息 - 时间窗口 @@ -453,7 +453,7 @@ ## 八、总结 -尾盘策略是一个强大的自动化交易工具,可以帮助您: +加密价差策略是一个强大的自动化交易工具,可以帮助您: 1. **自动化交易**:无需手动盯盘,系统自动执行 2. **精准控制**:通过时间窗口和价格区间精确控制触发条件 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0726391..a8c0faa 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -35,6 +35,7 @@ import Announcements from './pages/Announcements' import BacktestList from './pages/BacktestList' import BacktestDetail from './pages/BacktestDetail' import CryptoTailStrategyList from './pages/CryptoTailStrategyList' +import CryptoTailMonitor from './pages/CryptoTailMonitor' import { wsManager } from './services/websocket' import type { OrderPushMessage } from './types' import { apiService } from './services/api' @@ -252,6 +253,7 @@ function App() { } /> } /> } /> + } /> } /> {/* 保留旧路由以保持向后兼容 */} } /> diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 6c4c8a2..6f4239a 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -21,7 +21,9 @@ import { SendOutlined, ApiOutlined, NotificationOutlined, - LineChartOutlined + LineChartOutlined, + RocketOutlined, + DashboardOutlined } from '@ant-design/icons' import type { MenuProps } from 'antd' import type { ReactNode } from 'react' @@ -75,6 +77,9 @@ const Layout: React.FC = ({ children }) => { if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) { keys.push('/copy-trading-management') } + if (path.startsWith('/crypto-tail-strategy') || path.startsWith('/crypto-tail-monitor')) { + keys.push('/crypto-tail-management') + } if (path.startsWith('/system-settings')) { keys.push('/system-settings') } @@ -90,6 +95,9 @@ const Layout: React.FC = ({ children }) => { if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) { keys.push('/copy-trading-management') } + if (path.startsWith('/crypto-tail-strategy') || path.startsWith('/crypto-tail-monitor')) { + keys.push('/crypto-tail-management') + } if (path.startsWith('/system-settings')) { keys.push('/system-settings') } @@ -158,9 +166,21 @@ const Layout: React.FC = ({ children }) => { ] }, { - key: '/crypto-tail-strategy', + key: '/crypto-tail-management', icon: , - label: t('menu.cryptoTailStrategy') + label: t('menu.cryptoSpreadStrategy'), + children: [ + { + key: '/crypto-tail-strategy', + icon: , + label: t('menu.cryptoTailStrategy') + }, + { + key: '/crypto-tail-monitor', + icon: , + label: t('menu.cryptoTailMonitor') + } + ] }, { key: '/positions', @@ -230,7 +250,7 @@ const Layout: React.FC = ({ children }) => { const handleMenuClick = ({ key }: { key: string }) => { // 如果是父菜单,不导航(但 /system-settings 作为子菜单项时可以导航) - if (key === '/copy-trading-management') { + if (key === '/copy-trading-management' || key === '/crypto-tail-management') { return } @@ -288,7 +308,7 @@ const Layout: React.FC = ({ children }) => { alignItems: 'center', verticalAlign: 'middle' }} - title={hasUpdate ? '有新版本可用,点击前往系统更新' : '当前已是最新版本'} + title={hasUpdate ? t('systemUpdate.versionTooltipNew') : t('systemUpdate.versionTooltipLatest')} > {getVersionInfo().gitTag || `v${getVersionText()}`} @@ -410,7 +430,7 @@ const Layout: React.FC = ({ children }) => { alignItems: 'center', verticalAlign: 'middle' }} - title={hasUpdate ? '有新版本可用,点击前往系统更新' : '当前已是最新版本'} + title={hasUpdate ? t('systemUpdate.versionTooltipNew') : t('systemUpdate.versionTooltipLatest')} > {getVersionInfo().gitTag || `v${getVersionText()}`} diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index d424ede..bd68b3c 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -312,7 +312,9 @@ "leaders": "Leader Management", "templates": "Templates", "copyTradingConfig": "Copy Trading Config", - "cryptoTailStrategy": "Tail Strategy", + "cryptoSpreadStrategy": "Crypto Spread Strategy", + "cryptoTailStrategy": "Strategy Config", + "cryptoTailMonitor": "Real-time Monitor", "positions": "Position Management", "backtest": "Backtest", "statistics": "Statistics", @@ -418,6 +420,40 @@ "saveFailed": "Failed to save auto redeem configuration" } }, + "systemUpdate": { + "title": "System Update", + "currentVersion": "Current Version", + "ready": "Ready", + "hasNewVersion": "New version available: {{version}}", + "alreadyLatest": "You are on the latest version", + "checkFailed": "Failed to check for updates", + "confirmTitle": "Confirm Update", + "confirmContent1": "Update to version {{version}}?", + "confirmContent2": "The system will be temporarily unavailable during the update (about 30-60 seconds).", + "confirmContent3": "The page will refresh automatically when the update completes.", + "okText": "Update Now", + "cancelText": "Cancel", + "updateStarted": "Update started, please wait...", + "updateFailedWithMessage": "Update failed: {{message}}", + "updateSuccessRefresh": "Update successful! Page will refresh in 3 seconds...", + "needAdmin": "Admin permission required to perform update", + "startFailed": "Failed to start update", + "updating": "System is updating", + "updateFailedTitle": "Update Failed", + "checkUpdate": "Check for Updates", + "newVersionFound": "New Version Available", + "publishedAt": "Published At", + "releaseNotes": "Release Notes", + "upgradeNow": "Upgrade to v{{version}} Now", + "usageTitle": "Instructions", + "usage1": "Click \"Check for Updates\" to see if a new version is available", + "usage2": "Update takes about 30-60 seconds; the system will be temporarily unavailable", + "usage3": "The page will refresh automatically after a successful update", + "usage4": "If the update fails, the system will roll back to the current version", + "versionTooltipNew": "New version available, click to go to System Update", + "versionTooltipLatest": "You are on the latest version", + "prerelease": "Pre-release" + }, "builderApiKey": { "title": "Builder API Key Configuration", "alertTitle": "What is Builder API Key?", @@ -1450,12 +1486,12 @@ "cryptoTailStrategy": { "binanceApiAlert": { "title": "Cannot connect to Binance API — strategy cannot run for now", - "description": "Tail strategy needs Binance market data to work. The connection failed; this may be a network issue or Binance outage. Try again later by clicking the button below.", + "description": "Crypto spread strategy needs Binance market data to work. The connection failed; this may be a network issue or Binance outage. Try again later by clicking the button below.", "recheck": "Re-check" }, "list": { - "title": "Crypto Tail Strategy", - "walletTip": "Use a dedicated wallet for tail strategy. Do not use it for manual trading or copy trading to avoid balance/position issues.", + "title": "Crypto Spread Strategy", + "walletTip": "Use a dedicated wallet for crypto spread strategy. Do not use it for manual trading or copy trading to avoid balance/position issues.", "addStrategy": "Add Strategy", "strategyName": "Strategy Name", "account": "Account", @@ -1512,7 +1548,7 @@ }, "redeemRequiredModal": { "title": "Configure Auto Redeem First", - "description": "Tail strategy requires auto redeem. Please configure Builder API Key and enable auto redeem in System Settings.", + "description": "Crypto spread strategy requires auto redeem. Please configure Builder API Key and enable auto redeem in System Settings.", "goToSettings": "Go to Settings", "cancel": "Cancel" }, @@ -1541,5 +1577,73 @@ "emptyFail": "No failed records", "totalCount": "{count} record(s)" } + }, + "cryptoTailMonitor": { + "title": "Crypto Spread Strategy Monitor", + "selectStrategy": "Strategy", + "selectStrategyPlaceholder": "Select a strategy to monitor", + "direction": "Direction", + "directionUp": "Up", + "directionDown": "Down", + "noData": "Select a strategy to start monitoring", + "priceRange": "Price Range", + "timeWindow": "Time Window", + "stat": { + "openPrice": "Open Price", + "currentPrice": "Current Price", + "spread": "Spread", + "remainingTime": "Remaining", + "configuredSpread": "Configured Spread", + "configuredSpreadMin": "Min Spread", + "configuredSpreadMax": "Max Spread", + "status": "Status", + "minSpreadLine": "Min Spread", + "periodSpreadMinMax": "Period Spread", + "periodSpreadMin": "Period Min Spread", + "periodSpreadMax": "Period Max Spread", + "minSpread": "Min", + "maxSpread": "Max" + }, + "status": { + "triggered": "Triggered", + "periodEnded": "Period Ended", + "inCondition": "In Condition", + "waiting": "Waiting" + }, + "chart": { + "title": "Price Chart", + "btcTitle": "BTC Price Chart", + "marketTitle": "Market Price Chart", + "priceChart": "Price Chart", + "price": "Price", + "openPrice": "Open", + "spread": "Spread", + "minSpreadLine": "Min Spread Line", + "maxSpreadLine": "Max Spread Line", + "marketUp": "Up", + "marketDown": "Down", + "time": "Time", + "latestPrice": "Latest", + "timeWindowStart": "Window Start", + "timeWindowEnd": "Window End" + }, + "strategyInfo": { + "title": "Strategy Info", + "market": "Market", + "interval": "Interval", + "account": "Account", + "spreadMode": "Spread Mode", + "spreadDirection": "Spread Direction" + }, + "periodSwitch": { + "mode": "Period Switch Mode", + "auto": "Auto", + "manual": "Manual", + "autoDesc": "Automatically switch to the latest period when it ends", + "manualDesc": "Keep complete data when period ends", + "switchToLatest": "Switch to Latest Period", + "periodEnded": "Current period has ended", + "newPeriodAvailable": "New period has started" + } } } \ No newline at end of file diff --git a/frontend/src/locales/zh-CN/common.json b/frontend/src/locales/zh-CN/common.json index 3b3f561..8f442c8 100644 --- a/frontend/src/locales/zh-CN/common.json +++ b/frontend/src/locales/zh-CN/common.json @@ -92,6 +92,7 @@ "accountNamePlaceholder": "账户名称(可选)", "accountIdRequired": "账户ID不能为空", "walletAddress": "钱包地址", + "walletType": "钱包类型", "proxyAddress": "代理钱包地址", "apiCredentials": "API 凭证", "apiKey": "API Key", @@ -311,7 +312,9 @@ "leaders": "Leader 管理", "templates": "跟单模板", "copyTradingConfig": "跟单配置", - "cryptoTailStrategy": "尾盘策略", + "cryptoSpreadStrategy": "加密价差策略", + "cryptoTailStrategy": "策略配置", + "cryptoTailMonitor": "实时监控", "positions": "仓位管理", "backtest": "回测", "statistics": "统计信息", @@ -417,6 +420,40 @@ "saveFailed": "保存自动赎回配置失败" } }, + "systemUpdate": { + "title": "系统更新", + "currentVersion": "当前版本", + "ready": "就绪", + "hasNewVersion": "发现新版本: {{version}}", + "alreadyLatest": "当前已是最新版本", + "checkFailed": "检查更新失败", + "confirmTitle": "确认更新", + "confirmContent1": "确定要更新到版本 {{version}} 吗?", + "confirmContent2": "更新过程中系统将暂时不可用(约30-60秒)。", + "confirmContent3": "更新完成后页面将自动刷新。", + "okText": "立即更新", + "cancelText": "取消", + "updateStarted": "更新已启动,请稍候...", + "updateFailedWithMessage": "更新失败: {{message}}", + "updateSuccessRefresh": "更新成功!页面将在3秒后刷新...", + "needAdmin": "需要管理员权限才能执行更新", + "startFailed": "启动更新失败", + "updating": "系统正在更新", + "updateFailedTitle": "更新失败", + "checkUpdate": "检查更新", + "newVersionFound": "发现新版本", + "publishedAt": "发布时间", + "releaseNotes": "更新内容", + "upgradeNow": "立即升级到 v{{version}}", + "usageTitle": "使用说明", + "usage1": "点击「检查更新」按钮检查是否有新版本", + "usage2": "更新过程约需30-60秒,期间系统将暂时不可用", + "usage3": "更新成功后页面将自动刷新", + "usage4": "如果更新失败,系统会自动回滚到当前版本", + "versionTooltipNew": "有新版本可用,点击前往系统更新", + "versionTooltipLatest": "当前已是最新版本", + "prerelease": "预发布" + }, "builderApiKey": { "title": "Builder API Key 配置", "alertTitle": "什么是 Builder API Key?", @@ -1449,12 +1486,12 @@ "cryptoTailStrategy": { "binanceApiAlert": { "title": "无法连接币安 API,策略暂时不能运行", - "description": "尾盘策略需要从币安获取行情数据才能工作。当前连接失败,可能是网络问题或币安服务异常,请稍后点击下方按钮重新检测。", + "description": "加密价差策略需要从币安获取行情数据才能工作。当前连接失败,可能是网络问题或币安服务异常,请稍后点击下方按钮重新检测。", "recheck": "重新检测" }, "list": { - "title": "加密尾盘策略", - "walletTip": "请使用单独的钱包运行尾盘策略,避免该钱包用于手动交易、跟单等其他操作,否则可能导致余额或仓位变化,造成策略执行异常。", + "title": "加密价差策略", + "walletTip": "请使用单独的钱包运行加密价差策略,避免该钱包用于手动交易、跟单等其他操作,否则可能导致余额或仓位变化,造成策略执行异常。", "addStrategy": "新增策略", "strategyName": "策略名称", "account": "账户", @@ -1511,7 +1548,7 @@ }, "redeemRequiredModal": { "title": "请先配置自动赎回", - "description": "尾盘策略依赖自动赎回功能,请先在系统设置中配置 Builder API Key 并开启自动赎回。", + "description": "加密价差策略依赖自动赎回功能,请先在系统设置中配置 Builder API Key 并开启自动赎回。", "goToSettings": "去配置", "cancel": "取消" }, @@ -1540,5 +1577,73 @@ "emptyFail": "暂无失败记录", "totalCount": "共 {count} 条" } + }, + "cryptoTailMonitor": { + "title": "加密价差策略监控", + "selectStrategy": "选择策略", + "selectStrategyPlaceholder": "请选择要监控的策略", + "direction": "监控方向", + "directionUp": "Up", + "directionDown": "Down", + "noData": "请选择一个策略开始监控", + "priceRange": "价格区间", + "timeWindow": "时间窗口", + "stat": { + "openPrice": "开盘价", + "currentPrice": "最新价", + "spread": "价差", + "remainingTime": "剩余时间", + "configuredSpread": "配置价差", + "configuredSpreadMin": "最小价差", + "configuredSpreadMax": "最大价差", + "status": "状态", + "minSpreadLine": "最小价差线", + "periodSpreadMinMax": "周期内价差", + "periodSpreadMin": "周期内最小价差", + "periodSpreadMax": "周期内最大价差", + "minSpread": "最小", + "maxSpread": "最大" + }, + "status": { + "triggered": "已触发", + "periodEnded": "周期结束", + "inCondition": "满足条件", + "waiting": "等待中" + }, + "chart": { + "title": "分时图", + "btcTitle": "BTC 分时图", + "marketTitle": "市场分时图", + "priceChart": "价格分时图", + "price": "价格", + "openPrice": "开盘价", + "spread": "价差", + "minSpreadLine": "最小价差线", + "maxSpreadLine": "最大价差线", + "marketUp": "Up", + "marketDown": "Down", + "time": "时间", + "latestPrice": "最新价", + "timeWindowStart": "区间开始", + "timeWindowEnd": "区间结束" + }, + "strategyInfo": { + "title": "策略信息", + "market": "市场", + "interval": "周期", + "account": "账户", + "spreadMode": "价差模式", + "spreadDirection": "价差方向" + }, + "periodSwitch": { + "mode": "周期切换模式", + "auto": "自动", + "manual": "手动", + "autoDesc": "周期结束时自动切换到最新周期", + "manualDesc": "周期结束时保留完整数据", + "switchToLatest": "切换到最新周期", + "periodEnded": "当前周期已结束", + "newPeriodAvailable": "新周期已开始" + } } } \ No newline at end of file diff --git a/frontend/src/locales/zh-TW/common.json b/frontend/src/locales/zh-TW/common.json index ab6a7c5..a0d8fd2 100644 --- a/frontend/src/locales/zh-TW/common.json +++ b/frontend/src/locales/zh-TW/common.json @@ -312,7 +312,9 @@ "leaders": "Leader 管理", "templates": "跟單模板", "copyTradingConfig": "跟單配置", - "cryptoTailStrategy": "尾盤策略", + "cryptoSpreadStrategy": "加密價差策略", + "cryptoTailStrategy": "策略配置", + "cryptoTailMonitor": "即時監控", "positions": "倉位管理", "backtest": "回測", "statistics": "統計信息", @@ -418,6 +420,40 @@ "saveFailed": "保存自動贖回配置失敗" } }, + "systemUpdate": { + "title": "系統更新", + "currentVersion": "當前版本", + "ready": "就緒", + "hasNewVersion": "發現新版本: {{version}}", + "alreadyLatest": "當前已是最新版本", + "checkFailed": "檢查更新失敗", + "confirmTitle": "確認更新", + "confirmContent1": "確定要更新到版本 {{version}} 嗎?", + "confirmContent2": "更新過程中系統將暫時不可用(約30-60秒)。", + "confirmContent3": "更新完成後頁面將自動刷新。", + "okText": "立即更新", + "cancelText": "取消", + "updateStarted": "更新已啟動,請稍候...", + "updateFailedWithMessage": "更新失敗: {{message}}", + "updateSuccessRefresh": "更新成功!頁面將在3秒後刷新...", + "needAdmin": "需要管理員權限才能執行更新", + "startFailed": "啟動更新失敗", + "updating": "系統正在更新", + "updateFailedTitle": "更新失敗", + "checkUpdate": "檢查更新", + "newVersionFound": "發現新版本", + "publishedAt": "發佈時間", + "releaseNotes": "更新內容", + "upgradeNow": "立即升級到 v{{version}}", + "usageTitle": "使用說明", + "usage1": "點擊「檢查更新」按鈕檢查是否有新版本", + "usage2": "更新過程約需30-60秒,期間系統將暫時不可用", + "usage3": "更新成功後頁面將自動刷新", + "usage4": "如果更新失敗,系統會自動回滾到當前版本", + "versionTooltipNew": "有新版本可用,點擊前往系統更新", + "versionTooltipLatest": "當前已是最新版本", + "prerelease": "預發佈" + }, "builderApiKey": { "title": "Builder API Key 配置", "alertTitle": "什麼是 Builder API Key?", @@ -1450,12 +1486,12 @@ "cryptoTailStrategy": { "binanceApiAlert": { "title": "無法連接幣安 API,策略暫時不能運行", - "description": "尾盤策略需要從幣安取得行情資料才能運作。目前連線失敗,可能是網路問題或幣安服務異常,請稍後點擊下方按鈕重新檢測。", + "description": "加密價差策略需要從幣安取得行情資料才能運作。目前連線失敗,可能是網路問題或幣安服務異常,請稍後點擊下方按鈕重新檢測。", "recheck": "重新檢測" }, "list": { - "title": "加密尾盤策略", - "walletTip": "請使用單獨的錢包運行尾盤策略,避免該錢包用於手動交易、跟單等其他操作,否則可能導致餘額或倉位變化,造成策略執行異常。", + "title": "加密價差策略", + "walletTip": "請使用單獨的錢包運行加密價差策略,避免該錢包用於手動交易、跟單等其他操作,否則可能導致餘額或倉位變化,造成策略執行異常。", "addStrategy": "新增策略", "strategyName": "策略名稱", "account": "賬戶", @@ -1512,7 +1548,7 @@ }, "redeemRequiredModal": { "title": "請先配置自動贖回", - "description": "尾盤策略依賴自動贖回功能,請先在系統設置中配置 Builder API Key 並開啟自動贖回。", + "description": "加密價差策略依賴自動贖回功能,請先在系統設置中配置 Builder API Key 並開啟自動贖回。", "goToSettings": "去配置", "cancel": "取消" }, @@ -1541,5 +1577,73 @@ "emptyFail": "暫無失敗記錄", "totalCount": "共 {count} 條" } + }, + "cryptoTailMonitor": { + "title": "加密價差策略監控", + "selectStrategy": "選擇策略", + "selectStrategyPlaceholder": "請選擇要監控的策略", + "direction": "監控方向", + "directionUp": "Up", + "directionDown": "Down", + "noData": "請選擇一個策略開始監控", + "priceRange": "價格區間", + "timeWindow": "時間窗口", + "stat": { + "openPrice": "開盤價", + "currentPrice": "最新價", + "spread": "價差", + "remainingTime": "剩餘時間", + "configuredSpread": "配置價差", + "configuredSpreadMin": "最小價差", + "configuredSpreadMax": "最大價差", + "status": "狀態", + "minSpreadLine": "最小價差線", + "periodSpreadMinMax": "週期內價差", + "periodSpreadMin": "週期內最小價差", + "periodSpreadMax": "週期內最大價差", + "minSpread": "最小", + "maxSpread": "最大" + }, + "status": { + "triggered": "已觸發", + "periodEnded": "週期結束", + "inCondition": "滿足條件", + "waiting": "等待中" + }, + "chart": { + "title": "分時圖", + "btcTitle": "BTC 分時圖", + "marketTitle": "市場分時圖", + "priceChart": "價格分時圖", + "price": "價格", + "openPrice": "開盤價", + "spread": "價差", + "minSpreadLine": "最小價差線", + "maxSpreadLine": "最大價差線", + "marketUp": "Up", + "marketDown": "Down", + "time": "時間", + "latestPrice": "最新價", + "timeWindowStart": "區間開始", + "timeWindowEnd": "區間結束" + }, + "strategyInfo": { + "title": "策略信息", + "market": "市場", + "interval": "週期", + "account": "賬戶", + "spreadMode": "價差模式", + "spreadDirection": "價差方向" + }, + "periodSwitch": { + "mode": "週期切換模式", + "auto": "自動", + "manual": "手動", + "autoDesc": "週期結束時自動切換到最新週期", + "manualDesc": "週期結束時保留完整數據", + "switchToLatest": "切換到最新週期", + "periodEnded": "當前週期已結束", + "newPeriodAvailable": "新週期已開始" + } } } \ No newline at end of file diff --git a/frontend/src/pages/CryptoTailMonitor.tsx b/frontend/src/pages/CryptoTailMonitor.tsx new file mode 100644 index 0000000..ee47bda --- /dev/null +++ b/frontend/src/pages/CryptoTailMonitor.tsx @@ -0,0 +1,951 @@ +import { useEffect, useState, useRef, useCallback } from 'react' +import { + Card, + Select, + Space, + Statistic, + Row, + Col, + Typography, + Spin, + Empty, + Alert, + Radio, + Button, + Tooltip +} from 'antd' +import { ClockCircleOutlined, SyncOutlined, InfoCircleOutlined } from '@ant-design/icons' +import { useTranslation } from 'react-i18next' +import { useMediaQuery } from 'react-responsive' +import * as echarts from 'echarts' +import type { EChartsOption } from 'echarts' +import { apiService } from '../services/api' +import { useWebSocketSubscription } from '../hooks/useWebSocket' +import { formatNumber } from '../utils' +import type { + CryptoTailStrategyDto, + CryptoTailMonitorInitResponse, + CryptoTailMonitorPushData +} from '../types' + +const { Title, Text } = Typography + +/** 分时图数据点:时间戳、BTC 价格 USDC、市场 Up/Down 价格 0-1 */ +interface PriceDataPoint { + time: number + btcPrice: number | null + marketPriceUp: number | null + marketPriceDown: number | null +} + +const CryptoTailMonitor: React.FC = () => { + const { t } = useTranslation() + const isMobile = useMediaQuery({ maxWidth: 768 }) + + // 策略列表 + const [strategies, setStrategies] = useState([]) + const [strategiesLoading, setStrategiesLoading] = useState(false) + + // 选中的策略 + const [selectedStrategyId, setSelectedStrategyId] = useState(null) + + // 监控数据 + const [initData, setInitData] = useState(null) + const [pushData, setPushData] = useState(null) + const [initLoading, setInitLoading] = useState(false) + + // 价格历史数据(用于分时图) + const [priceHistory, setPriceHistory] = useState([]) + const chartRef = useRef(null) + const chartInstance = useRef(null) + const marketChartRef = useRef(null) + const marketChartInstance = useRef(null) + const lastPeriodStartRef = useRef(null) + + // localStorage key for period switch mode + const PERIOD_SWITCH_MODE_KEY = 'cryptoTailMonitor_periodSwitchMode' + + // 记录首次数据进入时间(用于中途进入时的横轴起点) + const [firstDataTime, setFirstDataTime] = useState(null) + // 标记是否已切换过周期(切换后使用完整周期) + const [hasSwitchedPeriod, setHasSwitchedPeriod] = useState(false) + // 周期切换模式:auto(自动切换)| manual(手动切换),从 localStorage 读取缓存 + const [periodSwitchMode, setPeriodSwitchMode] = useState<'auto' | 'manual'>(() => { + const cached = localStorage.getItem(PERIOD_SWITCH_MODE_KEY) + return (cached === 'auto' || cached === 'manual') ? cached : 'auto' + }) + // 手动模式下,存储最新周期的数据(用户未切换时) + const [pendingPeriodData, setPendingPeriodData] = useState<{ + periodStartUnix: number + priceHistory: PriceDataPoint[] + initData: CryptoTailMonitorInitResponse | null + pushData: CryptoTailMonitorPushData | null + } | null>(null) + // 标记当前是否在查看旧周期(手动模式下) + const [isViewingOldPeriod, setIsViewingOldPeriod] = useState(false) + + // 获取策略列表 + useEffect(() => { + const fetchStrategies = async () => { + setStrategiesLoading(true) + try { + const res = await apiService.cryptoTailStrategy.list({ enabled: true }) + if (res.data.code === 0 && res.data.data) { + setStrategies(res.data.data.list ?? []) + // 自动选择第一个策略 + if (res.data.data.list?.length > 0 && !selectedStrategyId) { + setSelectedStrategyId(res.data.data.list[0].id) + } + } + } catch (e) { + console.error('Failed to fetch strategies:', e) + } finally { + setStrategiesLoading(false) + } + } + fetchStrategies() + }, []) + + // 初始化监控数据 + useEffect(() => { + if (!selectedStrategyId) { + setInitData(null) + setPushData(null) + setPriceHistory([]) + setFirstDataTime(null) + setHasSwitchedPeriod(false) + setPendingPeriodData(null) + setIsViewingOldPeriod(false) + return + } + + const initMonitor = async () => { + setInitLoading(true) + setPriceHistory([]) + setFirstDataTime(null) + setHasSwitchedPeriod(false) + setPendingPeriodData(null) + setIsViewingOldPeriod(false) + try { + const res = await apiService.cryptoTailStrategy.monitorInit(selectedStrategyId) + if (res.data.code === 0 && res.data.data) { + setInitData(res.data.data) + } else { + setInitData(null) + } + } catch (e) { + console.error('Failed to init monitor:', e) + setInitData(null) + } finally { + setInitLoading(false) + } + } + initMonitor() + }, [selectedStrategyId]) + + // WebSocket 订阅 + const handlePushData = useCallback((data: CryptoTailMonitorPushData) => { + if (data.strategyId !== selectedStrategyId) return + + const btcPrice = data.currentPriceBtc != null && data.currentPriceBtc !== '' + ? parseFloat(data.currentPriceBtc) + : null + const marketUp = data.currentPriceUp != null && data.currentPriceUp !== '' + ? parseFloat(data.currentPriceUp) + : null + const marketDown = data.currentPriceDown != null && data.currentPriceDown !== '' + ? parseFloat(data.currentPriceDown) + : null + const hasBtc = btcPrice != null && !Number.isNaN(btcPrice) + const hasMarket = (marketUp != null && !Number.isNaN(marketUp)) || (marketDown != null && !Number.isNaN(marketDown)) + if (!hasBtc && !hasMarket) return + + const newPoint: PriceDataPoint = { + time: data.timestamp, + btcPrice: hasBtc ? btcPrice : null, + marketPriceUp: hasMarket && marketUp != null && !Number.isNaN(marketUp) ? marketUp : null, + marketPriceDown: hasMarket && marketDown != null && !Number.isNaN(marketDown) ? marketDown : null + } + + // 用 ref 检测周期切换,避免因依赖 initData 导致回调频繁重建 + const pushPeriod = data.periodStartUnix + const lastPeriod = lastPeriodStartRef.current + + if (pushPeriod != null && pushPeriod !== lastPeriod) { + // 新周期到来 + lastPeriodStartRef.current = pushPeriod + + if (periodSwitchMode === 'manual' && lastPeriod != null) { + // 手动模式:保存新周期数据到 pending,保留当前显示 + setPendingPeriodData({ + periodStartUnix: pushPeriod, + priceHistory: [newPoint], + initData: null, + pushData: data + }) + setIsViewingOldPeriod(true) + // 更新 pending 数据的 initData + setInitData(prev => { + if (prev) { + setPendingPeriodData(p => p ? { ...p, initData: { ...prev, periodStartUnix: pushPeriod, marketTitle: (data as { marketTitle?: string }).marketTitle ?? prev.marketTitle } } : null) + } + return prev + }) + } else { + // 自动模式或首次推送:直接切换 + if (lastPeriod != null) { + setHasSwitchedPeriod(true) + } + setFirstDataTime(newPoint.time) + setInitData(prev => prev ? { ...prev, periodStartUnix: pushPeriod, marketTitle: (data as { marketTitle?: string }).marketTitle ?? prev.marketTitle } : null) + setPriceHistory([newPoint]) + setPushData(data) + setIsViewingOldPeriod(false) + setPendingPeriodData(null) + return + } + } else { + // 同周期:追加数据 + if (periodSwitchMode === 'manual' && isViewingOldPeriod && pendingPeriodData) { + // 手动模式下,更新 pending 数据 + const minIntervalMs = 1_000 + setPendingPeriodData(prev => { + if (!prev) return null + const lastTime = prev.priceHistory.length > 0 ? prev.priceHistory[prev.priceHistory.length - 1].time : 0 + if (prev.priceHistory.length > 0 && newPoint.time - lastTime < minIntervalMs) { + return { ...prev, pushData: data } + } + const maxPoints = 300 + const newHistory = [...prev.priceHistory, newPoint].slice(-maxPoints) + return { ...prev, priceHistory: newHistory, pushData: data } + }) + } else { + setFirstDataTime(prev => { + if (prev == null) { + return newPoint.time + } + return prev + }) + const minIntervalMs = 1_000 + setPriceHistory(prev => { + const lastTime = prev.length > 0 ? prev[prev.length - 1].time : 0 + if (prev.length > 0 && newPoint.time - lastTime < minIntervalMs) { + return prev + } + const maxPoints = 300 + const newHistory = [...prev, newPoint] + return newHistory.slice(-maxPoints) + }) + setPushData(data) + } + } + }, [selectedStrategyId, periodSwitchMode, isViewingOldPeriod, pendingPeriodData]) + + // 手动切换到最新周期 + const handleSwitchToLatestPeriod = useCallback(() => { + if (pendingPeriodData) { + setPriceHistory(pendingPeriodData.priceHistory) + setFirstDataTime(pendingPeriodData.priceHistory[0]?.time ?? null) + if (pendingPeriodData.initData) { + setInitData(pendingPeriodData.initData) + } + if (pendingPeriodData.pushData) { + setPushData(pendingPeriodData.pushData) + } + setHasSwitchedPeriod(true) + setIsViewingOldPeriod(false) + setPendingPeriodData(null) + } + }, [pendingPeriodData]) + + const channel = selectedStrategyId ? `crypto_tail_monitor_${selectedStrategyId}` : '' + useWebSocketSubscription(channel, handlePushData) + + // 图表容器仅在 initData 存在时渲染,故在更新图表时懒初始化 + useEffect(() => { + const handleResize = () => { + chartInstance.current?.resize() + marketChartInstance.current?.resize() + } + window.addEventListener('resize', handleResize) + return () => { + window.removeEventListener('resize', handleResize) + chartInstance.current?.dispose() + chartInstance.current = null + marketChartInstance.current?.dispose() + marketChartInstance.current = null + } + }, []) + + // 切换策略时销毁并重新初始化图表实例 + useEffect(() => { + if (chartInstance.current) { + chartInstance.current.dispose() + chartInstance.current = null + } + if (marketChartInstance.current) { + marketChartInstance.current.dispose() + marketChartInstance.current = null + } + }, [selectedStrategyId]) + + // 更新图表:分时图为 BTC 价格 USDC + useEffect(() => { + if (!initData) return + if (chartRef.current && !chartInstance.current) { + chartInstance.current = echarts.init(chartRef.current) + } + if (!chartInstance.current) return + + const periodStartMs = (initData.periodStartUnix ?? 0) * 1000 + const periodEndMs = periodStartMs + (initData.intervalSeconds ?? 300) * 1000 + + // data.timestamp 为毫秒,firstDataTime 已是 ms,无需再乘 1000 + const firstDataMs = firstDataTime != null ? firstDataTime : null + const isMidEntry = firstDataMs != null && !hasSwitchedPeriod && firstDataMs > periodStartMs + // 中途进入时横轴起点为进入时刻,否则为周期起点 + const xAxisMin = isMidEntry ? firstDataMs : periodStartMs + + const btcData: [number, number | null][] = priceHistory.length > 0 + ? priceHistory.map(p => [p.time, p.btcPrice]) + : [] + const openBtc = pushData?.openPriceBtc ?? initData.openPriceBtc + const openBtcNum = openBtc != null ? parseFloat(openBtc) : null + + const hasAnyBtcData = btcData.some(([, v]) => v != null && !Number.isNaN(v)) + const btcPlaceholderTime = xAxisMin + const displayBtcData: [number, number | null][] = hasAnyBtcData + ? btcData + : (openBtcNum != null ? [[btcPlaceholderTime, openBtcNum]] : []) + const minSpreadUpRaw = pushData?.minSpreadLineUp ?? initData.autoMinSpreadUp + const minSpreadDownRaw = pushData?.minSpreadLineDown ?? initData.autoMinSpreadDown + const minSpreadUp = minSpreadUpRaw != null && minSpreadUpRaw !== '' ? parseFloat(minSpreadUpRaw) : null + const minSpreadDown = minSpreadDownRaw != null && minSpreadDownRaw !== '' ? parseFloat(minSpreadDownRaw) : null + + const validPrices = displayBtcData.flatMap(([, v]) => (v != null && !Number.isNaN(v) ? [v] : [])) + const defaultRange = 500 + let yMin: number | undefined + let yMax: number | undefined + if (validPrices.length > 0) { + const dataMin = Math.min(...validPrices) + const dataMax = Math.max(...validPrices) + const dataRange = dataMax - dataMin + const minRange = Math.max(Math.abs(dataMax) * 0.01, 10) + const range = Math.max(dataRange, minRange) + const padding = range * 0.25 + yMin = dataMin - padding + yMax = dataMax + padding + } else if (openBtcNum != null) { + const spread = minSpreadUp ?? minSpreadDown ?? defaultRange + const halfRange = spread * 1.5 + yMin = openBtcNum - halfRange + yMax = openBtcNum + halfRange + } + + const markLineData: Array<{ name?: string; yAxis?: number; xAxis?: number; lineStyle: { type: 'dashed' | 'solid'; color: string }; label?: { show: boolean; formatter?: string }; emphasis?: { label?: { show?: boolean; formatter?: string } } }> = [] + if (openBtcNum != null && !Number.isNaN(openBtcNum)) { + markLineData.push({ + name: t('cryptoTailMonitor.chart.openPrice'), + yAxis: openBtcNum, + lineStyle: { type: 'dashed', color: '#999' } + }) + } + const isMaxSpread = (initData.spreadDirection ?? 'MIN') === 'MAX' + const spreadLineLabelKey = isMaxSpread ? 'cryptoTailMonitor.chart.maxSpreadLine' : 'cryptoTailMonitor.chart.minSpreadLine' + if (openBtcNum != null && minSpreadUp != null && !Number.isNaN(minSpreadUp)) { + markLineData.push({ + name: t(spreadLineLabelKey) + ' Up', + yAxis: openBtcNum + minSpreadUp, + lineStyle: { type: 'dashed', color: '#ff4d4f' } + }) + } + if (openBtcNum != null && minSpreadDown != null && !Number.isNaN(minSpreadDown)) { + markLineData.push({ + name: t(spreadLineLabelKey) + ' Down', + yAxis: openBtcNum - minSpreadDown, + lineStyle: { type: 'dashed', color: '#ff4d4f' } + }) + } + // 时间窗口两条竖线:灰色虚线,悬停时显示标签 + const windowStartMs = periodStartMs + (initData.windowStartSeconds ?? 0) * 1000 + const windowEndMs = periodStartMs + (initData.windowEndSeconds ?? 0) * 1000 + if (windowStartMs > xAxisMin && windowStartMs < periodEndMs) { + markLineData.push({ + xAxis: windowStartMs, + lineStyle: { type: 'dashed', color: 'rgba(128, 128, 128, 0.9)' }, + label: { show: false }, + emphasis: { label: { show: true, formatter: t('cryptoTailMonitor.chart.timeWindowStart') } } + }) + } + if (windowEndMs > xAxisMin && windowEndMs < periodEndMs && windowEndMs !== windowStartMs) { + markLineData.push({ + xAxis: windowEndMs, + lineStyle: { type: 'dashed', color: 'rgba(128, 128, 128, 0.9)' }, + label: { show: false }, + emphasis: { label: { show: true, formatter: t('cryptoTailMonitor.chart.timeWindowEnd') } } + }) + } + + const periodStartUnixSec = initData.periodStartUnix ?? 0 + const option: EChartsOption = { + tooltip: { + trigger: 'axis', + confine: true, + padding: [6, 8], + formatter: (params: unknown) => { + const arr = params as Array<{ seriesName: string; name: string | number; value: number | [number, number]; axisValue?: number }> + const priceParam = arr.find(p => p.seriesName === t('cryptoTailMonitor.chart.price')) + if (!priceParam) return '' + const val = Array.isArray(priceParam.value) ? priceParam.value[1] : priceParam.value + if (val == null || Number.isNaN(val)) return '' + // 优先从 value[0] 取时间戳(毫秒),否则用 axisValue 或 name + const rawTime = Array.isArray(priceParam.value) + ? priceParam.value[0] + : (priceParam.axisValue ?? priceParam.name) + let timeStr = '' + if (typeof rawTime === 'number' && !Number.isNaN(rawTime)) { + const offsetSec = Math.floor(rawTime / 1000) - periodStartUnixSec + const mins = Math.floor(offsetSec / 60) + const secs = Math.abs(offsetSec) % 60 + timeStr = `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}` + } else if (rawTime != null && rawTime !== '') { + timeStr = String(rawTime) + } else { + timeStr = '--' + } + return `${timeStr}   ${Number(val).toFixed(2)} USDC` + } + }, + legend: { + show: true, + top: 0 + }, + grid: { + left: '3%', + right: '4%', + bottom: '3%', + top: '12%', + containLabel: true + }, + xAxis: { + type: 'time', + min: xAxisMin, + max: periodEndMs, + axisLabel: { + formatter: (val: number) => { + const offsetSec = Math.floor(val / 1000) - periodStartUnixSec + const mins = Math.floor(offsetSec / 60) + const secs = Math.abs(offsetSec) % 60 + return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}` + } + } + }, + yAxis: { + type: 'value', + scale: true, + min: yMin, + max: yMax, + axisLabel: { + formatter: (value: number) => value.toFixed(0) + } + }, + series: [ + { + name: t('cryptoTailMonitor.chart.price'), + type: 'line', + data: displayBtcData, + smooth: true, + symbol: displayBtcData.length === 1 ? 'circle' : 'none', + symbolSize: 4, + lineStyle: { width: 2, color: '#1890ff' }, + areaStyle: { + color: { + type: 'linear', + x: 0, + y: 0, + x2: 0, + y2: 1, + colorStops: [ + { offset: 0, color: 'rgba(24, 144, 255, 0.3)' }, + { offset: 1, color: 'rgba(24, 144, 255, 0.05)' } + ] + } + }, + markLine: markLineData.length > 0 ? { symbol: ['none', 'none'], data: markLineData } : undefined, + // 添加满足条件的价差区域(浅绿色背景) + markArea: (() => { + if (openBtcNum == null) return undefined + const areas: Array<[{ yAxis: number }, { yAxis: number }]> = [] + if (isMaxSpread) { + // 最大价差:价差 <= 配置值触发,满足条件为靠近开盘价的带状区域 + if (minSpreadUp != null && !Number.isNaN(minSpreadUp)) { + areas.push([ + { yAxis: openBtcNum }, + { yAxis: openBtcNum + minSpreadUp } + ]) + } + if (minSpreadDown != null && !Number.isNaN(minSpreadDown)) { + areas.push([ + { yAxis: openBtcNum - minSpreadDown }, + { yAxis: openBtcNum } + ]) + } + } else { + // 最小价差:价差 >= 配置值触发,满足条件为远离开盘价的两侧 + if (minSpreadUp != null && !Number.isNaN(minSpreadUp)) { + areas.push([ + { yAxis: openBtcNum + minSpreadUp }, + { yAxis: yMax ?? openBtcNum + minSpreadUp * 2 } + ]) + } + if (minSpreadDown != null && !Number.isNaN(minSpreadDown)) { + areas.push([ + { yAxis: yMin ?? openBtcNum - minSpreadDown * 2 }, + { yAxis: openBtcNum - minSpreadDown } + ]) + } + } + return areas.length > 0 ? { + silent: true, + data: areas, + itemStyle: { color: 'rgba(82, 196, 26, 0.12)' } + } : undefined + })() + } + ] + } + + chartInstance.current.setOption(option, true) + chartInstance.current.resize() + }, [priceHistory, initData, pushData, firstDataTime, hasSwitchedPeriod, t]) + + // 更新市场分时图:Polymarket 价格 0-1 + useEffect(() => { + if (!initData) return + if (marketChartRef.current && !marketChartInstance.current) { + marketChartInstance.current = echarts.init(marketChartRef.current) + } + if (!marketChartInstance.current) return + + const periodStartMs = (initData.periodStartUnix ?? 0) * 1000 + const periodEndMs = periodStartMs + (initData.intervalSeconds ?? 300) * 1000 + + // data.timestamp 为毫秒,firstDataTime 已是 ms,无需再乘 1000 + const firstDataMs = firstDataTime != null ? firstDataTime : null + const isMidEntry = firstDataMs != null && !hasSwitchedPeriod && firstDataMs > periodStartMs + const xAxisMin = isMidEntry ? firstDataMs : periodStartMs + + const toMs = (t: number) => (t > 0 && t < 1e12 ? t * 1000 : t) + let marketUpData: [number, number | null][] = priceHistory.length > 0 + ? priceHistory.map(p => [toMs(p.time), p.marketPriceUp]) + : [] + let marketDownData: [number, number | null][] = priceHistory.length > 0 + ? priceHistory.map(p => [toMs(p.time), p.marketPriceDown]) + : [] + + // 若推送有最新价且与当前周期一致,追加到末端使曲线显示到最新价格 + if (pushData && pushData.periodStartUnix === (initData.periodStartUnix ?? 0)) { + const ts = pushData.timestamp + const lastTime = marketUpData.length > 0 ? marketUpData[marketUpData.length - 1][0] : 0 + const tsMs = ts > 0 && ts < 1e12 ? ts * 1000 : ts + if (tsMs >= lastTime) { + const up = pushData.currentPriceUp != null && pushData.currentPriceUp !== '' ? parseFloat(pushData.currentPriceUp) : null + const down = pushData.currentPriceDown != null && pushData.currentPriceDown !== '' ? parseFloat(pushData.currentPriceDown) : null + const upVal = up != null && !Number.isNaN(up) ? up : (down != null && !Number.isNaN(down) ? 1 - down : null) + const downVal = down != null && !Number.isNaN(down) ? down : (up != null && !Number.isNaN(up) ? 1 - up : null) + if (upVal != null) marketUpData = [...marketUpData, [tsMs, upVal]] + if (downVal != null) marketDownData = [...marketDownData, [tsMs, downVal]] + } + } + + const minPrice = parseFloat(initData.minPrice) + const maxPrice = parseFloat(initData.maxPrice) + const midPrice = (minPrice + maxPrice) / 2 + const isValid = (v: number | null): v is number => v != null && !Number.isNaN(v) + const validUp: [number, number][] = marketUpData.filter((point): point is [number, number] => isValid(point[1])) + const validDown: [number, number][] = marketDownData.filter((point): point is [number, number] => isValid(point[1])) + const hasAnyMarketData = validUp.length > 0 || validDown.length > 0 + const placeholderTime = xAxisMin + const finalMarketUp: [number, number][] = hasAnyMarketData ? validUp : [[placeholderTime, midPrice]] + const finalMarketDown: [number, number][] = hasAnyMarketData ? validDown : [[placeholderTime, midPrice]] + + const periodStartUnixSec = initData.periodStartUnix ?? 0 + const windowStartMs = periodStartMs + (initData.windowStartSeconds ?? 0) * 1000 + const windowEndMs = periodStartMs + (initData.windowEndSeconds ?? 0) * 1000 + const timeWindowMarkLine: Array<{ xAxis: number; lineStyle: { type: 'dashed'; color: string }; label: { show: boolean }; emphasis: { label: { show: boolean; formatter: string } } }> = [] + if (windowStartMs > xAxisMin && windowStartMs < periodEndMs) { + timeWindowMarkLine.push({ + xAxis: windowStartMs, + lineStyle: { type: 'dashed', color: 'rgba(128, 128, 128, 0.9)' }, + label: { show: false }, + emphasis: { label: { show: true, formatter: t('cryptoTailMonitor.chart.timeWindowStart') } } + }) + } + if (windowEndMs > xAxisMin && windowEndMs < periodEndMs && windowEndMs !== windowStartMs) { + timeWindowMarkLine.push({ + xAxis: windowEndMs, + lineStyle: { type: 'dashed', color: 'rgba(128, 128, 128, 0.9)' }, + label: { show: false }, + emphasis: { label: { show: true, formatter: t('cryptoTailMonitor.chart.timeWindowEnd') } } + }) + } + const option: EChartsOption = { + tooltip: { + trigger: 'axis', + formatter: (params: unknown) => { + const arr = params as Array<{ seriesName: string; name: string | number; value: number | [number, number]; axisValue?: number }> + const upParam = arr.find(p => p.seriesName === t('cryptoTailMonitor.chart.marketUp')) + const downParam = arr.find(p => p.seriesName === t('cryptoTailMonitor.chart.marketDown')) + const firstParam = arr[0] + const rawTime = firstParam && Array.isArray(firstParam.value) + ? firstParam.value[0] + : (firstParam?.axisValue ?? firstParam?.name) + let timeStr = '' + if (typeof rawTime === 'number' && !Number.isNaN(rawTime)) { + const offsetSec = Math.floor(rawTime / 1000) - periodStartUnixSec + const mins = Math.floor(offsetSec / 60) + const secs = Math.abs(offsetSec) % 60 + timeStr = `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}` + } else if (rawTime != null && rawTime !== '') { + timeStr = String(rawTime) + } else { + timeStr = '--' + } + let html = `
${t('cryptoTailMonitor.chart.time')}: ${timeStr}
` + const upVal = Array.isArray(upParam?.value) ? upParam?.value[1] : upParam?.value + const downVal = Array.isArray(downParam?.value) ? downParam?.value[1] : downParam?.value + if (upVal != null && !Number.isNaN(upVal)) html += `
Up: ${Number(upVal).toFixed(4)}
` + if (downVal != null && !Number.isNaN(downVal)) html += `
Down: ${Number(downVal).toFixed(4)}
` + html += '
' + return html + } + }, + legend: { + show: true, + top: 0, + data: [t('cryptoTailMonitor.chart.marketUp'), t('cryptoTailMonitor.chart.marketDown')] + }, + grid: { + left: '3%', + right: '4%', + bottom: '3%', + top: '15%', + containLabel: true + }, + xAxis: { + type: 'time', + min: xAxisMin, + max: periodEndMs, + axisLabel: { + formatter: (val: number) => { + const offsetSec = Math.floor(val / 1000) - periodStartUnixSec + const mins = Math.floor(offsetSec / 60) + const secs = Math.abs(offsetSec) % 60 + return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}` + } + } + }, + yAxis: { + type: 'value', + min: 0, + max: 1, + interval: 0.2, + axisLabel: { formatter: (v: number) => v.toFixed(1) } + }, + series: [ + { + name: t('cryptoTailMonitor.chart.marketUp'), + type: 'line', + data: finalMarketUp, + smooth: true, + symbol: 'circle', + symbolSize: 4, + showSymbol: true, + connectNulls: true, + lineStyle: { width: 2, color: '#1890ff' }, + itemStyle: { color: '#1890ff' }, + markArea: { + silent: true, + itemStyle: { color: 'rgba(82, 196, 26, 0.12)' }, + data: [[{ yAxis: minPrice }, { yAxis: maxPrice }]] + }, + markLine: timeWindowMarkLine.length > 0 ? { symbol: ['none', 'none'], data: timeWindowMarkLine } : undefined + }, + { + name: t('cryptoTailMonitor.chart.marketDown'), + type: 'line', + data: finalMarketDown, + smooth: true, + symbol: 'circle', + symbolSize: 4, + showSymbol: true, + connectNulls: true, + lineStyle: { width: 2, color: '#fa8c16' }, + itemStyle: { color: '#fa8c16' } + } + ] + } + + marketChartInstance.current.setOption(option, true) + marketChartInstance.current.resize() + }, [priceHistory, initData, pushData, firstDataTime, hasSwitchedPeriod, t]) + + // 格式化剩余时间 + const formatRemainingTime = (seconds: number): string => { + const mins = Math.floor(seconds / 60) + const secs = seconds % 60 + return `${mins}:${secs.toString().padStart(2, '0')}` + } + + // 显示 BTC 价格(最新价、价差、开盘价均为 USDC) + const openPrice = pushData?.openPriceBtc ?? initData?.openPriceBtc + const currentPrice = pushData?.currentPriceBtc + const currentSpread = pushData?.spreadBtc + const minSpreadUpStr = pushData?.minSpreadLineUp ?? initData?.autoMinSpreadUp + const minSpreadDownStr = pushData?.minSpreadLineDown ?? initData?.autoMinSpreadDown + const minSpreadUpVal = minSpreadUpStr != null && minSpreadUpStr !== '' ? parseFloat(minSpreadUpStr) : null + const minSpreadDownVal = minSpreadDownStr != null && minSpreadDownStr !== '' ? parseFloat(minSpreadDownStr) : null + const minSpreadLineNum = [minSpreadUpVal, minSpreadDownVal].filter((v): v is number => v != null && !Number.isNaN(v)) + const spreadBelowThreshold = currentSpread != null && currentSpread !== '' && minSpreadLineNum.length > 0 && + parseFloat(currentSpread) < Math.min(...minSpreadLineNum) + + return ( +
+ + {t('cryptoTailMonitor.title')} + + + {/* 顶部控制区 */} + + + + {t('cryptoTailMonitor.selectStrategy')} +