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:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
-1
@@ -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 价差 USDC(currentPriceBtc - openPriceBtc) */
|
||||||
|
val spreadBtc: String? = null,
|
||||||
|
/** 周期剩余秒数 */
|
||||||
|
val remainingSeconds: Int = 0,
|
||||||
|
/** 是否在时间窗口内 */
|
||||||
|
val inTimeWindow: Boolean = false,
|
||||||
|
/** 是否在价格区间内 (Up方向) */
|
||||||
|
val inPriceRangeUp: Boolean = false,
|
||||||
|
/** 是否在价格区间内 (Down方向) */
|
||||||
|
val inPriceRangeDown: Boolean = false,
|
||||||
|
/** 是否已触发 */
|
||||||
|
val triggered: Boolean = false,
|
||||||
|
/** 触发方向: UP, DOWN, null */
|
||||||
|
val triggerDirection: String? = null,
|
||||||
|
/** 周期是否已结束 */
|
||||||
|
val periodEnded: Boolean = false
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
|||||||
+9
-4
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+85
-7
@@ -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(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+781
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-3
@@ -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
|
||||||
|
|||||||
+3
-3
@@ -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
|
||||||
|
|||||||
+175
-30
@@ -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> {
|
||||||
|
|||||||
@@ -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>} />
|
||||||
|
|||||||
@@ -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 />,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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": "价差模式"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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": "價差模式"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
@@ -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 })
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 价差 USDC(currentPriceBtc - openPriceBtc) */
|
||||||
|
spreadBtc?: string
|
||||||
|
/** 周期剩余秒数 */
|
||||||
|
remainingSeconds: number
|
||||||
|
/** 是否在时间窗口内 */
|
||||||
|
inTimeWindow: boolean
|
||||||
|
/** 是否在价格区间内 (Up方向) */
|
||||||
|
inPriceRangeUp: boolean
|
||||||
|
/** 是否在价格区间内 (Down方向) */
|
||||||
|
inPriceRangeDown: boolean
|
||||||
|
/** 是否已触发 */
|
||||||
|
triggered: boolean
|
||||||
|
/** 触发方向: UP, DOWN, null */
|
||||||
|
triggerDirection?: string
|
||||||
|
/** 周期是否已结束 */
|
||||||
|
periodEnded: boolean
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user