From b3872aa8f2a87d4fb2cea6568a85dd1563facf65 Mon Sep 17 00:00:00 2001 From: WrBug Date: Wed, 27 May 2026 04:34:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=B8=82=E5=9C=BA?= =?UTF-8?q?=E5=A4=A7=E5=8D=95=E7=9B=91=E5=90=AC=E7=AD=96=E7=95=A5=EF=BC=88?= =?UTF-8?q?Whale=20Monitor=20Strategy=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 支持按市场分类/体育联赛筛选并选择市场,实时监听 Activity WebSocket 交易流, 在滑动窗口内聚合 BUY 成交额,达到阈值后自动下 FAK 单。 后端:策略 CRUD、WS 监听、滑动窗口聚合、订单执行、触发记录 前端:策略列表/创建/编辑、市场搜索选择(分类+联赛)、触发记录查看 Co-Authored-By: Claude Opus 4.7 --- .../polymarketbot/api/PolymarketGammaApi.kt | 55 +- .../controller/markets/MarketController.kt | 40 ++ .../WhaleMonitorStrategyController.kt | 135 ++++ .../dto/WhaleMonitorStrategyDto.kt | 119 ++++ .../entity/WhaleMonitorStrategy.kt | 53 ++ .../entity/WhaleMonitorTrigger.kt | 51 ++ .../wrbug/polymarketbot/enums/ErrorCode.kt | 17 +- .../event/WhaleMonitorStrategyChangedEvent.kt | 8 + .../WhaleMonitorStrategyRepository.kt | 10 + .../WhaleMonitorTriggerRepository.kt | 17 + .../service/common/MarketService.kt | 265 +++++++ .../WhaleMonitorLifecycleService.kt | 56 ++ .../WhaleMonitorOrderExecutionService.kt | 204 ++++++ .../WhaleMonitorStrategyService.kt | 294 ++++++++ .../whalemonitor/WhaleMonitorWsService.kt | 301 ++++++++ ...__create_whale_monitor_strategy_tables.sql | 44 ++ .../resources/i18n/messages_en.properties | 13 + .../resources/i18n/messages_zh_CN.properties | 13 + .../resources/i18n/messages_zh_TW.properties | 13 + frontend/src/App.tsx | 4 + frontend/src/components/Layout.tsx | 25 +- .../WhaleMonitorMarketGroupedList.tsx | 169 +++++ .../components/WhaleMonitorMarketListItem.tsx | 140 ++++ .../WhaleMonitorMarketThumbnail.tsx | 36 + frontend/src/constants/whaleMonitor.ts | 208 ++++++ frontend/src/locales/en/common.json | 109 +++ frontend/src/locales/zh-CN/common.json | 109 +++ frontend/src/locales/zh-TW/common.json | 109 +++ .../src/pages/WhaleMonitorMarketSelect.tsx | 330 +++++++++ .../src/pages/WhaleMonitorStrategyList.tsx | 650 ++++++++++++++++++ frontend/src/services/api.ts | 80 ++- frontend/src/types/index.ts | 68 ++ 32 files changed, 3738 insertions(+), 7 deletions(-) create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/controller/whalemonitor/WhaleMonitorStrategyController.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/dto/WhaleMonitorStrategyDto.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/entity/WhaleMonitorStrategy.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/entity/WhaleMonitorTrigger.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/event/WhaleMonitorStrategyChangedEvent.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/repository/WhaleMonitorStrategyRepository.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/repository/WhaleMonitorTriggerRepository.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/whalemonitor/WhaleMonitorLifecycleService.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/whalemonitor/WhaleMonitorOrderExecutionService.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/whalemonitor/WhaleMonitorStrategyService.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/whalemonitor/WhaleMonitorWsService.kt create mode 100644 backend/src/main/resources/db/migration/V41__create_whale_monitor_strategy_tables.sql create mode 100644 frontend/src/components/WhaleMonitorMarketGroupedList.tsx create mode 100644 frontend/src/components/WhaleMonitorMarketListItem.tsx create mode 100644 frontend/src/components/WhaleMonitorMarketThumbnail.tsx create mode 100644 frontend/src/constants/whaleMonitor.ts create mode 100644 frontend/src/pages/WhaleMonitorMarketSelect.tsx create mode 100644 frontend/src/pages/WhaleMonitorStrategyList.tsx diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketGammaApi.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketGammaApi.kt index 5948bb3..1f8527d 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketGammaApi.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketGammaApi.kt @@ -25,9 +25,31 @@ interface PolymarketGammaApi { suspend fun listMarkets( @Query("condition_ids") conditionIds: List? = null, @Query("clob_token_ids") clobTokenIds: List? = null, - @Query("include_tag") includeTag: Boolean? = null + @Query("include_tag") includeTag: Boolean? = null, + @Query("tag_id") tagId: String? = null, + @Query("title") title: String? = null, + @Query("closed") closed: Boolean? = null, + @Query("limit") limit: Int? = null, + @Query("active") active: Boolean? = null ): Response> + /** + * 按系列/标签列出事件(体育单场比赛市场嵌套在 events.markets 中) + * 文档: https://docs.polymarket.com/market-data/fetching-markets + */ + @GET("/events") + suspend fun listEvents( + @Query("series_id") seriesId: String? = null, + @Query("tag_id") tagId: String? = null, + @Query("title") title: String? = null, + @Query("closed") closed: Boolean? = null, + @Query("limit") limit: Int? = null, + @Query("offset") offset: Int? = null, + @Query("active") active: Boolean? = null, + @Query("order") order: String? = null, + @Query("ascending") ascending: Boolean? = null + ): Response> + /** * 根据 slug 获取事件(用于 5/15 分钟加密市场) * GET /events/slug/{slug},如 btc-updown-5m-1771007400 @@ -35,6 +57,13 @@ interface PolymarketGammaApi { */ @GET("/events/slug/{slug}") suspend fun getEventBySlug(@Path("slug") slug: String): Response + + /** + * 获取体育联赛列表 + * GET /sports 返回所有可用的体育分类(NBA、MLB、EPL 等) + */ + @GET("/sports") + suspend fun listSports(): Response> } /** @@ -79,6 +108,30 @@ data class EventResponse( val negRisk: Boolean? = null ) +/** + * 体育联赛项(来自 /sports 端点) + */ +data class GammaSportItem( + val id: Int? = null, + val sport: String? = null, + val image: String? = null, + val tags: String? = null, + val series: String? = null +) + +/** + * Gamma /events 列表项(含嵌套 markets,用于体育联赛单场比赛) + */ +data class GammaEventListItem( + val id: String? = null, + val title: String? = null, + val slug: String? = null, + val category: String? = null, + val image: String? = null, + val icon: String? = null, + val markets: List? = null +) + /** * 市场响应(根据 Gamma API 文档) */ diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/markets/MarketController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/markets/MarketController.kt index 16ed5e2..dcf9b64 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/markets/MarketController.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/markets/MarketController.kt @@ -5,6 +5,9 @@ import com.wrbug.polymarketbot.dto.* import com.wrbug.polymarketbot.enums.ErrorCode import com.wrbug.polymarketbot.service.accounts.AccountService import com.wrbug.polymarketbot.service.common.MarketPriceService +import com.wrbug.polymarketbot.service.common.MarketSearchResult +import com.wrbug.polymarketbot.service.common.MarketService +import com.wrbug.polymarketbot.service.common.SportCategoryResult import com.wrbug.polymarketbot.service.common.PolymarketClobService import kotlinx.coroutines.runBlocking import java.math.BigDecimal @@ -23,6 +26,7 @@ class MarketController( private val accountService: AccountService, private val clobService: PolymarketClobService, private val marketPriceService: MarketPriceService, + private val marketService: MarketService, private val messageSource: MessageSource ) { @@ -83,6 +87,42 @@ class MarketController( ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_MARKET_LATEST_PRICE_FETCH_FAILED, e.message, messageSource)) } } + + /** + * 搜索市场(按标题关键词和/或分类标签,调用 Gamma API) + */ + @PostMapping("/search") + fun searchMarkets(@RequestBody request: Map): ResponseEntity>> { + return try { + val keyword = (request["keyword"] as? String)?.trim() ?: "" + val tagId = request["tagId"] as? String + val seriesId = request["seriesId"] as? String + val sportSlug = request["sportSlug"] as? String + val limit = (request["limit"] as? Number)?.toInt() ?: 50 + if (keyword.length < 2 && tagId.isNullOrBlank() && seriesId.isNullOrBlank() && sportSlug.isNullOrBlank()) { + return ResponseEntity.ok(ApiResponse.success(emptyList())) + } + val results = runBlocking { marketService.searchMarkets(keyword, tagId, seriesId, sportSlug, limit) } + ResponseEntity.ok(ApiResponse.success(results)) + } catch (e: Exception) { + logger.error("搜索市场异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource)) + } + } + + /** + * 获取体育联赛子分类列表(NBA、MLB、EPL 等) + */ + @PostMapping("/sports-categories") + fun getSportsCategories(): ResponseEntity>> { + return try { + val results = runBlocking { marketService.listSportsCategories() } + ResponseEntity.ok(ApiResponse.success(results)) + } catch (e: Exception) { + logger.error("获取体育分类异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource)) + } + } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/whalemonitor/WhaleMonitorStrategyController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/whalemonitor/WhaleMonitorStrategyController.kt new file mode 100644 index 0000000..abd6dfb --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/whalemonitor/WhaleMonitorStrategyController.kt @@ -0,0 +1,135 @@ +package com.wrbug.polymarketbot.controller.whalemonitor + +import com.wrbug.polymarketbot.dto.* +import com.wrbug.polymarketbot.enums.ErrorCode +import com.wrbug.polymarketbot.service.whalemonitor.WhaleMonitorStrategyService +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/whale-monitor-strategy") +class WhaleMonitorStrategyController( + private val whaleMonitorStrategyService: WhaleMonitorStrategyService, + private val messageSource: MessageSource +) { + + private val logger = LoggerFactory.getLogger(WhaleMonitorStrategyController::class.java) + + @PostMapping("/list") + fun list(@RequestBody request: WhaleMonitorStrategyListRequest): ResponseEntity> { + return try { + val result = whaleMonitorStrategyService.list(request) + result.fold( + onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) }, + onFailure = { e -> + logger.error("查询大单监听策略列表失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_WHALE_MONITOR_STRATEGY_LIST_FETCH_FAILED, e.message, messageSource)) + } + ) + } catch (e: Exception) { + logger.error("查询大单监听策略列表异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_WHALE_MONITOR_STRATEGY_LIST_FETCH_FAILED, e.message, messageSource)) + } + } + + @PostMapping("/create") + fun create(@RequestBody request: WhaleMonitorStrategyCreateRequest): ResponseEntity> { + return try { + val result = whaleMonitorStrategyService.create(request) + result.fold( + onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) }, + onFailure = { e -> + logger.error("创建大单监听策略失败: ${e.message}", e) + val code = when (e.message) { + ErrorCode.WHALE_MONITOR_STRATEGY_CONDITION_IDS_EMPTY.messageKey -> ErrorCode.WHALE_MONITOR_STRATEGY_CONDITION_IDS_EMPTY + ErrorCode.WHALE_MONITOR_STRATEGY_WINDOW_INVALID.messageKey -> ErrorCode.WHALE_MONITOR_STRATEGY_WINDOW_INVALID + ErrorCode.WHALE_MONITOR_STRATEGY_THRESHOLD_INVALID.messageKey -> ErrorCode.WHALE_MONITOR_STRATEGY_THRESHOLD_INVALID + ErrorCode.WHALE_MONITOR_STRATEGY_AMOUNT_INVALID.messageKey -> ErrorCode.WHALE_MONITOR_STRATEGY_AMOUNT_INVALID + ErrorCode.WHALE_MONITOR_STRATEGY_PRICE_INVALID.messageKey -> ErrorCode.WHALE_MONITOR_STRATEGY_PRICE_INVALID + ErrorCode.PARAM_ACCOUNT_ID_INVALID.messageKey -> ErrorCode.PARAM_ACCOUNT_ID_INVALID + else -> ErrorCode.SERVER_WHALE_MONITOR_STRATEGY_CREATE_FAILED + } + ResponseEntity.ok(ApiResponse.error(code, messageSource = messageSource)) + } + ) + } catch (e: Exception) { + logger.error("创建大单监听策略异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_WHALE_MONITOR_STRATEGY_CREATE_FAILED, e.message, messageSource)) + } + } + + @PostMapping("/update") + fun update(@RequestBody request: WhaleMonitorStrategyUpdateRequest): ResponseEntity> { + return try { + if (request.strategyId <= 0) { + return ResponseEntity.ok(ApiResponse.error(ErrorCode.WHALE_MONITOR_STRATEGY_NOT_FOUND, messageSource = messageSource)) + } + val result = whaleMonitorStrategyService.update(request) + result.fold( + onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) }, + onFailure = { e -> + logger.error("更新大单监听策略失败: ${e.message}", e) + val code = when (e.message) { + ErrorCode.WHALE_MONITOR_STRATEGY_NOT_FOUND.messageKey -> ErrorCode.WHALE_MONITOR_STRATEGY_NOT_FOUND + ErrorCode.WHALE_MONITOR_STRATEGY_CONDITION_IDS_EMPTY.messageKey -> ErrorCode.WHALE_MONITOR_STRATEGY_CONDITION_IDS_EMPTY + ErrorCode.WHALE_MONITOR_STRATEGY_WINDOW_INVALID.messageKey -> ErrorCode.WHALE_MONITOR_STRATEGY_WINDOW_INVALID + ErrorCode.WHALE_MONITOR_STRATEGY_THRESHOLD_INVALID.messageKey -> ErrorCode.WHALE_MONITOR_STRATEGY_THRESHOLD_INVALID + ErrorCode.WHALE_MONITOR_STRATEGY_AMOUNT_INVALID.messageKey -> ErrorCode.WHALE_MONITOR_STRATEGY_AMOUNT_INVALID + ErrorCode.WHALE_MONITOR_STRATEGY_PRICE_INVALID.messageKey -> ErrorCode.WHALE_MONITOR_STRATEGY_PRICE_INVALID + else -> ErrorCode.SERVER_WHALE_MONITOR_STRATEGY_UPDATE_FAILED + } + ResponseEntity.ok(ApiResponse.error(code, messageSource = messageSource)) + } + ) + } catch (e: Exception) { + logger.error("更新大单监听策略异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_WHALE_MONITOR_STRATEGY_UPDATE_FAILED, e.message, messageSource)) + } + } + + @PostMapping("/delete") + fun delete(@RequestBody request: WhaleMonitorStrategyDeleteRequest): ResponseEntity> { + return try { + val strategyId = request.strategyId + if (strategyId <= 0) { + return ResponseEntity.ok(ApiResponse.error(ErrorCode.WHALE_MONITOR_STRATEGY_NOT_FOUND, messageSource = messageSource)) + } + val result = whaleMonitorStrategyService.delete(strategyId) + result.fold( + onSuccess = { ResponseEntity.ok(ApiResponse.success(Unit)) }, + onFailure = { e -> + logger.error("删除大单监听策略失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_WHALE_MONITOR_STRATEGY_DELETE_FAILED, e.message, messageSource)) + } + ) + } catch (e: Exception) { + logger.error("删除大单监听策略异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_WHALE_MONITOR_STRATEGY_DELETE_FAILED, e.message, messageSource)) + } + } + + @PostMapping("/triggers") + fun getTriggerRecords(@RequestBody request: WhaleMonitorTriggerListRequest): ResponseEntity> { + return try { + if (request.strategyId <= 0) { + return ResponseEntity.ok(ApiResponse.error(ErrorCode.WHALE_MONITOR_STRATEGY_NOT_FOUND, messageSource = messageSource)) + } + val result = whaleMonitorStrategyService.getTriggerRecords(request) + result.fold( + onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) }, + onFailure = { e -> + logger.error("查询大单监听触发记录失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_WHALE_MONITOR_STRATEGY_TRIGGERS_FETCH_FAILED, e.message, messageSource)) + } + ) + } catch (e: Exception) { + logger.error("查询大单监听触发记录异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_WHALE_MONITOR_STRATEGY_TRIGGERS_FETCH_FAILED, e.message, messageSource)) + } + } +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/WhaleMonitorStrategyDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/WhaleMonitorStrategyDto.kt new file mode 100644 index 0000000..0aca4f6 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/WhaleMonitorStrategyDto.kt @@ -0,0 +1,119 @@ +package com.wrbug.polymarketbot.dto + +/** + * 大单监听策略创建请求 + * 金额与价格使用 String,后端转为 BigDecimal + */ +data class WhaleMonitorStrategyCreateRequest( + val accountId: Long = 0L, + val name: String? = null, + /** 监听市场 conditionId 列表 */ + val conditionIds: List = emptyList(), + val windowSeconds: Int = 10, + /** 触发阈值金额 */ + val thresholdAmount: String = "0", + /** 固定下单金额 */ + val orderAmount: String = "0", + val minPrice: String = "0", + val maxPrice: String = "1", + val cooldownSeconds: Int = 60, + val enabled: Boolean = true +) + +/** + * 大单监听策略更新请求 + */ +data class WhaleMonitorStrategyUpdateRequest( + val strategyId: Long = 0L, + val name: String? = null, + val conditionIds: List? = null, + val windowSeconds: Int? = null, + val thresholdAmount: String? = null, + val orderAmount: String? = null, + val minPrice: String? = null, + val maxPrice: String? = null, + val cooldownSeconds: Int? = null, + val enabled: Boolean? = null +) + +/** + * 大单监听策略列表请求 + */ +data class WhaleMonitorStrategyListRequest( + val accountId: Long? = null, + val enabled: Boolean? = null +) + +/** + * 大单监听策略 DTO(列表与详情) + */ +data class WhaleMonitorStrategyDto( + val id: Long = 0L, + val accountId: Long = 0L, + val name: String? = null, + val conditionIds: List = emptyList(), + val windowSeconds: Int = 10, + val thresholdAmount: String = "0", + val orderAmount: String = "0", + val minPrice: String = "0", + val maxPrice: String = "1", + val cooldownSeconds: Int = 60, + val enabled: Boolean = true, + val lastTriggerAt: Long? = null, + val triggerCount: Long = 0L, + val createdAt: Long = 0L, + val updatedAt: Long = 0L +) + +/** + * 大单监听策略列表响应 + */ +data class WhaleMonitorStrategyListResponse( + val list: List = emptyList() +) + +/** + * 大单监听策略删除请求 + */ +data class WhaleMonitorStrategyDeleteRequest( + val strategyId: Long = 0L +) + +/** + * 触发记录列表请求 + */ +data class WhaleMonitorTriggerListRequest( + val strategyId: Long = 0L, + val page: Int = 1, + val pageSize: Int = 20, + val status: String? = null, + val startDate: Long? = null, + val endDate: Long? = null +) + +/** + * 触发记录 DTO + */ +data class WhaleMonitorTriggerDto( + val id: Long = 0L, + val strategyId: Long = 0L, + val conditionId: String = "", + val tokenId: String = "", + val side: String = "BUY", + val triggerVolume: String = "0", + val orderPrice: String = "0", + val orderSize: String = "0", + val orderAmount: String = "0", + val orderId: String? = null, + val status: String = "success", + val failReason: String? = null, + val createdAt: Long = 0L +) + +/** + * 触发记录分页响应 + */ +data class WhaleMonitorTriggerListResponse( + val list: List = emptyList(), + val total: Long = 0L +) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/WhaleMonitorStrategy.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/WhaleMonitorStrategy.kt new file mode 100644 index 0000000..ff6a518 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/WhaleMonitorStrategy.kt @@ -0,0 +1,53 @@ +package com.wrbug.polymarketbot.entity + +import jakarta.persistence.* +import java.math.BigDecimal + +/** + * 大单监听策略实体 + * 监听自选市场的 Activity WS 成交,按 tokenId+BUY 聚合窗口内 notional,达阈值自动 FAK 下单 + */ +@Entity +@Table(name = "whale_monitor_strategy") +data class WhaleMonitorStrategy( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @Column(name = "account_id", nullable = false) + val accountId: Long = 0L, + + @Column(name = "name", length = 255) + val name: String? = null, + + /** 监听市场 conditionId 列表,JSON 数组格式如 ["0xabc...","0xdef..."] */ + @Column(name = "condition_ids", nullable = false, columnDefinition = "TEXT") + val conditionIds: String = "[]", + + @Column(name = "window_seconds", nullable = false) + val windowSeconds: Int = 10, + + @Column(name = "threshold_amount", nullable = false, precision = 20, scale = 8) + val thresholdAmount: BigDecimal = BigDecimal.ZERO, + + @Column(name = "order_amount", nullable = false, precision = 20, scale = 8) + val orderAmount: BigDecimal = BigDecimal.ZERO, + + @Column(name = "min_price", nullable = false, precision = 20, scale = 8) + val minPrice: BigDecimal = BigDecimal.ZERO, + + @Column(name = "max_price", nullable = false, precision = 20, scale = 8) + val maxPrice: BigDecimal = BigDecimal.ONE, + + @Column(name = "cooldown_seconds", nullable = false) + val cooldownSeconds: Int = 60, + + @Column(name = "enabled", nullable = false) + val enabled: Boolean = true, + + @Column(name = "created_at", nullable = false) + val createdAt: Long = System.currentTimeMillis(), + + @Column(name = "updated_at", nullable = false) + var updatedAt: Long = System.currentTimeMillis() +) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/WhaleMonitorTrigger.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/WhaleMonitorTrigger.kt new file mode 100644 index 0000000..bf618bd --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/WhaleMonitorTrigger.kt @@ -0,0 +1,51 @@ +package com.wrbug.polymarketbot.entity + +import jakarta.persistence.* +import java.math.BigDecimal + +/** + * 大单监听触发记录 + */ +@Entity +@Table(name = "whale_monitor_trigger") +data class WhaleMonitorTrigger( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @Column(name = "strategy_id", nullable = false) + val strategyId: Long = 0L, + + @Column(name = "condition_id", nullable = false, length = 128) + val conditionId: String = "", + + @Column(name = "token_id", nullable = false, length = 128) + val tokenId: String = "", + + @Column(name = "side", nullable = false, length = 10) + val side: String = "BUY", + + @Column(name = "trigger_volume", nullable = false, precision = 20, scale = 8) + val triggerVolume: BigDecimal = BigDecimal.ZERO, + + @Column(name = "order_price", nullable = false, precision = 20, scale = 8) + val orderPrice: BigDecimal = BigDecimal.ZERO, + + @Column(name = "order_size", nullable = false, precision = 20, scale = 8) + val orderSize: BigDecimal = BigDecimal.ZERO, + + @Column(name = "order_amount", nullable = false, precision = 20, scale = 8) + val orderAmount: BigDecimal = BigDecimal.ZERO, + + @Column(name = "order_id", length = 128) + val orderId: String? = null, + + @Column(name = "status", nullable = false, length = 20) + val status: String = "success", + + @Column(name = "fail_reason", length = 500) + val failReason: String? = null, + + @Column(name = "created_at", nullable = false) + val createdAt: Long = System.currentTimeMillis() +) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/ErrorCode.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/ErrorCode.kt index b4f6dcd..af4fa13 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/ErrorCode.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/ErrorCode.kt @@ -158,6 +158,14 @@ enum class ErrorCode( ACCOUNT_BALANCE_FETCH_FAILED(4707, "查询账户余额失败", "error.account_balance_fetch_failed"), ACCOUNT_POSITIONS_FETCH_FAILED(4708, "查询仓位列表失败", "error.account_positions_fetch_failed"), + // 大单监听策略 (4730-4739) + WHALE_MONITOR_STRATEGY_NOT_FOUND(4730, "大单监听策略不存在", "error.whale_monitor_strategy_not_found"), + WHALE_MONITOR_STRATEGY_CONDITION_IDS_EMPTY(4731, "监听市场不能为空", "error.whale_monitor_strategy_condition_ids_empty"), + WHALE_MONITOR_STRATEGY_WINDOW_INVALID(4732, "时间窗口无效", "error.whale_monitor_strategy_window_invalid"), + WHALE_MONITOR_STRATEGY_THRESHOLD_INVALID(4733, "触发阈值无效", "error.whale_monitor_strategy_threshold_invalid"), + WHALE_MONITOR_STRATEGY_AMOUNT_INVALID(4734, "下单金额无效", "error.whale_monitor_strategy_amount_invalid"), + WHALE_MONITOR_STRATEGY_PRICE_INVALID(4735, "价格区间无效", "error.whale_monitor_strategy_price_invalid"), + // 加密价差策略 (4710-4729) CRYPTO_TAIL_STRATEGY_NOT_FOUND(4710, "加密价差策略不存在", "error.crypto_tail_strategy_not_found"), CRYPTO_TAIL_STRATEGY_WINDOW_INVALID(4711, "时间区间开始不能大于结束", "error.crypto_tail_strategy_window_invalid"), @@ -264,7 +272,14 @@ 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"), + + // 大单监听策略服务 (5630-5639) + SERVER_WHALE_MONITOR_STRATEGY_CREATE_FAILED(5630, "创建大单监听策略失败", "error.server.whale_monitor_strategy_create_failed"), + SERVER_WHALE_MONITOR_STRATEGY_UPDATE_FAILED(5631, "更新大单监听策略失败", "error.server.whale_monitor_strategy_update_failed"), + SERVER_WHALE_MONITOR_STRATEGY_DELETE_FAILED(5632, "删除大单监听策略失败", "error.server.whale_monitor_strategy_delete_failed"), + SERVER_WHALE_MONITOR_STRATEGY_LIST_FETCH_FAILED(5633, "查询大单监听策略列表失败", "error.server.whale_monitor_strategy_list_fetch_failed"), + SERVER_WHALE_MONITOR_STRATEGY_TRIGGERS_FETCH_FAILED(5634, "查询大单监听触发记录失败", "error.server.whale_monitor_strategy_triggers_fetch_failed"); companion object { /** diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/event/WhaleMonitorStrategyChangedEvent.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/event/WhaleMonitorStrategyChangedEvent.kt new file mode 100644 index 0000000..174c04d --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/event/WhaleMonitorStrategyChangedEvent.kt @@ -0,0 +1,8 @@ +package com.wrbug.polymarketbot.event + +import org.springframework.context.ApplicationEvent + +/** + * 大单监听策略创建/更新/删除/启用状态变更后发布,通知 WS 服务重载监听配置 + */ +class WhaleMonitorStrategyChangedEvent(source: Any) : ApplicationEvent(source) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/WhaleMonitorStrategyRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/WhaleMonitorStrategyRepository.kt new file mode 100644 index 0000000..ea64a64 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/WhaleMonitorStrategyRepository.kt @@ -0,0 +1,10 @@ +package com.wrbug.polymarketbot.repository + +import com.wrbug.polymarketbot.entity.WhaleMonitorStrategy +import org.springframework.data.jpa.repository.JpaRepository + +interface WhaleMonitorStrategyRepository : JpaRepository { + fun findAllByAccountId(accountId: Long): List + fun findAllByEnabledTrue(): List + fun findByAccountIdAndEnabled(accountId: Long, enabled: Boolean): List +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/WhaleMonitorTriggerRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/WhaleMonitorTriggerRepository.kt new file mode 100644 index 0000000..6ba95e4 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/WhaleMonitorTriggerRepository.kt @@ -0,0 +1,17 @@ +package com.wrbug.polymarketbot.repository + +import com.wrbug.polymarketbot.entity.WhaleMonitorTrigger +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.JpaRepository + +interface WhaleMonitorTriggerRepository : JpaRepository { + fun findAllByStrategyIdOrderByCreatedAtDesc(strategyId: Long, pageable: Pageable): Page + fun findAllByStrategyIdAndStatusOrderByCreatedAtDesc(strategyId: Long, status: String, pageable: Pageable): Page + fun findAllByStrategyIdAndCreatedAtBetweenOrderByCreatedAtDesc(strategyId: Long, startTs: Long, endTs: Long, pageable: Pageable): Page + fun findAllByStrategyIdAndStatusAndCreatedAtBetweenOrderByCreatedAtDesc(strategyId: Long, status: String, startTs: Long, endTs: Long, pageable: Pageable): Page + fun countByStrategyId(strategyId: Long): Long + fun countByStrategyIdAndStatus(strategyId: Long, status: String): Long + fun countByStrategyIdAndCreatedAtBetween(strategyId: Long, startTs: Long, endTs: Long): Long + fun countByStrategyIdAndStatusAndCreatedAtBetween(strategyId: Long, status: String, startTs: Long, endTs: Long): Long +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/MarketService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/MarketService.kt index 992c426..5f98480 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/MarketService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/MarketService.kt @@ -31,6 +31,12 @@ class MarketService( private val marketCache: Cache = Caffeine.newBuilder() .maximumSize(200) // 最多缓存 200 条记录 .build() + + /** 体育联赛列表缓存(tagId → seriesId 解析) */ + private val sportsCategoriesCache: Cache> = Caffeine.newBuilder() + .maximumSize(1) + .expireAfterWrite(java.time.Duration.ofMinutes(10)) + .build() /** * 根据市场ID获取市场信息 @@ -281,6 +287,233 @@ class MarketService( null } } + + /** + * 搜索市场(按标题关键词,可选按标签筛选) + * 调用 Gamma API /markets?title=xxx 搜索,返回活跃且未关闭的市场 + */ + suspend fun searchMarkets( + keyword: String, + tagId: String? = null, + seriesId: String? = null, + sportSlug: String? = null, + limit: Int = 20 + ): List { + if (keyword.isBlank() && tagId.isNullOrBlank() && seriesId.isNullOrBlank() && sportSlug.isNullOrBlank()) { + return emptyList() + } + return try { + val sport = resolveSportCategory(seriesId, tagId, sportSlug) + if (sport != null && !sport.seriesId.isNullOrBlank()) { + searchSportsLeagueMarkets(keyword, sport, limit) + } else { + searchMarketsByTag(keyword, tagId, limit) + } + } catch (e: Exception) { + logger.warn("搜索市场失败: keyword=$keyword, seriesId=$seriesId, sportSlug=$sportSlug, tagId=$tagId, error=${e.message}") + emptyList() + } + } + + /** + * 解析体育联赛配置(seriesId 用于单场比赛,tagId 用于长期市场) + */ + private suspend fun resolveSportCategory( + seriesId: String?, + tagId: String?, + sportSlug: String? + ): SportCategoryResult? { + val sports = getSportsCategoriesCached() + seriesId?.takeIf { it.isNotBlank() }?.let { sid -> + sports.find { it.seriesId == sid }?.let { return it } + } + sportSlug?.trim()?.takeIf { it.isNotBlank() }?.let { slug -> + sports.find { it.slug.equals(slug, ignoreCase = true) }?.let { return it } + } + tagId?.takeIf { it.isNotBlank() }?.let { tid -> + sports.find { it.tagId == tid }?.let { return it } + } + return null + } + + /** + * 体育联赛:单场比赛(series events)+ 长期市场(tag markets)合并返回 + */ + private suspend fun searchSportsLeagueMarkets( + keyword: String, + sport: SportCategoryResult, + limit: Int + ): List { + val seriesId = sport.seriesId ?: return searchMarketsByTag(keyword, sport.tagId, limit) + val gameLimit = ((limit * 2) / 3).coerceIn(50, limit) + val seasonLimit = (limit - gameLimit).coerceAtLeast(30) + val games = searchMarketsBySeries(keyword, seriesId, gameLimit) + val seenIds = games.map { it.conditionId }.toMutableSet() + val seasons = searchMarketsByTag(keyword, sport.tagId, seasonLimit) + .filter { seenIds.add(it.conditionId) } + return games + seasons + } + + private suspend fun getSportsCategoriesCached(): List { + sportsCategoriesCache.getIfPresent("all")?.let { return it } + val fresh = fetchSportsCategoriesFromApi() + sportsCategoriesCache.put("all", fresh) + return fresh + } + + private suspend fun searchMarketsByTag(keyword: String, tagId: String?, limit: Int): List { + val gammaApi = retrofitFactory.createGammaApi() + val response = gammaApi.listMarkets( + title = keyword, + tagId = tagId, + closed = false, + active = true, + limit = limit + ) + if (!response.isSuccessful || response.body().isNullOrEmpty()) return emptyList() + return response.body()!!.mapNotNull { m -> + m.toMarketSearchResult(marketType = MarketSearchResult.TYPE_SEASON) + } + } + + /** + * 体育联赛:通过 series_id 拉取 events,展开其中活跃 markets(单场比赛、让分等) + */ + private suspend fun searchMarketsBySeries(keyword: String, seriesId: String, limit: Int): List { + val gammaApi = retrofitFactory.createGammaApi() + val kw = keyword.trim().lowercase() + val results = mutableListOf() + val seenConditionIds = mutableSetOf() + var offset = 0 + val pageSize = 30 + while (results.size < limit && offset < 300) { + val response = gammaApi.listEvents( + seriesId = seriesId, + closed = false, + active = true, + limit = pageSize, + offset = offset, + order = "volume", + ascending = false + ) + if (!response.isSuccessful || response.body().isNullOrEmpty()) break + val events = response.body()!! + if (events.isEmpty()) break + for (event in events) { + val eventTitle = event.title?.trim().orEmpty() + for (market in event.markets.orEmpty()) { + if (market.active != true || market.closed == true) continue + val item = market.toMarketSearchResult( + eventTitle = eventTitle, + eventSlug = event.slug, + eventCategory = event.category, + eventImage = event.image, + eventIcon = event.icon, + marketType = MarketSearchResult.TYPE_GAME + ) ?: continue + if (!seenConditionIds.add(item.conditionId)) continue + if (kw.length >= 2) { + val haystack = "${eventTitle} ${item.title}".lowercase() + if (!haystack.contains(kw)) continue + } + results.add(item) + if (results.size >= limit) return results + } + } + offset += pageSize + if (events.size < pageSize) break + } + return results + } + + private fun pickImageUrl(image: String?, icon: String?): String? { + return image?.trim()?.takeIf { it.isNotBlank() } + ?: icon?.trim()?.takeIf { it.isNotBlank() } + } + + private fun MarketResponse.toMarketSearchResult( + eventTitle: String = "", + eventSlug: String? = null, + eventCategory: String? = null, + eventImage: String? = null, + eventIcon: String? = null, + marketType: String = MarketSearchResult.TYPE_SEASON + ): MarketSearchResult? { + val conditionId = conditionId ?: return null + val question = question?.trim().orEmpty() + val displayEventTitle = if (marketType == MarketSearchResult.TYPE_GAME) { + eventTitle.takeIf { it.isNotBlank() && !question.contains(it, ignoreCase = true) } + } else { + null + } + val marketImageUrl = pickImageUrl(image, icon) + val eventImageUrl = if (marketType == MarketSearchResult.TYPE_GAME) { + pickImageUrl(eventImage, eventIcon) ?: marketImageUrl + } else { + null + } + return MarketSearchResult( + conditionId = conditionId, + title = question, + slug = slug ?: eventSlug, + category = category ?: eventCategory, + volume = volume, + outcomes = outcomes, + eventTitle = displayEventTitle, + marketType = marketType, + image = marketImageUrl, + icon = icon?.trim()?.takeIf { it.isNotBlank() }, + eventImage = eventImageUrl + ) + } + + private val sportNameMap = mapOf( + "ncaab" to "NCAA Basketball", "epl" to "EPL", "lal" to "La Liga", + "ipl" to "IPL Cricket", "wnba" to "WNBA", "bun" to "Bundesliga", + "mlb" to "MLB", "cfb" to "CFB", "nfl" to "NFL", + "fl1" to "Ligue 1", "sea" to "Serie A", "ucl" to "Champions League", + "afc" to "AFC", "ofc" to "OFC", "acn" to "Africa Cup of Nations", + "ncaaw" to "NCAA Women's BB", "clp" to "Copa Libertadores", + "mls" to "MLS", "nba" to "NBA", "nhl" to "NHL" + ) + + private val popularSportSlugs = listOf("nba", "nfl", "mlb", "nhl", "epl", "ucl", "lal", "serie-a", "sea", "bun") + + /** + * 获取体育联赛子分类列表(仅含具备 seriesId 的联赛,用于拉取单场比赛盘口) + */ + suspend fun listSportsCategories(): List { + return try { + getSportsCategoriesCached() + } catch (e: Exception) { + logger.warn("获取体育分类失败: ${e.message}") + emptyList() + } + } + + private suspend fun fetchSportsCategoriesFromApi(): List { + val gammaApi = retrofitFactory.createGammaApi() + val response = gammaApi.listSports() + if (!response.isSuccessful || response.body().isNullOrEmpty()) return emptyList() + return response.body()!!.mapNotNull { sport -> + val slug = sport.sport ?: return@mapNotNull null + val series = sport.series?.trim().orEmpty() + if (series.isEmpty()) return@mapNotNull null + val tags = (sport.tags ?: "").split(",") + val specificTag = tags.firstOrNull { it != "1" && it != "100639" } ?: return@mapNotNull null + SportCategoryResult( + id = sport.id ?: return@mapNotNull null, + slug = slug, + label = sportNameMap[slug] ?: slug.uppercase(), + tagId = specificTag, + seriesId = series, + image = sport.image + ) + }.sortedBy { sport -> + val idx = popularSportSlugs.indexOf(sport.slug) + if (idx >= 0) idx else Int.MAX_VALUE + } + } } /** @@ -291,3 +524,35 @@ data class MarketInfoByTokenId( val outcomeIndex: Int, val outcome: String? = null ) + +data class MarketSearchResult( + val conditionId: String, + val title: String, + val slug: String? = null, + val category: String? = null, + val volume: String? = null, + val outcomes: String? = null, + /** 所属赛事/对阵(体育单场比赛) */ + val eventTitle: String? = null, + /** game=单场赛事盘口,season=长期/赛季类市场 */ + val marketType: String = TYPE_SEASON, + /** 市场封面图(优先 image,无则 icon) */ + val image: String? = null, + val icon: String? = null, + /** 所属赛事封面(体育单场比赛分组用) */ + val eventImage: String? = null +) { + companion object { + const val TYPE_GAME = "game" + const val TYPE_SEASON = "season" + } +} + +data class SportCategoryResult( + val id: Int, + val slug: String, + val label: String, + val tagId: String, + val seriesId: String? = null, + val image: String? = null +) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/whalemonitor/WhaleMonitorLifecycleService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/whalemonitor/WhaleMonitorLifecycleService.kt new file mode 100644 index 0000000..2862a91 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/whalemonitor/WhaleMonitorLifecycleService.kt @@ -0,0 +1,56 @@ +package com.wrbug.polymarketbot.service.whalemonitor + +import com.wrbug.polymarketbot.event.WhaleMonitorStrategyChangedEvent +import jakarta.annotation.PostConstruct +import jakarta.annotation.PreDestroy +import org.slf4j.LoggerFactory +import org.springframework.context.event.EventListener +import org.springframework.stereotype.Service + +/** + * 大单监听生命周期管理 + * 启动时加载已启用策略、监听策略变更事件重载 WS 配置 + */ +@Service +class WhaleMonitorLifecycleService( + private val strategyService: WhaleMonitorStrategyService, + private val wsService: WhaleMonitorWsService +) { + + private val logger = LoggerFactory.getLogger(WhaleMonitorLifecycleService::class.java) + + @PostConstruct + fun init() { + try { + val strategies = strategyService.getEnabledStrategies() + if (strategies.isNotEmpty()) { + logger.info("大单监听: 加载 ${strategies.size} 个已启用策略") + wsService.start(strategies) + } else { + logger.info("大单监听: 没有已启用的策略") + } + } catch (e: Exception) { + logger.error("大单监听: 初始化失败: ${e.message}", e) + } + } + + @EventListener + fun onStrategyChanged(event: WhaleMonitorStrategyChangedEvent) { + logger.info("大单监听: 策略变更,重新加载") + try { + val strategies = strategyService.getEnabledStrategies() + if (strategies.isNotEmpty()) { + wsService.start(strategies) + } else { + wsService.stop() + } + } catch (e: Exception) { + logger.error("大单监听: 重载策略失败: ${e.message}", e) + } + } + + @PreDestroy + fun destroy() { + wsService.stop() + } +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/whalemonitor/WhaleMonitorOrderExecutionService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/whalemonitor/WhaleMonitorOrderExecutionService.kt new file mode 100644 index 0000000..c0918f5 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/whalemonitor/WhaleMonitorOrderExecutionService.kt @@ -0,0 +1,204 @@ +package com.wrbug.polymarketbot.service.whalemonitor + +import com.wrbug.polymarketbot.api.NewOrderRequest +import com.wrbug.polymarketbot.entity.Account +import com.wrbug.polymarketbot.entity.WhaleMonitorStrategy +import com.wrbug.polymarketbot.entity.WhaleMonitorTrigger +import com.wrbug.polymarketbot.repository.AccountRepository +import com.wrbug.polymarketbot.repository.WhaleMonitorTriggerRepository +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.toSafeBigDecimal +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.math.BigDecimal +import java.math.RoundingMode + +@Service +class WhaleMonitorOrderExecutionService( + private val accountRepository: AccountRepository, + private val triggerRepository: WhaleMonitorTriggerRepository, + private val clobService: PolymarketClobService, + private val orderSigningService: OrderSigningService, + private val retrofitFactory: RetrofitFactory, + private val cryptoUtils: CryptoUtils +) { + + private val logger = LoggerFactory.getLogger(WhaleMonitorOrderExecutionService::class.java) + + private val mutexMap = java.util.concurrent.ConcurrentHashMap() + + suspend fun executeOrder( + strategy: WhaleMonitorStrategy, + conditionId: String, + tokenId: String, + triggerVolume: BigDecimal + ) { + val mutexKey = "${strategy.id}-$tokenId" + val mutex = mutexMap.computeIfAbsent(mutexKey) { Mutex() } + mutex.withLock { + try { + doExecuteOrder(strategy, conditionId, tokenId, triggerVolume) + } catch (e: Exception) { + logger.error("大单监听下单执行失败 strategyId=${strategy.id} tokenId=$tokenId: ${e.message}", e) + saveTriggerRecord( + strategy = strategy, + conditionId = conditionId, + tokenId = tokenId, + triggerVolume = triggerVolume, + orderPrice = BigDecimal.ZERO, + orderSize = BigDecimal.ZERO, + orderAmount = BigDecimal.ZERO, + orderId = null, + status = "fail", + failReason = e.message?.take(500) + ) + } + } + } + + private suspend fun doExecuteOrder( + strategy: WhaleMonitorStrategy, + conditionId: String, + tokenId: String, + triggerVolume: BigDecimal + ) { + val account = accountRepository.findById(strategy.accountId).orElse(null) + if (account == null) { + logger.error("大单监听: 账户不存在 accountId=${strategy.accountId}") + return + } + if (account.apiKey.isNullOrBlank() || account.apiSecret.isNullOrBlank() || account.apiPassphrase.isNullOrBlank()) { + logger.error("大单监听: 账户 API 凭证未配置 accountId=${strategy.accountId}") + return + } + + val orderbookResult = clobService.getOrderbookByTokenId(tokenId) + if (orderbookResult.isFailure) { + logger.error("大单监听: 获取订单簿失败 tokenId=$tokenId") + return + } + val orderbook = orderbookResult.getOrThrow() + val asks = orderbook.asks + if (asks.isNullOrEmpty()) { + logger.info("大单监听: 订单簿无卖单,跳过 tokenId=$tokenId") + return + } + val bestAsk = asks.minOfOrNull { it.price.toSafeBigDecimal() } ?: return + + if (bestAsk < strategy.minPrice || bestAsk > strategy.maxPrice) { + logger.info("大单监听: 价格 $bestAsk 不在区间 [${strategy.minPrice}, ${strategy.maxPrice}],跳过 tokenId=$tokenId") + saveTriggerRecord( + strategy = strategy, + conditionId = conditionId, + tokenId = tokenId, + triggerVolume = triggerVolume, + orderPrice = bestAsk, + orderSize = BigDecimal.ZERO, + orderAmount = BigDecimal.ZERO, + orderId = null, + status = "fail", + failReason = "价格 $bestAsk 不在区间 [${strategy.minPrice}, ${strategy.maxPrice}]" + ) + return + } + + val orderAmount = strategy.orderAmount + val orderSize = orderAmount.divide(bestAsk, 0, RoundingMode.UP).coerceAtLeast(BigDecimal.ONE) + + val decryptedPrivateKey = cryptoUtils.decrypt(account.privateKey) + val signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType) + + val signedOrder = orderSigningService.createAndSignOrder( + privateKey = decryptedPrivateKey, + makerAddress = account.proxyAddress, + tokenId = tokenId, + side = "BUY", + price = bestAsk.toPlainString(), + size = orderSize.toPlainString(), + signatureType = signatureType + ) + + val newOrderRequest = NewOrderRequest( + order = signedOrder, + owner = account.apiKey, + orderType = "FAK" + ) + + val apiSecret = cryptoUtils.decrypt(account.apiSecret) + val apiPassphrase = cryptoUtils.decrypt(account.apiPassphrase) + val clobApi = retrofitFactory.createClobApi( + account.apiKey, apiSecret, apiPassphrase, account.walletAddress + ) + + val response = clobApi.createOrder(newOrderRequest) + if (response.isSuccessful) { + val body = response.body() + val orderId = body?.orderId + logger.info("大单监听: 下单成功 strategyId=${strategy.id} tokenId=$tokenId orderId=$orderId price=$bestAsk size=$orderSize") + saveTriggerRecord( + strategy = strategy, + conditionId = conditionId, + tokenId = tokenId, + triggerVolume = triggerVolume, + orderPrice = bestAsk, + orderSize = orderSize, + orderAmount = orderAmount, + orderId = orderId, + status = "success", + failReason = null + ) + } else { + val errorBody = response.errorBody()?.string()?.take(200) + logger.error("大单监听: 下单失败 strategyId=${strategy.id} tokenId=$tokenId code=${response.code()} body=$errorBody") + saveTriggerRecord( + strategy = strategy, + conditionId = conditionId, + tokenId = tokenId, + triggerVolume = triggerVolume, + orderPrice = bestAsk, + orderSize = orderSize, + orderAmount = orderAmount, + orderId = null, + status = "fail", + failReason = "下单失败: ${response.code()} $errorBody" + ) + } + } + + private fun saveTriggerRecord( + strategy: WhaleMonitorStrategy, + conditionId: String, + tokenId: String, + triggerVolume: BigDecimal, + orderPrice: BigDecimal, + orderSize: BigDecimal, + orderAmount: BigDecimal, + orderId: String?, + status: String, + failReason: String? + ) { + try { + val trigger = WhaleMonitorTrigger( + strategyId = strategy.id ?: return, + conditionId = conditionId, + tokenId = tokenId, + side = "BUY", + triggerVolume = triggerVolume, + orderPrice = orderPrice, + orderSize = orderSize, + orderAmount = orderAmount, + orderId = orderId, + status = status, + failReason = failReason + ) + triggerRepository.save(trigger) + } catch (e: Exception) { + logger.error("大单监听: 保存触发记录失败: ${e.message}", e) + } + } +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/whalemonitor/WhaleMonitorStrategyService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/whalemonitor/WhaleMonitorStrategyService.kt new file mode 100644 index 0000000..9a77f90 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/whalemonitor/WhaleMonitorStrategyService.kt @@ -0,0 +1,294 @@ +package com.wrbug.polymarketbot.service.whalemonitor + +import com.wrbug.polymarketbot.dto.* +import com.wrbug.polymarketbot.entity.WhaleMonitorStrategy +import com.wrbug.polymarketbot.entity.WhaleMonitorTrigger +import com.wrbug.polymarketbot.enums.ErrorCode +import com.wrbug.polymarketbot.event.WhaleMonitorStrategyChangedEvent +import com.wrbug.polymarketbot.repository.WhaleMonitorStrategyRepository +import com.wrbug.polymarketbot.repository.WhaleMonitorTriggerRepository +import com.wrbug.polymarketbot.util.fromJson +import com.wrbug.polymarketbot.util.gt +import com.wrbug.polymarketbot.util.toSafeBigDecimal +import com.wrbug.polymarketbot.util.toJson +import org.slf4j.LoggerFactory +import org.springframework.context.ApplicationEventPublisher +import org.springframework.data.domain.PageRequest +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.math.BigDecimal +import java.math.RoundingMode +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +@Service +class WhaleMonitorStrategyService( + private val strategyRepository: WhaleMonitorStrategyRepository, + private val triggerRepository: WhaleMonitorTriggerRepository, + private val eventPublisher: ApplicationEventPublisher +) { + + private val logger = LoggerFactory.getLogger(WhaleMonitorStrategyService::class.java) + + @Transactional + fun create(request: WhaleMonitorStrategyCreateRequest): Result { + return try { + if (request.accountId <= 0) { + return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ACCOUNT_ID_INVALID.messageKey)) + } + if (request.conditionIds.isEmpty()) { + return Result.failure(IllegalArgumentException(ErrorCode.WHALE_MONITOR_STRATEGY_CONDITION_IDS_EMPTY.messageKey)) + } + if (request.windowSeconds <= 0) { + return Result.failure(IllegalArgumentException(ErrorCode.WHALE_MONITOR_STRATEGY_WINDOW_INVALID.messageKey)) + } + val thresholdAmount = request.thresholdAmount.toSafeBigDecimal() + if (thresholdAmount <= BigDecimal.ZERO) { + return Result.failure(IllegalArgumentException(ErrorCode.WHALE_MONITOR_STRATEGY_THRESHOLD_INVALID.messageKey)) + } + val orderAmount = request.orderAmount.toSafeBigDecimal() + if (orderAmount <= BigDecimal.ZERO) { + return Result.failure(IllegalArgumentException(ErrorCode.WHALE_MONITOR_STRATEGY_AMOUNT_INVALID.messageKey)) + } + if (request.cooldownSeconds <= 0) { + return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey)) + } + val minPrice = request.minPrice.toSafeBigDecimal() + val maxPrice = request.maxPrice.toSafeBigDecimal() + if (minPrice < BigDecimal.ZERO || maxPrice > BigDecimal.ONE || minPrice > maxPrice) { + return Result.failure(IllegalArgumentException(ErrorCode.WHALE_MONITOR_STRATEGY_PRICE_INVALID.messageKey)) + } + validatePriceDecimalPlaces(minPrice, "minPrice") + validatePriceDecimalPlaces(maxPrice, "maxPrice") + + val conditionIdsJson = request.conditionIds.toJson() + val nameToSave = request.name?.takeIf { it.isNotBlank() } + ?: generateStrategyName() + + val entity = WhaleMonitorStrategy( + accountId = request.accountId, + name = nameToSave, + conditionIds = conditionIdsJson, + windowSeconds = request.windowSeconds, + thresholdAmount = thresholdAmount, + orderAmount = orderAmount, + minPrice = minPrice, + maxPrice = maxPrice, + cooldownSeconds = request.cooldownSeconds, + enabled = request.enabled + ) + val saved = strategyRepository.save(entity) + eventPublisher.publishEvent(WhaleMonitorStrategyChangedEvent(this)) + Result.success(entityToDto(saved, null, 0L)) + } catch (e: IllegalArgumentException) { + Result.failure(e) + } catch (e: Exception) { + logger.error("创建大单监听策略失败: ${e.message}", e) + Result.failure(e) + } + } + + @Transactional + fun update(request: WhaleMonitorStrategyUpdateRequest): Result { + return try { + val existing = strategyRepository.findById(request.strategyId).orElse(null) + ?: return Result.failure(IllegalArgumentException(ErrorCode.WHALE_MONITOR_STRATEGY_NOT_FOUND.messageKey)) + + val conditionIdsJson = request.conditionIds?.let { + if (it.isEmpty()) return Result.failure(IllegalArgumentException(ErrorCode.WHALE_MONITOR_STRATEGY_CONDITION_IDS_EMPTY.messageKey)) + it.toJson() + } ?: existing.conditionIds + + val windowSeconds = request.windowSeconds ?: existing.windowSeconds + if (windowSeconds <= 0) { + return Result.failure(IllegalArgumentException(ErrorCode.WHALE_MONITOR_STRATEGY_WINDOW_INVALID.messageKey)) + } + + val thresholdAmount = request.thresholdAmount?.toSafeBigDecimal() ?: existing.thresholdAmount + if (thresholdAmount <= BigDecimal.ZERO) { + return Result.failure(IllegalArgumentException(ErrorCode.WHALE_MONITOR_STRATEGY_THRESHOLD_INVALID.messageKey)) + } + val orderAmount = request.orderAmount?.toSafeBigDecimal() ?: existing.orderAmount + if (orderAmount <= BigDecimal.ZERO) { + return Result.failure(IllegalArgumentException(ErrorCode.WHALE_MONITOR_STRATEGY_AMOUNT_INVALID.messageKey)) + } + + val minPrice = request.minPrice?.toSafeBigDecimal() ?: existing.minPrice + val maxPrice = request.maxPrice?.toSafeBigDecimal() ?: existing.maxPrice + if (minPrice < BigDecimal.ZERO || maxPrice > BigDecimal.ONE || minPrice > maxPrice) { + return Result.failure(IllegalArgumentException(ErrorCode.WHALE_MONITOR_STRATEGY_PRICE_INVALID.messageKey)) + } + validatePriceDecimalPlaces(minPrice, "minPrice") + validatePriceDecimalPlaces(maxPrice, "maxPrice") + + val cooldownSeconds = request.cooldownSeconds ?: existing.cooldownSeconds + if (cooldownSeconds <= 0) { + return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey)) + } + + val nameToSave = request.name?.takeIf { it.isNotBlank() } + ?: existing.name?.takeIf { it.isNotBlank() } + ?: generateStrategyName() + + val updated = existing.copy( + name = nameToSave, + conditionIds = conditionIdsJson, + windowSeconds = windowSeconds, + thresholdAmount = thresholdAmount, + orderAmount = orderAmount, + minPrice = minPrice, + maxPrice = maxPrice, + cooldownSeconds = cooldownSeconds, + enabled = request.enabled ?: existing.enabled, + updatedAt = System.currentTimeMillis() + ) + val saved = strategyRepository.save(updated) + eventPublisher.publishEvent(WhaleMonitorStrategyChangedEvent(this)) + val lastTrigger = triggerRepository.findAllByStrategyIdOrderByCreatedAtDesc(saved.id!!, PageRequest.of(0, 1)) + .content.firstOrNull()?.createdAt + val triggerCount = triggerRepository.countByStrategyId(saved.id!!) + Result.success(entityToDto(saved, lastTrigger, triggerCount)) + } catch (e: IllegalArgumentException) { + Result.failure(e) + } catch (e: Exception) { + logger.error("更新大单监听策略失败: ${e.message}", e) + Result.failure(e) + } + } + + @Transactional + fun delete(strategyId: Long): Result { + return try { + if (!strategyRepository.existsById(strategyId)) { + return Result.failure(IllegalArgumentException(ErrorCode.WHALE_MONITOR_STRATEGY_NOT_FOUND.messageKey)) + } + strategyRepository.deleteById(strategyId) + eventPublisher.publishEvent(WhaleMonitorStrategyChangedEvent(this)) + Result.success(Unit) + } catch (e: Exception) { + logger.error("删除大单监听策略失败: ${e.message}", e) + Result.failure(e) + } + } + + fun list(request: WhaleMonitorStrategyListRequest): Result { + return try { + val list = when { + request.accountId != null && request.enabled != null -> strategyRepository.findByAccountIdAndEnabled(request.accountId, request.enabled) + request.accountId != null -> strategyRepository.findAllByAccountId(request.accountId) + request.enabled == true -> strategyRepository.findAllByEnabledTrue() + request.enabled == false -> strategyRepository.findAll().filter { !it.enabled } + else -> strategyRepository.findAll() + } + val dtos = list.map { entity -> + val lastTrigger = if (entity.id != null) { + triggerRepository.findAllByStrategyIdOrderByCreatedAtDesc(entity.id, PageRequest.of(0, 1)) + .content.firstOrNull()?.createdAt + } else null + val triggerCount = if (entity.id != null) triggerRepository.countByStrategyId(entity.id) else 0L + entityToDto(entity, lastTrigger, triggerCount) + } + Result.success(WhaleMonitorStrategyListResponse(list = dtos)) + } catch (e: Exception) { + logger.error("查询大单监听策略列表失败: ${e.message}", e) + Result.failure(e) + } + } + + fun getTriggerRecords(request: WhaleMonitorTriggerListRequest): Result { + return try { + val page = PageRequest.of((request.page - 1).coerceAtLeast(0), request.pageSize.coerceIn(1, 100)) + val startTs = request.startDate ?: 0L + val endTs = request.endDate ?: Long.MAX_VALUE + val useTimeRange = request.startDate != null || request.endDate != null + val pageResult = when { + useTimeRange && !request.status.isNullOrBlank() -> + triggerRepository.findAllByStrategyIdAndStatusAndCreatedAtBetweenOrderByCreatedAtDesc( + request.strategyId, request.status, startTs, endTs, page + ) + useTimeRange -> + triggerRepository.findAllByStrategyIdAndCreatedAtBetweenOrderByCreatedAtDesc( + request.strategyId, startTs, endTs, page + ) + !request.status.isNullOrBlank() -> + triggerRepository.findAllByStrategyIdAndStatusOrderByCreatedAtDesc(request.strategyId, request.status, page) + else -> + triggerRepository.findAllByStrategyIdOrderByCreatedAtDesc(request.strategyId, page) + } + val list = pageResult.content.map { triggerToDto(it) } + val total = when { + useTimeRange && !request.status.isNullOrBlank() -> + triggerRepository.countByStrategyIdAndStatusAndCreatedAtBetween(request.strategyId, request.status, startTs, endTs) + useTimeRange -> + triggerRepository.countByStrategyIdAndCreatedAtBetween(request.strategyId, startTs, endTs) + !request.status.isNullOrBlank() -> + triggerRepository.countByStrategyIdAndStatus(request.strategyId, request.status) + else -> + pageResult.totalElements + } + Result.success(WhaleMonitorTriggerListResponse(list = list, total = total)) + } catch (e: Exception) { + logger.error("查询大单监听触发记录失败: ${e.message}", e) + Result.failure(e) + } + } + + fun getEnabledStrategies(): List = strategyRepository.findAllByEnabledTrue() + + fun getStrategy(strategyId: Long): WhaleMonitorStrategy? = strategyRepository.findById(strategyId).orElse(null) + + private fun validatePriceDecimalPlaces(value: BigDecimal, fieldName: String) { + val scale = value.stripTrailingZeros().scale() + if (scale > 2) { + throw IllegalArgumentException(ErrorCode.WHALE_MONITOR_STRATEGY_PRICE_INVALID.messageKey) + } + } + + private fun generateStrategyName(): String { + val suffix = Instant.now().atZone(ZoneId.systemDefault()) + .format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")) + return "大单监听策略-$suffix" + } + + private fun entityToDto(e: WhaleMonitorStrategy, lastTriggerAt: Long?, triggerCount: Long): WhaleMonitorStrategyDto { + val conditionIdList: List = try { + e.conditionIds.fromJson>() ?: emptyList() + } catch (_: Exception) { + emptyList() + } + return WhaleMonitorStrategyDto( + id = e.id ?: 0L, + accountId = e.accountId, + name = e.name, + conditionIds = conditionIdList, + windowSeconds = e.windowSeconds, + thresholdAmount = e.thresholdAmount.toPlainString(), + orderAmount = e.orderAmount.toPlainString(), + minPrice = e.minPrice.toPlainString(), + maxPrice = e.maxPrice.toPlainString(), + cooldownSeconds = e.cooldownSeconds, + enabled = e.enabled, + lastTriggerAt = lastTriggerAt, + triggerCount = triggerCount, + createdAt = e.createdAt, + updatedAt = e.updatedAt + ) + } + + private fun triggerToDto(t: WhaleMonitorTrigger): WhaleMonitorTriggerDto = WhaleMonitorTriggerDto( + id = t.id ?: 0L, + strategyId = t.strategyId, + conditionId = t.conditionId, + tokenId = t.tokenId, + side = t.side, + triggerVolume = t.triggerVolume.toPlainString(), + orderPrice = t.orderPrice.toPlainString(), + orderSize = t.orderSize.toPlainString(), + orderAmount = t.orderAmount.toPlainString(), + orderId = t.orderId, + status = t.status, + failReason = t.failReason, + createdAt = t.createdAt + ) +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/whalemonitor/WhaleMonitorWsService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/whalemonitor/WhaleMonitorWsService.kt new file mode 100644 index 0000000..62d2098 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/whalemonitor/WhaleMonitorWsService.kt @@ -0,0 +1,301 @@ +package com.wrbug.polymarketbot.service.whalemonitor + +import com.github.benmanes.caffeine.cache.Cache +import com.github.benmanes.caffeine.cache.Caffeine +import com.wrbug.polymarketbot.dto.ActivityTradeMessage +import com.wrbug.polymarketbot.entity.WhaleMonitorStrategy +import com.wrbug.polymarketbot.constants.PolymarketConstants +import com.wrbug.polymarketbot.util.fromJson +import com.wrbug.polymarketbot.util.toSafeBigDecimal +import com.wrbug.polymarketbot.util.toJson +import com.wrbug.polymarketbot.websocket.PolymarketWebSocketClient +import jakarta.annotation.PreDestroy +import kotlinx.coroutines.* +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.math.BigDecimal +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit + +private data class TradePoint(val tsMillis: Long, val notional: BigDecimal) +private data class AggKey(val conditionId: String, val tokenId: String, val side: String) + +/** + * 大单监听 WebSocket 服务 + * 订阅全局 Activity trades,按 conditionId 过滤,滑动窗口聚合 notional,达阈值触发下单 + */ +@Service +class WhaleMonitorWsService( + private val orderExecutionService: WhaleMonitorOrderExecutionService +) { + + private val logger = LoggerFactory.getLogger(WhaleMonitorWsService::class.java) + + private val websocketUrl: String = PolymarketConstants.ACTIVITY_WS_URL + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + private var wsClient: PolymarketWebSocketClient? = null + + /** conditionId -> 关联的策略列表 */ + private val conditionIdStrategies = ConcurrentHashMap>() + + /** 聚合窗口:每个 AggKey 维护一个滑动窗口 */ + private val windowBuffers = ConcurrentHashMap>() + private val runningSums = ConcurrentHashMap() + + /** 冷却:strategyId-tokenId -> 上次触发毫秒时间戳 */ + private val cooldownTimestamps = ConcurrentHashMap() + + /** txHash 去重 */ + private val processedTxHashes: Cache = Caffeine.newBuilder() + .maximumSize(500) + .expireAfterWrite(10, TimeUnit.MINUTES) + .build() + + @Volatile + private var isSubscribed = false + + private var cleanupJob: Job? = null + + fun start(strategies: List) { + rebuildConditionIdMap(strategies) + if (conditionIdStrategies.isEmpty()) { + logger.info("没有需要监听的市场,停止大单监听 WebSocket") + stop() + return + } + logger.info("启动大单监听 WebSocket,监控 ${conditionIdStrategies.size} 个市场") + connectAndSubscribe() + startCleanupTask() + } + + fun stop() { + logger.info("停止大单监听 WebSocket") + cleanupJob?.cancel() + cleanupJob = null + wsClient?.closeConnection() + wsClient = null + isSubscribed = false + conditionIdStrategies.clear() + windowBuffers.clear() + runningSums.clear() + cooldownTimestamps.clear() + processedTxHashes.invalidateAll() + } + + fun getMonitoredMarketCount(): Int = conditionIdStrategies.size + + private fun rebuildConditionIdMap(strategies: List) { + conditionIdStrategies.clear() + for (strategy in strategies) { + addStrategyToMap(strategy) + } + } + + private fun addStrategyToMap(strategy: WhaleMonitorStrategy) { + val ids: List = try { + strategy.conditionIds.fromJson>() ?: emptyList() + } catch (_: Exception) { + emptyList() + } + for (cid in ids) { + conditionIdStrategies.computeIfAbsent(cid) { mutableListOf() }.add(strategy) + } + } + + private fun connectAndSubscribe() { + val existingClient = wsClient + if (existingClient != null && existingClient.isConnected()) { + if (!isSubscribed) subscribe() + return + } + + logger.info("连接大单监听 Activity WebSocket: $websocketUrl") + val newClient = PolymarketWebSocketClient( + url = websocketUrl, + sessionId = "whale-monitor-activity", + onMessage = { message -> handleMessage(message) }, + onOpen = { + logger.info("大单监听 WebSocket 连接成功") + subscribe() + }, + onReconnect = { + logger.info("大单监听 WebSocket 重连成功,重新订阅") + subscribe() + } + ) + wsClient = newClient + scope.launch { + try { + newClient.connect() + } catch (e: Exception) { + logger.error("连接大单监听 WebSocket 失败", e) + } + } + } + + private fun subscribe() { + val client = wsClient ?: return + if (!client.isConnected()) return + try { + val subscribeMessage = """ + { + "action": "subscribe", + "subscriptions": [ + { + "topic": "activity", + "type": "trades" + } + ] + } + """.trimIndent() + client.sendMessage(subscribeMessage) + isSubscribed = true + logger.info("大单监听 WebSocket 订阅成功(全局交易流: trades)") + } catch (e: Exception) { + logger.error("订阅大单监听 WebSocket 失败", e) + isSubscribed = false + } + } + + private fun handleMessage(message: String) { + try { + if (message.trim() == "PONG" || message.trim() == "pong") return + + if (!fastFilterByConditionId(message)) return + + val tradeMessage = message.fromJson() ?: return + + if (tradeMessage.topic != "activity" || tradeMessage.type != "trades") return + + val payload = tradeMessage.payload + + val txHash = payload.transactionHash + if (!txHash.isNullOrBlank()) { + val now = System.currentTimeMillis() + val existing = processedTxHashes.asMap().putIfAbsent(txHash, now) + if (existing != null) return + } + + val side = payload.side?.uppercase() ?: return + if (side != "BUY") return + + val conditionId = payload.conditionId ?: return + val tokenId = payload.asset ?: return + if (conditionId.isBlank() || tokenId.isBlank()) return + + val strategies = conditionIdStrategies[conditionId] ?: return + + val price = convertToBigDecimal(payload.price) ?: return + val size = convertToBigDecimal(payload.size) ?: return + val notional = price.multiply(size) + + val aggKey = AggKey(conditionId, tokenId, side) + + for (strategy in strategies) { + addToWindow(strategy, aggKey, notional) + } + } catch (e: Exception) { + logger.error("大单监听处理消息失败: ${e.message}", e) + } + } + + /** + * 快速字符串级别过滤:检查消息中是否包含任一监听的 conditionId + */ + private fun fastFilterByConditionId(message: String): Boolean { + if (message.length < 50) return false + for (cid in conditionIdStrategies.keys) { + if (message.contains("\"conditionId\":\"$cid\"")) return true + } + return false + } + + private fun addToWindow(strategy: WhaleMonitorStrategy, key: AggKey, notional: BigDecimal) { + val now = System.currentTimeMillis() + val windowMs = strategy.windowSeconds.toLong() * 1000 + + val deque = windowBuffers.computeIfAbsent(key) { ArrayDeque() } + val sum = runningSums.computeIfAbsent(key) { BigDecimal.ZERO } + + // 过期清理 + while (deque.isNotEmpty() && deque.first().tsMillis < now - windowMs) { + val expired = deque.removeFirst() + runningSums[key] = sum.subtract(expired.notional) + } + + // 入队 + deque.addLast(TradePoint(now, notional)) + runningSums[key] = sum.add(notional) + + val currentSum = runningSums[key] ?: BigDecimal.ZERO + + // 阈值检查 + if (currentSum >= strategy.thresholdAmount) { + val cooldownKey = "${strategy.id}-${key.tokenId}" + val lastTrigger = cooldownTimestamps[cooldownKey] ?: 0L + if (now - lastTrigger >= strategy.cooldownSeconds.toLong() * 1000) { + cooldownTimestamps[cooldownKey] = now + logger.info("大单监听触发: strategyId=${strategy.id} tokenId=${key.tokenId} volume=$currentSum threshold=${strategy.thresholdAmount}") + scope.launch { + try { + orderExecutionService.executeOrder( + strategy = strategy, + conditionId = key.conditionId, + tokenId = key.tokenId, + triggerVolume = currentSum + ) + } catch (e: Exception) { + logger.error("大单监听下单执行异常: strategyId=${strategy.id} tokenId=${key.tokenId}: ${e.message}", e) + } + } + } + } + } + + private fun startCleanupTask() { + cleanupJob?.cancel() + cleanupJob = scope.launch { + while (isActive) { + delay(60_000) + cleanupExpiredEntries() + } + } + } + + private fun cleanupExpiredEntries() { + val now = System.currentTimeMillis() + val maxWindowMs = conditionIdStrategies.values.flatten().maxOfOrNull { it.windowSeconds.toLong() * 1000 } ?: 10_000 + + val iter = windowBuffers.entries.iterator() + while (iter.hasNext()) { + val entry = iter.next() + val deque = entry.value + while (deque.isNotEmpty() && deque.first().tsMillis < now - maxWindowMs) { + val expired = deque.removeFirst() + val currentSum = runningSums[entry.key] ?: BigDecimal.ZERO + runningSums[entry.key] = currentSum.subtract(expired.notional) + } + if (deque.isEmpty()) { + runningSums.remove(entry.key) + iter.remove() + } + } + } + + private fun convertToBigDecimal(value: Any?): BigDecimal? { + if (value == null) return null + return when (value) { + is String -> value.toSafeBigDecimal() + is Number -> BigDecimal(value.toString()) + is BigDecimal -> value + else -> value.toString().toSafeBigDecimal() + } + } + + @PreDestroy + fun destroy() { + stop() + scope.cancel() + } +} diff --git a/backend/src/main/resources/db/migration/V41__create_whale_monitor_strategy_tables.sql b/backend/src/main/resources/db/migration/V41__create_whale_monitor_strategy_tables.sql new file mode 100644 index 0000000..75e8568 --- /dev/null +++ b/backend/src/main/resources/db/migration/V41__create_whale_monitor_strategy_tables.sql @@ -0,0 +1,44 @@ +-- ============================================ +-- V41: 大单监听策略表 +-- ============================================ +CREATE TABLE IF NOT EXISTS whale_monitor_strategy ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '策略ID', + account_id BIGINT NOT NULL COMMENT '钱包账户ID', + name VARCHAR(255) DEFAULT NULL COMMENT '策略名称(可选,用于列表展示)', + condition_ids TEXT NOT NULL COMMENT '监听市场 conditionId 列表(JSON 数组)', + window_seconds INT NOT NULL DEFAULT 10 COMMENT '聚合窗口秒数', + threshold_amount DECIMAL(20, 8) NOT NULL COMMENT '触发阈值金额', + order_amount DECIMAL(20, 8) NOT NULL COMMENT '固定下单金额', + min_price DECIMAL(20, 8) NOT NULL DEFAULT 0 COMMENT '最低下单价 0~1', + max_price DECIMAL(20, 8) NOT NULL DEFAULT 1 COMMENT '最高下单价 0~1', + cooldown_seconds INT NOT NULL DEFAULT 60 COMMENT '冷却秒数(同一 tokenId 两次触发间隔)', + enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用: 0=停用, 1=启用', + created_at BIGINT NOT NULL COMMENT '创建时间', + updated_at BIGINT NOT NULL COMMENT '更新时间', + INDEX idx_account_id (account_id), + INDEX idx_enabled (enabled), + FOREIGN KEY (account_id) REFERENCES wallet_accounts(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='大单监听策略表'; + +-- ============================================ +-- 大单监听触发记录表 +-- ============================================ +CREATE TABLE IF NOT EXISTS whale_monitor_trigger ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '记录ID', + strategy_id BIGINT NOT NULL COMMENT '策略ID', + condition_id VARCHAR(128) NOT NULL COMMENT '市场 conditionId', + token_id VARCHAR(128) NOT NULL COMMENT '触发 tokenId', + side VARCHAR(10) NOT NULL DEFAULT 'BUY' COMMENT '方向', + trigger_volume DECIMAL(20, 8) NOT NULL COMMENT '触发时窗口累计金额', + order_price DECIMAL(20, 8) NOT NULL COMMENT '下单价格', + order_size DECIMAL(20, 8) NOT NULL COMMENT '下单数量', + order_amount DECIMAL(20, 8) NOT NULL COMMENT '下单金额', + order_id VARCHAR(128) DEFAULT NULL COMMENT '订单ID(成功时有值)', + status VARCHAR(20) NOT NULL DEFAULT 'success' COMMENT '状态: success, fail', + fail_reason VARCHAR(500) DEFAULT NULL COMMENT '失败原因', + created_at BIGINT NOT NULL COMMENT '创建时间', + INDEX idx_strategy_id (strategy_id), + INDEX idx_token (strategy_id, token_id), + INDEX idx_created_at (created_at), + FOREIGN KEY (strategy_id) REFERENCES whale_monitor_strategy(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='大单监听触发记录表'; diff --git a/backend/src/main/resources/i18n/messages_en.properties b/backend/src/main/resources/i18n/messages_en.properties index 58c3d18..f2e3269 100644 --- a/backend/src/main/resources/i18n/messages_en.properties +++ b/backend/src/main/resources/i18n/messages_en.properties @@ -304,6 +304,19 @@ error.server.crypto_tail_strategy_update_failed=Failed to update crypto spread s error.server.crypto_tail_strategy_delete_failed=Failed to delete crypto spread strategy error.server.crypto_tail_strategy_list_fetch_failed=Failed to fetch crypto spread strategy list error.server.crypto_tail_strategy_triggers_fetch_failed=Failed to fetch trigger records + +# Whale Monitor Strategy +error.whale_monitor_strategy_not_found=Whale monitor strategy not found +error.whale_monitor_strategy_condition_ids_empty=Monitored markets cannot be empty +error.whale_monitor_strategy_window_invalid=Time window must be greater than 0 +error.whale_monitor_strategy_threshold_invalid=Threshold amount must be greater than 0 +error.whale_monitor_strategy_amount_invalid=Order amount must be greater than 0 +error.whale_monitor_strategy_price_invalid=Invalid price range, must satisfy 0 <= minPrice <= maxPrice <= 1 +error.server.whale_monitor_strategy_create_failed=Failed to create whale monitor strategy +error.server.whale_monitor_strategy_update_failed=Failed to update whale monitor strategy +error.server.whale_monitor_strategy_delete_failed=Failed to delete whale monitor strategy +error.server.whale_monitor_strategy_list_fetch_failed=Failed to fetch whale monitor strategy list +error.server.whale_monitor_strategy_triggers_fetch_failed=Failed to fetch whale monitor trigger records # Backtest Management backtest.title=Backtest Management backtest.create_task=Create Backtest diff --git a/backend/src/main/resources/i18n/messages_zh_CN.properties b/backend/src/main/resources/i18n/messages_zh_CN.properties index 63eede7..c84a7a6 100644 --- a/backend/src/main/resources/i18n/messages_zh_CN.properties +++ b/backend/src/main/resources/i18n/messages_zh_CN.properties @@ -304,6 +304,19 @@ error.server.crypto_tail_strategy_update_failed=更新加密价差策略失败 error.server.crypto_tail_strategy_delete_failed=删除加密价差策略失败 error.server.crypto_tail_strategy_list_fetch_failed=查询加密价差策略列表失败 error.server.crypto_tail_strategy_triggers_fetch_failed=查询触发记录失败 + +# 大单监听策略 +error.whale_monitor_strategy_not_found=大单监听策略不存在 +error.whale_monitor_strategy_condition_ids_empty=监听市场不能为空 +error.whale_monitor_strategy_window_invalid=时间窗口必须大于0 +error.whale_monitor_strategy_threshold_invalid=触发阈值必须大于0 +error.whale_monitor_strategy_amount_invalid=下单金额必须大于0 +error.whale_monitor_strategy_price_invalid=价格区间无效,须满足 0 <= minPrice <= maxPrice <= 1 +error.server.whale_monitor_strategy_create_failed=创建大单监听策略失败 +error.server.whale_monitor_strategy_update_failed=更新大单监听策略失败 +error.server.whale_monitor_strategy_delete_failed=删除大单监听策略失败 +error.server.whale_monitor_strategy_list_fetch_failed=查询大单监听策略列表失败 +error.server.whale_monitor_strategy_triggers_fetch_failed=查询大单监听触发记录失败 # 回测管理 backtest.title=回测管理 backtest.create_task=新增回测 diff --git a/backend/src/main/resources/i18n/messages_zh_TW.properties b/backend/src/main/resources/i18n/messages_zh_TW.properties index 43d493a..1d86132 100644 --- a/backend/src/main/resources/i18n/messages_zh_TW.properties +++ b/backend/src/main/resources/i18n/messages_zh_TW.properties @@ -304,6 +304,19 @@ error.server.crypto_tail_strategy_update_failed=更新加密價差策略失敗 error.server.crypto_tail_strategy_delete_failed=刪除加密價差策略失敗 error.server.crypto_tail_strategy_list_fetch_failed=查詢加密價差策略列表失敗 error.server.crypto_tail_strategy_triggers_fetch_failed=查詢觸發記錄失敗 + +# 大單監聽策略 +error.whale_monitor_strategy_not_found=大單監聽策略不存在 +error.whale_monitor_strategy_condition_ids_empty=監聽市場不能為空 +error.whale_monitor_strategy_window_invalid=時間窗口必須大於0 +error.whale_monitor_strategy_threshold_invalid=觸發閾值必須大於0 +error.whale_monitor_strategy_amount_invalid=下單金額必須大於0 +error.whale_monitor_strategy_price_invalid=價格區間無效,須滿足 0 <= minPrice <= maxPrice <= 1 +error.server.whale_monitor_strategy_create_failed=創建大單監聽策略失敗 +error.server.whale_monitor_strategy_update_failed=更新大單監聽策略失敗 +error.server.whale_monitor_strategy_delete_failed=刪除大單監聽策略失敗 +error.server.whale_monitor_strategy_list_fetch_failed=查詢大單監聯策略列表失敗 +error.server.whale_monitor_strategy_triggers_fetch_failed=查詢大單監聯觸發記錄失敗 # 回測管理 backtest.title=回測管理 backtest.create_task=新增回測 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ae16f14..ae811d8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -37,6 +37,8 @@ import BacktestList from './pages/BacktestList' import BacktestDetail from './pages/BacktestDetail' import CryptoTailStrategyList from './pages/CryptoTailStrategyList' import CryptoTailMonitor from './pages/CryptoTailMonitor' +import WhaleMonitorStrategyList from './pages/WhaleMonitorStrategyList' +import WhaleMonitorMarketSelect from './pages/WhaleMonitorMarketSelect' import { wsManager } from './services/websocket' import type { OrderPushMessage } from './types' import { apiService } from './services/api' @@ -273,6 +275,8 @@ function App() { } /> } /> } /> + } /> + } /> } /> {/* 保留旧路由以保持向后兼容 */} } /> diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 7f054a6..6ef23d6 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -23,7 +23,8 @@ import { NotificationOutlined, LineChartOutlined, RocketOutlined, - DashboardOutlined + DashboardOutlined, + EyeOutlined } from '@ant-design/icons' import type { MenuProps } from 'antd' import type { ReactNode } from 'react' @@ -81,14 +82,17 @@ const Layout: React.FC = ({ children }) => { if (path.startsWith('/crypto-tail-strategy') || path.startsWith('/crypto-tail-monitor')) { keys.push('/crypto-tail-management') } + if (path.startsWith('/whale-monitor-strategy')) { + keys.push('/whale-monitor-management') + } if (path.startsWith('/system-settings')) { keys.push('/system-settings') } return keys } - + const [openKeys, setOpenKeys] = useState(getInitialOpenKeys()) - + // 当路径变化时,自动打开对应的父菜单 useEffect(() => { const path = location.pathname @@ -99,6 +103,9 @@ const Layout: React.FC = ({ children }) => { if (path.startsWith('/crypto-tail-strategy') || path.startsWith('/crypto-tail-monitor')) { keys.push('/crypto-tail-management') } + if (path.startsWith('/whale-monitor-strategy')) { + keys.push('/whale-monitor-management') + } if (path.startsWith('/system-settings')) { keys.push('/system-settings') } @@ -183,6 +190,18 @@ const Layout: React.FC = ({ children }) => { } ] }, + { + key: '/whale-monitor-management', + icon: , + label: t('menu.whaleMonitorStrategy'), + children: [ + { + key: '/whale-monitor-strategy', + icon: , + label: t('menu.whaleMonitorStrategyConfig') + } + ] + }, { key: '/positions', icon: , diff --git a/frontend/src/components/WhaleMonitorMarketGroupedList.tsx b/frontend/src/components/WhaleMonitorMarketGroupedList.tsx new file mode 100644 index 0000000..993f3c8 --- /dev/null +++ b/frontend/src/components/WhaleMonitorMarketGroupedList.tsx @@ -0,0 +1,169 @@ +import { useMemo } from 'react' +import { Checkbox, Collapse, Divider, Typography } from 'antd' +import { useTranslation } from 'react-i18next' +import { groupMarketsBySection, type WhaleMonitorMarketGroup } from '../constants/whaleMonitor' +import type { WhaleMonitorMarketItem } from '../types' +import WhaleMonitorMarketListItem from './WhaleMonitorMarketListItem' +import WhaleMonitorMarketThumbnail from './WhaleMonitorMarketThumbnail' + +const { Text, Title } = Typography + +interface WhaleMonitorMarketGroupedListProps { + markets: WhaleMonitorMarketItem[] + selectedMap: Map + isMobile: boolean + onToggleMarket: (market: WhaleMonitorMarketItem, checked: boolean) => void + onToggleGroup: (markets: WhaleMonitorMarketItem[], checked: boolean) => void +} + +const renderMarketGroups = ( + groups: WhaleMonitorMarketGroup[], + selectedMap: Map, + isMobile: boolean, + onToggleMarket: (market: WhaleMonitorMarketItem, checked: boolean) => void, + onToggleGroup: (markets: WhaleMonitorMarketItem[], checked: boolean) => void, + t: (key: string, options?: Record) => string +) => { + const showAsGroups = groups.length > 1 || (groups.length === 1 && groups[0].key.startsWith('event:')) + + if (!showAsGroups) { + const flatMarkets = groups.flatMap(g => g.markets) + return ( +
+ {flatMarkets.map(market => ( + onToggleMarket(market, checked)} + /> + ))} +
+ ) + } + + const collapseItems = groups.map(group => { + const selectedInGroup = group.markets.filter(m => selectedMap.has(m.conditionId)).length + const allSelected = selectedInGroup === group.markets.length && group.markets.length > 0 + const indeterminate = selectedInGroup > 0 && !allSelected + + return { + key: group.key, + label: ( +
+
+ +
+ + {group.title} + + + {t('whaleMonitorStrategy.marketSelect.marketsInGroup', { count: group.markets.length })} + +
+
+
e.stopPropagation()} onKeyDown={e => e.stopPropagation()}> + onToggleGroup(group.markets, e.target.checked)} + > + {t('whaleMonitorStrategy.marketSelect.selectGroup')} + +
+
+ ), + children: ( +
+ {group.markets.map(market => ( + onToggleMarket(market, checked)} + /> + ))} +
+ ) + } + }) + + return ( + g.key)} + items={collapseItems} + style={{ background: 'transparent' }} + /> + ) +} + +const WhaleMonitorMarketGroupedList: React.FC = ({ + markets, + selectedMap, + isMobile, + onToggleMarket, + onToggleGroup +}) => { + const { t } = useTranslation() + + const sections = useMemo( + () => + groupMarketsBySection(markets, { + game: t('whaleMonitorStrategy.marketSelect.sectionGame'), + season: t('whaleMonitorStrategy.marketSelect.sectionSeason'), + ungrouped: t('whaleMonitorStrategy.marketSelect.ungrouped') + }), + [markets, t] + ) + + if (sections.length === 1) { + const section = sections[0] + const showSectionTitle = + markets.some(m => m.marketType === 'game' || m.marketType === 'season') || + section.groups.some(g => g.key.startsWith('event:')) + return ( + <> + {showSectionTitle && ( + + {section.title} + + )} + {renderMarketGroups(section.groups, selectedMap, isMobile, onToggleMarket, onToggleGroup, t)} + + ) + } + + return ( +
+ {sections.map((section, index) => ( +
+ {index > 0 && } + + {section.title} + <Text type="secondary" style={{ fontSize: 13, fontWeight: 400, marginLeft: 8 }}> + ({section.groups.reduce((sum, g) => sum + g.markets.length, 0)}) + </Text> + + {renderMarketGroups(section.groups, selectedMap, isMobile, onToggleMarket, onToggleGroup, t)} +
+ ))} +
+ ) +} + +export default WhaleMonitorMarketGroupedList diff --git a/frontend/src/components/WhaleMonitorMarketListItem.tsx b/frontend/src/components/WhaleMonitorMarketListItem.tsx new file mode 100644 index 0000000..c0bbbda --- /dev/null +++ b/frontend/src/components/WhaleMonitorMarketListItem.tsx @@ -0,0 +1,140 @@ +import { Checkbox, Tag, Typography } from 'antd' +import { LinkOutlined } from '@ant-design/icons' +import { useTranslation } from 'react-i18next' +import { parseMarketOutcomes, pickMarketImageUrl } from '../constants/whaleMonitor' +import type { WhaleMonitorMarketItem } from '../types' +import { formatUSDC } from '../utils' +import WhaleMonitorMarketThumbnail from './WhaleMonitorMarketThumbnail' + +const { Text } = Typography + +interface WhaleMonitorMarketListItemProps { + market: WhaleMonitorMarketItem + checked: boolean + isMobile: boolean + hideEventTitle?: boolean + compact?: boolean + onToggle: (checked: boolean) => void +} + +const formatConditionId = (id: string): string => + id.length > 20 ? `${id.slice(0, 10)}...${id.slice(-6)}` : id + +const WhaleMonitorMarketListItem: React.FC = ({ + market, + checked, + isMobile, + hideEventTitle = false, + compact = false, + onToggle +}) => { + const { t } = useTranslation() + const outcomeLabels = parseMarketOutcomes(market.outcomes) + const volumeDisplay = + market.volume && parseFloat(market.volume) > 0 ? formatUSDC(market.volume) : null + const polymarketUrl = market.slug ? `https://polymarket.com/event/${market.slug}` : null + const imageUrl = pickMarketImageUrl(market) + const thumbSize = compact ? 36 : isMobile ? 40 : 44 + + const handleOpenLink = (e: React.MouseEvent) => { + e.stopPropagation() + if (polymarketUrl) { + window.open(polymarketUrl, '_blank', 'noopener,noreferrer') + } + } + + return ( +
onToggle(!checked)} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onToggle(!checked) + } + }} + style={{ + display: 'flex', + alignItems: 'flex-start', + gap: 12, + padding: compact ? (isMobile ? '8px 8px' : '8px 10px') : isMobile ? '12px 8px' : '10px 12px', + borderRadius: 8, + cursor: 'pointer', + minHeight: 44, + background: checked ? 'rgba(22, 119, 255, 0.06)' : undefined, + border: checked ? '1px solid rgba(22, 119, 255, 0.3)' : '1px solid transparent' + }} + > + e.stopPropagation()} + onChange={e => onToggle(e.target.checked)} + style={{ marginTop: 2 }} + /> + +
+ {market.eventTitle && !hideEventTitle && ( + + {market.eventTitle} + + )} +
{market.title}
+
+ {market.category && ( + {market.category} + )} + {volumeDisplay && ( + + {t('whaleMonitorStrategy.marketSelect.volume')}: ${volumeDisplay} + + )} +
+ {outcomeLabels.length > 0 && ( +
+ {outcomeLabels.map(label => ( + + {label} + + ))} +
+ )} +
+ + {formatConditionId(market.conditionId)} + + {polymarketUrl && ( + + + {t('whaleMonitorStrategy.marketSelect.viewMarket')} + + )} +
+
+
+ ) +} + +export default WhaleMonitorMarketListItem diff --git a/frontend/src/components/WhaleMonitorMarketThumbnail.tsx b/frontend/src/components/WhaleMonitorMarketThumbnail.tsx new file mode 100644 index 0000000..25e16d4 --- /dev/null +++ b/frontend/src/components/WhaleMonitorMarketThumbnail.tsx @@ -0,0 +1,36 @@ +interface WhaleMonitorMarketThumbnailProps { + src?: string + size?: number + alt?: string +} + +const WhaleMonitorMarketThumbnail: React.FC = ({ + src, + size = 40, + alt = '' +}) => { + if (!src?.trim()) return null + + return ( + {alt} { + const target = e.currentTarget + target.style.display = 'none' + }} + /> + ) +} + +export default WhaleMonitorMarketThumbnail diff --git a/frontend/src/constants/whaleMonitor.ts b/frontend/src/constants/whaleMonitor.ts new file mode 100644 index 0000000..ff9913a --- /dev/null +++ b/frontend/src/constants/whaleMonitor.ts @@ -0,0 +1,208 @@ +import type { WhaleMonitorFormDraft, WhaleMonitorMarketItem } from '../types' + +export const WHALE_MONITOR_FORM_DRAFT_KEY = 'whale_monitor_form_draft' + +export function saveWhaleMonitorFormDraft(draft: WhaleMonitorFormDraft): void { + sessionStorage.setItem(WHALE_MONITOR_FORM_DRAFT_KEY, JSON.stringify(draft)) +} + +export function loadWhaleMonitorFormDraft(): WhaleMonitorFormDraft | null { + const raw = sessionStorage.getItem(WHALE_MONITOR_FORM_DRAFT_KEY) + if (!raw) return null + try { + return JSON.parse(raw) as WhaleMonitorFormDraft + } catch { + return null + } +} + +export function clearWhaleMonitorFormDraft(): void { + sessionStorage.removeItem(WHALE_MONITOR_FORM_DRAFT_KEY) +} + +export function conditionIdsToMarkets( + conditionIds: string[], + known: WhaleMonitorMarketItem[] = [] +): WhaleMonitorMarketItem[] { + return conditionIds.map(id => { + const found = known.find(m => m.conditionId === id) + return found ?? { conditionId: id, title: id } + }) +} + +/** 解析 Gamma 返回的 outcomes JSON 字符串 */ +export function parseMarketOutcomes(outcomes?: string): string[] { + if (!outcomes?.trim()) return [] + try { + const parsed = JSON.parse(outcomes) as unknown + if (Array.isArray(parsed)) { + return parsed.filter((item): item is string => typeof item === 'string' && item.length > 0) + } + } catch { + return outcomes.split(',').map(s => s.trim()).filter(Boolean) + } + return [] +} + +export interface WhaleMonitorMarketGroup { + key: string + title: string + markets: WhaleMonitorMarketItem[] + imageUrl?: string +} + +/** 市场行展示图:image > icon > eventImage */ +export function pickMarketImageUrl(market?: WhaleMonitorMarketItem | null): string | undefined { + if (!market) return undefined + return market.image?.trim() || market.icon?.trim() || market.eventImage?.trim() || undefined +} + +/** 赛事分组头图:优先 eventImage,否则取组内首个市场图 */ +export function pickGroupImageUrl(markets: WhaleMonitorMarketItem[]): string | undefined { + if (markets.length === 0) return undefined + const eventImg = markets[0].eventImage?.trim() + if (eventImg) return eventImg + return pickMarketImageUrl(markets[0]) +} + +export interface WhaleMonitorMarketSection { + key: 'game' | 'season' + title: string + groups: WhaleMonitorMarketGroup[] +} + +export interface WhaleMonitorMarketSectionLabels { + game: string + season: string + ungrouped: string +} + +/** 先按单场/长期分块,再在块内按赛事或分类分组 */ +export function groupMarketsBySection( + markets: WhaleMonitorMarketItem[], + labels: WhaleMonitorMarketSectionLabels +): WhaleMonitorMarketSection[] { + if (markets.length === 0) return [] + + const hasTyped = markets.some(m => m.marketType === 'game' || m.marketType === 'season') + if (!hasTyped) { + const groups = groupMarketsForDisplay(markets, labels.ungrouped) + if (groups.length === 0) return [] + return [{ key: 'season', title: labels.ungrouped, groups }] + } + + const sections: WhaleMonitorMarketSection[] = [] + const games = markets.filter(m => m.marketType === 'game') + const seasons = markets.filter(m => m.marketType === 'season') + + if (games.length > 0) { + sections.push({ + key: 'game', + title: labels.game, + groups: groupMarketsForDisplay(games, labels.ungrouped) + }) + } + if (seasons.length > 0) { + sections.push({ + key: 'season', + title: labels.season, + groups: groupMarketsForDisplay(seasons, labels.ungrouped) + }) + } + return sections +} + +/** 按赛事/分类分组展示市场列表 */ +export function groupMarketsForDisplay( + markets: WhaleMonitorMarketItem[], + ungroupedLabel: string +): WhaleMonitorMarketGroup[] { + if (markets.length === 0) return [] + + const eventMap = new Map() + const miscMarkets: WhaleMonitorMarketItem[] = [] + + for (const market of markets) { + if (market.marketType === 'season') { + miscMarkets.push(market) + continue + } + const eventTitle = market.eventTitle?.trim() + if (eventTitle) { + const list = eventMap.get(eventTitle) ?? [] + list.push(market) + eventMap.set(eventTitle, list) + } else { + miscMarkets.push(market) + } + } + + if (eventMap.size > 0) { + const groups: WhaleMonitorMarketGroup[] = Array.from(eventMap.entries()) + .map(([title, items]) => ({ + key: `event:${title}`, + title, + markets: items, + imageUrl: pickGroupImageUrl(items) + })) + .sort((a, b) => a.title.localeCompare(b.title)) + if (miscMarkets.length > 0) { + groups.push({ + key: 'misc', + title: ungroupedLabel, + markets: miscMarkets, + imageUrl: pickMarketImageUrl(miscMarkets[0]) + }) + } + return groups + } + + const categoryMap = new Map() + for (const market of markets) { + const category = market.category?.trim() || ungroupedLabel + const list = categoryMap.get(category) ?? [] + list.push(market) + categoryMap.set(category, list) + } + + if (categoryMap.size <= 1) { + return [{ key: 'all', title: ungroupedLabel, markets }] + } + + return Array.from(categoryMap.entries()) + .map(([title, items]) => ({ + key: `cat:${title}`, + title, + markets: items, + imageUrl: pickMarketImageUrl(items[0]) + })) + .sort((a, b) => a.title.localeCompare(b.title)) +} + +export function toWhaleMonitorMarketItem(m: { + conditionId: string + title: string + slug?: string + category?: string + volume?: string + outcomes?: string + eventTitle?: string + marketType?: 'game' | 'season' + image?: string + icon?: string + eventImage?: string +}): WhaleMonitorMarketItem { + return { + conditionId: m.conditionId, + title: m.title, + slug: m.slug, + category: m.category, + volume: m.volume, + outcomes: m.outcomes, + eventTitle: m.eventTitle, + marketType: m.marketType, + image: m.image, + icon: m.icon, + eventImage: m.eventImage + } +} diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index 90a45f4..c6a3609 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -317,6 +317,8 @@ "cryptoSpreadStrategy": "Crypto Spread Strategy", "cryptoTailStrategy": "Strategy Config", "cryptoTailMonitor": "Real-time Monitor", + "whaleMonitorStrategy": "Whale Monitor", + "whaleMonitorStrategyConfig": "Strategy Config", "positions": "Position Management", "backtest": "Backtest", "statistics": "Statistics", @@ -1846,6 +1848,113 @@ "periodChanged": "Period has changed, popup closed" } }, + "whaleMonitorStrategy": { + "list": { + "title": "Whale Monitor Strategy", + "addStrategy": "New Strategy", + "strategyName": "Strategy Name", + "account": "Account", + "markets": "Markets", + "marketCount": "Markets", + "window": "Window", + "threshold": "Threshold", + "orderAmount": "Order Amount", + "priceRange": "Price Range", + "cooldown": "Cooldown", + "enabled": "Enabled", + "lastTrigger": "Last Trigger", + "triggerCount": "Triggers", + "actions": "Actions", + "edit": "Edit", + "enable": "Enable", + "disable": "Disable", + "delete": "Delete", + "viewTriggers": "Trigger Records", + "deleteConfirm": "Are you sure you want to delete this strategy?", + "fetchFailed": "Failed to fetch strategy list", + "seconds": "sec", + "conditionId": "ConditionId" + }, + "form": { + "strategyName": "Strategy Name", + "strategyNamePlaceholder": "Optional, auto-generated if empty", + "selectAccount": "Select Account", + "conditionIds": "Monitored Markets", + "conditionIdsPlaceholder": "Search market name or enter conditionId", + "selectMarket": "Select a market", + "sportLeague": "Select League", + "selectSportFirst": "Select a league first", + "tagFilter": "Category", + "tagSports": "Sports", + "tagCrypto": "Crypto", + "tagPolitics": "Politics", + "tagPopCulture": "Pop Culture", + "windowSeconds": "Aggregation Window (sec)", + "thresholdAmount": "Threshold Amount", + "orderAmount": "Fixed Order Amount", + "minPrice": "Min Price", + "maxPrice": "Max Price", + "priceTip": "Range 0~1, max 2 decimal places", + "cooldownSeconds": "Cooldown (sec)", + "enabled": "Enable Strategy", + "create": "Create", + "update": "Update", + "selectMarkets": "Select Markets", + "marketsSelected": "{{count}} market(s) selected" + }, + "marketSelect": { + "title": "Select Markets", + "subtitle": "Filter by category or search by name. Multiple selection supported.", + "back": "Back", + "searchPlaceholder": "Search market name (min. 2 characters)", + "searchHint": "Enter keywords or pick a category above", + "sportsGamesHint": "Sports leagues show individual game markets; search by team name", + "sportsGamesEmpty": "No active game markets for this league. Try searching a team name or check back later.", + "empty": "No markets found", + "selectedTitle": "Selected ({{count}})", + "selectedCount": "{{count}} selected", + "confirm": "Confirm", + "confirmWithCount": "Confirm ({{count}})", + "volume": "Volume", + "viewMarket": "View market", + "sectionGame": "Individual games", + "sectionSeason": "Long-term markets", + "ungrouped": "Other markets", + "selectGroup": "Select all", + "marketsInGroup": "{{count}} market(s)", + "tag": { + "all": "All", + "sports": "Sports", + "politics": "Politics", + "crypto": "Crypto", + "popCulture": "Pop Culture" + } + }, + "triggerRecords": { + "title": "Trigger Records", + "timeRange": "Time Range", + "startDate": "Start Date", + "endDate": "End Date", + "successTab": "Success", + "failTab": "Failed", + "triggerTime": "Trigger Time", + "conditionId": "Market", + "tokenId": "Token ID", + "side": "Side", + "triggerVolume": "Trigger Volume", + "orderPrice": "Order Price", + "orderSize": "Order Size", + "orderAmount": "Order Amount", + "orderId": "Order ID", + "status": "Status", + "success": "Success", + "fail": "Failed", + "failReason": "Fail Reason", + "emptySuccess": "No successful records", + "emptyFail": "No failed records", + "totalCount": "{{count}} records in total" + } + }, "clobMigration": { "title": "CLOB 2.0 Migration Notice", "description": "Polymarket CLOB has been upgraded to V2. Please go to the Accounts page to complete the USDC migration to ensure trading functions properly.", diff --git a/frontend/src/locales/zh-CN/common.json b/frontend/src/locales/zh-CN/common.json index 70571a1..b0ef18d 100644 --- a/frontend/src/locales/zh-CN/common.json +++ b/frontend/src/locales/zh-CN/common.json @@ -317,6 +317,8 @@ "cryptoSpreadStrategy": "加密价差策略", "cryptoTailStrategy": "策略配置", "cryptoTailMonitor": "实时监控", + "whaleMonitorStrategy": "大单监听", + "whaleMonitorStrategyConfig": "策略配置", "positions": "仓位管理", "backtest": "回测", "statistics": "统计信息", @@ -1846,6 +1848,113 @@ "periodChanged": "周期已切换,弹窗已关闭" } }, + "whaleMonitorStrategy": { + "list": { + "title": "大单监听策略", + "addStrategy": "新建策略", + "strategyName": "策略名称", + "account": "绑定账户", + "markets": "监听市场", + "marketCount": "市场数", + "window": "聚合窗口", + "threshold": "触发阈值", + "orderAmount": "下单金额", + "priceRange": "价格区间", + "cooldown": "冷却时间", + "enabled": "启用", + "lastTrigger": "最近触发", + "triggerCount": "触发次数", + "actions": "操作", + "edit": "编辑", + "enable": "启用", + "disable": "停用", + "delete": "删除", + "viewTriggers": "触发记录", + "deleteConfirm": "确定要删除该策略吗?", + "fetchFailed": "获取策略列表失败", + "seconds": "秒", + "conditionId": "ConditionId" + }, + "form": { + "strategyName": "策略名称", + "strategyNamePlaceholder": "可选,留空自动生成", + "selectAccount": "选择账户", + "conditionIds": "监听市场", + "conditionIdsPlaceholder": "搜索市场名称或输入 conditionId", + "selectMarket": "请选择市场", + "sportLeague": "选择联赛", + "selectSportFirst": "请先选择联赛", + "tagFilter": "市场分类", + "tagSports": "体育", + "tagCrypto": "加密货币", + "tagPolitics": "政治", + "tagPopCulture": "流行文化", + "windowSeconds": "聚合窗口(秒)", + "thresholdAmount": "触发阈值金额", + "orderAmount": "固定下单金额", + "minPrice": "最低价格", + "maxPrice": "最高价格", + "priceTip": "范围 0~1,最多两位小数", + "cooldownSeconds": "冷却时间(秒)", + "enabled": "启用策略", + "create": "创建", + "update": "更新", + "selectMarkets": "选择监听市场", + "marketsSelected": "已选 {{count}} 个市场" + }, + "marketSelect": { + "title": "选择市场", + "subtitle": "按分类筛选或搜索市场名称,可多选", + "back": "返回", + "searchPlaceholder": "搜索市场名称(至少 2 个字符)", + "searchHint": "请输入关键词搜索,或选择上方分类筛选", + "sportsGamesHint": "体育联赛将展示单场比赛盘口;也可输入队名搜索", + "sportsGamesEmpty": "该联赛当前暂无进行中的单场比赛盘口,可尝试搜索队名或稍后再试", + "empty": "未找到匹配的市场", + "selectedTitle": "已选市场({{count}})", + "selectedCount": "已选 {{count}} 个", + "confirm": "确认选择", + "confirmWithCount": "确认({{count}})", + "volume": "成交量", + "viewMarket": "查看市场", + "sectionGame": "单场比赛", + "sectionSeason": "长期市场", + "ungrouped": "其他市场", + "selectGroup": "全选", + "marketsInGroup": "{{count}} 个盘口", + "tag": { + "all": "全部", + "sports": "体育", + "politics": "政治", + "crypto": "加密货币", + "popCulture": "流行文化" + } + }, + "triggerRecords": { + "title": "触发记录", + "timeRange": "时间范围", + "startDate": "开始日期", + "endDate": "结束日期", + "successTab": "成功", + "failTab": "失败", + "triggerTime": "触发时间", + "conditionId": "市场", + "tokenId": "Token ID", + "side": "方向", + "triggerVolume": "触发金额", + "orderPrice": "下单价格", + "orderSize": "下单数量", + "orderAmount": "下单金额", + "orderId": "订单ID", + "status": "状态", + "success": "成功", + "fail": "失败", + "failReason": "失败原因", + "emptySuccess": "暂无成功记录", + "emptyFail": "暂无失败记录", + "totalCount": "共 {{count}} 条记录" + } + }, "clobMigration": { "title": "CLOB 2.0 迁移提醒", "description": "Polymarket CLOB 已升级至 V2 版本,您需要前往账户页完成 USDC 迁移操作,以确保交易功能正常使用。", diff --git a/frontend/src/locales/zh-TW/common.json b/frontend/src/locales/zh-TW/common.json index 1e48523..5719d10 100644 --- a/frontend/src/locales/zh-TW/common.json +++ b/frontend/src/locales/zh-TW/common.json @@ -317,6 +317,8 @@ "cryptoSpreadStrategy": "加密價差策略", "cryptoTailStrategy": "策略配置", "cryptoTailMonitor": "即時監控", + "whaleMonitorStrategy": "大單監聽", + "whaleMonitorStrategyConfig": "策略配置", "positions": "倉位管理", "backtest": "回測", "statistics": "統計信息", @@ -1846,6 +1848,113 @@ "periodChanged": "週期已切換,彈窗已關閉" } }, + "whaleMonitorStrategy": { + "list": { + "title": "大單監聽策略", + "addStrategy": "新建策略", + "strategyName": "策略名稱", + "account": "綁定賬戶", + "markets": "監聽市場", + "marketCount": "市場數", + "window": "聚合窗口", + "threshold": "觸發閾值", + "orderAmount": "下單金額", + "priceRange": "價格區間", + "cooldown": "冷卻時間", + "enabled": "啟用", + "lastTrigger": "最近觸發", + "triggerCount": "觸發次數", + "actions": "操作", + "edit": "編輯", + "enable": "啟用", + "disable": "停用", + "delete": "刪除", + "viewTriggers": "觸發記錄", + "deleteConfirm": "確定要刪除該策略嗎?", + "fetchFailed": "獲取策略列表失敗", + "seconds": "秒", + "conditionId": "ConditionId" + }, + "form": { + "strategyName": "策略名稱", + "strategyNamePlaceholder": "可選,留空自動生成", + "selectAccount": "選擇賬戶", + "conditionIds": "監聯市場", + "conditionIdsPlaceholder": "搜索市場名稱或輸入 conditionId", + "selectMarket": "請選擇市場", + "sportLeague": "選擇聯賽", + "selectSportFirst": "請先選擇聯賽", + "tagFilter": "市場分類", + "tagSports": "體育", + "tagCrypto": "加密貨幣", + "tagPolitics": "政治", + "tagPopCulture": "流行文化", + "windowSeconds": "聚合窗口(秒)", + "thresholdAmount": "觸發閾值金額", + "orderAmount": "固定下單金額", + "minPrice": "最低價格", + "maxPrice": "最高價格", + "priceTip": "範圍 0~1,最多兩位小數", + "cooldownSeconds": "冷卻時間(秒)", + "enabled": "啟用策略", + "create": "創建", + "update": "更新", + "selectMarkets": "選擇監聽市場", + "marketsSelected": "已選 {{count}} 個市場" + }, + "marketSelect": { + "title": "選擇市場", + "subtitle": "按分類篩選或搜索市場名稱,可多選", + "back": "返回", + "searchPlaceholder": "搜索市場名稱(至少 2 個字符)", + "searchHint": "請輸入關鍵詞搜索,或選擇上方分類篩選", + "sportsGamesHint": "體育聯賽將展示單場比賽盤口;也可輸入隊名搜索", + "sportsGamesEmpty": "該聯賽當前暫無進行中的單場比賽盤口,可嘗試搜索隊名或稍後再試", + "empty": "未找到匹配的市場", + "selectedTitle": "已選市場({{count}})", + "selectedCount": "已選 {{count}} 個", + "confirm": "確認選擇", + "confirmWithCount": "確認({{count}})", + "volume": "成交量", + "viewMarket": "查看市場", + "sectionGame": "單場比賽", + "sectionSeason": "長期市場", + "ungrouped": "其他市場", + "selectGroup": "全選", + "marketsInGroup": "{{count}} 個盤口", + "tag": { + "all": "全部", + "sports": "體育", + "politics": "政治", + "crypto": "加密貨幣", + "popCulture": "流行文化" + } + }, + "triggerRecords": { + "title": "觸發記錄", + "timeRange": "時間範圍", + "startDate": "開始日期", + "endDate": "結束日期", + "successTab": "成功", + "failTab": "失敗", + "triggerTime": "觸發時間", + "conditionId": "市場", + "tokenId": "Token ID", + "side": "方向", + "triggerVolume": "觸發金額", + "orderPrice": "下單價格", + "orderSize": "下單數量", + "orderAmount": "下單金額", + "orderId": "訂單ID", + "status": "狀態", + "success": "成功", + "fail": "失敗", + "failReason": "失敗原因", + "emptySuccess": "暫無成功記錄", + "emptyFail": "暫無失敗記錄", + "totalCount": "共 {{count}} 條記錄" + } + }, "clobMigration": { "title": "CLOB 2.0 遷移提醒", "description": "Polymarket CLOB 已升級至 V2 版本,您需要前往帳戶頁完成 USDC 遷移操作,以確保交易功能正常使用。", diff --git a/frontend/src/pages/WhaleMonitorMarketSelect.tsx b/frontend/src/pages/WhaleMonitorMarketSelect.tsx new file mode 100644 index 0000000..7b00569 --- /dev/null +++ b/frontend/src/pages/WhaleMonitorMarketSelect.tsx @@ -0,0 +1,330 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useNavigate, useLocation } from 'react-router-dom' +import { + Button, + Card, + Empty, + Input, + Select, + Segmented, + Spin, + Tag, + Typography +} from 'antd' +import { ArrowLeftOutlined, SearchOutlined } from '@ant-design/icons' +import { useTranslation } from 'react-i18next' +import { useMediaQuery } from 'react-responsive' +import { apiService } from '../services/api' +import WhaleMonitorMarketGroupedList from '../components/WhaleMonitorMarketGroupedList' +import WhaleMonitorMarketThumbnail from '../components/WhaleMonitorMarketThumbnail' +import { toWhaleMonitorMarketItem } from '../constants/whaleMonitor' +import type { WhaleMonitorMarketItem, WhaleMonitorMarketSelectLocationState } from '../types' + +const { Title, Text } = Typography + +const TAG_OPTIONS = [ + { key: 'all', value: '' }, + { key: 'sports', value: '1' }, + { key: 'politics', value: '2' }, + { key: 'crypto', value: '21' }, + { key: 'popCulture', value: '100639' } +] as const + +const WhaleMonitorMarketSelect: React.FC = () => { + const { t } = useTranslation() + const navigate = useNavigate() + const location = useLocation() + const isMobile = useMediaQuery({ maxWidth: 768 }) + + const locationState = (location.state as WhaleMonitorMarketSelectLocationState | null) ?? null + const initialSelected = locationState?.selectedMarkets ?? [] + + const [keyword, setKeyword] = useState('') + const [debouncedKeyword, setDebouncedKeyword] = useState('') + const [tagId, setTagId] = useState('') + const [sportSubSeriesId, setSportSubSeriesId] = useState(undefined) + const [sportSubCategories, setSportSubCategories] = useState< + Array<{ id: number; slug: string; label: string; tagId: string; seriesId: string; image?: string }> + >([]) + const [marketList, setMarketList] = useState([]) + const [loading, setLoading] = useState(false) + const [selectedMap, setSelectedMap] = useState>(() => { + const map = new Map() + initialSelected.forEach(m => map.set(m.conditionId, m)) + return map + }) + + const tagSegmentOptions = useMemo( + () => + TAG_OPTIONS.map(opt => ({ + label: t(`whaleMonitorStrategy.marketSelect.tag.${opt.key}`), + value: opt.value + })), + [t] + ) + + useEffect(() => { + const timer = setTimeout(() => setDebouncedKeyword(keyword.trim()), 400) + return () => clearTimeout(timer) + }, [keyword]) + + useEffect(() => { + if (tagId === '1') { + fetchSportSubCategories() + setSportSubSeriesId(undefined) + } else { + setSportSubCategories([]) + setSportSubSeriesId(undefined) + } + }, [tagId]) + + const fetchSportSubCategories = async () => { + try { + const res = await apiService.markets.sportsCategories() + if (res.data.code === 0 && res.data.data) { + setSportSubCategories( + res.data.data.filter((s): s is typeof s & { seriesId: string } => Boolean(s.seriesId)) + ) + } else { + setSportSubCategories([]) + } + } catch { + setSportSubCategories([]) + } + } + + const fetchMarkets = useCallback(async () => { + const selectedSport = sportSubCategories.find(s => s.seriesId === sportSubSeriesId) + const seriesId = sportSubSeriesId + const sportSlug = selectedSport?.slug + const effectiveTagId = tagId && tagId !== '1' ? tagId : undefined + const searchKeyword = debouncedKeyword + + if (tagId === '1' && !sportSubSeriesId && searchKeyword.length < 2) { + setMarketList([]) + return + } + if (!seriesId && !effectiveTagId && searchKeyword.length < 2) { + setMarketList([]) + return + } + + setLoading(true) + try { + const res = await apiService.markets.search({ + keyword: searchKeyword.length >= 2 ? searchKeyword : '', + seriesId: seriesId || undefined, + sportSlug: seriesId ? sportSlug : undefined, + tagId: seriesId ? undefined : effectiveTagId, + limit: 200 + }) + if (res.data.code === 0 && res.data.data) { + setMarketList(res.data.data.map(m => toWhaleMonitorMarketItem(m))) + } else { + setMarketList([]) + } + } catch { + setMarketList([]) + } finally { + setLoading(false) + } + }, [debouncedKeyword, tagId, sportSubSeriesId, sportSubCategories]) + + useEffect(() => { + fetchMarkets() + }, [fetchMarkets]) + + const selectedList = useMemo(() => Array.from(selectedMap.values()), [selectedMap]) + + const toggleMarket = (market: WhaleMonitorMarketItem, checked: boolean) => { + setSelectedMap(prev => { + const next = new Map(prev) + if (checked) { + next.set(market.conditionId, market) + } else { + next.delete(market.conditionId) + } + return next + }) + } + + const toggleGroupMarkets = (markets: WhaleMonitorMarketItem[], checked: boolean) => { + setSelectedMap(prev => { + const next = new Map(prev) + for (const market of markets) { + if (checked) { + next.set(market.conditionId, market) + } else { + next.delete(market.conditionId) + } + } + return next + }) + } + + const handleConfirm = () => { + navigate('/whale-monitor-strategy', { + state: { selectedMarkets: selectedList } + }) + } + + const handleBack = () => { + navigate('/whale-monitor-strategy') + } + + const showSelectSportHint = tagId === '1' && !sportSubSeriesId && debouncedKeyword.length < 2 + const showSportsGamesEmpty = + tagId === '1' && !!sportSubSeriesId && marketList.length === 0 && !loading && debouncedKeyword.length < 2 + const showSearchHint = !tagId && debouncedKeyword.length < 2 + + return ( +
+
+ + + {t('whaleMonitorStrategy.marketSelect.title')} + + + {t('whaleMonitorStrategy.marketSelect.subtitle')} + +
+ + + } + placeholder={t('whaleMonitorStrategy.marketSelect.searchPlaceholder')} + value={keyword} + onChange={e => setKeyword(e.target.value)} + size={isMobile ? 'large' : 'middle'} + style={{ marginBottom: 12 }} + /> + setTagId(val as string)} + style={{ marginBottom: tagId === '1' ? 12 : 0 }} + /> + {tagId === '1' && sportSubCategories.length > 0 && ( + <> + ({ + label: a.accountName || `${a.walletAddress?.slice(0, 6)}...${a.walletAddress?.slice(-4)}`, + value: a.id + }))} + /> + ({ + label: a.accountName || `${a.walletAddress?.slice(0, 6)}...${a.walletAddress?.slice(-4)}`, + value: a.id + }))} + /> + + + + + +
+ {selectedMarkets.length > 0 && ( +
+ {selectedMarkets.map(m => ( + + setSelectedMarkets(prev => prev.filter(item => item.conditionId !== m.conditionId)) + } + > + {m.title} + + ))} +
+ )} + +
+
+ + + + + + + + + + + + + + + + + +
+ {t('whaleMonitorStrategy.form.priceTip')} +
+ + + + + + + + + + setTriggerVisible(false)} + footer={null} + width={isMobile ? '95%' : 900} + > + + {triggerRecords.length === 0 ? ( + + ) : ( + { + setTriggerPage(page) + fetchTriggerRecords(triggerStrategyId, page, triggerTab === 'all' ? undefined : triggerTab) + } + }} + /> + )} + + + ) +} + +export default WhaleMonitorStrategyList diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 16baad4..4d31b84 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -314,8 +314,39 @@ export const apiService = { /** * 获取最新价(从订单表获取,供前端下单时显示) */ - getLatestPrice: (data: { tokenId: string }) => - apiClient.post>('/markets/latest-price', data) + getLatestPrice: (data: { tokenId: string }) => + apiClient.post>('/markets/latest-price', data), + + /** + * 搜索市场(按标题关键词) + */ + search: (data: { keyword: string; tagId?: string; seriesId?: string; sportSlug?: string; limit?: number }) => + apiClient.post>>('/markets/search', data), + + /** + * 获取体育联赛子分类列表 + */ + sportsCategories: () => + apiClient.post>>('/markets/sports-categories', {}) }, /** @@ -526,6 +557,51 @@ export const apiService = { apiClient.post>('/crypto-tail-strategy/manual-order', data) }, + /** + * 大单监听策略 API + */ + whaleMonitorStrategy: { + list: (data: { accountId?: number; enabled?: boolean } = {}) => + apiClient.post>('/whale-monitor-strategy/list', data), + create: (data: { + accountId: number + name?: string + conditionIds: string[] + windowSeconds?: number + thresholdAmount: string + orderAmount: string + minPrice?: string + maxPrice?: string + cooldownSeconds?: number + enabled?: boolean + }) => + apiClient.post>('/whale-monitor-strategy/create', data), + update: (data: { + strategyId: number + name?: string + conditionIds?: string[] + windowSeconds?: number + thresholdAmount?: string + orderAmount?: string + minPrice?: string + maxPrice?: string + cooldownSeconds?: number + enabled?: boolean + }) => + apiClient.post>('/whale-monitor-strategy/update', data), + delete: (data: { strategyId: number }) => + apiClient.post>('/whale-monitor-strategy/delete', data), + triggers: (data: { + strategyId: number + page?: number + pageSize?: number + status?: string + startDate?: number + endDate?: number + }) => + apiClient.post>('/whale-monitor-strategy/triggers', data) + }, + /** * 订单管理 API */ diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 7034f94..0f649cb 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1309,3 +1309,71 @@ export interface TemplateVariablesResponse { categories: TemplateVariableCategory[] // 分类列表 variables: TemplateVariable[] // 变量列表 } + +// ==================== 大单监听策略 ==================== + +export type WhaleMonitorMarketType = 'game' | 'season' + +export interface WhaleMonitorMarketItem { + conditionId: string + title: string + slug?: string + category?: string + volume?: string + outcomes?: string + /** 体育对阵/赛事名,如 Spurs vs. Thunder */ + eventTitle?: string + /** game=单场赛事,season=长期/赛季市场 */ + marketType?: WhaleMonitorMarketType + image?: string + icon?: string + eventImage?: string +} + +export interface WhaleMonitorFormDraft { + formValues: Record + selectedMarkets: WhaleMonitorMarketItem[] + editingStrategyId?: number +} + +export interface WhaleMonitorMarketSelectLocationState { + selectedMarkets?: WhaleMonitorMarketItem[] +} + +export interface WhaleMonitorStrategyListLocationState { + selectedMarkets?: WhaleMonitorMarketItem[] +} + +export interface WhaleMonitorStrategyDto { + id: number + accountId: number + name?: string + conditionIds: string[] + windowSeconds: number + thresholdAmount: string + orderAmount: string + minPrice: string + maxPrice: string + cooldownSeconds: number + enabled: boolean + lastTriggerAt?: number + triggerCount: number + createdAt: number + updatedAt: number +} + +export interface WhaleMonitorTriggerDto { + id: number + strategyId: number + conditionId: string + tokenId: string + side: string + triggerVolume: string + orderPrice: string + orderSize: string + orderAmount: string + orderId?: string + status: string + failReason?: string + createdAt: number +}