feat(crypto-tail): 价差策略监控页手动下单与 UI 优化

- 后端:手动下单 API,trigger_type 区分 AUTO/MANUAL,价格 4 位小数向上取整
- 前端:监控页手动买入 Up/Down 按钮、二次确认弹窗、策略信息移动端适配
- 下单价格最多 4 位小数;确认页方向仅显示 Up/Down;按钮文案改为「买入 Up/Down」

Made-with: Cursor
This commit is contained in:
WrBug
2026-02-26 19:23:00 +08:00
parent 708d6ddb41
commit a2be5b7f52
14 changed files with 1045 additions and 197 deletions
@@ -13,10 +13,13 @@ 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.dto.CryptoTailManualOrderRequest
import com.wrbug.polymarketbot.dto.CryptoTailManualOrderResponse
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 com.wrbug.polymarketbot.service.cryptotail.CryptoTailStrategyExecutionService
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
import org.springframework.http.ResponseEntity
@@ -24,12 +27,14 @@ import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import kotlinx.coroutines.runBlocking
@RestController
@RequestMapping("/api/crypto-tail-strategy")
class CryptoTailStrategyController(
private val cryptoTailStrategyService: CryptoTailStrategyService,
private val cryptoTailMonitorService: CryptoTailMonitorService,
private val cryptoTailStrategyExecutionService: CryptoTailStrategyExecutionService,
private val binanceKlineAutoSpreadService: BinanceKlineAutoSpreadService,
private val messageSource: MessageSource
) {
@@ -216,4 +221,41 @@ class CryptoTailStrategyController(
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
/**
* 手动下单
* 用户主动触发下单,不检查任何条件,仅检查当前周期是否已下单
*/
@PostMapping("/manual-order")
fun manualOrder(@RequestBody request: CryptoTailManualOrderRequest): ResponseEntity<ApiResponse<CryptoTailManualOrderResponse>> {
return runBlocking {
try {
if (request.strategyId <= 0) {
return@runBlocking ResponseEntity.ok(
ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource)
)
}
val result = cryptoTailStrategyExecutionService.manualOrder(request)
result.fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("手动下单失败: ${e.message}", e)
val code = when (e.message) {
"策略不存在" -> ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND
"当前周期已下单" -> ErrorCode.PARAM_ERROR
"价格必须在 0~1 之间" -> ErrorCode.PARAM_ERROR
"数量不能少于 1" -> ErrorCode.PARAM_ERROR
"总金额不能少于 1 USDC" -> ErrorCode.PARAM_ERROR
"总金额超过策略配置的投入金额" -> ErrorCode.PARAM_ERROR
else -> ErrorCode.SERVER_ERROR
}
ResponseEntity.ok(ApiResponse.error(code, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("手动下单异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
}
}
@@ -0,0 +1,21 @@
package com.wrbug.polymarketbot.dto
/**
* 加密价差策略手动下单请求
*/
data class CryptoTailManualOrderRequest(
/** 策略ID */
val strategyId: Long = 0L,
/** 当前周期开始时间 (Unix 秒) */
val periodStartUnix: Long = 0L,
/** 下单方向: UP or DOWN */
val direction: String = "UP",
/** 下单价格 */
val price: String = "0",
/** 下单数量 */
val size: String = "1",
/** 市场标题(用于记录) */
val marketTitle: String = "",
/** Token IDs */
val tokenIds: List<String> = emptyList()
)
@@ -0,0 +1,31 @@
package com.wrbug.polymarketbot.dto
/**
* 加密价差策略手动下单响应
*/
data class CryptoTailManualOrderResponse(
/** 是否成功 */
val success: Boolean = false,
/** 订单ID */
val orderId: String? = null,
/** 提示消息 */
val message: String = "",
/** 下单详情 */
val orderDetails: ManualOrderDetails? = null
)
/**
* 手动下单详情
*/
data class ManualOrderDetails(
/** 策略ID */
val strategyId: Long = 0L,
/** 方向 */
val direction: String = "",
/** 下单价格 */
val price: String = "",
/** 下单数量 */
val size: String = "",
/** 总金额 */
val totalAmount: String = ""
)
@@ -56,6 +56,9 @@ data class CryptoTailStrategyTrigger(
@Column(name = "fail_reason", length = 500)
val failReason: String? = null,
@Column(name = "trigger_type", nullable = false, length = 20)
val triggerType: String = "AUTO",
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@@ -3,6 +3,9 @@ package com.wrbug.polymarketbot.service.cryptotail
import com.wrbug.polymarketbot.api.GammaEventBySlugResponse
import com.wrbug.polymarketbot.api.NewOrderRequest
import com.wrbug.polymarketbot.api.PolymarketClobApi
import com.wrbug.polymarketbot.dto.CryptoTailManualOrderRequest
import com.wrbug.polymarketbot.dto.CryptoTailManualOrderResponse
import com.wrbug.polymarketbot.dto.ManualOrderDetails
import com.wrbug.polymarketbot.entity.Account
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
import com.wrbug.polymarketbot.entity.CryptoTailStrategyTrigger
@@ -411,7 +414,8 @@ class CryptoTailStrategyExecutionService(
outcomeIndex,
triggerPrice,
amountUsdc,
orderRequest
orderRequest,
triggerType = "AUTO"
)
return
}
@@ -427,7 +431,8 @@ class CryptoTailStrategyExecutionService(
outcomeIndex: Int,
triggerPrice: BigDecimal,
amountUsdc: BigDecimal,
orderRequest: NewOrderRequest
orderRequest: NewOrderRequest,
triggerType: String = "AUTO"
) {
var failReason: String? = null
try {
@@ -444,9 +449,10 @@ class CryptoTailStrategyExecutionService(
amountUsdc,
body.orderId,
"success",
null
null,
triggerType = triggerType
)
logger.info("加密价差策略下单成功: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, outcomeIndex=$outcomeIndex, orderId=${body.orderId}")
logger.info("加密价差策略下单成功: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, outcomeIndex=$outcomeIndex, orderId=${body.orderId}, triggerType=$triggerType")
return
}
failReason = body.errorMsg ?: "unknown"
@@ -467,7 +473,8 @@ class CryptoTailStrategyExecutionService(
amountUsdc,
null,
"fail",
failReason
failReason,
triggerType = triggerType
)
logger.error("加密价差策略下单失败: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, reason=$failReason")
}
@@ -650,7 +657,8 @@ class CryptoTailStrategyExecutionService(
amountUsdc: BigDecimal,
orderId: String?,
status: String,
failReason: String?
failReason: String?,
triggerType: String = "AUTO"
) {
val record = CryptoTailStrategyTrigger(
strategyId = strategy.id!!,
@@ -661,11 +669,173 @@ class CryptoTailStrategyExecutionService(
amountUsdc = amountUsdc,
orderId = orderId,
status = status,
failReason = failReason
failReason = failReason,
triggerType = triggerType
)
triggerRepository.save(record)
}
/**
* 手动下单:用户主动触发下单,不检查任何条件,仅检查当前周期是否已下单
*/
suspend fun manualOrder(request: CryptoTailManualOrderRequest): Result<CryptoTailManualOrderResponse> {
return try {
val strategy = strategyRepository.findById(request.strategyId).orElse(null)
?: return Result.failure(IllegalArgumentException("策略不存在"))
val outcomeIndex = if (request.direction.uppercase() == "UP") 0 else 1
if (outcomeIndex < 0 || outcomeIndex >= request.tokenIds.size) {
return Result.failure(IllegalArgumentException("outcomeIndex 越界"))
}
val price = request.price.toSafeBigDecimal()
if (price <= BigDecimal.ZERO || price > BigDecimal.ONE) {
return Result.failure(IllegalArgumentException("价格必须在 0~1 之间"))
}
val priceRounded = price.setScale(4, RoundingMode.UP)
val size = request.size.toSafeBigDecimal()
if (size < BigDecimal.ONE) {
return Result.failure(IllegalArgumentException("数量不能少于 1"))
}
val amountUsdc = priceRounded.multi(size).setScale(2, RoundingMode.HALF_UP)
if (amountUsdc < BigDecimal.ONE) {
return Result.failure(IllegalArgumentException("总金额不能少于 1 USDC"))
}
val mutex = getTriggerMutex(strategy.id!!, request.periodStartUnix)
mutex.withLock {
if (triggerRepository.findByStrategyIdAndPeriodStartUnix(
strategy.id!!,
request.periodStartUnix
) != null
) {
return@withLock Result.failure(IllegalArgumentException("当前周期已下单"))
}
var ctx = getOrInvalidatePeriodContext(strategy, request.periodStartUnix)
if (ctx == null) {
ctx = ensurePeriodContext(
strategy,
request.periodStartUnix,
request.tokenIds,
request.marketTitle.ifBlank { null }
)
}
if (ctx != null) {
val tokenId = request.tokenIds.getOrNull(outcomeIndex)
?: return@withLock Result.failure(IllegalArgumentException("tokenIds 越界"))
val priceStr = priceRounded.toPlainString()
val sizeStr = size.toPlainString()
val feeRateBps = ctx.feeRateByTokenId[tokenId] ?: "0"
val signedOrder = orderSigningService.createAndSignOrder(
privateKey = ctx.decryptedPrivateKey,
makerAddress = ctx.account.proxyAddress,
tokenId = tokenId,
side = "BUY",
price = priceStr,
size = sizeStr,
signatureType = ctx.signatureType,
nonce = "0",
feeRateBps = feeRateBps,
expiration = "0"
)
val orderRequest = NewOrderRequest(
order = signedOrder,
owner = ctx.account.apiKey!!,
orderType = "FAK",
deferExec = false
)
val orderResult = submitOrderForManualOrder(
ctx.clobApi,
strategy,
request.periodStartUnix,
request.marketTitle,
outcomeIndex,
priceRounded,
amountUsdc,
orderRequest
)
orderResult.fold(
onSuccess = { orderId ->
Result.success(
CryptoTailManualOrderResponse(
success = true,
orderId = orderId,
message = "下单成功",
orderDetails = ManualOrderDetails(
strategyId = strategy.id!!,
direction = request.direction,
price = priceStr,
size = sizeStr,
totalAmount = amountUsdc.toPlainString()
)
)
)
},
onFailure = { e ->
Result.failure(e)
}
)
} else {
Result.failure(IllegalArgumentException("账户未配置或凭证不足"))
}
}
} catch (e: Exception) {
logger.error("手动下单异常: strategyId=${request.strategyId}, ${e.message}", e)
Result.failure(e)
}
}
private suspend fun submitOrderForManualOrder(
clobApi: PolymarketClobApi,
strategy: CryptoTailStrategy,
periodStartUnix: Long,
marketTitle: String?,
outcomeIndex: Int,
price: BigDecimal,
amountUsdc: BigDecimal,
orderRequest: NewOrderRequest
): Result<String> {
return try {
val response = clobApi.createOrder(orderRequest)
if (response.isSuccessful && response.body() != null) {
val body = response.body()!!
if (body.success && body.orderId != null) {
saveTriggerRecord(
strategy,
periodStartUnix,
marketTitle,
outcomeIndex,
price,
amountUsdc,
body.orderId,
"success",
null,
triggerType = "MANUAL"
)
logger.info("手动下单成功: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, outcomeIndex=$outcomeIndex, orderId=${body.orderId}")
Result.success(body.orderId)
} else {
Result.failure(Exception(body.errorMsg ?: "unknown"))
}
} else {
val errorBody = response.errorBody()?.string().orEmpty()
Result.failure(Exception(errorBody.ifEmpty { "请求失败" }))
}
} catch (e: Exception) {
logger.error("手动下单异常: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix", e)
Result.failure(e)
}
}
@PreDestroy
fun destroy() {
// 清理所有周期上下文缓存,避免敏感信息(明文私钥、API Secret)在内存中保留
@@ -0,0 +1,5 @@
-- 添加触发类型字段到加密价差策略触发记录表
-- AUTO: 自动下单触发
-- MANUAL: 手动下单触发
ALTER TABLE crypto_tail_strategy_trigger
ADD COLUMN trigger_type VARCHAR(20) DEFAULT 'AUTO' COMMENT '触发类型:AUTO(自动)或 MANUAL(手动)';