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=新增回測
|
||||
|
||||
Reference in New Issue
Block a user