feat(crypto-tail): 尾盘策略完整实现与优化
- 尾盘策略 CRUD、订单簿 WS 订阅、周期内触发下单 - 订单簿订阅日志增加市场 slug,便于排查 - 移除轮询,完全依赖 WebSocket(删除 CryptoTailStrategyScheduler) - FIXED 模式数量改为小数、向上取整,与签名服务一致 - 前端策略列表页、多语言与 API 对接 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,6 +2,7 @@ package com.wrbug.polymarketbot.api
|
||||
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
@@ -26,8 +27,39 @@ interface PolymarketGammaApi {
|
||||
@Query("clob_token_ids") clobTokenIds: List<String>? = null,
|
||||
@Query("include_tag") includeTag: Boolean? = null
|
||||
): Response<List<MarketResponse>>
|
||||
|
||||
/**
|
||||
* 根据 slug 获取事件(用于 5/15 分钟加密市场)
|
||||
* GET /events/slug/{slug},如 btc-updown-5m-1771007400
|
||||
* 返回事件含 markets(conditionId、endDate、clobTokenIds 等)
|
||||
*/
|
||||
@GET("/events/slug/{slug}")
|
||||
suspend fun getEventBySlug(@Path("slug") slug: String): Response<GammaEventBySlugResponse>
|
||||
}
|
||||
|
||||
/**
|
||||
* Gamma 按 slug 返回的事件结构
|
||||
*/
|
||||
data class GammaEventBySlugResponse(
|
||||
val id: String? = null,
|
||||
val slug: String? = null,
|
||||
val title: String? = null,
|
||||
val startDate: String? = null,
|
||||
val endDate: String? = null,
|
||||
val markets: List<GammaEventMarketItem>? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 事件下的市场项(5/15 分钟市场为二元,通常两个 outcome)
|
||||
*/
|
||||
data class GammaEventMarketItem(
|
||||
val conditionId: String? = null,
|
||||
val question: String? = null,
|
||||
val endDate: String? = null,
|
||||
val startDate: String? = null,
|
||||
val clobTokenIds: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 事件响应(从 MarketResponse.events 解析)
|
||||
*/
|
||||
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
package com.wrbug.polymarketbot.controller.cryptotail
|
||||
|
||||
import com.wrbug.polymarketbot.dto.ApiResponse
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailStrategyCreateRequest
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailStrategyDeleteRequest
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailStrategyDto
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailStrategyListRequest
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailStrategyListResponse
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailStrategyTriggerListRequest
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailStrategyTriggerListResponse
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailStrategyUpdateRequest
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailMarketOptionDto
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.cryptotail.CryptoTailStrategyService
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.MessageSource
|
||||
import org.springframework.http.ResponseEntity
|
||||
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
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/crypto-tail-strategy")
|
||||
class CryptoTailStrategyController(
|
||||
private val cryptoTailStrategyService: CryptoTailStrategyService,
|
||||
private val messageSource: MessageSource
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CryptoTailStrategyController::class.java)
|
||||
|
||||
@PostMapping("/list")
|
||||
fun list(@RequestBody request: CryptoTailStrategyListRequest): ResponseEntity<ApiResponse<CryptoTailStrategyListResponse>> {
|
||||
return try {
|
||||
val result = cryptoTailStrategyService.list(request)
|
||||
result.fold(
|
||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||
onFailure = { 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)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_LIST_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/create")
|
||||
fun create(@RequestBody request: CryptoTailStrategyCreateRequest): ResponseEntity<ApiResponse<CryptoTailStrategyDto>> {
|
||||
return try {
|
||||
val result = cryptoTailStrategyService.create(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_WINDOW_INVALID.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID
|
||||
ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED
|
||||
ErrorCode.CRYPTO_TAIL_STRATEGY_INTERVAL_INVALID.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_INTERVAL_INVALID
|
||||
ErrorCode.CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID
|
||||
else -> ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_CREATE_FAILED
|
||||
}
|
||||
ResponseEntity.ok(ApiResponse.error(code, messageSource = messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("创建尾盘策略异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_CREATE_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/update")
|
||||
fun update(@RequestBody request: CryptoTailStrategyUpdateRequest): ResponseEntity<ApiResponse<CryptoTailStrategyDto>> {
|
||||
return try {
|
||||
if (request.strategyId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource))
|
||||
}
|
||||
val result = cryptoTailStrategyService.update(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.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND
|
||||
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
|
||||
ErrorCode.CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID
|
||||
else -> ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_UPDATE_FAILED
|
||||
}
|
||||
ResponseEntity.ok(ApiResponse.error(code, messageSource = messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("更新尾盘策略异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_UPDATE_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/delete")
|
||||
fun delete(@RequestBody request: CryptoTailStrategyDeleteRequest): ResponseEntity<ApiResponse<Unit>> {
|
||||
return try {
|
||||
val strategyId = request.strategyId
|
||||
if (strategyId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource))
|
||||
}
|
||||
val result = cryptoTailStrategyService.delete(strategyId)
|
||||
result.fold(
|
||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(Unit)) },
|
||||
onFailure = { 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)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_DELETE_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/triggers")
|
||||
fun getTriggerRecords(@RequestBody request: CryptoTailStrategyTriggerListRequest): ResponseEntity<ApiResponse<CryptoTailStrategyTriggerListResponse>> {
|
||||
return try {
|
||||
if (request.strategyId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource))
|
||||
}
|
||||
val result = cryptoTailStrategyService.getTriggerRecords(request)
|
||||
result.fold(
|
||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||
onFailure = { e ->
|
||||
logger.error("查询触发记录失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询触发记录异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/market-options")
|
||||
fun getMarketOptions(): ResponseEntity<ApiResponse<List<CryptoTailMarketOptionDto>>> {
|
||||
return try {
|
||||
val options = listOf(
|
||||
CryptoTailMarketOptionDto(slug = "btc-updown-5m", title = "Bitcoin Up or Down - 5 minute", intervalSeconds = 300, periodStartUnix = 0L, endDate = null),
|
||||
CryptoTailMarketOptionDto(slug = "btc-updown-15m", title = "Bitcoin Up or Down - 15 minute", intervalSeconds = 900, periodStartUnix = 0L, endDate = null)
|
||||
)
|
||||
ResponseEntity.ok(ApiResponse.success(options))
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取市场选项异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
/**
|
||||
* 尾盘策略创建请求
|
||||
* 金额与价格使用 String,后端转为 BigDecimal
|
||||
*/
|
||||
data class CryptoTailStrategyCreateRequest(
|
||||
val accountId: Long = 0L,
|
||||
val name: String? = null,
|
||||
val marketSlugPrefix: String = "",
|
||||
val intervalSeconds: Int = 300,
|
||||
val windowStartSeconds: Int = 0,
|
||||
val windowEndSeconds: Int = 0,
|
||||
val minPrice: String = "0",
|
||||
val maxPrice: String? = null,
|
||||
val amountMode: String = "RATIO",
|
||||
val amountValue: String = "0",
|
||||
val enabled: Boolean = true
|
||||
)
|
||||
|
||||
/**
|
||||
* 尾盘策略更新请求
|
||||
*/
|
||||
data class CryptoTailStrategyUpdateRequest(
|
||||
val strategyId: Long = 0L,
|
||||
val name: String? = null,
|
||||
val windowStartSeconds: Int? = null,
|
||||
val windowEndSeconds: Int? = null,
|
||||
val minPrice: String? = null,
|
||||
val maxPrice: String? = null,
|
||||
val amountMode: String? = null,
|
||||
val amountValue: String? = null,
|
||||
val enabled: Boolean? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 尾盘策略列表请求
|
||||
*/
|
||||
data class CryptoTailStrategyListRequest(
|
||||
val accountId: Long? = null,
|
||||
val enabled: Boolean? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 尾盘策略 DTO(列表与详情)
|
||||
*/
|
||||
data class CryptoTailStrategyDto(
|
||||
val id: Long = 0L,
|
||||
val accountId: Long = 0L,
|
||||
val name: String? = null,
|
||||
val marketSlugPrefix: String = "",
|
||||
val marketTitle: String? = null,
|
||||
val intervalSeconds: Int = 0,
|
||||
val windowStartSeconds: Int = 0,
|
||||
val windowEndSeconds: Int = 0,
|
||||
val minPrice: String = "0",
|
||||
val maxPrice: String = "1",
|
||||
val amountMode: String = "RATIO",
|
||||
val amountValue: String = "0",
|
||||
val enabled: Boolean = true,
|
||||
val lastTriggerAt: Long? = null,
|
||||
val createdAt: Long = 0L,
|
||||
val updatedAt: Long = 0L
|
||||
)
|
||||
|
||||
/**
|
||||
* 尾盘策略列表响应
|
||||
*/
|
||||
data class CryptoTailStrategyListResponse(
|
||||
val list: List<CryptoTailStrategyDto> = emptyList()
|
||||
)
|
||||
|
||||
/**
|
||||
* 尾盘策略删除请求
|
||||
*/
|
||||
data class CryptoTailStrategyDeleteRequest(
|
||||
val strategyId: Long = 0L
|
||||
)
|
||||
|
||||
/**
|
||||
* 触发记录列表请求
|
||||
*/
|
||||
data class CryptoTailStrategyTriggerListRequest(
|
||||
val strategyId: Long = 0L,
|
||||
val page: Int = 1,
|
||||
val pageSize: Int = 20,
|
||||
val status: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 触发记录 DTO
|
||||
*/
|
||||
data class CryptoTailStrategyTriggerDto(
|
||||
val id: Long = 0L,
|
||||
val strategyId: Long = 0L,
|
||||
val periodStartUnix: Long = 0L,
|
||||
val marketTitle: String? = null,
|
||||
val outcomeIndex: Int = 0,
|
||||
val triggerPrice: String = "0",
|
||||
val amountUsdc: String = "0",
|
||||
val orderId: String? = null,
|
||||
val status: String = "success",
|
||||
val failReason: String? = null,
|
||||
val createdAt: Long = 0L
|
||||
)
|
||||
|
||||
/**
|
||||
* 触发记录分页响应
|
||||
*/
|
||||
data class CryptoTailStrategyTriggerListResponse(
|
||||
val list: List<CryptoTailStrategyTriggerDto> = emptyList(),
|
||||
val total: Long = 0L
|
||||
)
|
||||
|
||||
/**
|
||||
* 5/15 分钟市场项(供前端选择市场)
|
||||
*/
|
||||
data class CryptoTailMarketOptionDto(
|
||||
val slug: String = "",
|
||||
val title: String = "",
|
||||
val intervalSeconds: Int = 0,
|
||||
val periodStartUnix: Long = 0L,
|
||||
val endDate: String? = null
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.wrbug.polymarketbot.entity
|
||||
|
||||
import jakarta.persistence.*
|
||||
import java.math.BigDecimal
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
|
||||
/**
|
||||
* 加密市场尾盘策略实体
|
||||
* 5/15 分钟 Up or Down 市场,在周期内时间窗口、价格进入区间时市价买入
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "crypto_tail_strategy")
|
||||
data class CryptoTailStrategy(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
@Column(name = "account_id", nullable = false)
|
||||
val accountId: Long = 0L,
|
||||
|
||||
@Column(name = "name", length = 255)
|
||||
val name: String? = null,
|
||||
|
||||
@Column(name = "market_slug_prefix", nullable = false, length = 64)
|
||||
val marketSlugPrefix: String = "",
|
||||
|
||||
@Column(name = "interval_seconds", nullable = false)
|
||||
val intervalSeconds: Int = 300,
|
||||
|
||||
@Column(name = "window_start_seconds", nullable = false)
|
||||
val windowStartSeconds: Int = 0,
|
||||
|
||||
@Column(name = "window_end_seconds", nullable = false)
|
||||
val windowEndSeconds: Int = 0,
|
||||
|
||||
@Column(name = "min_price", nullable = false, precision = 20, scale = 8)
|
||||
val minPrice: BigDecimal = BigDecimal.ONE,
|
||||
|
||||
@Column(name = "max_price", nullable = false, precision = 20, scale = 8)
|
||||
val maxPrice: BigDecimal = BigDecimal.ONE,
|
||||
|
||||
@Column(name = "amount_mode", nullable = false, length = 10)
|
||||
val amountMode: String = "RATIO",
|
||||
|
||||
@Column(name = "amount_value", nullable = false, precision = 20, scale = 8)
|
||||
val amountValue: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
@Column(name = "enabled", nullable = false)
|
||||
val enabled: Boolean = true,
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
var updatedAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.wrbug.polymarketbot.entity
|
||||
|
||||
import jakarta.persistence.*
|
||||
import java.math.BigDecimal
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
|
||||
/**
|
||||
* 尾盘策略触发记录
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "crypto_tail_strategy_trigger")
|
||||
data class CryptoTailStrategyTrigger(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
@Column(name = "strategy_id", nullable = false)
|
||||
val strategyId: Long = 0L,
|
||||
|
||||
@Column(name = "period_start_unix", nullable = false)
|
||||
val periodStartUnix: Long = 0L,
|
||||
|
||||
@Column(name = "market_title", length = 500)
|
||||
val marketTitle: String? = null,
|
||||
|
||||
@Column(name = "outcome_index", nullable = false)
|
||||
val outcomeIndex: Int = 0,
|
||||
|
||||
@Column(name = "trigger_price", nullable = false, precision = 20, scale = 8)
|
||||
val triggerPrice: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
@Column(name = "amount_usdc", nullable = false, precision = 20, scale = 8)
|
||||
val amountUsdc: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
@Column(name = "order_id", length = 128)
|
||||
val orderId: String? = null,
|
||||
|
||||
@Column(name = "status", nullable = false, length = 20)
|
||||
val status: String = "success",
|
||||
|
||||
@Column(name = "fail_reason", length = 500)
|
||||
val failReason: String? = null,
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
@@ -158,6 +158,13 @@ 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"),
|
||||
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"),
|
||||
CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID(4714, "投入方式仅支持 RATIO 或 FIXED", "error.crypto_tail_strategy_amount_mode_invalid"),
|
||||
|
||||
// 统计相关 (4801-4899)
|
||||
STATISTICS_FETCH_FAILED(4801, "获取统计信息失败", "error.statistics_fetch_failed"),
|
||||
ORDER_LIST_FETCH_FAILED(4802, "查询订单列表失败", "error.order_list_fetch_failed"),
|
||||
@@ -250,7 +257,14 @@ enum class ErrorCode(
|
||||
SERVER_BACKTEST_HISTORICAL_DATA_FETCH_FAILED(5610, "历史数据获取失败", "error.server.backtest_historical_data_fetch_failed"),
|
||||
SERVER_BACKTEST_STOP_FAILED(5611, "停止回测任务失败", "error.server.backtest_stop_failed"),
|
||||
SERVER_BACKTEST_RETRY_FAILED(5612, "重试回测任务失败", "error.server.backtest_retry_failed"),
|
||||
SERVER_BACKTEST_RERUN_FAILED(5613, "按配置重新测试失败", "error.server.backtest_rerun_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"),
|
||||
SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED(5624, "查询触发记录失败", "error.server.crypto_tail_strategy_triggers_fetch_failed");
|
||||
|
||||
companion object {
|
||||
/**
|
||||
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package com.wrbug.polymarketbot.event
|
||||
|
||||
import org.springframework.context.ApplicationEvent
|
||||
|
||||
/**
|
||||
* 尾盘策略创建/更新/启用状态变更后发布,用于立即触发一轮执行检查。
|
||||
*/
|
||||
class CryptoTailStrategyChangedEvent(source: Any) : ApplicationEvent(source)
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.wrbug.polymarketbot.repository
|
||||
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface CryptoTailStrategyRepository : JpaRepository<CryptoTailStrategy, Long> {
|
||||
|
||||
fun findAllByAccountId(accountId: Long): List<CryptoTailStrategy>
|
||||
fun findAllByEnabledTrue(): List<CryptoTailStrategy>
|
||||
fun findByAccountIdAndEnabled(accountId: Long, enabled: Boolean): List<CryptoTailStrategy>
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.wrbug.polymarketbot.repository
|
||||
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategyTrigger
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface CryptoTailStrategyTriggerRepository : JpaRepository<CryptoTailStrategyTrigger, Long> {
|
||||
|
||||
fun findByStrategyIdAndPeriodStartUnix(strategyId: Long, periodStartUnix: Long): CryptoTailStrategyTrigger?
|
||||
fun findAllByStrategyIdOrderByCreatedAtDesc(strategyId: Long, pageable: Pageable): Page<CryptoTailStrategyTrigger>
|
||||
fun findAllByStrategyIdAndStatusOrderByCreatedAtDesc(strategyId: Long, status: String, pageable: Pageable): Page<CryptoTailStrategyTrigger>
|
||||
fun countByStrategyIdAndStatus(strategyId: Long, status: String): Long
|
||||
}
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
package com.wrbug.polymarketbot.service.cryptotail
|
||||
|
||||
import com.wrbug.polymarketbot.api.GammaEventBySlugResponse
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
|
||||
import com.wrbug.polymarketbot.event.CryptoTailStrategyChangedEvent
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
|
||||
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.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
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 java.math.BigDecimal
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* 尾盘策略订单簿 WebSocket 监听:订阅 CLOB Market 频道,收到订单簿/价格变更时若满足条件立即触发下单。
|
||||
*/
|
||||
@Service
|
||||
class CryptoTailOrderbookWsService(
|
||||
private val strategyRepository: CryptoTailStrategyRepository,
|
||||
private val executionService: CryptoTailStrategyExecutionService,
|
||||
private val retrofitFactory: RetrofitFactory
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CryptoTailOrderbookWsService::class.java)
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
/** tokenId -> list of (strategy, periodStartUnix, marketTitle, tokenIds, outcomeIndex) */
|
||||
private val tokenToEntries = AtomicReference<Map<String, List<WsBookEntry>>>(emptyMap())
|
||||
|
||||
private var webSocket: WebSocket? = null
|
||||
private val wsUrl = PolymarketConstants.RTDS_WS_URL + "/ws/market"
|
||||
private val client = createClient().build()
|
||||
|
||||
/** 订阅成功后设置的倒计时 Job,在周期结束时自动刷新订阅 */
|
||||
private var periodEndCountdownJob: Job? = null
|
||||
|
||||
/** 重连延迟(毫秒) */
|
||||
private val reconnectDelayMs = 10_000L
|
||||
|
||||
data class WsBookEntry(
|
||||
val strategy: CryptoTailStrategy,
|
||||
val periodStartUnix: Long,
|
||||
val marketTitle: String?,
|
||||
val tokenIds: List<String>,
|
||||
val outcomeIndex: Int
|
||||
)
|
||||
|
||||
@PostConstruct
|
||||
fun init() {
|
||||
connect()
|
||||
}
|
||||
|
||||
private fun connect() {
|
||||
if (webSocket != null) return
|
||||
try {
|
||||
val request = Request.Builder().url(wsUrl).build()
|
||||
webSocket = client.newWebSocket(request, object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
|
||||
logger.info("尾盘策略订单簿 WebSocket 已连接")
|
||||
refreshAndSubscribe()
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
handleMessage(text)
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
this@CryptoTailOrderbookWsService.webSocket = null
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
|
||||
logger.warn("尾盘策略订单簿 WebSocket 异常: ${t.message}")
|
||||
this@CryptoTailOrderbookWsService.webSocket = null
|
||||
scheduleReconnect()
|
||||
}
|
||||
})
|
||||
} catch (e: Exception) {
|
||||
logger.error("尾盘策略订单簿 WebSocket 连接失败: ${e.message}", e)
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private var reconnectJob: Job? = null
|
||||
|
||||
private fun scheduleReconnect() {
|
||||
if (reconnectJob?.isActive == true) return
|
||||
reconnectJob = scope.launch {
|
||||
delay(reconnectDelayMs)
|
||||
reconnectJob = null
|
||||
logger.info("尾盘策略订单簿 WebSocket 尝试重连")
|
||||
connect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMessage(text: String) {
|
||||
if (text == "pong" || text.isEmpty()) return
|
||||
maybeRefreshSubscriptionIfPeriodChanged()
|
||||
val json = text.fromJson<com.google.gson.JsonObject>() ?: return
|
||||
val eventType = (json.get("event_type") as? com.google.gson.JsonPrimitive)?.asString ?: return
|
||||
|
||||
when (eventType) {
|
||||
"book" -> {
|
||||
val assetId = (json.get("asset_id") as? com.google.gson.JsonPrimitive)?.asString ?: return
|
||||
val bids = json.get("bids") as? com.google.gson.JsonArray
|
||||
val firstBid = bids?.get(0) as? com.google.gson.JsonObject
|
||||
val bestBid = (firstBid?.get("price") as? com.google.gson.JsonPrimitive)?.asString?.toSafeBigDecimal()
|
||||
if (bestBid != null) onBestBid(assetId, bestBid)
|
||||
}
|
||||
"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) onBestBid(assetId, bestBid)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onBestBid(tokenId: String, bestBid: BigDecimal) {
|
||||
val entries = tokenToEntries.get()[tokenId] ?: return
|
||||
val nowSeconds = System.currentTimeMillis() / 1000
|
||||
for (e in entries) {
|
||||
val windowStart = e.periodStartUnix + e.strategy.windowStartSeconds
|
||||
val windowEnd = e.periodStartUnix + e.strategy.windowEndSeconds
|
||||
if (nowSeconds < windowStart || nowSeconds >= windowEnd) continue
|
||||
scope.launch {
|
||||
try {
|
||||
runBlocking {
|
||||
executionService.tryTriggerWithPriceFromWs(
|
||||
strategy = e.strategy,
|
||||
periodStartUnix = e.periodStartUnix,
|
||||
marketTitle = e.marketTitle,
|
||||
tokenIds = e.tokenIds,
|
||||
outcomeIndex = e.outcomeIndex,
|
||||
bestBid = bestBid
|
||||
)
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
logger.error("WS 触发下单异常: strategyId=${e.strategy.id}, ${ex.message}", ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 事件驱动:仅在收到 WS 消息时检查当前周期是否变化,若变化则刷新订阅,无需定时轮询。
|
||||
*/
|
||||
private fun maybeRefreshSubscriptionIfPeriodChanged() {
|
||||
val subscribed = tokenToEntries.get().values.flatten().distinctBy { it.strategy.id }.associate { it.strategy.id!! to it.periodStartUnix }
|
||||
if (subscribed.isEmpty()) return
|
||||
val strategies = strategyRepository.findAllByEnabledTrue()
|
||||
val nowSeconds = System.currentTimeMillis() / 1000
|
||||
val currentStrategyIds = strategies.map { it.id!! }.toSet()
|
||||
if (subscribed.keys != currentStrategyIds) {
|
||||
refreshAndSubscribe()
|
||||
return
|
||||
}
|
||||
for (s in strategies) {
|
||||
val currentPeriod = (nowSeconds / s.intervalSeconds) * s.intervalSeconds
|
||||
val subPeriod = subscribed[s.id!!] ?: continue
|
||||
if (currentPeriod != subPeriod) {
|
||||
refreshAndSubscribe()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshAndSubscribe() {
|
||||
periodEndCountdownJob?.cancel()
|
||||
periodEndCountdownJob = null
|
||||
val (tokenIds, newMap) = buildSubscriptionMap()
|
||||
tokenToEntries.set(newMap)
|
||||
if (tokenIds.isEmpty()) return
|
||||
val marketSlugs = newMap.values.asSequence().flatten()
|
||||
.distinctBy { "${it.strategy.marketSlugPrefix}-${it.periodStartUnix}" }
|
||||
.map { "${it.strategy.marketSlugPrefix}-${it.periodStartUnix}" }
|
||||
.toList()
|
||||
val msg = """{"type":"MARKET","assets_ids":${tokenIds.toJson()}}"""
|
||||
try {
|
||||
webSocket?.send(msg)
|
||||
logger.info("尾盘策略订单簿订阅: ${tokenIds.size} 个 token, 市场: $marketSlugs")
|
||||
} catch (e: Exception) {
|
||||
logger.warn("发送订阅失败: ${e.message}")
|
||||
return
|
||||
}
|
||||
scheduleRefreshAtPeriodEnd(newMap)
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅成功后设置倒计时:在当前周期结束时自动刷新订阅,无需等消息触发。
|
||||
*/
|
||||
private fun scheduleRefreshAtPeriodEnd(newMap: Map<String, List<WsBookEntry>>) {
|
||||
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
|
||||
refreshAndSubscribe()
|
||||
}
|
||||
logger.debug("尾盘策略订单簿订阅倒计时: ${delayMs / 1000}s 后刷新")
|
||||
}
|
||||
|
||||
private fun buildSubscriptionMap(): Pair<List<String>, Map<String, List<WsBookEntry>>> {
|
||||
val strategies = strategyRepository.findAllByEnabledTrue()
|
||||
val nowSeconds = System.currentTimeMillis() / 1000
|
||||
val tokenIdSet = mutableSetOf<String>()
|
||||
val map = mutableMapOf<String, MutableList<WsBookEntry>>()
|
||||
|
||||
for (strategy in strategies) {
|
||||
val interval = strategy.intervalSeconds
|
||||
val periodStartUnix = (nowSeconds / interval) * interval
|
||||
val windowEnd = periodStartUnix + strategy.windowEndSeconds
|
||||
if (nowSeconds >= windowEnd) continue
|
||||
val slug = "${strategy.marketSlugPrefix}-$periodStartUnix"
|
||||
val event = fetchEventBySlug(slug).getOrNull() ?: continue
|
||||
val market = event.markets?.firstOrNull() ?: continue
|
||||
val tokenIds = parseClobTokenIds(market.clobTokenIds)
|
||||
if (tokenIds.size < 2) continue
|
||||
tokenIdSet.addAll(tokenIds)
|
||||
for (i in tokenIds.indices) {
|
||||
map.getOrPut(tokenIds[i]) { mutableListOf() }.add(
|
||||
WsBookEntry(strategy, periodStartUnix, event.title, tokenIds, i)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return Pair(tokenIdSet.toList(), map)
|
||||
}
|
||||
|
||||
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()
|
||||
val parsed = clobTokenIds.fromJson<List<String>>()
|
||||
return parsed ?: emptyList()
|
||||
}
|
||||
|
||||
@EventListener
|
||||
fun onStrategyChanged(event: CryptoTailStrategyChangedEvent) {
|
||||
refreshAndSubscribe()
|
||||
}
|
||||
}
|
||||
+444
@@ -0,0 +1,444 @@
|
||||
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.entity.Account
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategyTrigger
|
||||
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository
|
||||
import com.wrbug.polymarketbot.service.accounts.AccountService
|
||||
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
||||
import com.wrbug.polymarketbot.service.copytrading.orders.OrderSigningService
|
||||
import com.wrbug.polymarketbot.util.CryptoUtils
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.fromJson
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/** 尾盘策略固定下单价格(最高价 0.99),不再在触发时拉取最优价 */
|
||||
private const val TRIGGER_FIXED_PRICE = "0.99"
|
||||
|
||||
/** 数量小数位数,与 OrderSigningService 的 roundConfig.size 一致 */
|
||||
private const val SIZE_DECIMAL_SCALE = 2
|
||||
|
||||
/**
|
||||
* 周期内预置上下文:账户、解密凭证、费率、签名类型、CLOB 客户端;FIXED 模式含预签订单。
|
||||
* 触发时 RATIO 仅算 size 并签名提交,FIXED 直接提交预签订单。
|
||||
*/
|
||||
private data class PeriodContext(
|
||||
val strategy: CryptoTailStrategy,
|
||||
val periodStartUnix: Long,
|
||||
val account: Account,
|
||||
val decryptedPrivateKey: String,
|
||||
val apiSecretDecrypted: String,
|
||||
val apiPassphraseDecrypted: String,
|
||||
val clobApi: PolymarketClobApi,
|
||||
val feeRateByTokenId: Map<String, String>,
|
||||
val signatureType: Int,
|
||||
val tokenIds: List<String>,
|
||||
val marketTitle: String?,
|
||||
val preSignedOrderByOutcome: Map<Int, NewOrderRequest>?
|
||||
)
|
||||
|
||||
/**
|
||||
* 尾盘策略执行服务:按周期与时间窗口检查价格并下单,每周期最多触发一次。
|
||||
* 周期开始预置账户、解密、费率、签名类型、CLOB 客户端;FIXED 模式预签两张订单,触发时仅提交;RATIO 模式触发时再算 size 并签名提交。
|
||||
*/
|
||||
@Service
|
||||
class CryptoTailStrategyExecutionService(
|
||||
private val strategyRepository: CryptoTailStrategyRepository,
|
||||
private val triggerRepository: CryptoTailStrategyTriggerRepository,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val accountService: AccountService,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val clobService: PolymarketClobService,
|
||||
private val orderSigningService: OrderSigningService,
|
||||
private val cryptoUtils: CryptoUtils
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CryptoTailStrategyExecutionService::class.java)
|
||||
|
||||
private val maxRetryAttempts = 3
|
||||
private val retryDelayMs = 2000L
|
||||
|
||||
/** 按 (strategyId, periodStartUnix) 加锁,避免同一周期被调度器与 WebSocket 等多路并发重复下单 */
|
||||
private val triggerMutexMap = ConcurrentHashMap<String, Mutex>()
|
||||
|
||||
private fun triggerLockKey(strategyId: Long, periodStartUnix: Long): String = "$strategyId-$periodStartUnix"
|
||||
|
||||
private fun getTriggerMutex(strategyId: Long, periodStartUnix: Long): Mutex =
|
||||
triggerMutexMap.getOrPut(triggerLockKey(strategyId, periodStartUnix)) { Mutex() }
|
||||
|
||||
/** 周期预置上下文缓存:(strategyId-periodStartUnix) -> PeriodContext,过期周期在读取时剔除 */
|
||||
private val periodContextCache = ConcurrentHashMap<String, PeriodContext>()
|
||||
|
||||
/**
|
||||
* 在周期内首次需要时构建并缓存预置上下文;失败返回 null,触发流程将走完整路径。
|
||||
* 预置:账户、解密、费率、签名类型、CLOB 客户端;FIXED 时预签两个 outcome 的订单。
|
||||
*/
|
||||
private suspend fun ensurePeriodContext(
|
||||
strategy: CryptoTailStrategy,
|
||||
periodStartUnix: Long,
|
||||
tokenIds: List<String>,
|
||||
marketTitle: String?
|
||||
): PeriodContext? {
|
||||
val key = triggerLockKey(strategy.id!!, periodStartUnix)
|
||||
periodContextCache[key]?.let { return it }
|
||||
|
||||
val account = accountRepository.findById(strategy.accountId).orElse(null) ?: return null
|
||||
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) return null
|
||||
|
||||
val decryptedKey = try {
|
||||
cryptoUtils.decrypt(account.privateKey) ?: return null
|
||||
} catch (e: Exception) {
|
||||
logger.warn("尾盘策略周期上下文解密私钥失败: accountId=${account.id}", e)
|
||||
return null
|
||||
}
|
||||
val apiSecret = try {
|
||||
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)
|
||||
val feeRateByTokenId = tokenIds.associate { tokenId ->
|
||||
tokenId to (clobService.getFeeRate(tokenId).getOrNull()?.toString() ?: "0")
|
||||
}
|
||||
val signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
|
||||
|
||||
val preSignedOrderByOutcome: Map<Int, NewOrderRequest>? = when (strategy.amountMode.uppercase()) {
|
||||
"RATIO" -> null
|
||||
else -> {
|
||||
val amountUsdc = strategy.amountValue
|
||||
if (amountUsdc < BigDecimal("1")) return null
|
||||
val price = BigDecimal(TRIGGER_FIXED_PRICE)
|
||||
val size = computeSize(amountUsdc, price)
|
||||
val orders = mutableMapOf<Int, NewOrderRequest>()
|
||||
for (i in 0..1) {
|
||||
if (i >= tokenIds.size) break
|
||||
val tokenId = tokenIds[i]
|
||||
val feeRateBps = feeRateByTokenId[tokenId] ?: "0"
|
||||
try {
|
||||
val signedOrder = orderSigningService.createAndSignOrder(
|
||||
privateKey = decryptedKey,
|
||||
makerAddress = account.proxyAddress,
|
||||
tokenId = tokenId,
|
||||
side = "BUY",
|
||||
price = TRIGGER_FIXED_PRICE,
|
||||
size = size,
|
||||
signatureType = signatureType,
|
||||
nonce = "0",
|
||||
feeRateBps = feeRateBps,
|
||||
expiration = "0"
|
||||
)
|
||||
orders[i] = NewOrderRequest(
|
||||
order = signedOrder,
|
||||
owner = account.apiKey!!,
|
||||
orderType = "FAK",
|
||||
deferExec = false
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("尾盘策略预签订单失败: strategyId=${strategy.id}, outcomeIndex=$i", e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
orders.ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
val ctx = PeriodContext(
|
||||
strategy = strategy,
|
||||
periodStartUnix = periodStartUnix,
|
||||
account = account,
|
||||
decryptedPrivateKey = decryptedKey,
|
||||
apiSecretDecrypted = apiSecret,
|
||||
apiPassphraseDecrypted = apiPassphrase,
|
||||
clobApi = clobApi,
|
||||
feeRateByTokenId = feeRateByTokenId,
|
||||
signatureType = signatureType,
|
||||
tokenIds = tokenIds,
|
||||
marketTitle = marketTitle,
|
||||
preSignedOrderByOutcome = preSignedOrderByOutcome
|
||||
)
|
||||
periodContextCache[key] = ctx
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* 按投入金额和价格计算可买张数:size = ceil(amountUsdc/price),保留小数,至少 1。
|
||||
* 与 OrderSigningService 一致使用小数数量,向上取整保证不超过投入金额。
|
||||
*/
|
||||
private fun computeSize(amountUsdc: BigDecimal, price: BigDecimal): String {
|
||||
val size = amountUsdc.divide(price, SIZE_DECIMAL_SCALE, RoundingMode.UP).max(BigDecimal.ONE)
|
||||
return size.toPlainString()
|
||||
}
|
||||
|
||||
private fun getOrInvalidatePeriodContext(strategy: CryptoTailStrategy, periodStartUnix: Long): PeriodContext? {
|
||||
val key = triggerLockKey(strategy.id!!, periodStartUnix)
|
||||
val nowSeconds = System.currentTimeMillis() / 1000
|
||||
val ctx = periodContextCache[key] ?: return null
|
||||
if (periodStartUnix + strategy.intervalSeconds <= nowSeconds) {
|
||||
periodContextCache.remove(key)
|
||||
return null
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* 由订单簿 WebSocket 触发:当收到某 token 的 bestBid 且满足区间时调用,若本周期未触发则下单。
|
||||
*/
|
||||
suspend fun tryTriggerWithPriceFromWs(
|
||||
strategy: CryptoTailStrategy,
|
||||
periodStartUnix: Long,
|
||||
marketTitle: String?,
|
||||
tokenIds: List<String>,
|
||||
outcomeIndex: Int,
|
||||
bestBid: BigDecimal
|
||||
) {
|
||||
if (outcomeIndex < 0 || outcomeIndex >= tokenIds.size) return
|
||||
if (bestBid < strategy.minPrice || bestBid > strategy.maxPrice) return
|
||||
|
||||
val mutex = getTriggerMutex(strategy.id!!, periodStartUnix)
|
||||
mutex.withLock {
|
||||
if (triggerRepository.findByStrategyIdAndPeriodStartUnix(strategy.id!!, periodStartUnix) != null) return@withLock
|
||||
ensurePeriodContext(strategy, periodStartUnix, tokenIds, marketTitle)
|
||||
placeOrderForTrigger(strategy, periodStartUnix, marketTitle, tokenIds, outcomeIndex, bestBid)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun placeOrderForTrigger(
|
||||
strategy: CryptoTailStrategy,
|
||||
periodStartUnix: Long,
|
||||
marketTitle: String?,
|
||||
tokenIds: List<String>,
|
||||
outcomeIndex: Int,
|
||||
triggerPrice: BigDecimal
|
||||
) {
|
||||
val ctx = getOrInvalidatePeriodContext(strategy, periodStartUnix)
|
||||
|
||||
if (ctx != null) {
|
||||
val amountUsdc = when (strategy.amountMode.uppercase()) {
|
||||
"RATIO" -> {
|
||||
val balanceResult = accountService.getAccountBalance(ctx.account.id)
|
||||
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", "投入金额不足")
|
||||
return
|
||||
}
|
||||
|
||||
val tokenId = tokenIds.getOrNull(outcomeIndex) ?: run {
|
||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", "tokenIds 越界")
|
||||
return
|
||||
}
|
||||
|
||||
when {
|
||||
ctx.preSignedOrderByOutcome != null -> {
|
||||
val orderRequest = ctx.preSignedOrderByOutcome[outcomeIndex]
|
||||
if (orderRequest != null) {
|
||||
submitOrderAndSaveRecord(ctx.clobApi, strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, orderRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
strategy.amountMode.uppercase() == "RATIO" -> {
|
||||
val price = BigDecimal(TRIGGER_FIXED_PRICE)
|
||||
val size = computeSize(amountUsdc, price)
|
||||
val feeRateBps = ctx.feeRateByTokenId[tokenId] ?: "0"
|
||||
val signedOrder = orderSigningService.createAndSignOrder(
|
||||
privateKey = ctx.decryptedPrivateKey,
|
||||
makerAddress = ctx.account.proxyAddress,
|
||||
tokenId = tokenId,
|
||||
side = "BUY",
|
||||
price = TRIGGER_FIXED_PRICE,
|
||||
size = size,
|
||||
signatureType = ctx.signatureType,
|
||||
nonce = "0",
|
||||
feeRateBps = feeRateBps,
|
||||
expiration = "0"
|
||||
)
|
||||
val orderRequest = NewOrderRequest(
|
||||
order = signedOrder,
|
||||
owner = ctx.account.apiKey!!,
|
||||
orderType = "FAK",
|
||||
deferExec = false
|
||||
)
|
||||
submitOrderAndSaveRecord(ctx.clobApi, strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, orderRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
placeOrderForTriggerSlowPath(strategy, periodStartUnix, marketTitle, tokenIds, outcomeIndex, triggerPrice)
|
||||
}
|
||||
|
||||
private suspend fun submitOrderAndSaveRecord(
|
||||
clobApi: PolymarketClobApi,
|
||||
strategy: CryptoTailStrategy,
|
||||
periodStartUnix: Long,
|
||||
marketTitle: String?,
|
||||
outcomeIndex: Int,
|
||||
triggerPrice: BigDecimal,
|
||||
amountUsdc: BigDecimal,
|
||||
orderRequest: NewOrderRequest
|
||||
) {
|
||||
var lastError: String? = null
|
||||
for (attempt in 1..maxRetryAttempts) {
|
||||
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, triggerPrice, amountUsdc, body.orderId, "success", null)
|
||||
logger.info("尾盘策略下单成功: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, outcomeIndex=$outcomeIndex, orderId=${body.orderId}")
|
||||
return
|
||||
}
|
||||
lastError = body.errorMsg ?: "unknown"
|
||||
} else {
|
||||
lastError = "HTTP ${response.code()} ${response.errorBody()?.string()?.take(200)}"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
lastError = e.message ?: "exception"
|
||||
logger.warn("尾盘策略下单异常 (attempt $attempt/$maxRetryAttempts): strategyId=${strategy.id}, error=$lastError")
|
||||
}
|
||||
if (attempt < maxRetryAttempts) delay(retryDelayMs)
|
||||
}
|
||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", lastError)
|
||||
logger.warn("尾盘策略下单失败(已重试${maxRetryAttempts}次): strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, reason=$lastError")
|
||||
}
|
||||
|
||||
/** 无预置上下文时的完整流程:固定价格 0.99,账户/解密/费率/签名在触发时执行 */
|
||||
private suspend fun placeOrderForTriggerSlowPath(
|
||||
strategy: CryptoTailStrategy,
|
||||
periodStartUnix: Long,
|
||||
marketTitle: String?,
|
||||
tokenIds: List<String>,
|
||||
outcomeIndex: Int,
|
||||
triggerPrice: BigDecimal
|
||||
) {
|
||||
val account = accountRepository.findById(strategy.accountId).orElse(null) ?: run {
|
||||
logger.warn("账户不存在: accountId=${strategy.accountId}")
|
||||
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凭证")
|
||||
return
|
||||
}
|
||||
|
||||
val balanceResult = accountService.getAccountBalance(account.id)
|
||||
val availableBalance = balanceResult.getOrNull()?.availableBalance?.toSafeBigDecimal() ?: BigDecimal.ZERO
|
||||
val amountUsdc = when (strategy.amountMode.uppercase()) {
|
||||
"RATIO" -> 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", "投入金额不足")
|
||||
return
|
||||
}
|
||||
|
||||
val tokenId = tokenIds.getOrNull(outcomeIndex) ?: run {
|
||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", "tokenIds 越界")
|
||||
return
|
||||
}
|
||||
val price = BigDecimal(TRIGGER_FIXED_PRICE)
|
||||
val size = computeSize(amountUsdc, price)
|
||||
|
||||
val decryptedKey = try {
|
||||
cryptoUtils.decrypt(account.privateKey) ?: ""
|
||||
} catch (e: Exception) {
|
||||
logger.error("解密私钥失败: accountId=${account.id}", e)
|
||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", "解密私钥失败")
|
||||
return
|
||||
}
|
||||
val apiSecret = try {
|
||||
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)
|
||||
val feeRateBps = clobService.getFeeRate(tokenId).getOrNull()?.toString() ?: "0"
|
||||
val signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
|
||||
|
||||
val signedOrder = orderSigningService.createAndSignOrder(
|
||||
privateKey = decryptedKey,
|
||||
makerAddress = account.proxyAddress,
|
||||
tokenId = tokenId,
|
||||
side = "BUY",
|
||||
price = TRIGGER_FIXED_PRICE,
|
||||
size = size,
|
||||
signatureType = signatureType,
|
||||
nonce = "0",
|
||||
feeRateBps = feeRateBps,
|
||||
expiration = "0"
|
||||
)
|
||||
val orderRequest = NewOrderRequest(
|
||||
order = signedOrder,
|
||||
owner = account.apiKey!!,
|
||||
orderType = "FAK",
|
||||
deferExec = false
|
||||
)
|
||||
submitOrderAndSaveRecord(clobApi, strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, orderRequest)
|
||||
}
|
||||
|
||||
private suspend fun fetchEventBySlug(slug: String): Result<GammaEventBySlugResponse> {
|
||||
return try {
|
||||
val gammaApi = retrofitFactory.createGammaApi()
|
||||
val response = gammaApi.getEventBySlug(slug)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
Result.success(response.body()!!)
|
||||
} else {
|
||||
val msg = if (response.code() == 404) "404" else "code=${response.code()}"
|
||||
Result.failure(Exception(msg))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseClobTokenIds(clobTokenIds: String?): List<String> {
|
||||
if (clobTokenIds.isNullOrBlank()) return emptyList()
|
||||
val parsed = clobTokenIds.fromJson<List<String>>()
|
||||
return parsed ?: emptyList()
|
||||
}
|
||||
|
||||
private fun saveTriggerRecord(
|
||||
strategy: CryptoTailStrategy,
|
||||
periodStartUnix: Long,
|
||||
marketTitle: String?,
|
||||
outcomeIndex: Int,
|
||||
triggerPrice: BigDecimal,
|
||||
amountUsdc: BigDecimal,
|
||||
orderId: String?,
|
||||
status: String,
|
||||
failReason: String?
|
||||
) {
|
||||
val record = CryptoTailStrategyTrigger(
|
||||
strategyId = strategy.id!!,
|
||||
periodStartUnix = periodStartUnix,
|
||||
marketTitle = marketTitle,
|
||||
outcomeIndex = outcomeIndex,
|
||||
triggerPrice = triggerPrice,
|
||||
amountUsdc = amountUsdc,
|
||||
orderId = orderId,
|
||||
status = status,
|
||||
failReason = failReason
|
||||
)
|
||||
triggerRepository.save(record)
|
||||
}
|
||||
}
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
package com.wrbug.polymarketbot.service.cryptotail
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategyTrigger
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository
|
||||
import com.wrbug.polymarketbot.event.CryptoTailStrategyChangedEvent
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
|
||||
@Service
|
||||
class CryptoTailStrategyService(
|
||||
private val strategyRepository: CryptoTailStrategyRepository,
|
||||
private val triggerRepository: CryptoTailStrategyTriggerRepository,
|
||||
private val eventPublisher: ApplicationEventPublisher
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CryptoTailStrategyService::class.java)
|
||||
|
||||
private val maxWindowByInterval = mapOf(300 to 300, 900 to 900)
|
||||
|
||||
@Transactional
|
||||
fun create(request: CryptoTailStrategyCreateRequest): Result<CryptoTailStrategyDto> {
|
||||
return try {
|
||||
if (request.accountId <= 0) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ACCOUNT_ID_INVALID.messageKey))
|
||||
}
|
||||
if (request.marketSlugPrefix.isBlank()) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
val interval = request.intervalSeconds
|
||||
if (interval != 300 && interval != 900) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_INTERVAL_INVALID.messageKey))
|
||||
}
|
||||
val maxWindow = maxWindowByInterval[interval] ?: 300
|
||||
if (request.windowStartSeconds > request.windowEndSeconds) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID.messageKey))
|
||||
}
|
||||
if (request.windowEndSeconds > maxWindow) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED.messageKey))
|
||||
}
|
||||
val amountMode = request.amountMode.uppercase()
|
||||
if (amountMode != "RATIO" && amountMode != "FIXED") {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID.messageKey))
|
||||
}
|
||||
val minPrice = request.minPrice.toSafeBigDecimal()
|
||||
val maxPrice = (request.maxPrice ?: "1").toSafeBigDecimal()
|
||||
if (minPrice > maxPrice) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
val amountValue = request.amountValue.toSafeBigDecimal()
|
||||
if (amountValue <= BigDecimal.ZERO) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
|
||||
val entity = CryptoTailStrategy(
|
||||
accountId = request.accountId,
|
||||
name = request.name?.takeIf { it.isNotBlank() },
|
||||
marketSlugPrefix = request.marketSlugPrefix.trim(),
|
||||
intervalSeconds = interval,
|
||||
windowStartSeconds = request.windowStartSeconds,
|
||||
windowEndSeconds = request.windowEndSeconds,
|
||||
minPrice = minPrice,
|
||||
maxPrice = maxPrice,
|
||||
amountMode = amountMode,
|
||||
amountValue = amountValue,
|
||||
enabled = request.enabled
|
||||
)
|
||||
val saved = strategyRepository.save(entity)
|
||||
eventPublisher.publishEvent(CryptoTailStrategyChangedEvent(this))
|
||||
Result.success(entityToDto(saved, null))
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Result.failure(e)
|
||||
} catch (e: Exception) {
|
||||
logger.error("创建尾盘策略失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun update(request: CryptoTailStrategyUpdateRequest): Result<CryptoTailStrategyDto> {
|
||||
return try {
|
||||
val existing = strategyRepository.findById(request.strategyId).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND.messageKey))
|
||||
val interval = existing.intervalSeconds
|
||||
val maxWindow = maxWindowByInterval[interval] ?: 300
|
||||
|
||||
request.windowStartSeconds?.let { ws ->
|
||||
request.windowEndSeconds?.let { we ->
|
||||
if (ws > we) return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID.messageKey))
|
||||
if (we > maxWindow) return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED.messageKey))
|
||||
}
|
||||
}
|
||||
request.windowStartSeconds?.let { if (it > (request.windowEndSeconds ?: existing.windowEndSeconds)) return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID.messageKey)) }
|
||||
request.windowEndSeconds?.let { if (it > maxWindow) return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED.messageKey)) }
|
||||
|
||||
val updated = existing.copy(
|
||||
name = request.name?.takeIf { it.isNotBlank() } ?: existing.name,
|
||||
windowStartSeconds = request.windowStartSeconds ?: existing.windowStartSeconds,
|
||||
windowEndSeconds = request.windowEndSeconds ?: existing.windowEndSeconds,
|
||||
minPrice = request.minPrice?.toSafeBigDecimal() ?: existing.minPrice,
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: existing.maxPrice,
|
||||
amountMode = request.amountMode?.uppercase() ?: existing.amountMode,
|
||||
amountValue = request.amountValue?.toSafeBigDecimal() ?: existing.amountValue,
|
||||
enabled = request.enabled ?: existing.enabled,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
if (updated.minPrice > updated.maxPrice) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
request.amountMode?.uppercase()?.let { if (it != "RATIO" && it != "FIXED") return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID.messageKey)) }
|
||||
val saved = strategyRepository.save(updated)
|
||||
eventPublisher.publishEvent(CryptoTailStrategyChangedEvent(this))
|
||||
val lastTrigger = triggerRepository.findAllByStrategyIdOrderByCreatedAtDesc(saved.id!!, PageRequest.of(0, 1))
|
||||
.content.firstOrNull()?.createdAt
|
||||
Result.success(entityToDto(saved, lastTrigger))
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Result.failure(e)
|
||||
} catch (e: Exception) {
|
||||
logger.error("更新尾盘策略失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun delete(strategyId: Long): Result<Unit> {
|
||||
return try {
|
||||
if (!strategyRepository.existsById(strategyId)) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND.messageKey))
|
||||
}
|
||||
strategyRepository.deleteById(strategyId)
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
logger.error("删除尾盘策略失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun list(request: CryptoTailStrategyListRequest): Result<CryptoTailStrategyListResponse> {
|
||||
return try {
|
||||
val list = when {
|
||||
request.accountId != null && request.enabled != null -> strategyRepository.findByAccountIdAndEnabled(request.accountId, request.enabled)
|
||||
request.accountId != null -> strategyRepository.findAllByAccountId(request.accountId)
|
||||
request.enabled == true -> strategyRepository.findAllByEnabledTrue()
|
||||
request.enabled == false -> strategyRepository.findAll().filter { !it.enabled }
|
||||
else -> strategyRepository.findAll()
|
||||
}
|
||||
val lastTriggerMap = list.map { it.id!! }.associateWith { id ->
|
||||
triggerRepository.findAllByStrategyIdOrderByCreatedAtDesc(id, PageRequest.of(0, 1))
|
||||
.content.firstOrNull()?.createdAt
|
||||
}
|
||||
val dtos = list.map { entityToDto(it, lastTriggerMap[it.id]) }
|
||||
Result.success(CryptoTailStrategyListResponse(list = dtos))
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询尾盘策略列表失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun getTriggerRecords(request: CryptoTailStrategyTriggerListRequest): Result<CryptoTailStrategyTriggerListResponse> {
|
||||
return try {
|
||||
val page = PageRequest.of((request.page - 1).coerceAtLeast(0), request.pageSize.coerceIn(1, 100))
|
||||
val pageResult = if (request.status != null && request.status.isNotBlank()) {
|
||||
triggerRepository.findAllByStrategyIdAndStatusOrderByCreatedAtDesc(request.strategyId, request.status, page)
|
||||
} else {
|
||||
triggerRepository.findAllByStrategyIdOrderByCreatedAtDesc(request.strategyId, page)
|
||||
}
|
||||
val list = pageResult.content.map { triggerToDto(it) }
|
||||
val total = if (request.status != null && request.status.isNotBlank()) {
|
||||
triggerRepository.countByStrategyIdAndStatus(request.strategyId, request.status)
|
||||
} else {
|
||||
pageResult.totalElements
|
||||
}
|
||||
Result.success(CryptoTailStrategyTriggerListResponse(list = list, total = total))
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询触发记录失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun getStrategy(strategyId: Long): CryptoTailStrategy? = strategyRepository.findById(strategyId).orElse(null)
|
||||
|
||||
private fun entityToDto(e: CryptoTailStrategy, lastTriggerAt: Long?): CryptoTailStrategyDto = CryptoTailStrategyDto(
|
||||
id = e.id ?: 0L,
|
||||
accountId = e.accountId,
|
||||
name = e.name,
|
||||
marketSlugPrefix = e.marketSlugPrefix,
|
||||
marketTitle = null,
|
||||
intervalSeconds = e.intervalSeconds,
|
||||
windowStartSeconds = e.windowStartSeconds,
|
||||
windowEndSeconds = e.windowEndSeconds,
|
||||
minPrice = e.minPrice.toPlainString(),
|
||||
maxPrice = e.maxPrice.toPlainString(),
|
||||
amountMode = e.amountMode,
|
||||
amountValue = e.amountValue.toPlainString(),
|
||||
enabled = e.enabled,
|
||||
lastTriggerAt = lastTriggerAt,
|
||||
createdAt = e.createdAt,
|
||||
updatedAt = e.updatedAt
|
||||
)
|
||||
|
||||
private fun triggerToDto(t: CryptoTailStrategyTrigger): CryptoTailStrategyTriggerDto = CryptoTailStrategyTriggerDto(
|
||||
id = t.id ?: 0L,
|
||||
strategyId = t.strategyId,
|
||||
periodStartUnix = t.periodStartUnix,
|
||||
marketTitle = t.marketTitle,
|
||||
outcomeIndex = t.outcomeIndex,
|
||||
triggerPrice = t.triggerPrice.toPlainString(),
|
||||
amountUsdc = t.amountUsdc.toPlainString(),
|
||||
orderId = t.orderId,
|
||||
status = t.status,
|
||||
failReason = t.failReason,
|
||||
createdAt = t.createdAt
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
-- ============================================
|
||||
-- V34: 加密市场尾盘策略表
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS crypto_tail_strategy (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '策略ID',
|
||||
account_id BIGINT NOT NULL COMMENT '钱包账户ID',
|
||||
name VARCHAR(255) DEFAULT NULL COMMENT '策略名称(可选,用于列表展示)',
|
||||
market_slug_prefix VARCHAR(64) NOT NULL COMMENT '市场 slug 前缀,如 btc-updown-5m、btc-updown-15m',
|
||||
interval_seconds INT NOT NULL COMMENT '周期长度秒数:300(5分钟) 或 900(15分钟)',
|
||||
window_start_seconds INT NOT NULL COMMENT '时间窗口开始秒数(相对周期起点)',
|
||||
window_end_seconds INT NOT NULL COMMENT '时间窗口结束秒数(相对周期起点)',
|
||||
min_price DECIMAL(20, 8) NOT NULL COMMENT '最低触发价格 0~1',
|
||||
max_price DECIMAL(20, 8) NOT NULL DEFAULT 1 COMMENT '最高触发价格 0~1,默认1',
|
||||
amount_mode VARCHAR(10) NOT NULL DEFAULT 'RATIO' COMMENT '投入方式: RATIO=按比例, FIXED=固定金额',
|
||||
amount_value DECIMAL(20, 8) NOT NULL COMMENT '比例(0~100)或固定USDC金额',
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用: 0=停用, 1=启用',
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间',
|
||||
updated_at BIGINT NOT NULL COMMENT '更新时间',
|
||||
INDEX idx_account_id (account_id),
|
||||
INDEX idx_enabled (enabled),
|
||||
FOREIGN KEY (account_id) REFERENCES wallet_accounts(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='加密市场尾盘策略表';
|
||||
|
||||
-- ============================================
|
||||
-- 触发记录表
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS crypto_tail_strategy_trigger (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '记录ID',
|
||||
strategy_id BIGINT NOT NULL COMMENT '策略ID',
|
||||
period_start_unix BIGINT NOT NULL COMMENT '周期起点 Unix 秒',
|
||||
market_title VARCHAR(500) DEFAULT NULL COMMENT '市场标题',
|
||||
outcome_index INT NOT NULL COMMENT '方向: 0=Up, 1=Down',
|
||||
trigger_price DECIMAL(20, 8) NOT NULL COMMENT '触发时价格',
|
||||
amount_usdc DECIMAL(20, 8) NOT NULL COMMENT '投入金额 USDC',
|
||||
order_id VARCHAR(128) DEFAULT NULL COMMENT '订单ID(成功时有值)',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'success' COMMENT '状态: success, fail',
|
||||
fail_reason VARCHAR(500) DEFAULT NULL COMMENT '失败原因',
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间',
|
||||
INDEX idx_strategy_id (strategy_id),
|
||||
INDEX idx_period (strategy_id, period_start_unix),
|
||||
INDEX idx_created_at (created_at),
|
||||
FOREIGN KEY (strategy_id) REFERENCES crypto_tail_strategy(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='尾盘策略触发记录表';
|
||||
@@ -274,6 +274,18 @@ error.server.backtest_historical_data_fetch_failed=Failed to fetch historical da
|
||||
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
|
||||
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_triggers_fetch_failed=Failed to fetch trigger records
|
||||
# Backtest Management
|
||||
backtest.title=Backtest Management
|
||||
backtest.create_task=Create Backtest
|
||||
|
||||
@@ -274,6 +274,18 @@ error.server.backtest_historical_data_fetch_failed=历史数据获取失败
|
||||
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_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_triggers_fetch_failed=查询触发记录失败
|
||||
# 回测管理
|
||||
backtest.title=回测管理
|
||||
backtest.create_task=新增回测
|
||||
|
||||
@@ -274,6 +274,18 @@ error.server.backtest_historical_data_fetch_failed=歷史數據獲取失敗
|
||||
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_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_triggers_fetch_failed=查詢觸發記錄失敗
|
||||
# 回測管理
|
||||
backtest.title=回測管理
|
||||
backtest.create_task=新增回測
|
||||
|
||||
Reference in New Issue
Block a user