diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketGammaApi.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketGammaApi.kt index d56b8f0..5487260 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketGammaApi.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketGammaApi.kt @@ -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? = null, @Query("include_tag") includeTag: Boolean? = null ): Response> + + /** + * 根据 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 } +/** + * 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? = 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 解析) */ diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/cryptotail/CryptoTailStrategyController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/cryptotail/CryptoTailStrategyController.kt new file mode 100644 index 0000000..cba1466 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/cryptotail/CryptoTailStrategyController.kt @@ -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> { + 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> { + 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> { + 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> { + 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> { + 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>> { + 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)) + } + } +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailStrategyDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailStrategyDto.kt new file mode 100644 index 0000000..bf61c26 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailStrategyDto.kt @@ -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 = 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 = 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 +) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CryptoTailStrategy.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CryptoTailStrategy.kt new file mode 100644 index 0000000..2b02ab7 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CryptoTailStrategy.kt @@ -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() +) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CryptoTailStrategyTrigger.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CryptoTailStrategyTrigger.kt new file mode 100644 index 0000000..3e7a07d --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CryptoTailStrategyTrigger.kt @@ -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() +) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/ErrorCode.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/ErrorCode.kt index bc54129..5d1c80f 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/ErrorCode.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/ErrorCode.kt @@ -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 { /** diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/event/CryptoTailStrategyChangedEvent.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/event/CryptoTailStrategyChangedEvent.kt new file mode 100644 index 0000000..9496905 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/event/CryptoTailStrategyChangedEvent.kt @@ -0,0 +1,8 @@ +package com.wrbug.polymarketbot.event + +import org.springframework.context.ApplicationEvent + +/** + * 尾盘策略创建/更新/启用状态变更后发布,用于立即触发一轮执行检查。 + */ +class CryptoTailStrategyChangedEvent(source: Any) : ApplicationEvent(source) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CryptoTailStrategyRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CryptoTailStrategyRepository.kt new file mode 100644 index 0000000..580a4a0 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CryptoTailStrategyRepository.kt @@ -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 { + + fun findAllByAccountId(accountId: Long): List + fun findAllByEnabledTrue(): List + fun findByAccountIdAndEnabled(accountId: Long, enabled: Boolean): List +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CryptoTailStrategyTriggerRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CryptoTailStrategyTriggerRepository.kt new file mode 100644 index 0000000..5e99297 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CryptoTailStrategyTriggerRepository.kt @@ -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 { + + fun findByStrategyIdAndPeriodStartUnix(strategyId: Long, periodStartUnix: Long): CryptoTailStrategyTrigger? + fun findAllByStrategyIdOrderByCreatedAtDesc(strategyId: Long, pageable: Pageable): Page + fun findAllByStrategyIdAndStatusOrderByCreatedAtDesc(strategyId: Long, status: String, pageable: Pageable): Page + fun countByStrategyIdAndStatus(strategyId: Long, status: String): Long +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailOrderbookWsService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailOrderbookWsService.kt new file mode 100644 index 0000000..5827244 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailOrderbookWsService.kt @@ -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>>(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, + 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() ?: 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>) { + 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, Map>> { + val strategies = strategyRepository.findAllByEnabledTrue() + val nowSeconds = System.currentTimeMillis() / 1000 + val tokenIdSet = mutableSetOf() + val map = mutableMapOf>() + + 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 { + 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 { + if (clobTokenIds.isNullOrBlank()) return emptyList() + val parsed = clobTokenIds.fromJson>() + return parsed ?: emptyList() + } + + @EventListener + fun onStrategyChanged(event: CryptoTailStrategyChangedEvent) { + refreshAndSubscribe() + } +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyExecutionService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyExecutionService.kt new file mode 100644 index 0000000..c83881c --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyExecutionService.kt @@ -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, + val signatureType: Int, + val tokenIds: List, + val marketTitle: String?, + val preSignedOrderByOutcome: Map? +) + +/** + * 尾盘策略执行服务:按周期与时间窗口检查价格并下单,每周期最多触发一次。 + * 周期开始预置账户、解密、费率、签名类型、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() + + 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() + + /** + * 在周期内首次需要时构建并缓存预置上下文;失败返回 null,触发流程将走完整路径。 + * 预置:账户、解密、费率、签名类型、CLOB 客户端;FIXED 时预签两个 outcome 的订单。 + */ + private suspend fun ensurePeriodContext( + strategy: CryptoTailStrategy, + periodStartUnix: Long, + tokenIds: List, + 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? = 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() + 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, + 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, + 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, + 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 { + 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 { + if (clobTokenIds.isNullOrBlank()) return emptyList() + val parsed = clobTokenIds.fromJson>() + 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) + } +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyService.kt new file mode 100644 index 0000000..2587567 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyService.kt @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 + ) +} diff --git a/backend/src/main/resources/db/migration/V34__create_crypto_tail_strategy_tables.sql b/backend/src/main/resources/db/migration/V34__create_crypto_tail_strategy_tables.sql new file mode 100644 index 0000000..75de08f --- /dev/null +++ b/backend/src/main/resources/db/migration/V34__create_crypto_tail_strategy_tables.sql @@ -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='尾盘策略触发记录表'; diff --git a/backend/src/main/resources/i18n/messages_en.properties b/backend/src/main/resources/i18n/messages_en.properties index ca6c696..450b981 100644 --- a/backend/src/main/resources/i18n/messages_en.properties +++ b/backend/src/main/resources/i18n/messages_en.properties @@ -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 diff --git a/backend/src/main/resources/i18n/messages_zh_CN.properties b/backend/src/main/resources/i18n/messages_zh_CN.properties index 53ba493..b85d474 100644 --- a/backend/src/main/resources/i18n/messages_zh_CN.properties +++ b/backend/src/main/resources/i18n/messages_zh_CN.properties @@ -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=新增回测 diff --git a/backend/src/main/resources/i18n/messages_zh_TW.properties b/backend/src/main/resources/i18n/messages_zh_TW.properties index 8dafb38..c89ed19 100644 --- a/backend/src/main/resources/i18n/messages_zh_TW.properties +++ b/backend/src/main/resources/i18n/messages_zh_TW.properties @@ -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=新增回測 diff --git a/docs/zh/crypto-tail-strategy-flow.md b/docs/zh/crypto-tail-strategy-flow.md new file mode 100644 index 0000000..789de21 --- /dev/null +++ b/docs/zh/crypto-tail-strategy-flow.md @@ -0,0 +1,204 @@ +# 加密市场尾盘策略 - 流程图 + +## 一、整体架构 + +``` +┌─────────────────┐ POST 创建/更新 ┌──────────────────────────┐ +│ 前端 / API │ ──────────────────────►│ CryptoTailStrategyController│ +└─────────────────┘ └──────────────┬─────────────┘ + │ + ▼ + ┌──────────────────────────┐ + │ CryptoTailStrategyService │ + │ create / update │ + │ save → publishEvent │ + └──────────────┬─────────────┘ + │ + ┌─────────────────────────────────────────┼─────────────────────────────────────────┐ + │ CryptoTailStrategyChangedEvent │ │ + ▼ ▼ ▼ + ┌──────────────────────────────┐ ┌──────────────────────────────┐ ┌──────────────────────────────┐ + │ CryptoTailStrategyScheduler │ │ CryptoTailOrderbookWsService │ │ (其他监听方,如有) │ + │ @EventListener │ │ @EventListener │ └──────────────────────────────┘ + │ → runCycle() 一次(补充) │ │ → refreshAndSubscribe() │ + └──────────────┬───────────────┘ └──────────────┬───────────────┘ + │ │ + ▼ │ + ┌──────────────────────────────┐ │ + │ CryptoTailStrategyExecution │ │ 每 25 秒 + 事件时 + │ runCycle() │ │ refreshAndSubscribe() + │ (HTTP 拉订单簿,满足则下单) │ ▼ + └──────────────────────────────┘ ┌──────────────────────────────┐ + │ CLOB Market WebSocket │ + │ wss://.../ws/market │ + │ subscribe assets_ids │ + └──────────────┬───────────────┘ + │ book / price_change + ▼ + ┌──────────────────────────────┐ + │ onBestBid(tokenId, bestBid) │ + │ → tryTriggerWithPriceFromWs │ + └──────────────┬───────────────┘ + │ + ▼ + ┌──────────────────────────────┐ + │ CryptoTailStrategyExecution │ + │ placeOrderForTrigger │ + │ → CLOB 下单 + 写触发记录 │ + └──────────────────────────────┘ +``` + +--- + +## 二、策略创建/更新流程(API → 事件) + +```mermaid +sequenceDiagram + participant API as Controller + participant Svc as CryptoTailStrategyService + participant DB as DB + participant Event as ApplicationEventPublisher + + API->>Svc: create(request) / update(request) + Svc->>Svc: 参数校验(账户、窗口、价格、金额模式等) + Svc->>DB: save(entity) + Svc->>Event: publishEvent(CryptoTailStrategyChangedEvent) + Svc->>API: Result.success(dto) +``` + +- **创建**:校验通过后落库,发布 `CryptoTailStrategyChangedEvent`,返回 DTO。 +- **更新**:同上,更新实体后发布同一事件。 +- **删除**:不发布事件(策略已移除,WS 下次刷新订阅时会自然不再包含该策略)。 + +--- + +## 三、策略变更后:双路响应 + +事件发出后,两个监听方并行执行,互不阻塞: + +| 监听方 | 动作 | 说明 | +|--------|------|------| +| **CryptoTailStrategyScheduler** | `onStrategyChanged` → `runCycle()` 一次 | 用 HTTP 拉订单簿做一轮检查,作为 WS 未就绪时的补充。 | +| **CryptoTailOrderbookWsService** | `onStrategyChanged` → `refreshAndSubscribe()` | 按当前启用策略重新算 token 列表,向 WS 发送新的 `assets_ids` 订阅。 | + +```mermaid +flowchart LR + subgraph 事件 + E[CryptoTailStrategyChangedEvent] + end + subgraph 调度器 + S[Scheduler.onStrategyChanged] + R[executionService.runCycle] + S --> R + end + subgraph WS服务 + W[OrderbookWsService.onStrategyChanged] + Ref[refreshAndSubscribe] + W --> Ref + end + E --> S + E --> W +``` + +--- + +## 四、WebSocket 订单簿监听流程(主路径) + +```mermaid +flowchart TB + subgraph 启动与连接 + A[PostConstruct init] --> B[connect] + B --> C[OkHttp WebSocket 连接 wss://.../ws/market] + C --> D[onOpen: refreshAndSubscribe] + end + + subgraph 订阅维护 + D --> E[buildSubscriptionMap] + E --> F[遍历 enabled 策略] + F --> G[当前周期 periodStartUnix] + G --> H[slug = prefix-periodStartUnix] + H --> I[Gamma getEventBySlug] + I --> J[得到 tokenIds] + J --> K[tokenId → List of WsBookEntry] + K --> L[发送 type=MARKET, assets_ids=[...]] + T[每 25 秒 @Scheduled] --> E + EV[onStrategyChanged] --> E + end + + subgraph 收消息与触发 + M[onMessage: book / price_change] + M --> N[解析 asset_id, best_bid] + N --> O[onBestBid tokenId, bestBid] + O --> P[查 tokenToEntries 得到策略列表] + P --> Q[筛时间窗内] + Q --> R[scope.launch tryTriggerWithPriceFromWs] + R --> S[placeOrderForTrigger] + end + + L --> M +``` + +- **buildSubscriptionMap**:只包含「当前时间仍在窗口内」的策略(`nowSeconds < windowEnd`),并只订阅这些策略对应周期的 token。 +- **onBestBid**:再按当前时间过滤一次时间窗,对每个命中策略在协程里调用 `tryTriggerWithPriceFromWs`,内部会查「本周期是否已触发」和价格区间,通过则 `placeOrderForTrigger`。 + +--- + +## 五、执行层:下单条件与顺序(ExecutionService) + +无论来自 **runCycle(HTTP)** 还是 **tryTriggerWithPriceFromWs(WS)**,最终都走同一套下单逻辑。 + +```mermaid +flowchart TB + subgraph runCycle 入口 + A[runCycle] --> B[findAllByEnabledTrue] + B --> C[processStrategy 每个策略] + C --> D[在时间窗? 本周期已触发?] + D --> E[Gamma getEventBySlug] + E --> F[HTTP getOrderbook 两个 token] + F --> G[第一个 bestBid 在 minPrice~maxPrice?] + G --> H[placeOrderForTrigger] + end + + subgraph tryTriggerWithPriceFromWs 入口 + I[WS onBestBid] --> J[tryTriggerWithPriceFromWs] + J --> K[本周期已触发? bestBid 在区间?] + K --> H + end + + subgraph placeOrderForTrigger 统一 + H --> L[账户、API 凭证] + L --> M[余额、下单金额] + M --> N[最优价、数量] + N --> O[签名、CLOB 下单] + O --> P[保存 CryptoTailStrategyTrigger] + end +``` + +- **每周期最多触发一次**:由 `triggerRepository.findByStrategyIdAndPeriodStartUnix` 保证。 +- **价格区间**:`minPrice ≤ bestBid ≤ maxPrice` 才触发。 +- **时间窗**:仅当 `windowStart ≤ now < windowEnd`(以当前周期的 `periodStartUnix` 为基准)才参与检查/下单。 + +--- + +## 六、关键数据流小结 + +| 阶段 | 输入 | 输出/动作 | +|------|------|-----------| +| 创建/更新策略 | API 请求体 | 落库 + 发布 `CryptoTailStrategyChangedEvent` | +| 事件 → 调度器 | 事件 | 执行一次 `runCycle()`(HTTP 拉订单簿,满足则下单) | +| 事件 → WS 服务 | 事件 | `refreshAndSubscribe()`,更新订阅的 `assets_ids` | +| 定时刷新订阅 | 每 25 秒 | `refreshAndSubscribe()`,保证新周期、新策略被订阅 | +| WS 收 book/price_change | asset_id, best_bid | `onBestBid` → 时间窗内策略 → `tryTriggerWithPriceFromWs` → 未触发且价格在区间则 `placeOrderForTrigger` | +| placeOrderForTrigger | 策略、周期、token、outcome、价格 | 账户/余额/价格/签名 → CLOB 下单 → 写触发记录 | + +--- + +## 七、涉及类与职责 + +| 类 | 职责 | +|----|------| +| **CryptoTailStrategyController** | 接收 list/create/update/delete/triggers/marketOptions 的 POST。 | +| **CryptoTailStrategyService** | 策略 CRUD、校验、发布 `CryptoTailStrategyChangedEvent`。 | +| **CryptoTailStrategyScheduler** | 监听策略变更事件,执行一次 `runCycle()`。 | +| **CryptoTailOrderbookWsService** | 连接 CLOB Market WS、维护订阅(事件 + 每 25 秒)、处理 book/price_change、调用 `tryTriggerWithPriceFromWs`。 | +| **CryptoTailStrategyExecutionService** | `runCycle()`(HTTP 路径)、`tryTriggerWithPriceFromWs()`(WS 路径)、`placeOrderForTrigger()`(统一下单与写触发记录)。 | diff --git a/docs/zh/crypto-tail-strategy-market-data.md b/docs/zh/crypto-tail-strategy-market-data.md new file mode 100644 index 0000000..fb96d9f --- /dev/null +++ b/docs/zh/crypto-tail-strategy-market-data.md @@ -0,0 +1,178 @@ +# 加密市场尾盘策略 - 5/15 分钟市场数据获取说明 + +> 前端 UI 与交互详见 `crypto-tail-strategy-ui-spec.md`。 + +## 1. 数据源 + +- **Gamma API**:`https://gamma-api.polymarket.com` +- 用于获取市场元数据:conditionId、开始/结束时间、标题、clobTokenIds 等。 +- 无需鉴权。 + +## 2. 市场类型与 Slug 规则 + +| 类型 | Event Slug 规则 | 周期长度 | 说明 | +|------|-----------------|----------|------| +| Bitcoin 5 分钟 | `btc-updown-5m-{periodStartUnix}` | 5 min | periodStartUnix 为 5 分钟边界的 Unix 时间戳(秒) | +| Bitcoin 15 分钟 | `btc-updown-15m-{periodStartUnix}` | 15 min | periodStartUnix 为 15 分钟边界:`(now // 900) * 900` | +| Ethereum 5 分钟 | `eth-updown-5m-{ts}` | 5 min | 暂未验证是否在平台上线;如有可按相同规则推导 | +| Ethereum 15 分钟 | `eth-updown-15m-{ts}` | 15 min | 已验证存在 | + +- 5 分钟周期:按 **300 秒** 对齐;当前周期起点可用 `(nowUnix // 300) * 300`,下一周期为 `+300`。 +- 15 分钟周期:按 **900 秒** 对齐;当前周期起点可用 `(nowUnix // 900) * 900`。slug 中的时间戳即为周期起始 Unix 秒;周期结束以 API 的 endDate 为准。 + +## 3. 获取单个周期市场(开始时间、结束时间) + +### 3.1 请求 + +```bash +# 5 分钟 - 当前周期(示例时间戳需替换为当前周期起点) +curl -s "https://gamma-api.polymarket.com/events/slug/btc-updown-5m-1771007100" + +# 15 分钟 - 需使用实际存在的时间戳(可从前端或历史 slug 得知) +curl -s "https://gamma-api.polymarket.com/events/slug/btc-updown-15m-1770882300" +``` + +### 3.2 响应结构(与开始/结束时间相关) + +- **Event 层**:`startDate`、`endDate`(ISO 8601)。 +- **markets[]**:每个市场有 `conditionId`、`question`、`startDate`、`endDate`、`clobTokenIds` 等。 + +**周期本身**:例如 5 分钟市场 "1:30PM-1:35PM ET",理应是 **startDate = 1:30 PM**、**endDate = 1:35 PM**。 + +**API 返回值与周期起止的对应关系(已用脚本验证)**: + +| 字段 | 是否等于周期起止 | 说明 | +|------|------------------|------| +| **endDate**(Event / Market) | **是**,等于周期结束时间(如 1:35 PM) | API 的 endDate 即周期终点,可直接用。 | +| **startDate**(Event / Market) | **否**,不等于周期开始时间(1:30 PM) | API 的 startDate 是市场创建/开放时间,不是周期起点,故**不能**当 1:30 PM 用。 | + +**正确做法**:周期起点(1:30 PM)用 **slug 中的时间戳** 推导;周期终点(1:35 PM)用 API 的 **endDate**。 + +- **5 分钟**:周期开始 = `slug_ts`(即 slug 中的 Unix 秒),周期结束 = `endDate`(或 `slug_ts + 300`)。 +- **15 分钟**:周期开始 = `slug_ts`,周期结束 = `endDate`(或 `slug_ts + 900`)。 + +**示例(脚本输出解读)**:若 current 5m slug 为 `btc-updown-5m-1771007400`、title 为 "1:30PM-1:35PM ET"、endDate 为 `2026-02-13T18:35:00Z`,则 1771007400 = 18:30 UTC = 1:30 PM ET,即周期起点;endDate 18:35 UTC = 1:35 PM ET = 周期终点。next 5m slug 为 1771007700 = 1771007400 + 300,即下一周期起点。15m 同理:current slug 1771007400(1:30–1:45 PM ET),next 1771008300 = 1771007400 + 900(1:45–2:00 PM ET)。 + +## 4. 如何列出“当前及未来”5/15 分钟市场 + +- Gamma 未提供按“5 分钟 / 15 分钟”或“Up or Down”的 tag 筛选;`tag_id=744`(cryptocurrency)未返回这些短期市场。 +- **可行方式**: + 1. **按周期时间戳生成 slug 并逐个请求** + - 5 分钟:当前周期 `ts = (nowUnix // 300) * 300`,下一周期 `ts + 300`,再下一周期 `ts + 600` … + - 15 分钟:`ts = (nowUnix // 900) * 900`,然后 `ts + 900`、`ts + 1800` … + - 请求 `GET /events/slug/btc-updown-5m-{ts}` 或 `btc-updown-15m-{ts}`;若返回 404 表示该周期尚未创建或已过期,可跳过。 + 2. **用户选择“市场”时**:若前端/后端已知“系列”(如 Bitcoin 5 minute),则只需约定 slug 前缀(`btc-updown-5m`、`btc-updown-15m`)与周期长度(300/900),按当前时间计算周期起点并请求对应 slug 即可得到当前周期的 conditionId、startDate、endDate;下一周期同理。 + +## 5. 周期边界与“每周期监听” + +- **周期开始**:使用 **slug 中的时间戳** `periodStartUnix`(即请求 slug 时的 `btc-updown-5m-{ts}` 里的 `ts`),不要用 API 返回的 startDate。 +- **周期结束**:使用 API 返回的 **event.endDate 或 market.endDate**(与 slug_ts + 300/900 一致)。 +- 判断“当前是否在该周期内”:`periodStartUnix <= nowUnix < endDateUnix`,其中 `periodStartUnix` 从 slug 得到,`endDateUnix` 由 endDate 解析。 +- 策略“每周期开始时开始监听”:当 `now` 跨过当前周期的 endDate(或下一周期的 periodStartUnix)时,视为新周期开始,重置“本周期是否已触发”等状态。 + +## 6. 如何保证每个周期的市场都能正确处理 + +### 6.1 用“当前时间”唯一确定当前周期 + +- 服务端只用**当前 Unix 时间**推导周期,不依赖 API 的 startDate。 +- **5 分钟**:`periodStartUnix = (nowUnix / 300) * 300`(整除)。 +- **15 分钟**:`periodStartUnix = (nowUnix / 900) * 900`。 +- 同一时刻算出的 `periodStartUnix` 唯一,对应唯一 slug(如 `btc-updown-5m-{periodStartUnix}`),从而对应唯一市场(conditionId、tokenIds、endDate)。 + +### 6.2 按周期拉取市场并切换 + +- **首次进入或策略启用**:用当前的 `periodStartUnix` 拼 slug,请求 Gamma `GET /events/slug/{slug}`,拿到该周期的 conditionId、endDate、clobTokenIds;用 endDate 解析得到 `endDateUnix`。 +- **每次需要判断“是否还在本周期”或“是否该下单”时**:先算当前 `currentPeriodStart = (nowUnix / interval) * interval`(interval 为 300 或 900)。若 `currentPeriodStart` 大于上一笔使用的 `periodStartUnix`,说明已进入**下一周期**: + - 用新的 `currentPeriodStart` 拼 slug,重新请求 Gamma,拿到**新周期**的 conditionId、endDate、clobTokenIds; + - 用新周期的 tokenIds 订阅/拉取订单簿,用新 endDate 作为本周期结束时间; + - 重置本周期“是否已触发”等状态,避免把上一周期的状态带到新周期。 +- **周期内**:始终用**本周期**的 conditionId、tokenIds、endDate 做价格监听与下单,不要混用上一周期的数据。 + +### 6.3 周期切换时机与 404 处理 + +- **切换时机**:以 `nowUnix >= endDateUnix` 或 `(nowUnix / interval) * interval > periodStartUnix` 作为“本周期已结束”,立刻按 6.2 用新 `periodStartUnix` 拉新周期市场。 +- **新周期市场尚未创建(404)**:Gamma 可能稍晚才创建下一周期 event。若请求 slug 返回 404,可短间隔重试(如 5–15 秒)或等到下一整点/对齐点再试;重试时仍用**同一** `periodStartUnix`,避免用错周期。若长时间 404,可记录日志并跳过该周期,下一周期再正常拉取。 + +### 6.4 下单失败重试规则(每周期最多下单一次) + +- 市价单提交失败时,**最多重试 2 次**(即 1 次初始 + 2 次重试,共 3 次尝试)。 +- 若 3 次均失败: + - 本周期**不再**对该 outcome 下单; + - 记录失败原因与状态(便于审计与前端展示触发记录)。 +- 周期切换时(6.2)重置为“未下单”,仅对新周期做新的判断与尝试。 + +### 6.5 去重与幂等(每周期最多触发一次) + +- 以「策略 + 周期」唯一标识一次执行,例如 `(strategyId, periodStartUnix)` 或 `(accountId, slugPrefix, periodStartUnix)`。 +- 在数据库或内存中记录:本周期是否已触发、是否已下单。若已触发,同一周期内不再根据价格区间下单。 +- 周期切换时(6.2)清空或更新为“新周期未触发”,只对新周期的 conditionId/tokenIds 做监听与下单。 + +### 6.6 时间区间(窗口)内才触发 + +- 策略可配置**时间区间**:从周期起点起算的「开始秒数」与「结束秒数」,例如 5 分钟市场可选 0~300 秒内的一段,15 分钟市场可选 0~900 秒内的一段(对应前端“分+秒”下拉,如 3 分 0 秒~12 分 0 秒即 180~720 秒)。 +- **执行规则**:仅当 `periodStartUnix + windowStartSeconds <= nowUnix < periodStartUnix + windowEndSeconds` 时,才根据 7.1 判断价格是否进入 [minPrice, maxPrice] 并执行下单;**区间外不进行价格判断与下单**。 +- 存储:策略表(或配置)中保存 `windowStartSeconds`、`windowEndSeconds`(整数,单位秒);校验:`windowStartSeconds <= windowEndSeconds`,且不超过周期长度(5min 市场 ≤ 300,15min 市场 ≤ 900)。详见 [UI 规格 - 时间区间](crypto-tail-strategy-ui-spec.md)。 + +### 6.7 小结 + +| 要点 | 做法 | +|------|------| +| 周期唯一性 | 用 `(nowUnix / interval) * interval` 得到 periodStartUnix,再拼 slug,不依赖 API startDate。 | +| 周期数据 | 每周期用**该周期**的 slug 请求 Gamma,使用返回的 conditionId、endDate、clobTokenIds。 | +| 切换 | 当 `nowUnix >= endDateUnix` 或当前算出的 periodStartUnix 变化时,拉取新周期并重置状态。 | +| 404 | 同一 periodStartUnix 重试;长时间 404 可跳过该周期并打日志。 | +| 下单失败 | 失败后最多重试 2 次;仍失败则本周期不再下单并记录状态。 | +| 每周期只触发一次 | 用 (策略, periodStartUnix) 做去重,周期切换时重置“已触发”状态。 | +| 时间区间 | 仅当 periodStartUnix + windowStartSeconds ≤ now < periodStartUnix + windowEndSeconds 时做价格判断与下单;区间外不处理。 | + +按上述方式,每个周期都会对应到正确的 slug、正确的市场与 endDate,并在周期结束时切换到下一周期;仅在配置的时间窗口内才根据价格触发下单,避免混周期或漏周期。 + +## 7. 与订单簿 / 价格的关系 + +- 价格由 **CLOB 订单簿**(或 WebSocket)获取,不依赖 Gamma;Gamma 仅提供市场元数据。 +- 使用 market.conditionId 与 markets[].clobTokenIds 解析出 tokenId,再订阅或请求该 token 的订单簿即可得到实时价格,用于区间判断与市价下单。 + +### 7.1 价格区间与「反方向」判断(如 minPrice = 0.92) + +二元市场(Up or Down)有两个 outcome:通常 outcomeIndex 0 = Up,1 = Down,各对应一个 tokenId 和订单簿。 + +- **配置含义**:用户配置 minPrice = 0.92(及可选 maxPrice,默认 1)表示「当**某个 outcome 的价格**落在 [0.92, 1] 时触发市价买入**该** outcome」。 +- **不预先选方向**:不需要用户选「买 Up 还是买 Down」;谁的价格先进入区间就买谁。 +- **订单簿取价方式(与现有市价单逻辑一致)**: + - 对每个 outcome,取该 tokenId 订单簿的 **bestBid**(最高买入价)作为当前价格用于区间判断;若取价规则与现有市价买入逻辑不同,请以系统现有规则为准并在实现文档中写明。 +- **判断方式**: + - 同时取**两个 outcome** 的当前价格(按上述取价规则)。 + - 对 **outcome 0**:若 `price0 >= minPrice && price0 <= maxPrice` → 满足触发条件,买入 outcome 0(Up)。 + - 对 **outcome 1**:若 `price1 >= minPrice && price1 <= maxPrice` → 满足触发条件,买入 outcome 1(Down)。 +- **反方向**:「反方向」即另一个 outcome。例如若本轮已因 outcome 0 进入 [0.92, 1] 而买入 Up,则本周期内**不再**检查 outcome 1 是否也进入区间、也不再买 Down;反之若先触发的是 outcome 1(Down),则本周期不再买 Up。实现上:一旦本周期已对**任意一个** outcome 触发并下单,即标记本周期已触发,不再对**另一个 outcome(反方向)**做区间判断与下单。 +- **同一时刻两边都进区间**:若同一时刻 Up 和 Down 的价格都在 [0.92, 1](理论上二元市场 Up+Down≈1 时不会同时 ≥0.92,但若出现),可约定按 outcomeIndex 优先(如先判 0 再判 1)或先到先得,只执行一笔买入,本周期不再买反方向。 + +总结:配置 0.92 时,对**两个方向**都做同一区间判断;先满足区间的那一侧触发买入,另一侧即为反方向,本周期不再触发。 + +## 8. 验证方式 + +**startDate/endDate 验证结论**:已用脚本对比 slug 时间戳与 API 返回的 startDate/endDate。**endDate 等于当前周期结束时间**;**startDate 不等于周期起始点**(为市场创建/开放时间),周期起始点应以 slug 中的时间戳为准。详见上文 3.2、5 节。 + +### 8.1 脚本(推荐) + +项目内脚本,会请求当前/下一 5 分钟与 15 分钟 BTC 市场并打印 conditionId、startDate、endDate、clobTokenIds: + +```bash +python3 scripts/fetch_crypto_minute_markets.py +``` + +### 8.2 curl 示例 + +```bash +# 5 分钟 - 当前或下一周期(时间戳需替换为实际周期起点) +curl -s "https://gamma-api.polymarket.com/events/slug/btc-updown-5m-1771007100" + +# 15 分钟 - 当前周期(时间戳需替换为实际周期起点) +curl -s "https://gamma-api.polymarket.com/events/slug/btc-updown-15m-1771006500" + +# 15 分钟 - 历史存在的事件 +curl -s "https://gamma-api.polymarket.com/events/slug/btc-updown-15m-1770882300" +curl -s "https://gamma-api.polymarket.com/events/slug/eth-updown-15m-1770801300" +``` + +若返回 403,可加 User-Agent:`curl -s -H "User-Agent: PolymarketBot/1.0" "https://gamma-api.polymarket.com/events/slug/btc-updown-5m-1771007100"` diff --git a/docs/zh/crypto-tail-strategy-tasks.md b/docs/zh/crypto-tail-strategy-tasks.md new file mode 100644 index 0000000..c2f3ebc --- /dev/null +++ b/docs/zh/crypto-tail-strategy-tasks.md @@ -0,0 +1,150 @@ +# 加密市场尾盘策略 - 任务梳理 + +> 需求与 UI 见 `crypto-tail-strategy-ui-spec.md`,市场数据与执行规则见 `crypto-tail-strategy-market-data.md`。 + +以下按**文档 / 数据库 / 后端 / 前端**拆分为可执行任务,便于排期与验收。 + +--- + +## 一、文档(已完成) + +| 任务 | 状态 | 说明 | +|------|------|------| +| PRD 与需求 | ✅ | 周期、价格区间、每周期最多触发一次、重试 2 次等 | +| 市场数据文档 | ✅ | `crypto-tail-strategy-market-data.md`:Gamma slug、周期、时间区间、价格判断 | +| UI 规格 | ✅ | `crypto-tail-strategy-ui-spec.md`:列表、表单、时间区间、触发记录、赎回前置检查 | + +--- + +## 二、数据库 + +| 序号 | 任务 | 说明 | +|------|------|------| +| D1 | 策略表 migration | 新建表,字段建议:id, account_id, name, market_slug_prefix(如 btc-updown-5m), interval_seconds(300/900), window_start_seconds, window_end_seconds, min_price, max_price, amount_mode(ratio/fixed), amount_value(比例或 USDC 字符串), enabled, created_at, updated_at。唯一/外键按现有规范。 | +| D2 | 触发记录表 migration | 新建表,字段建议:id, strategy_id, period_start_unix, market_title, outcome_index(0=Up/1=Down), trigger_price, amount_usdc, order_id(可空), status(success/fail), fail_reason(可空), created_at。便于列表与筛选。 | + +--- + +## 三、后端(Kotlin) + +### 3.1 实体与 Repository + +| 序号 | 任务 | 说明 | +|------|------|------| +| B1 | 策略实体 Entity | 对应策略表;ID 用 Long?;时间 Long 时间戳;金额 BigDecimal;遵守 backend.mdc 实体规范。 | +| B2 | 触发记录实体 Entity | 对应触发记录表。 | +| B3 | JpaRepository | 策略、触发记录的 Repository;按 strategyId、时间等查记录。 | + +### 3.2 外部依赖与领域 + +| 序号 | 任务 | 说明 | +|------|------|------| +| B4 | Gamma API 按 slug 拉市场 | 已有或扩展 PolymarketGammaApi:GET /events/slug/{slug},返回 conditionId、endDate、clobTokenIds 等;与 market-data 文档 3、4 节一致。 | +| B5 | 周期与 slug 推导 | 工具或 Service:根据 interval(300/900)、当前时间算 periodStartUnix;拼 slug(如 btc-updown-5m-{ts});解析 endDate 得 endDateUnix。 | +| B6 | 订单簿价格 | 使用现有 CLOB/订单簿能力,按 conditionId、clobTokenIds 取各 outcome 的 bestBid;与 market-data 7.1 一致。 | +| B7 | 市价单与重试 | 按策略的 amount 计算下单金额;市价买入指定 outcome;失败时最多重试 2 次(共 3 次),仍失败则写触发记录状态为失败并记原因。 | + +### 3.3 策略执行核心逻辑(按 market-data 第 6、7 节) + +| 序号 | 任务 | 说明 | +|------|------|------| +| B8 | 周期内时间窗口判断 | 仅当 `periodStartUnix + windowStartSeconds <= nowUnix < periodStartUnix + windowEndSeconds` 时,才做价格区间判断与下单;区间外不处理。 | +| B9 | 价格区间与「先满足先买」 | 对两个 outcome 取价,若某 outcome 价格 ∈ [minPrice, maxPrice],则触发买该 outcome;另一 outcome 本周期不再触发(7.1)。 | +| B10 | 每周期只触发一次 | 以 (strategyId, periodStartUnix) 去重;周期切换时重置「本周期已触发」状态;结合 B8、B9 实现。 | +| B11 | 周期切换与 404 | 当 now >= endDateUnix 或新 periodStartUnix 时,用新 periodStartUnix 拉新 slug;404 时同 periodStartUnix 短间隔重试,长时间 404 可跳过本周期并打日志。 | + +### 3.4 API 与 DTO + +| 序号 | 任务 | 说明 | +|------|------|------| +| B12 | 策略 CRUD API | 列表(分页/筛选)、创建、更新、删除、启用/停用;请求/响应为 DTO,不用 Map;统一 ApiResponse;错误码与 MessageSource。 | +| B13 | 策略 DTO | 创建/更新包含:accountId, name, marketSlugPrefix, intervalSeconds, windowStartSeconds, windowEndSeconds, minPrice, maxPrice(可选默认 1), amountMode, amountValue;校验 windowStart <= windowEnd,且不超过周期长度。 | +| B14 | 触发记录 API | 按 strategyId 分页查询触发记录;返回列表 DTO(时间、市场、方向、价格、金额、订单 ID、状态)。 | +| B15 | 5/15 分钟市场列表 API(可选) | 若前端需要「可选市场」列表:可按当前/下一周期拼 slug 调 Gamma 返回市场信息,供前端选择;或前端直接按 slug 规则+周期展示。 | + +### 3.5 自动赎回与调度 + +| 序号 | 任务 | 说明 | +|------|------|------| +| B16 | 自动赎回包含尾盘策略仓位 | 尾盘策略产生的仓位与跟单/手动一视同仁,纳入现有自动赎回逻辑,不排除(见 UI 规格附录 A)。 | +| B17 | 调度/定时或常驻 | 对已启用策略按周期(如每 10–30 秒)检查:当前周期、是否在时间窗口内、是否已触发、价格是否进区间;满足则执行下单并写触发记录。 | + +--- + +## 四、前端(React + TypeScript) + +### 4.1 路由与导航 + +| 序号 | 任务 | 说明 | +|------|------|------| +| F1 | 路由 | App.tsx 增加 `/crypto-tail-strategy`、可选 `/crypto-tail-strategy/records/:id`。 | +| F2 | 菜单 | Layout 中增加「尾盘策略」菜单项,与跟单同级或在其下;key 与路由一致。 | + +### 4.2 列表页 + +| 序号 | 任务 | 说明 | +|------|------|------| +| F3 | 列表页组件 | 如 CryptoTailStrategyList.tsx;页面标题、钱包提示 Alert、新增按钮、筛选(账户、状态)。 | +| F4 | 列表展示 | 桌面 Table / 移动 Card:策略名、关联市场、时间区间、价格区间、投入方式、状态、最近触发、操作(编辑、启用/停用、删除、查看触发记录);删除 Popconfirm。 | +| F5 | 创建前检查 | 点击「新增策略」先调接口判断是否已配置自动赎回(如 builderApiKeyConfigured);未配置则弹出「请先配置自动赎回」Modal(去配置 → /system-settings,取消),不打开表单。 | + +### 4.3 新增/编辑表单 + +| 序号 | 任务 | 说明 | +|------|------|------| +| F6 | 表单弹窗 | 策略名、选择账户、选择市场、时间区间、minPrice、maxPrice、投入方式(比例/固定)、启用状态。 | +| F7 | 时间区间控件 | 区间开始/结束:下拉选「分钟」+「秒」;5min 市场 0–5 分+0–59 秒(总≤5min),15min 市场 0–15 分+0–59 秒(总≤15min);校验**开始 ≤ 结束**;提交时转为 windowStartSeconds、windowEndSeconds。 | +| F8 | 市场选择器 | 仅展示 5/15 分钟加密市场;支持搜索;展示市场标题+周期;选后用于校验时间区间上界(5min 结束≤300s,15min≤900s)。 | +| F9 | 表单校验与提交 | 市场类型、时间区间 start≤end 且不超周期、minPrice/maxPrice、比例或固定金额合法;提交后刷新列表、成功提示。 | + +### 4.4 触发记录 + +| 序号 | 任务 | 说明 | +|------|------|------| +| F10 | 触发记录展示 | 弹窗或独立页:触发时间、市场、方向(Up/Down)、触发价格、投入金额、订单 ID、状态;支持按时间、状态筛选;formatUSDC;移动端 Card/折叠。 | + +### 4.5 通用 + +| 序号 | 任务 | 说明 | +|------|------|------| +| F11 | 类型定义 | 策略、触发记录等 TypeScript 类型;无 any。 | +| F12 | API 封装 | apiService 中 cryptoTailStrategy.list/create/update/delete/toggle、records(strategyId) 等。 | +| F13 | 多语言 | locales 中 zh-CN、zh-TW、en 的 cryptoTailStrategy.*:list.title、list.walletTip、form.walletTip、redeemRequiredModal.*、时间区间/价格区间等文案。 | + +--- + +## 五、依赖关系简图 + +``` +文档 ✅ + ↓ +D1,D2 数据库 + ↓ +B1–B3 实体与 Repository + ↓ +B4–B7 外部 API、周期、价格、下单 + ↓ +B8–B11 执行逻辑(时间窗口+价格+去重+周期切换) + ↓ +B12–B15 API 与 DTO +B16 自动赎回 +B17 调度 + ↓ +F1–F2 路由与菜单 +F11–F12 类型与 API 封装 +F13 多语言 + ↓ +F3–F5 列表与创建前检查 +F6–F9 表单(含时间区间) +F10 触发记录 +``` + +--- + +## 六、验收要点 + +- **时间区间**:仅当周期内当前时间落在 [windowStartSeconds, windowEndSeconds] 时才判断价格并下单;前端区间开始 ≤ 结束,且不超出 5min/15min。 +- **每周期一次**:同一策略同一周期只触发一次(先满足价格的 outcome 买入,反方向不买)。 +- **重试**:下单失败最多重试 2 次,共 3 次;仍失败记入触发记录为失败。 +- **自动赎回**:尾盘策略产生的仓位可被自动赎回,无排除逻辑。 +- **创建前检查**:未配置自动赎回时点击新增策略弹出「去配置」弹窗,不打开表单。 diff --git a/docs/zh/crypto-tail-strategy-ui-spec.md b/docs/zh/crypto-tail-strategy-ui-spec.md new file mode 100644 index 0000000..bf66e43 --- /dev/null +++ b/docs/zh/crypto-tail-strategy-ui-spec.md @@ -0,0 +1,177 @@ +# 加密市场尾盘策略 - 前端 UI 规格 + +> 周期推导与市场数据获取详见 `crypto-tail-strategy-market-data.md`。 + +与现有跟单/回测保持同一风格(Ant Design、响应式、多语言),以下为页面结构及所含元素。 + +--- + +## 1. 导航与路由 + +| 项目 | 说明 | +|------|------| +| **菜单** | 在「跟单管理」同级或其下增加一项,如「尾盘策略」,key 建议 `/crypto-tail-strategy`。 | +| **路由** | 列表页 `/crypto-tail-strategy`;可选详情/触发记录 `/crypto-tail-strategy/records/:id`。 | + +参考:`Layout.tsx` 中 `/copy-trading`、`/backtest` 的配置;`App.tsx` 中对应 `Route`。 + +--- + +## 2. 列表页(主页面) + +**路径**:`/crypto-tail-strategy` +**组件**:如 `CryptoTailStrategyList.tsx`(或 `TailStrategyList.tsx`)。 + +### 2.1 顶部操作区 + +| 元素 | 类型 | 说明 | +|------|------|------| +| 页面标题 | 标题文案 | 如「加密尾盘策略」,用 `t('cryptoTailStrategy.list.title')`。 | +| **钱包使用提示** | **Alert(Warning)** | **必须**在页面顶部或标题下方展示:提示用户**使用单独/专用钱包**运行本策略,避免该钱包用于手动交易、跟单等其他操作,否则可能导致余额或仓位变化,进而造成策略执行异常(如余额不足、下单失败等)。文案走多语言 `t('cryptoTailStrategy.list.walletTip')`,可带 `showIcon`。 | +| 新增策略 | Button(Primary) | 点击时**先检查自动赎回相关配置**(见 2.4);若未配置则弹出「去配置」简易弹窗,若已配置则打开「新增策略」表单弹窗。图标可用 `PlusOutlined`。 | +| 筛选(可选) | Select / 筛选项 | 按账户、启用状态筛选;移动端可收起到抽屉或折叠。 | + +### 2.2 列表内容(桌面端:Table,移动端:Card 列表) + +| 列/卡片项 | 说明 | +|-----------|------| +| 策略名称 | 用户填的配置名或自动生成名。 | +| 关联市场 | 展示市场标题 + 周期,如「Bitcoin Up or Down - 5 minute」。 | +| 时间区间 | 如「3 分 0 秒 ~ 12 分 0 秒」(与周期类型一致:5min 为 0–5 分,15min 为 0–15 分)。 | +| 价格区间 | 如 `[0.92, 1]` 或「0.92 ~ 1」(maxPrice 为空时显示为 1)。 | +| 投入方式 | 「比例 10%」或「固定 100 USDC」,用 `formatUSDC` 格式化金额。 | +| 状态 | Tag 或 Switch:启用 / 停用。 | +| 最近触发 | 最近一次触发时间(若有);无则「-」。 | +| 操作 | 编辑、启用/停用、删除、查看触发记录。删除前 Popconfirm 二次确认。 | + +### 2.3 与现有风格对齐 + +- 加载态:`Spin` 包裹列表。 +- 空状态:无数据时展示空状态插画 + 引导「新增策略」。 +- 响应式:`useMediaQuery({ maxWidth: 768 })`,桌面用 Table,移动用 Card + 操作折叠/抽屉。 + +参考:`CopyTradingList.tsx` 的 Table 列、Card 布局、筛选与 Modal 打开方式。 + +### 2.4 创建前检查:自动赎回配置(必须) + +策略依赖**自动赎回**(需通过 Relayer/Builder API 提交链上赎回)。用户点击「新增策略」时: + +1. **检查**:请求系统配置(如 `apiService.systemConfig.getConfig()` 或已有接口),判断是否已配置 Builder API Key(及可选:自动赎回已开启)。若 `builderApiKeyConfigured === false`(或后端约定之「未配置」状态),视为未配置。 +2. **未配置时**:不打开新增策略表单,改为弹出**简易弹窗**(Modal),内容建议: + - **标题**:如「请先配置自动赎回」,`t('cryptoTailStrategy.redeemRequiredModal.title')`。 + - **正文**:简短说明尾盘策略依赖自动赎回,需要先在「系统设置」中配置 Builder API Key 及自动赎回。文案 `t('cryptoTailStrategy.redeemRequiredModal.description')`。 + - **操作**: + - **去配置**:主按钮,点击后关闭弹窗并跳转到系统设置页(如 `/system-settings`,该页含 Relayer 配置与自动赎回开关)。 + - **取消**:次按钮或关闭图标,仅关闭弹窗。 +3. **已配置时**:正常打开新增策略表单弹窗。 + +弹窗保持简易,无需表单,仅提示 + 跳转;多语言键示例:`cryptoTailStrategy.redeemRequiredModal.title`、`cryptoTailStrategy.redeemRequiredModal.description`、`cryptoTailStrategy.redeemRequiredModal.goToSettings`、`cryptoTailStrategy.redeemRequiredModal.cancel`。 + +--- + +## 3. 新增 / 编辑策略弹窗(Modal) + +**组件**:如 `CryptoTailStrategyFormModal.tsx` 或内嵌在列表页的 Modal。 + +### 3.1 表单字段 + +| 表单项 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| **钱包提示(简短)** | **Alert(Warning)** | - | 在「选择账户」上方或表单单列顶部展示简短提示:建议使用**专用钱包**,避免手动操作等导致异常。文案如 `t('cryptoTailStrategy.form.walletTip')`。 | +| 策略名称 | Input | 否 | 用于列表展示,可占位「自动生成」。 | +| 选择账户 | Select | 是 | 下拉已导入账户(与跟单一致,来自 `useAccountStore()` 或接口)。 | +| 选择市场 | 市场选择器 | 是 | 仅展示 5/15 分钟加密市场;支持搜索;展示市场标题 + 周期(5min/15min);一个策略绑一个市场。 | +| **时间区间** | **开始 / 结束** | 是 | 仅在本周期内的该时间窗口内,价格满足时才下单;区间外不处理。见下方说明。 | +| 区间开始 | 下拉(分 + 秒) | 是 | 从周期起点起算的「开始」偏移。5 分钟市场可选 0~5 分 + 0~59 秒(总不超过 5 分钟);15 分钟市场可选 0~15 分 + 0~59 秒(总不超过 15 分钟)。 | +| 区间结束 | 下拉(分 + 秒) | 是 | 从周期起点起算的「结束」偏移。范围同上,且**区间开始不得大于区间结束**(前端校验)。 | +| 最低价 minPrice | InputNumber | 是 | 0~1,精度 2~4 位小数;校验 minPrice ≤ 1。 | +| 最高价 maxPrice | InputNumber | 否 | 0~1,占位「不填默认为 1」;若填则校验 minPrice ≤ maxPrice ≤ 1。 | +| 投入方式 | Radio.Group | 是 | 选项:「按比例」「固定金额」。 | +| 比例 % | InputNumber | 条件必填 | 选「按比例」时显示;0~100;可展示当前账户 USDC 余额与预估金额。 | +| 固定金额 (USDC) | InputNumber | 条件必填 | 选「固定金额」时显示;≥ 最小下单额,≤ 账户余额;用 `formatUSDC` 展示。 | +| 启用状态 | Switch | 否 | 新增默认开启;编辑可切换。 | + +**时间区间说明**:例如 15 分钟市场配置「3 分 0 秒」~「12 分 0 秒」,表示从周期开始后第 3 分钟到第 12 分钟之间,若价格进入 [minPrice, maxPrice] 才下单;第 0~3 分钟、第 12~15 分钟即使价格满足也不下单。5 分钟市场同理,可选 0~5 分钟内的一段(如 0~2、2~5)。前端用下拉选择「分钟」+「秒」,后端存为相对周期起点的秒数(如 windowStartSeconds、windowEndSeconds)。 + +### 3.2 校验与提交 + +- 提交前:市场为 5/15 分钟、**时间区间开始 ≤ 时间区间结束**、时间区间不超出周期长度(5min 市场结束 ≤ 5 分 0 秒,15min 市场结束 ≤ 15 分 0 秒)、minPrice 合法、maxPrice 若填则 ≥ minPrice、余额/比例合法。 +- 提交后:关闭弹窗、刷新列表、`message.success`;失败在表单上展示接口错误信息。 + +参考:`CopyTradingOrders/AddModal.tsx` 的 Form 布局、`Form.Item` + `rules`、条件显示(比例/固定金额)。 + +--- + +## 4. 触发记录 + +**入口**:列表行操作「查看触发记录」或单独 Tab/页。 + +### 4.1 展示方式(二选一或并存) + +- **弹窗**:Modal 内 Table,按策略 ID 拉取该策略的触发记录。 +- **独立页**:路由如 `/crypto-tail-strategy/records/:strategyId`,页面内 Table 或 Card 列表。 + +### 4.2 记录列表字段 + +| 列/项 | 说明 | +|-------|------| +| 触发时间 | 时间戳格式化为本地时间。 | +| 市场 | 市场标题 + 周期。 | +| 方向 (outcome) | Up / Down。 | +| 触发价格 | 当时进入区间的价格。 | +| 投入金额 | USDC,用 `formatUSDC`。 | +| 订单 ID | 若有;可截断 + Tooltip 全量。 | +| 状态 | 成功 / 失败。 | + +支持按时间范围、状态筛选;移动端用 Card 或折叠列表。 + +--- + +## 5. 组件与技术要点 + +| 要点 | 说明 | +|------|------| +| **钱包提示** | 列表页与新增/编辑表单**必须**包含「使用单独钱包」的 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`:「取消」。 | +| 金额 | 统一 `formatUSDC`(见 frontend.mdc)。 | +| 响应式 | `useMediaQuery`;按钮触摸目标 ≥ 44px;移动端主操作突出。 | +| 类型 | 不用 `any`;为策略、触发记录定义 TypeScript 类型。 | +| API | 通过 `apiService` 封装(如 `apiService.cryptoTailStrategy.list/create/update/delete/records`)。 | + +--- + +## 6. 页面与文件建议对应 + +| 功能 | 建议路径/文件 | +|------|----------------| +| 列表页 | `frontend/src/pages/CryptoTailStrategyList.tsx` | +| 未配置赎回时的简易弹窗 | 内嵌在列表页的 Modal,或 `CryptoTailStrategyList/RedeemRequiredModal.tsx` | +| 新增/编辑弹窗 | `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` 中增加「尾盘策略」菜单项 | +| 类型 | `frontend/src/types/index.ts` 或 `types/cryptoTailStrategy.ts` 中增加策略与触发记录类型 | +| 多语言 | `frontend/src/locales/{zh-CN,zh-TW,en}/common.json` 中增加 `cryptoTailStrategy.*` | + +--- + +## 7. 小结:UI 包含的主要元素 + +- **导航**:主导航中「尾盘策略」入口。 +- **列表页**:标题、钱包提示 Alert、新增按钮(点击前先检查赎回配置,未配置则弹「去配置」简易弹窗)、筛选、表格/卡片(策略名、市场、价格区间、投入方式、状态、最近触发、操作)、加载与空状态。 +- **未配置赎回弹窗**:简易 Modal,提示依赖自动赎回、需先配置 Builder API Key 与自动赎回;按钮「去配置」(跳转 `/system-settings`)、「取消」。 +- **表单弹窗**:策略名、账户、市场选择、minPrice/maxPrice、投入方式(比例/固定)、启用开关、提交/取消。 +- **触发记录**:时间、市场、outcome、触发价格、金额、订单 ID、状态;支持弹窗或独立页。 +- **通用**:Ant Design 组件、响应式、多语言、formatUSDC、TypeScript 类型。 + +--- + +## 附录 A 后端/产品要求:自动赎回须支持本策略仓位 + +自动赎回逻辑**必须支持赎回由尾盘策略产生的订单所对应的仓位**。即:本策略触发的市价买入会形成仓位,这些仓位在满足「可赎回」条件时,应被纳入现有自动赎回流程并正常发起赎回,不得因来源为「尾盘策略」而被排除。后端实现时需保证: + +- 尾盘策略下单产生的仓位,与跟单/手动下单等来源的仓位一视同仁,参与可赎回查询与批量赎回; +- 若当前自动赎回按账户或仓位类型过滤,需将「尾盘策略订单产生的仓位」包含在内。 + +这样前端所依赖的「自动赎回」对该策略才完整有效。 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3e3cd49..0726391 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -34,6 +34,7 @@ import RpcNodeSettings from './pages/RpcNodeSettings' import Announcements from './pages/Announcements' import BacktestList from './pages/BacktestList' import BacktestDetail from './pages/BacktestDetail' +import CryptoTailStrategyList from './pages/CryptoTailStrategyList' import { wsManager } from './services/websocket' import type { OrderPushMessage } from './types' import { apiService } from './services/api' @@ -250,6 +251,7 @@ function App() { } /> } /> } /> + } /> } /> {/* 保留旧路由以保持向后兼容 */} } /> diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index ddae261..6c4c8a2 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -157,6 +157,11 @@ const Layout: React.FC = ({ children }) => { } ] }, + { + key: '/crypto-tail-strategy', + icon: , + label: t('menu.cryptoTailStrategy') + }, { key: '/positions', icon: , diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index 1d2654e..21cd436 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -266,6 +266,7 @@ "leaders": "Leader Management", "templates": "Templates", "copyTradingConfig": "Copy Trading Config", + "cryptoTailStrategy": "Tail Strategy", "positions": "Position Management", "backtest": "Backtest", "statistics": "Statistics", @@ -1399,5 +1400,70 @@ "rerunTaskNamePlaceholder": "New task name (leave empty for \"Original name (copy)\")", "rerunSuccess": "New backtest task created", "rerunFailed": "Re-run failed" + }, + "cryptoTailStrategy": { + "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.", + "addStrategy": "Add Strategy", + "strategyName": "Strategy Name", + "market": "Market", + "timeWindow": "Time Window", + "priceRange": "Price Range", + "amountMode": "Amount Mode", + "ratio": "Ratio", + "fixed": "Fixed", + "recentTrigger": "Last Trigger", + "actions": "Actions", + "edit": "Edit", + "enable": "Enable", + "disable": "Disable", + "delete": "Delete", + "viewTriggers": "Trigger Records", + "deleteConfirm": "Delete this strategy?", + "fetchFailed": "Failed to fetch list" + }, + "form": { + "walletTip": "Use a dedicated wallet to avoid balance or order issues.", + "strategyName": "Strategy Name", + "strategyNamePlaceholder": "Auto", + "selectAccount": "Select Account", + "selectMarket": "Select Market", + "timeWindowStart": "Window Start", + "timeWindowEnd": "Window End", + "minute": "min", + "second": "sec", + "minPrice": "Min Price", + "maxPrice": "Max Price", + "maxPricePlaceholder": "Default 1", + "amountMode": "Amount Mode", + "ratioPercent": "Ratio %", + "fixedUsdc": "Fixed (USDC)", + "enabled": "Enabled", + "create": "Create", + "update": "Update", + "timeWindowStartLEEnd": "Window start must not be greater than end", + "timeWindowExceed": "Time window must not exceed period length" + }, + "redeemRequiredModal": { + "title": "Configure Auto Redeem First", + "description": "Tail strategy requires auto redeem. Please configure Builder API Key and enable auto redeem in System Settings.", + "goToSettings": "Go to Settings", + "cancel": "Cancel" + }, + "triggerRecords": { + "title": "Trigger Records", + "triggerTime": "Time", + "market": "Market", + "direction": "Direction", + "up": "Up", + "down": "Down", + "triggerPrice": "Trigger Price", + "amount": "Amount", + "orderId": "Order ID", + "status": "Status", + "success": "Success", + "fail": "Fail" + } } } \ No newline at end of file diff --git a/frontend/src/locales/zh-CN/common.json b/frontend/src/locales/zh-CN/common.json index 5c44ff3..912f458 100644 --- a/frontend/src/locales/zh-CN/common.json +++ b/frontend/src/locales/zh-CN/common.json @@ -265,6 +265,7 @@ "leaders": "Leader 管理", "templates": "跟单模板", "copyTradingConfig": "跟单配置", + "cryptoTailStrategy": "尾盘策略", "positions": "仓位管理", "backtest": "回测", "statistics": "统计信息", @@ -1398,5 +1399,70 @@ "rerunTaskNamePlaceholder": "新任务名称(留空使用「原名称 (副本)」)", "rerunSuccess": "已创建新回测任务", "rerunFailed": "重新测试失败" + }, + "cryptoTailStrategy": { + "list": { + "title": "加密尾盘策略", + "walletTip": "请使用单独的钱包运行尾盘策略,避免该钱包用于手动交易、跟单等其他操作,否则可能导致余额或仓位变化,造成策略执行异常。", + "addStrategy": "新增策略", + "strategyName": "策略名称", + "market": "关联市场", + "timeWindow": "时间区间", + "priceRange": "价格区间", + "amountMode": "投入方式", + "ratio": "比例", + "fixed": "固定金额", + "recentTrigger": "最近触发", + "actions": "操作", + "edit": "编辑", + "enable": "启用", + "disable": "停用", + "delete": "删除", + "viewTriggers": "查看触发记录", + "deleteConfirm": "确定删除该策略?", + "fetchFailed": "获取列表失败" + }, + "form": { + "walletTip": "建议使用专用钱包,避免手动操作等导致余额或下单异常。", + "strategyName": "策略名称", + "strategyNamePlaceholder": "自动生成", + "selectAccount": "选择账户", + "selectMarket": "选择市场", + "timeWindowStart": "区间开始", + "timeWindowEnd": "区间结束", + "minute": "分", + "second": "秒", + "minPrice": "最低价", + "maxPrice": "最高价", + "maxPricePlaceholder": "不填默认为 1", + "amountMode": "投入方式", + "ratioPercent": "比例 %", + "fixedUsdc": "固定金额 (USDC)", + "enabled": "启用", + "create": "创建", + "update": "更新", + "timeWindowStartLEEnd": "时间区间开始不能大于结束", + "timeWindowExceed": "时间区间不能超过周期长度" + }, + "redeemRequiredModal": { + "title": "请先配置自动赎回", + "description": "尾盘策略依赖自动赎回功能,请先在系统设置中配置 Builder API Key 并开启自动赎回。", + "goToSettings": "去配置", + "cancel": "取消" + }, + "triggerRecords": { + "title": "触发记录", + "triggerTime": "触发时间", + "market": "市场", + "direction": "方向", + "up": "Up", + "down": "Down", + "triggerPrice": "触发价格", + "amount": "投入金额", + "orderId": "订单 ID", + "status": "状态", + "success": "成功", + "fail": "失败" + } } } \ No newline at end of file diff --git a/frontend/src/locales/zh-TW/common.json b/frontend/src/locales/zh-TW/common.json index e6f203c..e106988 100644 --- a/frontend/src/locales/zh-TW/common.json +++ b/frontend/src/locales/zh-TW/common.json @@ -266,6 +266,7 @@ "leaders": "Leader 管理", "templates": "跟單模板", "copyTradingConfig": "跟單配置", + "cryptoTailStrategy": "尾盤策略", "positions": "倉位管理", "backtest": "回測", "statistics": "統計信息", @@ -1399,5 +1400,70 @@ "rerunTaskNamePlaceholder": "新任務名稱(留空使用「原名稱 (副本)」)", "rerunSuccess": "已創建新回測任務", "rerunFailed": "重新測試失敗" + }, + "cryptoTailStrategy": { + "list": { + "title": "加密尾盤策略", + "walletTip": "請使用單獨的錢包運行尾盤策略,避免該錢包用於手動交易、跟單等其他操作,否則可能導致餘額或倉位變化,造成策略執行異常。", + "addStrategy": "新增策略", + "strategyName": "策略名稱", + "market": "關聯市場", + "timeWindow": "時間區間", + "priceRange": "價格區間", + "amountMode": "投入方式", + "ratio": "比例", + "fixed": "固定金額", + "recentTrigger": "最近觸發", + "actions": "操作", + "edit": "編輯", + "enable": "啟用", + "disable": "停用", + "delete": "刪除", + "viewTriggers": "查看觸發記錄", + "deleteConfirm": "確定刪除該策略?", + "fetchFailed": "獲取列表失敗" + }, + "form": { + "walletTip": "建議使用專用錢包,避免手動操作等導致餘額或下單異常。", + "strategyName": "策略名稱", + "strategyNamePlaceholder": "自動生成", + "selectAccount": "選擇賬戶", + "selectMarket": "選擇市場", + "timeWindowStart": "區間開始", + "timeWindowEnd": "區間結束", + "minute": "分", + "second": "秒", + "minPrice": "最低價", + "maxPrice": "最高價", + "maxPricePlaceholder": "不填默認為 1", + "amountMode": "投入方式", + "ratioPercent": "比例 %", + "fixedUsdc": "固定金額 (USDC)", + "enabled": "啟用", + "create": "創建", + "update": "更新", + "timeWindowStartLEEnd": "時間區間開始不能大於結束", + "timeWindowExceed": "時間區間不能超過週期長度" + }, + "redeemRequiredModal": { + "title": "請先配置自動贖回", + "description": "尾盤策略依賴自動贖回功能,請先在系統設置中配置 Builder API Key 並開啟自動贖回。", + "goToSettings": "去配置", + "cancel": "取消" + }, + "triggerRecords": { + "title": "觸發記錄", + "triggerTime": "觸發時間", + "market": "市場", + "direction": "方向", + "up": "Up", + "down": "Down", + "triggerPrice": "觸發價格", + "amount": "投入金額", + "orderId": "訂單 ID", + "status": "狀態", + "success": "成功", + "fail": "失敗" + } } } \ No newline at end of file diff --git a/frontend/src/pages/CryptoTailStrategyList.tsx b/frontend/src/pages/CryptoTailStrategyList.tsx new file mode 100644 index 0000000..b7a7a71 --- /dev/null +++ b/frontend/src/pages/CryptoTailStrategyList.tsx @@ -0,0 +1,654 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { + Card, + Table, + Button, + Space, + Tag, + Popconfirm, + Switch, + message, + Select, + Modal, + Alert, + Form, + Input, + InputNumber, + Radio, + Spin +} from 'antd' +import { PlusOutlined, EditOutlined, UnorderedListOutlined } from '@ant-design/icons' +import { useTranslation } from 'react-i18next' +import { useMediaQuery } from 'react-responsive' +import { apiService } from '../services/api' +import { useAccountStore } from '../store/accountStore' +import type { CryptoTailStrategyDto, CryptoTailStrategyTriggerDto, CryptoTailMarketOptionDto } from '../types' +import { formatUSDC } from '../utils' + +const CryptoTailStrategyList: React.FC = () => { + const { t } = useTranslation() + const navigate = useNavigate() + const isMobile = useMediaQuery({ maxWidth: 768 }) + const { accounts, fetchAccounts } = useAccountStore() + const [list, setList] = useState([]) + const [loading, setLoading] = useState(false) + const [filters, setFilters] = useState<{ accountId?: number; enabled?: boolean }>({}) + const [systemConfig, setSystemConfig] = useState<{ builderApiKeyConfigured?: boolean; autoRedeemEnabled?: boolean } | null>(null) + const [redeemModalOpen, setRedeemModalOpen] = useState(false) + const [formModalOpen, setFormModalOpen] = useState(false) + const [editingId, setEditingId] = useState(null) + const [marketOptions, setMarketOptions] = useState([]) + const [triggersModalOpen, setTriggersModalOpen] = useState(false) + const [, setTriggersStrategyId] = useState(null) + const [triggers, setTriggers] = useState([]) + const [, setTriggersTotal] = useState(0) + const [triggersLoading, setTriggersLoading] = useState(false) + const [form] = Form.useForm() + + useEffect(() => { + fetchAccounts() + fetchSystemConfig() + fetchMarketOptions() + }, []) + + useEffect(() => { + fetchList() + }, [filters]) + + const fetchSystemConfig = async () => { + try { + const res = await apiService.systemConfig.get() + if (res.data.code === 0 && res.data.data) { + setSystemConfig(res.data.data) + } + } catch { + setSystemConfig(null) + } + } + + const fetchMarketOptions = async () => { + try { + const res = await apiService.cryptoTailStrategy.marketOptions() + if (res.data.code === 0 && res.data.data) { + setMarketOptions(res.data.data) + } + } catch { + setMarketOptions([]) + } + } + + const fetchList = async () => { + setLoading(true) + try { + const res = await apiService.cryptoTailStrategy.list(filters) + if (res.data.code === 0 && res.data.data) { + setList(res.data.data.list ?? []) + } else { + message.error(res.data.msg || t('cryptoTailStrategy.list.fetchFailed')) + } + } catch (e) { + message.error((e as Error).message || t('cryptoTailStrategy.list.fetchFailed')) + } finally { + setLoading(false) + } + } + + const openAddModal = () => { + const needApiKey = !systemConfig?.builderApiKeyConfigured + const needAutoRedeem = !systemConfig?.autoRedeemEnabled + if (needApiKey || needAutoRedeem) { + setRedeemModalOpen(true) + return + } + setEditingId(null) + form.resetFields() + form.setFieldsValue({ + enabled: true, + amountMode: 'RATIO', + maxPrice: '1', + windowStartMinutes: 0, + windowStartSeconds: 0 + }) + setFormModalOpen(true) + } + + const openEditModal = (record: CryptoTailStrategyDto) => { + setEditingId(record.id) + form.setFieldsValue({ + accountId: record.accountId, + name: record.name, + marketSlugPrefix: record.marketSlugPrefix, + intervalSeconds: record.intervalSeconds, + windowStartMinutes: Math.floor(record.windowStartSeconds / 60), + windowStartSeconds: record.windowStartSeconds % 60, + windowEndMinutes: Math.floor(record.windowEndSeconds / 60), + windowEndSeconds: record.windowEndSeconds % 60, + minPrice: record.minPrice, + maxPrice: record.maxPrice, + amountMode: record.amountMode, + amountValue: record.amountValue, + enabled: record.enabled + }) + setFormModalOpen(true) + } + + const handleFormSubmit = async () => { + try { + const v = await form.validateFields() + const interval = (editingId ? v.intervalSeconds : marketOptions.find((m) => m.slug === v.marketSlugPrefix)?.intervalSeconds) ?? 300 + const windowStartSeconds = (v.windowStartMinutes ?? 0) * 60 + (v.windowStartSeconds ?? 0) + const windowEndSeconds = (v.windowEndMinutes ?? 0) * 60 + (v.windowEndSeconds ?? 0) + if (windowStartSeconds > windowEndSeconds) { + message.error(t('cryptoTailStrategy.form.timeWindowStartLEEnd')) + return + } + const maxWindow = interval + if (windowEndSeconds > maxWindow) { + message.error(t('cryptoTailStrategy.form.timeWindowExceed')) + return + } + const payload = { + accountId: v.accountId as number, + name: v.name as string | undefined, + marketSlugPrefix: v.marketSlugPrefix as string, + intervalSeconds: interval, + windowStartSeconds, + windowEndSeconds, + minPrice: String(v.minPrice ?? 0), + maxPrice: v.maxPrice != null ? String(v.maxPrice) : undefined, + amountMode: v.amountMode as string, + amountValue: String(v.amountValue ?? 0), + enabled: v.enabled !== false + } + if (editingId) { + const res = await apiService.cryptoTailStrategy.update({ + strategyId: editingId, + name: payload.name, + windowStartSeconds: payload.windowStartSeconds, + windowEndSeconds: payload.windowEndSeconds, + minPrice: payload.minPrice, + maxPrice: payload.maxPrice, + amountMode: payload.amountMode, + amountValue: payload.amountValue, + enabled: payload.enabled + }) + if (res.data.code === 0) { + message.success(t('common.success')) + setFormModalOpen(false) + fetchList() + } else { + message.error(res.data.msg || t('common.failed')) + } + } else { + const res = await apiService.cryptoTailStrategy.create(payload) + if (res.data.code === 0) { + message.success(t('common.success')) + setFormModalOpen(false) + fetchList() + } else { + message.error(res.data.msg || t('common.failed')) + } + } + } catch (e) { + if ((e as { errorFields?: unknown[] })?.errorFields) { + return + } + message.error((e as Error).message) + } + } + + const handleToggle = async (record: CryptoTailStrategyDto) => { + try { + const res = await apiService.cryptoTailStrategy.update({ + strategyId: record.id, + enabled: !record.enabled + }) + if (res.data.code === 0) { + message.success(record.enabled ? t('cryptoTailStrategy.list.disable') : t('cryptoTailStrategy.list.enable')) + fetchList() + } else { + message.error(res.data.msg) + } + } catch (e) { + message.error((e as Error).message) + } + } + + const handleDelete = async (strategyId: number) => { + try { + const res = await apiService.cryptoTailStrategy.delete({ strategyId }) + if (res.data.code === 0) { + message.success(t('common.success')) + fetchList() + } else { + message.error(res.data.msg) + } + } catch (e) { + message.error((e as Error).message) + } + } + + const openTriggers = async (strategyId: number) => { + setTriggersStrategyId(strategyId) + setTriggersModalOpen(true) + setTriggersLoading(true) + try { + const res = await apiService.cryptoTailStrategy.triggers({ strategyId, page: 1, pageSize: 50 }) + if (res.data.code === 0 && res.data.data) { + setTriggers(res.data.data.list ?? []) + setTriggersTotal(res.data.data.total ?? 0) + } + } finally { + setTriggersLoading(false) + } + } + + const formatTimeWindow = (startSec: number, endSec: number): string => { + const sm = Math.floor(startSec / 60) + const ss = startSec % 60 + const em = Math.floor(endSec / 60) + const es = endSec % 60 + return `${sm} ${t('cryptoTailStrategy.form.minute')} ${ss} ${t('cryptoTailStrategy.form.second')} ~ ${em} ${t('cryptoTailStrategy.form.minute')} ${es} ${t('cryptoTailStrategy.form.second')}` + } + + const formatLastTrigger = (ts?: number) => { + if (ts == null) return '-' + const d = new Date(ts) + return d.toLocaleString() + } + + const columns = [ + { + title: t('cryptoTailStrategy.list.strategyName'), + dataIndex: 'name', + key: 'name', + width: isMobile ? 100 : 140, + render: (name: string | undefined, r: CryptoTailStrategyDto) => name || (r.marketTitle ?? r.marketSlugPrefix) || '-' + }, + { + title: t('cryptoTailStrategy.list.market'), + key: 'market', + width: isMobile ? 120 : 200, + render: (_: unknown, r: CryptoTailStrategyDto) => + marketOptions.find((m) => m.slug === r.marketSlugPrefix)?.title ?? r.marketTitle ?? r.marketSlugPrefix ?? '-' + }, + { + title: t('cryptoTailStrategy.list.timeWindow'), + key: 'timeWindow', + width: isMobile ? 140 : 180, + render: (_: unknown, r: CryptoTailStrategyDto) => formatTimeWindow(r.windowStartSeconds, r.windowEndSeconds) + }, + { + title: t('cryptoTailStrategy.list.priceRange'), + key: 'priceRange', + width: isMobile ? 90 : 120, + render: (_: unknown, r: CryptoTailStrategyDto) => `[${r.minPrice}, ${r.maxPrice}]` + }, + { + title: t('cryptoTailStrategy.list.amountMode'), + key: 'amountMode', + width: isMobile ? 90 : 120, + render: (_: unknown, r: CryptoTailStrategyDto) => + r.amountMode === 'RATIO' + ? `${t('cryptoTailStrategy.list.ratio')} ${r.amountValue}%` + : `${t('cryptoTailStrategy.list.fixed')} ${formatUSDC(r.amountValue)} USDC` + }, + { + title: t('common.status'), + dataIndex: 'enabled', + key: 'enabled', + width: 90, + render: (enabled: boolean, record: CryptoTailStrategyDto) => ( + handleToggle(record)} + checkedChildren={t('cryptoTailStrategy.list.enable')} + unCheckedChildren={t('cryptoTailStrategy.list.disable')} + /> + ) + }, + { + title: t('cryptoTailStrategy.list.recentTrigger'), + dataIndex: 'lastTriggerAt', + key: 'lastTriggerAt', + width: isMobile ? 100 : 160, + render: (ts: number | undefined) => formatLastTrigger(ts) + }, + { + title: t('cryptoTailStrategy.list.actions'), + key: 'actions', + width: isMobile ? 120 : 200, + fixed: 'right' as const, + render: (_: unknown, record: CryptoTailStrategyDto) => ( + + + + handleDelete(record.id)} + okText={t('common.confirm')} + cancelText={t('common.cancel')} + > + + + + ) + } + ] + + const selectedMarket = Form.useWatch('marketSlugPrefix', form) + const intervalSeconds = marketOptions.find((m) => m.slug === selectedMarket)?.intervalSeconds ?? 300 + const maxMinutes = Math.floor(intervalSeconds / 60) + + // 新建时:选择市场后,区间开始默认 0分0秒,区间结束默认 x分0秒(x=周期) + useEffect(() => { + if (!formModalOpen || editingId != null || !selectedMarket) return + const intervalMin = Math.floor(intervalSeconds / 60) + form.setFieldsValue({ + windowStartMinutes: 0, + windowStartSeconds: 0, + windowEndMinutes: intervalMin, + windowEndSeconds: 0 + }) + }, [formModalOpen, editingId, selectedMarket, intervalSeconds]) + + return ( +
+

{t('cryptoTailStrategy.list.title')}

+ + +
+ + setFilters((f) => ({ ...f, enabled: en }))} + value={filters.enabled} + options={[ + { label: t('common.enabled'), value: true }, + { label: t('common.disabled'), value: false } + ]} + /> +
+ + {isMobile ? ( +
+ {list.map((item) => ( + +
+ {item.name || item.marketSlugPrefix || '-'} +
+
+ {t('cryptoTailStrategy.list.timeWindow')}: {formatTimeWindow(item.windowStartSeconds, item.windowEndSeconds)} +
+
+ {t('cryptoTailStrategy.list.priceRange')}: [{item.minPrice}, {item.maxPrice}] +
+
+ {item.amountMode === 'RATIO' ? `${item.amountValue}%` : `${formatUSDC(item.amountValue)} USDC`} +
+ + handleToggle(item)} + size="small" + /> + + + handleDelete(item.id)} + okText={t('common.confirm')} + cancelText={t('common.cancel')} + > + + + +
+ ))} +
+ ) : ( + + )} + + + + setRedeemModalOpen(false)} + footer={[ + , + + ]} + > +

{t('cryptoTailStrategy.redeemRequiredModal.description')}

+
+ + setFormModalOpen(false)} + onOk={handleFormSubmit} + width={isMobile ? '100%' : 520} + destroyOnClose + > + +
+ + + + + ({ label: `${i}`, value: i }))} + /> + + {t('cryptoTailStrategy.form.minute')} + + ({ label: `${i}`, value: i }))} + /> + + {t('cryptoTailStrategy.form.minute')} + +
new Date(ts).toLocaleString() + }, + { + title: t('cryptoTailStrategy.triggerRecords.market'), + dataIndex: 'marketTitle', + key: 'marketTitle', + ellipsis: true + }, + { + title: t('cryptoTailStrategy.triggerRecords.direction'), + dataIndex: 'outcomeIndex', + key: 'outcomeIndex', + render: (i: number) => (i === 0 ? t('cryptoTailStrategy.triggerRecords.up') : t('cryptoTailStrategy.triggerRecords.down')) + }, + { + title: t('cryptoTailStrategy.triggerRecords.triggerPrice'), + dataIndex: 'triggerPrice', + key: 'triggerPrice' + }, + { + title: t('cryptoTailStrategy.triggerRecords.amount'), + dataIndex: 'amountUsdc', + key: 'amountUsdc', + render: (v: string) => `${formatUSDC(v)} USDC` + }, + { + title: t('cryptoTailStrategy.triggerRecords.orderId'), + dataIndex: 'orderId', + key: 'orderId', + ellipsis: true + }, + { + title: t('cryptoTailStrategy.triggerRecords.status'), + dataIndex: 'status', + key: 'status', + render: (s: string) => ( + + {s === 'success' ? t('cryptoTailStrategy.triggerRecords.success') : t('cryptoTailStrategy.triggerRecords.fail')} + + ) + } + ]} + pagination={false} + scroll={{ x: 600 }} + /> + + + + ) +} + +export default CryptoTailStrategyList diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 6325605..343e8d8 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -429,6 +429,46 @@ export const apiService = { }) => apiClient.post>('/copy-trading/configs/filtered-orders', data) }, + + /** + * 尾盘策略 API + */ + cryptoTailStrategy: { + list: (data: { accountId?: number; enabled?: boolean } = {}) => + apiClient.post>('/crypto-tail-strategy/list', data), + create: (data: { + accountId: number + name?: string + marketSlugPrefix: string + intervalSeconds: number + windowStartSeconds: number + windowEndSeconds: number + minPrice: string + maxPrice?: string + amountMode: string + amountValue: string + enabled?: boolean + }) => + apiClient.post>('/crypto-tail-strategy/create', data), + update: (data: { + strategyId: number + name?: string + windowStartSeconds?: number + windowEndSeconds?: number + minPrice?: string + maxPrice?: string + amountMode?: string + amountValue?: string + enabled?: boolean + }) => + apiClient.post>('/crypto-tail-strategy/update', data), + delete: (data: { strategyId: number }) => + apiClient.post>('/crypto-tail-strategy/delete', data), + triggers: (data: { strategyId: number; page?: number; pageSize?: number; status?: string }) => + apiClient.post>('/crypto-tail-strategy/triggers', data), + marketOptions: () => + apiClient.post>('/crypto-tail-strategy/market-options', {}) + }, /** * 订单管理 API diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index ce1e0f9..4ef799e 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1034,3 +1034,53 @@ export interface BacktestTaskDto { executionStartedAt?: number executionFinishedAt?: number } + +/** + * 尾盘策略 + */ +export interface CryptoTailStrategyDto { + id: number + accountId: number + name?: string + marketSlugPrefix: string + marketTitle?: string + intervalSeconds: number + windowStartSeconds: number + windowEndSeconds: number + minPrice: string + maxPrice: string + amountMode: string + amountValue: string + enabled: boolean + lastTriggerAt?: number + createdAt: number + updatedAt: number +} + +/** + * 尾盘策略触发记录 + */ +export interface CryptoTailStrategyTriggerDto { + id: number + strategyId: number + periodStartUnix: number + marketTitle?: string + outcomeIndex: number + triggerPrice: string + amountUsdc: string + orderId?: string + status: string + failReason?: string + createdAt: number +} + +/** + * 尾盘策略市场选项 + */ +export interface CryptoTailMarketOptionDto { + slug: string + title: string + intervalSeconds: number + periodStartUnix: number + endDate?: string +} diff --git a/scripts/fetch_crypto_minute_markets.py b/scripts/fetch_crypto_minute_markets.py new file mode 100644 index 0000000..139bdc3 --- /dev/null +++ b/scripts/fetch_crypto_minute_markets.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +""" +获取 Polymarket 5/15 分钟加密市场数据(开始时间、结束时间、conditionId)。 +使用 Gamma API: https://gamma-api.polymarket.com +验证方式: python3 scripts/fetch_crypto_minute_markets.py +""" +import json +import time +import urllib.request +from datetime import datetime, timezone + +GAMMA_BASE = "https://gamma-api.polymarket.com" + + +def fetch_event_by_slug(slug: str) -> dict | None: + url = f"{GAMMA_BASE}/events/slug/{slug}" + req = urllib.request.Request(url, headers={"User-Agent": "PolymarketBot/1.0 (script)"}) + try: + with urllib.request.urlopen(req, timeout=10) as resp: + return json.load(resp) + except urllib.error.HTTPError as e: + if e.code == 404: + return None + raise + except Exception as e: + print(f"Request error {url}: {e}") + return None + + +def parse_iso_to_ms(iso: str | None) -> int | None: + if not iso: + return None + try: + # ISO 可能带 Z 或 +00:00 + if iso.endswith("Z"): + iso = iso.replace("Z", "+00:00") + dt = datetime.fromisoformat(iso.replace("Z", "+00:00")) + return int(dt.timestamp() * 1000) + except Exception: + return None + + +def main(): + now = int(time.time()) + # 5 分钟周期边界 (300s) + period_5m = (now // 300) * 300 + next_5m = period_5m + 300 + # 15 分钟周期边界 (900s);slug 可能用结束时间,这里试起点 + period_15m = (now // 900) * 900 + next_15m = period_15m + 900 + + print("=== 5 minute markets (BTC) ===") + for ts, label in [(period_5m, "current"), (next_5m, "next")]: + slug = f"btc-updown-5m-{ts}" + ev = fetch_event_by_slug(slug) + if ev and ev.get("slug"): + start = ev.get("startDate") + end = ev.get("endDate") + print(f" [{label}] slug={slug}") + print(f" title: {ev.get('title', '')[:70]}") + print(f" startDate: {start} endDate: {end}") + markets = ev.get("markets") or [] + for m in markets[:1]: + cid = m.get("conditionId") + print(f" conditionId: {cid}") + print(f" question: {(m.get('question') or '')[:60]}") + # clobTokenIds 用于订单簿 + tokens = m.get("clobTokenIds") + if tokens: + try: + ids = json.loads(tokens) if isinstance(tokens, str) else tokens + print(f" clobTokenIds: {ids[:2]}..." if len(ids) > 2 else f" clobTokenIds: {ids}") + except Exception: + print(f" clobTokenIds: {tokens[:80]}...") + else: + print(f" [{label}] slug={slug} -> not found (404 or empty)") + + print("\n=== 15 minute markets (BTC) ===") + for ts, label in [(period_15m, "current"), (next_15m, "next")]: + slug = f"btc-updown-15m-{ts}" + ev = fetch_event_by_slug(slug) + if ev and ev.get("slug"): + print(f" [{label}] slug={slug}") + print(f" title: {ev.get('title', '')[:70]}") + print(f" startDate: {ev.get('startDate')} endDate: {ev.get('endDate')}") + for m in (ev.get("markets") or [])[:1]: + print(f" conditionId: {m.get('conditionId')}") + else: + print(f" [{label}] slug={slug} -> not found") + + print("\n=== Summary ===") + print("5m: slug btc-updown-5m-{periodStartUnix}, periodStartUnix = (now // 300) * 300; period end = endDate.") + print("15m: slug btc-updown-15m-{periodStartUnix}, periodStartUnix = (now // 900) * 900; period end = endDate.") + print("Period start = slug timestamp; period end = API endDate (do not use startDate as period start).") + + +if __name__ == "__main__": + main()