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:
@@ -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 文档)
|
||||
*/
|
||||
|
||||
+40
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
+135
@@ -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 {
|
||||
/**
|
||||
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package com.wrbug.polymarketbot.event
|
||||
|
||||
import org.springframework.context.ApplicationEvent
|
||||
|
||||
/**
|
||||
* 大单监听策略创建/更新/删除/启用状态变更后发布,通知 WS 服务重载监听配置
|
||||
*/
|
||||
class WhaleMonitorStrategyChangedEvent(source: Any) : ApplicationEvent(source)
|
||||
+10
@@ -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>
|
||||
}
|
||||
+17
@@ -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
|
||||
)
|
||||
|
||||
+56
@@ -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()
|
||||
}
|
||||
}
|
||||
+204
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+294
@@ -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
|
||||
)
|
||||
}
|
||||
+301
@@ -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=新增回測
|
||||
|
||||
@@ -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>} />
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 迁移操作,以确保交易功能正常使用。",
|
||||
|
||||
@@ -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')}
|
||||
|
|
||||
{t('whaleMonitorStrategy.list.threshold')}: ${formatUSDC(strategy.thresholdAmount)}
|
||||
|
|
||||
{t('whaleMonitorStrategy.list.orderAmount')}: ${formatUSDC(strategy.orderAmount)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#888', marginBottom: 8 }}>
|
||||
{t('whaleMonitorStrategy.list.priceRange')}: {strategy.minPrice} ~ {strategy.maxPrice}
|
||||
|
|
||||
{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
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user