feat(cryptotail): 尾盘监控双连接与分时图优化

- 监控 WebSocket 拆分为当前周期连接与下一周期连接,周期切换时关闭过期连接并新建下一周期
- 下一周期市场未创建时也建立第二条空连接,保证始终两条连接
- refreshSubscription 增加 Mutex 防重入,避免周期结束时定时器与消息同时触发导致重复执行
- 修复 initMonitor/buildPushData 中 getCurrentOpenClose、spreadMode/spreadValue 等 API 与实体字段引用
- 移除重复的 buildSubscriptionMap、buildPushData 等方法,修复 StrategyPriceData.periodStartUnix
- 前端分时图:市场价折线增加 connectNulls,新周期默认 0.5 价格展示
- 多语言与监控页入口、API 类型与 WebSocket 订阅集成

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WrBug
2026-02-25 17:00:41 +08:00
co-authored by Cursor
parent d3196a783f
commit 84c79d8812
18 changed files with 2162 additions and 59 deletions
@@ -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)
}
}
@@ -11,9 +11,12 @@ import com.wrbug.polymarketbot.dto.CryptoTailStrategyTriggerListResponse
import com.wrbug.polymarketbot.dto.CryptoTailStrategyUpdateRequest import com.wrbug.polymarketbot.dto.CryptoTailStrategyUpdateRequest
import com.wrbug.polymarketbot.dto.CryptoTailMarketOptionDto import com.wrbug.polymarketbot.dto.CryptoTailMarketOptionDto
import com.wrbug.polymarketbot.dto.CryptoTailAutoMinSpreadResponse 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.enums.ErrorCode
import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService
import com.wrbug.polymarketbot.service.cryptotail.CryptoTailStrategyService import com.wrbug.polymarketbot.service.cryptotail.CryptoTailStrategyService
import com.wrbug.polymarketbot.service.cryptotail.CryptoTailMonitorService
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource import org.springframework.context.MessageSource
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
@@ -26,6 +29,7 @@ import org.springframework.web.bind.annotation.RestController
@RequestMapping("/api/crypto-tail-strategy") @RequestMapping("/api/crypto-tail-strategy")
class CryptoTailStrategyController( class CryptoTailStrategyController(
private val cryptoTailStrategyService: CryptoTailStrategyService, private val cryptoTailStrategyService: CryptoTailStrategyService,
private val cryptoTailMonitorService: CryptoTailMonitorService,
private val binanceKlineAutoSpreadService: BinanceKlineAutoSpreadService, private val binanceKlineAutoSpreadService: BinanceKlineAutoSpreadService,
private val messageSource: MessageSource private val messageSource: MessageSource
) { ) {
@@ -173,7 +177,7 @@ class CryptoTailStrategyController(
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, messageSource = messageSource)) return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, messageSource = messageSource))
} }
val periodStartUnix = (request["periodStartUnix"] as? Number)?.toLong() val periodStartUnix = (request["periodStartUnix"] as? Number)?.toLong()
?: (System.currentTimeMillis() / 1000 / intervalSeconds) * intervalSeconds ?: ((System.currentTimeMillis() / 1000 / intervalSeconds) * intervalSeconds)
// 默认使用 BTC 市场(向后兼容) // 默认使用 BTC 市场(向后兼容)
val marketSlugPrefix = (request["marketSlugPrefix"] as? String) ?: "btc-updown" val marketSlugPrefix = (request["marketSlugPrefix"] as? String) ?: "btc-updown"
val pair = binanceKlineAutoSpreadService.computeAndCache(marketSlugPrefix, intervalSeconds, periodStartUnix) val pair = binanceKlineAutoSpreadService.computeAndCache(marketSlugPrefix, intervalSeconds, periodStartUnix)
@@ -188,4 +192,28 @@ class CryptoTailStrategyController(
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource)) ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
} }
} }
/**
* 初始化尾盘策略监控
* 返回策略信息、开盘价、tokenIds等初始化数据
*/
@PostMapping("/monitor/init")
fun initMonitor(@RequestBody request: CryptoTailMonitorInitRequest): ResponseEntity<ApiResponse<CryptoTailMonitorInitResponse>> {
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))
}
}
} }
@@ -0,0 +1,103 @@
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,
/** 当前价格 (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 价差 USDCcurrentPriceBtc - 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
)
@@ -125,8 +125,8 @@ class AccountService(
// 7. 加密敏感信息 // 7. 加密敏感信息
val encryptedPrivateKey = cryptoUtils.encrypt(request.privateKey) val encryptedPrivateKey = cryptoUtils.encrypt(request.privateKey)
val encryptedApiSecret = apiKeyCreds.secret?.let { cryptoUtils.encrypt(it) } val encryptedApiSecret = apiKeyCreds.secret.let { cryptoUtils.encrypt(it) }
val encryptedApiPassphrase = apiKeyCreds.passphrase?.let { cryptoUtils.encrypt(it) } val encryptedApiPassphrase = apiKeyCreds.passphrase.let { cryptoUtils.encrypt(it) }
// 8. 生成账户名称(如果未提供,使用 SAFE/MAGIC-代理地址后4位) // 8. 生成账户名称(如果未提供,使用 SAFE/MAGIC-代理地址后4位)
val accountName = if (request.accountName.isNullOrBlank()) { val accountName = if (request.accountName.isNullOrBlank()) {
@@ -518,8 +518,8 @@ class AccountService(
} }
val creds = result.getOrNull() val creds = result.getOrNull()
?: return Result.failure(IllegalStateException("API Key 返回为空")) ?: return Result.failure(IllegalStateException("API Key 返回为空"))
val encryptedSecret = creds.secret?.let { cryptoUtils.encrypt(it) } val encryptedSecret = creds.secret.let { cryptoUtils.encrypt(it) }
val encryptedPassphrase = creds.passphrase?.let { cryptoUtils.encrypt(it) } val encryptedPassphrase = creds.passphrase.let { cryptoUtils.encrypt(it) }
val updated = account.copy( val updated = account.copy(
apiKey = creds.apiKey, apiKey = creds.apiKey,
apiSecret = encryptedSecret, apiSecret = encryptedSecret,
@@ -1128,7 +1128,7 @@ class AccountService(
// 3. 验证仓位是否存在并获取原始数量 // 3. 验证仓位是否存在并获取原始数量
val positionsResult = getAllPositions() val positionsResult = getAllPositions()
val (position, originalQuantity) = positionsResult.fold( val (_, originalQuantity) = positionsResult.fold(
onSuccess = { positionListResponse -> onSuccess = { positionListResponse ->
val position = positionListResponse.currentPositions.find { val position = positionListResponse.currentPositions.find {
it.accountId == request.accountId && it.accountId == request.accountId &&
@@ -1161,7 +1161,7 @@ class AccountService(
onFailure = { e -> onFailure = { e ->
return Result.failure(Exception("查询仓位失败: ${e.message}")) return Result.failure(Exception("查询仓位失败: ${e.message}"))
} }
) ?: return Result.failure(IllegalArgumentException("仓位不存在")) )
// 4. 计算实际卖出数量 // 4. 计算实际卖出数量
val sellQuantity = if (percentDecimal != null) { val sellQuantity = if (percentDecimal != null) {
@@ -1280,7 +1280,7 @@ class AccountService(
val newOrderRequest = com.wrbug.polymarketbot.api.NewOrderRequest( val newOrderRequest = com.wrbug.polymarketbot.api.NewOrderRequest(
order = signedOrder, order = signedOrder,
owner = account.apiKey!!, // API Key owner = account.apiKey, // API Key
orderType = orderType, orderType = orderType,
deferExec = false deferExec = false
) )
@@ -1300,7 +1300,7 @@ class AccountService(
} }
val clobApi = retrofitFactory.createClobApi( val clobApi = retrofitFactory.createClobApi(
account.apiKey!!, account.apiKey,
apiSecret, apiSecret,
apiPassphrase, apiPassphrase,
account.walletAddress account.walletAddress
@@ -29,7 +29,9 @@ class BinanceKlineService {
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private val wsBase = "wss://stream.binance.com:9443" 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) */ /** (marketSlugPrefix, intervalSeconds, periodStartUnix) -> (open, close) */
private val openCloseByPeriod = ConcurrentHashMap<String, Pair<BigDecimal, BigDecimal>>() private val openCloseByPeriod = ConcurrentHashMap<String, Pair<BigDecimal, BigDecimal>>()
@@ -82,6 +84,7 @@ class BinanceKlineService {
*/ */
fun updateSubscriptions(marketPrefixes: Set<String>) { fun updateSubscriptions(marketPrefixes: Set<String>) {
val normalized = marketPrefixes.map { it.lowercase() }.toSet() val normalized = marketPrefixes.map { it.lowercase() }.toSet()
val parsed = normalized.mapNotNull { full -> val parsed = normalized.mapNotNull { full ->
parseMarketSlug(full)?.let { (base, interval) -> parseMarketSlug(full)?.let { (base, interval) ->
getSymbol(base)?.let { symbol -> Triple(full, symbol, interval) } getSymbol(base)?.let { symbol -> Triple(full, symbol, interval) }
@@ -123,14 +126,14 @@ class BinanceKlineService {
else -> 300 else -> 300
} }
val request = Request.Builder().url(url).build() 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) { override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
connectedWebSockets[wsKey] = webSocket connectedWebSockets[wsKey] = webSocket
logger.info("币安 K 线 WS 已连接: $streamName") logger.info("币安 K 线 WS 已连接: $streamName")
} }
override fun onMessage(webSocket: WebSocket, text: String) { 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) onKline(marketPrefix, intervalSeconds, tMs, o, c)
} }
} }
@@ -152,7 +155,7 @@ class BinanceKlineService {
}) })
} }
private fun parseKlineMessage(text: String, intervalSeconds: Int): Triple<Long, BigDecimal, BigDecimal>? { private fun parseKlineMessage(text: String): Triple<Long, BigDecimal, BigDecimal>? {
return try { return try {
val json = com.google.gson.JsonParser.parseString(text).asJsonObject val json = com.google.gson.JsonParser.parseString(text).asJsonObject
if (json.get("e")?.asString != "kline") return null if (json.get("e")?.asString != "kline") return null
@@ -176,6 +179,8 @@ class BinanceKlineService {
connectedWebSockets.values.forEach { it.close(1000, "reconnect") } connectedWebSockets.values.forEach { it.close(1000, "reconnect") }
connectedWebSockets.clear() connectedWebSockets.clear()
logger.info("币安 K 线 WS 尝试重连") logger.info("币安 K 线 WS 尝试重连")
// 清空 requiredMarketPrefixes,否则 updateSubscriptions(current) 内会因 normalized == requiredMarketPrefixes.get() 直接 return,不会重新 connectStream
requiredMarketPrefixes.set(emptySet())
updateSubscriptions(current) updateSubscriptions(current)
} }
} }
@@ -1,11 +1,13 @@
package com.wrbug.polymarketbot.service.common package com.wrbug.polymarketbot.service.common
import com.wrbug.polymarketbot.dto.CryptoTailMonitorPushData
import com.wrbug.polymarketbot.dto.OrderPushMessage import com.wrbug.polymarketbot.dto.OrderPushMessage
import com.wrbug.polymarketbot.dto.PositionPushMessage import com.wrbug.polymarketbot.dto.PositionPushMessage
import com.wrbug.polymarketbot.dto.WebSocketMessage as WsMessage import com.wrbug.polymarketbot.dto.WebSocketMessage as WsMessage
import com.wrbug.polymarketbot.dto.WebSocketMessageType import com.wrbug.polymarketbot.dto.WebSocketMessageType
import com.wrbug.polymarketbot.service.accounts.PositionPushService import com.wrbug.polymarketbot.service.accounts.PositionPushService
import com.wrbug.polymarketbot.service.copytrading.orders.OrderPushService import com.wrbug.polymarketbot.service.copytrading.orders.OrderPushService
import com.wrbug.polymarketbot.service.cryptotail.CryptoTailMonitorService
import kotlinx.coroutines.* import kotlinx.coroutines.*
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@@ -38,12 +40,26 @@ class WebSocketSubscriptionService(
// 存储 order 频道的订阅回调:sessionId -> callback(用于取消订阅) // 存储 order 频道的订阅回调:sessionId -> callback(用于取消订阅)
private val orderChannelCallbacks = ConcurrentHashMap<String, (OrderPushMessage) -> Unit>() private val orderChannelCallbacks = ConcurrentHashMap<String, (OrderPushMessage) -> Unit>()
// 存储尾盘监控频道的订阅回调:sessionId -> (strategyId -> callback)
private val monitorChannelCallbacks = ConcurrentHashMap<String, MutableMap<Long, (CryptoTailMonitorPushData) -> Unit>>()
// 尾盘监控服务(延迟注入,避免循环依赖)
private var cryptoTailMonitorService: CryptoTailMonitorService? = null
/**
* 设置尾盘监控服务(由 Spring 在初始化后调用)
*/
fun setCryptoTailMonitorService(service: CryptoTailMonitorService) {
cryptoTailMonitorService = service
}
/** /**
* 注册会话 * 注册会话
*/ */
fun registerSession(sessionId: String, callback: (WsMessage) -> Unit) { fun registerSession(sessionId: String, callback: (WsMessage) -> Unit) {
sessionCallbacks[sessionId] = callback sessionCallbacks[sessionId] = callback
sessionSubscriptions[sessionId] = mutableSetOf() sessionSubscriptions[sessionId] = mutableSetOf()
monitorChannelCallbacks[sessionId] = mutableMapOf()
} }
/** /**
@@ -60,6 +76,12 @@ class WebSocketSubscriptionService(
// 清理 order 频道的回调 // 清理 order 频道的回调
orderChannelCallbacks.remove(sessionId) orderChannelCallbacks.remove(sessionId)
// 清理尾盘监控频道的回调
val monitorCallbacks = monitorChannelCallbacks.remove(sessionId)
monitorCallbacks?.keys?.forEach { strategyId ->
cryptoTailMonitorService?.unsubscribe(sessionId, strategyId)
}
sessionCallbacks.remove(sessionId) sessionCallbacks.remove(sessionId)
} }
@@ -83,8 +105,8 @@ class WebSocketSubscriptionService(
sendSubscribeAck(sessionId, channel, true) sendSubscribeAck(sessionId, channel, true)
// 根据频道类型启动推送服务 // 根据频道类型启动推送服务
when (channel) { when {
"position" -> { channel == "position" -> {
positionPushService.subscribe(sessionId) { message -> positionPushService.subscribe(sessionId) { message ->
pushData(sessionId, channel, message) pushData(sessionId, channel, message)
} }
@@ -97,7 +119,7 @@ class WebSocketSubscriptionService(
} }
} }
} }
"order" -> { channel == "order" -> {
// 订单推送:自动订阅所有启用的账户 // 订单推送:自动订阅所有启用的账户
val callback: (OrderPushMessage) -> Unit = { message -> val callback: (OrderPushMessage) -> Unit = { message ->
pushData(sessionId, channel, message) pushData(sessionId, channel, message)
@@ -105,6 +127,20 @@ class WebSocketSubscriptionService(
orderChannelCallbacks[sessionId] = callback orderChannelCallbacks[sessionId] = callback
orderPushService.subscribeAllEnabled(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 -> { else -> {
logger.warn("未知的频道: $channel") logger.warn("未知的频道: $channel")
sendSubscribeAck(sessionId, channel, false, "未知的频道") sendSubscribeAck(sessionId, channel, false, "未知的频道")
@@ -122,15 +158,58 @@ class WebSocketSubscriptionService(
channelSubscriptions[channel]?.remove(sessionId) channelSubscriptions[channel]?.remove(sessionId)
// 取消推送服务的订阅(推送服务内部会处理是否停止轮询) // 取消推送服务的订阅(推送服务内部会处理是否停止轮询)
when (channel) { when {
"position" -> positionPushService.unsubscribe(sessionId) channel == "position" -> positionPushService.unsubscribe(sessionId)
"order" -> { channel == "order" -> {
// 取消订阅所有账户的订单推送 // 取消订阅所有账户的订单推送
val callback = orderChannelCallbacks.remove(sessionId) val callback = orderChannelCallbacks.remove(sessionId)
if (callback != null) { if (callback != null) {
orderPushService.unsubscribeAll(callback) 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 +247,3 @@ class WebSocketSubscriptionService(
} }
} }
} }
@@ -0,0 +1,781 @@
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.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<Map<String, List<MonitorEntry>>>(emptyMap())
/** 下一周期 token 映射 */
private val nextPeriodTokenToStrategy = AtomicReference<Map<String, List<MonitorEntry>>>(emptyMap())
/** strategyId -> 当前价格数据 */
private val strategyPriceData = ConcurrentHashMap<Long, StrategyPriceData>()
/** strategyId -> 订阅者数量 */
private val strategySubscribers = ConcurrentHashMap<Long, Int>()
private var currentPeriodWebSocket: WebSocket? = null
private var nextPeriodWebSocket: WebSocket? = null
private val wsUrl = PolymarketConstants.RTDS_WS_URL + "/ws/market"
private val client = OkHttpClient.Builder().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<Long, MutableList<CryptoTailMonitorPushData>>()
private val strategyHistoryPeriod = ConcurrentHashMap<Long, Long>()
private val maxHistorySize = 300
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<CryptoTailMonitorInitResponse> {
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,
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
logger.info("策略 $strategyId 订阅数: $count")
// 注册推送回调
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 count = strategySubscribers.merge(strategyId, -1) { old, dec -> (old - dec).coerceAtLeast(0) } ?: 0
logger.info("策略 $strategyId 订阅数: $count")
// 移除回调
webSocketSubscriptionService.unregisterMonitorCallback(sessionId, strategyId)
// 如果没有订阅者,关闭 WebSocket 和定时推送
if (count == 0) {
strategySubscribers.remove(strategyId)
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()) {
logger.debug("refreshSubscription 正在执行,跳过本次并发调用")
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<Long>): Pair<List<String>, Map<String, List<MonitorEntry>>> {
val strategies = strategyRepository.findAllById(strategyIds)
val nowSeconds = System.currentTimeMillis() / 1000
val tokenIdSet = mutableSetOf<String>()
val map = mutableMapOf<String, MutableList<MonitorEntry>>()
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
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<Long>): Pair<List<String>, Map<String, List<MonitorEntry>>> {
val strategies = strategyRepository.findAllById(strategyIds)
val nowSeconds = System.currentTimeMillis() / 1000
val tokenIdSet = mutableSetOf<String>()
val map = mutableMapOf<String, MutableList<MonitorEntry>>()
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() ?: continue
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, nextPeriod, i, tokenIds[i], true)
)
}
}
return Pair(tokenIdSet.toList(), map)
}
/** 更新策略价格数据为指定周期(开盘价、价差线等),可选是否推送默认 0.5 */
private suspend fun updateStrategyPriceDataForPeriod(strategyIds: List<Long>, 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<String>, map: Map<String, List<MonitorEntry>>) {
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, isFromCurrentPeriod = true)
}
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<String>, map: Map<String, List<MonitorEntry>>) {
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, isFromCurrentPeriod = false)
}
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, isFromCurrentPeriod: Boolean) {
if (text == "pong" || text.isEmpty()) return
if (closedForNoSubscribers.get()) return
if (!isFromCurrentPeriod) return
maybeRefreshSubscriptionIfPeriodChanged()
val json = text.fromJson<com.google.gson.JsonObject>() ?: return
val eventType = (json.get("event_type") as? com.google.gson.JsonPrimitive)?.asString ?: return
val map = currentPeriodTokenToStrategy.get()
when (eventType) {
"book" -> {
val assetId = (json.get("asset_id") as? com.google.gson.JsonPrimitive)?.asString ?: return
val bids = json.get("bids") as? com.google.gson.JsonArray
if (bids == null || bids.isEmpty) return
val firstBid = bids.get(0) as? com.google.gson.JsonObject
val bestBid = (firstBid?.get("price") as? com.google.gson.JsonPrimitive)?.asString?.toSafeBigDecimal()
if (bestBid != null) onPriceUpdate(assetId, bestBid, map)
}
"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<String, List<MonitorEntry>>) {
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 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<CryptoTailMonitorPushData>())
}
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
return CryptoTailMonitorPushData(
strategyId = strategy.id!!,
timestamp = System.currentTimeMillis(),
periodStartUnix = periodStartUnix,
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<String, List<MonitorEntry>>) {
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<GammaEventBySlugResponse> {
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<String> {
if (clobTokenIds.isNullOrBlank()) return emptyList()
return clobTokenIds.fromJson<List<String>>() ?: emptyList()
}
@PreDestroy
fun destroy() {
reconnectJob?.cancel()
periodEndCountdownJob?.cancel()
periodicPushJob?.cancel()
currentPeriodWebSocket?.close(1000, "shutdown")
currentPeriodWebSocket = null
nextPeriodWebSocket?.close(1000, "shutdown")
nextPeriodWebSocket = null
}
}
@@ -102,16 +102,16 @@ class CryptoTailOrderNotificationPollingService(
return false return false
} }
val apiSecret = try { val apiSecret = try {
cryptoUtils.decrypt(account.apiSecret) ?: return false cryptoUtils.decrypt(account.apiSecret)
} catch (e: Exception) { } catch (e: Exception) {
logger.warn("解密 API Secret 失败: accountId=${account.id}", e) logger.warn("解密 API Secret 失败: accountId=${account.id}", e)
return false return false
} }
val apiPassphrase = try { val apiPassphrase = try {
cryptoUtils.decrypt(account.apiPassphrase) ?: "" cryptoUtils.decrypt(account.apiPassphrase)
} catch (e: Exception) { "" } } catch (e: Exception) { "" }
val clobApi = retrofitFactory.createClobApi( val clobApi = retrofitFactory.createClobApi(
account.apiKey!!, account.apiKey,
apiSecret, apiSecret,
apiPassphrase, apiPassphrase,
account.walletAddress account.walletAddress
@@ -228,11 +228,11 @@ class CryptoTailSettlementService(
val match = activities.firstOrNull { a -> val match = activities.firstOrNull { a ->
a.type == "TRADE" && a.type == "TRADE" &&
a.conditionId == conditionId && a.conditionId == conditionId &&
a.outcomeIndex != null && a.outcomeIndex!! in 0..1 && a.outcomeIndex != null && a.outcomeIndex in 0..1 &&
a.outcomeIndex == trigger.outcomeIndex && a.outcomeIndex == trigger.outcomeIndex &&
a.side?.uppercase() == "BUY" && a.side?.uppercase() == "BUY" &&
a.price != null && a.price!! > 0 && a.price != null && a.price > 0 &&
a.size != null && a.size!! > 0 a.size != null && a.size > 0
} ?: run { } ?: 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 return null
@@ -135,13 +135,17 @@ class CryptoTailStrategyExecutionService(
return null return null
} }
val apiSecret = try { val apiSecret = try {
account.apiSecret?.let { cryptoUtils.decrypt(it) } ?: "" account.apiSecret.let { cryptoUtils.decrypt(it) }
} catch (e: Exception) { "" } } catch (e: Exception) {
""
}
val apiPassphrase = try { val apiPassphrase = try {
account.apiPassphrase?.let { cryptoUtils.decrypt(it) } ?: "" account.apiPassphrase.let { cryptoUtils.decrypt(it) }
} catch (e: Exception) { "" } } 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 -> val feeRateByTokenId = tokenIds.associate { tokenId ->
tokenId to (clobService.getFeeRate(tokenId).getOrNull()?.toString() ?: "0") tokenId to (clobService.getFeeRate(tokenId).getOrNull()?.toString() ?: "0")
} }
@@ -211,11 +215,19 @@ class CryptoTailStrategyExecutionService(
val mutex = getTriggerMutex(strategy.id!!, periodStartUnix) val mutex = getTriggerMutex(strategy.id!!, periodStartUnix)
mutex.withLock { 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) val logKey = triggerLockKey(strategy.id!!, periodStartUnix)
if (conditionLoggedCache.getIfPresent(logKey) == null) { if (conditionLoggedCache.getIfPresent(logKey) == null) {
conditionLoggedCache.put(logKey, periodStartUnix + strategy.intervalSeconds) 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 openPrice = oc?.first?.toPlainString() ?: "-"
val closePrice = oc?.second?.toPlainString() ?: "-" val closePrice = oc?.second?.toPlainString() ?: "-"
val strategyName = strategy.name?.takeIf { it.isNotBlank() } ?: "尾盘策略-${strategy.marketSlugPrefix}" val strategyName = strategy.name?.takeIf { it.isNotBlank() } ?: "尾盘策略-${strategy.marketSlugPrefix}"
@@ -223,8 +235,8 @@ class CryptoTailStrategyExecutionService(
val modeStr = if (strategy.spreadDirection == SpreadDirection.MAX) "最大价差" else "最小价差" val modeStr = if (strategy.spreadDirection == SpreadDirection.MAX) "最大价差" else "最小价差"
logger.info( logger.info(
"尾盘策略首次满足条件: strategyName=$strategyName, strategyId=${strategy.id}, " + "尾盘策略首次满足条件: strategyName=$strategyName, strategyId=${strategy.id}, " +
"openPrice=$openPrice, closePrice=$closePrice, marketPrice=${bestBid.toPlainString()}, " + "openPrice=$openPrice, closePrice=$closePrice, marketPrice=${bestBid.toPlainString()}, " +
"direction=$direction, outcomeIndex=$outcomeIndex, spreadMode=$modeStr" "direction=$direction, outcomeIndex=$outcomeIndex, spreadMode=$modeStr"
) )
} }
if (!passSpreadCheck(strategy, periodStartUnix, outcomeIndex)) return@withLock if (!passSpreadCheck(strategy, periodStartUnix, outcomeIndex)) return@withLock
@@ -235,7 +247,11 @@ class CryptoTailStrategyExecutionService(
private fun passSpreadCheck(strategy: CryptoTailStrategy, periodStartUnix: Long, outcomeIndex: Int): Boolean { private fun passSpreadCheck(strategy: CryptoTailStrategy, periodStartUnix: Long, outcomeIndex: Int): Boolean {
if (strategy.spreadMode == SpreadMode.NONE) return true 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 ?: return false
val (openP, closeP) = oc val (openP, closeP) = oc
val spreadAbs = closeP.subtract(openP).abs() val spreadAbs = closeP.subtract(openP).abs()
@@ -245,10 +261,12 @@ class CryptoTailStrategyExecutionService(
SpreadMode.FIXED -> { SpreadMode.FIXED -> {
strategy.spreadValue?.takeIf { it > BigDecimal.ZERO } ?: return true strategy.spreadValue?.takeIf { it > BigDecimal.ZERO } ?: return true
} }
SpreadMode.AUTO -> { SpreadMode.AUTO -> {
val result = computeAutoEffectiveSpread(strategy, periodStartUnix, outcomeIndex) ?: return true val result = computeAutoEffectiveSpread(strategy, periodStartUnix, outcomeIndex) ?: return true
result.effectiveSpread.takeIf { it > BigDecimal.ZERO } ?: return true result.effectiveSpread.takeIf { it > BigDecimal.ZERO } ?: return true
} }
SpreadMode.NONE -> return true SpreadMode.NONE -> return true
} }
@@ -271,9 +289,22 @@ class CryptoTailStrategyExecutionService(
val effectiveSpread: BigDecimal val effectiveSpread: BigDecimal
) )
private fun computeAutoEffectiveSpread(strategy: CryptoTailStrategy, periodStartUnix: Long, outcomeIndex: Int): AutoSpreadResult? { private fun computeAutoEffectiveSpread(
val baseSpread = binanceKlineAutoSpreadService.getAutoMinSpreadBase(strategy.marketSlugPrefix, strategy.intervalSeconds, periodStartUnix, outcomeIndex) strategy: CryptoTailStrategy,
?: binanceKlineAutoSpreadService.computeAndCache(strategy.marketSlugPrefix, strategy.intervalSeconds, periodStartUnix)?.let { if (outcomeIndex == 0) it.first else it.second } 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 ?: return null
if (baseSpread <= BigDecimal.ZERO) return null if (baseSpread <= BigDecimal.ZERO) return null
val windowStartMs = (periodStartUnix + strategy.windowStartSeconds) * 1000L val windowStartMs = (periodStartUnix + strategy.windowStartSeconds) * 1000L
@@ -306,18 +337,40 @@ class CryptoTailStrategyExecutionService(
val amountUsdc = when (strategy.amountMode.uppercase()) { val amountUsdc = when (strategy.amountMode.uppercase()) {
"RATIO" -> { "RATIO" -> {
val balanceResult = accountService.getAccountBalance(ctx.account.id) 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) availableBalance.multiply(strategy.amountValue).divide(BigDecimal("100"), 18, RoundingMode.DOWN)
} }
else -> strategy.amountValue else -> strategy.amountValue
} }
if (amountUsdc < BigDecimal("1")) { if (amountUsdc < BigDecimal("1")) {
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", "投入金额不足") saveTriggerRecord(
strategy,
periodStartUnix,
marketTitle,
outcomeIndex,
triggerPrice,
amountUsdc,
null,
"fail",
"投入金额不足"
)
return return
} }
val tokenId = tokenIds.getOrNull(outcomeIndex) ?: run { 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 return
} }
@@ -350,7 +403,16 @@ class CryptoTailStrategyExecutionService(
orderType = "FAK", orderType = "FAK",
deferExec = false deferExec = false
) )
submitOrderAndSaveRecord(ctx.clobApi, strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, orderRequest) submitOrderAndSaveRecord(
ctx.clobApi,
strategy,
periodStartUnix,
marketTitle,
outcomeIndex,
triggerPrice,
amountUsdc,
orderRequest
)
return return
} }
@@ -373,7 +435,17 @@ class CryptoTailStrategyExecutionService(
if (response.isSuccessful && response.body() != null) { if (response.isSuccessful && response.body() != null) {
val body = response.body()!! val body = response.body()!!
if (body.success && body.orderId != null) { if (body.success && body.orderId != null) {
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, body.orderId, "success", null) saveTriggerRecord(
strategy,
periodStartUnix,
marketTitle,
outcomeIndex,
triggerPrice,
amountUsdc,
body.orderId,
"success",
null
)
logger.info("尾盘策略下单成功: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, outcomeIndex=$outcomeIndex, orderId=${body.orderId}") logger.info("尾盘策略下单成功: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, outcomeIndex=$outcomeIndex, orderId=${body.orderId}")
return return
} }
@@ -386,7 +458,17 @@ class CryptoTailStrategyExecutionService(
failReason = e.message ?: e.toString() 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) saveTriggerRecord(
strategy,
periodStartUnix,
marketTitle,
outcomeIndex,
triggerPrice,
amountUsdc,
null,
"fail",
failReason
)
logger.error("尾盘策略下单失败: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, reason=$failReason") logger.error("尾盘策略下单失败: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, reason=$failReason")
} }
@@ -401,12 +483,32 @@ class CryptoTailStrategyExecutionService(
) { ) {
val account = accountRepository.findById(strategy.accountId).orElse(null) ?: run { val account = accountRepository.findById(strategy.accountId).orElse(null) ?: run {
logger.warn("账户不存在: accountId=${strategy.accountId}") 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 return
} }
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) { if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
logger.warn("账户未配置 API 凭证: accountId=${account.id}") 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 return
} }
@@ -417,12 +519,32 @@ class CryptoTailStrategyExecutionService(
else -> strategy.amountValue else -> strategy.amountValue
} }
if (amountUsdc < BigDecimal("1")) { if (amountUsdc < BigDecimal("1")) {
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", "投入金额不足") saveTriggerRecord(
strategy,
periodStartUnix,
marketTitle,
outcomeIndex,
triggerPrice,
amountUsdc,
null,
"fail",
"投入金额不足"
)
return return
} }
val tokenId = tokenIds.getOrNull(outcomeIndex) ?: run { 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 return
} }
@@ -441,16 +563,30 @@ class CryptoTailStrategyExecutionService(
cryptoUtils.decrypt(account.privateKey) ?: "" cryptoUtils.decrypt(account.privateKey) ?: ""
} catch (e: Exception) { } catch (e: Exception) {
logger.error("解密私钥失败: accountId=${account.id}", e) 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 return
} }
val apiSecret = try { val apiSecret = try {
account.apiSecret?.let { cryptoUtils.decrypt(it) } ?: "" account.apiSecret.let { cryptoUtils.decrypt(it) }
} catch (e: Exception) { "" } } catch (e: Exception) {
""
}
val apiPassphrase = try { val apiPassphrase = try {
account.apiPassphrase?.let { cryptoUtils.decrypt(it) } ?: "" account.apiPassphrase.let { cryptoUtils.decrypt(it) }
} catch (e: Exception) { "" } } catch (e: Exception) {
val clobApi = retrofitFactory.createClobApi(account.apiKey!!, apiSecret, apiPassphrase, account.walletAddress) ""
}
val clobApi = retrofitFactory.createClobApi(account.apiKey, apiSecret, apiPassphrase, account.walletAddress)
val feeRateBps = clobService.getFeeRate(tokenId).getOrNull()?.toString() ?: "0" val feeRateBps = clobService.getFeeRate(tokenId).getOrNull()?.toString() ?: "0"
val signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType) val signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
@@ -472,7 +608,16 @@ class CryptoTailStrategyExecutionService(
orderType = "FAK", orderType = "FAK",
deferExec = false 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<GammaEventBySlugResponse> { private suspend fun fetchEventBySlug(slug: String): Result<GammaEventBySlugResponse> {
+2
View File
@@ -35,6 +35,7 @@ import Announcements from './pages/Announcements'
import BacktestList from './pages/BacktestList' import BacktestList from './pages/BacktestList'
import BacktestDetail from './pages/BacktestDetail' import BacktestDetail from './pages/BacktestDetail'
import CryptoTailStrategyList from './pages/CryptoTailStrategyList' import CryptoTailStrategyList from './pages/CryptoTailStrategyList'
import CryptoTailMonitor from './pages/CryptoTailMonitor'
import { wsManager } from './services/websocket' import { wsManager } from './services/websocket'
import type { OrderPushMessage } from './types' import type { OrderPushMessage } from './types'
import { apiService } from './services/api' import { apiService } from './services/api'
@@ -252,6 +253,7 @@ function App() {
<Route path="/templates/edit/:id" element={<ProtectedRoute><TemplateEdit /></ProtectedRoute>} /> <Route path="/templates/edit/:id" element={<ProtectedRoute><TemplateEdit /></ProtectedRoute>} />
<Route path="/copy-trading" element={<ProtectedRoute><CopyTradingList /></ProtectedRoute>} /> <Route path="/copy-trading" element={<ProtectedRoute><CopyTradingList /></ProtectedRoute>} />
<Route path="/crypto-tail-strategy" element={<ProtectedRoute><CryptoTailStrategyList /></ProtectedRoute>} /> <Route path="/crypto-tail-strategy" element={<ProtectedRoute><CryptoTailStrategyList /></ProtectedRoute>} />
<Route path="/crypto-tail-monitor" element={<ProtectedRoute><CryptoTailMonitor /></ProtectedRoute>} />
<Route path="/copy-trading/statistics/:copyTradingId" element={<ProtectedRoute><CopyTradingStatistics /></ProtectedRoute>} /> <Route path="/copy-trading/statistics/:copyTradingId" element={<ProtectedRoute><CopyTradingStatistics /></ProtectedRoute>} />
{/* 保留旧路由以保持向后兼容 */} {/* 保留旧路由以保持向后兼容 */}
<Route path="/copy-trading/orders/buy/:copyTradingId" element={<ProtectedRoute><CopyTradingBuyOrders /></ProtectedRoute>} /> <Route path="/copy-trading/orders/buy/:copyTradingId" element={<ProtectedRoute><CopyTradingBuyOrders /></ProtectedRoute>} />
+5
View File
@@ -162,6 +162,11 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
icon: <LineChartOutlined />, icon: <LineChartOutlined />,
label: t('menu.cryptoTailStrategy') label: t('menu.cryptoTailStrategy')
}, },
{
key: '/crypto-tail-monitor',
icon: <LineChartOutlined />,
label: t('menu.cryptoTailMonitor')
},
{ {
key: '/positions', key: '/positions',
icon: <UnorderedListOutlined />, icon: <UnorderedListOutlined />,
+53
View File
@@ -313,6 +313,7 @@
"templates": "Templates", "templates": "Templates",
"copyTradingConfig": "Copy Trading Config", "copyTradingConfig": "Copy Trading Config",
"cryptoTailStrategy": "Tail Strategy", "cryptoTailStrategy": "Tail Strategy",
"cryptoTailMonitor": "Tail Monitor",
"positions": "Position Management", "positions": "Position Management",
"backtest": "Backtest", "backtest": "Backtest",
"statistics": "Statistics", "statistics": "Statistics",
@@ -1541,5 +1542,57 @@
"emptyFail": "No failed records", "emptyFail": "No failed records",
"totalCount": "{count} record(s)" "totalCount": "{count} record(s)"
} }
},
"cryptoTailMonitor": {
"title": "Tail 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",
"price": "BTC Price",
"openPrice": "Open",
"spread": "Spread",
"minSpreadLine": "Min Spread Line",
"marketUp": "Up",
"marketDown": "Down",
"time": "Time"
},
"strategyInfo": {
"title": "Strategy Info",
"market": "Market",
"interval": "Interval",
"account": "Account",
"spreadMode": "Spread Mode"
}
} }
} }
+53
View File
@@ -312,6 +312,7 @@
"templates": "跟单模板", "templates": "跟单模板",
"copyTradingConfig": "跟单配置", "copyTradingConfig": "跟单配置",
"cryptoTailStrategy": "尾盘策略", "cryptoTailStrategy": "尾盘策略",
"cryptoTailMonitor": "尾盘监控",
"positions": "仓位管理", "positions": "仓位管理",
"backtest": "回测", "backtest": "回测",
"statistics": "统计信息", "statistics": "统计信息",
@@ -1540,5 +1541,57 @@
"emptyFail": "暂无失败记录", "emptyFail": "暂无失败记录",
"totalCount": "共 {count} 条" "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": "市场分时图",
"price": "BTC 价格",
"openPrice": "开盘价",
"spread": "价差",
"minSpreadLine": "最小价差线",
"marketUp": "Up",
"marketDown": "Down",
"time": "时间"
},
"strategyInfo": {
"title": "策略信息",
"market": "市场",
"interval": "周期",
"account": "账户",
"spreadMode": "价差模式"
}
} }
} }
+53
View File
@@ -313,6 +313,7 @@
"templates": "跟單模板", "templates": "跟單模板",
"copyTradingConfig": "跟單配置", "copyTradingConfig": "跟單配置",
"cryptoTailStrategy": "尾盤策略", "cryptoTailStrategy": "尾盤策略",
"cryptoTailMonitor": "尾盤監控",
"positions": "倉位管理", "positions": "倉位管理",
"backtest": "回測", "backtest": "回測",
"statistics": "統計信息", "statistics": "統計信息",
@@ -1541,5 +1542,57 @@
"emptyFail": "暫無失敗記錄", "emptyFail": "暫無失敗記錄",
"totalCount": "共 {count} 條" "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": "市場分時圖",
"price": "BTC 價格",
"openPrice": "開盤價",
"spread": "價差",
"minSpreadLine": "最小價差線",
"marketUp": "Up",
"marketDown": "Down",
"time": "時間"
},
"strategyInfo": {
"title": "策略信息",
"market": "市場",
"interval": "週期",
"account": "賬戶",
"spreadMode": "價差模式"
}
} }
} }
+678
View File
@@ -0,0 +1,678 @@
import { useEffect, useState, useRef, useCallback } from 'react'
import {
Card,
Select,
Space,
Statistic,
Row,
Col,
Typography,
Spin,
Empty,
Alert
} from 'antd'
import { ClockCircleOutlined } 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<CryptoTailStrategyDto[]>([])
const [strategiesLoading, setStrategiesLoading] = useState(false)
// 选中的策略
const [selectedStrategyId, setSelectedStrategyId] = useState<number | null>(null)
// 监控数据
const [initData, setInitData] = useState<CryptoTailMonitorInitResponse | null>(null)
const [pushData, setPushData] = useState<CryptoTailMonitorPushData | null>(null)
const [initLoading, setInitLoading] = useState(false)
// 价格历史数据(用于分时图)
const [priceHistory, setPriceHistory] = useState<PriceDataPoint[]>([])
const chartRef = useRef<HTMLDivElement>(null)
const chartInstance = useRef<echarts.ECharts | null>(null)
const marketChartRef = useRef<HTMLDivElement>(null)
const marketChartInstance = useRef<echarts.ECharts | null>(null)
const lastPeriodStartRef = useRef<number | null>(null)
// 记录首次数据进入时间(用于中途进入时的横轴起点)
const [firstDataTime, setFirstDataTime] = useState<number | null>(null)
// 标记是否已切换过周期(切换后使用完整周期)
const [hasSwitchedPeriod, setHasSwitchedPeriod] = useState<boolean>(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)
return
}
const initMonitor = async () => {
setInitLoading(true)
setPriceHistory([])
setFirstDataTime(null)
setHasSwitchedPeriod(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
setPushData(data)
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) {
// 新周期或首次推送:更新 ref 和 initData,清空历史
lastPeriodStartRef.current = pushPeriod
// 如果之前已经有周期数据,说明是周期切换,标记为已切换
if (lastPeriod != null) {
setHasSwitchedPeriod(true)
}
// 周期切换时重置首次数据时间
setFirstDataTime(newPoint.time)
setInitData(prev => prev ? { ...prev, periodStartUnix: pushPeriod } : null)
setPriceHistory([newPoint])
} else {
// 同周期:追加数据
// 记录首次数据时间(仅在中途进入且未切换过周期时记录)
setFirstDataTime(prev => {
if (prev == null) {
return newPoint.time
}
return prev
})
setPriceHistory(prev => {
const maxPoints = 300
const newHistory = [...prev, newPoint]
return newHistory.slice(-maxPoints)
})
}
}, [selectedStrategyId])
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
}
}, [])
// 更新图表:分时图为 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; lineStyle: { type: 'dashed'; color: string } }> = []
if (openBtcNum != null && !Number.isNaN(openBtcNum)) {
markLineData.push({
name: t('cryptoTailMonitor.chart.openPrice'),
yAxis: openBtcNum,
lineStyle: { type: 'dashed', color: '#999' }
})
}
if (openBtcNum != null && minSpreadUp != null && !Number.isNaN(minSpreadUp)) {
markLineData.push({
name: t('cryptoTailMonitor.chart.minSpreadLine') + ' Up',
yAxis: openBtcNum + minSpreadUp,
lineStyle: { type: 'dashed', color: '#ff4d4f' }
})
}
if (openBtcNum != null && minSpreadDown != null && !Number.isNaN(minSpreadDown)) {
markLineData.push({
name: t('cryptoTailMonitor.chart.minSpreadLine') + ' Down',
yAxis: openBtcNum - minSpreadDown,
lineStyle: { type: 'dashed', color: '#ff4d4f' }
})
}
const option: EChartsOption = {
tooltip: {
trigger: 'axis',
formatter: (params: unknown) => {
const arr = params as Array<{ seriesName: string; name: string; value: number | [number, 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 ''
const ts = priceParam.name as unknown as number
const offsetSec = Math.floor(ts / 1000) - (initData?.periodStartUnix ?? 0)
const mins = Math.floor(offsetSec / 60)
const secs = offsetSec % 60
const timeStr = `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
return `
<div>
<div>${t('cryptoTailMonitor.chart.time')}: ${timeStr}</div>
<div>${t('cryptoTailMonitor.chart.price')}: ${Number(val).toFixed(2)} USDC</div>
</div>
`
}
},
legend: {
show: true,
top: 0
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: '12%',
containLabel: true
},
xAxis: {
type: 'time',
min: xAxisMin,
max: periodEndMs,
boundaryGap: false,
axisLabel: {
formatter: (val: number) => {
const offsetSec = Math.floor(val / 1000) - (initData.periodStartUnix ?? 0)
const mins = Math.floor(offsetSec / 60)
const secs = 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 ? { data: markLineData } : 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)
const marketUpData: [number, number | null][] = priceHistory.length > 0
? priceHistory.map(p => [toMs(p.time), p.marketPriceUp])
: []
const marketDownData: [number, number | null][] = priceHistory.length > 0
? priceHistory.map(p => [toMs(p.time), p.marketPriceDown])
: []
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 option: EChartsOption = {
tooltip: {
trigger: 'axis',
formatter: (params: unknown) => {
const arr = params as Array<{ seriesName: string; name: string | number; value: number | [number, number] }>
const upParam = arr.find(p => p.seriesName === t('cryptoTailMonitor.chart.marketUp'))
const downParam = arr.find(p => p.seriesName === t('cryptoTailMonitor.chart.marketDown'))
const rawTime = arr[0]?.name
const timeStr = typeof rawTime === 'number' && initData
? (() => {
const offsetSec = Math.floor(rawTime / 1000) - (initData.periodStartUnix ?? 0)
const mins = Math.floor(offsetSec / 60)
const secs = offsetSec % 60
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
})()
: String(rawTime ?? '')
let html = `<div><div>${t('cryptoTailMonitor.chart.time')}: ${timeStr}</div>`
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 += `<div>Up: ${Number(upVal).toFixed(4)}</div>`
if (downVal != null && !Number.isNaN(downVal)) html += `<div>Down: ${Number(downVal).toFixed(4)}</div>`
html += '</div>'
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,
boundaryGap: false,
axisLabel: {
formatter: (val: number) => {
const offsetSec = Math.floor(val / 1000) - (initData.periodStartUnix ?? 0)
const mins = Math.floor(offsetSec / 60)
const secs = 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 }]]
}
},
{
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, 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 (
<div style={{ padding: isMobile ? 12 : 24 }}>
<Title level={2} style={{ marginBottom: 16, fontSize: isMobile ? 20 : 24 }}>
{t('cryptoTailMonitor.title')}
</Title>
{/* 顶部控制区 */}
<Card style={{ marginBottom: 16 }}>
<Space wrap size="middle">
<Space>
<Text strong>{t('cryptoTailMonitor.selectStrategy')}</Text>
<Select
style={{ minWidth: isMobile ? 200 : 300 }}
loading={strategiesLoading}
value={selectedStrategyId}
onChange={(id) => setSelectedStrategyId(id)}
placeholder={t('cryptoTailMonitor.selectStrategyPlaceholder')}
options={strategies.map(s => ({
label: `${s.name || s.marketSlugPrefix} (${s.intervalSeconds === 300 ? '5m' : '15m'})`,
value: s.id
}))}
/>
</Space>
</Space>
</Card>
{initLoading ? (
<Spin spinning style={{ display: 'flex', justifyContent: 'center', padding: 100 }} />
) : !initData ? (
<Empty description={t('cryptoTailMonitor.noData')} />
) : (
<>
{/* 状态卡片:最小宽度填满整行,间距 16 */}
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col flex="1" style={{ minWidth: 140 }}>
<Card size="small" style={{ width: '100%', minWidth: 0 }}>
<Statistic
title={t('cryptoTailMonitor.stat.openPrice')}
value={openPrice ? formatNumber(openPrice, 2) : '-'}
precision={2}
/>
</Card>
</Col>
<Col flex="1" style={{ minWidth: 140 }}>
<Card size="small" style={{ width: '100%', minWidth: 0 }}>
<Statistic
title={t('cryptoTailMonitor.stat.currentPrice')}
value={currentPrice ? formatNumber(currentPrice, 2) : '-'}
precision={2}
valueStyle={{ color: isMobile ? undefined : '#1890ff' }}
/>
</Card>
</Col>
<Col flex="1" style={{ minWidth: 140 }}>
<Card size="small" style={{ width: '100%', minWidth: 0 }}>
<Statistic
title={t('cryptoTailMonitor.stat.spread')}
value={(() => {
if (currentSpread == null || currentSpread === '') return '-'
const num = parseFloat(currentSpread)
if (Number.isNaN(num)) return '-'
const formatted = formatNumber(currentSpread, 2)
return num >= 0 ? `+${formatted}` : formatted
})()}
precision={2}
valueStyle={{
color: spreadBelowThreshold ? '#ff4d4f' : undefined
}}
/>
</Card>
</Col>
<Col flex="1" style={{ minWidth: 140 }}>
<Card size="small" style={{ width: '100%', minWidth: 0 }}>
<Statistic
title={t('cryptoTailMonitor.stat.remainingTime')}
value={pushData ? formatRemainingTime(pushData.remainingSeconds) : '-'}
prefix={<ClockCircleOutlined />}
valueStyle={{
color: pushData && pushData.remainingSeconds < 60 ? '#ff4d4f' : undefined
}}
/>
</Card>
</Col>
<Col flex="1" style={{ minWidth: 140 }}>
<Card size="small" style={{ width: '100%', minWidth: 0 }}>
<Statistic
title={(initData.spreadDirection ?? 'MIN') === 'MAX' ? t('cryptoTailMonitor.stat.configuredSpreadMax') : t('cryptoTailMonitor.stat.configuredSpreadMin')}
valueRender={() => {
const mode = initData.minSpreadMode ?? 'NONE'
if (mode === 'NONE') return <Text type="secondary">-</Text>
if (mode === 'FIXED') {
const v = initData.minSpreadValue
return v != null && v !== '' ? formatNumber(v, 2) : '-'
}
const up = minSpreadUpStr != null && minSpreadUpStr !== '' ? formatNumber(minSpreadUpStr, 2) : null
const down = minSpreadDownStr != null && minSpreadDownStr !== '' ? formatNumber(minSpreadDownStr, 2) : null
if (up == null && down == null) return <Text type="secondary">-</Text>
return (
<Text style={{ fontSize: 13, lineHeight: 1.4 }}>
{up != null && <span style={{ display: 'block' }}>Up: {up}</span>}
{down != null && <span style={{ display: 'block' }}>Down: {down}</span>}
</Text>
)
}}
/>
</Card>
</Col>
</Row>
{/* 价格区间提示 */}
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message={`${t('cryptoTailMonitor.priceRange')}: ${formatNumber(initData.minPrice, 2)} ~ ${formatNumber(initData.maxPrice, 2)} | ${t('cryptoTailMonitor.timeWindow')}: ${Math.floor(initData.windowStartSeconds / 60)}:${(initData.windowStartSeconds % 60).toString().padStart(2, '0')} ~ ${Math.floor(initData.windowEndSeconds / 60)}:${(initData.windowEndSeconds % 60).toString().padStart(2, '0')}`}
/>
{/* BTC 分时图 */}
<Card title={t('cryptoTailMonitor.chart.btcTitle')}>
<div
ref={chartRef}
style={{
width: '100%',
height: isMobile ? 200 : 240
}}
/>
</Card>
{/* 市场分时图 */}
<Card title={t('cryptoTailMonitor.chart.marketTitle')} style={{ marginTop: 16 }}>
<div
ref={marketChartRef}
style={{
width: '100%',
height: isMobile ? 200 : 240
}}
/>
</Card>
{/* 策略信息 */}
<Card title={t('cryptoTailMonitor.strategyInfo.title')} style={{ marginTop: 16 }}>
<Row gutter={[16, 8]}>
<Col span={12}>
<Text type="secondary">{t('cryptoTailMonitor.strategyInfo.market')}: </Text>
<Text>{initData.marketTitle}</Text>
</Col>
<Col span={12}>
<Text type="secondary">{t('cryptoTailMonitor.strategyInfo.interval')}: </Text>
<Text>{initData.intervalSeconds === 300 ? '5m' : '15m'}</Text>
</Col>
<Col span={12}>
<Text type="secondary">{t('cryptoTailMonitor.strategyInfo.account')}: </Text>
<Text>{initData.accountName || `#${initData.accountId}`}</Text>
</Col>
<Col span={12}>
<Text type="secondary">{t('cryptoTailMonitor.strategyInfo.spreadMode')}: </Text>
<Text>{initData.minSpreadMode}</Text>
{initData.minSpreadMode === 'FIXED' && initData.minSpreadValue && (
<Text> ({formatNumber(initData.minSpreadValue, 4)})</Text>
)}
</Col>
</Row>
</Card>
</>
)}
</div>
)
}
export default CryptoTailMonitor
+3 -1
View File
@@ -497,7 +497,9 @@ export const apiService = {
marketOptions: () => marketOptions: () =>
apiClient.post<ApiResponse<import('../types').CryptoTailMarketOptionDto[]>>('/crypto-tail-strategy/market-options', {}), apiClient.post<ApiResponse<import('../types').CryptoTailMarketOptionDto[]>>('/crypto-tail-strategy/market-options', {}),
autoMinSpread: (data: { intervalSeconds: number }) => autoMinSpread: (data: { intervalSeconds: number }) =>
apiClient.post<ApiResponse<import('../types').CryptoTailAutoMinSpreadResponse>>('/crypto-tail-strategy/auto-min-spread', data) apiClient.post<ApiResponse<import('../types').CryptoTailAutoMinSpreadResponse>>('/crypto-tail-strategy/auto-min-spread', data),
monitorInit: (strategyId: number) =>
apiClient.post<ApiResponse<import('../types').CryptoTailMonitorInitResponse>>('/crypto-tail-strategy/monitor/init', { strategyId })
}, },
/** /**
+94
View File
@@ -1107,3 +1107,97 @@ export interface CryptoTailMarketOptionDto {
periodStartUnix: number periodStartUnix: number
endDate?: string endDate?: string
} }
/**
*
*/
export interface CryptoTailMonitorInitResponse {
/** 策略ID */
strategyId: number
/** 策略名称 */
name: string
/** 账户ID */
accountId: number
/** 账户名称 */
accountName: string
/** 市场 slug 前缀 */
marketSlugPrefix: string
/** 市场标题 */
marketTitle: string
/** 周期秒数 (300=5m, 900=15m) */
intervalSeconds: number
/** 当前周期开始时间 (Unix 秒) */
periodStartUnix: number
/** 时间窗口开始秒数 */
windowStartSeconds: number
/** 时间窗口结束秒数 */
windowEndSeconds: number
/** 最低价格 */
minPrice: string
/** 最高价格 */
maxPrice: string
/** 最小价差模式: NONE, FIXED, AUTO */
minSpreadMode: string
/** 价差方向: MIN(显示周期内最小价差), MAX(显示周期内最大价差) */
spreadDirection?: string
/** 最小价差数值 (FIXED 时有值) */
minSpreadValue?: string
/** 自动计算的最小价差 (Up方向) */
autoMinSpreadUp?: string
/** 自动计算的最小价差 (Down方向) */
autoMinSpreadDown?: string
/** BTC 开盘价 USDC(来自币安 K 线) */
openPriceBtc?: string
/** Up tokenId */
tokenIdUp?: string
/** Down tokenId */
tokenIdDown?: string
/** 当前时间 (毫秒时间戳) */
currentTimestamp: number
/** 是否启用 */
enabled: boolean
}
/**
*
*/
export interface CryptoTailMonitorPushData {
/** 策略ID */
strategyId: number
/** 推送时间 (毫秒时间戳) */
timestamp: number
/** 当前周期开始时间 (Unix 秒) */
periodStartUnix: number
/** 当前价格 (Up方向,来自订单簿) */
currentPriceUp?: string
/** 当前价格 (Down方向,来自订单簿) */
currentPriceDown?: string
/** 当前价差 (Up方向: 1 - currentPriceUp) */
spreadUp?: string
/** 当前价差 (Down方向: currentPriceUp) */
spreadDown?: string
/** 最小价差线 (Up方向,USDC) */
minSpreadLineUp?: string
/** 最小价差线 (Down方向,USDC) */
minSpreadLineDown?: string
/** BTC 开盘价 USDC */
openPriceBtc?: string
/** BTC 最新价 USDC */
currentPriceBtc?: string
/** BTC 价差 USDCcurrentPriceBtc - openPriceBtc */
spreadBtc?: string
/** 周期剩余秒数 */
remainingSeconds: number
/** 是否在时间窗口内 */
inTimeWindow: boolean
/** 是否在价格区间内 (Up方向) */
inPriceRangeUp: boolean
/** 是否在价格区间内 (Down方向) */
inPriceRangeDown: boolean
/** 是否已触发 */
triggered: boolean
/** 触发方向: UP, DOWN, null */
triggerDirection?: string
/** 周期是否已结束 */
periodEnded: boolean
}