Compare commits

...

6 Commits

Author SHA1 Message Date
WrBug c5d59dfdf9 refactor: 尾盘策略统一更名为加密价差策略
- 前端:菜单父级「加密价差策略」,子项「策略配置」「实时监控」;i18n 与类型注释全部替换
- 后端:i18n、ErrorCode、DTO/实体/服务注释与日志「尾盘」→「加密价差策略」;TG 通知文案
- 文档:README、中英文用户指南、UI 规格、任务、流程、价差流程、市场数据、动态系数等全部更新
- 不修改 API 路径、路由、表名与类名,保持兼容

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 21:30:50 +08:00
WrBug 4b277eaeab feat(frontend): 尾盘父级菜单与图标优化
- 尾盘策略、尾盘监控归入「尾盘」父级菜单,与跟单交易结构一致
- 菜单图标:父级 LineChart,尾盘策略 RocketOutlined,尾盘监控 DashboardOutlined
- 多语言:新增 menu.cryptoTail(尾盘/尾盤/Crypto Tail)
- 含 CryptoTailMonitor 及后端 DTO/Service 等修改

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 21:17:44 +08:00
WrBug f35bad78d4 feat(i18n): 系统更新功能多语言支持与缺失 key 补全
- 新增 systemUpdate 区块(zh-CN/zh-TW/en)覆盖系统更新页与版本提示
- zh-CN 补充 account.walletType
- SystemUpdate 页与 Layout 版本 tag 全部改为 useTranslation,移除硬编码中文

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 21:14:59 +08:00
WrBug 0740abcf16 fix(crypto-tail): 监控分时图推送节流与前端采样,避免1s内多条数据导致点过密
- 后端: price_change 推送节流,每策略 1s 内最多推送 1 次,其余靠定时 1.5s 推送补足
- 前端: 同周期内分时图追加点时至少间隔 1s 才追加,保证曲线连续且不过密
- 最新价等展示仍实时更新,仅图表序列做采样

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 20:01:29 +08:00
WrBug 377da4fff6 fix(cryptotail): 优化 WebSocket 订阅管理与日志清理
- 使用 createClient() 懒加载 OkHttpClient
- 修复订阅计数归零时未移除策略的问题
- 简化 handleMessage,移除 isFromCurrentPeriod 参数和 book 事件处理
- 清理调试日志,保留关键业务日志

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