From 2dee65900e0c3f14b05a90250e1dc95b84626d7c Mon Sep 17 00:00:00 2001 From: WrBug Date: Sat, 7 Mar 2026 04:33:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(sports-tail):=20=E4=BD=93=E8=82=B2?= =?UTF-8?q?=E5=B0=BE=E7=9B=98=E7=AD=96=E7=95=A5=E4=B8=8E=20createClient=20?= =?UTF-8?q?lazy=20=E5=88=9D=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 体育尾盘策略:实体/Repository/Service/Controller/执行服务/订单簿 WS - 迁移 V41:体育尾盘策略表 - API:PolymarketGammaSportsApi,RetrofitFactory 去重 - 前端:体育尾盘策略列表页、路由、API、多语言 - ErrorCode 与 i18n 消息 - createClient 调用改为 lazy:SportsTailOrderbookWsService、CryptoTailOrderbookWsService、TelegramNotificationService Made-with: Cursor --- .../api/PolymarketGammaSportsApi.kt | 86 +++ .../SportsTailStrategyController.kt | 260 ++++++++ .../dto/SportsTailStrategyDto.kt | 216 +++++++ .../entity/SportsTailStrategy.kt | 118 ++++ .../entity/SportsTailStrategyTrigger.kt | 103 ++++ .../wrbug/polymarketbot/enums/ErrorCode.kt | 23 +- .../event/SportsTailStrategyChangedEvent.kt | 9 + .../SportsTailStrategyRepository.kt | 56 ++ .../SportsTailStrategyTriggerRepository.kt | 94 +++ .../CryptoTailOrderbookWsService.kt | 2 +- .../SportsTailOrderbookWsService.kt | 290 +++++++++ .../SportsTailStrategyExecutionService.kt | 303 +++++++++ .../sportstail/SportsTailStrategyService.kt | 420 +++++++++++++ .../system/TelegramNotificationService.kt | 12 +- .../polymarketbot/util/RetrofitFactory.kt | 20 + .../V41__add_sports_tail_strategy_tables.sql | 67 ++ .../resources/i18n/messages_en.properties | 17 + .../resources/i18n/messages_zh_CN.properties | 17 + .../resources/i18n/messages_zh_TW.properties | 17 + frontend/src/App.tsx | 2 + frontend/src/components/Layout.tsx | 12 +- frontend/src/locales/en/common.json | 74 +++ frontend/src/locales/zh-CN/common.json | 74 +++ frontend/src/locales/zh-TW/common.json | 74 +++ frontend/src/pages/SportsTailStrategyList.tsx | 575 ++++++++++++++++++ frontend/src/services/api.ts | 20 + frontend/src/types/index.ts | 122 ++++ 27 files changed, 3072 insertions(+), 11 deletions(-) create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketGammaSportsApi.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/controller/sportstail/SportsTailStrategyController.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/dto/SportsTailStrategyDto.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/entity/SportsTailStrategy.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/entity/SportsTailStrategyTrigger.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/event/SportsTailStrategyChangedEvent.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/repository/SportsTailStrategyRepository.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/repository/SportsTailStrategyTriggerRepository.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/sportstail/SportsTailOrderbookWsService.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/sportstail/SportsTailStrategyExecutionService.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/sportstail/SportsTailStrategyService.kt create mode 100644 backend/src/main/resources/db/migration/V41__add_sports_tail_strategy_tables.sql create mode 100644 frontend/src/pages/SportsTailStrategyList.tsx diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketGammaSportsApi.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketGammaSportsApi.kt new file mode 100644 index 0000000..93f1486 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketGammaSportsApi.kt @@ -0,0 +1,86 @@ +package com.wrbug.polymarketbot.api + +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.Query + +/** + * Polymarket Gamma API 体育市场接口 + * Base URL: https://gamma-api.polymarket.com + */ +interface PolymarketGammaSportsApi { + + /** + * 获取体育类别列表 + * GET /sports + */ + @GET("/sports") + suspend fun getSports(): Response> + + /** + * 按条件搜索市场 + * GET /markets + * @param tagId 标签ID(体育类别) + * @param active 是否活跃 + * @param closed 是否已关闭 + * @param limit 返回数量 + * @param order 排序字段 + * @param ascending 是否升序 + * @param slug 搜索关键词 + */ + @GET("/markets") + suspend fun searchMarkets( + @Query("tag_id") tagId: Long? = null, + @Query("active") active: Boolean? = null, + @Query("closed") closed: Boolean? = null, + @Query("limit") limit: Int? = null, + @Query("order") order: String? = null, + @Query("ascending") ascending: Boolean? = null, + @Query("slug") slug: String? = null, + @Query("condition_ids") conditionIds: String? = null + ): Response> +} + +/** + * 体育类别响应 + */ +data class SportsCategoryResponse( + val sport: String? = null, + val image: String? = null, + val tags: String? = null +) + +/** + * 体育市场响应 + */ +data class SportsMarketResponse( + val id: String? = null, + val question: String? = null, + val conditionId: String? = null, + val slug: String? = null, + val outcomes: String? = null, + val outcomePrices: String? = null, + val endDate: String? = null, + val startDate: String? = null, + val bestBid: Double? = null, + val bestAsk: Double? = null, + val clobTokenIds: String? = null, + val liquidity: String? = null, + val liquidityNum: Double? = null, + val volume: String? = null, + val volumeNum: Double? = null, + val active: Boolean? = null, + val closed: Boolean? = null, + val events: List? = null +) + +/** + * 体育事件响应 + */ +data class SportsEventResponse( + val id: String? = null, + val slug: String? = null, + val title: String? = null, + val ticker: String? = null +) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/sportstail/SportsTailStrategyController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/sportstail/SportsTailStrategyController.kt new file mode 100644 index 0000000..56102b8 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/sportstail/SportsTailStrategyController.kt @@ -0,0 +1,260 @@ +package com.wrbug.polymarketbot.controller.sportstail + +import com.wrbug.polymarketbot.dto.ApiResponse +import com.wrbug.polymarketbot.dto.SportsCategoryListResponse +import com.wrbug.polymarketbot.dto.SportsMarketDetailRequest +import com.wrbug.polymarketbot.dto.SportsMarketDetailResponse +import com.wrbug.polymarketbot.dto.SportsMarketSearchRequest +import com.wrbug.polymarketbot.dto.SportsMarketSearchResponse +import com.wrbug.polymarketbot.dto.SportsTailStrategyCreateRequest +import com.wrbug.polymarketbot.dto.SportsTailStrategyCreateResponse +import com.wrbug.polymarketbot.dto.SportsTailStrategyDeleteRequest +import com.wrbug.polymarketbot.dto.SportsTailStrategyListRequest +import com.wrbug.polymarketbot.dto.SportsTailStrategyListResponse +import com.wrbug.polymarketbot.dto.SportsTailTriggerListRequest +import com.wrbug.polymarketbot.dto.SportsTailTriggerListResponse +import com.wrbug.polymarketbot.enums.ErrorCode +import com.wrbug.polymarketbot.service.sportstail.SportsTailStrategyService +import kotlinx.coroutines.runBlocking +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/sports-tail-strategy") +class SportsTailStrategyController( + private val sportsTailStrategyService: SportsTailStrategyService, + private val messageSource: MessageSource +) { + + private val logger = LoggerFactory.getLogger(SportsTailStrategyController::class.java) + + @PostMapping("/list") + fun list(@RequestBody request: SportsTailStrategyListRequest): ResponseEntity> { + return try { + val result = sportsTailStrategyService.list(request) + result.fold( + onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) }, + onFailure = { e -> + logger.error("查询体育尾盘策略列表失败: ${e.message}", e) + ResponseEntity.ok( + ApiResponse.error( + ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_LIST_FETCH_FAILED, + e.message, + messageSource + ) + ) + } + ) + } catch (e: Exception) { + logger.error("查询体育尾盘策略列表异常: ${e.message}", e) + ResponseEntity.ok( + ApiResponse.error( + ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_LIST_FETCH_FAILED, + e.message, + messageSource + ) + ) + } + } + + @PostMapping("/create") + fun create(@RequestBody request: SportsTailStrategyCreateRequest): ResponseEntity> { + return try { + val result = sportsTailStrategyService.create(request) + result.fold( + onSuccess = { + ResponseEntity.ok( + ApiResponse.success(SportsTailStrategyCreateResponse(id = it.id)) + ) + }, + onFailure = { e -> + logger.error("创建体育尾盘策略失败: ${e.message}", e) + val code = when (e.message) { + ErrorCode.ACCOUNT_NOT_FOUND.messageKey -> ErrorCode.ACCOUNT_NOT_FOUND + ErrorCode.SPORTS_TAIL_STRATEGY_CONDITION_ID_EMPTY.messageKey -> ErrorCode.SPORTS_TAIL_STRATEGY_CONDITION_ID_EMPTY + ErrorCode.SPORTS_TAIL_STRATEGY_PRICE_INVALID.messageKey -> ErrorCode.SPORTS_TAIL_STRATEGY_PRICE_INVALID + ErrorCode.SPORTS_TAIL_STRATEGY_AMOUNT_MODE_INVALID.messageKey -> ErrorCode.SPORTS_TAIL_STRATEGY_AMOUNT_MODE_INVALID + "该市场已存在策略" -> ErrorCode.PARAM_ERROR + else -> ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_CREATE_FAILED + } + ResponseEntity.ok(ApiResponse.error(code, e.message, messageSource)) + } + ) + } catch (e: Exception) { + logger.error("创建体育尾盘策略异常: ${e.message}", e) + ResponseEntity.ok( + ApiResponse.error( + ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_CREATE_FAILED, + e.message, + messageSource + ) + ) + } + } + + @PostMapping("/delete") + fun delete(@RequestBody request: SportsTailStrategyDeleteRequest): ResponseEntity> { + return try { + val id = request.id + if (id <= 0) { + return ResponseEntity.ok( + ApiResponse.error(ErrorCode.SPORTS_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource) + ) + } + val result = sportsTailStrategyService.delete(id) + result.fold( + onSuccess = { ResponseEntity.ok(ApiResponse.success(Unit)) }, + onFailure = { e -> + logger.error("删除体育尾盘策略失败: ${e.message}", e) + val code = when (e.message) { + ErrorCode.SPORTS_TAIL_STRATEGY_NOT_FOUND.messageKey -> ErrorCode.SPORTS_TAIL_STRATEGY_NOT_FOUND + "已成交未卖出的策略不能删除" -> ErrorCode.PARAM_ERROR + else -> ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_DELETE_FAILED + } + ResponseEntity.ok(ApiResponse.error(code, e.message, messageSource)) + } + ) + } catch (e: Exception) { + logger.error("删除体育尾盘策略异常: ${e.message}", e) + ResponseEntity.ok( + ApiResponse.error( + ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_DELETE_FAILED, + e.message, + messageSource + ) + ) + } + } + + @PostMapping("/triggers") + fun triggers(@RequestBody request: SportsTailTriggerListRequest): ResponseEntity> { + return try { + val result = sportsTailStrategyService.getTriggers(request) + result.fold( + onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) }, + onFailure = { e -> + logger.error("查询触发记录失败: ${e.message}", e) + ResponseEntity.ok( + ApiResponse.error( + ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED, + e.message, + messageSource + ) + ) + } + ) + } catch (e: Exception) { + logger.error("查询触发记录异常: ${e.message}", e) + ResponseEntity.ok( + ApiResponse.error( + ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED, + e.message, + messageSource + ) + ) + } + } + + @PostMapping("/sports-list") + fun sportsList(): ResponseEntity> { + return runBlocking { + try { + val result = sportsTailStrategyService.getSportsCategories() + result.fold( + onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) }, + onFailure = { e -> + logger.error("查询体育类别失败: ${e.message}", e) + ResponseEntity.ok( + ApiResponse.error( + ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_SPORTS_FETCH_FAILED, + e.message, + messageSource + ) + ) + } + ) + } catch (e: Exception) { + logger.error("查询体育类别异常: ${e.message}", e) + ResponseEntity.ok( + ApiResponse.error( + ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_SPORTS_FETCH_FAILED, + e.message, + messageSource + ) + ) + } + } + } + + @PostMapping("/market-search") + fun marketSearch(@RequestBody request: SportsMarketSearchRequest): ResponseEntity> { + return runBlocking { + try { + val result = sportsTailStrategyService.searchMarkets(request) + result.fold( + onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) }, + onFailure = { e -> + logger.error("搜索市场失败: ${e.message}", e) + ResponseEntity.ok( + ApiResponse.error( + ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_MARKET_SEARCH_FAILED, + e.message, + messageSource + ) + ) + } + ) + } catch (e: Exception) { + logger.error("搜索市场异常: ${e.message}", e) + ResponseEntity.ok( + ApiResponse.error( + ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_MARKET_SEARCH_FAILED, + e.message, + messageSource + ) + ) + } + } + } + + @PostMapping("/market-detail") + fun marketDetail(@RequestBody request: SportsMarketDetailRequest): ResponseEntity> { + return runBlocking { + try { + if (request.conditionId.isBlank()) { + return@runBlocking ResponseEntity.ok( + ApiResponse.error(ErrorCode.SPORTS_TAIL_STRATEGY_CONDITION_ID_EMPTY, messageSource = messageSource) + ) + } + val result = sportsTailStrategyService.getMarketDetail(request.conditionId) + result.fold( + onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) }, + onFailure = { e -> + logger.error("获取市场详情失败: ${e.message}", e) + ResponseEntity.ok( + ApiResponse.error( + ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_MARKET_DETAIL_FAILED, + e.message, + messageSource + ) + ) + } + ) + } catch (e: Exception) { + logger.error("获取市场详情异常: ${e.message}", e) + ResponseEntity.ok( + ApiResponse.error( + ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_MARKET_DETAIL_FAILED, + e.message, + messageSource + ) + ) + } + } + } +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/SportsTailStrategyDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/SportsTailStrategyDto.kt new file mode 100644 index 0000000..0c8152a --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/SportsTailStrategyDto.kt @@ -0,0 +1,216 @@ +package com.wrbug.polymarketbot.dto + +/** + * 体育尾盘策略 DTO + */ +data class SportsTailStrategyDto( + val id: Long = 0L, + val accountId: Long = 0L, + val accountName: String? = null, + val conditionId: String = "", + val marketTitle: String? = null, + val eventSlug: String? = null, + val triggerPrice: String = "", + val amountMode: String = "FIXED", + val amountValue: String = "", + val takeProfitPrice: String? = null, + val stopLossPrice: String? = null, + + /** 成交信息 */ + val filled: Boolean = false, + val filledPrice: String? = null, + val filledOutcomeIndex: Int? = null, + val filledOutcomeName: String? = null, + val filledAmount: String? = null, + val filledShares: String? = null, + val filledAt: Long? = null, + + /** 卖出信息 */ + val sold: Boolean = false, + val sellPrice: String? = null, + val sellType: String? = null, + val sellAmount: String? = null, + val realizedPnl: String? = null, + val soldAt: Long? = null, + + /** 实时价格(未成交时返回) */ + val realtimeYesPrice: String? = null, + val realtimeNoPrice: String? = null, + + val createdAt: Long = 0L, + val updatedAt: Long = 0L +) + +/** + * 策略列表请求 + */ +data class SportsTailStrategyListRequest( + val accountId: Long? = null, + val sport: String? = null +) + +/** + * 策略列表响应 + */ +data class SportsTailStrategyListResponse( + val list: List = emptyList() +) + +/** + * 策略创建请求 + */ +data class SportsTailStrategyCreateRequest( + val accountId: Long = 0L, + val conditionId: String = "", + val marketTitle: String = "", + val eventSlug: String? = null, + val triggerPrice: String = "", + val amountMode: String = "FIXED", + val amountValue: String = "", + val takeProfitPrice: String? = null, + val stopLossPrice: String? = null +) + +/** + * 策略创建响应 + */ +data class SportsTailStrategyCreateResponse( + val id: Long = 0L +) + +/** + * 策略删除请求 + */ +data class SportsTailStrategyDeleteRequest( + val id: Long = 0L +) + +/** + * 策略触发记录 DTO + */ +data class SportsTailTriggerDto( + val id: Long = 0L, + val strategyId: Long = 0L, + + /** 市场信息 */ + val marketTitle: String? = null, + val conditionId: String = "", + + /** 买入信息 */ + val buyPrice: String = "", + val outcomeIndex: Int = 0, + val outcomeName: String? = null, + val buyAmount: String = "", + val buyShares: String? = null, + val buyStatus: String = "PENDING", + + /** 卖出信息 */ + val sellPrice: String? = null, + val sellType: String? = null, + val sellAmount: String? = null, + val sellStatus: String? = null, + + /** 盈亏 */ + val realizedPnl: String? = null, + + /** 时间 */ + val triggeredAt: Long = 0L, + val soldAt: Long? = null +) + +/** + * 触发记录列表请求 + */ +data class SportsTailTriggerListRequest( + val accountId: Long? = null, + val status: String? = null, + val startTime: Long? = null, + val endTime: Long? = null, + val page: Int = 1, + val pageSize: Int = 20 +) + +/** + * 触发记录列表响应 + */ +data class SportsTailTriggerListResponse( + val total: Long = 0L, + val list: List = emptyList() +) + +/** + * 体育类别 DTO + */ +data class SportsCategoryDto( + val sport: String = "", + val image: String? = null, + val tagId: Long = 0L, + val name: String = "" +) + +/** + * 体育类别列表响应 + */ +data class SportsCategoryListResponse( + val list: List = emptyList() +) + +/** + * 体育市场 DTO + */ +data class SportsMarketDto( + val conditionId: String = "", + val question: String = "", + val outcomes: List = emptyList(), + val outcomePrices: List = emptyList(), + val endDate: String? = null, + val liquidity: String? = null, + val bestBid: Double? = null, + val bestAsk: Double? = null, + val yesTokenId: String? = null, + val noTokenId: String? = null, + val eventSlug: String? = null +) + +/** + * 市场搜索请求 + */ +data class SportsMarketSearchRequest( + val sport: String? = null, + val endDateMin: String? = null, + val endDateMax: String? = null, + val minLiquidity: String? = null, + val keyword: String? = null, + val limit: Int = 50 +) + +/** + * 市场搜索响应 + */ +data class SportsMarketSearchResponse( + val list: List = emptyList() +) + +/** + * 市场详情请求 + */ +data class SportsMarketDetailRequest( + val conditionId: String = "" +) + +/** + * 市场详情响应 + */ +data class SportsMarketDetailResponse( + val conditionId: String = "", + val question: String = "", + val outcomes: List = emptyList(), + val outcomePrices: List = emptyList(), + val endDate: String? = null, + val liquidity: String? = null, + val bestBid: Double? = null, + val bestAsk: Double? = null, + val yesTokenId: String? = null, + val noTokenId: String? = null, + val eventSlug: String? = null +) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/SportsTailStrategy.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/SportsTailStrategy.kt new file mode 100644 index 0000000..ae3fa86 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/SportsTailStrategy.kt @@ -0,0 +1,118 @@ +package com.wrbug.polymarketbot.entity + +import jakarta.persistence.* +import java.math.BigDecimal + +/** + * 体育尾盘策略实体 + * 在价格达到设定值时自动买入,支持止盈止损 + */ +@Entity +@Table(name = "sports_tail_strategy") +data class SportsTailStrategy( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + /** 账户ID */ + @Column(name = "account_id", nullable = false) + val accountId: Long = 0L, + + /** 市场 conditionId */ + @Column(name = "condition_id", nullable = false, length = 100) + val conditionId: String = "", + + /** 市场标题 */ + @Column(name = "market_title", length = 500) + val marketTitle: String? = null, + + /** 事件 slug */ + @Column(name = "event_slug", length = 255) + val eventSlug: String? = null, + + /** YES Token ID */ + @Column(name = "yes_token_id", length = 100) + val yesTokenId: String? = null, + + /** NO Token ID */ + @Column(name = "no_token_id", length = 100) + val noTokenId: String? = null, + + /** 触发价格 */ + @Column(name = "trigger_price", nullable = false, precision = 20, scale = 8) + val triggerPrice: BigDecimal = BigDecimal.ONE, + + /** 金额模式: FIXED=固定金额, RATIO=余额比例 */ + @Column(name = "amount_mode", nullable = false, length = 10) + val amountMode: String = "FIXED", + + /** 金额值 */ + @Column(name = "amount_value", nullable = false, precision = 20, scale = 8) + val amountValue: BigDecimal = BigDecimal.ZERO, + + /** 止盈价格 */ + @Column(name = "take_profit_price", precision = 20, scale = 8) + val takeProfitPrice: BigDecimal? = null, + + /** 止损价格 */ + @Column(name = "stop_loss_price", precision = 20, scale = 8) + val stopLossPrice: BigDecimal? = null, + + /** 是否已成交 */ + @Column(name = "filled", nullable = false) + val filled: Boolean = false, + + /** 成交价格 */ + @Column(name = "filled_price", precision = 20, scale = 8) + val filledPrice: BigDecimal? = null, + + /** 成交方向索引: 0=YES, 1=NO */ + @Column(name = "filled_outcome_index") + val filledOutcomeIndex: Int? = null, + + /** 成交方向名称 */ + @Column(name = "filled_outcome_name", length = 50) + val filledOutcomeName: String? = null, + + /** 成交金额 */ + @Column(name = "filled_amount", precision = 20, scale = 8) + val filledAmount: BigDecimal? = null, + + /** 成交份额 */ + @Column(name = "filled_shares", precision = 20, scale = 8) + val filledShares: BigDecimal? = null, + + /** 成交时间 */ + @Column(name = "filled_at") + val filledAt: Long? = null, + + /** 是否已卖出 */ + @Column(name = "sold", nullable = false) + val sold: Boolean = false, + + /** 卖出价格 */ + @Column(name = "sell_price", precision = 20, scale = 8) + val sellPrice: BigDecimal? = null, + + /** 卖出类型: TAKE_PROFIT, STOP_LOSS, MANUAL */ + @Column(name = "sell_type", length = 20) + val sellType: String? = null, + + /** 卖出金额 */ + @Column(name = "sell_amount", precision = 20, scale = 8) + val sellAmount: BigDecimal? = null, + + /** 已实现盈亏 */ + @Column(name = "realized_pnl", precision = 20, scale = 8) + val realizedPnl: BigDecimal? = null, + + /** 卖出时间 */ + @Column(name = "sold_at") + val soldAt: Long? = null, + + @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/SportsTailStrategyTrigger.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/SportsTailStrategyTrigger.kt new file mode 100644 index 0000000..40bad97 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/SportsTailStrategyTrigger.kt @@ -0,0 +1,103 @@ +package com.wrbug.polymarketbot.entity + +import jakarta.persistence.* +import java.math.BigDecimal + +/** + * 体育尾盘策略触发记录 + * 记录每次买入/卖出的详细信息 + */ +@Entity +@Table(name = "sports_tail_strategy_trigger") +data class SportsTailStrategyTrigger( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + /** 策略ID */ + @Column(name = "strategy_id", nullable = false) + val strategyId: Long = 0L, + + /** 账户ID */ + @Column(name = "account_id", nullable = false) + val accountId: Long = 0L, + + /** 市场 conditionId */ + @Column(name = "condition_id", nullable = false, length = 100) + val conditionId: String = "", + + /** 市场标题 */ + @Column(name = "market_title", length = 500) + val marketTitle: String? = null, + + /** 买入价格 */ + @Column(name = "buy_price", nullable = false, precision = 20, scale = 8) + val buyPrice: BigDecimal = BigDecimal.ZERO, + + /** 买入方向索引: 0=YES, 1=NO */ + @Column(name = "outcome_index", nullable = false) + val outcomeIndex: Int = 0, + + /** 买入方向名称 */ + @Column(name = "outcome_name", length = 50) + val outcomeName: String? = null, + + /** 买入金额 */ + @Column(name = "buy_amount", nullable = false, precision = 20, scale = 8) + val buyAmount: BigDecimal = BigDecimal.ZERO, + + /** 买入份额 */ + @Column(name = "buy_shares", precision = 20, scale = 8) + val buyShares: BigDecimal? = null, + + /** 买入订单ID */ + @Column(name = "buy_order_id", length = 100) + val buyOrderId: String? = null, + + /** 买入状态: PENDING, SUCCESS, FAIL */ + @Column(name = "buy_status", nullable = false, length = 20) + val buyStatus: String = "PENDING", + + /** 买入失败原因 */ + @Column(name = "buy_fail_reason", length = 500) + val buyFailReason: String? = null, + + /** 卖出价格 */ + @Column(name = "sell_price", precision = 20, scale = 8) + val sellPrice: BigDecimal? = null, + + /** 卖出类型: TAKE_PROFIT, STOP_LOSS, MANUAL */ + @Column(name = "sell_type", length = 20) + val sellType: String? = null, + + /** 卖出金额 */ + @Column(name = "sell_amount", precision = 20, scale = 8) + val sellAmount: BigDecimal? = null, + + /** 卖出订单ID */ + @Column(name = "sell_order_id", length = 100) + val sellOrderId: String? = null, + + /** 卖出状态: PENDING, SUCCESS, FAIL */ + @Column(name = "sell_status", length = 20) + val sellStatus: String? = null, + + /** 卖出失败原因 */ + @Column(name = "sell_fail_reason", length = 500) + val sellFailReason: String? = null, + + /** 已实现盈亏 */ + @Column(name = "realized_pnl", precision = 20, scale = 8) + val realizedPnl: BigDecimal? = null, + + /** 触发时间 */ + @Column(name = "triggered_at", nullable = false) + val triggeredAt: Long = System.currentTimeMillis(), + + /** 卖出时间 */ + @Column(name = "sold_at") + val soldAt: Long? = 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 b4f6dcd..c7a9657 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/ErrorCode.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/ErrorCode.kt @@ -264,8 +264,27 @@ enum class ErrorCode( 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"); - + SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED(5624, "查询触发记录失败", "error.server.crypto_tail_strategy_triggers_fetch_failed"), + + // 体育尾盘策略 (4730-4749) + SPORTS_TAIL_STRATEGY_NOT_FOUND(4730, "体育尾盘策略不存在", "error.sports_tail_strategy_not_found"), + SPORTS_TAIL_STRATEGY_ALREADY_FILLED(4731, "策略已成交", "error.sports_tail_strategy_already_filled"), + SPORTS_TAIL_STRATEGY_ALREADY_SOLD(4732, "策略已卖出", "error.sports_tail_strategy_already_sold"), + SPORTS_TAIL_STRATEGY_AMOUNT_MODE_INVALID(4733, "金额模式仅支持 FIXED 或 RATIO", "error.sports_tail_strategy_amount_mode_invalid"), + SPORTS_TAIL_STRATEGY_PRICE_INVALID(4734, "触发价格无效", "error.sports_tail_strategy_price_invalid"), + SPORTS_TAIL_STRATEGY_CONDITION_ID_EMPTY(4735, "市场ID不能为空", "error.sports_tail_strategy_condition_id_empty"), + + // 体育尾盘策略服务 (5630-5649) + SERVER_SPORTS_TAIL_STRATEGY_CREATE_FAILED(5630, "创建体育尾盘策略失败", "error.server.sports_tail_strategy_create_failed"), + SERVER_SPORTS_TAIL_STRATEGY_DELETE_FAILED(5631, "删除体育尾盘策略失败", "error.server.sports_tail_strategy_delete_failed"), + SERVER_SPORTS_TAIL_STRATEGY_LIST_FETCH_FAILED(5632, "查询体育尾盘策略列表失败", "error.server.sports_tail_strategy_list_fetch_failed"), + SERVER_SPORTS_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED(5633, "查询触发记录失败", "error.server.sports_tail_strategy_triggers_fetch_failed"), + SERVER_SPORTS_TAIL_STRATEGY_SPORTS_FETCH_FAILED(5634, "查询体育类别失败", "error.server.sports_tail_strategy_sports_fetch_failed"), + SERVER_SPORTS_TAIL_STRATEGY_MARKET_SEARCH_FAILED(5635, "搜索市场失败", "error.server.sports_tail_strategy_market_search_failed"), + SERVER_SPORTS_TAIL_STRATEGY_MARKET_DETAIL_FAILED(5636, "查询市场详情失败", "error.server.sports_tail_strategy_market_detail_failed"), + SERVER_SPORTS_TAIL_STRATEGY_BUY_FAILED(5637, "买入执行失败", "error.server.sports_tail_strategy_buy_failed"), + SERVER_SPORTS_TAIL_STRATEGY_SELL_FAILED(5638, "卖出执行失败", "error.server.sports_tail_strategy_sell_failed"); + companion object { /** * 根据错误码查找枚举 diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/event/SportsTailStrategyChangedEvent.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/event/SportsTailStrategyChangedEvent.kt new file mode 100644 index 0000000..88b913e --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/event/SportsTailStrategyChangedEvent.kt @@ -0,0 +1,9 @@ +package com.wrbug.polymarketbot.event + +import org.springframework.context.ApplicationEvent + +/** + * 体育尾盘策略变更事件 + * 当策略创建、删除、成交、卖出时发布此事件 + */ +class SportsTailStrategyChangedEvent(source: Any) : ApplicationEvent(source) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/SportsTailStrategyRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/SportsTailStrategyRepository.kt new file mode 100644 index 0000000..a73d317 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/SportsTailStrategyRepository.kt @@ -0,0 +1,56 @@ +package com.wrbug.polymarketbot.repository + +import com.wrbug.polymarketbot.entity.SportsTailStrategy +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import org.springframework.stereotype.Repository +import java.math.BigDecimal + +@Repository +interface SportsTailStrategyRepository : JpaRepository { + + /** 查询所有策略 */ + fun findAllByOrderByCreatedAtDesc(): List + + /** 按账户查询 */ + fun findAllByAccountIdOrderByCreatedAtDesc(accountId: Long): List + + /** 按账户和 conditionId 查询 */ + fun findByAccountIdAndConditionId(accountId: Long, conditionId: String): SportsTailStrategy? + + /** 按条件查询(用于列表筛选) */ + fun findAllByAccountId(accountId: Long): List + + /** 查询未成交的策略 */ + fun findAllByFilledFalse(): List + + /** 查询已成交但未卖出的策略 */ + fun findAllByFilledTrueAndSoldFalse(): List + + /** 按 conditionId 查询未完成的策略(未成交或已成交未卖出) */ + @Query("SELECT s FROM SportsTailStrategy s WHERE s.conditionId = :conditionId AND (s.filled = false OR s.sold = false)") + fun findActiveByConditionId(@Param("conditionId") conditionId: String): List + + /** 按 conditionId 查询未成交的策略 */ + @Query("SELECT s FROM SportsTailStrategy s WHERE s.conditionId = :conditionId AND s.filled = false") + fun findPendingByConditionId(@Param("conditionId") conditionId: String): List + + /** 按 conditionId 查询已成交但未卖出的策略(用于止盈止损监控) */ + @Query("SELECT s FROM SportsTailStrategy s WHERE s.conditionId = :conditionId AND s.filled = true AND s.sold = false") + fun findFilledByConditionId(@Param("conditionId") conditionId: String): List + + /** 按 conditionId 查询已成交但未卖出且有止盈止损的策略 */ + @Query("SELECT s FROM SportsTailStrategy s WHERE s.conditionId = :conditionId AND s.filled = true AND s.sold = false AND (s.takeProfitPrice IS NOT NULL OR s.stopLossPrice IS NOT NULL)") + fun findFilledWithStopByConditionId(@Param("conditionId") conditionId: String): List + + /** 按账户统计总盈亏 */ + @Query("SELECT SUM(s.realizedPnl) FROM SportsTailStrategy s WHERE s.accountId = :accountId AND s.sold = true") + fun sumRealizedPnlByAccountId(@Param("accountId") accountId: Long): BigDecimal? + + /** 按策略统计总盈亏 */ + @Query("SELECT SUM(t.realizedPnl) FROM SportsTailStrategyTrigger t WHERE t.strategyId = :strategyId AND t.sellStatus = 'SUCCESS'") + fun sumRealizedPnlByStrategyId(@Param("strategyId") strategyId: Long): BigDecimal? +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/SportsTailStrategyTriggerRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/SportsTailStrategyTriggerRepository.kt new file mode 100644 index 0000000..8eef64a --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/SportsTailStrategyTriggerRepository.kt @@ -0,0 +1,94 @@ +package com.wrbug.polymarketbot.repository + +import com.wrbug.polymarketbot.entity.SportsTailStrategyTrigger +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import org.springframework.stereotype.Repository + +@Repository +interface SportsTailStrategyTriggerRepository : JpaRepository { + + /** 按策略ID查询(分页) */ + fun findAllByStrategyIdOrderByTriggeredAtDesc(strategyId: Long, pageable: Pageable): Page + + /** 按账户ID查询(分页) */ + fun findAllByAccountIdOrderByTriggeredAtDesc(accountId: Long, pageable: Pageable): Page + + /** 按账户ID和时间范围查询(分页) */ + fun findAllByAccountIdAndTriggeredAtBetweenOrderByTriggeredAtDesc( + accountId: Long, + startTime: Long, + endTime: Long, + pageable: Pageable + ): Page + + /** 全局查询(分页) */ + fun findAllByOrderByTriggeredAtDesc(pageable: Pageable): Page + + /** 全局按时间范围查询(分页) */ + fun findAllByTriggeredAtBetweenOrderByTriggeredAtDesc( + startTime: Long, + endTime: Long, + pageable: Pageable + ): Page + + /** 按账户ID和买入状态查询 */ + fun findAllByAccountIdAndBuyStatusOrderByTriggeredAtDesc( + accountId: Long, + buyStatus: String, + pageable: Pageable + ): Page + + /** 按账户ID和时间范围和买入状态查询 */ + fun findAllByAccountIdAndBuyStatusAndTriggeredAtBetweenOrderByTriggeredAtDesc( + accountId: Long, + buyStatus: String, + startTime: Long, + endTime: Long, + pageable: Pageable + ): Page + + /** 统计总数 */ + fun countByAccountId(accountId: Long): Long + + fun countByAccountIdAndBuyStatus(accountId: Long, buyStatus: String): Long + + fun countByAccountIdAndTriggeredAtBetween(accountId: Long, startTime: Long, endTime: Long): Long + + fun countByAccountIdAndBuyStatusAndTriggeredAtBetween( + accountId: Long, + buyStatus: String, + startTime: Long, + endTime: Long + ): Long + + fun countByTriggeredAtBetween(startTime: Long, endTime: Long): Long + + fun countByBuyStatusAndTriggeredAtBetween(buyStatus: String, startTime: Long, endTime: Long): Long + + /** 全局按买入状态查询(分页) */ + fun findAllByBuyStatusOrderByTriggeredAtDesc( + buyStatus: String, + pageable: Pageable + ): Page + + /** 全局按买入状态和时间范围查询(分页) */ + fun findAllByBuyStatusAndTriggeredAtBetweenOrderByTriggeredAtDesc( + buyStatus: String, + startTime: Long, + endTime: Long, + pageable: Pageable + ): Page + + /** 全局统计 */ + fun countByBuyStatus(buyStatus: String): Long + + /** 查询某策略最近一条买入成功的触发记录(用于卖出时更新) */ + fun findFirstByStrategyIdAndBuyStatusOrderByTriggeredAtDesc( + strategyId: Long, + buyStatus: String + ): SportsTailStrategyTrigger? +} 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 index 3eaefe9..9b09878 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailOrderbookWsService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailOrderbookWsService.kt @@ -56,7 +56,7 @@ class CryptoTailOrderbookWsService( private var webSocket: WebSocket? = null private val wsUrl = PolymarketConstants.RTDS_WS_URL + "/ws/market" - private val client = createClient().build() + private val client by lazy { createClient().build() } /** 订阅成功后设置的倒计时 Job,在周期结束时自动刷新订阅 */ private var periodEndCountdownJob: Job? = null diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/sportstail/SportsTailOrderbookWsService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/sportstail/SportsTailOrderbookWsService.kt new file mode 100644 index 0000000..3a0c875 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/sportstail/SportsTailOrderbookWsService.kt @@ -0,0 +1,290 @@ +package com.wrbug.polymarketbot.service.sportstail + +import com.wrbug.polymarketbot.constants.PolymarketConstants +import com.wrbug.polymarketbot.entity.SportsTailStrategy +import com.wrbug.polymarketbot.event.SportsTailStrategyChangedEvent +import com.wrbug.polymarketbot.repository.SportsTailStrategyRepository +import com.wrbug.polymarketbot.util.createClient +import com.wrbug.polymarketbot.util.fromJson +import com.wrbug.polymarketbot.util.gte +import com.wrbug.polymarketbot.util.gt +import com.wrbug.polymarketbot.util.lte +import com.wrbug.polymarketbot.util.toJson +import com.wrbug.polymarketbot.util.toSafeBigDecimal +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import com.google.gson.JsonPrimitive +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 okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import org.slf4j.LoggerFactory +import org.springframework.context.event.EventListener +import org.springframework.stereotype.Service +import jakarta.annotation.PostConstruct +import jakarta.annotation.PreDestroy +import java.math.BigDecimal +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference + +/** + * 体育尾盘策略订单簿 WebSocket 服务:订阅 CLOB 市场频道,价格达到触发价时执行买入/止盈止损卖出。 + */ +@Service +class SportsTailOrderbookWsService( + private val strategyRepository: SportsTailStrategyRepository, + private val executionService: SportsTailStrategyExecutionService +) { + + private val logger = LoggerFactory.getLogger(SportsTailOrderbookWsService::class.java) + + private val scopeJob = SupervisorJob() + private val scope = CoroutineScope(Dispatchers.Default + scopeJob) + + /** tokenId -> list of (strategy, outcomeIndex for buy=0/1, isSellPhase) */ + private val tokenToEntries = AtomicReference>>(emptyMap()) + + private var webSocket: WebSocket? = null + private val wsUrl = PolymarketConstants.RTDS_WS_URL + "/ws/market" + private val client: OkHttpClient by lazy { createClient().build() } + + private val reconnectDelayMs = 3_000L + private val closedForNoStrategies = AtomicBoolean(false) + private val connectLock = Any() + private val refreshLock = Any() + private val isRefreshing = AtomicBoolean(false) + + private data class WsEntry( + val strategy: SportsTailStrategy, + val outcomeIndex: Int, + val isSellPhase: Boolean + ) + + private var reconnectJob: Job? = null + + @PostConstruct + fun init() { + if (hasActiveStrategies()) connect() + } + + @PreDestroy + fun destroy() { + reconnectJob?.cancel() + reconnectJob = null + closedForNoStrategies.set(true) + try { + webSocket?.close(1000, "shutdown") + } catch (e: Exception) { + logger.debug("关闭体育尾盘 WebSocket 时异常: ${e.message}") + } + webSocket = null + scopeJob.cancel() + } + + private fun hasActiveStrategies(): Boolean { + val all = strategyRepository.findAll() + return all.any { !it.filled || (it.filled && !it.sold && (it.takeProfitPrice != null || it.stopLossPrice != null)) } + } + + private fun connect() { + synchronized(connectLock) { + 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(fromConnect = true) + } + + override fun onMessage(webSocket: WebSocket, text: String) { + handleMessage(text) + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + this@SportsTailOrderbookWsService.webSocket = null + if (!closedForNoStrategies.getAndSet(false)) scheduleReconnect() + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) { + logger.warn("体育尾盘策略订单簿 WebSocket 异常: ${t.message}") + this@SportsTailOrderbookWsService.webSocket = null + scheduleReconnect() + } + }) + } catch (e: Exception) { + logger.error("体育尾盘策略订单簿 WebSocket 连接失败: ${e.message}", e) + scheduleReconnect() + } + } + } + + private fun scheduleReconnect() { + if (reconnectJob?.isActive == true) return + reconnectJob = scope.launch { + delay(reconnectDelayMs) + reconnectJob = null + if (!hasActiveStrategies()) return@launch + logger.info("体育尾盘策略订单簿 WebSocket 尝试重连") + connect() + } + } + + private fun handleMessage(text: String) { + if (text == "pong" || text.isEmpty()) return + if (closedForNoStrategies.get()) return + val json = text.fromJson() ?: return + val eventType = (json.get("event_type") as? JsonPrimitive)?.asString ?: return + + when (eventType) { + "book" -> { + val assetId = (json.get("asset_id") as? JsonPrimitive)?.asString ?: return + val bids = json.get("bids") as? JsonArray + if (bids == null || bids.isEmpty) return + var bestBid: BigDecimal? = null + for (i in 0 until bids.size()) { + val level = bids.get(i) as? JsonObject ?: continue + val p = (level.get("price") as? JsonPrimitive)?.asString?.toSafeBigDecimal() ?: continue + if (bestBid == null || p.gt(bestBid)) bestBid = p + } + if (bestBid != null) onPriceUpdate(assetId, bestBid) + } + "price_change" -> { + val priceChanges = json.get("price_changes") as? JsonArray ?: return + for (i in 0 until priceChanges.size()) { + val pc = priceChanges.get(i) as? JsonObject ?: continue + val assetId = (pc.get("asset_id") as? JsonPrimitive)?.asString ?: continue + val bestBidStr = (pc.get("best_bid") as? JsonPrimitive)?.asString + val bestBid = bestBidStr?.toSafeBigDecimal() + if (bestBid != null) onPriceUpdate(assetId, bestBid) + } + } + } + } + + private fun onPriceUpdate(tokenId: String, bestBid: BigDecimal) { + if (closedForNoStrategies.get()) return + val entries = tokenToEntries.get()[tokenId] ?: return + for (e in entries) { + scope.launch { + try { + if (e.isSellPhase) { + checkSellTrigger(e.strategy, bestBid) + } else { + checkBuyTrigger(e.strategy, e.outcomeIndex, bestBid) + } + } catch (ex: Exception) { + logger.error("体育尾盘 WS 处理异常: strategyId=${e.strategy.id}, ${ex.message}", ex) + } + } + } + } + + private suspend fun checkBuyTrigger(strategy: SportsTailStrategy, outcomeIndex: Int, price: BigDecimal) { + if (strategy.filled) return + if (price.gte(strategy.triggerPrice)) { + executionService.executeBuy(strategy, outcomeIndex, price) + } + } + + private suspend fun checkSellTrigger(strategy: SportsTailStrategy, currentPrice: BigDecimal) { + if (!strategy.filled || strategy.sold) return + strategy.takeProfitPrice?.let { if (currentPrice.gte(it)) { executionService.executeSell(strategy, "TAKE_PROFIT", currentPrice); return } } + strategy.stopLossPrice?.let { if (currentPrice.lte(it)) { executionService.executeSell(strategy, "STOP_LOSS", currentPrice); return } } + } + + private fun refreshAndSubscribe(fromConnect: Boolean = false) { + synchronized(refreshLock) { + if (isRefreshing.get()) return + isRefreshing.set(true) + } + try { + val strategies = strategyRepository.findAll() + val active = strategies.filter { s -> + !s.filled || (s.filled && !s.sold && (s.takeProfitPrice != null || s.stopLossPrice != null)) + } + val tokenIdSet = mutableSetOf() + val map = mutableMapOf>() + + for (s in active) { + if (!s.filled) { + s.yesTokenId?.let { id -> + if (id.isNotBlank()) { + tokenIdSet.add(id) + map.getOrPut(id) { mutableListOf() }.add(WsEntry(s, 0, false)) + } + } + s.noTokenId?.let { id -> + if (id.isNotBlank()) { + tokenIdSet.add(id) + map.getOrPut(id) { mutableListOf() }.add(WsEntry(s, 1, false)) + } + } + } else if (!s.sold && (s.takeProfitPrice != null || s.stopLossPrice != null)) { + val idx = s.filledOutcomeIndex ?: continue + val tokenId = if (idx == 0) s.yesTokenId else s.noTokenId + tokenId?.takeIf { it.isNotBlank() }?.let { id -> + tokenIdSet.add(id) + map.getOrPut(id) { mutableListOf() }.add(WsEntry(s, idx, true)) + } + } + } + + tokenToEntries.set(map) + + if (tokenIdSet.isEmpty()) { + closeForNoStrategies() + return + } + if (!fromConnect) { + if (webSocket == null) { + connect() + return + } + closeAndReconnect() + return + } + val msg = """{"type":"MARKET","assets_ids":${tokenIdSet.toList().toJson()}}""" + try { + webSocket?.send(msg) + logger.info("体育尾盘策略订单簿订阅: ${tokenIdSet.size} 个 token") + } catch (e: Exception) { + logger.warn("发送体育尾盘订阅失败: ${e.message}") + } + } finally { + isRefreshing.set(false) + } + } + + private fun closeAndReconnect() { + val ws = webSocket + if (ws != null) { + webSocket = null + try { ws.close(1000, "subscription_change") } catch (e: Exception) { } + logger.info("体育尾盘策略订单簿 WebSocket 已关闭(订阅更新,将重连)") + } + } + + private fun closeForNoStrategies() { + reconnectJob?.cancel() + reconnectJob = null + val ws = webSocket + if (ws != null) { + closedForNoStrategies.set(true) + webSocket = null + try { ws.close(1000, "no_active_strategies") } catch (e: Exception) { } + logger.info("体育尾盘策略订单簿 WebSocket 已关闭(无活跃策略)") + } + } + + @EventListener + fun onStrategyChanged(event: SportsTailStrategyChangedEvent) { + refreshAndSubscribe() + } +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/sportstail/SportsTailStrategyExecutionService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/sportstail/SportsTailStrategyExecutionService.kt new file mode 100644 index 0000000..c243f26 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/sportstail/SportsTailStrategyExecutionService.kt @@ -0,0 +1,303 @@ +package com.wrbug.polymarketbot.service.sportstail + +import com.wrbug.polymarketbot.api.NewOrderRequest +import com.wrbug.polymarketbot.api.PolymarketClobApi +import com.wrbug.polymarketbot.entity.SportsTailStrategy +import com.wrbug.polymarketbot.entity.SportsTailStrategyTrigger +import com.wrbug.polymarketbot.event.SportsTailStrategyChangedEvent +import com.wrbug.polymarketbot.repository.AccountRepository +import com.wrbug.polymarketbot.repository.SportsTailStrategyRepository +import com.wrbug.polymarketbot.repository.SportsTailStrategyTriggerRepository +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.div +import com.wrbug.polymarketbot.util.toSafeBigDecimal +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.slf4j.LoggerFactory +import org.springframework.context.ApplicationEventPublisher +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.math.BigDecimal +import java.math.RoundingMode +import java.util.concurrent.ConcurrentHashMap + +private const val SIZE_DECIMAL_SCALE = 2 + +/** + * 体育尾盘策略执行服务:根据价格触发执行买入/卖出,并更新策略与触发记录。 + */ +@Service +class SportsTailStrategyExecutionService( + private val strategyRepository: SportsTailStrategyRepository, + private val triggerRepository: SportsTailStrategyTriggerRepository, + 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 eventPublisher: ApplicationEventPublisher +) { + + private val logger = LoggerFactory.getLogger(SportsTailStrategyExecutionService::class.java) + + private val buyMutexMap = ConcurrentHashMap() + + private fun buyMutex(strategyId: Long): Mutex = + buyMutexMap.getOrPut(strategyId) { Mutex() } + + /** + * 执行买入:市价买入指定方向,写入触发记录并更新策略为已成交。 + */ + @Transactional + suspend fun executeBuy( + strategy: SportsTailStrategy, + outcomeIndex: Int, + triggerPrice: BigDecimal + ): Result { + if (strategy.filled) return Result.failure(IllegalStateException("策略已成交")) + val tokenId = if (outcomeIndex == 0) strategy.yesTokenId else strategy.noTokenId + if (tokenId.isNullOrBlank()) return Result.failure(IllegalStateException("Token ID 为空")) + + return buyMutex(strategy.id!!).withLock { + val latest = strategyRepository.findById(strategy.id!!).orElse(null) + ?: return@withLock Result.failure(IllegalStateException("策略不存在")) + if (latest.filled) return@withLock Result.success(Unit) + + val account = accountRepository.findById(latest.accountId).orElse(null) + ?: return@withLock Result.failure(IllegalStateException("账户不存在")) + if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) { + return@withLock Result.failure(IllegalStateException("账户未配置 API 凭证")) + } + + val decryptedKey = try { + cryptoUtils.decrypt(account.privateKey) ?: return@withLock Result.failure(IllegalStateException("解密私钥失败")) + } catch (e: Exception) { + logger.error("解密私钥失败: accountId=${account.id}", e) + return@withLock Result.failure(e) + } + val apiSecret = try { cryptoUtils.decrypt(account.apiSecret) ?: "" } catch (e: Exception) { "" } + val apiPassphrase = try { cryptoUtils.decrypt(account.apiPassphrase) ?: "" } catch (e: Exception) { "" } + + val amountUsdc = when (latest.amountMode.uppercase()) { + "RATIO" -> { + val balanceResult = accountService.getAccountBalance(account.id!!) + val available = balanceResult.getOrNull()?.availableBalance?.toSafeBigDecimal() ?: BigDecimal.ZERO + available.multiply(latest.amountValue).div(BigDecimal("100"), 18, RoundingMode.DOWN) + } + else -> latest.amountValue + } + if (amountUsdc < BigDecimal("1")) { + saveTriggerOnBuyFail(latest, outcomeIndex, triggerPrice, amountUsdc, "投入金额不足") + return@withLock Result.failure(IllegalStateException("投入金额不足")) + } + + val priceStr = triggerPrice.setScale(2, RoundingMode.HALF_UP).toPlainString() + val size = amountUsdc.div(triggerPrice, SIZE_DECIMAL_SCALE, RoundingMode.UP).max(BigDecimal.ONE) + val sizeStr = size.toPlainString() + + 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 = priceStr, + size = sizeStr, + signatureType = signatureType, + nonce = "0", + feeRateBps = feeRateBps, + expiration = "0" + ) + val orderRequest = NewOrderRequest( + order = signedOrder, + owner = account.apiKey!!, + orderType = "FAK", + deferExec = false + ) + + val response = clobApi.createOrder(orderRequest) + if (response.isSuccessful && response.body() != null) { + val body = response.body()!! + if (body.success && body.orderId != null) { + val outcomeName = if (outcomeIndex == 0) "Yes" else "No" + triggerRepository.save( + SportsTailStrategyTrigger( + strategyId = latest.id!!, + accountId = latest.accountId, + conditionId = latest.conditionId, + marketTitle = latest.marketTitle, + buyPrice = triggerPrice, + outcomeIndex = outcomeIndex, + outcomeName = outcomeName, + buyAmount = amountUsdc, + buyShares = size, + buyOrderId = body.orderId, + buyStatus = "SUCCESS", + triggeredAt = System.currentTimeMillis() + ) + ) + strategyRepository.save( + latest.copy( + filled = true, + filledPrice = triggerPrice, + filledOutcomeIndex = outcomeIndex, + filledOutcomeName = outcomeName, + filledAmount = amountUsdc, + filledShares = size, + filledAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis() + ) + ) + eventPublisher.publishEvent(SportsTailStrategyChangedEvent(this)) + logger.info("体育尾盘策略买入成功: strategyId=${latest.id}, outcomeIndex=$outcomeIndex, orderId=${body.orderId}") + return@withLock Result.success(Unit) + } + } + val failReason = response.body()?.getErrorMessage() ?: response.errorBody()?.string() ?: "下单失败" + saveTriggerOnBuyFail(latest, outcomeIndex, triggerPrice, amountUsdc, failReason) + logger.error("体育尾盘策略买入失败: strategyId=${latest.id}, reason=$failReason") + Result.failure(IllegalStateException(failReason)) + } + } + + private fun saveTriggerOnBuyFail( + strategy: SportsTailStrategy, + outcomeIndex: Int, + buyPrice: BigDecimal, + buyAmount: BigDecimal, + failReason: String + ) { + val outcomeName = if (outcomeIndex == 0) "Yes" else "No" + triggerRepository.save( + SportsTailStrategyTrigger( + strategyId = strategy.id!!, + accountId = strategy.accountId, + conditionId = strategy.conditionId, + marketTitle = strategy.marketTitle, + buyPrice = buyPrice, + outcomeIndex = outcomeIndex, + outcomeName = outcomeName, + buyAmount = buyAmount, + buyStatus = "FAIL", + buyFailReason = failReason, + triggeredAt = System.currentTimeMillis() + ) + ) + } + + /** + * 执行卖出:按当前价市价卖出持仓,更新策略与触发记录。 + */ + @Transactional + suspend fun executeSell( + strategy: SportsTailStrategy, + sellType: String, + currentPrice: BigDecimal + ): Result { + if (!strategy.filled || strategy.sold) return Result.failure(IllegalStateException("策略未成交或已卖出")) + val outcomeIndex = strategy.filledOutcomeIndex ?: return Result.failure(IllegalStateException("无成交方向")) + val tokenId = if (outcomeIndex == 0) strategy.yesTokenId else strategy.noTokenId + val filledShares = strategy.filledShares ?: return Result.failure(IllegalStateException("无成交份额")) + if (tokenId.isNullOrBlank()) return Result.failure(IllegalStateException("Token ID 为空")) + + val account = accountRepository.findById(strategy.accountId).orElse(null) + ?: return Result.failure(IllegalStateException("账户不存在")) + if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) { + return Result.failure(IllegalStateException("账户未配置 API 凭证")) + } + + val decryptedKey = try { + cryptoUtils.decrypt(account.privateKey) ?: return Result.failure(IllegalStateException("解密私钥失败")) + } catch (e: Exception) { + logger.error("解密私钥失败: accountId=${account.id}", e) + return Result.failure(e) + } + val apiSecret = try { cryptoUtils.decrypt(account.apiSecret) ?: "" } catch (e: Exception) { "" } + val apiPassphrase = try { cryptoUtils.decrypt(account.apiPassphrase) ?: "" } catch (e: Exception) { "" } + + val priceStr = currentPrice.setScale(2, RoundingMode.HALF_UP).toPlainString() + val sizeStr = filledShares.setScale(SIZE_DECIMAL_SCALE, RoundingMode.DOWN).toPlainString() + + 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 = "SELL", + price = priceStr, + size = sizeStr, + signatureType = signatureType, + nonce = "0", + feeRateBps = feeRateBps, + expiration = "0" + ) + val orderRequest = NewOrderRequest( + order = signedOrder, + owner = account.apiKey!!, + orderType = "FAK", + deferExec = false + ) + + val response = clobApi.createOrder(orderRequest) + val filledAmount = strategy.filledAmount ?: BigDecimal.ZERO + if (response.isSuccessful && response.body() != null) { + val body = response.body()!! + if (body.success && body.orderId != null) { + val sellAmount = currentPrice.multiply(filledShares).setScale(2, RoundingMode.HALF_UP) + val pnl = sellAmount.subtract(filledAmount) + + strategyRepository.save( + strategy.copy( + sold = true, + sellPrice = currentPrice, + sellType = sellType, + sellAmount = sellAmount, + realizedPnl = pnl, + soldAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis() + ) + ) + val trigger = triggerRepository.findFirstByStrategyIdAndBuyStatusOrderByTriggeredAtDesc(strategy.id!!, "SUCCESS") + if (trigger != null) { + triggerRepository.save( + trigger.copy( + sellPrice = currentPrice, + sellType = sellType, + sellAmount = sellAmount, + sellOrderId = body.orderId, + sellStatus = "SUCCESS", + realizedPnl = pnl, + soldAt = System.currentTimeMillis() + ) + ) + } + eventPublisher.publishEvent(SportsTailStrategyChangedEvent(this)) + logger.info("体育尾盘策略卖出成功: strategyId=${strategy.id}, sellType=$sellType, orderId=${body.orderId}") + return Result.success(Unit) + } + } + val failReason = response.body()?.getErrorMessage() ?: response.errorBody()?.string() ?: "卖出失败" + val trigger = triggerRepository.findFirstByStrategyIdAndBuyStatusOrderByTriggeredAtDesc(strategy.id!!, "SUCCESS") + if (trigger != null) { + triggerRepository.save( + trigger.copy( + sellStatus = "FAIL", + sellFailReason = failReason + ) + ) + } + logger.error("体育尾盘策略卖出失败: strategyId=${strategy.id}, reason=$failReason") + return Result.failure(IllegalStateException(failReason)) + } +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/sportstail/SportsTailStrategyService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/sportstail/SportsTailStrategyService.kt new file mode 100644 index 0000000..08da908 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/sportstail/SportsTailStrategyService.kt @@ -0,0 +1,420 @@ +package com.wrbug.polymarketbot.service.sportstail + +import com.wrbug.polymarketbot.api.MarketResponse +import com.wrbug.polymarketbot.api.PolymarketGammaApi +import com.wrbug.polymarketbot.dto.* +import com.wrbug.polymarketbot.entity.Account +import com.wrbug.polymarketbot.entity.SportsTailStrategy +import com.wrbug.polymarketbot.entity.SportsTailStrategyTrigger +import com.wrbug.polymarketbot.enums.ErrorCode +import com.wrbug.polymarketbot.event.SportsTailStrategyChangedEvent +import com.wrbug.polymarketbot.repository.AccountRepository +import com.wrbug.polymarketbot.repository.SportsTailStrategyRepository +import com.wrbug.polymarketbot.repository.SportsTailStrategyTriggerRepository +import com.wrbug.polymarketbot.util.RetrofitFactory +import com.wrbug.polymarketbot.util.fromJson +import com.wrbug.polymarketbot.util.toSafeBigDecimal +import kotlinx.coroutines.runBlocking +import org.slf4j.LoggerFactory +import org.springframework.context.ApplicationEventPublisher +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageRequest +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.math.BigDecimal + +@Service +class SportsTailStrategyService( + private val strategyRepository: SportsTailStrategyRepository, + private val triggerRepository: SportsTailStrategyTriggerRepository, + private val accountRepository: AccountRepository, + private val retrofitFactory: RetrofitFactory, + private val eventPublisher: ApplicationEventPublisher +) { + + private val logger = LoggerFactory.getLogger(SportsTailStrategyService::class.java) + + companion object { + private val SPORT_NAMES = mapOf( + "nba" to "NBA", + "nfl" to "NFL", + "epl" to "英超", + "lal" to "西甲", + "mlb" to "MLB", + "nhl" to "NHL", + "ufc" to "UFC" + ) + } + + @Transactional + fun create(request: SportsTailStrategyCreateRequest): Result { + return try { + if (request.accountId <= 0) { + return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ACCOUNT_ID_INVALID.messageKey)) + } + if (request.conditionId.isBlank()) { + return Result.failure(IllegalArgumentException(ErrorCode.SPORTS_TAIL_STRATEGY_CONDITION_ID_EMPTY.messageKey)) + } + + val triggerPrice = request.triggerPrice.toSafeBigDecimal() + if (triggerPrice <= BigDecimal.ZERO || triggerPrice >= BigDecimal.ONE) { + return Result.failure(IllegalArgumentException(ErrorCode.SPORTS_TAIL_STRATEGY_PRICE_INVALID.messageKey)) + } + + val amountMode = request.amountMode.uppercase() + if (amountMode != "FIXED" && amountMode != "RATIO") { + return Result.failure(IllegalArgumentException(ErrorCode.SPORTS_TAIL_STRATEGY_AMOUNT_MODE_INVALID.messageKey)) + } + + val amountValue = request.amountValue.toSafeBigDecimal() + if (amountValue <= BigDecimal.ZERO) { + return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey)) + } + + val account = accountRepository.findById(request.accountId).orElse(null) + ?: return Result.failure(IllegalArgumentException(ErrorCode.ACCOUNT_NOT_FOUND.messageKey)) + + val existing = strategyRepository.findByAccountIdAndConditionId(request.accountId, request.conditionId) + if (existing != null) { + return Result.failure(IllegalArgumentException("该市场已存在策略")) + } + + val takeProfitPrice = request.takeProfitPrice?.takeIf { it.isNotBlank() }?.toSafeBigDecimal() + val stopLossPrice = request.stopLossPrice?.takeIf { it.isNotBlank() }?.toSafeBigDecimal() + + val marketInfo = runBlocking { fetchMarketInfo(request.conditionId).getOrNull() } + + val entity = SportsTailStrategy( + accountId = request.accountId, + conditionId = request.conditionId, + marketTitle = request.marketTitle.takeIf { it.isNotBlank() } ?: marketInfo?.question, + eventSlug = request.eventSlug ?: marketInfo?.eventSlug, + yesTokenId = marketInfo?.yesTokenId, + noTokenId = marketInfo?.noTokenId, + triggerPrice = triggerPrice, + amountMode = amountMode, + amountValue = amountValue, + takeProfitPrice = takeProfitPrice, + stopLossPrice = stopLossPrice + ) + val saved = strategyRepository.save(entity) + eventPublisher.publishEvent(SportsTailStrategyChangedEvent(this)) + Result.success(entityToDto(saved, account)) + } catch (e: IllegalArgumentException) { + Result.failure(e) + } catch (e: Exception) { + logger.error("创建体育尾盘策略失败: ${e.message}", e) + Result.failure(e) + } + } + + @Transactional + fun delete(id: Long): Result { + return try { + val existing = strategyRepository.findById(id).orElse(null) + ?: return Result.failure(IllegalArgumentException(ErrorCode.SPORTS_TAIL_STRATEGY_NOT_FOUND.messageKey)) + + if (existing.filled && !existing.sold) { + return Result.failure(IllegalArgumentException("已成交未卖出的策略不能删除")) + } + + strategyRepository.deleteById(id) + eventPublisher.publishEvent(SportsTailStrategyChangedEvent(this)) + Result.success(Unit) + } catch (e: IllegalArgumentException) { + Result.failure(e) + } catch (e: Exception) { + logger.error("删除体育尾盘策略失败: ${e.message}", e) + Result.failure(e) + } + } + + fun list(request: SportsTailStrategyListRequest): Result { + return try { + val list = when { + request.accountId != null -> strategyRepository.findAllByAccountIdOrderByCreatedAtDesc(request.accountId) + else -> strategyRepository.findAllByOrderByCreatedAtDesc() + } + + val accountIds = list.map { it.accountId }.distinct() + val accountMap = accountRepository.findAllById(accountIds).associateBy { it.id } + + val dtos = list.map { entityToDto(it, accountMap[it.accountId]) } + Result.success(SportsTailStrategyListResponse(list = dtos)) + } catch (e: Exception) { + logger.error("查询体育尾盘策略列表失败: ${e.message}", e) + Result.failure(e) + } + } + + fun getTriggers(request: SportsTailTriggerListRequest): Result { + return try { + val page = PageRequest.of((request.page - 1).coerceAtLeast(0), request.pageSize.coerceIn(1, 100)) + val startTs = request.startTime ?: 0L + val endTs = request.endTime ?: Long.MAX_VALUE + val useTimeRange = request.startTime != null || request.endTime != null + val useStatus = !request.status.isNullOrBlank() + + val pageResult: Page = when { + request.accountId != null && useTimeRange && useStatus -> + triggerRepository.findAllByAccountIdAndBuyStatusAndTriggeredAtBetweenOrderByTriggeredAtDesc( + request.accountId, request.status!!, startTs, endTs, page + ) + request.accountId != null && useTimeRange -> + triggerRepository.findAllByAccountIdAndTriggeredAtBetweenOrderByTriggeredAtDesc( + request.accountId, startTs, endTs, page + ) + request.accountId != null && useStatus -> + triggerRepository.findAllByAccountIdAndBuyStatusOrderByTriggeredAtDesc( + request.accountId, request.status!!, page + ) + request.accountId != null -> + triggerRepository.findAllByAccountIdOrderByTriggeredAtDesc(request.accountId, page) + useTimeRange && useStatus -> + triggerRepository.findAllByBuyStatusAndTriggeredAtBetweenOrderByTriggeredAtDesc( + request.status!!, startTs, endTs, page + ) + useTimeRange -> + triggerRepository.findAllByTriggeredAtBetweenOrderByTriggeredAtDesc(startTs, endTs, page) + useStatus -> + triggerRepository.findAllByBuyStatusOrderByTriggeredAtDesc(request.status!!, page) + else -> + triggerRepository.findAllByOrderByTriggeredAtDesc(page) + } + + val total = pageResult.totalElements + val list = pageResult.content.map { triggerToDto(it) } + Result.success(SportsTailTriggerListResponse(total = total, list = list)) + } catch (e: Exception) { + logger.error("查询触发记录失败: ${e.message}", e) + Result.failure(e) + } + } + + suspend fun getSportsCategories(): Result { + return try { + val api = retrofitFactory.createGammaSportsApi() + val response = api.getSports() + if (response.isSuccessful && response.body() != null) { + val body = response.body()!! + val list = body.map { c -> categoryToDto(c) } + Result.success(SportsCategoryListResponse(list = list)) + } else { + logger.warn("获取体育类别失败: ${response.code()}") + Result.failure(Exception("获取体育类别失败")) + } + } catch (e: Exception) { + logger.error("获取体育类别失败: ${e.message}", e) + Result.failure(e) + } + } + + suspend fun searchMarkets(request: SportsMarketSearchRequest): Result { + return try { + val api = retrofitFactory.createGammaSportsApi() + + val tagId = if (!request.sport.isNullOrBlank()) { + getTagIdBySport(request.sport) + } else null + + val response = api.searchMarkets( + tagId = tagId, + active = true, + closed = false, + limit = request.limit, + order = "endDate", + ascending = true, + slug = request.keyword + ) + + if (response.isSuccessful && response.body() != null) { + val markets = response.body()!! + val filtered = if (!request.minLiquidity.isNullOrBlank()) { + val minLiquidity = request.minLiquidity.toSafeBigDecimal() + markets.filter { m -> + val liquidity = m.liquidityNum?.toSafeBigDecimal() ?: BigDecimal.ZERO + liquidity >= minLiquidity + } + } else { + markets + } + val list = filtered.map { m -> marketToDto(m) } + Result.success(SportsMarketSearchResponse(list = list)) + } else { + logger.warn("搜索市场失败: ${response.code()}") + Result.failure(Exception("搜索市场失败")) + } + } catch (e: Exception) { + logger.error("搜索市场失败: ${e.message}", e) + Result.failure(e) + } + } + + suspend fun getMarketDetail(conditionId: String): Result { + return try { + val marketInfo = fetchMarketInfo(conditionId).getOrNull() + ?: return Result.failure(Exception("市场不存在")) + + Result.success( + SportsMarketDetailResponse( + conditionId = marketInfo.conditionId, + question = marketInfo.question, + outcomes = marketInfo.outcomes, + outcomePrices = marketInfo.outcomePrices, + endDate = marketInfo.endDate, + liquidity = marketInfo.liquidity, + bestBid = marketInfo.bestBid, + bestAsk = marketInfo.bestAsk, + yesTokenId = marketInfo.yesTokenId, + noTokenId = marketInfo.noTokenId, + eventSlug = marketInfo.eventSlug + ) + ) + } catch (e: Exception) { + logger.error("获取市场详情失败: ${e.message}", e) + Result.failure(e) + } + } + + private suspend fun fetchMarketInfo(conditionId: String): Result { + return try { + val api = retrofitFactory.createGammaApi() + val response = api.listMarkets(conditionIds = listOf(conditionId)) + if (response.isSuccessful && !response.body().isNullOrEmpty()) { + val m = response.body()!![0] + Result.success(marketResponseToDto(m)) + } else { + Result.failure(Exception("市场不存在")) + } + } catch (e: Exception) { + logger.error("获取市场信息失败: ${e.message}", e) + Result.failure(e) + } + } + + private suspend fun getTagIdBySport(sport: String): Long? { + return try { + val api = retrofitFactory.createGammaSportsApi() + val response = api.getSports() + if (response.isSuccessful && response.body() != null) { + val body = response.body()!! + val category = body.find { c -> c.sport == sport.lowercase() } + category?.tags?.split(",")?.firstOrNull()?.toLongOrNull() + } else null + } catch (e: Exception) { + null + } + } + + private fun parseClobTokenIds(clobTokenIds: String?): List { + if (clobTokenIds.isNullOrBlank()) return emptyList() + return clobTokenIds.fromJson>() ?: emptyList() + } + + private fun parseOutcomes(outcomes: String?): List { + if (outcomes.isNullOrBlank()) return emptyList() + return outcomes.fromJson>() ?: emptyList() + } + + private fun parseOutcomePrices(outcomePrices: String?): List { + if (outcomePrices.isNullOrBlank()) return emptyList() + return outcomePrices.fromJson>() ?: emptyList() + } + + private fun entityToDto(e: SportsTailStrategy, account: Account?): SportsTailStrategyDto { + return SportsTailStrategyDto( + id = e.id ?: 0L, + accountId = e.accountId, + accountName = account?.accountName ?: account?.walletAddress?.take(8), + conditionId = e.conditionId, + marketTitle = e.marketTitle, + eventSlug = e.eventSlug, + triggerPrice = e.triggerPrice.toPlainString(), + amountMode = e.amountMode, + amountValue = e.amountValue.toPlainString(), + takeProfitPrice = e.takeProfitPrice?.toPlainString(), + stopLossPrice = e.stopLossPrice?.toPlainString(), + filled = e.filled, + filledPrice = e.filledPrice?.toPlainString(), + filledOutcomeIndex = e.filledOutcomeIndex, + filledOutcomeName = e.filledOutcomeName, + filledAmount = e.filledAmount?.toPlainString(), + filledShares = e.filledShares?.toPlainString(), + filledAt = e.filledAt, + sold = e.sold, + sellPrice = e.sellPrice?.toPlainString(), + sellType = e.sellType, + sellAmount = e.sellAmount?.toPlainString(), + realizedPnl = e.realizedPnl?.toPlainString(), + soldAt = e.soldAt, + createdAt = e.createdAt, + updatedAt = e.updatedAt + ) + } + + private fun categoryToDto(c: com.wrbug.polymarketbot.api.SportsCategoryResponse): SportsCategoryDto { + val tagId = c.tags?.split(",")?.firstOrNull()?.toLongOrNull() ?: 0L + return SportsCategoryDto( + sport = c.sport ?: "", + image = c.image, + tagId = tagId, + name = SPORT_NAMES[c.sport] ?: c.sport ?: "" + ) + } + + private fun marketResponseToDto(m: MarketResponse): SportsMarketDto { + val tokenIds = parseClobTokenIds(m.clobTokenIds ?: m.clob_token_ids) + return SportsMarketDto( + conditionId = m.conditionId ?: "", + question = m.question ?: "", + outcomes = parseOutcomes(m.outcomes), + outcomePrices = parseOutcomePrices(m.outcomePrices), + endDate = m.endDate, + liquidity = m.liquidityNum?.toString() ?: m.liquidity, + bestBid = m.bestBid, + bestAsk = m.bestAsk, + yesTokenId = tokenIds.getOrNull(0), + noTokenId = tokenIds.getOrNull(1), + eventSlug = m.events?.firstOrNull()?.slug + ) + } + + private fun marketToDto(m: com.wrbug.polymarketbot.api.SportsMarketResponse): SportsMarketDto { + val tokenIds = parseClobTokenIds(m.clobTokenIds) + return SportsMarketDto( + conditionId = m.conditionId ?: "", + question = m.question ?: "", + outcomes = parseOutcomes(m.outcomes), + outcomePrices = parseOutcomePrices(m.outcomePrices), + endDate = m.endDate, + liquidity = m.liquidityNum?.toString() ?: m.liquidity, + bestBid = m.bestBid, + bestAsk = m.bestAsk, + yesTokenId = tokenIds.getOrNull(0), + noTokenId = tokenIds.getOrNull(1), + eventSlug = m.events?.firstOrNull()?.slug + ) + } + + private fun triggerToDto(t: SportsTailStrategyTrigger): SportsTailTriggerDto { + return SportsTailTriggerDto( + id = t.id ?: 0L, + strategyId = t.strategyId, + marketTitle = t.marketTitle, + conditionId = t.conditionId, + buyPrice = t.buyPrice.toPlainString(), + outcomeIndex = t.outcomeIndex, + outcomeName = t.outcomeName, + buyAmount = t.buyAmount.toPlainString(), + buyShares = t.buyShares?.toPlainString(), + buyStatus = t.buyStatus, + sellPrice = t.sellPrice?.toPlainString(), + sellType = t.sellType, + sellAmount = t.sellAmount?.toPlainString(), + sellStatus = t.sellStatus, + realizedPnl = t.realizedPnl?.toPlainString(), + triggeredAt = t.triggeredAt, + soldAt = t.soldAt + ) + } +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/TelegramNotificationService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/TelegramNotificationService.kt index c473a88..2341170 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/TelegramNotificationService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/TelegramNotificationService.kt @@ -33,11 +33,13 @@ class TelegramNotificationService( private val logger = LoggerFactory.getLogger(TelegramNotificationService::class.java) - private val okHttpClient = createClient() - .connectTimeout(5, TimeUnit.SECONDS) - .readTimeout(5, TimeUnit.SECONDS) - .writeTimeout(5, TimeUnit.SECONDS) - .build() + private val okHttpClient by lazy { + createClient() + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .writeTimeout(5, TimeUnit.SECONDS) + .build() + } private val apiBaseUrl = "https://api.telegram.org/bot" diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/RetrofitFactory.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/RetrofitFactory.kt index c751d0e..703fe6b 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/RetrofitFactory.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/RetrofitFactory.kt @@ -8,6 +8,7 @@ import com.wrbug.polymarketbot.api.GitHubApi import com.wrbug.polymarketbot.api.PolymarketClobApi import com.wrbug.polymarketbot.api.PolymarketDataApi import com.wrbug.polymarketbot.api.PolymarketGammaApi +import com.wrbug.polymarketbot.api.PolymarketGammaSportsApi import com.wrbug.polymarketbot.constants.PolymarketConstants import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull @@ -360,6 +361,25 @@ class RetrofitFactory( fun createGitHubApi(): GitHubApi { return githubApi } + + // 缓存 Gamma Sports API 客户端(单例) + private val gammaSportsApi: PolymarketGammaSportsApi by lazy { + Retrofit.Builder() + .baseUrl(PolymarketConstants.GAMMA_BASE_URL) + .client(sharedOkHttpClient) + .addConverterFactory(GsonConverterFactory.create(gson)) + .build() + .create(PolymarketGammaSportsApi::class.java) + } + + /** + * 创建 Polymarket Gamma Sports API 客户端 + * Gamma Sports API 是公开 API,不需要认证 + * @return PolymarketGammaSportsApi 客户端(单例) + */ + fun createGammaSportsApi(): PolymarketGammaSportsApi { + return gammaSportsApi + } /** * 清理缓存(用于测试或配置变更时) diff --git a/backend/src/main/resources/db/migration/V41__add_sports_tail_strategy_tables.sql b/backend/src/main/resources/db/migration/V41__add_sports_tail_strategy_tables.sql new file mode 100644 index 0000000..0389631 --- /dev/null +++ b/backend/src/main/resources/db/migration/V41__add_sports_tail_strategy_tables.sql @@ -0,0 +1,67 @@ +-- Flyway migration V41 +-- Create sports_tail_strategy table +CREATE TABLE `sports_tail_strategy` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `account_id` BIGINT NOT NULL COMMENT '账户ID', + `condition_id` VARCHAR(100) NOT NULL COMMENT '市场 conditionId', + `market_title` VARCHAR(500) COMMENT '市场标题', + `event_slug` VARCHAR(255) COMMENT '事件slug', + `yes_token_id` VARCHAR(100) COMMENT 'YES Token ID', + `no_token_id` VARCHAR(100) COMMENT 'NO Token ID', + `trigger_price` DECIMAL(20, 8) NOT NULL COMMENT '触发价格', + `amount_mode` VARCHAR(10) NOT NULL COMMENT '金额模式: FIXED/RATIO', + `amount_value` DECIMAL(20, 8) NOT NULL COMMENT '金额值', + `take_profit_price` DECIMAL(20, 8) COMMENT '止盈价格', + `stop_loss_price` DECIMAL(20, 8) COMMENT '止损价格', + `filled` BOOLEAN NOT NULL DEFAULT false COMMENT '是否已成交', + `filled_price` DECIMAL(20, 8) COMMENT '成交价格', + `filled_outcome_index` INT COMMENT '成交方向索引 0=YES, 1=NO', + `filled_outcome_name` VARCHAR(50) COMMENT '成交方向名称', + `filled_amount` DECIMAL(20, 8) COMMENT '成交金额', + `filled_shares` DECIMAL(20, 8) COMMENT '成交份额', + `filled_at` BIGINT COMMENT '成交时间', + `sold` BOOLEAN NOT NULL DEFAULT false COMMENT '是否已卖出', + `sell_price` DECIMAL(20, 8) COMMENT '卖出价格', + `sell_type` VARCHAR(20) COMMENT '卖出类型', + `sell_amount` DECIMAL(20, 8) COMMENT '卖出金额', + `realized_pnl` DECIMAL(20, 8) COMMENT '已实现盈亏', + `sold_at` BIGINT COMMENT '卖出时间', + `created_at` BIGINT NOT NULL COMMENT '创建时间', + `updated_at` BIGINT NOT NULL COMMENT '更新时间', + PRIMARY KEY (`id`) +); + +-- Create sports_tail_strategy_trigger table +CREATE TABLE `sports_tail_strategy_trigger` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `strategy_id` BIGINT NOT NULL COMMENT '策略ID', + `account_id` BIGINT NOT NULL COMMENT '账户ID', + `condition_id` VARCHAR(100) NOT NULL COMMENT '市场 conditionId', + `market_title` VARCHAR(500) COMMENT '市场标题', + `buy_price` DECIMAL(20, 8) NOT NULL COMMENT '买入价格', + `outcome_index` INT NOT NULL COMMENT '买入方向索引 0=YES, 1=NO', + `outcome_name` VARCHAR(50) COMMENT '买入方向名称', + `buy_amount` DECIMAL(20, 8) NOT NULL COMMENT '买入金额', + `buy_shares` DECIMAL(20, 8) COMMENT '买入份额', + `buy_order_id` VARCHAR(100) COMMENT '买入订单ID', + `buy_status` VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '买入状态', + `buy_fail_reason` VARCHAR(500) COMMENT '买入失败原因', + `sell_price` DECIMAL(20, 8) COMMENT '卖出价格', + `sell_type` VARCHAR(20) COMMENT '卖出类型', + `sell_amount` DECIMAL(20, 8) COMMENT '卖出金额', + `sell_order_id` VARCHAR(100) COMMENT '卖出订单ID', + `sell_status` VARCHAR(20) COMMENT '卖出状态', + `sell_fail_reason` VARCHAR(500) COMMENT '卖出失败原因', + `realized_pnl` DECIMAL(20, 8) COMMENT '已实现盈亏', + `triggered_at` BIGINT NOT NULL COMMENT '触发时间', + `sold_at` BIGINT COMMENT '卖出时间', + `created_at` BIGINT NOT NULL COMMENT '创建时间', + PRIMARY KEY (`id`) +); + +-- Create indexes +CREATE INDEX idx_sports_tail_strategy_account_id ON sports_tail_strategy (account_id); +CREATE INDEX idx_sports_tail_strategy_condition_id ON sports_tail_strategy (condition_id); +CREATE INDEX idx_sports_tail_trigger_account_id ON sports_tail_strategy_trigger (account_id); +CREATE INDEX idx_sports_tail_trigger_strategy_id ON sports_tail_strategy_trigger (strategy_id); +CREATE INDEX idx_sports_tail_trigger_triggered_at ON sports_tail_strategy_trigger (triggered_at); diff --git a/backend/src/main/resources/i18n/messages_en.properties b/backend/src/main/resources/i18n/messages_en.properties index 58c3d18..a29df1b 100644 --- a/backend/src/main/resources/i18n/messages_en.properties +++ b/backend/src/main/resources/i18n/messages_en.properties @@ -338,3 +338,20 @@ backtest.copy_mode.fixed=Fixed Amount backtest.price_tolerance=Price Tolerance backtest.delay_seconds=Delay Seconds backtest.support_sell=Support Sell + +# Sports Tail Strategy +error.sports_tail_strategy_not_found=Sports tail strategy not found +error.sports_tail_strategy_already_filled=Strategy already filled +error.sports_tail_strategy_already_sold=Strategy already sold +error.sports_tail_strategy_amount_mode_invalid=Amount mode must be FIXED or RATIO +error.sports_tail_strategy_price_invalid=Trigger price is invalid +error.sports_tail_strategy_condition_id_empty=Market ID cannot be empty +error.server.sports_tail_strategy_create_failed=Failed to create sports tail strategy +error.server.sports_tail_strategy_delete_failed=Failed to delete sports tail strategy +error.server.sports_tail_strategy_list_fetch_failed=Failed to fetch sports tail strategy list +error.server.sports_tail_strategy_triggers_fetch_failed=Failed to fetch trigger records +error.server.sports_tail_strategy_sports_fetch_failed=Failed to fetch sports categories +error.server.sports_tail_strategy_market_search_failed=Failed to search markets +error.server.sports_tail_strategy_market_detail_failed=Failed to fetch market detail +error.server.sports_tail_strategy_buy_failed=Failed to execute buy +error.server.sports_tail_strategy_sell_failed=Failed to execute sell diff --git a/backend/src/main/resources/i18n/messages_zh_CN.properties b/backend/src/main/resources/i18n/messages_zh_CN.properties index 63eede7..8af50b5 100644 --- a/backend/src/main/resources/i18n/messages_zh_CN.properties +++ b/backend/src/main/resources/i18n/messages_zh_CN.properties @@ -344,3 +344,20 @@ error.server.order_tracking_process_failed=处理订单跟踪失败 error.server.order_tracking_buy_failed=处理买入订单失败 error.server.order_tracking_sell_failed=处理卖出订单失败 error.server.order_tracking_match_failed=订单匹配失败 + +# 体育尾盘策略 +error.sports_tail_strategy_not_found=体育尾盘策略不存在 +error.sports_tail_strategy_already_filled=策略已成交 +error.sports_tail_strategy_already_sold=策略已卖出 +error.sports_tail_strategy_amount_mode_invalid=金额模式仅支持 FIXED 或 RATIO +error.sports_tail_strategy_price_invalid=触发价格无效 +error.sports_tail_strategy_condition_id_empty=市场ID不能为空 +error.server.sports_tail_strategy_create_failed=创建体育尾盘策略失败 +error.server.sports_tail_strategy_delete_failed=删除体育尾盘策略失败 +error.server.sports_tail_strategy_list_fetch_failed=查询体育尾盘策略列表失败 +error.server.sports_tail_strategy_triggers_fetch_failed=查询触发记录失败 +error.server.sports_tail_strategy_sports_fetch_failed=查询体育类别失败 +error.server.sports_tail_strategy_market_search_failed=搜索市场失败 +error.server.sports_tail_strategy_market_detail_failed=查询市场详情失败 +error.server.sports_tail_strategy_buy_failed=买入执行失败 +error.server.sports_tail_strategy_sell_failed=卖出执行失败 diff --git a/backend/src/main/resources/i18n/messages_zh_TW.properties b/backend/src/main/resources/i18n/messages_zh_TW.properties index 43d493a..d3a578d 100644 --- a/backend/src/main/resources/i18n/messages_zh_TW.properties +++ b/backend/src/main/resources/i18n/messages_zh_TW.properties @@ -338,3 +338,20 @@ backtest.copy_mode.fixed=固定金額 backtest.price_tolerance=價格容忍度 backtest.delay_seconds=延遲秒數 backtest.support_sell=支持賣出 + +# 體育尾盤策略 +error.sports_tail_strategy_not_found=體育尾盤策略不存在 +error.sports_tail_strategy_already_filled=策略已成交 +error.sports_tail_strategy_already_sold=策略已賣出 +error.sports_tail_strategy_amount_mode_invalid=金額模式僅支持 FIXED 或 RATIO +error.sports_tail_strategy_price_invalid=觸發價格無效 +error.sports_tail_strategy_condition_id_empty=市場ID不能為空 +error.server.sports_tail_strategy_create_failed=創建體育尾盤策略失敗 +error.server.sports_tail_strategy_delete_failed=刪除體育尾盤策略失敗 +error.server.sports_tail_strategy_list_fetch_failed=查詢體育尾盤策略列表失敗 +error.server.sports_tail_strategy_triggers_fetch_failed=查詢觸發記錄失敗 +error.server.sports_tail_strategy_sports_fetch_failed=查詢體育類別失敗 +error.server.sports_tail_strategy_market_search_failed=搜尋市場失敗 +error.server.sports_tail_strategy_market_detail_failed=查詢市場詳情失敗 +error.server.sports_tail_strategy_buy_failed=買入執行失敗 +error.server.sports_tail_strategy_sell_failed=賣出執行失敗 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index caeaf1e..115adf9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -37,6 +37,7 @@ import BacktestList from './pages/BacktestList' import BacktestDetail from './pages/BacktestDetail' import CryptoTailStrategyList from './pages/CryptoTailStrategyList' import CryptoTailMonitor from './pages/CryptoTailMonitor' +import SportsTailStrategyList from './pages/SportsTailStrategyList' import { wsManager } from './services/websocket' import type { OrderPushMessage } from './types' import { apiService } from './services/api' @@ -264,6 +265,7 @@ function App() { } /> } /> } /> + } /> } /> {/* 保留旧路由以保持向后兼容 */} } /> diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 4ddd575..2a197f1 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -23,7 +23,8 @@ import { NotificationOutlined, LineChartOutlined, RocketOutlined, - DashboardOutlined + DashboardOutlined, + TrophyOutlined } from '@ant-design/icons' import type { MenuProps } from 'antd' import type { ReactNode } from 'react' @@ -77,7 +78,7 @@ const Layout: React.FC = ({ children }) => { if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) { keys.push('/copy-trading-management') } - if (path.startsWith('/crypto-tail-strategy') || path.startsWith('/crypto-tail-monitor')) { + if (path.startsWith('/crypto-tail-strategy') || path.startsWith('/crypto-tail-monitor') || path.startsWith('/sports-tail-strategy')) { keys.push('/crypto-tail-management') } if (path.startsWith('/system-settings')) { @@ -95,7 +96,7 @@ const Layout: React.FC = ({ children }) => { if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) { keys.push('/copy-trading-management') } - if (path.startsWith('/crypto-tail-strategy') || path.startsWith('/crypto-tail-monitor')) { + if (path.startsWith('/crypto-tail-strategy') || path.startsWith('/crypto-tail-monitor') || path.startsWith('/sports-tail-strategy')) { keys.push('/crypto-tail-management') } if (path.startsWith('/system-settings')) { @@ -179,6 +180,11 @@ const Layout: React.FC = ({ children }) => { key: '/crypto-tail-monitor', icon: , label: t('menu.cryptoTailMonitor') + }, + { + key: '/sports-tail-strategy', + icon: , + label: t('menu.sportsTailStrategy') } ] }, diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index 48dae45..dc1ca16 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -317,6 +317,7 @@ "cryptoSpreadStrategy": "Crypto Spread Strategy", "cryptoTailStrategy": "Strategy Config", "cryptoTailMonitor": "Real-time Monitor", + "sportsTailStrategy": "Sports Tail Strategy", "positions": "Position Management", "backtest": "Backtest", "statistics": "Statistics", @@ -1691,6 +1692,79 @@ "empty": "No settled orders yet, cannot show PnL curve" } }, + "sportsTailStrategy": { + "list": { + "title": "Sports Tail Strategy", + "addStrategy": "Add Strategy", + "filter": { + "account": "Account", + "category": "Category", + "allCategory": "All" + }, + "triggerPrice": "Trigger Price", + "amount": "Amount", + "takeProfitStopLoss": "Take Profit / Stop Loss", + "filledPrice": "Filled Price", + "realtimePrice": "Realtime Price", + "shares": "Shares", + "pnl": "PnL", + "pending": "Pending", + "viewRecords": "View Records", + "delete": "Delete", + "deleteConfirm": "Delete this strategy?", + "fetchFailed": "Failed to fetch list" + }, + "form": { + "title": "Add Sports Tail Strategy", + "account": "Account", + "selectAccount": "Select Account", + "selectMarket": "Select Market", + "triggerCondition": "Trigger Condition", + "triggerPriceHelp": "Buy when either side reaches trigger price", + "amount": "Amount", + "fixedAmount": "Fixed Amount", + "ratio": "Balance Ratio", + "autoSell": "Enable Auto Sell", + "takeProfitPrice": "Take Profit Price", + "takeProfitHelp": "Sell when price rises to this value", + "stopLossPrice": "Stop Loss Price", + "stopLossHelp": "Sell when price falls to this value", + "estimatedReturn": "Estimated Return", + "buyPrice": "Buy Price", + "buyAmount": "Buy Amount", + "estimatedShares": "Estimated Shares", + "estimatedPnl": "Estimated PnL", + "createSuccess": "Strategy created", + "createFailed": "Failed to create strategy" + }, + "marketSearch": { + "sport": "Sport", + "marketType": "Market Type", + "endTime": "End Time", + "minLiquidity": "Min Liquidity", + "keyword": "Keyword", + "search": "Search", + "liquidity": "Liquidity", + "remaining": "Remaining", + "all": "All", + "today": "Today", + "next24h": "Next 24 Hours", + "next7days": "Next 7 Days" + }, + "records": { + "title": "Trigger Records", + "time": "Time", + "market": "Market", + "direction": "Direction", + "buyPrice": "Buy Price", + "amount": "Amount", + "sellPrice": "Sell Price", + "pnl": "PnL", + "pending": "Pending", + "takeProfit": "Take Profit", + "stopLoss": "Stop Loss" + } + }, "cryptoTailMonitor": { "title": "Crypto Spread Strategy Monitor", "selectStrategy": "Strategy", diff --git a/frontend/src/locales/zh-CN/common.json b/frontend/src/locales/zh-CN/common.json index a39316d..0fc2d46 100644 --- a/frontend/src/locales/zh-CN/common.json +++ b/frontend/src/locales/zh-CN/common.json @@ -317,6 +317,7 @@ "cryptoSpreadStrategy": "加密价差策略", "cryptoTailStrategy": "策略配置", "cryptoTailMonitor": "实时监控", + "sportsTailStrategy": "体育尾盘策略", "positions": "仓位管理", "backtest": "回测", "statistics": "统计信息", @@ -1691,6 +1692,79 @@ "empty": "暂无已结算订单,无法展示收益曲线" } }, + "sportsTailStrategy": { + "list": { + "title": "体育尾盘策略", + "addStrategy": "新增策略", + "filter": { + "account": "账户", + "category": "类别", + "allCategory": "全部" + }, + "triggerPrice": "触发价", + "amount": "金额", + "takeProfitStopLoss": "止盈/止损", + "filledPrice": "成交价", + "realtimePrice": "实时价格", + "shares": "份", + "pnl": "盈亏", + "pending": "待结算", + "viewRecords": "查看记录", + "delete": "删除", + "deleteConfirm": "确定删除该策略吗?", + "fetchFailed": "获取列表失败" + }, + "form": { + "title": "新增体育尾盘策略", + "account": "账户", + "selectAccount": "选择账户", + "selectMarket": "选择市场", + "triggerCondition": "触发条件", + "triggerPriceHelp": "当任意方向价格达到触发价时买入", + "amount": "下注金额", + "fixedAmount": "固定金额", + "ratio": "余额比例", + "autoSell": "启用自动卖出", + "takeProfitPrice": "止盈价格", + "takeProfitHelp": "价格上涨到此值时自动卖出", + "stopLossPrice": "止损价格", + "stopLossHelp": "价格下跌到此值时自动卖出", + "estimatedReturn": "预估收益", + "buyPrice": "买入价格", + "buyAmount": "买入金额", + "estimatedShares": "预计份额", + "estimatedPnl": "预计收益", + "createSuccess": "策略创建成功", + "createFailed": "策略创建失败" + }, + "marketSearch": { + "sport": "体育类别", + "marketType": "市场类型", + "endTime": "结束时间", + "minLiquidity": "最小流动性", + "keyword": "搜索关键词", + "search": "搜索", + "liquidity": "流动性", + "remaining": "剩余", + "all": "全部", + "today": "今天", + "next24h": "未来24小时", + "next7days": "未来7天" + }, + "records": { + "title": "触发记录", + "time": "时间", + "market": "市场", + "direction": "方向", + "buyPrice": "买入价", + "amount": "金额", + "sellPrice": "卖出价", + "pnl": "盈亏", + "pending": "待结算", + "takeProfit": "已止盈", + "stopLoss": "已止损" + } + }, "cryptoTailMonitor": { "title": "加密价差策略监控", "selectStrategy": "选择策略", diff --git a/frontend/src/locales/zh-TW/common.json b/frontend/src/locales/zh-TW/common.json index beebeaa..bad8c10 100644 --- a/frontend/src/locales/zh-TW/common.json +++ b/frontend/src/locales/zh-TW/common.json @@ -317,6 +317,7 @@ "cryptoSpreadStrategy": "加密價差策略", "cryptoTailStrategy": "策略配置", "cryptoTailMonitor": "即時監控", + "sportsTailStrategy": "體育尾盤策略", "positions": "倉位管理", "backtest": "回測", "statistics": "統計信息", @@ -1691,6 +1692,79 @@ "empty": "暫無已結算訂單,無法展示收益曲線" } }, + "sportsTailStrategy": { + "list": { + "title": "體育尾盤策略", + "addStrategy": "新增策略", + "filter": { + "account": "賬戶", + "category": "類別", + "allCategory": "全部" + }, + "triggerPrice": "觸發價", + "amount": "金額", + "takeProfitStopLoss": "止盈/止損", + "filledPrice": "成交價", + "realtimePrice": "實時價格", + "shares": "份", + "pnl": "盈虧", + "pending": "待結算", + "viewRecords": "查看記錄", + "delete": "刪除", + "deleteConfirm": "確定刪除該策略嗎?", + "fetchFailed": "獲取列表失敗" + }, + "form": { + "title": "新增體育尾盤策略", + "account": "賬戶", + "selectAccount": "選擇賬戶", + "selectMarket": "選擇市場", + "triggerCondition": "觸發條件", + "triggerPriceHelp": "當任意方向價格達到觸發價時買入", + "amount": "下注金額", + "fixedAmount": "固定金額", + "ratio": "餘額比例", + "autoSell": "啟用自動賣出", + "takeProfitPrice": "止盈價格", + "takeProfitHelp": "價格上漲到此值時自動賣出", + "stopLossPrice": "止損價格", + "stopLossHelp": "價格下跌到此值時自動賣出", + "estimatedReturn": "預估收益", + "buyPrice": "買入價格", + "buyAmount": "買入金額", + "estimatedShares": "預計份額", + "estimatedPnl": "預計收益", + "createSuccess": "策略創建成功", + "createFailed": "策略創建失敗" + }, + "marketSearch": { + "sport": "體育類別", + "marketType": "市場類型", + "endTime": "結束時間", + "minLiquidity": "最小流動性", + "keyword": "搜索關鍵詞", + "search": "搜索", + "liquidity": "流動性", + "remaining": "剩餘", + "all": "全部", + "today": "今天", + "next24h": "未來24小時", + "next7days": "未來7天" + }, + "records": { + "title": "觸發記錄", + "time": "時間", + "market": "市場", + "direction": "方向", + "buyPrice": "買入價", + "amount": "金額", + "sellPrice": "賣出價", + "pnl": "盈虧", + "pending": "待結算", + "takeProfit": "已止盈", + "stopLoss": "已止損" + } + }, "cryptoTailMonitor": { "title": "加密價差策略監控", "selectStrategy": "選擇策略", diff --git a/frontend/src/pages/SportsTailStrategyList.tsx b/frontend/src/pages/SportsTailStrategyList.tsx new file mode 100644 index 0000000..a7e94d0 --- /dev/null +++ b/frontend/src/pages/SportsTailStrategyList.tsx @@ -0,0 +1,575 @@ +import { useEffect, useState } from 'react' +import { + Card, + Table, + Button, + Space, + message, + Select, + Modal, + Form, + Input, + InputNumber, + Radio, + Spin, + Popconfirm, + Empty, + Drawer, + Row, + Col, + Typography +} from 'antd' +import dayjs from 'dayjs' +import { PlusOutlined, DeleteOutlined } 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 { + SportsTailStrategyDto, + SportsTailStrategyCreateRequest, + SportsTailTriggerDto, + SportsCategoryDto, + SportsMarketDto +} from '../types' +import { formatUSDC } from '../utils' + +const POLYMARKET_BASE = 'https://polymarket.com/event/' + +const SportsTailStrategyList: React.FC = () => { + const { t } = useTranslation() + const isMobile = useMediaQuery({ maxWidth: 768 }) + const { accounts, fetchAccounts } = useAccountStore() + const [list, setList] = useState([]) + const [loading, setLoading] = useState(false) + const [filters, setFilters] = useState<{ accountId?: number; sport?: string }>({}) + const [formModalOpen, setFormModalOpen] = useState(false) + const [sportsList, setSportsList] = useState([]) + const [marketSearchLoading, setMarketSearchLoading] = useState(false) + const [marketSearchResult, setMarketSearchResult] = useState([]) + const [marketSearchFilters, setMarketSearchFilters] = useState<{ + sport?: string + keyword?: string + }>({}) + const [recordsDrawerOpen, setRecordsDrawerOpen] = useState(false) + const [records, setRecords] = useState([]) + const [recordsTotal, setRecordsTotal] = useState(0) + const [recordsLoading, setRecordsLoading] = useState(false) + const [recordsPage, setRecordsPage] = useState(1) + const [recordsPageSize] = useState(20) + const [recordsFilters, setRecordsFilters] = useState<{ + accountId?: number + status?: string + startTime?: number + endTime?: number + }>({}) + const [form] = Form.useForm() + + useEffect(() => { + fetchAccounts() + fetchSportsList() + }, []) + + useEffect(() => { + fetchList() + }, [filters]) + + const fetchList = async () => { + setLoading(true) + try { + const res = await apiService.sportsTailStrategy.list(filters) + if (res.data.code === 0 && res.data.data?.list) { + setList(res.data.data.list) + } else { + message.error(res.data.msg || t('sportsTailStrategy.list.fetchFailed')) + } + } catch (e) { + message.error((e as Error).message || t('sportsTailStrategy.list.fetchFailed')) + } finally { + setLoading(false) + } + } + + const fetchSportsList = async () => { + try { + const res = await apiService.sportsTailStrategy.sportsList() + if (res.data.code === 0 && res.data.data?.list) { + setSportsList(res.data.data.list) + } + } catch { + setSportsList([]) + } + } + + const fetchMarketSearch = async () => { + setMarketSearchLoading(true) + try { + const res = await apiService.sportsTailStrategy.marketSearch({ + sport: marketSearchFilters.sport || undefined, + keyword: marketSearchFilters.keyword || undefined, + limit: 50 + }) + if (res.data.code === 0 && res.data.data?.list) { + setMarketSearchResult(res.data.data.list) + } else { + setMarketSearchResult([]) + } + } catch { + setMarketSearchResult([]) + } finally { + setMarketSearchLoading(false) + } + } + + const fetchRecords = async (page = 1) => { + setRecordsLoading(true) + try { + const res = await apiService.sportsTailStrategy.triggers({ + accountId: recordsFilters.accountId, + status: recordsFilters.status, + startTime: recordsFilters.startTime, + endTime: recordsFilters.endTime, + page, + pageSize: recordsPageSize + }) + if (res.data.code === 0 && res.data.data) { + setRecords(res.data.data.list) + setRecordsTotal(res.data.data.total) + setRecordsPage(page) + } + } catch { + setRecords([]) + setRecordsTotal(0) + } finally { + setRecordsLoading(false) + } + } + + const openAddModal = () => { + form.resetFields() + form.setFieldsValue({ amountMode: 'FIXED' }) + setFormModalOpen(true) + setMarketSearchResult([]) + setMarketSearchFilters({}) + fetchSportsList() + } + + const handleFormSubmit = async () => { + try { + const v = await form.validateFields() + const payload: SportsTailStrategyCreateRequest = { + accountId: v.accountId, + conditionId: v.conditionId, + marketTitle: v.marketTitle, + eventSlug: v.eventSlug || undefined, + triggerPrice: String(v.triggerPrice), + amountMode: v.amountMode, + amountValue: String(v.amountValue), + takeProfitPrice: v.takeProfitPrice != null ? String(v.takeProfitPrice) : undefined, + stopLossPrice: v.stopLossPrice != null ? String(v.stopLossPrice) : undefined + } + const res = await apiService.sportsTailStrategy.create(payload) + if (res.data.code === 0) { + message.success(t('sportsTailStrategy.form.createSuccess')) + setFormModalOpen(false) + fetchList() + } else { + message.error(res.data.msg || t('sportsTailStrategy.form.createFailed')) + } + } catch (e) { + if (e && typeof (e as { errorFields?: unknown }).errorFields === 'undefined') { + message.error((e as Error).message || t('sportsTailStrategy.form.createFailed')) + } + } + } + + const handleDelete = async (id: number) => { + try { + const res = await apiService.sportsTailStrategy.delete({ id }) + if (res.data.code === 0) { + message.success(t('message.success')) + fetchList() + } else { + message.error(res.data.msg) + } + } catch (e) { + message.error((e as Error).message) + } + } + + const openRecordsDrawer = () => { + setRecordsDrawerOpen(true) + setRecordsFilters({}) + fetchRecords(1) + } + + useEffect(() => { + if (recordsDrawerOpen) { + fetchRecords(recordsPage) + } + }, [recordsDrawerOpen, recordsFilters]) + + const renderAmount = (row: SportsTailStrategyDto) => { + if (row.amountMode === 'FIXED') { + return `${formatUSDC(row.amountValue)} USDC` + } + return `${row.amountValue}%` + } + + const renderTakeProfitStopLoss = (row: SportsTailStrategyDto) => { + const a = row.takeProfitPrice != null ? formatUSDC(row.takeProfitPrice) : '-' + const b = row.stopLossPrice != null ? formatUSDC(row.stopLossPrice) : '-' + return `${a} / ${b}` + } + + const renderFilledOrRealtime = (row: SportsTailStrategyDto) => { + if (row.filled && row.filledPrice != null && row.filledOutcomeName != null && row.filledShares != null) { + return `${formatUSDC(row.filledPrice)} ${row.filledOutcomeName} | ${formatUSDC(row.filledShares)} ${t('sportsTailStrategy.list.shares')}` + } + const yes = row.realtimeYesPrice != null ? formatUSDC(row.realtimeYesPrice) : '-' + const no = row.realtimeNoPrice != null ? formatUSDC(row.realtimeNoPrice) : '-' + return `${t('sportsTailStrategy.list.realtimePrice')}: ${yes} / ${no}` + } + + const renderPnl = (row: SportsTailStrategyDto) => { + if (row.sold && row.realizedPnl != null) { + const n = parseFloat(row.realizedPnl) + const prefix = n >= 0 ? '+' : '' + return `${prefix}${formatUSDC(row.realizedPnl)} USDC` + } + if (row.filled && !row.sold) return t('sportsTailStrategy.list.pending') + return '-' + } + + const columns = [ + { + title: t('sportsTailStrategy.list.triggerPrice'), + dataIndex: 'triggerPrice', + key: 'triggerPrice', + render: (v: string) => `>= ${formatUSDC(v)}` + }, + { + title: t('sportsTailStrategy.list.amount'), + key: 'amount', + render: (_: unknown, row: SportsTailStrategyDto) => renderAmount(row) + }, + { + title: t('sportsTailStrategy.list.takeProfitStopLoss'), + key: 'tpSl', + render: (_: unknown, row: SportsTailStrategyDto) => renderTakeProfitStopLoss(row) + }, + { + title: t('sportsTailStrategy.list.filledPrice'), + key: 'filled', + render: (_: unknown, row: SportsTailStrategyDto) => renderFilledOrRealtime(row) + }, + { + title: t('sportsTailStrategy.list.pnl'), + key: 'pnl', + render: (_: unknown, row: SportsTailStrategyDto) => renderPnl(row) + }, + { + title: t('common.actions'), + key: 'actions', + render: (_: unknown, row: SportsTailStrategyDto) => ( + + + handleDelete(row.id)} + okText={t('common.confirm')} + cancelText={t('common.cancel')} + > + + + + ) + } + ] + + const recordColumns = [ + { + title: t('sportsTailStrategy.records.time'), + dataIndex: 'triggeredAt', + key: 'triggeredAt', + render: (v: number) => dayjs(v).format('MM-DD HH:mm') + }, + { + title: t('sportsTailStrategy.records.market'), + dataIndex: 'marketTitle', + key: 'marketTitle' + }, + { + title: t('sportsTailStrategy.records.direction'), + dataIndex: 'outcomeName', + key: 'outcomeName' + }, + { + title: t('sportsTailStrategy.records.buyPrice'), + dataIndex: 'buyPrice', + key: 'buyPrice', + render: (v: string) => formatUSDC(v) + }, + { + title: t('sportsTailStrategy.records.amount'), + dataIndex: 'buyAmount', + key: 'buyAmount', + render: (v: string) => formatUSDC(v) + }, + { + title: t('sportsTailStrategy.records.sellPrice'), + dataIndex: 'sellPrice', + key: 'sellPrice', + render: (v: string | null) => (v != null ? formatUSDC(v) : '-') + }, + { + title: t('sportsTailStrategy.records.pnl'), + dataIndex: 'realizedPnl', + key: 'realizedPnl', + render: (v: string | null) => { + if (v == null) return t('sportsTailStrategy.records.pending') + const n = parseFloat(v) + const prefix = n >= 0 ? '+' : '' + return `${prefix}${formatUSDC(v)} USDC` + } + } + ] + + return ( +
+
+ + {t('sportsTailStrategy.list.title')} + + + + setFilters((prev) => ({ ...prev, sport: v }))} + options={[{ label: t('sportsTailStrategy.list.filter.allCategory'), value: undefined }, ...sportsList.map((s) => ({ label: s.name || s.sport, value: s.sport }))]} + /> + +
+ + + {isMobile ? ( + + {list.length === 0 ? ( + + ) : ( + list.map((row) => ( + + + {t('sportsTailStrategy.list.filter.account')}: {row.accountName} + {t('sportsTailStrategy.list.triggerPrice')}: >= {formatUSDC(row.triggerPrice)} | {t('sportsTailStrategy.list.amount')}: {renderAmount(row)} + {t('sportsTailStrategy.list.takeProfitStopLoss')}: {renderTakeProfitStopLoss(row)} + {renderFilledOrRealtime(row)} + {t('sportsTailStrategy.list.pnl')}: {renderPnl(row)} + + + + handleDelete(row.id)} + okText={t('common.confirm')} + cancelText={t('common.cancel')} + > + + + + + + + )) + )} + + ) : ( + { + const url = row.eventSlug ? `${POLYMARKET_BASE}${row.eventSlug}` : null + if (url) { + return {text} + } + return text + } + }, + { + title: t('sportsTailStrategy.list.filter.account'), + dataIndex: 'accountName', + key: 'accountName', + width: 100 + }, + ...columns + ]} + pagination={false} + locale={{ emptyText: t('common.noData') }} + /> + )} + + + setFormModalOpen(false)} + onOk={handleFormSubmit} + width={isMobile ? '100%' : 560} + destroyOnClose + > +
+ + setMarketSearchFilters((prev) => ({ ...prev, sport: v }))} + options={[{ label: t('sportsTailStrategy.marketSearch.all'), value: undefined }, ...sportsList.map((s) => ({ label: s.name || s.sport, value: s.sport }))]} + /> + setMarketSearchFilters((prev) => ({ ...prev, keyword: e.target.value }))} + /> + + +
+ {marketSearchLoading ? ( +
+ ) : marketSearchResult.length === 0 ? ( + + ) : ( + { + const c = e.target.value as SportsMarketDto + form.setFieldsValue({ + conditionId: c.conditionId, + marketTitle: c.question, + eventSlug: c.eventSlug ?? undefined + }) + }} + > + + {marketSearchResult.map((m) => ( + +
+
{m.question}
+ + {m.outcomes?.[0]}: {m.outcomePrices?.[0] ?? '-'} | {m.outcomes?.[1]}: {m.outcomePrices?.[1] ?? '-'} | {t('sportsTailStrategy.marketSearch.liquidity')}: {formatUSDC(m.liquidity)} USDC + +
+
+ ))} +
+
+ )} +
+ +
+ + + + + + + + + {t('sportsTailStrategy.form.fixedAmount')} (USDC) + {t('sportsTailStrategy.form.ratio')} (%) + + + + + + + + + + + + +
+ + setRecordsDrawerOpen(false)} + width={isMobile ? '100%' : 720} + > + +
fetchRecords(p) + }} + size="small" + locale={{ emptyText: t('common.noData') }} + /> + + + ) +} + +export default SportsTailStrategyList diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 2dbbe9c..58194c5 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -514,6 +514,26 @@ export const apiService = { apiClient.post>('/crypto-tail-strategy/manual-order', data) }, + /** + * 体育尾盘策略 API + */ + sportsTailStrategy: { + list: (data: { accountId?: number; sport?: string } = {}) => + apiClient.post>('/sports-tail-strategy/list', data), + create: (data: import('../types').SportsTailStrategyCreateRequest) => + apiClient.post>('/sports-tail-strategy/create', data), + delete: (data: { id: number }) => + apiClient.post>('/sports-tail-strategy/delete', data), + triggers: (data: import('../types').SportsTailTriggerListRequest) => + apiClient.post>('/sports-tail-strategy/triggers', data), + sportsList: () => + apiClient.post>('/sports-tail-strategy/sports-list', {}), + marketSearch: (data: import('../types').SportsMarketSearchRequest) => + apiClient.post>('/sports-tail-strategy/market-search', data), + marketDetail: (data: { conditionId: string }) => + apiClient.post>('/sports-tail-strategy/market-detail', data) + }, + /** * 订单管理 API */ diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 7034f94..8e49c16 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1261,6 +1261,128 @@ export interface ManualOrderDetails { totalAmount: string } +// ==================== 体育尾盘策略相关类型 ==================== + +/** 体育尾盘策略 DTO */ +export interface SportsTailStrategyDto { + id: number + accountId: number + accountName: string + conditionId: string + marketTitle: string + eventSlug: string | null + triggerPrice: string + amountMode: 'FIXED' | 'RATIO' + amountValue: string + takeProfitPrice: string | null + stopLossPrice: string | null + filled: boolean + filledPrice: string | null + filledOutcomeIndex: number | null + filledOutcomeName: string | null + filledAmount: string | null + filledShares: string | null + filledAt: number | null + sold: boolean + sellPrice: string | null + sellType: string | null + sellAmount: string | null + realizedPnl: string | null + soldAt: number | null + realtimeYesPrice: string | null + realtimeNoPrice: string | null + createdAt: number + updatedAt: number +} + +/** 体育尾盘策略创建请求 */ +export interface SportsTailStrategyCreateRequest { + accountId: number + conditionId: string + marketTitle: string + eventSlug?: string + triggerPrice: string + amountMode: 'FIXED' | 'RATIO' + amountValue: string + takeProfitPrice?: string + stopLossPrice?: string +} + +/** 体育尾盘策略列表响应 */ +export interface SportsTailStrategyListResponse { + list: SportsTailStrategyDto[] +} + +/** 体育尾盘策略触发记录 DTO */ +export interface SportsTailTriggerDto { + id: number + strategyId: number + marketTitle: string + conditionId: string + buyPrice: string + outcomeIndex: number + outcomeName: string | null + buyAmount: string + buyShares: string | null + buyStatus: string + sellPrice: string | null + sellType: string | null + sellAmount: string | null + sellStatus: string | null + realizedPnl: string | null + triggeredAt: number + soldAt: number | null +} + +/** 体育尾盘策略触发记录列表请求 */ +export interface SportsTailTriggerListRequest { + accountId?: number + status?: string + startTime?: number + endTime?: number + page?: number + pageSize?: number +} + +/** 体育尾盘策略触发记录列表响应 */ +export interface SportsTailTriggerListResponse { + total: number + list: SportsTailTriggerDto[] +} + +/** 体育类别 DTO */ +export interface SportsCategoryDto { + sport: string + image: string + tagId: number + name: string +} + +/** 体育市场 DTO */ +export interface SportsMarketDto { + conditionId: string + question: string + outcomes: string[] + outcomePrices: string[] + endDate: string + liquidity: string + bestBid: number | null + bestAsk: number | null + yesTokenId: string + noTokenId: string + eventSlug?: string | null +} + +/** 体育市场搜索请求 */ +export interface SportsMarketSearchRequest { + sport?: string + endDateMin?: string + endDateMax?: string + minLiquidity?: string + keyword?: string + limit?: number +} + // ==================== 消息模板相关类型 ==================== /**