feat(sports-tail): 体育尾盘策略与 createClient lazy 初始化

- 体育尾盘策略:实体/Repository/Service/Controller/执行服务/订单簿 WS
- 迁移 V41:体育尾盘策略表
- API:PolymarketGammaSportsApi,RetrofitFactory 去重
- 前端:体育尾盘策略列表页、路由、API、多语言
- ErrorCode 与 i18n 消息
- createClient 调用改为 lazy:SportsTailOrderbookWsService、CryptoTailOrderbookWsService、TelegramNotificationService

Made-with: Cursor
This commit is contained in:
WrBug
2026-03-07 04:33:42 +08:00
parent e20d47ba4c
commit 2dee65900e
27 changed files with 3072 additions and 11 deletions
@@ -0,0 +1,86 @@
package com.wrbug.polymarketbot.api
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
/**
* Polymarket Gamma API 体育市场接口
* Base URL: https://gamma-api.polymarket.com
*/
interface PolymarketGammaSportsApi {
/**
* 获取体育类别列表
* GET /sports
*/
@GET("/sports")
suspend fun getSports(): Response<List<SportsCategoryResponse>>
/**
* 按条件搜索市场
* GET /markets
* @param tagId 标签ID(体育类别)
* @param active 是否活跃
* @param closed 是否已关闭
* @param limit 返回数量
* @param order 排序字段
* @param ascending 是否升序
* @param slug 搜索关键词
*/
@GET("/markets")
suspend fun searchMarkets(
@Query("tag_id") tagId: Long? = null,
@Query("active") active: Boolean? = null,
@Query("closed") closed: Boolean? = null,
@Query("limit") limit: Int? = null,
@Query("order") order: String? = null,
@Query("ascending") ascending: Boolean? = null,
@Query("slug") slug: String? = null,
@Query("condition_ids") conditionIds: String? = null
): Response<List<SportsMarketResponse>>
}
/**
* 体育类别响应
*/
data class SportsCategoryResponse(
val sport: String? = null,
val image: String? = null,
val tags: String? = null
)
/**
* 体育市场响应
*/
data class SportsMarketResponse(
val id: String? = null,
val question: String? = null,
val conditionId: String? = null,
val slug: String? = null,
val outcomes: String? = null,
val outcomePrices: String? = null,
val endDate: String? = null,
val startDate: String? = null,
val bestBid: Double? = null,
val bestAsk: Double? = null,
val clobTokenIds: String? = null,
val liquidity: String? = null,
val liquidityNum: Double? = null,
val volume: String? = null,
val volumeNum: Double? = null,
val active: Boolean? = null,
val closed: Boolean? = null,
val events: List<SportsEventResponse>? = null
)
/**
* 体育事件响应
*/
data class SportsEventResponse(
val id: String? = null,
val slug: String? = null,
val title: String? = null,
val ticker: String? = null
)
@@ -0,0 +1,260 @@
package com.wrbug.polymarketbot.controller.sportstail
import com.wrbug.polymarketbot.dto.ApiResponse
import com.wrbug.polymarketbot.dto.SportsCategoryListResponse
import com.wrbug.polymarketbot.dto.SportsMarketDetailRequest
import com.wrbug.polymarketbot.dto.SportsMarketDetailResponse
import com.wrbug.polymarketbot.dto.SportsMarketSearchRequest
import com.wrbug.polymarketbot.dto.SportsMarketSearchResponse
import com.wrbug.polymarketbot.dto.SportsTailStrategyCreateRequest
import com.wrbug.polymarketbot.dto.SportsTailStrategyCreateResponse
import com.wrbug.polymarketbot.dto.SportsTailStrategyDeleteRequest
import com.wrbug.polymarketbot.dto.SportsTailStrategyListRequest
import com.wrbug.polymarketbot.dto.SportsTailStrategyListResponse
import com.wrbug.polymarketbot.dto.SportsTailTriggerListRequest
import com.wrbug.polymarketbot.dto.SportsTailTriggerListResponse
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.sportstail.SportsTailStrategyService
import kotlinx.coroutines.runBlocking
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/sports-tail-strategy")
class SportsTailStrategyController(
private val sportsTailStrategyService: SportsTailStrategyService,
private val messageSource: MessageSource
) {
private val logger = LoggerFactory.getLogger(SportsTailStrategyController::class.java)
@PostMapping("/list")
fun list(@RequestBody request: SportsTailStrategyListRequest): ResponseEntity<ApiResponse<SportsTailStrategyListResponse>> {
return try {
val result = sportsTailStrategyService.list(request)
result.fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("查询体育尾盘策略列表失败: ${e.message}", e)
ResponseEntity.ok(
ApiResponse.error(
ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_LIST_FETCH_FAILED,
e.message,
messageSource
)
)
}
)
} catch (e: Exception) {
logger.error("查询体育尾盘策略列表异常: ${e.message}", e)
ResponseEntity.ok(
ApiResponse.error(
ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_LIST_FETCH_FAILED,
e.message,
messageSource
)
)
}
}
@PostMapping("/create")
fun create(@RequestBody request: SportsTailStrategyCreateRequest): ResponseEntity<ApiResponse<SportsTailStrategyCreateResponse>> {
return try {
val result = sportsTailStrategyService.create(request)
result.fold(
onSuccess = {
ResponseEntity.ok(
ApiResponse.success(SportsTailStrategyCreateResponse(id = it.id))
)
},
onFailure = { e ->
logger.error("创建体育尾盘策略失败: ${e.message}", e)
val code = when (e.message) {
ErrorCode.ACCOUNT_NOT_FOUND.messageKey -> ErrorCode.ACCOUNT_NOT_FOUND
ErrorCode.SPORTS_TAIL_STRATEGY_CONDITION_ID_EMPTY.messageKey -> ErrorCode.SPORTS_TAIL_STRATEGY_CONDITION_ID_EMPTY
ErrorCode.SPORTS_TAIL_STRATEGY_PRICE_INVALID.messageKey -> ErrorCode.SPORTS_TAIL_STRATEGY_PRICE_INVALID
ErrorCode.SPORTS_TAIL_STRATEGY_AMOUNT_MODE_INVALID.messageKey -> ErrorCode.SPORTS_TAIL_STRATEGY_AMOUNT_MODE_INVALID
"该市场已存在策略" -> ErrorCode.PARAM_ERROR
else -> ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_CREATE_FAILED
}
ResponseEntity.ok(ApiResponse.error(code, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("创建体育尾盘策略异常: ${e.message}", e)
ResponseEntity.ok(
ApiResponse.error(
ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_CREATE_FAILED,
e.message,
messageSource
)
)
}
}
@PostMapping("/delete")
fun delete(@RequestBody request: SportsTailStrategyDeleteRequest): ResponseEntity<ApiResponse<Unit>> {
return try {
val id = request.id
if (id <= 0) {
return ResponseEntity.ok(
ApiResponse.error(ErrorCode.SPORTS_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource)
)
}
val result = sportsTailStrategyService.delete(id)
result.fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(Unit)) },
onFailure = { e ->
logger.error("删除体育尾盘策略失败: ${e.message}", e)
val code = when (e.message) {
ErrorCode.SPORTS_TAIL_STRATEGY_NOT_FOUND.messageKey -> ErrorCode.SPORTS_TAIL_STRATEGY_NOT_FOUND
"已成交未卖出的策略不能删除" -> ErrorCode.PARAM_ERROR
else -> ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_DELETE_FAILED
}
ResponseEntity.ok(ApiResponse.error(code, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("删除体育尾盘策略异常: ${e.message}", e)
ResponseEntity.ok(
ApiResponse.error(
ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_DELETE_FAILED,
e.message,
messageSource
)
)
}
}
@PostMapping("/triggers")
fun triggers(@RequestBody request: SportsTailTriggerListRequest): ResponseEntity<ApiResponse<SportsTailTriggerListResponse>> {
return try {
val result = sportsTailStrategyService.getTriggers(request)
result.fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("查询触发记录失败: ${e.message}", e)
ResponseEntity.ok(
ApiResponse.error(
ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED,
e.message,
messageSource
)
)
}
)
} catch (e: Exception) {
logger.error("查询触发记录异常: ${e.message}", e)
ResponseEntity.ok(
ApiResponse.error(
ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED,
e.message,
messageSource
)
)
}
}
@PostMapping("/sports-list")
fun sportsList(): ResponseEntity<ApiResponse<SportsCategoryListResponse>> {
return runBlocking {
try {
val result = sportsTailStrategyService.getSportsCategories()
result.fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("查询体育类别失败: ${e.message}", e)
ResponseEntity.ok(
ApiResponse.error(
ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_SPORTS_FETCH_FAILED,
e.message,
messageSource
)
)
}
)
} catch (e: Exception) {
logger.error("查询体育类别异常: ${e.message}", e)
ResponseEntity.ok(
ApiResponse.error(
ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_SPORTS_FETCH_FAILED,
e.message,
messageSource
)
)
}
}
}
@PostMapping("/market-search")
fun marketSearch(@RequestBody request: SportsMarketSearchRequest): ResponseEntity<ApiResponse<SportsMarketSearchResponse>> {
return runBlocking {
try {
val result = sportsTailStrategyService.searchMarkets(request)
result.fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("搜索市场失败: ${e.message}", e)
ResponseEntity.ok(
ApiResponse.error(
ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_MARKET_SEARCH_FAILED,
e.message,
messageSource
)
)
}
)
} catch (e: Exception) {
logger.error("搜索市场异常: ${e.message}", e)
ResponseEntity.ok(
ApiResponse.error(
ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_MARKET_SEARCH_FAILED,
e.message,
messageSource
)
)
}
}
}
@PostMapping("/market-detail")
fun marketDetail(@RequestBody request: SportsMarketDetailRequest): ResponseEntity<ApiResponse<SportsMarketDetailResponse>> {
return runBlocking {
try {
if (request.conditionId.isBlank()) {
return@runBlocking ResponseEntity.ok(
ApiResponse.error(ErrorCode.SPORTS_TAIL_STRATEGY_CONDITION_ID_EMPTY, messageSource = messageSource)
)
}
val result = sportsTailStrategyService.getMarketDetail(request.conditionId)
result.fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("获取市场详情失败: ${e.message}", e)
ResponseEntity.ok(
ApiResponse.error(
ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_MARKET_DETAIL_FAILED,
e.message,
messageSource
)
)
}
)
} catch (e: Exception) {
logger.error("获取市场详情异常: ${e.message}", e)
ResponseEntity.ok(
ApiResponse.error(
ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_MARKET_DETAIL_FAILED,
e.message,
messageSource
)
)
}
}
}
}
@@ -0,0 +1,216 @@
package com.wrbug.polymarketbot.dto
/**
* 体育尾盘策略 DTO
*/
data class SportsTailStrategyDto(
val id: Long = 0L,
val accountId: Long = 0L,
val accountName: String? = null,
val conditionId: String = "",
val marketTitle: String? = null,
val eventSlug: String? = null,
val triggerPrice: String = "",
val amountMode: String = "FIXED",
val amountValue: String = "",
val takeProfitPrice: String? = null,
val stopLossPrice: String? = null,
/** 成交信息 */
val filled: Boolean = false,
val filledPrice: String? = null,
val filledOutcomeIndex: Int? = null,
val filledOutcomeName: String? = null,
val filledAmount: String? = null,
val filledShares: String? = null,
val filledAt: Long? = null,
/** 卖出信息 */
val sold: Boolean = false,
val sellPrice: String? = null,
val sellType: String? = null,
val sellAmount: String? = null,
val realizedPnl: String? = null,
val soldAt: Long? = null,
/** 实时价格(未成交时返回) */
val realtimeYesPrice: String? = null,
val realtimeNoPrice: String? = null,
val createdAt: Long = 0L,
val updatedAt: Long = 0L
)
/**
* 策略列表请求
*/
data class SportsTailStrategyListRequest(
val accountId: Long? = null,
val sport: String? = null
)
/**
* 策略列表响应
*/
data class SportsTailStrategyListResponse(
val list: List<SportsTailStrategyDto> = emptyList()
)
/**
* 策略创建请求
*/
data class SportsTailStrategyCreateRequest(
val accountId: Long = 0L,
val conditionId: String = "",
val marketTitle: String = "",
val eventSlug: String? = null,
val triggerPrice: String = "",
val amountMode: String = "FIXED",
val amountValue: String = "",
val takeProfitPrice: String? = null,
val stopLossPrice: String? = null
)
/**
* 策略创建响应
*/
data class SportsTailStrategyCreateResponse(
val id: Long = 0L
)
/**
* 策略删除请求
*/
data class SportsTailStrategyDeleteRequest(
val id: Long = 0L
)
/**
* 策略触发记录 DTO
*/
data class SportsTailTriggerDto(
val id: Long = 0L,
val strategyId: Long = 0L,
/** 市场信息 */
val marketTitle: String? = null,
val conditionId: String = "",
/** 买入信息 */
val buyPrice: String = "",
val outcomeIndex: Int = 0,
val outcomeName: String? = null,
val buyAmount: String = "",
val buyShares: String? = null,
val buyStatus: String = "PENDING",
/** 卖出信息 */
val sellPrice: String? = null,
val sellType: String? = null,
val sellAmount: String? = null,
val sellStatus: String? = null,
/** 盈亏 */
val realizedPnl: String? = null,
/** 时间 */
val triggeredAt: Long = 0L,
val soldAt: Long? = null
)
/**
* 触发记录列表请求
*/
data class SportsTailTriggerListRequest(
val accountId: Long? = null,
val status: String? = null,
val startTime: Long? = null,
val endTime: Long? = null,
val page: Int = 1,
val pageSize: Int = 20
)
/**
* 触发记录列表响应
*/
data class SportsTailTriggerListResponse(
val total: Long = 0L,
val list: List<SportsTailTriggerDto> = emptyList()
)
/**
* 体育类别 DTO
*/
data class SportsCategoryDto(
val sport: String = "",
val image: String? = null,
val tagId: Long = 0L,
val name: String = ""
)
/**
* 体育类别列表响应
*/
data class SportsCategoryListResponse(
val list: List<SportsCategoryDto> = emptyList()
)
/**
* 体育市场 DTO
*/
data class SportsMarketDto(
val conditionId: String = "",
val question: String = "",
val outcomes: List<String> = emptyList(),
val outcomePrices: List<String> = emptyList(),
val endDate: String? = null,
val liquidity: String? = null,
val bestBid: Double? = null,
val bestAsk: Double? = null,
val yesTokenId: String? = null,
val noTokenId: String? = null,
val eventSlug: String? = null
)
/**
* 市场搜索请求
*/
data class SportsMarketSearchRequest(
val sport: String? = null,
val endDateMin: String? = null,
val endDateMax: String? = null,
val minLiquidity: String? = null,
val keyword: String? = null,
val limit: Int = 50
)
/**
* 市场搜索响应
*/
data class SportsMarketSearchResponse(
val list: List<SportsMarketDto> = emptyList()
)
/**
* 市场详情请求
*/
data class SportsMarketDetailRequest(
val conditionId: String = ""
)
/**
* 市场详情响应
*/
data class SportsMarketDetailResponse(
val conditionId: String = "",
val question: String = "",
val outcomes: List<String> = emptyList(),
val outcomePrices: List<String> = emptyList(),
val endDate: String? = null,
val liquidity: String? = null,
val bestBid: Double? = null,
val bestAsk: Double? = null,
val yesTokenId: String? = null,
val noTokenId: String? = null,
val eventSlug: String? = null
)
@@ -0,0 +1,118 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
import java.math.BigDecimal
/**
* 体育尾盘策略实体
* 在价格达到设定值时自动买入,支持止盈止损
*/
@Entity
@Table(name = "sports_tail_strategy")
data class SportsTailStrategy(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
/** 账户ID */
@Column(name = "account_id", nullable = false)
val accountId: Long = 0L,
/** 市场 conditionId */
@Column(name = "condition_id", nullable = false, length = 100)
val conditionId: String = "",
/** 市场标题 */
@Column(name = "market_title", length = 500)
val marketTitle: String? = null,
/** 事件 slug */
@Column(name = "event_slug", length = 255)
val eventSlug: String? = null,
/** YES Token ID */
@Column(name = "yes_token_id", length = 100)
val yesTokenId: String? = null,
/** NO Token ID */
@Column(name = "no_token_id", length = 100)
val noTokenId: String? = null,
/** 触发价格 */
@Column(name = "trigger_price", nullable = false, precision = 20, scale = 8)
val triggerPrice: BigDecimal = BigDecimal.ONE,
/** 金额模式: FIXED=固定金额, RATIO=余额比例 */
@Column(name = "amount_mode", nullable = false, length = 10)
val amountMode: String = "FIXED",
/** 金额值 */
@Column(name = "amount_value", nullable = false, precision = 20, scale = 8)
val amountValue: BigDecimal = BigDecimal.ZERO,
/** 止盈价格 */
@Column(name = "take_profit_price", precision = 20, scale = 8)
val takeProfitPrice: BigDecimal? = null,
/** 止损价格 */
@Column(name = "stop_loss_price", precision = 20, scale = 8)
val stopLossPrice: BigDecimal? = null,
/** 是否已成交 */
@Column(name = "filled", nullable = false)
val filled: Boolean = false,
/** 成交价格 */
@Column(name = "filled_price", precision = 20, scale = 8)
val filledPrice: BigDecimal? = null,
/** 成交方向索引: 0=YES, 1=NO */
@Column(name = "filled_outcome_index")
val filledOutcomeIndex: Int? = null,
/** 成交方向名称 */
@Column(name = "filled_outcome_name", length = 50)
val filledOutcomeName: String? = null,
/** 成交金额 */
@Column(name = "filled_amount", precision = 20, scale = 8)
val filledAmount: BigDecimal? = null,
/** 成交份额 */
@Column(name = "filled_shares", precision = 20, scale = 8)
val filledShares: BigDecimal? = null,
/** 成交时间 */
@Column(name = "filled_at")
val filledAt: Long? = null,
/** 是否已卖出 */
@Column(name = "sold", nullable = false)
val sold: Boolean = false,
/** 卖出价格 */
@Column(name = "sell_price", precision = 20, scale = 8)
val sellPrice: BigDecimal? = null,
/** 卖出类型: TAKE_PROFIT, STOP_LOSS, MANUAL */
@Column(name = "sell_type", length = 20)
val sellType: String? = null,
/** 卖出金额 */
@Column(name = "sell_amount", precision = 20, scale = 8)
val sellAmount: BigDecimal? = null,
/** 已实现盈亏 */
@Column(name = "realized_pnl", precision = 20, scale = 8)
val realizedPnl: BigDecimal? = null,
/** 卖出时间 */
@Column(name = "sold_at")
val soldAt: Long? = null,
@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,103 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
import java.math.BigDecimal
/**
* 体育尾盘策略触发记录
* 记录每次买入/卖出的详细信息
*/
@Entity
@Table(name = "sports_tail_strategy_trigger")
data class SportsTailStrategyTrigger(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
/** 策略ID */
@Column(name = "strategy_id", nullable = false)
val strategyId: Long = 0L,
/** 账户ID */
@Column(name = "account_id", nullable = false)
val accountId: Long = 0L,
/** 市场 conditionId */
@Column(name = "condition_id", nullable = false, length = 100)
val conditionId: String = "",
/** 市场标题 */
@Column(name = "market_title", length = 500)
val marketTitle: String? = null,
/** 买入价格 */
@Column(name = "buy_price", nullable = false, precision = 20, scale = 8)
val buyPrice: BigDecimal = BigDecimal.ZERO,
/** 买入方向索引: 0=YES, 1=NO */
@Column(name = "outcome_index", nullable = false)
val outcomeIndex: Int = 0,
/** 买入方向名称 */
@Column(name = "outcome_name", length = 50)
val outcomeName: String? = null,
/** 买入金额 */
@Column(name = "buy_amount", nullable = false, precision = 20, scale = 8)
val buyAmount: BigDecimal = BigDecimal.ZERO,
/** 买入份额 */
@Column(name = "buy_shares", precision = 20, scale = 8)
val buyShares: BigDecimal? = null,
/** 买入订单ID */
@Column(name = "buy_order_id", length = 100)
val buyOrderId: String? = null,
/** 买入状态: PENDING, SUCCESS, FAIL */
@Column(name = "buy_status", nullable = false, length = 20)
val buyStatus: String = "PENDING",
/** 买入失败原因 */
@Column(name = "buy_fail_reason", length = 500)
val buyFailReason: String? = null,
/** 卖出价格 */
@Column(name = "sell_price", precision = 20, scale = 8)
val sellPrice: BigDecimal? = null,
/** 卖出类型: TAKE_PROFIT, STOP_LOSS, MANUAL */
@Column(name = "sell_type", length = 20)
val sellType: String? = null,
/** 卖出金额 */
@Column(name = "sell_amount", precision = 20, scale = 8)
val sellAmount: BigDecimal? = null,
/** 卖出订单ID */
@Column(name = "sell_order_id", length = 100)
val sellOrderId: String? = null,
/** 卖出状态: PENDING, SUCCESS, FAIL */
@Column(name = "sell_status", length = 20)
val sellStatus: String? = null,
/** 卖出失败原因 */
@Column(name = "sell_fail_reason", length = 500)
val sellFailReason: String? = null,
/** 已实现盈亏 */
@Column(name = "realized_pnl", precision = 20, scale = 8)
val realizedPnl: BigDecimal? = null,
/** 触发时间 */
@Column(name = "triggered_at", nullable = false)
val triggeredAt: Long = System.currentTimeMillis(),
/** 卖出时间 */
@Column(name = "sold_at")
val soldAt: Long? = null,
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis()
)
@@ -264,8 +264,27 @@ 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"),
// 体育尾盘策略 (4730-4749)
SPORTS_TAIL_STRATEGY_NOT_FOUND(4730, "体育尾盘策略不存在", "error.sports_tail_strategy_not_found"),
SPORTS_TAIL_STRATEGY_ALREADY_FILLED(4731, "策略已成交", "error.sports_tail_strategy_already_filled"),
SPORTS_TAIL_STRATEGY_ALREADY_SOLD(4732, "策略已卖出", "error.sports_tail_strategy_already_sold"),
SPORTS_TAIL_STRATEGY_AMOUNT_MODE_INVALID(4733, "金额模式仅支持 FIXED 或 RATIO", "error.sports_tail_strategy_amount_mode_invalid"),
SPORTS_TAIL_STRATEGY_PRICE_INVALID(4734, "触发价格无效", "error.sports_tail_strategy_price_invalid"),
SPORTS_TAIL_STRATEGY_CONDITION_ID_EMPTY(4735, "市场ID不能为空", "error.sports_tail_strategy_condition_id_empty"),
// 体育尾盘策略服务 (5630-5649)
SERVER_SPORTS_TAIL_STRATEGY_CREATE_FAILED(5630, "创建体育尾盘策略失败", "error.server.sports_tail_strategy_create_failed"),
SERVER_SPORTS_TAIL_STRATEGY_DELETE_FAILED(5631, "删除体育尾盘策略失败", "error.server.sports_tail_strategy_delete_failed"),
SERVER_SPORTS_TAIL_STRATEGY_LIST_FETCH_FAILED(5632, "查询体育尾盘策略列表失败", "error.server.sports_tail_strategy_list_fetch_failed"),
SERVER_SPORTS_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED(5633, "查询触发记录失败", "error.server.sports_tail_strategy_triggers_fetch_failed"),
SERVER_SPORTS_TAIL_STRATEGY_SPORTS_FETCH_FAILED(5634, "查询体育类别失败", "error.server.sports_tail_strategy_sports_fetch_failed"),
SERVER_SPORTS_TAIL_STRATEGY_MARKET_SEARCH_FAILED(5635, "搜索市场失败", "error.server.sports_tail_strategy_market_search_failed"),
SERVER_SPORTS_TAIL_STRATEGY_MARKET_DETAIL_FAILED(5636, "查询市场详情失败", "error.server.sports_tail_strategy_market_detail_failed"),
SERVER_SPORTS_TAIL_STRATEGY_BUY_FAILED(5637, "买入执行失败", "error.server.sports_tail_strategy_buy_failed"),
SERVER_SPORTS_TAIL_STRATEGY_SELL_FAILED(5638, "卖出执行失败", "error.server.sports_tail_strategy_sell_failed");
companion object {
/**
* 根据错误码查找枚举
@@ -0,0 +1,9 @@
package com.wrbug.polymarketbot.event
import org.springframework.context.ApplicationEvent
/**
* 体育尾盘策略变更事件
* 当策略创建、删除、成交、卖出时发布此事件
*/
class SportsTailStrategyChangedEvent(source: Any) : ApplicationEvent(source)
@@ -0,0 +1,56 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.SportsTailStrategy
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository
import java.math.BigDecimal
@Repository
interface SportsTailStrategyRepository : JpaRepository<SportsTailStrategy, Long> {
/** 查询所有策略 */
fun findAllByOrderByCreatedAtDesc(): List<SportsTailStrategy>
/** 按账户查询 */
fun findAllByAccountIdOrderByCreatedAtDesc(accountId: Long): List<SportsTailStrategy>
/** 按账户和 conditionId 查询 */
fun findByAccountIdAndConditionId(accountId: Long, conditionId: String): SportsTailStrategy?
/** 按条件查询(用于列表筛选) */
fun findAllByAccountId(accountId: Long): List<SportsTailStrategy>
/** 查询未成交的策略 */
fun findAllByFilledFalse(): List<SportsTailStrategy>
/** 查询已成交但未卖出的策略 */
fun findAllByFilledTrueAndSoldFalse(): List<SportsTailStrategy>
/** 按 conditionId 查询未完成的策略(未成交或已成交未卖出) */
@Query("SELECT s FROM SportsTailStrategy s WHERE s.conditionId = :conditionId AND (s.filled = false OR s.sold = false)")
fun findActiveByConditionId(@Param("conditionId") conditionId: String): List<SportsTailStrategy>
/** 按 conditionId 查询未成交的策略 */
@Query("SELECT s FROM SportsTailStrategy s WHERE s.conditionId = :conditionId AND s.filled = false")
fun findPendingByConditionId(@Param("conditionId") conditionId: String): List<SportsTailStrategy>
/** 按 conditionId 查询已成交但未卖出的策略(用于止盈止损监控) */
@Query("SELECT s FROM SportsTailStrategy s WHERE s.conditionId = :conditionId AND s.filled = true AND s.sold = false")
fun findFilledByConditionId(@Param("conditionId") conditionId: String): List<SportsTailStrategy>
/** 按 conditionId 查询已成交但未卖出且有止盈止损的策略 */
@Query("SELECT s FROM SportsTailStrategy s WHERE s.conditionId = :conditionId AND s.filled = true AND s.sold = false AND (s.takeProfitPrice IS NOT NULL OR s.stopLossPrice IS NOT NULL)")
fun findFilledWithStopByConditionId(@Param("conditionId") conditionId: String): List<SportsTailStrategy>
/** 按账户统计总盈亏 */
@Query("SELECT SUM(s.realizedPnl) FROM SportsTailStrategy s WHERE s.accountId = :accountId AND s.sold = true")
fun sumRealizedPnlByAccountId(@Param("accountId") accountId: Long): BigDecimal?
/** 按策略统计总盈亏 */
@Query("SELECT SUM(t.realizedPnl) FROM SportsTailStrategyTrigger t WHERE t.strategyId = :strategyId AND t.sellStatus = 'SUCCESS'")
fun sumRealizedPnlByStrategyId(@Param("strategyId") strategyId: Long): BigDecimal?
}
@@ -0,0 +1,94 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.SportsTailStrategyTrigger
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository
@Repository
interface SportsTailStrategyTriggerRepository : JpaRepository<SportsTailStrategyTrigger, Long> {
/** 按策略ID查询(分页) */
fun findAllByStrategyIdOrderByTriggeredAtDesc(strategyId: Long, pageable: Pageable): Page<SportsTailStrategyTrigger>
/** 按账户ID查询(分页) */
fun findAllByAccountIdOrderByTriggeredAtDesc(accountId: Long, pageable: Pageable): Page<SportsTailStrategyTrigger>
/** 按账户ID和时间范围查询(分页) */
fun findAllByAccountIdAndTriggeredAtBetweenOrderByTriggeredAtDesc(
accountId: Long,
startTime: Long,
endTime: Long,
pageable: Pageable
): Page<SportsTailStrategyTrigger>
/** 全局查询(分页) */
fun findAllByOrderByTriggeredAtDesc(pageable: Pageable): Page<SportsTailStrategyTrigger>
/** 全局按时间范围查询(分页) */
fun findAllByTriggeredAtBetweenOrderByTriggeredAtDesc(
startTime: Long,
endTime: Long,
pageable: Pageable
): Page<SportsTailStrategyTrigger>
/** 按账户ID和买入状态查询 */
fun findAllByAccountIdAndBuyStatusOrderByTriggeredAtDesc(
accountId: Long,
buyStatus: String,
pageable: Pageable
): Page<SportsTailStrategyTrigger>
/** 按账户ID和时间范围和买入状态查询 */
fun findAllByAccountIdAndBuyStatusAndTriggeredAtBetweenOrderByTriggeredAtDesc(
accountId: Long,
buyStatus: String,
startTime: Long,
endTime: Long,
pageable: Pageable
): Page<SportsTailStrategyTrigger>
/** 统计总数 */
fun countByAccountId(accountId: Long): Long
fun countByAccountIdAndBuyStatus(accountId: Long, buyStatus: String): Long
fun countByAccountIdAndTriggeredAtBetween(accountId: Long, startTime: Long, endTime: Long): Long
fun countByAccountIdAndBuyStatusAndTriggeredAtBetween(
accountId: Long,
buyStatus: String,
startTime: Long,
endTime: Long
): Long
fun countByTriggeredAtBetween(startTime: Long, endTime: Long): Long
fun countByBuyStatusAndTriggeredAtBetween(buyStatus: String, startTime: Long, endTime: Long): Long
/** 全局按买入状态查询(分页) */
fun findAllByBuyStatusOrderByTriggeredAtDesc(
buyStatus: String,
pageable: Pageable
): Page<SportsTailStrategyTrigger>
/** 全局按买入状态和时间范围查询(分页) */
fun findAllByBuyStatusAndTriggeredAtBetweenOrderByTriggeredAtDesc(
buyStatus: String,
startTime: Long,
endTime: Long,
pageable: Pageable
): Page<SportsTailStrategyTrigger>
/** 全局统计 */
fun countByBuyStatus(buyStatus: String): Long
/** 查询某策略最近一条买入成功的触发记录(用于卖出时更新) */
fun findFirstByStrategyIdAndBuyStatusOrderByTriggeredAtDesc(
strategyId: Long,
buyStatus: String
): SportsTailStrategyTrigger?
}
@@ -56,7 +56,7 @@ class CryptoTailOrderbookWsService(
private var webSocket: WebSocket? = null
private val wsUrl = PolymarketConstants.RTDS_WS_URL + "/ws/market"
private val client = createClient().build()
private val client by lazy { createClient().build() }
/** 订阅成功后设置的倒计时 Job,在周期结束时自动刷新订阅 */
private var periodEndCountdownJob: Job? = null
@@ -0,0 +1,290 @@
package com.wrbug.polymarketbot.service.sportstail
import com.wrbug.polymarketbot.constants.PolymarketConstants
import com.wrbug.polymarketbot.entity.SportsTailStrategy
import com.wrbug.polymarketbot.event.SportsTailStrategyChangedEvent
import com.wrbug.polymarketbot.repository.SportsTailStrategyRepository
import com.wrbug.polymarketbot.util.createClient
import com.wrbug.polymarketbot.util.fromJson
import com.wrbug.polymarketbot.util.gte
import com.wrbug.polymarketbot.util.gt
import com.wrbug.polymarketbot.util.lte
import com.wrbug.polymarketbot.util.toJson
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.google.gson.JsonPrimitive
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import org.slf4j.LoggerFactory
import org.springframework.context.event.EventListener
import org.springframework.stereotype.Service
import jakarta.annotation.PostConstruct
import jakarta.annotation.PreDestroy
import java.math.BigDecimal
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
/**
* 体育尾盘策略订单簿 WebSocket 服务:订阅 CLOB 市场频道,价格达到触发价时执行买入/止盈止损卖出。
*/
@Service
class SportsTailOrderbookWsService(
private val strategyRepository: SportsTailStrategyRepository,
private val executionService: SportsTailStrategyExecutionService
) {
private val logger = LoggerFactory.getLogger(SportsTailOrderbookWsService::class.java)
private val scopeJob = SupervisorJob()
private val scope = CoroutineScope(Dispatchers.Default + scopeJob)
/** tokenId -> list of (strategy, outcomeIndex for buy=0/1, isSellPhase) */
private val tokenToEntries = AtomicReference<Map<String, List<WsEntry>>>(emptyMap())
private var webSocket: WebSocket? = null
private val wsUrl = PolymarketConstants.RTDS_WS_URL + "/ws/market"
private val client: OkHttpClient by lazy { createClient().build() }
private val reconnectDelayMs = 3_000L
private val closedForNoStrategies = AtomicBoolean(false)
private val connectLock = Any()
private val refreshLock = Any()
private val isRefreshing = AtomicBoolean(false)
private data class WsEntry(
val strategy: SportsTailStrategy,
val outcomeIndex: Int,
val isSellPhase: Boolean
)
private var reconnectJob: Job? = null
@PostConstruct
fun init() {
if (hasActiveStrategies()) connect()
}
@PreDestroy
fun destroy() {
reconnectJob?.cancel()
reconnectJob = null
closedForNoStrategies.set(true)
try {
webSocket?.close(1000, "shutdown")
} catch (e: Exception) {
logger.debug("关闭体育尾盘 WebSocket 时异常: ${e.message}")
}
webSocket = null
scopeJob.cancel()
}
private fun hasActiveStrategies(): Boolean {
val all = strategyRepository.findAll()
return all.any { !it.filled || (it.filled && !it.sold && (it.takeProfitPrice != null || it.stopLossPrice != null)) }
}
private fun connect() {
synchronized(connectLock) {
if (webSocket != null) return
try {
val request = Request.Builder().url(wsUrl).build()
webSocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
logger.info("体育尾盘策略订单簿 WebSocket 已连接")
refreshAndSubscribe(fromConnect = true)
}
override fun onMessage(webSocket: WebSocket, text: String) {
handleMessage(text)
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
this@SportsTailOrderbookWsService.webSocket = null
if (!closedForNoStrategies.getAndSet(false)) scheduleReconnect()
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
logger.warn("体育尾盘策略订单簿 WebSocket 异常: ${t.message}")
this@SportsTailOrderbookWsService.webSocket = null
scheduleReconnect()
}
})
} catch (e: Exception) {
logger.error("体育尾盘策略订单簿 WebSocket 连接失败: ${e.message}", e)
scheduleReconnect()
}
}
}
private fun scheduleReconnect() {
if (reconnectJob?.isActive == true) return
reconnectJob = scope.launch {
delay(reconnectDelayMs)
reconnectJob = null
if (!hasActiveStrategies()) return@launch
logger.info("体育尾盘策略订单簿 WebSocket 尝试重连")
connect()
}
}
private fun handleMessage(text: String) {
if (text == "pong" || text.isEmpty()) return
if (closedForNoStrategies.get()) return
val json = text.fromJson<JsonObject>() ?: return
val eventType = (json.get("event_type") as? JsonPrimitive)?.asString ?: return
when (eventType) {
"book" -> {
val assetId = (json.get("asset_id") as? JsonPrimitive)?.asString ?: return
val bids = json.get("bids") as? JsonArray
if (bids == null || bids.isEmpty) return
var bestBid: BigDecimal? = null
for (i in 0 until bids.size()) {
val level = bids.get(i) as? JsonObject ?: continue
val p = (level.get("price") as? JsonPrimitive)?.asString?.toSafeBigDecimal() ?: continue
if (bestBid == null || p.gt(bestBid)) bestBid = p
}
if (bestBid != null) onPriceUpdate(assetId, bestBid)
}
"price_change" -> {
val priceChanges = json.get("price_changes") as? JsonArray ?: return
for (i in 0 until priceChanges.size()) {
val pc = priceChanges.get(i) as? JsonObject ?: continue
val assetId = (pc.get("asset_id") as? JsonPrimitive)?.asString ?: continue
val bestBidStr = (pc.get("best_bid") as? JsonPrimitive)?.asString
val bestBid = bestBidStr?.toSafeBigDecimal()
if (bestBid != null) onPriceUpdate(assetId, bestBid)
}
}
}
}
private fun onPriceUpdate(tokenId: String, bestBid: BigDecimal) {
if (closedForNoStrategies.get()) return
val entries = tokenToEntries.get()[tokenId] ?: return
for (e in entries) {
scope.launch {
try {
if (e.isSellPhase) {
checkSellTrigger(e.strategy, bestBid)
} else {
checkBuyTrigger(e.strategy, e.outcomeIndex, bestBid)
}
} catch (ex: Exception) {
logger.error("体育尾盘 WS 处理异常: strategyId=${e.strategy.id}, ${ex.message}", ex)
}
}
}
}
private suspend fun checkBuyTrigger(strategy: SportsTailStrategy, outcomeIndex: Int, price: BigDecimal) {
if (strategy.filled) return
if (price.gte(strategy.triggerPrice)) {
executionService.executeBuy(strategy, outcomeIndex, price)
}
}
private suspend fun checkSellTrigger(strategy: SportsTailStrategy, currentPrice: BigDecimal) {
if (!strategy.filled || strategy.sold) return
strategy.takeProfitPrice?.let { if (currentPrice.gte(it)) { executionService.executeSell(strategy, "TAKE_PROFIT", currentPrice); return } }
strategy.stopLossPrice?.let { if (currentPrice.lte(it)) { executionService.executeSell(strategy, "STOP_LOSS", currentPrice); return } }
}
private fun refreshAndSubscribe(fromConnect: Boolean = false) {
synchronized(refreshLock) {
if (isRefreshing.get()) return
isRefreshing.set(true)
}
try {
val strategies = strategyRepository.findAll()
val active = strategies.filter { s ->
!s.filled || (s.filled && !s.sold && (s.takeProfitPrice != null || s.stopLossPrice != null))
}
val tokenIdSet = mutableSetOf<String>()
val map = mutableMapOf<String, MutableList<WsEntry>>()
for (s in active) {
if (!s.filled) {
s.yesTokenId?.let { id ->
if (id.isNotBlank()) {
tokenIdSet.add(id)
map.getOrPut(id) { mutableListOf() }.add(WsEntry(s, 0, false))
}
}
s.noTokenId?.let { id ->
if (id.isNotBlank()) {
tokenIdSet.add(id)
map.getOrPut(id) { mutableListOf() }.add(WsEntry(s, 1, false))
}
}
} else if (!s.sold && (s.takeProfitPrice != null || s.stopLossPrice != null)) {
val idx = s.filledOutcomeIndex ?: continue
val tokenId = if (idx == 0) s.yesTokenId else s.noTokenId
tokenId?.takeIf { it.isNotBlank() }?.let { id ->
tokenIdSet.add(id)
map.getOrPut(id) { mutableListOf() }.add(WsEntry(s, idx, true))
}
}
}
tokenToEntries.set(map)
if (tokenIdSet.isEmpty()) {
closeForNoStrategies()
return
}
if (!fromConnect) {
if (webSocket == null) {
connect()
return
}
closeAndReconnect()
return
}
val msg = """{"type":"MARKET","assets_ids":${tokenIdSet.toList().toJson()}}"""
try {
webSocket?.send(msg)
logger.info("体育尾盘策略订单簿订阅: ${tokenIdSet.size} 个 token")
} catch (e: Exception) {
logger.warn("发送体育尾盘订阅失败: ${e.message}")
}
} finally {
isRefreshing.set(false)
}
}
private fun closeAndReconnect() {
val ws = webSocket
if (ws != null) {
webSocket = null
try { ws.close(1000, "subscription_change") } catch (e: Exception) { }
logger.info("体育尾盘策略订单簿 WebSocket 已关闭(订阅更新,将重连)")
}
}
private fun closeForNoStrategies() {
reconnectJob?.cancel()
reconnectJob = null
val ws = webSocket
if (ws != null) {
closedForNoStrategies.set(true)
webSocket = null
try { ws.close(1000, "no_active_strategies") } catch (e: Exception) { }
logger.info("体育尾盘策略订单簿 WebSocket 已关闭(无活跃策略)")
}
}
@EventListener
fun onStrategyChanged(event: SportsTailStrategyChangedEvent) {
refreshAndSubscribe()
}
}
@@ -0,0 +1,303 @@
package com.wrbug.polymarketbot.service.sportstail
import com.wrbug.polymarketbot.api.NewOrderRequest
import com.wrbug.polymarketbot.api.PolymarketClobApi
import com.wrbug.polymarketbot.entity.SportsTailStrategy
import com.wrbug.polymarketbot.entity.SportsTailStrategyTrigger
import com.wrbug.polymarketbot.event.SportsTailStrategyChangedEvent
import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.repository.SportsTailStrategyRepository
import com.wrbug.polymarketbot.repository.SportsTailStrategyTriggerRepository
import com.wrbug.polymarketbot.service.accounts.AccountService
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.div
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.slf4j.LoggerFactory
import org.springframework.context.ApplicationEventPublisher
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
import java.math.RoundingMode
import java.util.concurrent.ConcurrentHashMap
private const val SIZE_DECIMAL_SCALE = 2
/**
* 体育尾盘策略执行服务:根据价格触发执行买入/卖出,并更新策略与触发记录。
*/
@Service
class SportsTailStrategyExecutionService(
private val strategyRepository: SportsTailStrategyRepository,
private val triggerRepository: SportsTailStrategyTriggerRepository,
private val accountRepository: AccountRepository,
private val accountService: AccountService,
private val retrofitFactory: RetrofitFactory,
private val clobService: PolymarketClobService,
private val orderSigningService: OrderSigningService,
private val cryptoUtils: CryptoUtils,
private val eventPublisher: ApplicationEventPublisher
) {
private val logger = LoggerFactory.getLogger(SportsTailStrategyExecutionService::class.java)
private val buyMutexMap = ConcurrentHashMap<Long, Mutex>()
private fun buyMutex(strategyId: Long): Mutex =
buyMutexMap.getOrPut(strategyId) { Mutex() }
/**
* 执行买入:市价买入指定方向,写入触发记录并更新策略为已成交。
*/
@Transactional
suspend fun executeBuy(
strategy: SportsTailStrategy,
outcomeIndex: Int,
triggerPrice: BigDecimal
): Result<Unit> {
if (strategy.filled) return Result.failure(IllegalStateException("策略已成交"))
val tokenId = if (outcomeIndex == 0) strategy.yesTokenId else strategy.noTokenId
if (tokenId.isNullOrBlank()) return Result.failure(IllegalStateException("Token ID 为空"))
return buyMutex(strategy.id!!).withLock {
val latest = strategyRepository.findById(strategy.id!!).orElse(null)
?: return@withLock Result.failure(IllegalStateException("策略不存在"))
if (latest.filled) return@withLock Result.success(Unit)
val account = accountRepository.findById(latest.accountId).orElse(null)
?: return@withLock Result.failure(IllegalStateException("账户不存在"))
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
return@withLock Result.failure(IllegalStateException("账户未配置 API 凭证"))
}
val decryptedKey = try {
cryptoUtils.decrypt(account.privateKey) ?: return@withLock Result.failure(IllegalStateException("解密私钥失败"))
} catch (e: Exception) {
logger.error("解密私钥失败: accountId=${account.id}", e)
return@withLock Result.failure(e)
}
val apiSecret = try { cryptoUtils.decrypt(account.apiSecret) ?: "" } catch (e: Exception) { "" }
val apiPassphrase = try { cryptoUtils.decrypt(account.apiPassphrase) ?: "" } catch (e: Exception) { "" }
val amountUsdc = when (latest.amountMode.uppercase()) {
"RATIO" -> {
val balanceResult = accountService.getAccountBalance(account.id!!)
val available = balanceResult.getOrNull()?.availableBalance?.toSafeBigDecimal() ?: BigDecimal.ZERO
available.multiply(latest.amountValue).div(BigDecimal("100"), 18, RoundingMode.DOWN)
}
else -> latest.amountValue
}
if (amountUsdc < BigDecimal("1")) {
saveTriggerOnBuyFail(latest, outcomeIndex, triggerPrice, amountUsdc, "投入金额不足")
return@withLock Result.failure(IllegalStateException("投入金额不足"))
}
val priceStr = triggerPrice.setScale(2, RoundingMode.HALF_UP).toPlainString()
val size = amountUsdc.div(triggerPrice, SIZE_DECIMAL_SCALE, RoundingMode.UP).max(BigDecimal.ONE)
val sizeStr = size.toPlainString()
val clobApi = retrofitFactory.createClobApi(account.apiKey!!, apiSecret, apiPassphrase, account.walletAddress)
val feeRateBps = clobService.getFeeRate(tokenId).getOrNull()?.toString() ?: "0"
val signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
val signedOrder = orderSigningService.createAndSignOrder(
privateKey = decryptedKey,
makerAddress = account.proxyAddress,
tokenId = tokenId,
side = "BUY",
price = priceStr,
size = sizeStr,
signatureType = signatureType,
nonce = "0",
feeRateBps = feeRateBps,
expiration = "0"
)
val orderRequest = NewOrderRequest(
order = signedOrder,
owner = account.apiKey!!,
orderType = "FAK",
deferExec = false
)
val response = clobApi.createOrder(orderRequest)
if (response.isSuccessful && response.body() != null) {
val body = response.body()!!
if (body.success && body.orderId != null) {
val outcomeName = if (outcomeIndex == 0) "Yes" else "No"
triggerRepository.save(
SportsTailStrategyTrigger(
strategyId = latest.id!!,
accountId = latest.accountId,
conditionId = latest.conditionId,
marketTitle = latest.marketTitle,
buyPrice = triggerPrice,
outcomeIndex = outcomeIndex,
outcomeName = outcomeName,
buyAmount = amountUsdc,
buyShares = size,
buyOrderId = body.orderId,
buyStatus = "SUCCESS",
triggeredAt = System.currentTimeMillis()
)
)
strategyRepository.save(
latest.copy(
filled = true,
filledPrice = triggerPrice,
filledOutcomeIndex = outcomeIndex,
filledOutcomeName = outcomeName,
filledAmount = amountUsdc,
filledShares = size,
filledAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis()
)
)
eventPublisher.publishEvent(SportsTailStrategyChangedEvent(this))
logger.info("体育尾盘策略买入成功: strategyId=${latest.id}, outcomeIndex=$outcomeIndex, orderId=${body.orderId}")
return@withLock Result.success(Unit)
}
}
val failReason = response.body()?.getErrorMessage() ?: response.errorBody()?.string() ?: "下单失败"
saveTriggerOnBuyFail(latest, outcomeIndex, triggerPrice, amountUsdc, failReason)
logger.error("体育尾盘策略买入失败: strategyId=${latest.id}, reason=$failReason")
Result.failure(IllegalStateException(failReason))
}
}
private fun saveTriggerOnBuyFail(
strategy: SportsTailStrategy,
outcomeIndex: Int,
buyPrice: BigDecimal,
buyAmount: BigDecimal,
failReason: String
) {
val outcomeName = if (outcomeIndex == 0) "Yes" else "No"
triggerRepository.save(
SportsTailStrategyTrigger(
strategyId = strategy.id!!,
accountId = strategy.accountId,
conditionId = strategy.conditionId,
marketTitle = strategy.marketTitle,
buyPrice = buyPrice,
outcomeIndex = outcomeIndex,
outcomeName = outcomeName,
buyAmount = buyAmount,
buyStatus = "FAIL",
buyFailReason = failReason,
triggeredAt = System.currentTimeMillis()
)
)
}
/**
* 执行卖出:按当前价市价卖出持仓,更新策略与触发记录。
*/
@Transactional
suspend fun executeSell(
strategy: SportsTailStrategy,
sellType: String,
currentPrice: BigDecimal
): Result<Unit> {
if (!strategy.filled || strategy.sold) return Result.failure(IllegalStateException("策略未成交或已卖出"))
val outcomeIndex = strategy.filledOutcomeIndex ?: return Result.failure(IllegalStateException("无成交方向"))
val tokenId = if (outcomeIndex == 0) strategy.yesTokenId else strategy.noTokenId
val filledShares = strategy.filledShares ?: return Result.failure(IllegalStateException("无成交份额"))
if (tokenId.isNullOrBlank()) return Result.failure(IllegalStateException("Token ID 为空"))
val account = accountRepository.findById(strategy.accountId).orElse(null)
?: return Result.failure(IllegalStateException("账户不存在"))
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
return Result.failure(IllegalStateException("账户未配置 API 凭证"))
}
val decryptedKey = try {
cryptoUtils.decrypt(account.privateKey) ?: return Result.failure(IllegalStateException("解密私钥失败"))
} catch (e: Exception) {
logger.error("解密私钥失败: accountId=${account.id}", e)
return Result.failure(e)
}
val apiSecret = try { cryptoUtils.decrypt(account.apiSecret) ?: "" } catch (e: Exception) { "" }
val apiPassphrase = try { cryptoUtils.decrypt(account.apiPassphrase) ?: "" } catch (e: Exception) { "" }
val priceStr = currentPrice.setScale(2, RoundingMode.HALF_UP).toPlainString()
val sizeStr = filledShares.setScale(SIZE_DECIMAL_SCALE, RoundingMode.DOWN).toPlainString()
val clobApi = retrofitFactory.createClobApi(account.apiKey!!, apiSecret, apiPassphrase, account.walletAddress)
val feeRateBps = clobService.getFeeRate(tokenId).getOrNull()?.toString() ?: "0"
val signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
val signedOrder = orderSigningService.createAndSignOrder(
privateKey = decryptedKey,
makerAddress = account.proxyAddress,
tokenId = tokenId,
side = "SELL",
price = priceStr,
size = sizeStr,
signatureType = signatureType,
nonce = "0",
feeRateBps = feeRateBps,
expiration = "0"
)
val orderRequest = NewOrderRequest(
order = signedOrder,
owner = account.apiKey!!,
orderType = "FAK",
deferExec = false
)
val response = clobApi.createOrder(orderRequest)
val filledAmount = strategy.filledAmount ?: BigDecimal.ZERO
if (response.isSuccessful && response.body() != null) {
val body = response.body()!!
if (body.success && body.orderId != null) {
val sellAmount = currentPrice.multiply(filledShares).setScale(2, RoundingMode.HALF_UP)
val pnl = sellAmount.subtract(filledAmount)
strategyRepository.save(
strategy.copy(
sold = true,
sellPrice = currentPrice,
sellType = sellType,
sellAmount = sellAmount,
realizedPnl = pnl,
soldAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis()
)
)
val trigger = triggerRepository.findFirstByStrategyIdAndBuyStatusOrderByTriggeredAtDesc(strategy.id!!, "SUCCESS")
if (trigger != null) {
triggerRepository.save(
trigger.copy(
sellPrice = currentPrice,
sellType = sellType,
sellAmount = sellAmount,
sellOrderId = body.orderId,
sellStatus = "SUCCESS",
realizedPnl = pnl,
soldAt = System.currentTimeMillis()
)
)
}
eventPublisher.publishEvent(SportsTailStrategyChangedEvent(this))
logger.info("体育尾盘策略卖出成功: strategyId=${strategy.id}, sellType=$sellType, orderId=${body.orderId}")
return Result.success(Unit)
}
}
val failReason = response.body()?.getErrorMessage() ?: response.errorBody()?.string() ?: "卖出失败"
val trigger = triggerRepository.findFirstByStrategyIdAndBuyStatusOrderByTriggeredAtDesc(strategy.id!!, "SUCCESS")
if (trigger != null) {
triggerRepository.save(
trigger.copy(
sellStatus = "FAIL",
sellFailReason = failReason
)
)
}
logger.error("体育尾盘策略卖出失败: strategyId=${strategy.id}, reason=$failReason")
return Result.failure(IllegalStateException(failReason))
}
}
@@ -0,0 +1,420 @@
package com.wrbug.polymarketbot.service.sportstail
import com.wrbug.polymarketbot.api.MarketResponse
import com.wrbug.polymarketbot.api.PolymarketGammaApi
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.entity.Account
import com.wrbug.polymarketbot.entity.SportsTailStrategy
import com.wrbug.polymarketbot.entity.SportsTailStrategyTrigger
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.event.SportsTailStrategyChangedEvent
import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.repository.SportsTailStrategyRepository
import com.wrbug.polymarketbot.repository.SportsTailStrategyTriggerRepository
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.fromJson
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
@Service
class SportsTailStrategyService(
private val strategyRepository: SportsTailStrategyRepository,
private val triggerRepository: SportsTailStrategyTriggerRepository,
private val accountRepository: AccountRepository,
private val retrofitFactory: RetrofitFactory,
private val eventPublisher: ApplicationEventPublisher
) {
private val logger = LoggerFactory.getLogger(SportsTailStrategyService::class.java)
companion object {
private val SPORT_NAMES = mapOf(
"nba" to "NBA",
"nfl" to "NFL",
"epl" to "英超",
"lal" to "西甲",
"mlb" to "MLB",
"nhl" to "NHL",
"ufc" to "UFC"
)
}
@Transactional
fun create(request: SportsTailStrategyCreateRequest): Result<SportsTailStrategyDto> {
return try {
if (request.accountId <= 0) {
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ACCOUNT_ID_INVALID.messageKey))
}
if (request.conditionId.isBlank()) {
return Result.failure(IllegalArgumentException(ErrorCode.SPORTS_TAIL_STRATEGY_CONDITION_ID_EMPTY.messageKey))
}
val triggerPrice = request.triggerPrice.toSafeBigDecimal()
if (triggerPrice <= BigDecimal.ZERO || triggerPrice >= BigDecimal.ONE) {
return Result.failure(IllegalArgumentException(ErrorCode.SPORTS_TAIL_STRATEGY_PRICE_INVALID.messageKey))
}
val amountMode = request.amountMode.uppercase()
if (amountMode != "FIXED" && amountMode != "RATIO") {
return Result.failure(IllegalArgumentException(ErrorCode.SPORTS_TAIL_STRATEGY_AMOUNT_MODE_INVALID.messageKey))
}
val amountValue = request.amountValue.toSafeBigDecimal()
if (amountValue <= BigDecimal.ZERO) {
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
}
val account = accountRepository.findById(request.accountId).orElse(null)
?: return Result.failure(IllegalArgumentException(ErrorCode.ACCOUNT_NOT_FOUND.messageKey))
val existing = strategyRepository.findByAccountIdAndConditionId(request.accountId, request.conditionId)
if (existing != null) {
return Result.failure(IllegalArgumentException("该市场已存在策略"))
}
val takeProfitPrice = request.takeProfitPrice?.takeIf { it.isNotBlank() }?.toSafeBigDecimal()
val stopLossPrice = request.stopLossPrice?.takeIf { it.isNotBlank() }?.toSafeBigDecimal()
val marketInfo = runBlocking { fetchMarketInfo(request.conditionId).getOrNull() }
val entity = SportsTailStrategy(
accountId = request.accountId,
conditionId = request.conditionId,
marketTitle = request.marketTitle.takeIf { it.isNotBlank() } ?: marketInfo?.question,
eventSlug = request.eventSlug ?: marketInfo?.eventSlug,
yesTokenId = marketInfo?.yesTokenId,
noTokenId = marketInfo?.noTokenId,
triggerPrice = triggerPrice,
amountMode = amountMode,
amountValue = amountValue,
takeProfitPrice = takeProfitPrice,
stopLossPrice = stopLossPrice
)
val saved = strategyRepository.save(entity)
eventPublisher.publishEvent(SportsTailStrategyChangedEvent(this))
Result.success(entityToDto(saved, account))
} catch (e: IllegalArgumentException) {
Result.failure(e)
} catch (e: Exception) {
logger.error("创建体育尾盘策略失败: ${e.message}", e)
Result.failure(e)
}
}
@Transactional
fun delete(id: Long): Result<Unit> {
return try {
val existing = strategyRepository.findById(id).orElse(null)
?: return Result.failure(IllegalArgumentException(ErrorCode.SPORTS_TAIL_STRATEGY_NOT_FOUND.messageKey))
if (existing.filled && !existing.sold) {
return Result.failure(IllegalArgumentException("已成交未卖出的策略不能删除"))
}
strategyRepository.deleteById(id)
eventPublisher.publishEvent(SportsTailStrategyChangedEvent(this))
Result.success(Unit)
} catch (e: IllegalArgumentException) {
Result.failure(e)
} catch (e: Exception) {
logger.error("删除体育尾盘策略失败: ${e.message}", e)
Result.failure(e)
}
}
fun list(request: SportsTailStrategyListRequest): Result<SportsTailStrategyListResponse> {
return try {
val list = when {
request.accountId != null -> strategyRepository.findAllByAccountIdOrderByCreatedAtDesc(request.accountId)
else -> strategyRepository.findAllByOrderByCreatedAtDesc()
}
val accountIds = list.map { it.accountId }.distinct()
val accountMap = accountRepository.findAllById(accountIds).associateBy { it.id }
val dtos = list.map { entityToDto(it, accountMap[it.accountId]) }
Result.success(SportsTailStrategyListResponse(list = dtos))
} catch (e: Exception) {
logger.error("查询体育尾盘策略列表失败: ${e.message}", e)
Result.failure(e)
}
}
fun getTriggers(request: SportsTailTriggerListRequest): Result<SportsTailTriggerListResponse> {
return try {
val page = PageRequest.of((request.page - 1).coerceAtLeast(0), request.pageSize.coerceIn(1, 100))
val startTs = request.startTime ?: 0L
val endTs = request.endTime ?: Long.MAX_VALUE
val useTimeRange = request.startTime != null || request.endTime != null
val useStatus = !request.status.isNullOrBlank()
val pageResult: Page<SportsTailStrategyTrigger> = when {
request.accountId != null && useTimeRange && useStatus ->
triggerRepository.findAllByAccountIdAndBuyStatusAndTriggeredAtBetweenOrderByTriggeredAtDesc(
request.accountId, request.status!!, startTs, endTs, page
)
request.accountId != null && useTimeRange ->
triggerRepository.findAllByAccountIdAndTriggeredAtBetweenOrderByTriggeredAtDesc(
request.accountId, startTs, endTs, page
)
request.accountId != null && useStatus ->
triggerRepository.findAllByAccountIdAndBuyStatusOrderByTriggeredAtDesc(
request.accountId, request.status!!, page
)
request.accountId != null ->
triggerRepository.findAllByAccountIdOrderByTriggeredAtDesc(request.accountId, page)
useTimeRange && useStatus ->
triggerRepository.findAllByBuyStatusAndTriggeredAtBetweenOrderByTriggeredAtDesc(
request.status!!, startTs, endTs, page
)
useTimeRange ->
triggerRepository.findAllByTriggeredAtBetweenOrderByTriggeredAtDesc(startTs, endTs, page)
useStatus ->
triggerRepository.findAllByBuyStatusOrderByTriggeredAtDesc(request.status!!, page)
else ->
triggerRepository.findAllByOrderByTriggeredAtDesc(page)
}
val total = pageResult.totalElements
val list = pageResult.content.map { triggerToDto(it) }
Result.success(SportsTailTriggerListResponse(total = total, list = list))
} catch (e: Exception) {
logger.error("查询触发记录失败: ${e.message}", e)
Result.failure(e)
}
}
suspend fun getSportsCategories(): Result<SportsCategoryListResponse> {
return try {
val api = retrofitFactory.createGammaSportsApi()
val response = api.getSports()
if (response.isSuccessful && response.body() != null) {
val body = response.body()!!
val list = body.map { c -> categoryToDto(c) }
Result.success(SportsCategoryListResponse(list = list))
} else {
logger.warn("获取体育类别失败: ${response.code()}")
Result.failure(Exception("获取体育类别失败"))
}
} catch (e: Exception) {
logger.error("获取体育类别失败: ${e.message}", e)
Result.failure(e)
}
}
suspend fun searchMarkets(request: SportsMarketSearchRequest): Result<SportsMarketSearchResponse> {
return try {
val api = retrofitFactory.createGammaSportsApi()
val tagId = if (!request.sport.isNullOrBlank()) {
getTagIdBySport(request.sport)
} else null
val response = api.searchMarkets(
tagId = tagId,
active = true,
closed = false,
limit = request.limit,
order = "endDate",
ascending = true,
slug = request.keyword
)
if (response.isSuccessful && response.body() != null) {
val markets = response.body()!!
val filtered = if (!request.minLiquidity.isNullOrBlank()) {
val minLiquidity = request.minLiquidity.toSafeBigDecimal()
markets.filter { m ->
val liquidity = m.liquidityNum?.toSafeBigDecimal() ?: BigDecimal.ZERO
liquidity >= minLiquidity
}
} else {
markets
}
val list = filtered.map { m -> marketToDto(m) }
Result.success(SportsMarketSearchResponse(list = list))
} else {
logger.warn("搜索市场失败: ${response.code()}")
Result.failure(Exception("搜索市场失败"))
}
} catch (e: Exception) {
logger.error("搜索市场失败: ${e.message}", e)
Result.failure(e)
}
}
suspend fun getMarketDetail(conditionId: String): Result<SportsMarketDetailResponse> {
return try {
val marketInfo = fetchMarketInfo(conditionId).getOrNull()
?: return Result.failure(Exception("市场不存在"))
Result.success(
SportsMarketDetailResponse(
conditionId = marketInfo.conditionId,
question = marketInfo.question,
outcomes = marketInfo.outcomes,
outcomePrices = marketInfo.outcomePrices,
endDate = marketInfo.endDate,
liquidity = marketInfo.liquidity,
bestBid = marketInfo.bestBid,
bestAsk = marketInfo.bestAsk,
yesTokenId = marketInfo.yesTokenId,
noTokenId = marketInfo.noTokenId,
eventSlug = marketInfo.eventSlug
)
)
} catch (e: Exception) {
logger.error("获取市场详情失败: ${e.message}", e)
Result.failure(e)
}
}
private suspend fun fetchMarketInfo(conditionId: String): Result<SportsMarketDto> {
return try {
val api = retrofitFactory.createGammaApi()
val response = api.listMarkets(conditionIds = listOf(conditionId))
if (response.isSuccessful && !response.body().isNullOrEmpty()) {
val m = response.body()!![0]
Result.success(marketResponseToDto(m))
} else {
Result.failure(Exception("市场不存在"))
}
} catch (e: Exception) {
logger.error("获取市场信息失败: ${e.message}", e)
Result.failure(e)
}
}
private suspend fun getTagIdBySport(sport: String): Long? {
return try {
val api = retrofitFactory.createGammaSportsApi()
val response = api.getSports()
if (response.isSuccessful && response.body() != null) {
val body = response.body()!!
val category = body.find { c -> c.sport == sport.lowercase() }
category?.tags?.split(",")?.firstOrNull()?.toLongOrNull()
} else null
} catch (e: Exception) {
null
}
}
private fun parseClobTokenIds(clobTokenIds: String?): List<String> {
if (clobTokenIds.isNullOrBlank()) return emptyList()
return clobTokenIds.fromJson<List<String>>() ?: emptyList()
}
private fun parseOutcomes(outcomes: String?): List<String> {
if (outcomes.isNullOrBlank()) return emptyList()
return outcomes.fromJson<List<String>>() ?: emptyList()
}
private fun parseOutcomePrices(outcomePrices: String?): List<String> {
if (outcomePrices.isNullOrBlank()) return emptyList()
return outcomePrices.fromJson<List<String>>() ?: emptyList()
}
private fun entityToDto(e: SportsTailStrategy, account: Account?): SportsTailStrategyDto {
return SportsTailStrategyDto(
id = e.id ?: 0L,
accountId = e.accountId,
accountName = account?.accountName ?: account?.walletAddress?.take(8),
conditionId = e.conditionId,
marketTitle = e.marketTitle,
eventSlug = e.eventSlug,
triggerPrice = e.triggerPrice.toPlainString(),
amountMode = e.amountMode,
amountValue = e.amountValue.toPlainString(),
takeProfitPrice = e.takeProfitPrice?.toPlainString(),
stopLossPrice = e.stopLossPrice?.toPlainString(),
filled = e.filled,
filledPrice = e.filledPrice?.toPlainString(),
filledOutcomeIndex = e.filledOutcomeIndex,
filledOutcomeName = e.filledOutcomeName,
filledAmount = e.filledAmount?.toPlainString(),
filledShares = e.filledShares?.toPlainString(),
filledAt = e.filledAt,
sold = e.sold,
sellPrice = e.sellPrice?.toPlainString(),
sellType = e.sellType,
sellAmount = e.sellAmount?.toPlainString(),
realizedPnl = e.realizedPnl?.toPlainString(),
soldAt = e.soldAt,
createdAt = e.createdAt,
updatedAt = e.updatedAt
)
}
private fun categoryToDto(c: com.wrbug.polymarketbot.api.SportsCategoryResponse): SportsCategoryDto {
val tagId = c.tags?.split(",")?.firstOrNull()?.toLongOrNull() ?: 0L
return SportsCategoryDto(
sport = c.sport ?: "",
image = c.image,
tagId = tagId,
name = SPORT_NAMES[c.sport] ?: c.sport ?: ""
)
}
private fun marketResponseToDto(m: MarketResponse): SportsMarketDto {
val tokenIds = parseClobTokenIds(m.clobTokenIds ?: m.clob_token_ids)
return SportsMarketDto(
conditionId = m.conditionId ?: "",
question = m.question ?: "",
outcomes = parseOutcomes(m.outcomes),
outcomePrices = parseOutcomePrices(m.outcomePrices),
endDate = m.endDate,
liquidity = m.liquidityNum?.toString() ?: m.liquidity,
bestBid = m.bestBid,
bestAsk = m.bestAsk,
yesTokenId = tokenIds.getOrNull(0),
noTokenId = tokenIds.getOrNull(1),
eventSlug = m.events?.firstOrNull()?.slug
)
}
private fun marketToDto(m: com.wrbug.polymarketbot.api.SportsMarketResponse): SportsMarketDto {
val tokenIds = parseClobTokenIds(m.clobTokenIds)
return SportsMarketDto(
conditionId = m.conditionId ?: "",
question = m.question ?: "",
outcomes = parseOutcomes(m.outcomes),
outcomePrices = parseOutcomePrices(m.outcomePrices),
endDate = m.endDate,
liquidity = m.liquidityNum?.toString() ?: m.liquidity,
bestBid = m.bestBid,
bestAsk = m.bestAsk,
yesTokenId = tokenIds.getOrNull(0),
noTokenId = tokenIds.getOrNull(1),
eventSlug = m.events?.firstOrNull()?.slug
)
}
private fun triggerToDto(t: SportsTailStrategyTrigger): SportsTailTriggerDto {
return SportsTailTriggerDto(
id = t.id ?: 0L,
strategyId = t.strategyId,
marketTitle = t.marketTitle,
conditionId = t.conditionId,
buyPrice = t.buyPrice.toPlainString(),
outcomeIndex = t.outcomeIndex,
outcomeName = t.outcomeName,
buyAmount = t.buyAmount.toPlainString(),
buyShares = t.buyShares?.toPlainString(),
buyStatus = t.buyStatus,
sellPrice = t.sellPrice?.toPlainString(),
sellType = t.sellType,
sellAmount = t.sellAmount?.toPlainString(),
sellStatus = t.sellStatus,
realizedPnl = t.realizedPnl?.toPlainString(),
triggeredAt = t.triggeredAt,
soldAt = t.soldAt
)
}
}
@@ -33,11 +33,13 @@ class TelegramNotificationService(
private val logger = LoggerFactory.getLogger(TelegramNotificationService::class.java)
private val okHttpClient = createClient()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.writeTimeout(5, TimeUnit.SECONDS)
.build()
private val okHttpClient by lazy {
createClient()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.writeTimeout(5, TimeUnit.SECONDS)
.build()
}
private val apiBaseUrl = "https://api.telegram.org/bot"
@@ -8,6 +8,7 @@ import com.wrbug.polymarketbot.api.GitHubApi
import com.wrbug.polymarketbot.api.PolymarketClobApi
import com.wrbug.polymarketbot.api.PolymarketDataApi
import com.wrbug.polymarketbot.api.PolymarketGammaApi
import com.wrbug.polymarketbot.api.PolymarketGammaSportsApi
import com.wrbug.polymarketbot.constants.PolymarketConstants
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
@@ -360,6 +361,25 @@ class RetrofitFactory(
fun createGitHubApi(): GitHubApi {
return githubApi
}
// 缓存 Gamma Sports API 客户端(单例)
private val gammaSportsApi: PolymarketGammaSportsApi by lazy {
Retrofit.Builder()
.baseUrl(PolymarketConstants.GAMMA_BASE_URL)
.client(sharedOkHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(PolymarketGammaSportsApi::class.java)
}
/**
* 创建 Polymarket Gamma Sports API 客户端
* Gamma Sports API 是公开 API,不需要认证
* @return PolymarketGammaSportsApi 客户端(单例)
*/
fun createGammaSportsApi(): PolymarketGammaSportsApi {
return gammaSportsApi
}
/**
* 清理缓存(用于测试或配置变更时)
@@ -0,0 +1,67 @@
-- Flyway migration V41
-- Create sports_tail_strategy table
CREATE TABLE `sports_tail_strategy` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`account_id` BIGINT NOT NULL COMMENT '账户ID',
`condition_id` VARCHAR(100) NOT NULL COMMENT '市场 conditionId',
`market_title` VARCHAR(500) COMMENT '市场标题',
`event_slug` VARCHAR(255) COMMENT '事件slug',
`yes_token_id` VARCHAR(100) COMMENT 'YES Token ID',
`no_token_id` VARCHAR(100) COMMENT 'NO Token ID',
`trigger_price` DECIMAL(20, 8) NOT NULL COMMENT '触发价格',
`amount_mode` VARCHAR(10) NOT NULL COMMENT '金额模式: FIXED/RATIO',
`amount_value` DECIMAL(20, 8) NOT NULL COMMENT '金额值',
`take_profit_price` DECIMAL(20, 8) COMMENT '止盈价格',
`stop_loss_price` DECIMAL(20, 8) COMMENT '止损价格',
`filled` BOOLEAN NOT NULL DEFAULT false COMMENT '是否已成交',
`filled_price` DECIMAL(20, 8) COMMENT '成交价格',
`filled_outcome_index` INT COMMENT '成交方向索引 0=YES, 1=NO',
`filled_outcome_name` VARCHAR(50) COMMENT '成交方向名称',
`filled_amount` DECIMAL(20, 8) COMMENT '成交金额',
`filled_shares` DECIMAL(20, 8) COMMENT '成交份额',
`filled_at` BIGINT COMMENT '成交时间',
`sold` BOOLEAN NOT NULL DEFAULT false COMMENT '是否已卖出',
`sell_price` DECIMAL(20, 8) COMMENT '卖出价格',
`sell_type` VARCHAR(20) COMMENT '卖出类型',
`sell_amount` DECIMAL(20, 8) COMMENT '卖出金额',
`realized_pnl` DECIMAL(20, 8) COMMENT '已实现盈亏',
`sold_at` BIGINT COMMENT '卖出时间',
`created_at` BIGINT NOT NULL COMMENT '创建时间',
`updated_at` BIGINT NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`)
);
-- Create sports_tail_strategy_trigger table
CREATE TABLE `sports_tail_strategy_trigger` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`strategy_id` BIGINT NOT NULL COMMENT '策略ID',
`account_id` BIGINT NOT NULL COMMENT '账户ID',
`condition_id` VARCHAR(100) NOT NULL COMMENT '市场 conditionId',
`market_title` VARCHAR(500) COMMENT '市场标题',
`buy_price` DECIMAL(20, 8) NOT NULL COMMENT '买入价格',
`outcome_index` INT NOT NULL COMMENT '买入方向索引 0=YES, 1=NO',
`outcome_name` VARCHAR(50) COMMENT '买入方向名称',
`buy_amount` DECIMAL(20, 8) NOT NULL COMMENT '买入金额',
`buy_shares` DECIMAL(20, 8) COMMENT '买入份额',
`buy_order_id` VARCHAR(100) COMMENT '买入订单ID',
`buy_status` VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '买入状态',
`buy_fail_reason` VARCHAR(500) COMMENT '买入失败原因',
`sell_price` DECIMAL(20, 8) COMMENT '卖出价格',
`sell_type` VARCHAR(20) COMMENT '卖出类型',
`sell_amount` DECIMAL(20, 8) COMMENT '卖出金额',
`sell_order_id` VARCHAR(100) COMMENT '卖出订单ID',
`sell_status` VARCHAR(20) COMMENT '卖出状态',
`sell_fail_reason` VARCHAR(500) COMMENT '卖出失败原因',
`realized_pnl` DECIMAL(20, 8) COMMENT '已实现盈亏',
`triggered_at` BIGINT NOT NULL COMMENT '触发时间',
`sold_at` BIGINT COMMENT '卖出时间',
`created_at` BIGINT NOT NULL COMMENT '创建时间',
PRIMARY KEY (`id`)
);
-- Create indexes
CREATE INDEX idx_sports_tail_strategy_account_id ON sports_tail_strategy (account_id);
CREATE INDEX idx_sports_tail_strategy_condition_id ON sports_tail_strategy (condition_id);
CREATE INDEX idx_sports_tail_trigger_account_id ON sports_tail_strategy_trigger (account_id);
CREATE INDEX idx_sports_tail_trigger_strategy_id ON sports_tail_strategy_trigger (strategy_id);
CREATE INDEX idx_sports_tail_trigger_triggered_at ON sports_tail_strategy_trigger (triggered_at);
@@ -338,3 +338,20 @@ backtest.copy_mode.fixed=Fixed Amount
backtest.price_tolerance=Price Tolerance
backtest.delay_seconds=Delay Seconds
backtest.support_sell=Support Sell
# Sports Tail Strategy
error.sports_tail_strategy_not_found=Sports tail strategy not found
error.sports_tail_strategy_already_filled=Strategy already filled
error.sports_tail_strategy_already_sold=Strategy already sold
error.sports_tail_strategy_amount_mode_invalid=Amount mode must be FIXED or RATIO
error.sports_tail_strategy_price_invalid=Trigger price is invalid
error.sports_tail_strategy_condition_id_empty=Market ID cannot be empty
error.server.sports_tail_strategy_create_failed=Failed to create sports tail strategy
error.server.sports_tail_strategy_delete_failed=Failed to delete sports tail strategy
error.server.sports_tail_strategy_list_fetch_failed=Failed to fetch sports tail strategy list
error.server.sports_tail_strategy_triggers_fetch_failed=Failed to fetch trigger records
error.server.sports_tail_strategy_sports_fetch_failed=Failed to fetch sports categories
error.server.sports_tail_strategy_market_search_failed=Failed to search markets
error.server.sports_tail_strategy_market_detail_failed=Failed to fetch market detail
error.server.sports_tail_strategy_buy_failed=Failed to execute buy
error.server.sports_tail_strategy_sell_failed=Failed to execute sell
@@ -344,3 +344,20 @@ error.server.order_tracking_process_failed=处理订单跟踪失败
error.server.order_tracking_buy_failed=处理买入订单失败
error.server.order_tracking_sell_failed=处理卖出订单失败
error.server.order_tracking_match_failed=订单匹配失败
# 体育尾盘策略
error.sports_tail_strategy_not_found=体育尾盘策略不存在
error.sports_tail_strategy_already_filled=策略已成交
error.sports_tail_strategy_already_sold=策略已卖出
error.sports_tail_strategy_amount_mode_invalid=金额模式仅支持 FIXED 或 RATIO
error.sports_tail_strategy_price_invalid=触发价格无效
error.sports_tail_strategy_condition_id_empty=市场ID不能为空
error.server.sports_tail_strategy_create_failed=创建体育尾盘策略失败
error.server.sports_tail_strategy_delete_failed=删除体育尾盘策略失败
error.server.sports_tail_strategy_list_fetch_failed=查询体育尾盘策略列表失败
error.server.sports_tail_strategy_triggers_fetch_failed=查询触发记录失败
error.server.sports_tail_strategy_sports_fetch_failed=查询体育类别失败
error.server.sports_tail_strategy_market_search_failed=搜索市场失败
error.server.sports_tail_strategy_market_detail_failed=查询市场详情失败
error.server.sports_tail_strategy_buy_failed=买入执行失败
error.server.sports_tail_strategy_sell_failed=卖出执行失败
@@ -338,3 +338,20 @@ backtest.copy_mode.fixed=固定金額
backtest.price_tolerance=價格容忍度
backtest.delay_seconds=延遲秒數
backtest.support_sell=支持賣出
# 體育尾盤策略
error.sports_tail_strategy_not_found=體育尾盤策略不存在
error.sports_tail_strategy_already_filled=策略已成交
error.sports_tail_strategy_already_sold=策略已賣出
error.sports_tail_strategy_amount_mode_invalid=金額模式僅支持 FIXED 或 RATIO
error.sports_tail_strategy_price_invalid=觸發價格無效
error.sports_tail_strategy_condition_id_empty=市場ID不能為空
error.server.sports_tail_strategy_create_failed=創建體育尾盤策略失敗
error.server.sports_tail_strategy_delete_failed=刪除體育尾盤策略失敗
error.server.sports_tail_strategy_list_fetch_failed=查詢體育尾盤策略列表失敗
error.server.sports_tail_strategy_triggers_fetch_failed=查詢觸發記錄失敗
error.server.sports_tail_strategy_sports_fetch_failed=查詢體育類別失敗
error.server.sports_tail_strategy_market_search_failed=搜尋市場失敗
error.server.sports_tail_strategy_market_detail_failed=查詢市場詳情失敗
error.server.sports_tail_strategy_buy_failed=買入執行失敗
error.server.sports_tail_strategy_sell_failed=賣出執行失敗
+2
View File
@@ -37,6 +37,7 @@ import BacktestList from './pages/BacktestList'
import BacktestDetail from './pages/BacktestDetail'
import CryptoTailStrategyList from './pages/CryptoTailStrategyList'
import CryptoTailMonitor from './pages/CryptoTailMonitor'
import SportsTailStrategyList from './pages/SportsTailStrategyList'
import { wsManager } from './services/websocket'
import type { OrderPushMessage } from './types'
import { apiService } from './services/api'
@@ -264,6 +265,7 @@ function App() {
<Route path="/copy-trading" element={<ProtectedRoute><CopyTradingList /></ProtectedRoute>} />
<Route path="/crypto-tail-strategy" element={<ProtectedRoute><CryptoTailStrategyList /></ProtectedRoute>} />
<Route path="/crypto-tail-monitor" element={<ProtectedRoute><CryptoTailMonitor /></ProtectedRoute>} />
<Route path="/sports-tail-strategy" element={<ProtectedRoute><SportsTailStrategyList /></ProtectedRoute>} />
<Route path="/copy-trading/statistics/:copyTradingId" element={<ProtectedRoute><CopyTradingStatistics /></ProtectedRoute>} />
{/* 保留旧路由以保持向后兼容 */}
<Route path="/copy-trading/orders/buy/:copyTradingId" element={<ProtectedRoute><CopyTradingBuyOrders /></ProtectedRoute>} />
+9 -3
View File
@@ -23,7 +23,8 @@ import {
NotificationOutlined,
LineChartOutlined,
RocketOutlined,
DashboardOutlined
DashboardOutlined,
TrophyOutlined
} from '@ant-design/icons'
import type { MenuProps } from 'antd'
import type { ReactNode } from 'react'
@@ -77,7 +78,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) {
keys.push('/copy-trading-management')
}
if (path.startsWith('/crypto-tail-strategy') || path.startsWith('/crypto-tail-monitor')) {
if (path.startsWith('/crypto-tail-strategy') || path.startsWith('/crypto-tail-monitor') || path.startsWith('/sports-tail-strategy')) {
keys.push('/crypto-tail-management')
}
if (path.startsWith('/system-settings')) {
@@ -95,7 +96,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) {
keys.push('/copy-trading-management')
}
if (path.startsWith('/crypto-tail-strategy') || path.startsWith('/crypto-tail-monitor')) {
if (path.startsWith('/crypto-tail-strategy') || path.startsWith('/crypto-tail-monitor') || path.startsWith('/sports-tail-strategy')) {
keys.push('/crypto-tail-management')
}
if (path.startsWith('/system-settings')) {
@@ -179,6 +180,11 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
key: '/crypto-tail-monitor',
icon: <DashboardOutlined />,
label: t('menu.cryptoTailMonitor')
},
{
key: '/sports-tail-strategy',
icon: <TrophyOutlined />,
label: t('menu.sportsTailStrategy')
}
]
},
+74
View File
@@ -317,6 +317,7 @@
"cryptoSpreadStrategy": "Crypto Spread Strategy",
"cryptoTailStrategy": "Strategy Config",
"cryptoTailMonitor": "Real-time Monitor",
"sportsTailStrategy": "Sports Tail Strategy",
"positions": "Position Management",
"backtest": "Backtest",
"statistics": "Statistics",
@@ -1691,6 +1692,79 @@
"empty": "No settled orders yet, cannot show PnL curve"
}
},
"sportsTailStrategy": {
"list": {
"title": "Sports Tail Strategy",
"addStrategy": "Add Strategy",
"filter": {
"account": "Account",
"category": "Category",
"allCategory": "All"
},
"triggerPrice": "Trigger Price",
"amount": "Amount",
"takeProfitStopLoss": "Take Profit / Stop Loss",
"filledPrice": "Filled Price",
"realtimePrice": "Realtime Price",
"shares": "Shares",
"pnl": "PnL",
"pending": "Pending",
"viewRecords": "View Records",
"delete": "Delete",
"deleteConfirm": "Delete this strategy?",
"fetchFailed": "Failed to fetch list"
},
"form": {
"title": "Add Sports Tail Strategy",
"account": "Account",
"selectAccount": "Select Account",
"selectMarket": "Select Market",
"triggerCondition": "Trigger Condition",
"triggerPriceHelp": "Buy when either side reaches trigger price",
"amount": "Amount",
"fixedAmount": "Fixed Amount",
"ratio": "Balance Ratio",
"autoSell": "Enable Auto Sell",
"takeProfitPrice": "Take Profit Price",
"takeProfitHelp": "Sell when price rises to this value",
"stopLossPrice": "Stop Loss Price",
"stopLossHelp": "Sell when price falls to this value",
"estimatedReturn": "Estimated Return",
"buyPrice": "Buy Price",
"buyAmount": "Buy Amount",
"estimatedShares": "Estimated Shares",
"estimatedPnl": "Estimated PnL",
"createSuccess": "Strategy created",
"createFailed": "Failed to create strategy"
},
"marketSearch": {
"sport": "Sport",
"marketType": "Market Type",
"endTime": "End Time",
"minLiquidity": "Min Liquidity",
"keyword": "Keyword",
"search": "Search",
"liquidity": "Liquidity",
"remaining": "Remaining",
"all": "All",
"today": "Today",
"next24h": "Next 24 Hours",
"next7days": "Next 7 Days"
},
"records": {
"title": "Trigger Records",
"time": "Time",
"market": "Market",
"direction": "Direction",
"buyPrice": "Buy Price",
"amount": "Amount",
"sellPrice": "Sell Price",
"pnl": "PnL",
"pending": "Pending",
"takeProfit": "Take Profit",
"stopLoss": "Stop Loss"
}
},
"cryptoTailMonitor": {
"title": "Crypto Spread Strategy Monitor",
"selectStrategy": "Strategy",
+74
View File
@@ -317,6 +317,7 @@
"cryptoSpreadStrategy": "加密价差策略",
"cryptoTailStrategy": "策略配置",
"cryptoTailMonitor": "实时监控",
"sportsTailStrategy": "体育尾盘策略",
"positions": "仓位管理",
"backtest": "回测",
"statistics": "统计信息",
@@ -1691,6 +1692,79 @@
"empty": "暂无已结算订单,无法展示收益曲线"
}
},
"sportsTailStrategy": {
"list": {
"title": "体育尾盘策略",
"addStrategy": "新增策略",
"filter": {
"account": "账户",
"category": "类别",
"allCategory": "全部"
},
"triggerPrice": "触发价",
"amount": "金额",
"takeProfitStopLoss": "止盈/止损",
"filledPrice": "成交价",
"realtimePrice": "实时价格",
"shares": "份",
"pnl": "盈亏",
"pending": "待结算",
"viewRecords": "查看记录",
"delete": "删除",
"deleteConfirm": "确定删除该策略吗?",
"fetchFailed": "获取列表失败"
},
"form": {
"title": "新增体育尾盘策略",
"account": "账户",
"selectAccount": "选择账户",
"selectMarket": "选择市场",
"triggerCondition": "触发条件",
"triggerPriceHelp": "当任意方向价格达到触发价时买入",
"amount": "下注金额",
"fixedAmount": "固定金额",
"ratio": "余额比例",
"autoSell": "启用自动卖出",
"takeProfitPrice": "止盈价格",
"takeProfitHelp": "价格上涨到此值时自动卖出",
"stopLossPrice": "止损价格",
"stopLossHelp": "价格下跌到此值时自动卖出",
"estimatedReturn": "预估收益",
"buyPrice": "买入价格",
"buyAmount": "买入金额",
"estimatedShares": "预计份额",
"estimatedPnl": "预计收益",
"createSuccess": "策略创建成功",
"createFailed": "策略创建失败"
},
"marketSearch": {
"sport": "体育类别",
"marketType": "市场类型",
"endTime": "结束时间",
"minLiquidity": "最小流动性",
"keyword": "搜索关键词",
"search": "搜索",
"liquidity": "流动性",
"remaining": "剩余",
"all": "全部",
"today": "今天",
"next24h": "未来24小时",
"next7days": "未来7天"
},
"records": {
"title": "触发记录",
"time": "时间",
"market": "市场",
"direction": "方向",
"buyPrice": "买入价",
"amount": "金额",
"sellPrice": "卖出价",
"pnl": "盈亏",
"pending": "待结算",
"takeProfit": "已止盈",
"stopLoss": "已止损"
}
},
"cryptoTailMonitor": {
"title": "加密价差策略监控",
"selectStrategy": "选择策略",
+74
View File
@@ -317,6 +317,7 @@
"cryptoSpreadStrategy": "加密價差策略",
"cryptoTailStrategy": "策略配置",
"cryptoTailMonitor": "即時監控",
"sportsTailStrategy": "體育尾盤策略",
"positions": "倉位管理",
"backtest": "回測",
"statistics": "統計信息",
@@ -1691,6 +1692,79 @@
"empty": "暫無已結算訂單,無法展示收益曲線"
}
},
"sportsTailStrategy": {
"list": {
"title": "體育尾盤策略",
"addStrategy": "新增策略",
"filter": {
"account": "賬戶",
"category": "類別",
"allCategory": "全部"
},
"triggerPrice": "觸發價",
"amount": "金額",
"takeProfitStopLoss": "止盈/止損",
"filledPrice": "成交價",
"realtimePrice": "實時價格",
"shares": "份",
"pnl": "盈虧",
"pending": "待結算",
"viewRecords": "查看記錄",
"delete": "刪除",
"deleteConfirm": "確定刪除該策略嗎?",
"fetchFailed": "獲取列表失敗"
},
"form": {
"title": "新增體育尾盤策略",
"account": "賬戶",
"selectAccount": "選擇賬戶",
"selectMarket": "選擇市場",
"triggerCondition": "觸發條件",
"triggerPriceHelp": "當任意方向價格達到觸發價時買入",
"amount": "下注金額",
"fixedAmount": "固定金額",
"ratio": "餘額比例",
"autoSell": "啟用自動賣出",
"takeProfitPrice": "止盈價格",
"takeProfitHelp": "價格上漲到此值時自動賣出",
"stopLossPrice": "止損價格",
"stopLossHelp": "價格下跌到此值時自動賣出",
"estimatedReturn": "預估收益",
"buyPrice": "買入價格",
"buyAmount": "買入金額",
"estimatedShares": "預計份額",
"estimatedPnl": "預計收益",
"createSuccess": "策略創建成功",
"createFailed": "策略創建失敗"
},
"marketSearch": {
"sport": "體育類別",
"marketType": "市場類型",
"endTime": "結束時間",
"minLiquidity": "最小流動性",
"keyword": "搜索關鍵詞",
"search": "搜索",
"liquidity": "流動性",
"remaining": "剩餘",
"all": "全部",
"today": "今天",
"next24h": "未來24小時",
"next7days": "未來7天"
},
"records": {
"title": "觸發記錄",
"time": "時間",
"market": "市場",
"direction": "方向",
"buyPrice": "買入價",
"amount": "金額",
"sellPrice": "賣出價",
"pnl": "盈虧",
"pending": "待結算",
"takeProfit": "已止盈",
"stopLoss": "已止損"
}
},
"cryptoTailMonitor": {
"title": "加密價差策略監控",
"selectStrategy": "選擇策略",
@@ -0,0 +1,575 @@
import { useEffect, useState } from 'react'
import {
Card,
Table,
Button,
Space,
message,
Select,
Modal,
Form,
Input,
InputNumber,
Radio,
Spin,
Popconfirm,
Empty,
Drawer,
Row,
Col,
Typography
} from 'antd'
import dayjs from 'dayjs'
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import { useMediaQuery } from 'react-responsive'
import { apiService } from '../services/api'
import { useAccountStore } from '../store/accountStore'
import type {
SportsTailStrategyDto,
SportsTailStrategyCreateRequest,
SportsTailTriggerDto,
SportsCategoryDto,
SportsMarketDto
} from '../types'
import { formatUSDC } from '../utils'
const POLYMARKET_BASE = 'https://polymarket.com/event/'
const SportsTailStrategyList: React.FC = () => {
const { t } = useTranslation()
const isMobile = useMediaQuery({ maxWidth: 768 })
const { accounts, fetchAccounts } = useAccountStore()
const [list, setList] = useState<SportsTailStrategyDto[]>([])
const [loading, setLoading] = useState(false)
const [filters, setFilters] = useState<{ accountId?: number; sport?: string }>({})
const [formModalOpen, setFormModalOpen] = useState(false)
const [sportsList, setSportsList] = useState<SportsCategoryDto[]>([])
const [marketSearchLoading, setMarketSearchLoading] = useState(false)
const [marketSearchResult, setMarketSearchResult] = useState<SportsMarketDto[]>([])
const [marketSearchFilters, setMarketSearchFilters] = useState<{
sport?: string
keyword?: string
}>({})
const [recordsDrawerOpen, setRecordsDrawerOpen] = useState(false)
const [records, setRecords] = useState<SportsTailTriggerDto[]>([])
const [recordsTotal, setRecordsTotal] = useState(0)
const [recordsLoading, setRecordsLoading] = useState(false)
const [recordsPage, setRecordsPage] = useState(1)
const [recordsPageSize] = useState(20)
const [recordsFilters, setRecordsFilters] = useState<{
accountId?: number
status?: string
startTime?: number
endTime?: number
}>({})
const [form] = Form.useForm()
useEffect(() => {
fetchAccounts()
fetchSportsList()
}, [])
useEffect(() => {
fetchList()
}, [filters])
const fetchList = async () => {
setLoading(true)
try {
const res = await apiService.sportsTailStrategy.list(filters)
if (res.data.code === 0 && res.data.data?.list) {
setList(res.data.data.list)
} else {
message.error(res.data.msg || t('sportsTailStrategy.list.fetchFailed'))
}
} catch (e) {
message.error((e as Error).message || t('sportsTailStrategy.list.fetchFailed'))
} finally {
setLoading(false)
}
}
const fetchSportsList = async () => {
try {
const res = await apiService.sportsTailStrategy.sportsList()
if (res.data.code === 0 && res.data.data?.list) {
setSportsList(res.data.data.list)
}
} catch {
setSportsList([])
}
}
const fetchMarketSearch = async () => {
setMarketSearchLoading(true)
try {
const res = await apiService.sportsTailStrategy.marketSearch({
sport: marketSearchFilters.sport || undefined,
keyword: marketSearchFilters.keyword || undefined,
limit: 50
})
if (res.data.code === 0 && res.data.data?.list) {
setMarketSearchResult(res.data.data.list)
} else {
setMarketSearchResult([])
}
} catch {
setMarketSearchResult([])
} finally {
setMarketSearchLoading(false)
}
}
const fetchRecords = async (page = 1) => {
setRecordsLoading(true)
try {
const res = await apiService.sportsTailStrategy.triggers({
accountId: recordsFilters.accountId,
status: recordsFilters.status,
startTime: recordsFilters.startTime,
endTime: recordsFilters.endTime,
page,
pageSize: recordsPageSize
})
if (res.data.code === 0 && res.data.data) {
setRecords(res.data.data.list)
setRecordsTotal(res.data.data.total)
setRecordsPage(page)
}
} catch {
setRecords([])
setRecordsTotal(0)
} finally {
setRecordsLoading(false)
}
}
const openAddModal = () => {
form.resetFields()
form.setFieldsValue({ amountMode: 'FIXED' })
setFormModalOpen(true)
setMarketSearchResult([])
setMarketSearchFilters({})
fetchSportsList()
}
const handleFormSubmit = async () => {
try {
const v = await form.validateFields()
const payload: SportsTailStrategyCreateRequest = {
accountId: v.accountId,
conditionId: v.conditionId,
marketTitle: v.marketTitle,
eventSlug: v.eventSlug || undefined,
triggerPrice: String(v.triggerPrice),
amountMode: v.amountMode,
amountValue: String(v.amountValue),
takeProfitPrice: v.takeProfitPrice != null ? String(v.takeProfitPrice) : undefined,
stopLossPrice: v.stopLossPrice != null ? String(v.stopLossPrice) : undefined
}
const res = await apiService.sportsTailStrategy.create(payload)
if (res.data.code === 0) {
message.success(t('sportsTailStrategy.form.createSuccess'))
setFormModalOpen(false)
fetchList()
} else {
message.error(res.data.msg || t('sportsTailStrategy.form.createFailed'))
}
} catch (e) {
if (e && typeof (e as { errorFields?: unknown }).errorFields === 'undefined') {
message.error((e as Error).message || t('sportsTailStrategy.form.createFailed'))
}
}
}
const handleDelete = async (id: number) => {
try {
const res = await apiService.sportsTailStrategy.delete({ id })
if (res.data.code === 0) {
message.success(t('message.success'))
fetchList()
} else {
message.error(res.data.msg)
}
} catch (e) {
message.error((e as Error).message)
}
}
const openRecordsDrawer = () => {
setRecordsDrawerOpen(true)
setRecordsFilters({})
fetchRecords(1)
}
useEffect(() => {
if (recordsDrawerOpen) {
fetchRecords(recordsPage)
}
}, [recordsDrawerOpen, recordsFilters])
const renderAmount = (row: SportsTailStrategyDto) => {
if (row.amountMode === 'FIXED') {
return `${formatUSDC(row.amountValue)} USDC`
}
return `${row.amountValue}%`
}
const renderTakeProfitStopLoss = (row: SportsTailStrategyDto) => {
const a = row.takeProfitPrice != null ? formatUSDC(row.takeProfitPrice) : '-'
const b = row.stopLossPrice != null ? formatUSDC(row.stopLossPrice) : '-'
return `${a} / ${b}`
}
const renderFilledOrRealtime = (row: SportsTailStrategyDto) => {
if (row.filled && row.filledPrice != null && row.filledOutcomeName != null && row.filledShares != null) {
return `${formatUSDC(row.filledPrice)} ${row.filledOutcomeName} | ${formatUSDC(row.filledShares)} ${t('sportsTailStrategy.list.shares')}`
}
const yes = row.realtimeYesPrice != null ? formatUSDC(row.realtimeYesPrice) : '-'
const no = row.realtimeNoPrice != null ? formatUSDC(row.realtimeNoPrice) : '-'
return `${t('sportsTailStrategy.list.realtimePrice')}: ${yes} / ${no}`
}
const renderPnl = (row: SportsTailStrategyDto) => {
if (row.sold && row.realizedPnl != null) {
const n = parseFloat(row.realizedPnl)
const prefix = n >= 0 ? '+' : ''
return `${prefix}${formatUSDC(row.realizedPnl)} USDC`
}
if (row.filled && !row.sold) return t('sportsTailStrategy.list.pending')
return '-'
}
const columns = [
{
title: t('sportsTailStrategy.list.triggerPrice'),
dataIndex: 'triggerPrice',
key: 'triggerPrice',
render: (v: string) => `>= ${formatUSDC(v)}`
},
{
title: t('sportsTailStrategy.list.amount'),
key: 'amount',
render: (_: unknown, row: SportsTailStrategyDto) => renderAmount(row)
},
{
title: t('sportsTailStrategy.list.takeProfitStopLoss'),
key: 'tpSl',
render: (_: unknown, row: SportsTailStrategyDto) => renderTakeProfitStopLoss(row)
},
{
title: t('sportsTailStrategy.list.filledPrice'),
key: 'filled',
render: (_: unknown, row: SportsTailStrategyDto) => renderFilledOrRealtime(row)
},
{
title: t('sportsTailStrategy.list.pnl'),
key: 'pnl',
render: (_: unknown, row: SportsTailStrategyDto) => renderPnl(row)
},
{
title: t('common.actions'),
key: 'actions',
render: (_: unknown, row: SportsTailStrategyDto) => (
<Space>
<Button type="link" size="small" onClick={openRecordsDrawer}>
{t('sportsTailStrategy.list.viewRecords')}
</Button>
<Popconfirm
title={t('sportsTailStrategy.list.deleteConfirm')}
onConfirm={() => handleDelete(row.id)}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
>
<Button type="link" danger size="small" icon={<DeleteOutlined />}>
{t('sportsTailStrategy.list.delete')}
</Button>
</Popconfirm>
</Space>
)
}
]
const recordColumns = [
{
title: t('sportsTailStrategy.records.time'),
dataIndex: 'triggeredAt',
key: 'triggeredAt',
render: (v: number) => dayjs(v).format('MM-DD HH:mm')
},
{
title: t('sportsTailStrategy.records.market'),
dataIndex: 'marketTitle',
key: 'marketTitle'
},
{
title: t('sportsTailStrategy.records.direction'),
dataIndex: 'outcomeName',
key: 'outcomeName'
},
{
title: t('sportsTailStrategy.records.buyPrice'),
dataIndex: 'buyPrice',
key: 'buyPrice',
render: (v: string) => formatUSDC(v)
},
{
title: t('sportsTailStrategy.records.amount'),
dataIndex: 'buyAmount',
key: 'buyAmount',
render: (v: string) => formatUSDC(v)
},
{
title: t('sportsTailStrategy.records.sellPrice'),
dataIndex: 'sellPrice',
key: 'sellPrice',
render: (v: string | null) => (v != null ? formatUSDC(v) : '-')
},
{
title: t('sportsTailStrategy.records.pnl'),
dataIndex: 'realizedPnl',
key: 'realizedPnl',
render: (v: string | null) => {
if (v == null) return t('sportsTailStrategy.records.pending')
const n = parseFloat(v)
const prefix = n >= 0 ? '+' : ''
return `${prefix}${formatUSDC(v)} USDC`
}
}
]
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', flexWrap: 'wrap', gap: 12, alignItems: 'center' }}>
<Typography.Title level={4} style={{ margin: 0 }}>
{t('sportsTailStrategy.list.title')}
</Typography.Title>
<Button type="primary" icon={<PlusOutlined />} onClick={openAddModal}>
{t('sportsTailStrategy.list.addStrategy')}
</Button>
<Space>
<Select
placeholder={t('sportsTailStrategy.list.filter.account')}
allowClear
style={{ minWidth: 140 }}
value={filters.accountId}
onChange={(v) => setFilters((prev) => ({ ...prev, accountId: v }))}
options={accounts.map((a) => ({ label: a.accountName || a.proxyAddress?.slice(0, 8) + '...', value: a.id }))}
/>
<Select
placeholder={t('sportsTailStrategy.list.filter.category')}
allowClear
style={{ minWidth: 120 }}
value={filters.sport}
onChange={(v) => setFilters((prev) => ({ ...prev, sport: v }))}
options={[{ label: t('sportsTailStrategy.list.filter.allCategory'), value: undefined }, ...sportsList.map((s) => ({ label: s.name || s.sport, value: s.sport }))]}
/>
</Space>
</div>
<Spin spinning={loading}>
{isMobile ? (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
{list.length === 0 ? (
<Empty description={t('common.noData')} />
) : (
list.map((row) => (
<Card key={row.id} size="small" title={row.marketTitle}>
<Row gutter={[8, 8]}>
<Col span={24}>{t('sportsTailStrategy.list.filter.account')}: {row.accountName}</Col>
<Col span={24}>{t('sportsTailStrategy.list.triggerPrice')}: &gt;= {formatUSDC(row.triggerPrice)} | {t('sportsTailStrategy.list.amount')}: {renderAmount(row)}</Col>
<Col span={24}>{t('sportsTailStrategy.list.takeProfitStopLoss')}: {renderTakeProfitStopLoss(row)}</Col>
<Col span={24}>{renderFilledOrRealtime(row)}</Col>
<Col span={24}>{t('sportsTailStrategy.list.pnl')}: {renderPnl(row)}</Col>
<Col span={24}>
<Space>
<Button type="link" size="small" onClick={openRecordsDrawer}>{t('sportsTailStrategy.list.viewRecords')}</Button>
<Popconfirm
title={t('sportsTailStrategy.list.deleteConfirm')}
onConfirm={() => handleDelete(row.id)}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
>
<Button type="link" danger size="small">{t('sportsTailStrategy.list.delete')}</Button>
</Popconfirm>
</Space>
</Col>
</Row>
</Card>
))
)}
</Space>
) : (
<Table
rowKey="id"
dataSource={list}
columns={[
{
title: t('sportsTailStrategy.records.market'),
dataIndex: 'marketTitle',
key: 'marketTitle',
ellipsis: true,
render: (text: string, row: SportsTailStrategyDto) => {
const url = row.eventSlug ? `${POLYMARKET_BASE}${row.eventSlug}` : null
if (url) {
return <a href={url} target="_blank" rel="noopener noreferrer">{text}</a>
}
return text
}
},
{
title: t('sportsTailStrategy.list.filter.account'),
dataIndex: 'accountName',
key: 'accountName',
width: 100
},
...columns
]}
pagination={false}
locale={{ emptyText: t('common.noData') }}
/>
)}
</Spin>
<Modal
title={t('sportsTailStrategy.form.title')}
open={formModalOpen}
onCancel={() => setFormModalOpen(false)}
onOk={handleFormSubmit}
width={isMobile ? '100%' : 560}
destroyOnClose
>
<Form form={form} layout="vertical" preserve={false}>
<Form.Item name="accountId" label={t('sportsTailStrategy.form.account')} rules={[{ required: true }]}>
<Select
placeholder={t('sportsTailStrategy.form.selectAccount')}
options={accounts.map((a) => ({ label: a.accountName || a.proxyAddress, value: a.id }))}
/>
</Form.Item>
<Form.Item label={t('sportsTailStrategy.form.selectMarket')} required>
<Space direction="vertical" style={{ width: '100%' }}>
<Space wrap>
<Select
placeholder={t('sportsTailStrategy.marketSearch.sport')}
allowClear
style={{ minWidth: 120 }}
value={marketSearchFilters.sport}
onChange={(v) => setMarketSearchFilters((prev) => ({ ...prev, sport: v }))}
options={[{ label: t('sportsTailStrategy.marketSearch.all'), value: undefined }, ...sportsList.map((s) => ({ label: s.name || s.sport, value: s.sport }))]}
/>
<Input
placeholder={t('sportsTailStrategy.marketSearch.keyword')}
style={{ width: 160 }}
value={marketSearchFilters.keyword}
onChange={(e) => setMarketSearchFilters((prev) => ({ ...prev, keyword: e.target.value }))}
/>
<Button onClick={fetchMarketSearch} loading={marketSearchLoading}>{t('sportsTailStrategy.marketSearch.search')}</Button>
</Space>
<div style={{ maxHeight: 200, overflow: 'auto', border: '1px solid #d9d9d9', borderRadius: 6, padding: 8 }}>
{marketSearchLoading ? (
<div style={{ textAlign: 'center', padding: 16 }}><Spin /></div>
) : marketSearchResult.length === 0 ? (
<Empty description={t('common.noData')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<Radio.Group
style={{ width: '100%' }}
onChange={(e) => {
const c = e.target.value as SportsMarketDto
form.setFieldsValue({
conditionId: c.conditionId,
marketTitle: c.question,
eventSlug: c.eventSlug ?? undefined
})
}}
>
<Space direction="vertical" style={{ width: '100%' }}>
{marketSearchResult.map((m) => (
<Radio key={m.conditionId} value={m}>
<div>
<div>{m.question}</div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{m.outcomes?.[0]}: {m.outcomePrices?.[0] ?? '-'} | {m.outcomes?.[1]}: {m.outcomePrices?.[1] ?? '-'} | {t('sportsTailStrategy.marketSearch.liquidity')}: {formatUSDC(m.liquidity)} USDC
</Typography.Text>
</div>
</Radio>
))}
</Space>
</Radio.Group>
)}
</div>
</Space>
</Form.Item>
<Form.Item name="conditionId" hidden rules={[{ required: true, message: t('sportsTailStrategy.form.selectMarket') }]}>
<Input />
</Form.Item>
<Form.Item name="marketTitle" hidden><Input /></Form.Item>
<Form.Item name="eventSlug" hidden><Input /></Form.Item>
<Form.Item
name="triggerPrice"
label={t('sportsTailStrategy.form.triggerCondition')}
rules={[{ required: true }, { type: 'number', min: 0.01, max: 1 }]}
extra={t('sportsTailStrategy.form.triggerPriceHelp')}
>
<InputNumber min={0.01} max={1} step={0.01} style={{ width: '100%' }} placeholder="0.90" />
</Form.Item>
<Form.Item name="amountMode" label={t('sportsTailStrategy.form.amount')} rules={[{ required: true }]}>
<Radio.Group>
<Radio value="FIXED">{t('sportsTailStrategy.form.fixedAmount')} (USDC)</Radio>
<Radio value="RATIO">{t('sportsTailStrategy.form.ratio')} (%)</Radio>
</Radio.Group>
</Form.Item>
<Form.Item
name="amountValue"
rules={[{ required: true }]}
noStyle
>
<InputNumber min={0.01} step={0.1} style={{ width: 160 }} placeholder="10" />
</Form.Item>
<Form.Item name="takeProfitPrice" label={t('sportsTailStrategy.form.takeProfitPrice')} extra={t('sportsTailStrategy.form.takeProfitHelp')}>
<InputNumber min={0} max={1} step={0.01} style={{ width: '100%' }} placeholder="0.98" />
</Form.Item>
<Form.Item name="stopLossPrice" label={t('sportsTailStrategy.form.stopLossPrice')} extra={t('sportsTailStrategy.form.stopLossHelp')}>
<InputNumber min={0} max={1} step={0.01} style={{ width: '100%' }} placeholder="0.85" />
</Form.Item>
</Form>
</Modal>
<Drawer
title={t('sportsTailStrategy.records.title')}
open={recordsDrawerOpen}
onClose={() => setRecordsDrawerOpen(false)}
width={isMobile ? '100%' : 720}
>
<Space direction="vertical" style={{ width: '100%', marginBottom: 16 }}>
<Select
placeholder={t('sportsTailStrategy.list.filter.account')}
allowClear
style={{ minWidth: 160 }}
value={recordsFilters.accountId}
onChange={(v) => setRecordsFilters((prev) => ({ ...prev, accountId: v }))}
options={accounts.map((a) => ({ label: a.accountName || a.proxyAddress?.slice(0, 8) + '...', value: a.id }))}
/>
<Button onClick={() => fetchRecords(1)} loading={recordsLoading}>{t('common.refresh')}</Button>
</Space>
<Table
rowKey="id"
dataSource={records}
columns={recordColumns}
loading={recordsLoading}
pagination={{
current: recordsPage,
pageSize: recordsPageSize,
total: recordsTotal,
showSizeChanger: false,
onChange: (p) => fetchRecords(p)
}}
size="small"
locale={{ emptyText: t('common.noData') }}
/>
</Drawer>
</div>
)
}
export default SportsTailStrategyList
+20
View File
@@ -514,6 +514,26 @@ export const apiService = {
apiClient.post<ApiResponse<import('../types').CryptoTailManualOrderResponse>>('/crypto-tail-strategy/manual-order', data)
},
/**
* API
*/
sportsTailStrategy: {
list: (data: { accountId?: number; sport?: string } = {}) =>
apiClient.post<ApiResponse<import('../types').SportsTailStrategyListResponse>>('/sports-tail-strategy/list', data),
create: (data: import('../types').SportsTailStrategyCreateRequest) =>
apiClient.post<ApiResponse<{ id: number }>>('/sports-tail-strategy/create', data),
delete: (data: { id: number }) =>
apiClient.post<ApiResponse<void>>('/sports-tail-strategy/delete', data),
triggers: (data: import('../types').SportsTailTriggerListRequest) =>
apiClient.post<ApiResponse<import('../types').SportsTailTriggerListResponse>>('/sports-tail-strategy/triggers', data),
sportsList: () =>
apiClient.post<ApiResponse<{ list: import('../types').SportsCategoryDto[] }>>('/sports-tail-strategy/sports-list', {}),
marketSearch: (data: import('../types').SportsMarketSearchRequest) =>
apiClient.post<ApiResponse<{ list: import('../types').SportsMarketDto[] }>>('/sports-tail-strategy/market-search', data),
marketDetail: (data: { conditionId: string }) =>
apiClient.post<ApiResponse<import('../types').SportsMarketDto>>('/sports-tail-strategy/market-detail', data)
},
/**
* API
*/
+122
View File
@@ -1261,6 +1261,128 @@ export interface ManualOrderDetails {
totalAmount: string
}
// ==================== 体育尾盘策略相关类型 ====================
/** 体育尾盘策略 DTO */
export interface SportsTailStrategyDto {
id: number
accountId: number
accountName: string
conditionId: string
marketTitle: string
eventSlug: string | null
triggerPrice: string
amountMode: 'FIXED' | 'RATIO'
amountValue: string
takeProfitPrice: string | null
stopLossPrice: string | null
filled: boolean
filledPrice: string | null
filledOutcomeIndex: number | null
filledOutcomeName: string | null
filledAmount: string | null
filledShares: string | null
filledAt: number | null
sold: boolean
sellPrice: string | null
sellType: string | null
sellAmount: string | null
realizedPnl: string | null
soldAt: number | null
realtimeYesPrice: string | null
realtimeNoPrice: string | null
createdAt: number
updatedAt: number
}
/** 体育尾盘策略创建请求 */
export interface SportsTailStrategyCreateRequest {
accountId: number
conditionId: string
marketTitle: string
eventSlug?: string
triggerPrice: string
amountMode: 'FIXED' | 'RATIO'
amountValue: string
takeProfitPrice?: string
stopLossPrice?: string
}
/** 体育尾盘策略列表响应 */
export interface SportsTailStrategyListResponse {
list: SportsTailStrategyDto[]
}
/** 体育尾盘策略触发记录 DTO */
export interface SportsTailTriggerDto {
id: number
strategyId: number
marketTitle: string
conditionId: string
buyPrice: string
outcomeIndex: number
outcomeName: string | null
buyAmount: string
buyShares: string | null
buyStatus: string
sellPrice: string | null
sellType: string | null
sellAmount: string | null
sellStatus: string | null
realizedPnl: string | null
triggeredAt: number
soldAt: number | null
}
/** 体育尾盘策略触发记录列表请求 */
export interface SportsTailTriggerListRequest {
accountId?: number
status?: string
startTime?: number
endTime?: number
page?: number
pageSize?: number
}
/** 体育尾盘策略触发记录列表响应 */
export interface SportsTailTriggerListResponse {
total: number
list: SportsTailTriggerDto[]
}
/** 体育类别 DTO */
export interface SportsCategoryDto {
sport: string
image: string
tagId: number
name: string
}
/** 体育市场 DTO */
export interface SportsMarketDto {
conditionId: string
question: string
outcomes: string[]
outcomePrices: string[]
endDate: string
liquidity: string
bestBid: number | null
bestAsk: number | null
yesTokenId: string
noTokenId: string
eventSlug?: string | null
}
/** 体育市场搜索请求 */
export interface SportsMarketSearchRequest {
sport?: string
endDateMin?: string
endDateMax?: string
minLiquidity?: string
keyword?: string
limit?: number
}
// ==================== 消息模板相关类型 ====================
/**