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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
WrBug
2026-05-27 04:34:31 +08:00
co-authored by Claude Opus 4.7
parent 83a415fb7e
commit b3872aa8f2
32 changed files with 3738 additions and 7 deletions
@@ -25,9 +25,31 @@ interface PolymarketGammaApi {
suspend fun listMarkets(
@Query("condition_ids") conditionIds: List<String>? = null,
@Query("clob_token_ids") clobTokenIds: List<String>? = null,
@Query("include_tag") includeTag: Boolean? = null
@Query("include_tag") includeTag: Boolean? = null,
@Query("tag_id") tagId: String? = null,
@Query("title") title: String? = null,
@Query("closed") closed: Boolean? = null,
@Query("limit") limit: Int? = null,
@Query("active") active: Boolean? = null
): Response<List<MarketResponse>>
/**
* 按系列/标签列出事件(体育单场比赛市场嵌套在 events.markets 中)
* 文档: https://docs.polymarket.com/market-data/fetching-markets
*/
@GET("/events")
suspend fun listEvents(
@Query("series_id") seriesId: String? = null,
@Query("tag_id") tagId: String? = null,
@Query("title") title: String? = null,
@Query("closed") closed: Boolean? = null,
@Query("limit") limit: Int? = null,
@Query("offset") offset: Int? = null,
@Query("active") active: Boolean? = null,
@Query("order") order: String? = null,
@Query("ascending") ascending: Boolean? = null
): Response<List<GammaEventListItem>>
/**
* 根据 slug 获取事件(用于 5/15 分钟加密市场)
* GET /events/slug/{slug},如 btc-updown-5m-1771007400
@@ -35,6 +57,13 @@ interface PolymarketGammaApi {
*/
@GET("/events/slug/{slug}")
suspend fun getEventBySlug(@Path("slug") slug: String): Response<GammaEventBySlugResponse>
/**
* 获取体育联赛列表
* GET /sports 返回所有可用的体育分类(NBA、MLB、EPL 等)
*/
@GET("/sports")
suspend fun listSports(): Response<List<GammaSportItem>>
}
/**
@@ -79,6 +108,30 @@ data class EventResponse(
val negRisk: Boolean? = null
)
/**
* 体育联赛项(来自 /sports 端点)
*/
data class GammaSportItem(
val id: Int? = null,
val sport: String? = null,
val image: String? = null,
val tags: String? = null,
val series: String? = null
)
/**
* Gamma /events 列表项(含嵌套 markets,用于体育联赛单场比赛)
*/
data class GammaEventListItem(
val id: String? = null,
val title: String? = null,
val slug: String? = null,
val category: String? = null,
val image: String? = null,
val icon: String? = null,
val markets: List<MarketResponse>? = null
)
/**
* 市场响应(根据 Gamma API 文档)
*/
@@ -5,6 +5,9 @@ import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.accounts.AccountService
import com.wrbug.polymarketbot.service.common.MarketPriceService
import com.wrbug.polymarketbot.service.common.MarketSearchResult
import com.wrbug.polymarketbot.service.common.MarketService
import com.wrbug.polymarketbot.service.common.SportCategoryResult
import com.wrbug.polymarketbot.service.common.PolymarketClobService
import kotlinx.coroutines.runBlocking
import java.math.BigDecimal
@@ -23,6 +26,7 @@ class MarketController(
private val accountService: AccountService,
private val clobService: PolymarketClobService,
private val marketPriceService: MarketPriceService,
private val marketService: MarketService,
private val messageSource: MessageSource
) {
@@ -83,6 +87,42 @@ class MarketController(
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_MARKET_LATEST_PRICE_FETCH_FAILED, e.message, messageSource))
}
}
/**
* 搜索市场(按标题关键词和/或分类标签,调用 Gamma API)
*/
@PostMapping("/search")
fun searchMarkets(@RequestBody request: Map<String, Any>): ResponseEntity<ApiResponse<List<MarketSearchResult>>> {
return try {
val keyword = (request["keyword"] as? String)?.trim() ?: ""
val tagId = request["tagId"] as? String
val seriesId = request["seriesId"] as? String
val sportSlug = request["sportSlug"] as? String
val limit = (request["limit"] as? Number)?.toInt() ?: 50
if (keyword.length < 2 && tagId.isNullOrBlank() && seriesId.isNullOrBlank() && sportSlug.isNullOrBlank()) {
return ResponseEntity.ok(ApiResponse.success(emptyList()))
}
val results = runBlocking { marketService.searchMarkets(keyword, tagId, seriesId, sportSlug, limit) }
ResponseEntity.ok(ApiResponse.success(results))
} catch (e: Exception) {
logger.error("搜索市场异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
/**
* 获取体育联赛子分类列表(NBA、MLB、EPL 等)
*/
@PostMapping("/sports-categories")
fun getSportsCategories(): ResponseEntity<ApiResponse<List<SportCategoryResult>>> {
return try {
val results = runBlocking { marketService.listSportsCategories() }
ResponseEntity.ok(ApiResponse.success(results))
} catch (e: Exception) {
logger.error("获取体育分类异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
}
@@ -0,0 +1,135 @@
package com.wrbug.polymarketbot.controller.whalemonitor
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.whalemonitor.WhaleMonitorStrategyService
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/whale-monitor-strategy")
class WhaleMonitorStrategyController(
private val whaleMonitorStrategyService: WhaleMonitorStrategyService,
private val messageSource: MessageSource
) {
private val logger = LoggerFactory.getLogger(WhaleMonitorStrategyController::class.java)
@PostMapping("/list")
fun list(@RequestBody request: WhaleMonitorStrategyListRequest): ResponseEntity<ApiResponse<WhaleMonitorStrategyListResponse>> {
return try {
val result = whaleMonitorStrategyService.list(request)
result.fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("查询大单监听策略列表失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_WHALE_MONITOR_STRATEGY_LIST_FETCH_FAILED, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("查询大单监听策略列表异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_WHALE_MONITOR_STRATEGY_LIST_FETCH_FAILED, e.message, messageSource))
}
}
@PostMapping("/create")
fun create(@RequestBody request: WhaleMonitorStrategyCreateRequest): ResponseEntity<ApiResponse<WhaleMonitorStrategyDto>> {
return try {
val result = whaleMonitorStrategyService.create(request)
result.fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("创建大单监听策略失败: ${e.message}", e)
val code = when (e.message) {
ErrorCode.WHALE_MONITOR_STRATEGY_CONDITION_IDS_EMPTY.messageKey -> ErrorCode.WHALE_MONITOR_STRATEGY_CONDITION_IDS_EMPTY
ErrorCode.WHALE_MONITOR_STRATEGY_WINDOW_INVALID.messageKey -> ErrorCode.WHALE_MONITOR_STRATEGY_WINDOW_INVALID
ErrorCode.WHALE_MONITOR_STRATEGY_THRESHOLD_INVALID.messageKey -> ErrorCode.WHALE_MONITOR_STRATEGY_THRESHOLD_INVALID
ErrorCode.WHALE_MONITOR_STRATEGY_AMOUNT_INVALID.messageKey -> ErrorCode.WHALE_MONITOR_STRATEGY_AMOUNT_INVALID
ErrorCode.WHALE_MONITOR_STRATEGY_PRICE_INVALID.messageKey -> ErrorCode.WHALE_MONITOR_STRATEGY_PRICE_INVALID
ErrorCode.PARAM_ACCOUNT_ID_INVALID.messageKey -> ErrorCode.PARAM_ACCOUNT_ID_INVALID
else -> ErrorCode.SERVER_WHALE_MONITOR_STRATEGY_CREATE_FAILED
}
ResponseEntity.ok(ApiResponse.error(code, messageSource = messageSource))
}
)
} catch (e: Exception) {
logger.error("创建大单监听策略异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_WHALE_MONITOR_STRATEGY_CREATE_FAILED, e.message, messageSource))
}
}
@PostMapping("/update")
fun update(@RequestBody request: WhaleMonitorStrategyUpdateRequest): ResponseEntity<ApiResponse<WhaleMonitorStrategyDto>> {
return try {
if (request.strategyId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.WHALE_MONITOR_STRATEGY_NOT_FOUND, messageSource = messageSource))
}
val result = whaleMonitorStrategyService.update(request)
result.fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("更新大单监听策略失败: ${e.message}", e)
val code = when (e.message) {
ErrorCode.WHALE_MONITOR_STRATEGY_NOT_FOUND.messageKey -> ErrorCode.WHALE_MONITOR_STRATEGY_NOT_FOUND
ErrorCode.WHALE_MONITOR_STRATEGY_CONDITION_IDS_EMPTY.messageKey -> ErrorCode.WHALE_MONITOR_STRATEGY_CONDITION_IDS_EMPTY
ErrorCode.WHALE_MONITOR_STRATEGY_WINDOW_INVALID.messageKey -> ErrorCode.WHALE_MONITOR_STRATEGY_WINDOW_INVALID
ErrorCode.WHALE_MONITOR_STRATEGY_THRESHOLD_INVALID.messageKey -> ErrorCode.WHALE_MONITOR_STRATEGY_THRESHOLD_INVALID
ErrorCode.WHALE_MONITOR_STRATEGY_AMOUNT_INVALID.messageKey -> ErrorCode.WHALE_MONITOR_STRATEGY_AMOUNT_INVALID
ErrorCode.WHALE_MONITOR_STRATEGY_PRICE_INVALID.messageKey -> ErrorCode.WHALE_MONITOR_STRATEGY_PRICE_INVALID
else -> ErrorCode.SERVER_WHALE_MONITOR_STRATEGY_UPDATE_FAILED
}
ResponseEntity.ok(ApiResponse.error(code, messageSource = messageSource))
}
)
} catch (e: Exception) {
logger.error("更新大单监听策略异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_WHALE_MONITOR_STRATEGY_UPDATE_FAILED, e.message, messageSource))
}
}
@PostMapping("/delete")
fun delete(@RequestBody request: WhaleMonitorStrategyDeleteRequest): ResponseEntity<ApiResponse<Unit>> {
return try {
val strategyId = request.strategyId
if (strategyId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.WHALE_MONITOR_STRATEGY_NOT_FOUND, messageSource = messageSource))
}
val result = whaleMonitorStrategyService.delete(strategyId)
result.fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(Unit)) },
onFailure = { e ->
logger.error("删除大单监听策略失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_WHALE_MONITOR_STRATEGY_DELETE_FAILED, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("删除大单监听策略异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_WHALE_MONITOR_STRATEGY_DELETE_FAILED, e.message, messageSource))
}
}
@PostMapping("/triggers")
fun getTriggerRecords(@RequestBody request: WhaleMonitorTriggerListRequest): ResponseEntity<ApiResponse<WhaleMonitorTriggerListResponse>> {
return try {
if (request.strategyId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.WHALE_MONITOR_STRATEGY_NOT_FOUND, messageSource = messageSource))
}
val result = whaleMonitorStrategyService.getTriggerRecords(request)
result.fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("查询大单监听触发记录失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_WHALE_MONITOR_STRATEGY_TRIGGERS_FETCH_FAILED, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("查询大单监听触发记录异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_WHALE_MONITOR_STRATEGY_TRIGGERS_FETCH_FAILED, e.message, messageSource))
}
}
}
@@ -0,0 +1,119 @@
package com.wrbug.polymarketbot.dto
/**
* 大单监听策略创建请求
* 金额与价格使用 String,后端转为 BigDecimal
*/
data class WhaleMonitorStrategyCreateRequest(
val accountId: Long = 0L,
val name: String? = null,
/** 监听市场 conditionId 列表 */
val conditionIds: List<String> = emptyList(),
val windowSeconds: Int = 10,
/** 触发阈值金额 */
val thresholdAmount: String = "0",
/** 固定下单金额 */
val orderAmount: String = "0",
val minPrice: String = "0",
val maxPrice: String = "1",
val cooldownSeconds: Int = 60,
val enabled: Boolean = true
)
/**
* 大单监听策略更新请求
*/
data class WhaleMonitorStrategyUpdateRequest(
val strategyId: Long = 0L,
val name: String? = null,
val conditionIds: List<String>? = null,
val windowSeconds: Int? = null,
val thresholdAmount: String? = null,
val orderAmount: String? = null,
val minPrice: String? = null,
val maxPrice: String? = null,
val cooldownSeconds: Int? = null,
val enabled: Boolean? = null
)
/**
* 大单监听策略列表请求
*/
data class WhaleMonitorStrategyListRequest(
val accountId: Long? = null,
val enabled: Boolean? = null
)
/**
* 大单监听策略 DTO(列表与详情)
*/
data class WhaleMonitorStrategyDto(
val id: Long = 0L,
val accountId: Long = 0L,
val name: String? = null,
val conditionIds: List<String> = emptyList(),
val windowSeconds: Int = 10,
val thresholdAmount: String = "0",
val orderAmount: String = "0",
val minPrice: String = "0",
val maxPrice: String = "1",
val cooldownSeconds: Int = 60,
val enabled: Boolean = true,
val lastTriggerAt: Long? = null,
val triggerCount: Long = 0L,
val createdAt: Long = 0L,
val updatedAt: Long = 0L
)
/**
* 大单监听策略列表响应
*/
data class WhaleMonitorStrategyListResponse(
val list: List<WhaleMonitorStrategyDto> = emptyList()
)
/**
* 大单监听策略删除请求
*/
data class WhaleMonitorStrategyDeleteRequest(
val strategyId: Long = 0L
)
/**
* 触发记录列表请求
*/
data class WhaleMonitorTriggerListRequest(
val strategyId: Long = 0L,
val page: Int = 1,
val pageSize: Int = 20,
val status: String? = null,
val startDate: Long? = null,
val endDate: Long? = null
)
/**
* 触发记录 DTO
*/
data class WhaleMonitorTriggerDto(
val id: Long = 0L,
val strategyId: Long = 0L,
val conditionId: String = "",
val tokenId: String = "",
val side: String = "BUY",
val triggerVolume: String = "0",
val orderPrice: String = "0",
val orderSize: String = "0",
val orderAmount: String = "0",
val orderId: String? = null,
val status: String = "success",
val failReason: String? = null,
val createdAt: Long = 0L
)
/**
* 触发记录分页响应
*/
data class WhaleMonitorTriggerListResponse(
val list: List<WhaleMonitorTriggerDto> = emptyList(),
val total: Long = 0L
)
@@ -0,0 +1,53 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
import java.math.BigDecimal
/**
* 大单监听策略实体
* 监听自选市场的 Activity WS 成交,按 tokenId+BUY 聚合窗口内 notional,达阈值自动 FAK 下单
*/
@Entity
@Table(name = "whale_monitor_strategy")
data class WhaleMonitorStrategy(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "account_id", nullable = false)
val accountId: Long = 0L,
@Column(name = "name", length = 255)
val name: String? = null,
/** 监听市场 conditionId 列表,JSON 数组格式如 ["0xabc...","0xdef..."] */
@Column(name = "condition_ids", nullable = false, columnDefinition = "TEXT")
val conditionIds: String = "[]",
@Column(name = "window_seconds", nullable = false)
val windowSeconds: Int = 10,
@Column(name = "threshold_amount", nullable = false, precision = 20, scale = 8)
val thresholdAmount: BigDecimal = BigDecimal.ZERO,
@Column(name = "order_amount", nullable = false, precision = 20, scale = 8)
val orderAmount: BigDecimal = BigDecimal.ZERO,
@Column(name = "min_price", nullable = false, precision = 20, scale = 8)
val minPrice: BigDecimal = BigDecimal.ZERO,
@Column(name = "max_price", nullable = false, precision = 20, scale = 8)
val maxPrice: BigDecimal = BigDecimal.ONE,
@Column(name = "cooldown_seconds", nullable = false)
val cooldownSeconds: Int = 60,
@Column(name = "enabled", nullable = false)
val enabled: Boolean = true,
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@Column(name = "updated_at", nullable = false)
var updatedAt: Long = System.currentTimeMillis()
)
@@ -0,0 +1,51 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
import java.math.BigDecimal
/**
* 大单监听触发记录
*/
@Entity
@Table(name = "whale_monitor_trigger")
data class WhaleMonitorTrigger(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "strategy_id", nullable = false)
val strategyId: Long = 0L,
@Column(name = "condition_id", nullable = false, length = 128)
val conditionId: String = "",
@Column(name = "token_id", nullable = false, length = 128)
val tokenId: String = "",
@Column(name = "side", nullable = false, length = 10)
val side: String = "BUY",
@Column(name = "trigger_volume", nullable = false, precision = 20, scale = 8)
val triggerVolume: BigDecimal = BigDecimal.ZERO,
@Column(name = "order_price", nullable = false, precision = 20, scale = 8)
val orderPrice: BigDecimal = BigDecimal.ZERO,
@Column(name = "order_size", nullable = false, precision = 20, scale = 8)
val orderSize: BigDecimal = BigDecimal.ZERO,
@Column(name = "order_amount", nullable = false, precision = 20, scale = 8)
val orderAmount: BigDecimal = BigDecimal.ZERO,
@Column(name = "order_id", length = 128)
val orderId: String? = null,
@Column(name = "status", nullable = false, length = 20)
val status: String = "success",
@Column(name = "fail_reason", length = 500)
val failReason: String? = null,
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis()
)
@@ -158,6 +158,14 @@ enum class ErrorCode(
ACCOUNT_BALANCE_FETCH_FAILED(4707, "查询账户余额失败", "error.account_balance_fetch_failed"),
ACCOUNT_POSITIONS_FETCH_FAILED(4708, "查询仓位列表失败", "error.account_positions_fetch_failed"),
// 大单监听策略 (4730-4739)
WHALE_MONITOR_STRATEGY_NOT_FOUND(4730, "大单监听策略不存在", "error.whale_monitor_strategy_not_found"),
WHALE_MONITOR_STRATEGY_CONDITION_IDS_EMPTY(4731, "监听市场不能为空", "error.whale_monitor_strategy_condition_ids_empty"),
WHALE_MONITOR_STRATEGY_WINDOW_INVALID(4732, "时间窗口无效", "error.whale_monitor_strategy_window_invalid"),
WHALE_MONITOR_STRATEGY_THRESHOLD_INVALID(4733, "触发阈值无效", "error.whale_monitor_strategy_threshold_invalid"),
WHALE_MONITOR_STRATEGY_AMOUNT_INVALID(4734, "下单金额无效", "error.whale_monitor_strategy_amount_invalid"),
WHALE_MONITOR_STRATEGY_PRICE_INVALID(4735, "价格区间无效", "error.whale_monitor_strategy_price_invalid"),
// 加密价差策略 (4710-4729)
CRYPTO_TAIL_STRATEGY_NOT_FOUND(4710, "加密价差策略不存在", "error.crypto_tail_strategy_not_found"),
CRYPTO_TAIL_STRATEGY_WINDOW_INVALID(4711, "时间区间开始不能大于结束", "error.crypto_tail_strategy_window_invalid"),
@@ -264,7 +272,14 @@ enum class ErrorCode(
SERVER_CRYPTO_TAIL_STRATEGY_UPDATE_FAILED(5621, "更新加密价差策略失败", "error.server.crypto_tail_strategy_update_failed"),
SERVER_CRYPTO_TAIL_STRATEGY_DELETE_FAILED(5622, "删除加密价差策略失败", "error.server.crypto_tail_strategy_delete_failed"),
SERVER_CRYPTO_TAIL_STRATEGY_LIST_FETCH_FAILED(5623, "查询加密价差策略列表失败", "error.server.crypto_tail_strategy_list_fetch_failed"),
SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED(5624, "查询触发记录失败", "error.server.crypto_tail_strategy_triggers_fetch_failed");
SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED(5624, "查询触发记录失败", "error.server.crypto_tail_strategy_triggers_fetch_failed"),
// 大单监听策略服务 (5630-5639)
SERVER_WHALE_MONITOR_STRATEGY_CREATE_FAILED(5630, "创建大单监听策略失败", "error.server.whale_monitor_strategy_create_failed"),
SERVER_WHALE_MONITOR_STRATEGY_UPDATE_FAILED(5631, "更新大单监听策略失败", "error.server.whale_monitor_strategy_update_failed"),
SERVER_WHALE_MONITOR_STRATEGY_DELETE_FAILED(5632, "删除大单监听策略失败", "error.server.whale_monitor_strategy_delete_failed"),
SERVER_WHALE_MONITOR_STRATEGY_LIST_FETCH_FAILED(5633, "查询大单监听策略列表失败", "error.server.whale_monitor_strategy_list_fetch_failed"),
SERVER_WHALE_MONITOR_STRATEGY_TRIGGERS_FETCH_FAILED(5634, "查询大单监听触发记录失败", "error.server.whale_monitor_strategy_triggers_fetch_failed");
companion object {
/**
@@ -0,0 +1,8 @@
package com.wrbug.polymarketbot.event
import org.springframework.context.ApplicationEvent
/**
* 大单监听策略创建/更新/删除/启用状态变更后发布,通知 WS 服务重载监听配置
*/
class WhaleMonitorStrategyChangedEvent(source: Any) : ApplicationEvent(source)
@@ -0,0 +1,10 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.WhaleMonitorStrategy
import org.springframework.data.jpa.repository.JpaRepository
interface WhaleMonitorStrategyRepository : JpaRepository<WhaleMonitorStrategy, Long> {
fun findAllByAccountId(accountId: Long): List<WhaleMonitorStrategy>
fun findAllByEnabledTrue(): List<WhaleMonitorStrategy>
fun findByAccountIdAndEnabled(accountId: Long, enabled: Boolean): List<WhaleMonitorStrategy>
}
@@ -0,0 +1,17 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.WhaleMonitorTrigger
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
interface WhaleMonitorTriggerRepository : JpaRepository<WhaleMonitorTrigger, Long> {
fun findAllByStrategyIdOrderByCreatedAtDesc(strategyId: Long, pageable: Pageable): Page<WhaleMonitorTrigger>
fun findAllByStrategyIdAndStatusOrderByCreatedAtDesc(strategyId: Long, status: String, pageable: Pageable): Page<WhaleMonitorTrigger>
fun findAllByStrategyIdAndCreatedAtBetweenOrderByCreatedAtDesc(strategyId: Long, startTs: Long, endTs: Long, pageable: Pageable): Page<WhaleMonitorTrigger>
fun findAllByStrategyIdAndStatusAndCreatedAtBetweenOrderByCreatedAtDesc(strategyId: Long, status: String, startTs: Long, endTs: Long, pageable: Pageable): Page<WhaleMonitorTrigger>
fun countByStrategyId(strategyId: Long): Long
fun countByStrategyIdAndStatus(strategyId: Long, status: String): Long
fun countByStrategyIdAndCreatedAtBetween(strategyId: Long, startTs: Long, endTs: Long): Long
fun countByStrategyIdAndStatusAndCreatedAtBetween(strategyId: Long, status: String, startTs: Long, endTs: Long): Long
}
@@ -31,6 +31,12 @@ class MarketService(
private val marketCache: Cache<String, Market> = Caffeine.newBuilder()
.maximumSize(200) // 最多缓存 200 条记录
.build()
/** 体育联赛列表缓存(tagId → seriesId 解析) */
private val sportsCategoriesCache: Cache<String, List<SportCategoryResult>> = Caffeine.newBuilder()
.maximumSize(1)
.expireAfterWrite(java.time.Duration.ofMinutes(10))
.build()
/**
* 根据市场ID获取市场信息
@@ -281,6 +287,233 @@ class MarketService(
null
}
}
/**
* 搜索市场(按标题关键词,可选按标签筛选)
* 调用 Gamma API /markets?title=xxx 搜索,返回活跃且未关闭的市场
*/
suspend fun searchMarkets(
keyword: String,
tagId: String? = null,
seriesId: String? = null,
sportSlug: String? = null,
limit: Int = 20
): List<MarketSearchResult> {
if (keyword.isBlank() && tagId.isNullOrBlank() && seriesId.isNullOrBlank() && sportSlug.isNullOrBlank()) {
return emptyList()
}
return try {
val sport = resolveSportCategory(seriesId, tagId, sportSlug)
if (sport != null && !sport.seriesId.isNullOrBlank()) {
searchSportsLeagueMarkets(keyword, sport, limit)
} else {
searchMarketsByTag(keyword, tagId, limit)
}
} catch (e: Exception) {
logger.warn("搜索市场失败: keyword=$keyword, seriesId=$seriesId, sportSlug=$sportSlug, tagId=$tagId, error=${e.message}")
emptyList()
}
}
/**
* 解析体育联赛配置(seriesId 用于单场比赛,tagId 用于长期市场)
*/
private suspend fun resolveSportCategory(
seriesId: String?,
tagId: String?,
sportSlug: String?
): SportCategoryResult? {
val sports = getSportsCategoriesCached()
seriesId?.takeIf { it.isNotBlank() }?.let { sid ->
sports.find { it.seriesId == sid }?.let { return it }
}
sportSlug?.trim()?.takeIf { it.isNotBlank() }?.let { slug ->
sports.find { it.slug.equals(slug, ignoreCase = true) }?.let { return it }
}
tagId?.takeIf { it.isNotBlank() }?.let { tid ->
sports.find { it.tagId == tid }?.let { return it }
}
return null
}
/**
* 体育联赛:单场比赛(series events+ 长期市场(tag markets)合并返回
*/
private suspend fun searchSportsLeagueMarkets(
keyword: String,
sport: SportCategoryResult,
limit: Int
): List<MarketSearchResult> {
val seriesId = sport.seriesId ?: return searchMarketsByTag(keyword, sport.tagId, limit)
val gameLimit = ((limit * 2) / 3).coerceIn(50, limit)
val seasonLimit = (limit - gameLimit).coerceAtLeast(30)
val games = searchMarketsBySeries(keyword, seriesId, gameLimit)
val seenIds = games.map { it.conditionId }.toMutableSet()
val seasons = searchMarketsByTag(keyword, sport.tagId, seasonLimit)
.filter { seenIds.add(it.conditionId) }
return games + seasons
}
private suspend fun getSportsCategoriesCached(): List<SportCategoryResult> {
sportsCategoriesCache.getIfPresent("all")?.let { return it }
val fresh = fetchSportsCategoriesFromApi()
sportsCategoriesCache.put("all", fresh)
return fresh
}
private suspend fun searchMarketsByTag(keyword: String, tagId: String?, limit: Int): List<MarketSearchResult> {
val gammaApi = retrofitFactory.createGammaApi()
val response = gammaApi.listMarkets(
title = keyword,
tagId = tagId,
closed = false,
active = true,
limit = limit
)
if (!response.isSuccessful || response.body().isNullOrEmpty()) return emptyList()
return response.body()!!.mapNotNull { m ->
m.toMarketSearchResult(marketType = MarketSearchResult.TYPE_SEASON)
}
}
/**
* 体育联赛:通过 series_id 拉取 events,展开其中活跃 markets(单场比赛、让分等)
*/
private suspend fun searchMarketsBySeries(keyword: String, seriesId: String, limit: Int): List<MarketSearchResult> {
val gammaApi = retrofitFactory.createGammaApi()
val kw = keyword.trim().lowercase()
val results = mutableListOf<MarketSearchResult>()
val seenConditionIds = mutableSetOf<String>()
var offset = 0
val pageSize = 30
while (results.size < limit && offset < 300) {
val response = gammaApi.listEvents(
seriesId = seriesId,
closed = false,
active = true,
limit = pageSize,
offset = offset,
order = "volume",
ascending = false
)
if (!response.isSuccessful || response.body().isNullOrEmpty()) break
val events = response.body()!!
if (events.isEmpty()) break
for (event in events) {
val eventTitle = event.title?.trim().orEmpty()
for (market in event.markets.orEmpty()) {
if (market.active != true || market.closed == true) continue
val item = market.toMarketSearchResult(
eventTitle = eventTitle,
eventSlug = event.slug,
eventCategory = event.category,
eventImage = event.image,
eventIcon = event.icon,
marketType = MarketSearchResult.TYPE_GAME
) ?: continue
if (!seenConditionIds.add(item.conditionId)) continue
if (kw.length >= 2) {
val haystack = "${eventTitle} ${item.title}".lowercase()
if (!haystack.contains(kw)) continue
}
results.add(item)
if (results.size >= limit) return results
}
}
offset += pageSize
if (events.size < pageSize) break
}
return results
}
private fun pickImageUrl(image: String?, icon: String?): String? {
return image?.trim()?.takeIf { it.isNotBlank() }
?: icon?.trim()?.takeIf { it.isNotBlank() }
}
private fun MarketResponse.toMarketSearchResult(
eventTitle: String = "",
eventSlug: String? = null,
eventCategory: String? = null,
eventImage: String? = null,
eventIcon: String? = null,
marketType: String = MarketSearchResult.TYPE_SEASON
): MarketSearchResult? {
val conditionId = conditionId ?: return null
val question = question?.trim().orEmpty()
val displayEventTitle = if (marketType == MarketSearchResult.TYPE_GAME) {
eventTitle.takeIf { it.isNotBlank() && !question.contains(it, ignoreCase = true) }
} else {
null
}
val marketImageUrl = pickImageUrl(image, icon)
val eventImageUrl = if (marketType == MarketSearchResult.TYPE_GAME) {
pickImageUrl(eventImage, eventIcon) ?: marketImageUrl
} else {
null
}
return MarketSearchResult(
conditionId = conditionId,
title = question,
slug = slug ?: eventSlug,
category = category ?: eventCategory,
volume = volume,
outcomes = outcomes,
eventTitle = displayEventTitle,
marketType = marketType,
image = marketImageUrl,
icon = icon?.trim()?.takeIf { it.isNotBlank() },
eventImage = eventImageUrl
)
}
private val sportNameMap = mapOf(
"ncaab" to "NCAA Basketball", "epl" to "EPL", "lal" to "La Liga",
"ipl" to "IPL Cricket", "wnba" to "WNBA", "bun" to "Bundesliga",
"mlb" to "MLB", "cfb" to "CFB", "nfl" to "NFL",
"fl1" to "Ligue 1", "sea" to "Serie A", "ucl" to "Champions League",
"afc" to "AFC", "ofc" to "OFC", "acn" to "Africa Cup of Nations",
"ncaaw" to "NCAA Women's BB", "clp" to "Copa Libertadores",
"mls" to "MLS", "nba" to "NBA", "nhl" to "NHL"
)
private val popularSportSlugs = listOf("nba", "nfl", "mlb", "nhl", "epl", "ucl", "lal", "serie-a", "sea", "bun")
/**
* 获取体育联赛子分类列表(仅含具备 seriesId 的联赛,用于拉取单场比赛盘口)
*/
suspend fun listSportsCategories(): List<SportCategoryResult> {
return try {
getSportsCategoriesCached()
} catch (e: Exception) {
logger.warn("获取体育分类失败: ${e.message}")
emptyList()
}
}
private suspend fun fetchSportsCategoriesFromApi(): List<SportCategoryResult> {
val gammaApi = retrofitFactory.createGammaApi()
val response = gammaApi.listSports()
if (!response.isSuccessful || response.body().isNullOrEmpty()) return emptyList()
return response.body()!!.mapNotNull { sport ->
val slug = sport.sport ?: return@mapNotNull null
val series = sport.series?.trim().orEmpty()
if (series.isEmpty()) return@mapNotNull null
val tags = (sport.tags ?: "").split(",")
val specificTag = tags.firstOrNull { it != "1" && it != "100639" } ?: return@mapNotNull null
SportCategoryResult(
id = sport.id ?: return@mapNotNull null,
slug = slug,
label = sportNameMap[slug] ?: slug.uppercase(),
tagId = specificTag,
seriesId = series,
image = sport.image
)
}.sortedBy { sport ->
val idx = popularSportSlugs.indexOf(sport.slug)
if (idx >= 0) idx else Int.MAX_VALUE
}
}
}
/**
@@ -291,3 +524,35 @@ data class MarketInfoByTokenId(
val outcomeIndex: Int,
val outcome: String? = null
)
data class MarketSearchResult(
val conditionId: String,
val title: String,
val slug: String? = null,
val category: String? = null,
val volume: String? = null,
val outcomes: String? = null,
/** 所属赛事/对阵(体育单场比赛) */
val eventTitle: String? = null,
/** game=单场赛事盘口,season=长期/赛季类市场 */
val marketType: String = TYPE_SEASON,
/** 市场封面图(优先 image,无则 icon */
val image: String? = null,
val icon: String? = null,
/** 所属赛事封面(体育单场比赛分组用) */
val eventImage: String? = null
) {
companion object {
const val TYPE_GAME = "game"
const val TYPE_SEASON = "season"
}
}
data class SportCategoryResult(
val id: Int,
val slug: String,
val label: String,
val tagId: String,
val seriesId: String? = null,
val image: String? = null
)
@@ -0,0 +1,56 @@
package com.wrbug.polymarketbot.service.whalemonitor
import com.wrbug.polymarketbot.event.WhaleMonitorStrategyChangedEvent
import jakarta.annotation.PostConstruct
import jakarta.annotation.PreDestroy
import org.slf4j.LoggerFactory
import org.springframework.context.event.EventListener
import org.springframework.stereotype.Service
/**
* 大单监听生命周期管理
* 启动时加载已启用策略、监听策略变更事件重载 WS 配置
*/
@Service
class WhaleMonitorLifecycleService(
private val strategyService: WhaleMonitorStrategyService,
private val wsService: WhaleMonitorWsService
) {
private val logger = LoggerFactory.getLogger(WhaleMonitorLifecycleService::class.java)
@PostConstruct
fun init() {
try {
val strategies = strategyService.getEnabledStrategies()
if (strategies.isNotEmpty()) {
logger.info("大单监听: 加载 ${strategies.size} 个已启用策略")
wsService.start(strategies)
} else {
logger.info("大单监听: 没有已启用的策略")
}
} catch (e: Exception) {
logger.error("大单监听: 初始化失败: ${e.message}", e)
}
}
@EventListener
fun onStrategyChanged(event: WhaleMonitorStrategyChangedEvent) {
logger.info("大单监听: 策略变更,重新加载")
try {
val strategies = strategyService.getEnabledStrategies()
if (strategies.isNotEmpty()) {
wsService.start(strategies)
} else {
wsService.stop()
}
} catch (e: Exception) {
logger.error("大单监听: 重载策略失败: ${e.message}", e)
}
}
@PreDestroy
fun destroy() {
wsService.stop()
}
}
@@ -0,0 +1,204 @@
package com.wrbug.polymarketbot.service.whalemonitor
import com.wrbug.polymarketbot.api.NewOrderRequest
import com.wrbug.polymarketbot.entity.Account
import com.wrbug.polymarketbot.entity.WhaleMonitorStrategy
import com.wrbug.polymarketbot.entity.WhaleMonitorTrigger
import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.repository.WhaleMonitorTriggerRepository
import com.wrbug.polymarketbot.service.common.PolymarketClobService
import com.wrbug.polymarketbot.service.copytrading.orders.OrderSigningService
import com.wrbug.polymarketbot.util.CryptoUtils
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.math.RoundingMode
@Service
class WhaleMonitorOrderExecutionService(
private val accountRepository: AccountRepository,
private val triggerRepository: WhaleMonitorTriggerRepository,
private val clobService: PolymarketClobService,
private val orderSigningService: OrderSigningService,
private val retrofitFactory: RetrofitFactory,
private val cryptoUtils: CryptoUtils
) {
private val logger = LoggerFactory.getLogger(WhaleMonitorOrderExecutionService::class.java)
private val mutexMap = java.util.concurrent.ConcurrentHashMap<String, Mutex>()
suspend fun executeOrder(
strategy: WhaleMonitorStrategy,
conditionId: String,
tokenId: String,
triggerVolume: BigDecimal
) {
val mutexKey = "${strategy.id}-$tokenId"
val mutex = mutexMap.computeIfAbsent(mutexKey) { Mutex() }
mutex.withLock {
try {
doExecuteOrder(strategy, conditionId, tokenId, triggerVolume)
} catch (e: Exception) {
logger.error("大单监听下单执行失败 strategyId=${strategy.id} tokenId=$tokenId: ${e.message}", e)
saveTriggerRecord(
strategy = strategy,
conditionId = conditionId,
tokenId = tokenId,
triggerVolume = triggerVolume,
orderPrice = BigDecimal.ZERO,
orderSize = BigDecimal.ZERO,
orderAmount = BigDecimal.ZERO,
orderId = null,
status = "fail",
failReason = e.message?.take(500)
)
}
}
}
private suspend fun doExecuteOrder(
strategy: WhaleMonitorStrategy,
conditionId: String,
tokenId: String,
triggerVolume: BigDecimal
) {
val account = accountRepository.findById(strategy.accountId).orElse(null)
if (account == null) {
logger.error("大单监听: 账户不存在 accountId=${strategy.accountId}")
return
}
if (account.apiKey.isNullOrBlank() || account.apiSecret.isNullOrBlank() || account.apiPassphrase.isNullOrBlank()) {
logger.error("大单监听: 账户 API 凭证未配置 accountId=${strategy.accountId}")
return
}
val orderbookResult = clobService.getOrderbookByTokenId(tokenId)
if (orderbookResult.isFailure) {
logger.error("大单监听: 获取订单簿失败 tokenId=$tokenId")
return
}
val orderbook = orderbookResult.getOrThrow()
val asks = orderbook.asks
if (asks.isNullOrEmpty()) {
logger.info("大单监听: 订单簿无卖单,跳过 tokenId=$tokenId")
return
}
val bestAsk = asks.minOfOrNull { it.price.toSafeBigDecimal() } ?: return
if (bestAsk < strategy.minPrice || bestAsk > strategy.maxPrice) {
logger.info("大单监听: 价格 $bestAsk 不在区间 [${strategy.minPrice}, ${strategy.maxPrice}],跳过 tokenId=$tokenId")
saveTriggerRecord(
strategy = strategy,
conditionId = conditionId,
tokenId = tokenId,
triggerVolume = triggerVolume,
orderPrice = bestAsk,
orderSize = BigDecimal.ZERO,
orderAmount = BigDecimal.ZERO,
orderId = null,
status = "fail",
failReason = "价格 $bestAsk 不在区间 [${strategy.minPrice}, ${strategy.maxPrice}]"
)
return
}
val orderAmount = strategy.orderAmount
val orderSize = orderAmount.divide(bestAsk, 0, RoundingMode.UP).coerceAtLeast(BigDecimal.ONE)
val decryptedPrivateKey = cryptoUtils.decrypt(account.privateKey)
val signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
val signedOrder = orderSigningService.createAndSignOrder(
privateKey = decryptedPrivateKey,
makerAddress = account.proxyAddress,
tokenId = tokenId,
side = "BUY",
price = bestAsk.toPlainString(),
size = orderSize.toPlainString(),
signatureType = signatureType
)
val newOrderRequest = NewOrderRequest(
order = signedOrder,
owner = account.apiKey,
orderType = "FAK"
)
val apiSecret = cryptoUtils.decrypt(account.apiSecret)
val apiPassphrase = cryptoUtils.decrypt(account.apiPassphrase)
val clobApi = retrofitFactory.createClobApi(
account.apiKey, apiSecret, apiPassphrase, account.walletAddress
)
val response = clobApi.createOrder(newOrderRequest)
if (response.isSuccessful) {
val body = response.body()
val orderId = body?.orderId
logger.info("大单监听: 下单成功 strategyId=${strategy.id} tokenId=$tokenId orderId=$orderId price=$bestAsk size=$orderSize")
saveTriggerRecord(
strategy = strategy,
conditionId = conditionId,
tokenId = tokenId,
triggerVolume = triggerVolume,
orderPrice = bestAsk,
orderSize = orderSize,
orderAmount = orderAmount,
orderId = orderId,
status = "success",
failReason = null
)
} else {
val errorBody = response.errorBody()?.string()?.take(200)
logger.error("大单监听: 下单失败 strategyId=${strategy.id} tokenId=$tokenId code=${response.code()} body=$errorBody")
saveTriggerRecord(
strategy = strategy,
conditionId = conditionId,
tokenId = tokenId,
triggerVolume = triggerVolume,
orderPrice = bestAsk,
orderSize = orderSize,
orderAmount = orderAmount,
orderId = null,
status = "fail",
failReason = "下单失败: ${response.code()} $errorBody"
)
}
}
private fun saveTriggerRecord(
strategy: WhaleMonitorStrategy,
conditionId: String,
tokenId: String,
triggerVolume: BigDecimal,
orderPrice: BigDecimal,
orderSize: BigDecimal,
orderAmount: BigDecimal,
orderId: String?,
status: String,
failReason: String?
) {
try {
val trigger = WhaleMonitorTrigger(
strategyId = strategy.id ?: return,
conditionId = conditionId,
tokenId = tokenId,
side = "BUY",
triggerVolume = triggerVolume,
orderPrice = orderPrice,
orderSize = orderSize,
orderAmount = orderAmount,
orderId = orderId,
status = status,
failReason = failReason
)
triggerRepository.save(trigger)
} catch (e: Exception) {
logger.error("大单监听: 保存触发记录失败: ${e.message}", e)
}
}
}
@@ -0,0 +1,294 @@
package com.wrbug.polymarketbot.service.whalemonitor
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.entity.WhaleMonitorStrategy
import com.wrbug.polymarketbot.entity.WhaleMonitorTrigger
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.event.WhaleMonitorStrategyChangedEvent
import com.wrbug.polymarketbot.repository.WhaleMonitorStrategyRepository
import com.wrbug.polymarketbot.repository.WhaleMonitorTriggerRepository
import com.wrbug.polymarketbot.util.fromJson
import com.wrbug.polymarketbot.util.gt
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import com.wrbug.polymarketbot.util.toJson
import org.slf4j.LoggerFactory
import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
import java.math.RoundingMode
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Service
class WhaleMonitorStrategyService(
private val strategyRepository: WhaleMonitorStrategyRepository,
private val triggerRepository: WhaleMonitorTriggerRepository,
private val eventPublisher: ApplicationEventPublisher
) {
private val logger = LoggerFactory.getLogger(WhaleMonitorStrategyService::class.java)
@Transactional
fun create(request: WhaleMonitorStrategyCreateRequest): Result<WhaleMonitorStrategyDto> {
return try {
if (request.accountId <= 0) {
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ACCOUNT_ID_INVALID.messageKey))
}
if (request.conditionIds.isEmpty()) {
return Result.failure(IllegalArgumentException(ErrorCode.WHALE_MONITOR_STRATEGY_CONDITION_IDS_EMPTY.messageKey))
}
if (request.windowSeconds <= 0) {
return Result.failure(IllegalArgumentException(ErrorCode.WHALE_MONITOR_STRATEGY_WINDOW_INVALID.messageKey))
}
val thresholdAmount = request.thresholdAmount.toSafeBigDecimal()
if (thresholdAmount <= BigDecimal.ZERO) {
return Result.failure(IllegalArgumentException(ErrorCode.WHALE_MONITOR_STRATEGY_THRESHOLD_INVALID.messageKey))
}
val orderAmount = request.orderAmount.toSafeBigDecimal()
if (orderAmount <= BigDecimal.ZERO) {
return Result.failure(IllegalArgumentException(ErrorCode.WHALE_MONITOR_STRATEGY_AMOUNT_INVALID.messageKey))
}
if (request.cooldownSeconds <= 0) {
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
}
val minPrice = request.minPrice.toSafeBigDecimal()
val maxPrice = request.maxPrice.toSafeBigDecimal()
if (minPrice < BigDecimal.ZERO || maxPrice > BigDecimal.ONE || minPrice > maxPrice) {
return Result.failure(IllegalArgumentException(ErrorCode.WHALE_MONITOR_STRATEGY_PRICE_INVALID.messageKey))
}
validatePriceDecimalPlaces(minPrice, "minPrice")
validatePriceDecimalPlaces(maxPrice, "maxPrice")
val conditionIdsJson = request.conditionIds.toJson()
val nameToSave = request.name?.takeIf { it.isNotBlank() }
?: generateStrategyName()
val entity = WhaleMonitorStrategy(
accountId = request.accountId,
name = nameToSave,
conditionIds = conditionIdsJson,
windowSeconds = request.windowSeconds,
thresholdAmount = thresholdAmount,
orderAmount = orderAmount,
minPrice = minPrice,
maxPrice = maxPrice,
cooldownSeconds = request.cooldownSeconds,
enabled = request.enabled
)
val saved = strategyRepository.save(entity)
eventPublisher.publishEvent(WhaleMonitorStrategyChangedEvent(this))
Result.success(entityToDto(saved, null, 0L))
} catch (e: IllegalArgumentException) {
Result.failure(e)
} catch (e: Exception) {
logger.error("创建大单监听策略失败: ${e.message}", e)
Result.failure(e)
}
}
@Transactional
fun update(request: WhaleMonitorStrategyUpdateRequest): Result<WhaleMonitorStrategyDto> {
return try {
val existing = strategyRepository.findById(request.strategyId).orElse(null)
?: return Result.failure(IllegalArgumentException(ErrorCode.WHALE_MONITOR_STRATEGY_NOT_FOUND.messageKey))
val conditionIdsJson = request.conditionIds?.let {
if (it.isEmpty()) return Result.failure(IllegalArgumentException(ErrorCode.WHALE_MONITOR_STRATEGY_CONDITION_IDS_EMPTY.messageKey))
it.toJson()
} ?: existing.conditionIds
val windowSeconds = request.windowSeconds ?: existing.windowSeconds
if (windowSeconds <= 0) {
return Result.failure(IllegalArgumentException(ErrorCode.WHALE_MONITOR_STRATEGY_WINDOW_INVALID.messageKey))
}
val thresholdAmount = request.thresholdAmount?.toSafeBigDecimal() ?: existing.thresholdAmount
if (thresholdAmount <= BigDecimal.ZERO) {
return Result.failure(IllegalArgumentException(ErrorCode.WHALE_MONITOR_STRATEGY_THRESHOLD_INVALID.messageKey))
}
val orderAmount = request.orderAmount?.toSafeBigDecimal() ?: existing.orderAmount
if (orderAmount <= BigDecimal.ZERO) {
return Result.failure(IllegalArgumentException(ErrorCode.WHALE_MONITOR_STRATEGY_AMOUNT_INVALID.messageKey))
}
val minPrice = request.minPrice?.toSafeBigDecimal() ?: existing.minPrice
val maxPrice = request.maxPrice?.toSafeBigDecimal() ?: existing.maxPrice
if (minPrice < BigDecimal.ZERO || maxPrice > BigDecimal.ONE || minPrice > maxPrice) {
return Result.failure(IllegalArgumentException(ErrorCode.WHALE_MONITOR_STRATEGY_PRICE_INVALID.messageKey))
}
validatePriceDecimalPlaces(minPrice, "minPrice")
validatePriceDecimalPlaces(maxPrice, "maxPrice")
val cooldownSeconds = request.cooldownSeconds ?: existing.cooldownSeconds
if (cooldownSeconds <= 0) {
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
}
val nameToSave = request.name?.takeIf { it.isNotBlank() }
?: existing.name?.takeIf { it.isNotBlank() }
?: generateStrategyName()
val updated = existing.copy(
name = nameToSave,
conditionIds = conditionIdsJson,
windowSeconds = windowSeconds,
thresholdAmount = thresholdAmount,
orderAmount = orderAmount,
minPrice = minPrice,
maxPrice = maxPrice,
cooldownSeconds = cooldownSeconds,
enabled = request.enabled ?: existing.enabled,
updatedAt = System.currentTimeMillis()
)
val saved = strategyRepository.save(updated)
eventPublisher.publishEvent(WhaleMonitorStrategyChangedEvent(this))
val lastTrigger = triggerRepository.findAllByStrategyIdOrderByCreatedAtDesc(saved.id!!, PageRequest.of(0, 1))
.content.firstOrNull()?.createdAt
val triggerCount = triggerRepository.countByStrategyId(saved.id!!)
Result.success(entityToDto(saved, lastTrigger, triggerCount))
} catch (e: IllegalArgumentException) {
Result.failure(e)
} catch (e: Exception) {
logger.error("更新大单监听策略失败: ${e.message}", e)
Result.failure(e)
}
}
@Transactional
fun delete(strategyId: Long): Result<Unit> {
return try {
if (!strategyRepository.existsById(strategyId)) {
return Result.failure(IllegalArgumentException(ErrorCode.WHALE_MONITOR_STRATEGY_NOT_FOUND.messageKey))
}
strategyRepository.deleteById(strategyId)
eventPublisher.publishEvent(WhaleMonitorStrategyChangedEvent(this))
Result.success(Unit)
} catch (e: Exception) {
logger.error("删除大单监听策略失败: ${e.message}", e)
Result.failure(e)
}
}
fun list(request: WhaleMonitorStrategyListRequest): Result<WhaleMonitorStrategyListResponse> {
return try {
val list = when {
request.accountId != null && request.enabled != null -> strategyRepository.findByAccountIdAndEnabled(request.accountId, request.enabled)
request.accountId != null -> strategyRepository.findAllByAccountId(request.accountId)
request.enabled == true -> strategyRepository.findAllByEnabledTrue()
request.enabled == false -> strategyRepository.findAll().filter { !it.enabled }
else -> strategyRepository.findAll()
}
val dtos = list.map { entity ->
val lastTrigger = if (entity.id != null) {
triggerRepository.findAllByStrategyIdOrderByCreatedAtDesc(entity.id, PageRequest.of(0, 1))
.content.firstOrNull()?.createdAt
} else null
val triggerCount = if (entity.id != null) triggerRepository.countByStrategyId(entity.id) else 0L
entityToDto(entity, lastTrigger, triggerCount)
}
Result.success(WhaleMonitorStrategyListResponse(list = dtos))
} catch (e: Exception) {
logger.error("查询大单监听策略列表失败: ${e.message}", e)
Result.failure(e)
}
}
fun getTriggerRecords(request: WhaleMonitorTriggerListRequest): Result<WhaleMonitorTriggerListResponse> {
return try {
val page = PageRequest.of((request.page - 1).coerceAtLeast(0), request.pageSize.coerceIn(1, 100))
val startTs = request.startDate ?: 0L
val endTs = request.endDate ?: Long.MAX_VALUE
val useTimeRange = request.startDate != null || request.endDate != null
val pageResult = when {
useTimeRange && !request.status.isNullOrBlank() ->
triggerRepository.findAllByStrategyIdAndStatusAndCreatedAtBetweenOrderByCreatedAtDesc(
request.strategyId, request.status, startTs, endTs, page
)
useTimeRange ->
triggerRepository.findAllByStrategyIdAndCreatedAtBetweenOrderByCreatedAtDesc(
request.strategyId, startTs, endTs, page
)
!request.status.isNullOrBlank() ->
triggerRepository.findAllByStrategyIdAndStatusOrderByCreatedAtDesc(request.strategyId, request.status, page)
else ->
triggerRepository.findAllByStrategyIdOrderByCreatedAtDesc(request.strategyId, page)
}
val list = pageResult.content.map { triggerToDto(it) }
val total = when {
useTimeRange && !request.status.isNullOrBlank() ->
triggerRepository.countByStrategyIdAndStatusAndCreatedAtBetween(request.strategyId, request.status, startTs, endTs)
useTimeRange ->
triggerRepository.countByStrategyIdAndCreatedAtBetween(request.strategyId, startTs, endTs)
!request.status.isNullOrBlank() ->
triggerRepository.countByStrategyIdAndStatus(request.strategyId, request.status)
else ->
pageResult.totalElements
}
Result.success(WhaleMonitorTriggerListResponse(list = list, total = total))
} catch (e: Exception) {
logger.error("查询大单监听触发记录失败: ${e.message}", e)
Result.failure(e)
}
}
fun getEnabledStrategies(): List<WhaleMonitorStrategy> = strategyRepository.findAllByEnabledTrue()
fun getStrategy(strategyId: Long): WhaleMonitorStrategy? = strategyRepository.findById(strategyId).orElse(null)
private fun validatePriceDecimalPlaces(value: BigDecimal, fieldName: String) {
val scale = value.stripTrailingZeros().scale()
if (scale > 2) {
throw IllegalArgumentException(ErrorCode.WHALE_MONITOR_STRATEGY_PRICE_INVALID.messageKey)
}
}
private fun generateStrategyName(): String {
val suffix = Instant.now().atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
return "大单监听策略-$suffix"
}
private fun entityToDto(e: WhaleMonitorStrategy, lastTriggerAt: Long?, triggerCount: Long): WhaleMonitorStrategyDto {
val conditionIdList: List<String> = try {
e.conditionIds.fromJson<List<String>>() ?: emptyList()
} catch (_: Exception) {
emptyList()
}
return WhaleMonitorStrategyDto(
id = e.id ?: 0L,
accountId = e.accountId,
name = e.name,
conditionIds = conditionIdList,
windowSeconds = e.windowSeconds,
thresholdAmount = e.thresholdAmount.toPlainString(),
orderAmount = e.orderAmount.toPlainString(),
minPrice = e.minPrice.toPlainString(),
maxPrice = e.maxPrice.toPlainString(),
cooldownSeconds = e.cooldownSeconds,
enabled = e.enabled,
lastTriggerAt = lastTriggerAt,
triggerCount = triggerCount,
createdAt = e.createdAt,
updatedAt = e.updatedAt
)
}
private fun triggerToDto(t: WhaleMonitorTrigger): WhaleMonitorTriggerDto = WhaleMonitorTriggerDto(
id = t.id ?: 0L,
strategyId = t.strategyId,
conditionId = t.conditionId,
tokenId = t.tokenId,
side = t.side,
triggerVolume = t.triggerVolume.toPlainString(),
orderPrice = t.orderPrice.toPlainString(),
orderSize = t.orderSize.toPlainString(),
orderAmount = t.orderAmount.toPlainString(),
orderId = t.orderId,
status = t.status,
failReason = t.failReason,
createdAt = t.createdAt
)
}
@@ -0,0 +1,301 @@
package com.wrbug.polymarketbot.service.whalemonitor
import com.github.benmanes.caffeine.cache.Cache
import com.github.benmanes.caffeine.cache.Caffeine
import com.wrbug.polymarketbot.dto.ActivityTradeMessage
import com.wrbug.polymarketbot.entity.WhaleMonitorStrategy
import com.wrbug.polymarketbot.constants.PolymarketConstants
import com.wrbug.polymarketbot.util.fromJson
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import com.wrbug.polymarketbot.util.toJson
import com.wrbug.polymarketbot.websocket.PolymarketWebSocketClient
import jakarta.annotation.PreDestroy
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
private data class TradePoint(val tsMillis: Long, val notional: BigDecimal)
private data class AggKey(val conditionId: String, val tokenId: String, val side: String)
/**
* 大单监听 WebSocket 服务
* 订阅全局 Activity trades conditionId 过滤滑动窗口聚合 notional达阈值触发下单
*/
@Service
class WhaleMonitorWsService(
private val orderExecutionService: WhaleMonitorOrderExecutionService
) {
private val logger = LoggerFactory.getLogger(WhaleMonitorWsService::class.java)
private val websocketUrl: String = PolymarketConstants.ACTIVITY_WS_URL
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private var wsClient: PolymarketWebSocketClient? = null
/** conditionId -> 关联的策略列表 */
private val conditionIdStrategies = ConcurrentHashMap<String, MutableList<WhaleMonitorStrategy>>()
/** 聚合窗口:每个 AggKey 维护一个滑动窗口 */
private val windowBuffers = ConcurrentHashMap<AggKey, ArrayDeque<TradePoint>>()
private val runningSums = ConcurrentHashMap<AggKey, BigDecimal>()
/** 冷却:strategyId-tokenId -> 上次触发毫秒时间戳 */
private val cooldownTimestamps = ConcurrentHashMap<String, Long>()
/** txHash 去重 */
private val processedTxHashes: Cache<String, Long> = Caffeine.newBuilder()
.maximumSize(500)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build()
@Volatile
private var isSubscribed = false
private var cleanupJob: Job? = null
fun start(strategies: List<WhaleMonitorStrategy>) {
rebuildConditionIdMap(strategies)
if (conditionIdStrategies.isEmpty()) {
logger.info("没有需要监听的市场,停止大单监听 WebSocket")
stop()
return
}
logger.info("启动大单监听 WebSocket,监控 ${conditionIdStrategies.size} 个市场")
connectAndSubscribe()
startCleanupTask()
}
fun stop() {
logger.info("停止大单监听 WebSocket")
cleanupJob?.cancel()
cleanupJob = null
wsClient?.closeConnection()
wsClient = null
isSubscribed = false
conditionIdStrategies.clear()
windowBuffers.clear()
runningSums.clear()
cooldownTimestamps.clear()
processedTxHashes.invalidateAll()
}
fun getMonitoredMarketCount(): Int = conditionIdStrategies.size
private fun rebuildConditionIdMap(strategies: List<WhaleMonitorStrategy>) {
conditionIdStrategies.clear()
for (strategy in strategies) {
addStrategyToMap(strategy)
}
}
private fun addStrategyToMap(strategy: WhaleMonitorStrategy) {
val ids: List<String> = try {
strategy.conditionIds.fromJson<List<String>>() ?: emptyList()
} catch (_: Exception) {
emptyList()
}
for (cid in ids) {
conditionIdStrategies.computeIfAbsent(cid) { mutableListOf() }.add(strategy)
}
}
private fun connectAndSubscribe() {
val existingClient = wsClient
if (existingClient != null && existingClient.isConnected()) {
if (!isSubscribed) subscribe()
return
}
logger.info("连接大单监听 Activity WebSocket: $websocketUrl")
val newClient = PolymarketWebSocketClient(
url = websocketUrl,
sessionId = "whale-monitor-activity",
onMessage = { message -> handleMessage(message) },
onOpen = {
logger.info("大单监听 WebSocket 连接成功")
subscribe()
},
onReconnect = {
logger.info("大单监听 WebSocket 重连成功,重新订阅")
subscribe()
}
)
wsClient = newClient
scope.launch {
try {
newClient.connect()
} catch (e: Exception) {
logger.error("连接大单监听 WebSocket 失败", e)
}
}
}
private fun subscribe() {
val client = wsClient ?: return
if (!client.isConnected()) return
try {
val subscribeMessage = """
{
"action": "subscribe",
"subscriptions": [
{
"topic": "activity",
"type": "trades"
}
]
}
""".trimIndent()
client.sendMessage(subscribeMessage)
isSubscribed = true
logger.info("大单监听 WebSocket 订阅成功(全局交易流: trades)")
} catch (e: Exception) {
logger.error("订阅大单监听 WebSocket 失败", e)
isSubscribed = false
}
}
private fun handleMessage(message: String) {
try {
if (message.trim() == "PONG" || message.trim() == "pong") return
if (!fastFilterByConditionId(message)) return
val tradeMessage = message.fromJson<ActivityTradeMessage>() ?: return
if (tradeMessage.topic != "activity" || tradeMessage.type != "trades") return
val payload = tradeMessage.payload
val txHash = payload.transactionHash
if (!txHash.isNullOrBlank()) {
val now = System.currentTimeMillis()
val existing = processedTxHashes.asMap().putIfAbsent(txHash, now)
if (existing != null) return
}
val side = payload.side?.uppercase() ?: return
if (side != "BUY") return
val conditionId = payload.conditionId ?: return
val tokenId = payload.asset ?: return
if (conditionId.isBlank() || tokenId.isBlank()) return
val strategies = conditionIdStrategies[conditionId] ?: return
val price = convertToBigDecimal(payload.price) ?: return
val size = convertToBigDecimal(payload.size) ?: return
val notional = price.multiply(size)
val aggKey = AggKey(conditionId, tokenId, side)
for (strategy in strategies) {
addToWindow(strategy, aggKey, notional)
}
} catch (e: Exception) {
logger.error("大单监听处理消息失败: ${e.message}", e)
}
}
/**
* 快速字符串级别过滤检查消息中是否包含任一监听的 conditionId
*/
private fun fastFilterByConditionId(message: String): Boolean {
if (message.length < 50) return false
for (cid in conditionIdStrategies.keys) {
if (message.contains("\"conditionId\":\"$cid\"")) return true
}
return false
}
private fun addToWindow(strategy: WhaleMonitorStrategy, key: AggKey, notional: BigDecimal) {
val now = System.currentTimeMillis()
val windowMs = strategy.windowSeconds.toLong() * 1000
val deque = windowBuffers.computeIfAbsent(key) { ArrayDeque() }
val sum = runningSums.computeIfAbsent(key) { BigDecimal.ZERO }
// 过期清理
while (deque.isNotEmpty() && deque.first().tsMillis < now - windowMs) {
val expired = deque.removeFirst()
runningSums[key] = sum.subtract(expired.notional)
}
// 入队
deque.addLast(TradePoint(now, notional))
runningSums[key] = sum.add(notional)
val currentSum = runningSums[key] ?: BigDecimal.ZERO
// 阈值检查
if (currentSum >= strategy.thresholdAmount) {
val cooldownKey = "${strategy.id}-${key.tokenId}"
val lastTrigger = cooldownTimestamps[cooldownKey] ?: 0L
if (now - lastTrigger >= strategy.cooldownSeconds.toLong() * 1000) {
cooldownTimestamps[cooldownKey] = now
logger.info("大单监听触发: strategyId=${strategy.id} tokenId=${key.tokenId} volume=$currentSum threshold=${strategy.thresholdAmount}")
scope.launch {
try {
orderExecutionService.executeOrder(
strategy = strategy,
conditionId = key.conditionId,
tokenId = key.tokenId,
triggerVolume = currentSum
)
} catch (e: Exception) {
logger.error("大单监听下单执行异常: strategyId=${strategy.id} tokenId=${key.tokenId}: ${e.message}", e)
}
}
}
}
}
private fun startCleanupTask() {
cleanupJob?.cancel()
cleanupJob = scope.launch {
while (isActive) {
delay(60_000)
cleanupExpiredEntries()
}
}
}
private fun cleanupExpiredEntries() {
val now = System.currentTimeMillis()
val maxWindowMs = conditionIdStrategies.values.flatten().maxOfOrNull { it.windowSeconds.toLong() * 1000 } ?: 10_000
val iter = windowBuffers.entries.iterator()
while (iter.hasNext()) {
val entry = iter.next()
val deque = entry.value
while (deque.isNotEmpty() && deque.first().tsMillis < now - maxWindowMs) {
val expired = deque.removeFirst()
val currentSum = runningSums[entry.key] ?: BigDecimal.ZERO
runningSums[entry.key] = currentSum.subtract(expired.notional)
}
if (deque.isEmpty()) {
runningSums.remove(entry.key)
iter.remove()
}
}
}
private fun convertToBigDecimal(value: Any?): BigDecimal? {
if (value == null) return null
return when (value) {
is String -> value.toSafeBigDecimal()
is Number -> BigDecimal(value.toString())
is BigDecimal -> value
else -> value.toString().toSafeBigDecimal()
}
}
@PreDestroy
fun destroy() {
stop()
scope.cancel()
}
}
@@ -0,0 +1,44 @@
-- ============================================
-- V41: 大单监听策略表
-- ============================================
CREATE TABLE IF NOT EXISTS whale_monitor_strategy (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '策略ID',
account_id BIGINT NOT NULL COMMENT '钱包账户ID',
name VARCHAR(255) DEFAULT NULL COMMENT '策略名称(可选,用于列表展示)',
condition_ids TEXT NOT NULL COMMENT '监听市场 conditionId 列表(JSON 数组)',
window_seconds INT NOT NULL DEFAULT 10 COMMENT '聚合窗口秒数',
threshold_amount DECIMAL(20, 8) NOT NULL COMMENT '触发阈值金额',
order_amount DECIMAL(20, 8) NOT NULL COMMENT '固定下单金额',
min_price DECIMAL(20, 8) NOT NULL DEFAULT 0 COMMENT '最低下单价 0~1',
max_price DECIMAL(20, 8) NOT NULL DEFAULT 1 COMMENT '最高下单价 0~1',
cooldown_seconds INT NOT NULL DEFAULT 60 COMMENT '冷却秒数(同一 tokenId 两次触发间隔)',
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用: 0=停用, 1=启用',
created_at BIGINT NOT NULL COMMENT '创建时间',
updated_at BIGINT NOT NULL COMMENT '更新时间',
INDEX idx_account_id (account_id),
INDEX idx_enabled (enabled),
FOREIGN KEY (account_id) REFERENCES wallet_accounts(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='大单监听策略表';
-- ============================================
-- 大单监听触发记录表
-- ============================================
CREATE TABLE IF NOT EXISTS whale_monitor_trigger (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '记录ID',
strategy_id BIGINT NOT NULL COMMENT '策略ID',
condition_id VARCHAR(128) NOT NULL COMMENT '市场 conditionId',
token_id VARCHAR(128) NOT NULL COMMENT '触发 tokenId',
side VARCHAR(10) NOT NULL DEFAULT 'BUY' COMMENT '方向',
trigger_volume DECIMAL(20, 8) NOT NULL COMMENT '触发时窗口累计金额',
order_price DECIMAL(20, 8) NOT NULL COMMENT '下单价格',
order_size DECIMAL(20, 8) NOT NULL COMMENT '下单数量',
order_amount DECIMAL(20, 8) NOT NULL COMMENT '下单金额',
order_id VARCHAR(128) DEFAULT NULL COMMENT '订单ID(成功时有值)',
status VARCHAR(20) NOT NULL DEFAULT 'success' COMMENT '状态: success, fail',
fail_reason VARCHAR(500) DEFAULT NULL COMMENT '失败原因',
created_at BIGINT NOT NULL COMMENT '创建时间',
INDEX idx_strategy_id (strategy_id),
INDEX idx_token (strategy_id, token_id),
INDEX idx_created_at (created_at),
FOREIGN KEY (strategy_id) REFERENCES whale_monitor_strategy(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='大单监听触发记录表';
@@ -304,6 +304,19 @@ error.server.crypto_tail_strategy_update_failed=Failed to update crypto spread s
error.server.crypto_tail_strategy_delete_failed=Failed to delete crypto spread strategy
error.server.crypto_tail_strategy_list_fetch_failed=Failed to fetch crypto spread strategy list
error.server.crypto_tail_strategy_triggers_fetch_failed=Failed to fetch trigger records
# Whale Monitor Strategy
error.whale_monitor_strategy_not_found=Whale monitor strategy not found
error.whale_monitor_strategy_condition_ids_empty=Monitored markets cannot be empty
error.whale_monitor_strategy_window_invalid=Time window must be greater than 0
error.whale_monitor_strategy_threshold_invalid=Threshold amount must be greater than 0
error.whale_monitor_strategy_amount_invalid=Order amount must be greater than 0
error.whale_monitor_strategy_price_invalid=Invalid price range, must satisfy 0 <= minPrice <= maxPrice <= 1
error.server.whale_monitor_strategy_create_failed=Failed to create whale monitor strategy
error.server.whale_monitor_strategy_update_failed=Failed to update whale monitor strategy
error.server.whale_monitor_strategy_delete_failed=Failed to delete whale monitor strategy
error.server.whale_monitor_strategy_list_fetch_failed=Failed to fetch whale monitor strategy list
error.server.whale_monitor_strategy_triggers_fetch_failed=Failed to fetch whale monitor trigger records
# Backtest Management
backtest.title=Backtest Management
backtest.create_task=Create Backtest
@@ -304,6 +304,19 @@ error.server.crypto_tail_strategy_update_failed=更新加密价差策略失败
error.server.crypto_tail_strategy_delete_failed=删除加密价差策略失败
error.server.crypto_tail_strategy_list_fetch_failed=查询加密价差策略列表失败
error.server.crypto_tail_strategy_triggers_fetch_failed=查询触发记录失败
# 大单监听策略
error.whale_monitor_strategy_not_found=大单监听策略不存在
error.whale_monitor_strategy_condition_ids_empty=监听市场不能为空
error.whale_monitor_strategy_window_invalid=时间窗口必须大于0
error.whale_monitor_strategy_threshold_invalid=触发阈值必须大于0
error.whale_monitor_strategy_amount_invalid=下单金额必须大于0
error.whale_monitor_strategy_price_invalid=价格区间无效,须满足 0 <= minPrice <= maxPrice <= 1
error.server.whale_monitor_strategy_create_failed=创建大单监听策略失败
error.server.whale_monitor_strategy_update_failed=更新大单监听策略失败
error.server.whale_monitor_strategy_delete_failed=删除大单监听策略失败
error.server.whale_monitor_strategy_list_fetch_failed=查询大单监听策略列表失败
error.server.whale_monitor_strategy_triggers_fetch_failed=查询大单监听触发记录失败
# 回测管理
backtest.title=回测管理
backtest.create_task=新增回测
@@ -304,6 +304,19 @@ error.server.crypto_tail_strategy_update_failed=更新加密價差策略失敗
error.server.crypto_tail_strategy_delete_failed=刪除加密價差策略失敗
error.server.crypto_tail_strategy_list_fetch_failed=查詢加密價差策略列表失敗
error.server.crypto_tail_strategy_triggers_fetch_failed=查詢觸發記錄失敗
# 大單監聽策略
error.whale_monitor_strategy_not_found=大單監聽策略不存在
error.whale_monitor_strategy_condition_ids_empty=監聽市場不能為空
error.whale_monitor_strategy_window_invalid=時間窗口必須大於0
error.whale_monitor_strategy_threshold_invalid=觸發閾值必須大於0
error.whale_monitor_strategy_amount_invalid=下單金額必須大於0
error.whale_monitor_strategy_price_invalid=價格區間無效,須滿足 0 <= minPrice <= maxPrice <= 1
error.server.whale_monitor_strategy_create_failed=創建大單監聽策略失敗
error.server.whale_monitor_strategy_update_failed=更新大單監聽策略失敗
error.server.whale_monitor_strategy_delete_failed=刪除大單監聽策略失敗
error.server.whale_monitor_strategy_list_fetch_failed=查詢大單監聯策略列表失敗
error.server.whale_monitor_strategy_triggers_fetch_failed=查詢大單監聯觸發記錄失敗
# 回測管理
backtest.title=回測管理
backtest.create_task=新增回測