Compare commits

..
Author SHA1 Message Date
WrBugandCursor a70ee3e811 feat(whale-monitor): 市场选择改为弹窗并优化列表交互
- 策略编辑内嵌市场选择弹窗,移除路由跳转与 sessionStorage 草稿
- 列表区固定高度内部滚动,底部仅保留一处确认操作
- 并发缓存 markets 时捕获唯一键冲突并回读已有记录

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 05:31:50 +08:00
WrBugandCursor 1d47cf0451 feat(whale-monitor): 保存策略时落库缓存市场并回显标题
- 创建/更新策略时复用 MarketService.getMarkets 写入 markets 表缓存
- 策略 DTO 增加 markets 字段,编辑页直接用其显示市场标题

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 05:19:27 +08:00
WrBugandCursor 5d6f0e03f6 feat(whale-monitor): 优化市场选择页交互与分类缓存
- 体育联赛通过 series_id 展示单场比赛,长期市场分块可折叠
- 移除全部分类,支持搜索与分类切换缓存、首屏自动加载
- 单组列表扁平展示,列表行不展示图片(联赛下拉保留 logo)
- 补充大单监听功能说明与 Code Review 清单文档

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 04:49:22 +08:00
WrBugandClaude Opus 4.7 b3872aa8f2 feat: 添加市场大单监听策略(Whale Monitor Strategy)
支持按市场分类/体育联赛筛选并选择市场,实时监听 Activity WebSocket 交易流,
在滑动窗口内聚合 BUY 成交额,达到阈值后自动下 FAK 单。

后端:策略 CRUD、WS 监听、滑动窗口聚合、订单执行、触发记录
前端:策略列表/创建/编辑、市场搜索选择(分类+联赛)、触发记录查看

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 04:34:31 +08:00
35 changed files with 4754 additions and 10 deletions
@@ -25,9 +25,31 @@ interface PolymarketGammaApi {
suspend fun listMarkets(
@Query("condition_ids") conditionIds: List<String>? = null,
@Query("clob_token_ids") clobTokenIds: List<String>? = 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<List<MarketResponse>>
/**
* 按系列/标签列出事件(体育单场比赛市场嵌套在 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<List<GammaEventListItem>>
/**
* 根据 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<GammaEventBySlugResponse>
/**
* 获取体育联赛列表
* GET /sports 返回所有可用的体育分类(NBA、MLB、EPL 等)
*/
@GET("/sports")
suspend fun listSports(): Response<List<GammaSportItem>>
}
/**
@@ -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<MarketResponse>? = null
)
/**
* 市场响应(根据 Gamma API 文档)
*/
@@ -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<String, Any>): ResponseEntity<ApiResponse<List<MarketSearchResult>>> {
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<ApiResponse<List<SportCategoryResult>>> {
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))
}
}
}
@@ -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<ApiResponse<WhaleMonitorStrategyListResponse>> {
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<ApiResponse<WhaleMonitorStrategyDto>> {
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<ApiResponse<WhaleMonitorStrategyDto>> {
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<ApiResponse<Unit>> {
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<ApiResponse<WhaleMonitorTriggerListResponse>> {
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))
}
}
}
@@ -0,0 +1,133 @@
package com.wrbug.polymarketbot.dto
/**
* 大单监听策略创建请求
* 金额与价格使用 String,后端转为 BigDecimal
*/
data class WhaleMonitorStrategyCreateRequest(
val accountId: Long = 0L,
val name: String? = null,
/** 监听市场 conditionId 列表 */
val conditionIds: List<String> = 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<String>? = 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
)
/**
* 策略关联市场展示信息(来自 markets 表缓存)
*/
data class WhaleMonitorMarketDto(
val conditionId: String = "",
val title: String = "",
val slug: String? = null,
val category: String? = null,
val image: String? = null,
val icon: String? = null
)
/**
* 大单监听策略 DTO(列表与详情)
*/
data class WhaleMonitorStrategyDto(
val id: Long = 0L,
val accountId: Long = 0L,
val name: String? = null,
val conditionIds: List<String> = emptyList(),
/** 监听市场展示信息(与 conditionIds 顺序一致,保存时写入 markets 缓存) */
val markets: List<WhaleMonitorMarketDto> = 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<WhaleMonitorStrategyDto> = 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<WhaleMonitorTriggerDto> = emptyList(),
val total: Long = 0L
)
@@ -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()
)
@@ -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()
)
@@ -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 {
/**
@@ -0,0 +1,8 @@
package com.wrbug.polymarketbot.event
import org.springframework.context.ApplicationEvent
/**
* 大单监听策略创建/更新/删除/启用状态变更后发布,通知 WS 服务重载监听配置
*/
class WhaleMonitorStrategyChangedEvent(source: Any) : ApplicationEvent(source)
@@ -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<WhaleMonitorStrategy, Long> {
fun findAllByAccountId(accountId: Long): List<WhaleMonitorStrategy>
fun findAllByEnabledTrue(): List<WhaleMonitorStrategy>
fun findByAccountIdAndEnabled(accountId: Long, enabled: Boolean): List<WhaleMonitorStrategy>
}
@@ -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<WhaleMonitorTrigger, Long> {
fun findAllByStrategyIdOrderByCreatedAtDesc(strategyId: Long, pageable: Pageable): Page<WhaleMonitorTrigger>
fun findAllByStrategyIdAndStatusOrderByCreatedAtDesc(strategyId: Long, status: String, pageable: Pageable): Page<WhaleMonitorTrigger>
fun findAllByStrategyIdAndCreatedAtBetweenOrderByCreatedAtDesc(strategyId: Long, startTs: Long, endTs: Long, pageable: Pageable): Page<WhaleMonitorTrigger>
fun findAllByStrategyIdAndStatusAndCreatedAtBetweenOrderByCreatedAtDesc(strategyId: Long, status: String, startTs: Long, endTs: Long, pageable: Pageable): Page<WhaleMonitorTrigger>
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
}
@@ -11,6 +11,7 @@ import com.wrbug.polymarketbot.util.getEventSlug
import com.wrbug.polymarketbot.util.parseStringArray
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.stereotype.Service
import java.time.Instant
import java.time.format.DateTimeFormatter
@@ -31,6 +32,12 @@ class MarketService(
private val marketCache: Cache<String, Market> = Caffeine.newBuilder()
.maximumSize(200) // 最多缓存 200 条记录
.build()
/** 体育联赛列表缓存(tagId → seriesId 解析) */
private val sportsCategoriesCache: Cache<String, List<SportCategoryResult>> = Caffeine.newBuilder()
.maximumSize(1)
.expireAfterWrite(java.time.Duration.ofMinutes(10))
.build()
/**
* 根据市场ID获取市场信息
@@ -199,9 +206,20 @@ class MarketService(
)
}
val savedMarket = marketRepository.save(market)
marketCache.put(marketId, savedMarket)
savedMarket
try {
val savedMarket = marketRepository.save(market)
marketCache.put(marketId, savedMarket)
savedMarket
} catch (e: DataIntegrityViolationException) {
// 并发写入同一个 marketId 时可能触发唯一索引冲突,这里降级为查询并返回已有记录
val existingAfter = marketRepository.findByMarketId(marketId)
if (existingAfter != null) {
marketCache.put(marketId, existingAfter)
existingAfter
} else {
throw e
}
}
} catch (e: Exception) {
logger.error("保存市场信息失败: marketId=$marketId, error=${e.message}", e)
null
@@ -281,6 +299,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<MarketSearchResult> {
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<MarketSearchResult> {
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<SportCategoryResult> {
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<MarketSearchResult> {
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<MarketSearchResult> {
val gammaApi = retrofitFactory.createGammaApi()
val kw = keyword.trim().lowercase()
val results = mutableListOf<MarketSearchResult>()
val seenConditionIds = mutableSetOf<String>()
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<SportCategoryResult> {
return try {
getSportsCategoriesCached()
} catch (e: Exception) {
logger.warn("获取体育分类失败: ${e.message}")
emptyList()
}
}
private suspend fun fetchSportsCategoriesFromApi(): List<SportCategoryResult> {
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 +536,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
)
@@ -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()
}
}
@@ -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<String, Mutex>()
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)
}
}
}
@@ -0,0 +1,327 @@
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.service.common.MarketService
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 marketService: MarketService,
private val eventPublisher: ApplicationEventPublisher
) {
private val logger = LoggerFactory.getLogger(WhaleMonitorStrategyService::class.java)
@Transactional
fun create(request: WhaleMonitorStrategyCreateRequest): Result<WhaleMonitorStrategyDto> {
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()
cacheMarkets(request.conditionIds)
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<WhaleMonitorStrategyDto> {
return try {
val existing = strategyRepository.findById(request.strategyId).orElse(null)
?: return Result.failure(IllegalArgumentException(ErrorCode.WHALE_MONITOR_STRATEGY_NOT_FOUND.messageKey))
val updatedConditionIds = request.conditionIds?.also {
if (it.isEmpty()) return Result.failure(IllegalArgumentException(ErrorCode.WHALE_MONITOR_STRATEGY_CONDITION_IDS_EMPTY.messageKey))
}
val conditionIdsJson = updatedConditionIds?.toJson() ?: existing.conditionIds
if (updatedConditionIds != null) {
cacheMarkets(updatedConditionIds)
}
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<Unit> {
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<WhaleMonitorStrategyListResponse> {
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<WhaleMonitorTriggerListResponse> {
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<WhaleMonitorStrategy> = 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"
}
/** 将 conditionId 对应市场写入 markets 表(复用 MarketService 缓存与 Gamma 拉取) */
private fun cacheMarkets(conditionIds: List<String>) {
if (conditionIds.isEmpty()) return
marketService.getMarkets(conditionIds)
}
private fun buildMarketDtos(conditionIds: List<String>): List<WhaleMonitorMarketDto> {
if (conditionIds.isEmpty()) return emptyList()
val cached = marketService.getMarkets(conditionIds)
return conditionIds.map { id ->
val market = cached[id]
if (market != null) {
WhaleMonitorMarketDto(
conditionId = id,
title = market.title,
slug = market.slug,
category = market.category,
image = market.image,
icon = market.icon
)
} else {
WhaleMonitorMarketDto(conditionId = id, title = id)
}
}
}
private fun entityToDto(e: WhaleMonitorStrategy, lastTriggerAt: Long?, triggerCount: Long): WhaleMonitorStrategyDto {
val conditionIdList: List<String> = try {
e.conditionIds.fromJson<List<String>>() ?: emptyList()
} catch (_: Exception) {
emptyList()
}
return WhaleMonitorStrategyDto(
id = e.id ?: 0L,
accountId = e.accountId,
name = e.name,
conditionIds = conditionIdList,
markets = buildMarketDtos(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
)
}
@@ -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<String, MutableList<WhaleMonitorStrategy>>()
/** 聚合窗口:每个 AggKey 维护一个滑动窗口 */
private val windowBuffers = ConcurrentHashMap<AggKey, ArrayDeque<TradePoint>>()
private val runningSums = ConcurrentHashMap<AggKey, BigDecimal>()
/** 冷却:strategyId-tokenId -> 上次触发毫秒时间戳 */
private val cooldownTimestamps = ConcurrentHashMap<String, Long>()
/** txHash 去重 */
private val processedTxHashes: Cache<String, Long> = Caffeine.newBuilder()
.maximumSize(500)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build()
@Volatile
private var isSubscribed = false
private var cleanupJob: Job? = null
fun start(strategies: List<WhaleMonitorStrategy>) {
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<WhaleMonitorStrategy>) {
conditionIdStrategies.clear()
for (strategy in strategies) {
addStrategyToMap(strategy)
}
}
private fun addStrategyToMap(strategy: WhaleMonitorStrategy) {
val ids: List<String> = try {
strategy.conditionIds.fromJson<List<String>>() ?: 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<ActivityTradeMessage>() ?: 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()
}
}
@@ -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='大单监听触发记录表';
@@ -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
@@ -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=新增回测
@@ -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=新增回測
@@ -0,0 +1,97 @@
# Agent 代码产出 Review Checklist
用于:每次 Agent 完成一段功能开发/修复后,你在合并/上线前做快速、系统性的代码审查。
## 0. 变更范围(先看清改了什么)
- **git diff 是否可控**:只包含本需求相关文件,避免“顺手重构”带来风险
- **是否引入新依赖**:新增依赖是否必要、是否有替代、是否影响体积/启动速度
- **配置变更**:是否修改了 `application.properties`(避免无意义的默认值/硬编码)
- **敏感信息**:确认没有提交 `.env`、私钥、API Key、Token、RPC URL 等
## 1. 需求一致性(功能层验收)
- **需求点逐条对照**:每个需求都有对应实现与测试路径
- **边界条件**:空数据、异常响应、重复触发、网络抖动、重连等是否考虑
- **失败行为明确**:API/下单失败时是否记录失败原因并可追踪(禁止“失败返回默认值”掩盖问题)
- **幂等性**:重复请求/重复消息/并发触发不会导致重复下单或重复落库
## 2. 后端(Kotlin / Spring)检查
### 2.1 项目规范硬约束
- **禁止**出现 `TODO/FIXME/XXX` 注释
- **Controller**
- 统一 `@PostMapping`
- **不能**使用 `suspend`
- 调用 suspend 方法时使用 `runBlocking`,且范围最小化
- **数值与时间**
- 金额/数量/价格计算:**必须 BigDecimal**(避免 Double
- DTO/Entity 金额字段:优先 **String 存储**(计算时再转 BigDecimal
- 时间字段:**Long 毫秒时间戳**
- **JSON**
- 优先使用扩展函数 `fromJson<T>()` / `toJson()`,不要直接 new `Gson`
- **API 响应**
- 统一 `ApiResponse { code,data,msg }`
- 错误信息通过 `ErrorCode + MessageSource`,不要硬编码中英文
- 响应里不要直接返回 `Map`
### 2.2 事务与并发
- **@Transactional 边界合理**:避免把网络请求(外部 API/WS)包进大事务
- **并发安全**:涉及下单/触发等必须考虑并发(数据库唯一键、Mutex、幂等表等)
- **重试策略**:重试次数、间隔、是否每次重试重新签名/更新 salt(如适用)
### 2.3 可观测性与排错
- **日志可用**:失败路径有 `logger.error`(包含关键上下文:strategyId/market/tokenId/orderId
- **告警/通知**:关键失败是否能被用户看到(UI/Telegram/推送)
- **性能**:WS 高吞吐场景是否有“快速过滤”避免全量 JSON 解析
## 3. 数据库与实体(如有)
- **Entity 规范**
- `id: Long? = null`
- 时间:Long 毫秒
- 数值字段:DB 用 Decimal,代码用 String/BigDecimal(按项目既定规则)
- **索引/唯一约束**
- 幂等表是否有唯一键(例如 `strategyId + windowStart + tokenId`
- **字段默认值**
- data class 字段必须提供默认值(字符串空串、集合 emptyList 等)
- **迁移脚本**
- 是否存在(如果项目采用迁移工具),是否可回滚/可重复执行
## 4. 前端(React + TypeScript)检查
### 4.1 项目规范硬约束
- **禁止 any**:没有 `any`、没有 `@ts-ignore`(除非明确理由)
- **禁止硬编码文案**:所有 UI 文案都走 `react-i18next``t('...')`
- **移动端适配**:关键页面在 <768px 下可用(按钮 ≥ 44x44
- **金额展示**
- 统一使用现有格式化函数(如 `formatUSDC` 或同类函数),不要手写 `toFixed`
### 4.2 类型与接口
- API 请求/响应类型是否完整、字段命名与后端一致
- 表单校验是否与后端一致(例如价格 0~1、最多两位小数)
- 错误处理是否统一(message/notification、loading 状态)
## 5. 交易/风控相关(若涉及下单)
- **价格区间/精度校验**:前后端双重校验一致
- **深度/滑点校验**:下单前是否检查订单簿深度足够
- **限额**
- 单次最大金额
- 每日最大金额/最大订单数
- cooldown/防连点(策略级)
- **订单类型**:是否符合预期(例如 FAK 快速成交,避免挂单风险)
- **失败不兜底**:下单失败不应该假装成功;必须可追踪与可重试
## 6. 安全与合规
- 私钥/API Secret 解密使用是否符合现有封装(避免重复实现)
- 日志里是否泄露私钥、完整 API Key/Secret、签名原文等
- 外部请求是否走统一的 Retrofit/拦截器(避免绕开认证/代理配置)
## 7. 本地验证(建议最小化操作清单)
- `git status` 干净(除了预期新增/修改文件)
- 后端:能编译通过(至少 `./gradlew test``./gradlew bootJar`,按你们习惯)
- 前端:能构建通过(`pnpm build`/`npm run build`,按你们项目)
- 关键路径手测:创建策略 → 触发 → 过滤/下单 → 记录/通知
## 8. 合并前最后一眼
- 变更是否可回滚(开关、配置、禁用策略等)
- 默认值是否安全(默认禁用自动下单/默认阈值合理/冷却存在)
- 文档是否更新(字段、接口、流程)
+193
View File
@@ -0,0 +1,193 @@
# 市场大单监听策略(10 秒聚合)
## 一、需求概述
目标:对用户配置的**自选市场列表**做实时成交监听。当某个市场的某个 outcome(`tokenId`)在短时间窗口内出现显著的买入成交额(可由多个成交组成),认为存在“聪明钱/趋势”,系统按策略配置自动下单。
本策略的关键特性:
- **监听数据源**Polymarket Activity WebSocket`topic=activity,type=trades`
- **聚合粒度**:按 **`tokenId + side`** 聚合(每个 outcome 单独统计)
- **触发方向**:默认只做 **BUY**(可扩展)
- **触发窗口**:默认 10 秒(可配置)
- **触发阈值**:窗口内累计成交额(金额)达到阈值
- **执行方式**:触发后**自动下单**,并且 **跟随触发成交的 `tokenId`**
- **价格区间**:只在 **下单价** 落入配置区间时才下单(0~1,最多两位小数)
## 二、数据与名词
### 2.1 Activity Trade 关键字段
从 Activity WS trade 消息中我们关注:
- `payload.conditionId`:市场 IDconditionId
- `payload.asset`outcome 的 **tokenId**(下单必须用)
- `payload.side``BUY` / `SELL`
- `payload.price`:成交价(0~1 的概率价)
- `payload.size`:成交数量(shares
- `payload.timestamp`:成交时间
- `payload.transactionHash`:去重用交易哈希(同一笔可能在不同类型推送里出现)
### 2.2 成交额(金额)定义
每条成交的金额计算:
\[
notional = price \times size
\]
其中:
- `price` 取 BigDecimal(字符串化后再解析)
- `size` 取 BigDecimal
- `notional` 作为本策略的累计指标与阈值比较对象
说明:
- 前端展示可以使用 `$` 符号,但后端计算统一使用 `String + BigDecimal`,不要在代码里绑定具体稳定币名称。
## 三、产品流程
### 3.1 策略创建/编辑(前端)
用户创建一条“大单监听策略”时需要配置:
- **基础信息**
- 策略名称
- 绑定账户(下单账户)
- 是否启用
- **监听范围**
- 自选市场列表(按 `conditionId`,可多选)
- **触发条件**
- 窗口秒数 `windowSeconds`(默认 10
- 触发阈值 `thresholdAmount`(金额)
- 冷却时间 `cooldownSeconds`(默认例如 60
- **下单参数**
- 固定下单金额 `orderAmount`(金额)
- `priceTolerance`(可选,用于提高成交概率的容忍度)
- **价格区间过滤(按下单价)**
- `minPrice` / `maxPrice`
- 取值范围:0~1
- 精度:最多两位小数
- 含义:仅当“最终下单价(orderPrice)”满足 `minPrice <= orderPrice <= maxPrice` 才下单;否则视为被过滤(记录过滤原因)。
### 3.2 运行时触发与执行
简化流程:
```
收到 trade 消息
按 tokenId+BUY 入 10 秒滑动窗口,累计 notional
累计 notional ≥ thresholdAmount ?
├─ 否:继续
└─ 是:
cooldown 内已触发过 ?
├─ 是:记录/忽略,不下单
└─ 否:
读取订单簿 bestAsk 计算最终下单价 orderPrice
orderPrice 是否在 [minPrice,maxPrice] ?
├─ 否:记录过滤原因,不下单
└─ 是:
用固定金额 orderAmount 计算 sizeShares
深度/滑点风控通过?
FAK 下单(tokenId=触发tokenId
记录触发与订单结果、推送通知
```
## 四、技术方案(后端)
### 4.1 监听与过滤
推荐新增独立服务(与跟单监听解耦),但复用现有基础设施:
- WebSocket 客户端:`backend/src/main/kotlin/com/wrbug/polymarketbot/websocket/PolymarketWebSocketClient.kt`
- Activity WS 协议:`docs/zh/polymarket-activity-websocket-api.md`
性能要点:
- 订阅全局 trades 可能消息频率较高,必须先做**快速字符串过滤**(按 `conditionId` 集合)再 JSON 解析。
### 4.2 10 秒滑动窗口聚合(按 tokenId+BUY
聚合 key
- `AggKey = (conditionId, tokenId, side)`,本需求默认只统计 `side=BUY`
每个 key 维护:
- `ArrayDeque<TradePoint(tsMillis, notional)>`
- `runningSum`:当前窗口累计 notional
每条新事件:
- 入队并 `runningSum += notional`
- 清理过期事件并 `runningSum -= expiredNotional`
-`runningSum >= thresholdAmount` 且不在 cooldown 内,则触发执行
去重:
- 使用 `txHash` TTL 去重,避免重复触发/重复入窗
### 4.3 冷却与幂等
冷却:
- 对同一 `strategyId + tokenId + side` 记录最近触发时间,`cooldownSeconds` 内不重复触发下单。
幂等落库:
- 触发记录表建议包含:`strategyId, conditionId, tokenId, side, windowStartTs, windowEndTs, windowSumNotional, status, failReason, createdAt`
- 唯一键建议:`strategyId + tokenId + side + windowStartTs`(窗口起点按 `floor(ts/windowMs)*windowMs`
### 4.4 下单执行(FAK,跟随 tokenId
关键步骤:
- 读取订单簿 `bestAsk`
- 生成最终下单价 `orderPrice`
- 若使用 `priceTolerance`:可按 `orderPrice = bestAsk * (1 + priceTolerance)`,并根据业务限制做截位
- **价格区间校验**(必须)
- `minPrice/maxPrice` 均为字符串配置,解析为 BigDecimal
- 必须满足:0~1 范围、最多两位小数(配置校验与下单前双重校验)
- 最终判断:`minPrice <= orderPrice <= maxPrice`
- 计算下单数量:
- `sizeShares = orderAmount / orderPrice`
- 使用你们统一的 BigDecimal 扩展函数做除法精度与四舍五入策略
- 风控建议:
- 订单簿深度能否覆盖 `orderAmount`(必要)
- 单次最大金额 / 日累计金额(建议)
- 最大允许价差/滑点(建议)
- 提交订单:
- 复用 `OrderSigningService.createAndSignOrder`
- `orderType = FAK`(允许部分成交,未成交部分取消)
## 五、配置校验规则
### 5.1 价格区间(minPrice/maxPrice
规则:
- 范围:`0 <= price <= 1`
- 精度:最多两位小数
建议校验点:
- 策略创建/更新时校验(后端)
- 下单前再次校验(避免异常数据导致下单)
### 5.2 价格与金额数据类型
- 后端 DTO/Entity 使用 `String` 存储金额与价格(保持统一,与现有跟单/策略一致)
- 计算时转换为 BigDecimal
- 禁止使用 `Double` 做金额/价格运算
## 六、可观测性与通知
建议输出与推送:
- 触发事件:包含市场、tokenId、窗口累计金额、下单价、是否通过区间、是否下单
- 下单结果:订单 ID、成交数量/均价(如可获取)、失败原因
## 七、后续扩展(可选)
- 支持 SELL 方向(做反向策略或止损策略)
- 触发条件增加“成交笔数”“大额单笔阈值”等组合条件
- 支持“市场整体聚合”(按 conditionId 聚合,而不是 tokenId)作为另一种策略类型
+4
View File
@@ -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() {
<Route path="/copy-trading" element={<ProtectedRoute><CopyTradingList /></ProtectedRoute>} />
<Route path="/crypto-tail-strategy" element={<ProtectedRoute><CryptoTailStrategyList /></ProtectedRoute>} />
<Route path="/crypto-tail-monitor" element={<ProtectedRoute><CryptoTailMonitor /></ProtectedRoute>} />
<Route path="/whale-monitor-strategy" element={<ProtectedRoute><WhaleMonitorStrategyList /></ProtectedRoute>} />
<Route path="/whale-monitor-strategy/markets" element={<ProtectedRoute><WhaleMonitorMarketSelect /></ProtectedRoute>} />
<Route path="/copy-trading/statistics/:copyTradingId" element={<ProtectedRoute><CopyTradingStatistics /></ProtectedRoute>} />
{/* 保留旧路由以保持向后兼容 */}
<Route path="/copy-trading/orders/buy/:copyTradingId" element={<ProtectedRoute><CopyTradingBuyOrders /></ProtectedRoute>} />
+22 -3
View File
@@ -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<LayoutProps> = ({ 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<string[]>(getInitialOpenKeys())
// 当路径变化时,自动打开对应的父菜单
useEffect(() => {
const path = location.pathname
@@ -99,6 +103,9 @@ const Layout: React.FC<LayoutProps> = ({ 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<LayoutProps> = ({ children }) => {
}
]
},
{
key: '/whale-monitor-management',
icon: <EyeOutlined />,
label: t('menu.whaleMonitorStrategy'),
children: [
{
key: '/whale-monitor-strategy',
icon: <DashboardOutlined />,
label: t('menu.whaleMonitorStrategyConfig')
}
]
},
{
key: '/positions',
icon: <UnorderedListOutlined />,
@@ -0,0 +1,267 @@
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'
const { Text, Title } = Typography
interface WhaleMonitorMarketGroupedListProps {
markets: WhaleMonitorMarketItem[]
selectedMap: Map<string, WhaleMonitorMarketItem>
isMobile: boolean
onToggleMarket: (market: WhaleMonitorMarketItem, checked: boolean) => void
onToggleGroup: (markets: WhaleMonitorMarketItem[], checked: boolean) => void
}
const renderFlatMarketList = (
markets: WhaleMonitorMarketItem[],
selectedMap: Map<string, WhaleMonitorMarketItem>,
isMobile: boolean,
onToggleMarket: (market: WhaleMonitorMarketItem, checked: boolean) => void,
compact = false
) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: isMobile ? 4 : 8 }}>
{markets.map(market => (
<WhaleMonitorMarketListItem
key={market.conditionId}
market={market}
checked={selectedMap.has(market.conditionId)}
isMobile={isMobile}
hideEventTitle
compact={compact}
onToggle={checked => onToggleMarket(market, checked)}
/>
))}
</div>
)
const renderMarketGroups = (
groups: WhaleMonitorMarketGroup[],
selectedMap: Map<string, WhaleMonitorMarketItem>,
isMobile: boolean,
onToggleMarket: (market: WhaleMonitorMarketItem, checked: boolean) => void,
onToggleGroup: (markets: WhaleMonitorMarketItem[], checked: boolean) => void,
t: (key: string, options?: Record<string, number>) => string,
defaultActiveKeys?: string[]
) => {
if (groups.length <= 1) {
return renderFlatMarketList(
groups.flatMap(g => g.markets),
selectedMap,
isMobile,
onToggleMarket,
groups.length === 1 && groups[0].key.startsWith('event:')
)
}
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: (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
width: '100%',
paddingRight: 8
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<Text strong style={{ wordBreak: 'break-word' }}>
{group.title}
</Text>
<Text type="secondary" style={{ fontSize: 12, display: 'block' }}>
{t('whaleMonitorStrategy.marketSelect.marketsInGroup', { count: group.markets.length })}
</Text>
</div>
<div onClick={e => e.stopPropagation()} onKeyDown={e => e.stopPropagation()}>
<Checkbox
checked={allSelected}
indeterminate={indeterminate}
onChange={e => onToggleGroup(group.markets, e.target.checked)}
>
<span style={{ fontSize: 12 }}>{t('whaleMonitorStrategy.marketSelect.selectGroup')}</span>
</Checkbox>
</div>
</div>
),
children: (
<div style={{ display: 'flex', flexDirection: 'column', gap: isMobile ? 4 : 6 }}>
{group.markets.map(market => (
<WhaleMonitorMarketListItem
key={market.conditionId}
market={market}
checked={selectedMap.has(market.conditionId)}
isMobile={isMobile}
hideEventTitle
compact
onToggle={checked => onToggleMarket(market, checked)}
/>
))}
</div>
)
}
})
return (
<Collapse
bordered={false}
defaultActiveKey={defaultActiveKeys ?? groups.map(g => g.key)}
items={collapseItems}
style={{ background: 'transparent' }}
/>
)
}
const renderSectionHeader = (sectionTitle: string, marketCount: number) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Text strong style={{ fontSize: 16 }}>
{sectionTitle}
</Text>
<Text type="secondary" style={{ fontSize: 13, fontWeight: 400 }}>
({marketCount})
</Text>
</div>
)
const renderSectionBody = (
section: { key: 'game' | 'season'; groups: WhaleMonitorMarketGroup[] },
selectedMap: Map<string, WhaleMonitorMarketItem>,
isMobile: boolean,
onToggleMarket: (market: WhaleMonitorMarketItem, checked: boolean) => void,
onToggleGroup: (markets: WhaleMonitorMarketItem[], checked: boolean) => void,
t: (key: string, options?: Record<string, number>) => string
) => {
const isSeason = section.key === 'season'
return renderMarketGroups(
section.groups,
selectedMap,
isMobile,
onToggleMarket,
onToggleGroup,
t,
isSeason ? [] : section.groups.map(g => g.key)
)
}
const WhaleMonitorMarketGroupedList: React.FC<WhaleMonitorMarketGroupedListProps> = ({
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]
)
const totalGroups = sections.reduce((sum, s) => sum + s.groups.length, 0)
const allMarkets = sections.flatMap(s => s.groups.flatMap(g => g.markets))
if (totalGroups <= 1) {
return renderFlatMarketList(allMarkets, selectedMap, isMobile, onToggleMarket)
}
if (sections.length === 1) {
const section = sections[0]
const marketCount = section.groups.reduce((sum, g) => sum + g.markets.length, 0)
if (section.key === 'season' && section.groups.length > 1) {
return (
<Collapse
bordered={false}
defaultActiveKey={[]}
style={{ background: 'transparent' }}
items={[
{
key: 'season-section',
label: renderSectionHeader(section.title, marketCount),
children: renderSectionBody(
section,
selectedMap,
isMobile,
onToggleMarket,
onToggleGroup,
t
)
}
]}
/>
)
}
return (
<>
<Title level={5} style={{ marginTop: 0, marginBottom: 12 }}>
{section.title}
<Text type="secondary" style={{ fontSize: 13, fontWeight: 400, marginLeft: 8 }}>
({marketCount})
</Text>
</Title>
{renderSectionBody(section, selectedMap, isMobile, onToggleMarket, onToggleGroup, t)}
</>
)
}
return (
<div>
{sections.map((section, index) => {
const marketCount = section.groups.reduce((sum, g) => sum + g.markets.length, 0)
return (
<div key={section.key}>
{index > 0 && <Divider style={{ margin: '16px 0' }} />}
{section.key === 'season' && section.groups.length > 1 ? (
<Collapse
bordered={false}
defaultActiveKey={[]}
style={{ background: 'transparent' }}
items={[
{
key: 'season-section',
label: renderSectionHeader(section.title, marketCount),
children: renderSectionBody(
section,
selectedMap,
isMobile,
onToggleMarket,
onToggleGroup,
t
)
}
]}
/>
) : (
<>
<Title level={5} style={{ marginTop: index === 0 ? 0 : undefined, marginBottom: 12 }}>
{section.title}
<Text type="secondary" style={{ fontSize: 13, fontWeight: 400, marginLeft: 8 }}>
({marketCount})
</Text>
</Title>
{renderSectionBody(section, selectedMap, isMobile, onToggleMarket, onToggleGroup, t)}
</>
)}
</div>
)
})}
</div>
)
}
export default WhaleMonitorMarketGroupedList
@@ -0,0 +1,135 @@
import { Checkbox, Tag, Typography } from 'antd'
import { LinkOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import { parseMarketOutcomes } from '../constants/whaleMonitor'
import type { WhaleMonitorMarketItem } from '../types'
import { formatUSDC } from '../utils'
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<WhaleMonitorMarketListItemProps> = ({
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 handleOpenLink = (e: React.MouseEvent) => {
e.stopPropagation()
if (polymarketUrl) {
window.open(polymarketUrl, '_blank', 'noopener,noreferrer')
}
}
return (
<div
role="button"
tabIndex={0}
onClick={() => 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'
}}
>
<Checkbox
checked={checked}
onClick={e => e.stopPropagation()}
onChange={e => onToggle(e.target.checked)}
style={{ marginTop: 2 }}
/>
<div style={{ flex: 1, minWidth: 0 }}>
{market.eventTitle && !hideEventTitle && (
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 2 }}>
{market.eventTitle}
</Text>
)}
<div style={{ fontWeight: 500, wordBreak: 'break-word', lineHeight: 1.4 }}>{market.title}</div>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: 6,
marginTop: 6
}}
>
{market.category && (
<Tag style={{ margin: 0 }}>{market.category}</Tag>
)}
{volumeDisplay && (
<Text type="secondary" style={{ fontSize: 12 }}>
{t('whaleMonitorStrategy.marketSelect.volume')}: ${volumeDisplay}
</Text>
)}
</div>
{outcomeLabels.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 6 }}>
{outcomeLabels.map(label => (
<Tag key={label} bordered={false} style={{ margin: 0, fontSize: 11 }}>
{label}
</Tag>
))}
</div>
)}
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: 8,
marginTop: 6
}}
>
<Text type="secondary" style={{ fontSize: 11 }}>
{formatConditionId(market.conditionId)}
</Text>
{polymarketUrl && (
<a
href={polymarketUrl}
target="_blank"
rel="noopener noreferrer"
onClick={handleOpenLink}
style={{ fontSize: 12, display: 'inline-flex', alignItems: 'center', gap: 4 }}
>
<LinkOutlined />
{t('whaleMonitorStrategy.marketSelect.viewMarket')}
</a>
)}
</div>
</div>
</div>
)
}
export default WhaleMonitorMarketListItem
@@ -0,0 +1,454 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Button, Card, Empty, Input, Modal, Segmented, Select, Space, Spin, Tag, Typography } from 'antd'
import { SearchOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import { useMediaQuery } from 'react-responsive'
import { apiService } from '../services/api'
import WhaleMonitorMarketGroupedList from './WhaleMonitorMarketGroupedList'
import WhaleMonitorMarketThumbnail from './WhaleMonitorMarketThumbnail'
import { toWhaleMonitorMarketItem } from '../constants/whaleMonitor'
import type { WhaleMonitorMarketItem } from '../types'
const { Text } = Typography
const TAG_OPTIONS = [
{ key: 'sports', value: '1' },
{ key: 'politics', value: '2' },
{ key: 'crypto', value: '21' },
{ key: 'popCulture', value: '100639' }
] as const
const MIN_SEARCH_LENGTH = 2
interface TagViewCache {
sportSubSeriesId?: string
keyword: string
debouncedKeyword: string
markets: WhaleMonitorMarketItem[]
}
const buildListCacheKey = (tag: string, seriesId: string | undefined, searchKeyword: string): string =>
`${tag}::${seriesId ?? ''}::${searchKeyword}`
interface WhaleMonitorMarketSelectModalProps {
open: boolean
initialSelected?: WhaleMonitorMarketItem[]
onCancel: () => void
onConfirm: (selected: WhaleMonitorMarketItem[]) => void
}
const WhaleMonitorMarketSelectModal: React.FC<WhaleMonitorMarketSelectModalProps> = ({
open,
initialSelected,
onCancel,
onConfirm
}) => {
const { t } = useTranslation()
const isMobile = useMediaQuery({ maxWidth: 768 })
const [keyword, setKeyword] = useState('')
const [debouncedKeyword, setDebouncedKeyword] = useState('')
const [tagId, setTagId] = useState<string | undefined>(undefined)
const [sportSubSeriesId, setSportSubSeriesId] = useState<string | undefined>(undefined)
const [sportSubCategories, setSportSubCategories] = useState<
Array<{ id: number; slug: string; label: string; tagId: string; seriesId: string; image?: string }>
>([])
const [marketList, setMarketList] = useState<WhaleMonitorMarketItem[]>([])
const [loading, setLoading] = useState(false)
const [selectedMap, setSelectedMap] = useState<Map<string, WhaleMonitorMarketItem>>(new Map())
const tagViewCacheRef = useRef<Map<string, TagViewCache>>(new Map())
const listCacheRef = useRef<Map<string, WhaleMonitorMarketItem[]>>(new Map())
const skipNextFetchRef = useRef(false)
const initialTagSetRef = useRef(false)
const initialSportLeagueSetRef = useRef(false)
useEffect(() => {
if (!open) return
const map = new Map<string, WhaleMonitorMarketItem>()
;(initialSelected ?? []).forEach(m => map.set(m.conditionId, m))
setSelectedMap(map)
}, [open, initialSelected])
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])
const fetchSportSubCategories = useCallback(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([])
}
}, [])
useEffect(() => {
if (!open) return
if (initialTagSetRef.current) return
initialTagSetRef.current = true
setTagId(TAG_OPTIONS[0].value)
}, [open])
useEffect(() => {
if (!open) return
if (tagId === '1') {
if (sportSubCategories.length === 0) {
fetchSportSubCategories()
}
} else {
setSportSubCategories([])
}
}, [open, tagId, sportSubCategories.length, fetchSportSubCategories])
useEffect(() => {
if (!open) return
if (tagId !== '1' || sportSubCategories.length === 0) return
if (sportSubSeriesId) return
const tagCached = tagViewCacheRef.current.get('1')
if (tagCached?.sportSubSeriesId) {
setSportSubSeriesId(tagCached.sportSubSeriesId)
return
}
if (!initialSportLeagueSetRef.current) {
initialSportLeagueSetRef.current = true
setSportSubSeriesId(sportSubCategories[0].seriesId)
}
}, [open, tagId, sportSubCategories, sportSubSeriesId])
const saveCurrentTagView = useCallback(() => {
if (!tagId) return
tagViewCacheRef.current.set(tagId, {
sportSubSeriesId,
keyword,
debouncedKeyword,
markets: marketList
})
listCacheRef.current.set(buildListCacheKey(tagId, sportSubSeriesId, debouncedKeyword), marketList)
}, [tagId, sportSubSeriesId, keyword, debouncedKeyword, marketList])
const handleTagChange = (newTagId: string) => {
if (newTagId === tagId) return
saveCurrentTagView()
const cached = tagViewCacheRef.current.get(newTagId)
skipNextFetchRef.current = !!cached
setTagId(newTagId)
if (cached) {
setSportSubSeriesId(cached.sportSubSeriesId)
setKeyword(cached.keyword)
setDebouncedKeyword(cached.debouncedKeyword)
setMarketList(cached.markets)
if (newTagId === '1' && sportSubCategories.length === 0) {
fetchSportSubCategories()
}
return
}
setKeyword('')
setDebouncedKeyword('')
setMarketList([])
if (newTagId === '1') {
setSportSubSeriesId(undefined)
if (sportSubCategories.length === 0) {
fetchSportSubCategories()
}
} else {
setSportSubSeriesId(undefined)
setSportSubCategories([])
}
}
const flushSearch = () => {
setDebouncedKeyword(keyword.trim())
}
const fetchMarkets = useCallback(async () => {
if (!open) return
if (!tagId) return
const selectedSport = sportSubCategories.find(s => s.seriesId === sportSubSeriesId)
const seriesId = sportSubSeriesId
const sportSlug = selectedSport?.slug
const searchKeyword = debouncedKeyword
const hasKeyword = searchKeyword.length >= MIN_SEARCH_LENGTH
const categoryTagId = tagId !== '1' ? tagId : undefined
const sportsTagId = tagId === '1' && !seriesId && hasKeyword ? '1' : undefined
const effectiveTagId = categoryTagId || sportsTagId
const listCacheKey = buildListCacheKey(tagId, sportSubSeriesId, searchKeyword)
if (tagId === '1' && !sportSubSeriesId && !hasKeyword) {
setMarketList([])
return
}
if (!seriesId && !effectiveTagId && !hasKeyword) {
setMarketList([])
return
}
const listCached = listCacheRef.current.get(listCacheKey)
if (listCached) {
setMarketList(listCached)
return
}
setLoading(true)
try {
const res = await apiService.markets.search({
keyword: hasKeyword ? searchKeyword : '',
seriesId: seriesId || undefined,
sportSlug: seriesId ? sportSlug : undefined,
tagId: seriesId ? undefined : effectiveTagId,
limit: 200
})
if (res.data.code === 0 && res.data.data) {
const items = res.data.data.map(m => toWhaleMonitorMarketItem(m))
listCacheRef.current.set(listCacheKey, items)
setMarketList(items)
tagViewCacheRef.current.set(tagId, {
sportSubSeriesId,
keyword,
debouncedKeyword: searchKeyword,
markets: items
})
} else {
setMarketList([])
}
} catch {
setMarketList([])
} finally {
setLoading(false)
}
}, [open, debouncedKeyword, tagId, sportSubSeriesId, sportSubCategories, keyword])
useEffect(() => {
if (!open) return
if (skipNextFetchRef.current) {
skipNextFetchRef.current = false
return
}
fetchMarkets()
}, [open, fetchMarkets])
const handleSportLeagueChange = (seriesId: string | undefined) => {
saveCurrentTagView()
setSportSubSeriesId(seriesId)
}
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 showSelectSportHint = tagId === '1' && !sportSubSeriesId && debouncedKeyword.length < MIN_SEARCH_LENGTH
const showSportsGamesEmpty =
tagId === '1' && !!sportSubSeriesId && marketList.length === 0 && !loading && debouncedKeyword.length < MIN_SEARCH_LENGTH
const listScrollHeight = isMobile ? 'min(360px, calc(70vh - 240px))' : 400
return (
<Modal
open={open}
onCancel={onCancel}
title={t('whaleMonitorStrategy.marketSelect.title')}
width={isMobile ? '95%' : 980}
destroyOnClose
footer={
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: 8
}}
>
<Text type="secondary">
{t('whaleMonitorStrategy.marketSelect.selectedCount', { count: selectedList.length })}
</Text>
<Space>
<Button onClick={onCancel} style={{ minHeight: 44 }}>
{t('common.cancel')}
</Button>
<Button type="primary" onClick={() => onConfirm(selectedList)} style={{ minHeight: 44 }}>
{isMobile
? t('whaleMonitorStrategy.marketSelect.confirmWithCount', { count: selectedList.length })
: t('whaleMonitorStrategy.marketSelect.confirm')}
</Button>
</Space>
</div>
}
styles={{
body: {
padding: isMobile ? 12 : 16,
maxHeight: isMobile ? '75vh' : '80vh',
overflow: 'hidden'
}
}}
>
<Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
{t('whaleMonitorStrategy.marketSelect.subtitle')}
</Text>
<Card size="small" style={{ marginBottom: 12, flexShrink: 0 }}>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder={t('whaleMonitorStrategy.marketSelect.searchPlaceholder')}
value={keyword}
onChange={e => setKeyword(e.target.value)}
onPressEnter={flushSearch}
size={isMobile ? 'large' : 'middle'}
style={{ marginBottom: 12 }}
/>
<Segmented
block={isMobile}
options={tagSegmentOptions}
value={tagId}
onChange={val => handleTagChange(val)}
style={{ marginBottom: tagId === '1' ? 12 : 0 }}
/>
{tagId === '1' && sportSubCategories.length > 0 && (
<>
<Select
allowClear
showSearch
placeholder={t('whaleMonitorStrategy.form.sportLeague')}
style={{ width: '100%', marginTop: 12 }}
value={sportSubSeriesId}
onChange={handleSportLeagueChange}
optionFilterProp="label"
size={isMobile ? 'large' : 'middle'}
options={sportSubCategories.map(s => ({
label: s.label,
value: s.seriesId,
image: s.image
}))}
optionRender={option => (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<WhaleMonitorMarketThumbnail src={option.data.image as string | undefined} size={28} />
<span>{option.label}</span>
</div>
)}
labelRender={props => {
const sport = sportSubCategories.find(s => s.seriesId === props.value)
if (!sport) return props.label
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<WhaleMonitorMarketThumbnail src={sport.image} size={22} />
<span>{sport.label}</span>
</div>
)
}}
/>
{sportSubSeriesId && (
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 8 }}>
{t('whaleMonitorStrategy.marketSelect.sportsGamesHint')}
</Text>
)}
</>
)}
</Card>
{selectedList.length > 0 && (
<Card
size="small"
title={t('whaleMonitorStrategy.marketSelect.selectedTitle', { count: selectedList.length })}
style={{ marginBottom: 12, flexShrink: 0 }}
styles={{ body: { maxHeight: 96, overflowY: 'auto', padding: '8px 12px' } }}
>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{selectedList.map(m => (
<Tag
key={m.conditionId}
closable
onClose={() => toggleMarket(m, false)}
style={{ margin: 0, maxWidth: '100%' }}
>
<span style={{ wordBreak: 'break-all' }}>{m.title}</span>
</Tag>
))}
</div>
</Card>
)}
<Card
size="small"
styles={{ body: { padding: isMobile ? 8 : 16, paddingBottom: 8 } }}
style={{ marginBottom: 0 }}
>
<div
style={{
height: listScrollHeight,
minHeight: 200,
overflowY: 'auto',
overflowX: 'hidden',
WebkitOverflowScrolling: 'touch'
}}
>
<Spin spinning={loading}>
{showSelectSportHint ? (
<Empty description={t('whaleMonitorStrategy.form.selectSportFirst')} />
) : showSportsGamesEmpty ? (
<Empty description={t('whaleMonitorStrategy.marketSelect.sportsGamesEmpty')} />
) : marketList.length === 0 && !loading ? (
<Empty description={t('whaleMonitorStrategy.marketSelect.empty')} />
) : (
<WhaleMonitorMarketGroupedList
markets={marketList}
selectedMap={selectedMap}
isMobile={isMobile}
onToggleMarket={toggleMarket}
onToggleGroup={toggleGroupMarkets}
/>
)}
</Spin>
</div>
</Card>
</Modal>
)
}
export default WhaleMonitorMarketSelectModal
@@ -0,0 +1,36 @@
interface WhaleMonitorMarketThumbnailProps {
src?: string
size?: number
alt?: string
}
const WhaleMonitorMarketThumbnail: React.FC<WhaleMonitorMarketThumbnailProps> = ({
src,
size = 40,
alt = ''
}) => {
if (!src?.trim()) return null
return (
<img
src={src}
alt={alt}
loading="lazy"
decoding="async"
style={{
width: size,
height: size,
borderRadius: 6,
objectFit: 'cover',
flexShrink: 0,
background: '#f5f5f5'
}}
onError={e => {
const target = e.currentTarget
target.style.display = 'none'
}}
/>
)
}
export default WhaleMonitorMarketThumbnail
+229
View File
@@ -0,0 +1,229 @@
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 }
})
}
/** 策略列表/详情中的 markets 字段转为表单展示项 */
export function strategyMarketsToItems(
markets: Array<{
conditionId: string
title: string
slug?: string
category?: string
image?: string
icon?: string
}>
): WhaleMonitorMarketItem[] {
return markets.map(m => ({
conditionId: m.conditionId,
title: m.title,
slug: m.slug,
category: m.category,
image: m.image,
icon: m.icon
}))
}
/** 解析 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<string, WhaleMonitorMarketItem[]>()
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<string, WhaleMonitorMarketItem[]>()
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
}
}
+109
View File
@@ -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": "Pick a category above, or enter at least 2 characters to search",
"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.",
+109
View File
@@ -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": "请选择上方分类,或输入至少 2 个字符搜索",
"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 迁移操作,以确保交易功能正常使用。",
+109
View File
@@ -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": "請選擇上方分類,或輸入至少 2 個字符搜索",
"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 遷移操作,以確保交易功能正常使用。",
@@ -0,0 +1,456 @@
import { useCallback, useEffect, useMemo, useRef, 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: 'sports', value: '1' },
{ key: 'politics', value: '2' },
{ key: 'crypto', value: '21' },
{ key: 'popCulture', value: '100639' }
] as const
const MIN_SEARCH_LENGTH = 2
interface TagViewCache {
sportSubSeriesId?: string
keyword: string
debouncedKeyword: string
markets: WhaleMonitorMarketItem[]
}
const buildListCacheKey = (
tag: string,
seriesId: string | undefined,
searchKeyword: string
): string => `${tag}::${seriesId ?? ''}::${searchKeyword}`
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<string | undefined>(undefined)
const [sportSubSeriesId, setSportSubSeriesId] = useState<string | undefined>(undefined)
const [sportSubCategories, setSportSubCategories] = useState<
Array<{ id: number; slug: string; label: string; tagId: string; seriesId: string; image?: string }>
>([])
const [marketList, setMarketList] = useState<WhaleMonitorMarketItem[]>([])
const [loading, setLoading] = useState(false)
const [selectedMap, setSelectedMap] = useState<Map<string, WhaleMonitorMarketItem>>(() => {
const map = new Map<string, WhaleMonitorMarketItem>()
initialSelected.forEach(m => map.set(m.conditionId, m))
return map
})
const tagViewCacheRef = useRef<Map<string, TagViewCache>>(new Map())
const listCacheRef = useRef<Map<string, WhaleMonitorMarketItem[]>>(new Map())
const skipNextFetchRef = useRef(false)
const initialTagSetRef = useRef(false)
const initialSportLeagueSetRef = useRef(false)
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])
const fetchSportSubCategories = useCallback(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([])
}
}, [])
useEffect(() => {
if (initialTagSetRef.current) return
initialTagSetRef.current = true
setTagId(TAG_OPTIONS[0].value)
}, [])
useEffect(() => {
if (tagId === '1') {
if (sportSubCategories.length === 0) {
fetchSportSubCategories()
}
} else {
setSportSubCategories([])
}
}, [tagId, sportSubCategories.length, fetchSportSubCategories])
useEffect(() => {
if (tagId !== '1' || sportSubCategories.length === 0) return
if (sportSubSeriesId) return
const tagCached = tagViewCacheRef.current.get('1')
if (tagCached?.sportSubSeriesId) {
setSportSubSeriesId(tagCached.sportSubSeriesId)
return
}
if (!initialSportLeagueSetRef.current) {
initialSportLeagueSetRef.current = true
setSportSubSeriesId(sportSubCategories[0].seriesId)
}
}, [tagId, sportSubCategories, sportSubSeriesId])
const saveCurrentTagView = useCallback(() => {
if (!tagId) return
tagViewCacheRef.current.set(tagId, {
sportSubSeriesId,
keyword,
debouncedKeyword,
markets: marketList
})
listCacheRef.current.set(
buildListCacheKey(tagId, sportSubSeriesId, debouncedKeyword),
marketList
)
}, [tagId, sportSubSeriesId, keyword, debouncedKeyword, marketList])
const handleTagChange = (newTagId: string) => {
if (newTagId === tagId) return
saveCurrentTagView()
const cached = tagViewCacheRef.current.get(newTagId)
skipNextFetchRef.current = !!cached
setTagId(newTagId)
if (cached) {
setSportSubSeriesId(cached.sportSubSeriesId)
setKeyword(cached.keyword)
setDebouncedKeyword(cached.debouncedKeyword)
setMarketList(cached.markets)
if (newTagId === '1' && sportSubCategories.length === 0) {
fetchSportSubCategories()
}
return
}
setKeyword('')
setDebouncedKeyword('')
setMarketList([])
if (newTagId === '1') {
setSportSubSeriesId(undefined)
if (sportSubCategories.length === 0) {
fetchSportSubCategories()
}
} else {
setSportSubSeriesId(undefined)
setSportSubCategories([])
}
}
const flushSearch = () => {
setDebouncedKeyword(keyword.trim())
}
const fetchMarkets = useCallback(async () => {
if (!tagId) return
const selectedSport = sportSubCategories.find(s => s.seriesId === sportSubSeriesId)
const seriesId = sportSubSeriesId
const sportSlug = selectedSport?.slug
const searchKeyword = debouncedKeyword
const hasKeyword = searchKeyword.length >= MIN_SEARCH_LENGTH
const categoryTagId = tagId !== '1' ? tagId : undefined
const sportsTagId = tagId === '1' && !seriesId && hasKeyword ? '1' : undefined
const effectiveTagId = categoryTagId || sportsTagId
const listCacheKey = buildListCacheKey(tagId, sportSubSeriesId, searchKeyword)
if (tagId === '1' && !sportSubSeriesId && !hasKeyword) {
setMarketList([])
return
}
if (!seriesId && !effectiveTagId && !hasKeyword) {
setMarketList([])
return
}
const listCached = listCacheRef.current.get(listCacheKey)
if (listCached) {
setMarketList(listCached)
return
}
setLoading(true)
try {
const res = await apiService.markets.search({
keyword: hasKeyword ? searchKeyword : '',
seriesId: seriesId || undefined,
sportSlug: seriesId ? sportSlug : undefined,
tagId: seriesId ? undefined : effectiveTagId,
limit: 200
})
if (res.data.code === 0 && res.data.data) {
const items = res.data.data.map(m => toWhaleMonitorMarketItem(m))
listCacheRef.current.set(listCacheKey, items)
setMarketList(items)
tagViewCacheRef.current.set(tagId, {
sportSubSeriesId,
keyword,
debouncedKeyword: searchKeyword,
markets: items
})
} else {
setMarketList([])
}
} catch {
setMarketList([])
} finally {
setLoading(false)
}
}, [debouncedKeyword, tagId, sportSubSeriesId, sportSubCategories, keyword])
useEffect(() => {
if (skipNextFetchRef.current) {
skipNextFetchRef.current = false
return
}
fetchMarkets()
}, [fetchMarkets])
const handleSportLeagueChange = (seriesId: string | undefined) => {
saveCurrentTagView()
setSportSubSeriesId(seriesId)
}
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 < MIN_SEARCH_LENGTH
const showSportsGamesEmpty =
tagId === '1' &&
!!sportSubSeriesId &&
marketList.length === 0 &&
!loading &&
debouncedKeyword.length < MIN_SEARCH_LENGTH
const showSearchHint = !tagId && debouncedKeyword.length < MIN_SEARCH_LENGTH
return (
<div style={{ padding: isMobile ? 12 : 24, paddingBottom: isMobile ? 88 : 24 }}>
<div style={{ marginBottom: 16 }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={handleBack}
style={{ marginBottom: 12, minHeight: 44 }}
>
{t('whaleMonitorStrategy.marketSelect.back')}
</Button>
<Title level={isMobile ? 4 : 3} style={{ margin: 0 }}>
{t('whaleMonitorStrategy.marketSelect.title')}
</Title>
<Text type="secondary" style={{ display: 'block', marginTop: 4 }}>
{t('whaleMonitorStrategy.marketSelect.subtitle')}
</Text>
</div>
<Card size="small" style={{ marginBottom: 12 }}>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder={t('whaleMonitorStrategy.marketSelect.searchPlaceholder')}
value={keyword}
onChange={e => setKeyword(e.target.value)}
onPressEnter={flushSearch}
size={isMobile ? 'large' : 'middle'}
style={{ marginBottom: 12 }}
/>
<Segmented
block={isMobile}
options={tagSegmentOptions}
value={tagId}
onChange={val => handleTagChange(val)}
style={{ marginBottom: tagId === '1' ? 12 : 0 }}
/>
{tagId === '1' && sportSubCategories.length > 0 && (
<>
<Select
allowClear
showSearch
placeholder={t('whaleMonitorStrategy.form.sportLeague')}
style={{ width: '100%', marginTop: 12 }}
value={sportSubSeriesId}
onChange={handleSportLeagueChange}
optionFilterProp="label"
size={isMobile ? 'large' : 'middle'}
options={sportSubCategories.map(s => ({
label: s.label,
value: s.seriesId,
image: s.image
}))}
optionRender={option => (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<WhaleMonitorMarketThumbnail src={option.data.image as string | undefined} size={28} />
<span>{option.label}</span>
</div>
)}
labelRender={props => {
const sport = sportSubCategories.find(s => s.seriesId === props.value)
if (!sport) return props.label
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<WhaleMonitorMarketThumbnail src={sport.image} size={22} />
<span>{sport.label}</span>
</div>
)
}}
/>
{sportSubSeriesId && (
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 8 }}>
{t('whaleMonitorStrategy.marketSelect.sportsGamesHint')}
</Text>
)}
</>
)}
</Card>
{selectedList.length > 0 && (
<Card size="small" title={t('whaleMonitorStrategy.marketSelect.selectedTitle', { count: selectedList.length })} style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{selectedList.map(m => (
<Tag
key={m.conditionId}
closable
onClose={() => toggleMarket(m, false)}
style={{ margin: 0, maxWidth: '100%' }}
>
<span style={{ wordBreak: 'break-all' }}>{m.title}</span>
</Tag>
))}
</div>
</Card>
)}
<Card size="small" bodyStyle={{ padding: isMobile ? 8 : 16 }}>
<Spin spinning={loading}>
{showSearchHint ? (
<Empty description={t('whaleMonitorStrategy.marketSelect.searchHint')} />
) : showSelectSportHint ? (
<Empty description={t('whaleMonitorStrategy.form.selectSportFirst')} />
) : showSportsGamesEmpty ? (
<Empty description={t('whaleMonitorStrategy.marketSelect.sportsGamesEmpty')} />
) : marketList.length === 0 && !loading ? (
<Empty description={t('whaleMonitorStrategy.marketSelect.empty')} />
) : (
<WhaleMonitorMarketGroupedList
markets={marketList}
selectedMap={selectedMap}
isMobile={isMobile}
onToggleMarket={toggleMarket}
onToggleGroup={toggleGroupMarkets}
/>
)}
</Spin>
</Card>
<div
style={{
position: isMobile ? 'fixed' : 'sticky',
bottom: 0,
left: 0,
right: 0,
padding: isMobile ? '12px 16px' : '16px 0 0',
background: isMobile ? '#fff' : 'transparent',
borderTop: isMobile ? '1px solid #f0f0f0' : undefined,
zIndex: 10,
display: 'flex',
gap: 12,
alignItems: 'center',
justifyContent: 'flex-end'
}}
>
{!isMobile && (
<Text type="secondary">
{t('whaleMonitorStrategy.marketSelect.selectedCount', { count: selectedList.length })}
</Text>
)}
<Button onClick={handleBack} style={{ minHeight: 44 }}>
{t('common.cancel')}
</Button>
<Button type="primary" onClick={handleConfirm} style={{ minHeight: 44 }}>
{isMobile
? t('whaleMonitorStrategy.marketSelect.confirmWithCount', { count: selectedList.length })
: t('whaleMonitorStrategy.marketSelect.confirm')}
</Button>
</div>
</div>
)
}
export default WhaleMonitorMarketSelect
@@ -0,0 +1,610 @@
import { useEffect, useState } from 'react'
import { Card, Table, Button, Space, Tag, Popconfirm, Switch, message, Select, Modal, Form, Input, InputNumber, Tabs, Empty, Tooltip } from 'antd'
import { PlusOutlined, EditOutlined, UnorderedListOutlined, DeleteOutlined, RightOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import { useMediaQuery } from 'react-responsive'
import { apiService } from '../services/api'
import { useAccountStore } from '../store/accountStore'
import {
conditionIdsToMarkets,
strategyMarketsToItems
} from '../constants/whaleMonitor'
import type {
WhaleMonitorStrategyDto,
WhaleMonitorTriggerDto,
WhaleMonitorMarketItem,
Account
} from '../types'
import { formatUSDC } from '../utils'
import WhaleMonitorMarketSelectModal from '../components/WhaleMonitorMarketSelectModal'
const WhaleMonitorStrategyList: React.FC = () => {
const { t } = useTranslation()
const isMobile = useMediaQuery({ maxWidth: 768 })
const { accounts, fetchAccounts } = useAccountStore()
const [strategies, setStrategies] = useState<WhaleMonitorStrategyDto[]>([])
const [loading, setLoading] = useState(false)
const [filterAccountId, setFilterAccountId] = useState<number | undefined>()
const [filterEnabled, setFilterEnabled] = useState<boolean | undefined>()
const [formVisible, setFormVisible] = useState(false)
const [editingStrategy, setEditingStrategy] = useState<WhaleMonitorStrategyDto | null>(null)
const [form] = Form.useForm()
const [submitting, setSubmitting] = useState(false)
const [selectedMarkets, setSelectedMarkets] = useState<WhaleMonitorMarketItem[]>([])
const [marketSelectVisible, setMarketSelectVisible] = useState(false)
const [triggerVisible, setTriggerVisible] = useState(false)
const [triggerStrategyId, setTriggerStrategyId] = useState<number>(0)
const [triggerRecords, setTriggerRecords] = useState<WhaleMonitorTriggerDto[]>([])
const [triggerTotal, setTriggerTotal] = useState(0)
const [triggerPage, setTriggerPage] = useState(1)
const [triggerTab, setTriggerTab] = useState<string>('success')
const [triggerLoading, setTriggerLoading] = useState(false)
useEffect(() => {
fetchAccounts()
}, [fetchAccounts])
useEffect(() => {
fetchList()
}, [filterAccountId, filterEnabled])
const fetchList = async () => {
setLoading(true)
try {
const params: { accountId?: number; enabled?: boolean } = {}
if (filterAccountId) params.accountId = filterAccountId
if (filterEnabled !== undefined) params.enabled = filterEnabled
const res = await apiService.whaleMonitorStrategy.list(params)
if (res.data.code === 0 && res.data.data) {
setStrategies(res.data.data.list || [])
} else {
message.error(t('whaleMonitorStrategy.list.fetchFailed'))
}
} catch (_e) {
message.error(t('whaleMonitorStrategy.list.fetchFailed'))
} finally {
setLoading(false)
}
}
const handleToggleEnabled = async (strategy: WhaleMonitorStrategyDto) => {
try {
const res = await apiService.whaleMonitorStrategy.update({
strategyId: strategy.id,
enabled: !strategy.enabled
})
if (res.data.code === 0) {
message.success(strategy.enabled ? t('whaleMonitorStrategy.list.disable') : t('whaleMonitorStrategy.list.enable'))
fetchList()
} else {
message.error(res.data.msg)
}
} catch (_e) {
message.error('Error')
}
}
const handleDelete = async (strategyId: number) => {
try {
const res = await apiService.whaleMonitorStrategy.delete({ strategyId })
if (res.data.code === 0) {
message.success('OK')
fetchList()
} else {
message.error(res.data.msg)
}
} catch (_e) {
message.error('Error')
}
}
const openMarketSelect = () => setMarketSelectVisible(true)
const showCreateModal = () => {
setEditingStrategy(null)
setSelectedMarkets([])
form.resetFields()
form.setFieldsValue({
windowSeconds: 10,
minPrice: 0,
maxPrice: 1,
cooldownSeconds: 60,
enabled: true
})
setFormVisible(true)
}
const showEditModal = (strategy: WhaleMonitorStrategyDto) => {
setEditingStrategy(strategy)
const known =
strategy.markets && strategy.markets.length > 0
? strategyMarketsToItems(strategy.markets)
: []
setSelectedMarkets(conditionIdsToMarkets(strategy.conditionIds, known))
form.setFieldsValue({
accountId: strategy.accountId,
name: strategy.name,
windowSeconds: strategy.windowSeconds,
thresholdAmount: strategy.thresholdAmount,
orderAmount: strategy.orderAmount,
minPrice: parseFloat(strategy.minPrice),
maxPrice: parseFloat(strategy.maxPrice),
cooldownSeconds: strategy.cooldownSeconds,
enabled: strategy.enabled
})
setFormVisible(true)
}
const handleSubmit = async () => {
try {
const values = await form.validateFields()
setSubmitting(true)
if (selectedMarkets.length === 0) {
message.error(t('whaleMonitorStrategy.form.conditionIds'))
setSubmitting(false)
return
}
const payload = {
...values,
conditionIds: selectedMarkets.map(m => m.conditionId),
minPrice: String(values.minPrice ?? 0),
maxPrice: String(values.maxPrice ?? 1),
thresholdAmount: String(values.thresholdAmount),
orderAmount: String(values.orderAmount)
}
if (editingStrategy) {
const res = await apiService.whaleMonitorStrategy.update({
strategyId: editingStrategy.id,
...payload
})
if (res.data.code === 0) {
message.success('OK')
setFormVisible(false)
fetchList()
} else {
message.error(res.data.msg)
}
} else {
const res = await apiService.whaleMonitorStrategy.create(payload)
if (res.data.code === 0) {
message.success('OK')
setFormVisible(false)
fetchList()
} else {
message.error(res.data.msg)
}
}
} catch (_e) {
// form validation error
} finally {
setSubmitting(false)
}
}
const fetchTriggerRecords = async (strategyId: number, page: number, status?: string) => {
setTriggerLoading(true)
try {
const params: { strategyId: number; page: number; pageSize: number; status?: string } = {
strategyId,
page,
pageSize: 20,
}
if (status) params.status = status
const res = await apiService.whaleMonitorStrategy.triggers(params)
if (res.data.code === 0 && res.data.data) {
setTriggerRecords(res.data.data.list || [])
setTriggerTotal(res.data.data.total || 0)
}
} catch (_e) {
// ignore
} finally {
setTriggerLoading(false)
}
}
const showTriggerRecords = (strategyId: number) => {
setTriggerStrategyId(strategyId)
setTriggerPage(1)
setTriggerTab('success')
setTriggerVisible(true)
fetchTriggerRecords(strategyId, 1, 'success')
}
const handleTriggerTabChange = (tab: string) => {
setTriggerTab(tab)
setTriggerPage(1)
fetchTriggerRecords(triggerStrategyId, 1, tab === 'all' ? undefined : tab)
}
const getAccountName = (accountId: number): string => {
const acc = accounts.find((a: Account) => a.id === accountId)
if (!acc) return String(accountId)
return acc.accountName || `${acc.walletAddress.slice(0, 6)}...${acc.walletAddress.slice(-4)}`
}
const formatTime = (ts: number): string => {
if (!ts) return '-'
return new Date(ts).toLocaleString()
}
const columns = [
{
title: t('whaleMonitorStrategy.list.strategyName'),
dataIndex: 'name',
key: 'name',
ellipsis: true,
render: (name: string) => name || '-'
},
{
title: t('whaleMonitorStrategy.list.account'),
dataIndex: 'accountId',
key: 'accountId',
render: (accountId: number) => getAccountName(accountId)
},
{
title: t('whaleMonitorStrategy.list.marketCount'),
dataIndex: 'conditionIds',
key: 'marketCount',
render: (ids: string[]) => (
<Tooltip title={ids.join(', ')}>
<Tag>{ids.length}</Tag>
</Tooltip>
)
},
{
title: t('whaleMonitorStrategy.list.window'),
dataIndex: 'windowSeconds',
key: 'windowSeconds',
render: (v: number) => `${v}${t('whaleMonitorStrategy.list.seconds')}`
},
{
title: t('whaleMonitorStrategy.list.threshold'),
dataIndex: 'thresholdAmount',
key: 'thresholdAmount',
render: (v: string) => `$${formatUSDC(v)}`
},
{
title: t('whaleMonitorStrategy.list.orderAmount'),
dataIndex: 'orderAmount',
key: 'orderAmount',
render: (v: string) => `$${formatUSDC(v)}`
},
{
title: t('whaleMonitorStrategy.list.priceRange'),
key: 'priceRange',
render: (_: unknown, record: WhaleMonitorStrategyDto) => `${record.minPrice} ~ ${record.maxPrice}`
},
{
title: t('whaleMonitorStrategy.list.cooldown'),
dataIndex: 'cooldownSeconds',
key: 'cooldownSeconds',
render: (v: number) => `${v}${t('whaleMonitorStrategy.list.seconds')}`
},
{
title: t('whaleMonitorStrategy.list.enabled'),
dataIndex: 'enabled',
key: 'enabled',
render: (enabled: boolean, record: WhaleMonitorStrategyDto) => (
<Switch checked={enabled} onChange={() => handleToggleEnabled(record)} />
)
},
{
title: t('whaleMonitorStrategy.list.triggerCount'),
dataIndex: 'triggerCount',
key: 'triggerCount',
render: (count: number) => count || 0
},
{
title: t('whaleMonitorStrategy.list.actions'),
key: 'actions',
render: (_: unknown, record: WhaleMonitorStrategyDto) => (
<Space size="small">
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => showEditModal(record)}>
{t('whaleMonitorStrategy.list.edit')}
</Button>
<Button type="link" size="small" icon={<UnorderedListOutlined />} onClick={() => showTriggerRecords(record.id)}>
{t('whaleMonitorStrategy.list.viewTriggers')}
</Button>
<Popconfirm title={t('whaleMonitorStrategy.list.deleteConfirm')} onConfirm={() => handleDelete(record.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
{t('whaleMonitorStrategy.list.delete')}
</Button>
</Popconfirm>
</Space>
)
}
]
const triggerColumns = [
{
title: t('whaleMonitorStrategy.triggerRecords.triggerTime'),
dataIndex: 'createdAt',
key: 'createdAt',
render: (ts: number) => formatTime(ts)
},
{
title: t('whaleMonitorStrategy.triggerRecords.conditionId'),
dataIndex: 'conditionId',
key: 'conditionId',
ellipsis: true,
render: (v: string) => (
<Tooltip title={v}>
<span>{v.slice(0, 10)}...</span>
</Tooltip>
)
},
{
title: t('whaleMonitorStrategy.triggerRecords.tokenId'),
dataIndex: 'tokenId',
key: 'tokenId',
ellipsis: true,
render: (v: string) => (
<Tooltip title={v}>
<span>{v.slice(0, 10)}...</span>
</Tooltip>
)
},
{
title: t('whaleMonitorStrategy.triggerRecords.triggerVolume'),
dataIndex: 'triggerVolume',
key: 'triggerVolume',
render: (v: string) => `$${formatUSDC(v)}`
},
{
title: t('whaleMonitorStrategy.triggerRecords.orderPrice'),
dataIndex: 'orderPrice',
key: 'orderPrice',
render: (v: string) => formatUSDC(v)
},
{
title: t('whaleMonitorStrategy.triggerRecords.orderAmount'),
dataIndex: 'orderAmount',
key: 'orderAmount',
render: (v: string) => `$${formatUSDC(v)}`
},
{
title: t('whaleMonitorStrategy.triggerRecords.orderId'),
dataIndex: 'orderId',
key: 'orderId',
ellipsis: true,
render: (v: string) => v ? (
<Tooltip title={v}><span>{v.slice(0, 10)}...</span></Tooltip>
) : '-'
},
{
title: t('whaleMonitorStrategy.triggerRecords.failReason'),
dataIndex: 'failReason',
key: 'failReason',
ellipsis: true,
render: (v: string) => v ? <Tooltip title={v}><span>{v}</span></Tooltip> : '-'
}
]
const renderMobileCard = (strategy: WhaleMonitorStrategyDto) => (
<Card key={strategy.id} size="small" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<strong>{strategy.name || '-'}</strong>
<Switch checked={strategy.enabled} onChange={() => handleToggleEnabled(strategy)} />
</div>
<div style={{ fontSize: 12, color: '#888', marginBottom: 4 }}>
{t('whaleMonitorStrategy.list.account')}: {getAccountName(strategy.accountId)}
</div>
<div style={{ fontSize: 12, color: '#888', marginBottom: 4 }}>
{t('whaleMonitorStrategy.list.marketCount')}: <Tag>{strategy.conditionIds.length}</Tag>
</div>
<div style={{ fontSize: 12, color: '#888', marginBottom: 4 }}>
{t('whaleMonitorStrategy.list.window')}: {strategy.windowSeconds}{t('whaleMonitorStrategy.list.seconds')}
&nbsp;|&nbsp;
{t('whaleMonitorStrategy.list.threshold')}: ${formatUSDC(strategy.thresholdAmount)}
&nbsp;|&nbsp;
{t('whaleMonitorStrategy.list.orderAmount')}: ${formatUSDC(strategy.orderAmount)}
</div>
<div style={{ fontSize: 12, color: '#888', marginBottom: 8 }}>
{t('whaleMonitorStrategy.list.priceRange')}: {strategy.minPrice} ~ {strategy.maxPrice}
&nbsp;|&nbsp;
{t('whaleMonitorStrategy.list.cooldown')}: {strategy.cooldownSeconds}{t('whaleMonitorStrategy.list.seconds')}
</div>
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => showEditModal(strategy)}>
{t('whaleMonitorStrategy.list.edit')}
</Button>
<Button size="small" icon={<UnorderedListOutlined />} onClick={() => showTriggerRecords(strategy.id)}>
{t('whaleMonitorStrategy.list.viewTriggers')}
</Button>
<Popconfirm title={t('whaleMonitorStrategy.list.deleteConfirm')} onConfirm={() => handleDelete(strategy.id)}>
<Button size="small" danger icon={<DeleteOutlined />}>
{t('whaleMonitorStrategy.list.delete')}
</Button>
</Popconfirm>
</Space>
</Card>
)
return (
<div style={{ padding: isMobile ? 12 : 24 }}>
<Card>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16, flexWrap: 'wrap', gap: 8 }}>
<h2 style={{ margin: 0 }}>{t('whaleMonitorStrategy.list.title')}</h2>
<Space wrap>
<Select
allowClear
placeholder={t('whaleMonitorStrategy.list.account')}
style={{ width: 160 }}
value={filterAccountId}
onChange={setFilterAccountId}
options={accounts.map((a: Account) => ({
label: a.accountName || `${a.walletAddress?.slice(0, 6)}...${a.walletAddress?.slice(-4)}`,
value: a.id
}))}
/>
<Select
allowClear
placeholder={t('whaleMonitorStrategy.list.enabled')}
style={{ width: 100 }}
value={filterEnabled}
onChange={setFilterEnabled}
options={[
{ label: t('whaleMonitorStrategy.list.enable'), value: true },
{ label: t('whaleMonitorStrategy.list.disable'), value: false }
]}
/>
<Button type="primary" icon={<PlusOutlined />} onClick={showCreateModal}>
{t('whaleMonitorStrategy.list.addStrategy')}
</Button>
</Space>
</div>
{isMobile ? (
<div>{strategies.map(renderMobileCard)}</div>
) : (
<Table
dataSource={strategies}
columns={columns}
rowKey="id"
loading={loading}
pagination={false}
scroll={{ x: 1200 }}
size="small"
/>
)}
</Card>
<Modal
title={editingStrategy ? t('whaleMonitorStrategy.list.edit') : t('whaleMonitorStrategy.list.addStrategy')}
open={formVisible}
onOk={handleSubmit}
onCancel={() => setFormVisible(false)}
confirmLoading={submitting}
width={isMobile ? '95%' : 600}
okText={editingStrategy ? t('whaleMonitorStrategy.form.update') : t('whaleMonitorStrategy.form.create')}
>
<Form form={form} layout="vertical">
<Form.Item name="accountId" label={t('whaleMonitorStrategy.form.selectAccount')} rules={[{ required: true }]}>
<Select
options={accounts.map((a: Account) => ({
label: a.accountName || `${a.walletAddress?.slice(0, 6)}...${a.walletAddress?.slice(-4)}`,
value: a.id
}))}
/>
</Form.Item>
<Form.Item name="name" label={t('whaleMonitorStrategy.form.strategyName')}>
<Input placeholder={t('whaleMonitorStrategy.form.strategyNamePlaceholder')} />
</Form.Item>
<Form.Item label={t('whaleMonitorStrategy.form.conditionIds')} required>
<div>
{selectedMarkets.length > 0 && (
<div style={{ marginBottom: 8, display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{selectedMarkets.map(m => (
<Tag
key={m.conditionId}
closable
onClose={() =>
setSelectedMarkets(prev => prev.filter(item => item.conditionId !== m.conditionId))
}
>
{m.title}
</Tag>
))}
</div>
)}
<Button
block={isMobile}
onClick={openMarketSelect}
style={{ minHeight: 44, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
>
<span>
{selectedMarkets.length > 0
? t('whaleMonitorStrategy.form.marketsSelected', { count: selectedMarkets.length })
: t('whaleMonitorStrategy.form.selectMarkets')}
</span>
<RightOutlined />
</Button>
</div>
</Form.Item>
<Form.Item name="windowSeconds" label={t('whaleMonitorStrategy.form.windowSeconds')} rules={[{ required: true }]}>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="thresholdAmount" label={t('whaleMonitorStrategy.form.thresholdAmount')} rules={[{ required: true }]}>
<InputNumber min={0.01} step={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="orderAmount" label={t('whaleMonitorStrategy.form.orderAmount')} rules={[{ required: true }]}>
<InputNumber min={1} step={1} style={{ width: '100%' }} />
</Form.Item>
<Space>
<Form.Item name="minPrice" label={t('whaleMonitorStrategy.form.minPrice')} rules={[{ required: true }]}>
<InputNumber min={0} max={1} step={0.01} precision={2} style={{ width: 120 }} />
</Form.Item>
<Form.Item name="maxPrice" label={t('whaleMonitorStrategy.form.maxPrice')} rules={[{ required: true }]}>
<InputNumber min={0} max={1} step={0.01} precision={2} style={{ width: 120 }} />
</Form.Item>
</Space>
<div style={{ fontSize: 12, color: '#999', marginTop: -16, marginBottom: 16 }}>
{t('whaleMonitorStrategy.form.priceTip')}
</div>
<Form.Item name="cooldownSeconds" label={t('whaleMonitorStrategy.form.cooldownSeconds')} rules={[{ required: true }]}>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="enabled" label={t('whaleMonitorStrategy.form.enabled')} valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
<WhaleMonitorMarketSelectModal
open={marketSelectVisible}
initialSelected={selectedMarkets}
onCancel={() => setMarketSelectVisible(false)}
onConfirm={(selected) => {
setSelectedMarkets(selected)
setMarketSelectVisible(false)
}}
/>
<Modal
title={t('whaleMonitorStrategy.triggerRecords.title')}
open={triggerVisible}
onCancel={() => setTriggerVisible(false)}
footer={null}
width={isMobile ? '95%' : 900}
>
<Tabs
activeKey={triggerTab}
onChange={handleTriggerTabChange}
items={[
{ key: 'success', label: t('whaleMonitorStrategy.triggerRecords.successTab') },
{ key: 'fail', label: t('whaleMonitorStrategy.triggerRecords.failTab') }
]}
/>
{triggerRecords.length === 0 ? (
<Empty description={triggerTab === 'success' ? t('whaleMonitorStrategy.triggerRecords.emptySuccess') : t('whaleMonitorStrategy.triggerRecords.emptyFail')} />
) : (
<Table
dataSource={triggerRecords}
columns={triggerColumns}
rowKey="id"
loading={triggerLoading}
size="small"
scroll={{ x: isMobile ? 700 : 'auto' }}
pagination={{
current: triggerPage,
total: triggerTotal,
pageSize: 20,
onChange: (page) => {
setTriggerPage(page)
fetchTriggerRecords(triggerStrategyId, page, triggerTab === 'all' ? undefined : triggerTab)
}
}}
/>
)}
</Modal>
</div>
)
}
export default WhaleMonitorStrategyList
+78 -2
View File
@@ -314,8 +314,39 @@ export const apiService = {
/**
*
*/
getLatestPrice: (data: { tokenId: string }) =>
apiClient.post<ApiResponse<any>>('/markets/latest-price', data)
getLatestPrice: (data: { tokenId: string }) =>
apiClient.post<ApiResponse<any>>('/markets/latest-price', data),
/**
*
*/
search: (data: { keyword: string; tagId?: string; seriesId?: string; sportSlug?: string; limit?: number }) =>
apiClient.post<ApiResponse<Array<{
conditionId: string
title: string
slug?: string
category?: string
volume?: string
outcomes?: string
eventTitle?: string
marketType?: 'game' | 'season'
image?: string
icon?: string
eventImage?: string
}>>>('/markets/search', data),
/**
*
*/
sportsCategories: () =>
apiClient.post<ApiResponse<Array<{
id: number
slug: string
label: string
tagId: string
seriesId?: string
image?: string
}>>>('/markets/sports-categories', {})
},
/**
@@ -526,6 +557,51 @@ export const apiService = {
apiClient.post<ApiResponse<import('../types').CryptoTailManualOrderResponse>>('/crypto-tail-strategy/manual-order', data)
},
/**
* API
*/
whaleMonitorStrategy: {
list: (data: { accountId?: number; enabled?: boolean } = {}) =>
apiClient.post<ApiResponse<{ list: import('../types').WhaleMonitorStrategyDto[] }>>('/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<ApiResponse<import('../types').WhaleMonitorStrategyDto>>('/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<ApiResponse<import('../types').WhaleMonitorStrategyDto>>('/whale-monitor-strategy/update', data),
delete: (data: { strategyId: number }) =>
apiClient.post<ApiResponse<void>>('/whale-monitor-strategy/delete', data),
triggers: (data: {
strategyId: number
page?: number
pageSize?: number
status?: string
startDate?: number
endDate?: number
}) =>
apiClient.post<ApiResponse<{ list: import('../types').WhaleMonitorTriggerDto[]; total: number }>>('/whale-monitor-strategy/triggers', data)
},
/**
* API
*/
+78
View File
@@ -1309,3 +1309,81 @@ 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<string, unknown>
selectedMarkets: WhaleMonitorMarketItem[]
editingStrategyId?: number
}
export interface WhaleMonitorMarketSelectLocationState {
selectedMarkets?: WhaleMonitorMarketItem[]
}
export interface WhaleMonitorStrategyListLocationState {
selectedMarkets?: WhaleMonitorMarketItem[]
}
export interface WhaleMonitorStrategyMarketDto {
conditionId: string
title: string
slug?: string
category?: string
image?: string
icon?: string
}
export interface WhaleMonitorStrategyDto {
id: number
accountId: number
name?: string
conditionIds: string[]
markets?: WhaleMonitorStrategyMarketDto[]
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
}