feat: 添加市场大单监听策略(Whale Monitor Strategy)

支持按市场分类/体育联赛筛选并选择市场,实时监听 Activity WebSocket 交易流,
在滑动窗口内聚合 BUY 成交额,达到阈值后自动下 FAK 单。

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
WrBug
2026-05-27 04:34:31 +08:00
parent 83a415fb7e
commit b3872aa8f2
32 changed files with 3738 additions and 7 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,119 @@
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
)
/**
* 大单监听策略 DTO(列表与详情)
*/
data class WhaleMonitorStrategyDto(
val id: Long = 0L,
val accountId: Long = 0L,
val name: String? = null,
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,
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
}
@@ -31,6 +31,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获取市场信息
@@ -281,6 +287,233 @@ class MarketService(
null
}
}
/**
* 搜索市场(按标题关键词,可选按标签筛选)
* 调用 Gamma API /markets?title=xxx 搜索,返回活跃且未关闭的市场
*/
suspend fun searchMarkets(
keyword: String,
tagId: String? = null,
seriesId: String? = null,
sportSlug: String? = null,
limit: Int = 20
): List<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 +524,35 @@ data class MarketInfoByTokenId(
val outcomeIndex: Int,
val outcome: String? = null
)
data class MarketSearchResult(
val conditionId: String,
val title: String,
val slug: String? = null,
val category: String? = null,
val volume: String? = null,
val outcomes: String? = null,
/** 所属赛事/对阵(体育单场比赛) */
val eventTitle: String? = null,
/** game=单场赛事盘口,season=长期/赛季类市场 */
val marketType: String = TYPE_SEASON,
/** 市场封面图(优先 image,无则 icon */
val image: String? = null,
val icon: String? = null,
/** 所属赛事封面(体育单场比赛分组用) */
val eventImage: String? = null
) {
companion object {
const val TYPE_GAME = "game"
const val TYPE_SEASON = "season"
}
}
data class SportCategoryResult(
val id: Int,
val slug: String,
val label: String,
val tagId: String,
val seriesId: String? = null,
val image: String? = null
)
@@ -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,294 @@
package com.wrbug.polymarketbot.service.whalemonitor
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.entity.WhaleMonitorStrategy
import com.wrbug.polymarketbot.entity.WhaleMonitorTrigger
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.event.WhaleMonitorStrategyChangedEvent
import com.wrbug.polymarketbot.repository.WhaleMonitorStrategyRepository
import com.wrbug.polymarketbot.repository.WhaleMonitorTriggerRepository
import com.wrbug.polymarketbot.util.fromJson
import com.wrbug.polymarketbot.util.gt
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import com.wrbug.polymarketbot.util.toJson
import org.slf4j.LoggerFactory
import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
import java.math.RoundingMode
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Service
class WhaleMonitorStrategyService(
private val strategyRepository: WhaleMonitorStrategyRepository,
private val triggerRepository: WhaleMonitorTriggerRepository,
private val eventPublisher: ApplicationEventPublisher
) {
private val logger = LoggerFactory.getLogger(WhaleMonitorStrategyService::class.java)
@Transactional
fun create(request: WhaleMonitorStrategyCreateRequest): Result<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()
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 conditionIdsJson = request.conditionIds?.let {
if (it.isEmpty()) return Result.failure(IllegalArgumentException(ErrorCode.WHALE_MONITOR_STRATEGY_CONDITION_IDS_EMPTY.messageKey))
it.toJson()
} ?: existing.conditionIds
val windowSeconds = request.windowSeconds ?: existing.windowSeconds
if (windowSeconds <= 0) {
return Result.failure(IllegalArgumentException(ErrorCode.WHALE_MONITOR_STRATEGY_WINDOW_INVALID.messageKey))
}
val thresholdAmount = request.thresholdAmount?.toSafeBigDecimal() ?: existing.thresholdAmount
if (thresholdAmount <= BigDecimal.ZERO) {
return Result.failure(IllegalArgumentException(ErrorCode.WHALE_MONITOR_STRATEGY_THRESHOLD_INVALID.messageKey))
}
val orderAmount = request.orderAmount?.toSafeBigDecimal() ?: existing.orderAmount
if (orderAmount <= BigDecimal.ZERO) {
return Result.failure(IllegalArgumentException(ErrorCode.WHALE_MONITOR_STRATEGY_AMOUNT_INVALID.messageKey))
}
val minPrice = request.minPrice?.toSafeBigDecimal() ?: existing.minPrice
val maxPrice = request.maxPrice?.toSafeBigDecimal() ?: existing.maxPrice
if (minPrice < BigDecimal.ZERO || maxPrice > BigDecimal.ONE || minPrice > maxPrice) {
return Result.failure(IllegalArgumentException(ErrorCode.WHALE_MONITOR_STRATEGY_PRICE_INVALID.messageKey))
}
validatePriceDecimalPlaces(minPrice, "minPrice")
validatePriceDecimalPlaces(maxPrice, "maxPrice")
val cooldownSeconds = request.cooldownSeconds ?: existing.cooldownSeconds
if (cooldownSeconds <= 0) {
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
}
val nameToSave = request.name?.takeIf { it.isNotBlank() }
?: existing.name?.takeIf { it.isNotBlank() }
?: generateStrategyName()
val updated = existing.copy(
name = nameToSave,
conditionIds = conditionIdsJson,
windowSeconds = windowSeconds,
thresholdAmount = thresholdAmount,
orderAmount = orderAmount,
minPrice = minPrice,
maxPrice = maxPrice,
cooldownSeconds = cooldownSeconds,
enabled = request.enabled ?: existing.enabled,
updatedAt = System.currentTimeMillis()
)
val saved = strategyRepository.save(updated)
eventPublisher.publishEvent(WhaleMonitorStrategyChangedEvent(this))
val lastTrigger = triggerRepository.findAllByStrategyIdOrderByCreatedAtDesc(saved.id!!, PageRequest.of(0, 1))
.content.firstOrNull()?.createdAt
val triggerCount = triggerRepository.countByStrategyId(saved.id!!)
Result.success(entityToDto(saved, lastTrigger, triggerCount))
} catch (e: IllegalArgumentException) {
Result.failure(e)
} catch (e: Exception) {
logger.error("更新大单监听策略失败: ${e.message}", e)
Result.failure(e)
}
}
@Transactional
fun delete(strategyId: Long): Result<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"
}
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,
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=新增回測
+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,169 @@
import { useMemo } from 'react'
import { Checkbox, Collapse, Divider, Typography } from 'antd'
import { useTranslation } from 'react-i18next'
import { groupMarketsBySection, type WhaleMonitorMarketGroup } from '../constants/whaleMonitor'
import type { WhaleMonitorMarketItem } from '../types'
import WhaleMonitorMarketListItem from './WhaleMonitorMarketListItem'
import WhaleMonitorMarketThumbnail from './WhaleMonitorMarketThumbnail'
const { Text, Title } = Typography
interface WhaleMonitorMarketGroupedListProps {
markets: WhaleMonitorMarketItem[]
selectedMap: Map<string, WhaleMonitorMarketItem>
isMobile: boolean
onToggleMarket: (market: WhaleMonitorMarketItem, checked: boolean) => void
onToggleGroup: (markets: WhaleMonitorMarketItem[], checked: boolean) => void
}
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
) => {
const showAsGroups = groups.length > 1 || (groups.length === 1 && groups[0].key.startsWith('event:'))
if (!showAsGroups) {
const flatMarkets = groups.flatMap(g => g.markets)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: isMobile ? 4 : 8 }}>
{flatMarkets.map(market => (
<WhaleMonitorMarketListItem
key={market.conditionId}
market={market}
checked={selectedMap.has(market.conditionId)}
isMobile={isMobile}
hideEventTitle
onToggle={checked => onToggleMarket(market, checked)}
/>
))}
</div>
)
}
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={{ display: 'flex', alignItems: 'center', gap: 10, flex: 1, minWidth: 0 }}>
<WhaleMonitorMarketThumbnail src={group.imageUrl} size={32} alt={group.title} />
<div style={{ flex: 1, minWidth: 0 }}>
<Text strong style={{ wordBreak: 'break-word' }}>
{group.title}
</Text>
<Text type="secondary" style={{ fontSize: 12, marginLeft: 0, display: 'block' }}>
{t('whaleMonitorStrategy.marketSelect.marketsInGroup', { count: group.markets.length })}
</Text>
</div>
</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={groups.map(g => g.key)}
items={collapseItems}
style={{ background: 'transparent' }}
/>
)
}
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]
)
if (sections.length === 1) {
const section = sections[0]
const showSectionTitle =
markets.some(m => m.marketType === 'game' || m.marketType === 'season') ||
section.groups.some(g => g.key.startsWith('event:'))
return (
<>
{showSectionTitle && (
<Title level={5} style={{ marginTop: 0, marginBottom: 12 }}>
{section.title}
</Title>
)}
{renderMarketGroups(section.groups, selectedMap, isMobile, onToggleMarket, onToggleGroup, t)}
</>
)
}
return (
<div>
{sections.map((section, index) => (
<div key={section.key}>
{index > 0 && <Divider style={{ margin: '16px 0' }} />}
<Title level={5} style={{ marginTop: index === 0 ? 0 : undefined, marginBottom: 12 }}>
{section.title}
<Text type="secondary" style={{ fontSize: 13, fontWeight: 400, marginLeft: 8 }}>
({section.groups.reduce((sum, g) => sum + g.markets.length, 0)})
</Text>
</Title>
{renderMarketGroups(section.groups, selectedMap, isMobile, onToggleMarket, onToggleGroup, t)}
</div>
))}
</div>
)
}
export default WhaleMonitorMarketGroupedList
@@ -0,0 +1,140 @@
import { Checkbox, Tag, Typography } from 'antd'
import { LinkOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import { parseMarketOutcomes, pickMarketImageUrl } from '../constants/whaleMonitor'
import type { WhaleMonitorMarketItem } from '../types'
import { formatUSDC } from '../utils'
import WhaleMonitorMarketThumbnail from './WhaleMonitorMarketThumbnail'
const { Text } = Typography
interface WhaleMonitorMarketListItemProps {
market: WhaleMonitorMarketItem
checked: boolean
isMobile: boolean
hideEventTitle?: boolean
compact?: boolean
onToggle: (checked: boolean) => void
}
const formatConditionId = (id: string): string =>
id.length > 20 ? `${id.slice(0, 10)}...${id.slice(-6)}` : id
const WhaleMonitorMarketListItem: React.FC<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 imageUrl = pickMarketImageUrl(market)
const thumbSize = compact ? 36 : isMobile ? 40 : 44
const handleOpenLink = (e: React.MouseEvent) => {
e.stopPropagation()
if (polymarketUrl) {
window.open(polymarketUrl, '_blank', 'noopener,noreferrer')
}
}
return (
<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 }}
/>
<WhaleMonitorMarketThumbnail src={imageUrl} size={thumbSize} />
<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,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
+208
View File
@@ -0,0 +1,208 @@
import type { WhaleMonitorFormDraft, WhaleMonitorMarketItem } from '../types'
export const WHALE_MONITOR_FORM_DRAFT_KEY = 'whale_monitor_form_draft'
export function saveWhaleMonitorFormDraft(draft: WhaleMonitorFormDraft): void {
sessionStorage.setItem(WHALE_MONITOR_FORM_DRAFT_KEY, JSON.stringify(draft))
}
export function loadWhaleMonitorFormDraft(): WhaleMonitorFormDraft | null {
const raw = sessionStorage.getItem(WHALE_MONITOR_FORM_DRAFT_KEY)
if (!raw) return null
try {
return JSON.parse(raw) as WhaleMonitorFormDraft
} catch {
return null
}
}
export function clearWhaleMonitorFormDraft(): void {
sessionStorage.removeItem(WHALE_MONITOR_FORM_DRAFT_KEY)
}
export function conditionIdsToMarkets(
conditionIds: string[],
known: WhaleMonitorMarketItem[] = []
): WhaleMonitorMarketItem[] {
return conditionIds.map(id => {
const found = known.find(m => m.conditionId === id)
return found ?? { conditionId: id, title: id }
})
}
/** 解析 Gamma 返回的 outcomes JSON 字符串 */
export function parseMarketOutcomes(outcomes?: string): string[] {
if (!outcomes?.trim()) return []
try {
const parsed = JSON.parse(outcomes) as unknown
if (Array.isArray(parsed)) {
return parsed.filter((item): item is string => typeof item === 'string' && item.length > 0)
}
} catch {
return outcomes.split(',').map(s => s.trim()).filter(Boolean)
}
return []
}
export interface WhaleMonitorMarketGroup {
key: string
title: string
markets: WhaleMonitorMarketItem[]
imageUrl?: string
}
/** 市场行展示图:image > icon > eventImage */
export function pickMarketImageUrl(market?: WhaleMonitorMarketItem | null): string | undefined {
if (!market) return undefined
return market.image?.trim() || market.icon?.trim() || market.eventImage?.trim() || undefined
}
/** 赛事分组头图:优先 eventImage,否则取组内首个市场图 */
export function pickGroupImageUrl(markets: WhaleMonitorMarketItem[]): string | undefined {
if (markets.length === 0) return undefined
const eventImg = markets[0].eventImage?.trim()
if (eventImg) return eventImg
return pickMarketImageUrl(markets[0])
}
export interface WhaleMonitorMarketSection {
key: 'game' | 'season'
title: string
groups: WhaleMonitorMarketGroup[]
}
export interface WhaleMonitorMarketSectionLabels {
game: string
season: string
ungrouped: string
}
/** 先按单场/长期分块,再在块内按赛事或分类分组 */
export function groupMarketsBySection(
markets: WhaleMonitorMarketItem[],
labels: WhaleMonitorMarketSectionLabels
): WhaleMonitorMarketSection[] {
if (markets.length === 0) return []
const hasTyped = markets.some(m => m.marketType === 'game' || m.marketType === 'season')
if (!hasTyped) {
const groups = groupMarketsForDisplay(markets, labels.ungrouped)
if (groups.length === 0) return []
return [{ key: 'season', title: labels.ungrouped, groups }]
}
const sections: WhaleMonitorMarketSection[] = []
const games = markets.filter(m => m.marketType === 'game')
const seasons = markets.filter(m => m.marketType === 'season')
if (games.length > 0) {
sections.push({
key: 'game',
title: labels.game,
groups: groupMarketsForDisplay(games, labels.ungrouped)
})
}
if (seasons.length > 0) {
sections.push({
key: 'season',
title: labels.season,
groups: groupMarketsForDisplay(seasons, labels.ungrouped)
})
}
return sections
}
/** 按赛事/分类分组展示市场列表 */
export function groupMarketsForDisplay(
markets: WhaleMonitorMarketItem[],
ungroupedLabel: string
): WhaleMonitorMarketGroup[] {
if (markets.length === 0) return []
const eventMap = new Map<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": "Enter keywords or pick a category above",
"sportsGamesHint": "Sports leagues show individual game markets; search by team name",
"sportsGamesEmpty": "No active game markets for this league. Try searching a team name or check back later.",
"empty": "No markets found",
"selectedTitle": "Selected ({{count}})",
"selectedCount": "{{count}} selected",
"confirm": "Confirm",
"confirmWithCount": "Confirm ({{count}})",
"volume": "Volume",
"viewMarket": "View market",
"sectionGame": "Individual games",
"sectionSeason": "Long-term markets",
"ungrouped": "Other markets",
"selectGroup": "Select all",
"marketsInGroup": "{{count}} market(s)",
"tag": {
"all": "All",
"sports": "Sports",
"politics": "Politics",
"crypto": "Crypto",
"popCulture": "Pop Culture"
}
},
"triggerRecords": {
"title": "Trigger Records",
"timeRange": "Time Range",
"startDate": "Start Date",
"endDate": "End Date",
"successTab": "Success",
"failTab": "Failed",
"triggerTime": "Trigger Time",
"conditionId": "Market",
"tokenId": "Token ID",
"side": "Side",
"triggerVolume": "Trigger Volume",
"orderPrice": "Order Price",
"orderSize": "Order Size",
"orderAmount": "Order Amount",
"orderId": "Order ID",
"status": "Status",
"success": "Success",
"fail": "Failed",
"failReason": "Fail Reason",
"emptySuccess": "No successful records",
"emptyFail": "No failed records",
"totalCount": "{{count}} records in total"
}
},
"clobMigration": {
"title": "CLOB 2.0 Migration Notice",
"description": "Polymarket CLOB has been upgraded to V2. Please go to the Accounts page to complete the USDC migration to ensure trading functions properly.",
+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": "请输入关键词搜索,或选择上方分类筛选",
"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": "請輸入關鍵詞搜索,或選擇上方分類篩選",
"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,330 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import {
Button,
Card,
Empty,
Input,
Select,
Segmented,
Spin,
Tag,
Typography
} from 'antd'
import { ArrowLeftOutlined, SearchOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import { useMediaQuery } from 'react-responsive'
import { apiService } from '../services/api'
import WhaleMonitorMarketGroupedList from '../components/WhaleMonitorMarketGroupedList'
import WhaleMonitorMarketThumbnail from '../components/WhaleMonitorMarketThumbnail'
import { toWhaleMonitorMarketItem } from '../constants/whaleMonitor'
import type { WhaleMonitorMarketItem, WhaleMonitorMarketSelectLocationState } from '../types'
const { Title, Text } = Typography
const TAG_OPTIONS = [
{ key: 'all', value: '' },
{ key: 'sports', value: '1' },
{ key: 'politics', value: '2' },
{ key: 'crypto', value: '21' },
{ key: 'popCulture', value: '100639' }
] as const
const WhaleMonitorMarketSelect: React.FC = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const location = useLocation()
const isMobile = useMediaQuery({ maxWidth: 768 })
const locationState = (location.state as WhaleMonitorMarketSelectLocationState | null) ?? null
const initialSelected = locationState?.selectedMarkets ?? []
const [keyword, setKeyword] = useState('')
const [debouncedKeyword, setDebouncedKeyword] = useState('')
const [tagId, setTagId] = useState<string>('')
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 tagSegmentOptions = useMemo(
() =>
TAG_OPTIONS.map(opt => ({
label: t(`whaleMonitorStrategy.marketSelect.tag.${opt.key}`),
value: opt.value
})),
[t]
)
useEffect(() => {
const timer = setTimeout(() => setDebouncedKeyword(keyword.trim()), 400)
return () => clearTimeout(timer)
}, [keyword])
useEffect(() => {
if (tagId === '1') {
fetchSportSubCategories()
setSportSubSeriesId(undefined)
} else {
setSportSubCategories([])
setSportSubSeriesId(undefined)
}
}, [tagId])
const fetchSportSubCategories = async () => {
try {
const res = await apiService.markets.sportsCategories()
if (res.data.code === 0 && res.data.data) {
setSportSubCategories(
res.data.data.filter((s): s is typeof s & { seriesId: string } => Boolean(s.seriesId))
)
} else {
setSportSubCategories([])
}
} catch {
setSportSubCategories([])
}
}
const fetchMarkets = useCallback(async () => {
const selectedSport = sportSubCategories.find(s => s.seriesId === sportSubSeriesId)
const seriesId = sportSubSeriesId
const sportSlug = selectedSport?.slug
const effectiveTagId = tagId && tagId !== '1' ? tagId : undefined
const searchKeyword = debouncedKeyword
if (tagId === '1' && !sportSubSeriesId && searchKeyword.length < 2) {
setMarketList([])
return
}
if (!seriesId && !effectiveTagId && searchKeyword.length < 2) {
setMarketList([])
return
}
setLoading(true)
try {
const res = await apiService.markets.search({
keyword: searchKeyword.length >= 2 ? searchKeyword : '',
seriesId: seriesId || undefined,
sportSlug: seriesId ? sportSlug : undefined,
tagId: seriesId ? undefined : effectiveTagId,
limit: 200
})
if (res.data.code === 0 && res.data.data) {
setMarketList(res.data.data.map(m => toWhaleMonitorMarketItem(m)))
} else {
setMarketList([])
}
} catch {
setMarketList([])
} finally {
setLoading(false)
}
}, [debouncedKeyword, tagId, sportSubSeriesId, sportSubCategories])
useEffect(() => {
fetchMarkets()
}, [fetchMarkets])
const selectedList = useMemo(() => Array.from(selectedMap.values()), [selectedMap])
const toggleMarket = (market: WhaleMonitorMarketItem, checked: boolean) => {
setSelectedMap(prev => {
const next = new Map(prev)
if (checked) {
next.set(market.conditionId, market)
} else {
next.delete(market.conditionId)
}
return next
})
}
const toggleGroupMarkets = (markets: WhaleMonitorMarketItem[], checked: boolean) => {
setSelectedMap(prev => {
const next = new Map(prev)
for (const market of markets) {
if (checked) {
next.set(market.conditionId, market)
} else {
next.delete(market.conditionId)
}
}
return next
})
}
const handleConfirm = () => {
navigate('/whale-monitor-strategy', {
state: { selectedMarkets: selectedList }
})
}
const handleBack = () => {
navigate('/whale-monitor-strategy')
}
const showSelectSportHint = tagId === '1' && !sportSubSeriesId && debouncedKeyword.length < 2
const showSportsGamesEmpty =
tagId === '1' && !!sportSubSeriesId && marketList.length === 0 && !loading && debouncedKeyword.length < 2
const showSearchHint = !tagId && debouncedKeyword.length < 2
return (
<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)}
size={isMobile ? 'large' : 'middle'}
style={{ marginBottom: 12 }}
/>
<Segmented
block={isMobile}
options={tagSegmentOptions}
value={tagId}
onChange={val => setTagId(val as string)}
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={setSportSubSeriesId}
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,650 @@
import { useEffect, useRef, 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 { useNavigate, useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useMediaQuery } from 'react-responsive'
import { apiService } from '../services/api'
import { useAccountStore } from '../store/accountStore'
import {
clearWhaleMonitorFormDraft,
conditionIdsToMarkets,
loadWhaleMonitorFormDraft,
saveWhaleMonitorFormDraft
} from '../constants/whaleMonitor'
import type {
WhaleMonitorStrategyDto,
WhaleMonitorTriggerDto,
WhaleMonitorMarketItem,
WhaleMonitorStrategyListLocationState,
Account
} from '../types'
import { formatUSDC } from '../utils'
const WhaleMonitorStrategyList: React.FC = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const location = useLocation()
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 [pendingFormDraft, setPendingFormDraft] = useState<ReturnType<typeof loadWhaleMonitorFormDraft>>(null)
const draftRestoreStarted = useRef(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])
useEffect(() => {
if (draftRestoreStarted.current) return
const draft = loadWhaleMonitorFormDraft()
const state = location.state as WhaleMonitorStrategyListLocationState | null
if (!draft && !state?.selectedMarkets) return
draftRestoreStarted.current = true
if (draft) clearWhaleMonitorFormDraft()
if (state?.selectedMarkets) {
navigate(location.pathname, { replace: true, state: null })
}
if (draft) {
setPendingFormDraft({
...draft,
selectedMarkets: state?.selectedMarkets ?? draft.selectedMarkets
})
}
}, [location.pathname, location.state, navigate])
useEffect(() => {
if (!pendingFormDraft) return
if (pendingFormDraft.editingStrategyId && loading) return
if (pendingFormDraft.editingStrategyId) {
const strategy = strategies.find(s => s.id === pendingFormDraft.editingStrategyId)
if (!strategy) {
setPendingFormDraft(null)
return
}
setEditingStrategy(strategy)
} else {
setEditingStrategy(null)
}
setSelectedMarkets(pendingFormDraft.selectedMarkets)
form.setFieldsValue(pendingFormDraft.formValues)
setFormVisible(true)
setPendingFormDraft(null)
}, [pendingFormDraft, strategies, loading, form])
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 goToMarketSelect = () => {
saveWhaleMonitorFormDraft({
formValues: form.getFieldsValue(),
selectedMarkets,
editingStrategyId: editingStrategy?.id
})
navigate('/whale-monitor-strategy/markets', {
state: { selectedMarkets }
})
}
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)
setSelectedMarkets(conditionIdsToMarkets(strategy.conditionIds))
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={goToMarketSelect}
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>
<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
*/
+68
View File
@@ -1309,3 +1309,71 @@ export interface TemplateVariablesResponse {
categories: TemplateVariableCategory[] // 分类列表
variables: TemplateVariable[] // 变量列表
}
// ==================== 大单监听策略 ====================
export type WhaleMonitorMarketType = 'game' | 'season'
export interface WhaleMonitorMarketItem {
conditionId: string
title: string
slug?: string
category?: string
volume?: string
outcomes?: string
/** 体育对阵/赛事名,如 Spurs vs. Thunder */
eventTitle?: string
/** game=单场赛事,season=长期/赛季市场 */
marketType?: WhaleMonitorMarketType
image?: string
icon?: string
eventImage?: string
}
export interface WhaleMonitorFormDraft {
formValues: Record<string, unknown>
selectedMarkets: WhaleMonitorMarketItem[]
editingStrategyId?: number
}
export interface WhaleMonitorMarketSelectLocationState {
selectedMarkets?: WhaleMonitorMarketItem[]
}
export interface WhaleMonitorStrategyListLocationState {
selectedMarkets?: WhaleMonitorMarketItem[]
}
export interface WhaleMonitorStrategyDto {
id: number
accountId: number
name?: string
conditionIds: string[]
windowSeconds: number
thresholdAmount: string
orderAmount: string
minPrice: string
maxPrice: string
cooldownSeconds: number
enabled: boolean
lastTriggerAt?: number
triggerCount: number
createdAt: number
updatedAt: number
}
export interface WhaleMonitorTriggerDto {
id: number
strategyId: number
conditionId: string
tokenId: string
side: string
triggerVolume: string
orderPrice: string
orderSize: string
orderAmount: string
orderId?: string
status: string
failReason?: string
createdAt: number
}