Compare commits

...

2 Commits

Author SHA1 Message Date
WrBug 2dee65900e feat(sports-tail): 体育尾盘策略与 createClient lazy 初始化
- 体育尾盘策略:实体/Repository/Service/Controller/执行服务/订单簿 WS
- 迁移 V41:体育尾盘策略表
- API:PolymarketGammaSportsApi,RetrofitFactory 去重
- 前端:体育尾盘策略列表页、路由、API、多语言
- ErrorCode 与 i18n 消息
- createClient 调用改为 lazy:SportsTailOrderbookWsService、CryptoTailOrderbookWsService、TelegramNotificationService

Made-with: Cursor
2026-03-07 04:33:42 +08:00
WrBug e20d47ba4c docs: 新增体育尾盘策略文档
- 新增 docs/sports-tail-strategy/ 目录
- 包含 README.md 目录说明
- 包含 zh/ 下的中文文档:
  - sports-tail-strategy-tasks.md (任务与验收)
  - sports-tail-strategy-ui-spec.md (UI 规格)
  - sports-tail-strategy-flow.md (流程说明)
  - sports-tail-strategy-market-data.md (市场数据与订阅)
  - sports-tail-strategy-api.md (后端 API 定义)

Made-with: Cursor
2026-03-07 03:16:36 +08:00
33 changed files with 4346 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=賣出執行失敗
+41
View File
@@ -0,0 +1,41 @@
# 体育尾盘策略文档 (Sports Tail Strategy)
本目录集中存放与 Polymarket 体育市场尾盘策略相关的文档。
## 目录结构
```
sports-tail-strategy/
├── README.md # 本说明
└── zh/ # 中文文档
├── sports-tail-strategy-tasks.md # 任务与验收
├── sports-tail-strategy-ui-spec.md # UI 规格
├── sports-tail-strategy-flow.md # 流程说明
└── sports-tail-strategy-market-data.md # 市场数据与订阅
```
## 文档说明
| 文档 | 说明 |
|------|------|
| **tasks** (zh) | 开发任务与验收项 |
| **ui-spec** (zh) | 前端列表、表单、触发记录等 UI 规格 |
| **flow** (zh) | 策略整体流程(创建→触发→止盈止损→完成) |
| **market-data** (zh) | Gamma API 数据获取、WebSocket 订阅、价格监控 |
## 功能概述
体育尾盘策略用于在体育市场接近尾盘(胜率 90%+)时自动买入,利用高胜率市场低风险获利。
### 核心特性
1. **不区分方向**:只设置触发价格,系统自动监控两个方向,任意方向达到触发价即买入
2. **实时订阅**:通过 WebSocket 订阅订单簿,实时监控价格变化
3. **止盈止损**:支持设置止盈/止损价格,自动卖出
4. **订阅管理**:同一市场多策略共享订阅,无策略时自动取消订阅
### 适用场景
- 体育比赛接近尾声,一方胜率 90%+ 时买入
- 大小分市场接近尾盘时套利
- 低风险稳定收益场景
@@ -0,0 +1,356 @@
# 体育尾盘策略 - API 设计
## 一、后端 API
### 1.1 策略管理
#### 列表
```
POST /api/sports-tail-strategy/list
```
**请求**
```typescript
interface StrategyListRequest {
accountId?: number; // 筛选账户
sport?: string; // 筛选类别
}
```
**响应**
```typescript
interface StrategyListResponse {
list: StrategyDto[];
}
interface StrategyDto {
id: number;
accountId: number;
accountName: string;
conditionId: string;
marketTitle: string;
eventSlug: string;
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;
}
```
#### 创建
```
POST /api/sports-tail-strategy/create
```
**请求**
```typescript
interface StrategyCreateRequest {
accountId: number; // 账户ID
conditionId: string; // 市场ID
marketTitle: string; // 市场标题
eventSlug?: string; // 事件slug
triggerPrice: string; // 触发价格
amountMode: "FIXED" | "RATIO";
amountValue: string; // 金额值
takeProfitPrice?: string; // 止盈价格
stopLossPrice?: string; // 止损价格
}
```
**响应**
```typescript
interface StrategyCreateResponse {
id: number;
}
```
#### 删除
```
POST /api/sports-tail-strategy/delete
```
**请求**
```typescript
interface StrategyDeleteRequest {
id: number;
}
```
**响应**
```typescript
interface StrategyDeleteResponse {
success: boolean;
}
```
---
### 1.2 市场数据
#### 体育类别列表
```
POST /api/sports-tail-strategy/sports-list
```
**响应**
```typescript
interface SportsListResponse {
list: SportDto[];
}
interface SportDto {
sport: string; // 类别标识:nba, nfl, epl...
image: string; // 图标URL
tagId: number; // 主Tag ID
name: string; // 显示名称(多语言)
}
```
#### 市场搜索
```
POST /api/sports-tail-strategy/market-search
```
**请求**
```typescript
interface MarketSearchRequest {
sport?: string; // 体育类别
endDateMin?: string; // 最小结束时间 ISO 8601
endDateMax?: string; // 最大结束时间 ISO 8601
minLiquidity?: string; // 最小流动性
keyword?: string; // 搜索关键词
limit?: number; // 返回数量,默认50
}
```
**响应**
```typescript
interface MarketSearchResponse {
list: MarketDto[];
}
interface MarketDto {
conditionId: string;
question: string;
outcomes: string[]; // ["Yes", "No"] 或 ["Over", "Under"]
outcomePrices: string[]; // 当前价格
endDate: string; // 结束时间 ISO 8601
liquidity: string; // 流动性
bestBid: number | null;
bestAsk: number | null;
yesTokenId: string;
noTokenId: string;
}
```
#### 市场详情
```
POST /api/sports-tail-strategy/market-detail
```
**请求**
```typescript
interface MarketDetailRequest {
conditionId: string;
}
```
**响应**
```typescript
interface MarketDetailResponse {
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;
}
```
---
### 1.3 触发记录
#### 全局记录列表
```
POST /api/sports-tail-strategy/triggers
```
**请求**
```typescript
interface TriggerListRequest {
accountId?: number; // 筛选账户
status?: string; // 筛选状态: SUCCESS/FAIL
startTime?: number; // 开始时间戳
endTime?: number; // 结束时间戳
page?: number; // 页码,默认1
pageSize?: number; // 每页数量,默认20
}
```
**响应**
```typescript
interface TriggerListResponse {
total: number;
list: TriggerDto[];
}
interface TriggerDto {
id: number;
strategyId: number;
// 市场信息
marketTitle: string;
conditionId: string;
// 买入信息
buyPrice: string;
outcomeIndex: number;
outcomeName: string | null;
buyAmount: string;
buyShares: string | null;
buyStatus: "PENDING" | "SUCCESS" | "FAIL";
// 卖出信息
sellPrice: string | null;
sellType: string | null; // TAKE_PROFIT/STOP_LOSS/MANUAL
sellAmount: string | null;
sellStatus: string | null;
// 盈亏
realizedPnl: string | null;
// 时间
triggeredAt: number;
soldAt: number | null;
}
```
---
## 二、前端 API 封装
### 2.1 apiService 方法
```typescript
// 策略管理
sportsTailStrategyList(params: StrategyListRequest): Promise<StrategyListResponse>
sportsTailStrategyCreate(data: StrategyCreateRequest): Promise<StrategyCreateResponse>
sportsTailStrategyDelete(id: number): Promise<StrategyDeleteResponse>
// 市场数据
sportsTailStrategySportsList(): Promise<SportsListResponse>
sportsTailStrategyMarketSearch(params: MarketSearchRequest): Promise<MarketSearchResponse>
sportsTailStrategyMarketDetail(conditionId: string): Promise<MarketDetailResponse>
// 触发记录
sportsTailStrategyTriggers(params: TriggerListRequest): Promise<TriggerListResponse>
```
---
## 三、多语言 Key
### 3.1 页面标题
```
sportsTailStrategy.list.title=体育尾盘策略
sportsTailStrategy.list.addStrategy=新增策略
sportsTailStrategy.list.filter.account=账户
sportsTailStrategy.list.filter.sport=类别
sportsTailStrategy.list.filter.all=全部
```
### 3.2 表单字段
```
sportsTailStrategy.form.account=账户
sportsTailStrategy.form.market=市场
sportsTailStrategy.form.triggerPrice=触发价格
sportsTailStrategy.form.amount=金额
sportsTailStrategy.form.amountMode=金额模式
sportsTailStrategy.form.fixed=固定金额
sportsTailStrategy.form.ratio=余额比例
sportsTailStrategy.form.takeProfit=止盈价格
sportsTailStrategy.form.stopLoss=止损价格
sportsTailStrategy.form.autoSell=自动卖出
```
### 3.3 列表字段
```
sportsTailStrategy.list.triggerPrice=触发价
sportsTailStrategy.list.amount=金额
sportsTailStrategy.list.takeProfitStopLoss=止盈/止损
sportsTailStrategy.list.filledPrice=成交价
sportsTailStrategy.list.shares=份
sportsTailStrategy.list.pnl=盈亏
sportsTailStrategy.list.realtimePrice=实时价格
sportsTailStrategy.list.pending=待结算
sportsTailStrategy.list.viewRecords=查看记录
sportsTailStrategy.list.delete=删除
```
### 3.4 市场筛选
```
sportsTailStrategy.market.filter.sport=类别
sportsTailStrategy.market.filter.allSports=全部类别
sportsTailStrategy.market.filter.endTime=结束时间
sportsTailStrategy.market.filter.today=今天
sportsTailStrategy.market.filter.next24h=未来24小时
sportsTailStrategy.market.filter.next7days=未来7天
sportsTailStrategy.market.filter.minLiquidity=最小流动性
sportsTailStrategy.market.filter.keyword=关键词
sportsTailStrategy.market.filter.search=搜索
sportsTailStrategy.market.select=选择市场
```
### 3.5 触发记录
```
sportsTailStrategy.records.title=触发记录
sportsTailStrategy.records.market=市场
sportsTailStrategy.records.direction=方向
sportsTailStrategy.records.buyPrice=买入价
sportsTailStrategy.records.buyAmount=买入金额
sportsTailStrategy.records.sellPrice=卖出价
sportsTailStrategy.records.sellType=卖出类型
sportsTailStrategy.records.pnl=盈亏
sportsTailStrategy.records.time=时间
sportsTailStrategy.records.status=状态
```
### 3.6 消息提示
```
sportsTailStrategy.message.createSuccess=策略创建成功
sportsTailStrategy.message.deleteSuccess=策略删除成功
sportsTailStrategy.message.deleteConfirm=确定删除该策略吗?
sportsTailStrategy.message.noMarketSelected=请选择市场
sportsTailStrategy.message.invalidPrice=价格格式无效
```
@@ -0,0 +1,262 @@
# 体育尾盘策略 - 流程说明
## 一、策略状态流转
```
┌─────────────┐ 价格>=触发价 ┌─────────────┐
│ 待触发 │ ──────────────────▶ │ 已成交 │
│ (filled=F) │ │ (filled=T) │
└─────────────┘ └─────────────┘
│ │
│ ┌─────────────────┼─────────────────┐
│ │ │ │
▼ │ 有止盈止损 │ 无止盈止损 │
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────────────────────────────┐
│ 保持订阅 │ │ 检查同市场是否有其他未完成策略 │
│ 监控卖出 │ │ - 有: 保持订阅 │
└─────────────┘ │ - 无: 取消订阅 │
│ └─────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ 价格 >= 止盈价 │
│ 或 价格 <= 止损价 │
└──────────────────────────────────────────────┘
┌─────────────┐
│ 自动卖出 │
│ (sold=T) │
└─────────────┘
```
---
## 二、订阅生命周期
### 2.1 策略创建时
```
策略创建
┌─────────────────────────────────────────────────────────────┐
│ SubscriptionManager.subscribeStrategy(strategy) │
│ 1. 检查市场是否已有订阅(marketSubscriptions 计数) │
│ 2. 如果市场已有订阅:仅增加计数,不新建连接 │
│ 3. 如果市场无订阅:建立 WebSocket 连接,订阅订单簿频道 │
└─────────────────────────────────────────────────────────────┘
```
### 2.2 实时监控
```
WebSocket 订单簿推送
┌─────────────────────────────────────────────────────────────┐
│ SportsTailWebSocketHandler.onOrderbookUpdate() │
│ 1. 解析消息,获取当前价格 │
│ 2. 查找该 Token ID 对应的所有未完成策略 │
│ 3. 遍历策略,检查触发条件: │
│ - 未成交:检查买入条件(价格 >= 触发价) │
│ - 已成交未卖出:检查卖出条件(止盈/止损) │
└─────────────────────────────────────────────────────────────┘
```
### 2.3 成交后处理
```
策略成交
┌─────────────────────────────────────────────────────────────┐
│ SubscriptionManager.onStrategyFilled(strategy) │
│ 1. 更新策略状态为已成交 │
│ 2. 检查是否需要保持订阅: │
│ - 有止盈止损:保持订阅,继续监控卖出条件 │
│ - 无止盈止损:检查同市场是否有其他未完成策略 │
│ - 有:保持订阅 │
│ - 无:取消订阅,关闭 WebSocket 连接 │
└─────────────────────────────────────────────────────────────┘
```
### 2.4 卖出后处理
```
策略卖出
┌─────────────────────────────────────────────────────────────┐
│ SubscriptionManager.onStrategySold(strategy) │
│ 1. 更新策略状态为已卖出 │
│ 2. 计算并记录盈亏 │
│ 3. 检查同市场是否有其他未完成策略: │
│ - 有:保持订阅 │
│ - 无:取消订阅,关闭 WebSocket 连接 │
└─────────────────────────────────────────────────────────────┘
```
### 2.5 策略删除时
```
策略删除
┌─────────────────────────────────────────────────────────────┐
│ SubscriptionManager.unsubscribeStrategy(strategy) │
│ 1. 减少市场的订阅计数 │
│ 2. 如果计数归零:取消订阅,关闭 WebSocket 连接 │
└─────────────────────────────────────────────────────────────┘
```
---
## 三、买入逻辑(不区分方向)
### 3.1 触发条件检查
```kotlin
fun checkBuyTrigger(strategy: SportsTailStrategy, yesPrice: BigDecimal, noPrice: BigDecimal): BuyTrigger? {
// 如果已成交,不检查
if (strategy.filled) return null
// 检查 YES 方向
if (yesPrice >= strategy.triggerPrice) {
return BuyTrigger(outcomeIndex = 0, price = yesPrice)
}
// 检查 NO 方向
if (noPrice >= strategy.triggerPrice) {
return BuyTrigger(outcomeIndex = 1, price = noPrice)
}
// 都不满足
return null
}
```
### 3.2 执行买入
```kotlin
suspend fun executeBuy(strategy: SportsTailStrategy, trigger: BuyTrigger) {
// 1. 创建市价单
val order = createMarketOrder(
tokenId = getTokenId(strategy.conditionId, trigger.outcomeIndex),
side = "BUY",
amount = calculateAmount(strategy)
)
// 2. 提交订单
val result = clobApi.createOrder(order)
// 3. 更新策略状态
strategy.filled = true
strategy.filledPrice = trigger.price
strategy.filledOutcomeIndex = trigger.outcomeIndex
strategy.filledAmount = order.amount
strategy.filledShares = calculateShares(order.amount, trigger.price)
strategy.filledAt = System.currentTimeMillis()
strategyRepository.save(strategy)
// 4. 记录触发记录
createTriggerRecord(strategy, trigger, result)
// 5. 通知订阅管理器
subscriptionManager.onStrategyFilled(strategy)
}
```
---
## 四、卖出逻辑(止盈止损)
### 4.1 止盈止损检查
```kotlin
fun checkSellTrigger(strategy: SportsTailStrategy, currentPrice: BigDecimal): SellTrigger? {
// 如果已卖出或未成交,不检查
if (strategy.sold || !strategy.filled) return null
// 只检查已买入方向的价格
val filledTokenId = getTokenId(strategy.conditionId, strategy.filledOutcomeIndex)
if (currentTokenId != filledTokenId) return null
// 检查止盈
if (strategy.takeProfitPrice != null && currentPrice >= strategy.takeProfitPrice) {
return SellTrigger(type = "TAKE_PROFIT", price = currentPrice)
}
// 检查止损
if (strategy.stopLossPrice != null && currentPrice <= strategy.stopLossPrice) {
return SellTrigger(type = "STOP_LOSS", price = currentPrice)
}
return null
}
```
### 4.2 执行卖出
```kotlin
suspend fun executeSell(strategy: SportsTailStrategy, trigger: SellTrigger) {
// 1. 创建市价单
val order = createMarketOrder(
tokenId = getTokenId(strategy.conditionId, strategy.filledOutcomeIndex),
side = "SELL",
shares = strategy.filledShares
)
// 2. 提交订单
val result = clobApi.createOrder(order)
// 3. 计算盈亏
val sellAmount = calculateSellAmount(trigger.price, strategy.filledShares)
val pnl = sellAmount - strategy.filledAmount
// 4. 更新策略状态
strategy.sold = true
strategy.sellPrice = trigger.price
strategy.sellType = trigger.type
strategy.sellAmount = sellAmount
strategy.realizedPnl = pnl
strategy.soldAt = System.currentTimeMillis()
strategyRepository.save(strategy)
// 5. 更新触发记录
updateTriggerRecord(strategy, trigger, result, pnl)
// 6. 通知订阅管理器
subscriptionManager.onStrategySold(strategy)
}
```
---
## 五、关键设计要点
### 5.1 订阅共享
- 同一市场(conditionId)多个策略共享一个 WebSocket 订阅
- 使用计数器管理订阅生命周期
- 避免重复连接和资源浪费
### 5.2 不区分方向
- 只设置触发价格,不选择 YES/NO
- 系统自动监控两个方向的价格
- 任意方向满足条件即买入该方向
### 5.3 实时订阅
- 使用 WebSocket 订阅订单簿,实时接收价格变化
- 不使用轮询方式
- 响应速度快,延迟低
### 5.4 智能取消
- 无策略时取消订阅
- 策略完成且无止盈止损时检查是否需要取消
- 有止盈止损时保持订阅直到卖出
@@ -0,0 +1,215 @@
# 体育尾盘策略 - 市场数据与订阅
> 本文档描述体育市场数据获取方式、 WebSocket 订阅管理逻辑。
## 一、Gamma API 数据获取
> 本文档描述如何从 Gamma API 获取体育市场数据。### 1.1 萜索条件
### 1.1 获取体育类别列表
> **前端选择体育类别时,需要获取可选的体育类别列表供用户选择。
```
GET https://gamma-api.polymarket.com/sports
```
> **返回示例**
```json
[
{"sport": "nba", "image": "https://...", "tags": "1,745,100639"},
{"sport": "nfl", "image": "https://...", "tags": "1,450,100639"},
{"sport": "epl", "image": "https://...", "tags": "1,82,306,100639"},
...
]
```
**主要类别**
| sport | 名称 | tag_id |
|-------|------|--------|
| nba | 篮球 NBA | 745 |
| nfl | 美式足球 NFL | 450 |
| epl | 英超 | 82 |
| lal | 西甲 | 780 |
| mlb | 棒球 MLB | 100381 |
| nhl | 冰球 NHL | 899 |
| ufc | 格斗 UFC | 100639 |
### 1.2 按类别筛选市场
> 根据用户选择的体育类别,使用 `tag_id` 参数筛选市场。
```
GET https://gamma-api.polymarket.com/markets?tag_id=745&active=true&closed=false&limit=50&order=endDate&ascending=true
```
> **返回字段**
- `id` - 市场ID
- `question` - 市场问题
- `conditionId` - 市场 conditionId
- `outcomes` - 结果选项 `["Yes", "No"]``["Over", "Under"]`
- `outcomePrices` - 当前价格 `["0.92", "0.08"]`
- `endDate` - 结束时间
- `bestBid` - 最佳买价
- `bestAsk` - 最佳卖价
- `clobTokenIds` - Token IDs
- `gameStartTime` - 比赛开始时间
- `sportsMarketType` - 市场类型
- `liquidityNum` - 流动性
- `volumeNum` - 成交量
- `events` - 关联事件信息
```
> **请求参数**
```kotlin
data class SportsMarketSearchRequest(
val sport: String? = null, // 体育类别: nba, nfl, epl...
val marketType: String? = null, // 市场类型: moneyline, spreads, totals
val endDateMin: String? = null, // 最小结束时间
val endDateMax: String? = null, // 最大结束时间
val minLiquidity: BigDecimal? = null, // 最小流动性
val keyword: String? = null, // 搜索关键词
val limit: Int = 50 // 返回数量
)
```
> **marketType 说明**
- `moneyline` - 胜负市场(谁会赢)
- `spreads` - 让分市场
- `totals` - 大小分市场
### 1.3 获取单个市场详情
```
GET https://gamma-api.polymarket.com/markets?condition_ids={conditionId}
```
> **用于**
- 创建策略时获取 `clobTokenIds`
- 监控价格时获取实时价格
- 获取 Token ID 用于下单
---
## 二、WebSocket 订阅管理
### 2.1 订阅策略
> 同一市场可能有多个策略,但只维护一个 WebSocket 订阅。
> 策略创建/删除时需要更新订阅计数。
```kotlin
// 市场订阅计数
private val marketSubscriptions = ConcurrentHashMap<String, Int>()
// 市场对应的 Token IDs 缓存
private val marketTokenIds = ConcurrentHashMap<String, Pair<String, String>>()
/**
* 订阅策略(创建策略时调用)
*/
fun subscribeStrategy(strategy: SportsTailStrategy) {
val conditionId = strategy.conditionId
marketSubscriptions.compute(conditionId) { _, count ->
val newCount = (count ?: 0) + 1
if (newCount == 1) {
// 首次订阅,建立 WebSocket 连接
subscribeMarket(conditionId)
}
newCount
}
}
```
> **订阅管理规则**
| 场景 | 操作 |
|------|------|
| 策略创建 | 如果市场无订阅,建立订阅;否则计数 +1 |
| 策略删除 | 计数 -1;如果计数为 0,取消订阅 |
| 策略成交(无止盈止损) | 检查同市场是否有其他未完成策略;无则取消订阅 |
| 策略卖出 | 检查同市场是否有其他未完成策略;无则取消订阅 |
### 2.2 讣阅流程
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 策略创建 │────▶│ 检查订阅计数 │────▶│ 首次? 建立连接 │
└─────────────────┘ └─────────────────┘ └─────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ WebSocket 接收订单簿更新 │
│ 1. 解析消息,获取 tokenId 和价格 │
│ 2. 查找该 Token 对应的未完成策略 │
│ 3. 检查触发条件 │
│ 4. 满足条件则执行买入/卖出 │
└─────────────────────────────────────────────────────────────┘
```
> **WebSocket 消息格式**
```json
{
"event_type": "book",
"asset_id": "1234567890",
"market": {
"bids": [...],
"asks": [...],
"timestamp": 1234567890
}
}
```
---
## 三、价格监控与触发
### 3.1 买入触发逻辑
> 任意方向价格达到触发价即买入
```kotlin
fun checkBuyTrigger(
strategy: SportsTailStrategy,
yesPrice: BigDecimal,
noPrice: BigDecimal
): TriggerResult? {
// 检查 YES 方向
if (yesPrice >= strategy.triggerPrice) {
return TriggerResult(outcomeIndex = 0, price = yesPrice)
}
// 检查 NO 方向
if (noPrice >= strategy.triggerPrice) {
return TriggerResult(outcomeIndex = 1, price = noPrice)
}
return null
}
```
> **注意**:不区分方向,系统自动选择价格满足的方向买入。
### 3.2 止盈止损逻辑
> 已成交的策略,监控持仓方向的价格变化
```kotlin
fun checkSellTrigger(
strategy: SportsTailStrategy,
currentPrice: BigDecimal
): SellTrigger? {
if (!strategy.filled || strategy.sold) {
return null
}
// 止盈:当前价格 >= 止盈价
if (strategy.takeProfitPrice != null && currentPrice >= strategy.takeProfitPrice) {
return SellTrigger(type = "TAKE_PROFIT", price = currentPrice)
}
// 止损:当前价格 <= 止损价
if (strategy.stopLossPrice != null && currentPrice <= strategy.stopLossPrice) {
return SellTrigger(type = "STOP_LOSS", price = currentPrice)
}
return null
}
```
> **注意**:只监控已买入方向的价格,不是两个方向都监控。
### 3.3 订阅生命周期
```
┌─────────────┐ 价格>=触发价 ┌─────────────┐ 止盈/止损 ┌─────────────┐
│ 监控两个方向 │ ────────────────▶ │ 已成交 │ ────────────────▶ │ 已完成 │
└─────────────┘ └─────────────┘ └─────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────────────────┐
│ 成交后检查止盈止损 │
│ - 有止盈止损:保持订阅,只监控买入方向 │
│ - 无止盈止损:检查同市场是否有其他未完成策略,无则取消订阅 │
└─────────────────────────────────────────────────────────────────────────┘
```
@@ -0,0 +1,141 @@
# 体育尾盘策略 - 任务梳理
> 需求与 UI 见 `sports-tail-strategy-ui-spec.md`,市场数据与订阅见 `sports-tail-strategy-market-data.md`。
以下按**数据库 / 后端 / 前端**拆分为可执行任务,便于排期与验收。
---
## 一、数据库
| 序号 | 任务 | 说明 |
|------|------|------|
| D1 | 策略表 migration | 新建表 `sports_tail_strategy`,字段:id, account_id, condition_id, market_title, event_slug, trigger_price, amount_mode(FIXED/RATIO), amount_value, take_profit_price, stop_loss_price, filled(BOOL), filled_price, filled_outcome_index, filled_amount, filled_shares, filled_at, sold(BOOL), sell_price, sell_type, sell_amount, realized_pnl, sold_at, created_at, updated_at |
| D2 | 触发记录表 migration | 新建表 `sports_tail_strategy_trigger`,字段:id, strategy_id, market_title, condition_id, account_id, buy_order_id, buy_price, outcome_index, outcome_name, buy_amount, buy_shares, buy_status(PENDING/SUCCESS/FAIL), sell_order_id, sell_price, sell_type(TAKE_PROFIT/STOP_LOSS/MANUAL), sell_amount, sell_status, realized_pnl, triggered_at, sold_at |
---
## 二、后端(Kotlin
### 2.1 实体与 Repository
| 序号 | 任务 | 说明 |
|------|------|------|
| B1 | 策略实体 Entity | 对应 `sports_tail_strategy` 表;ID 用 `Long?`;时间用 `Long` 时间戳;金额用 `BigDecimal`;遵守 backend.mdc 实体规范 |
| B2 | 触发记录实体 Entity | 对应 `sports_tail_strategy_trigger` 表 |
| B3 | JpaRepository | 策略、触发记录的 Repository;按 conditionId、accountId、filled、sold 等查询 |
### 2.2 Gamma API 扩展
| 序号 | 任务 | 说明 |
|------|------|------|
| B4 | 体育类别 API | `GET /sports` 获取体育元数据(sport, tags, image |
| B5 | 市场搜索 API | `GET /markets` 支持 tag_id、sports_market_types、end_date_min/max、liquidity_num_min 等筛选参数 |
| B6 | 事件列表 API | `GET /events` 支持 tag_slug、active、live 等参数,获取比赛状态 |
### 2.3 订阅管理
| 序号 | 任务 | 说明 |
|------|------|------|
| B7 | 订阅管理器 SubscriptionManager | 维护市场订阅计数,同一市场多策略共享订阅;无策略时自动取消订阅 |
| B8 | WebSocket 订单簿订阅 | 订阅订单簿 `channel: "book:<token_id>"`,接收实时价格更新 |
| B9 | 订阅生命周期管理 | 策略创建时订阅,成交后检查是否需要保持(有止盈止损则保持),卖出后检查是否需要取消 |
### 2.4 策略执行核心逻辑
| 序号 | 任务 | 说明 |
|------|------|------|
| B10 | 买入触发判断 | 接收订单簿更新,检查未成交策略;当任意方向价格 >= triggerPrice 时买入该方向 |
| B11 | 买入执行 | 调用 CLOB API 创建市价买单;记录成交价格、数量、方向;更新策略状态为已成交 |
| B12 | 止盈判断 | 已成交策略,当前价格 >= takeProfitPrice 时执行卖出 |
| B13 | 止损判断 | 已成交策略,当前价格 <= stopLossPrice 时执行卖出 |
| B14 | 卖出执行 | 调用 CLOB API 创建市价卖单;计算盈亏;更新策略状态为已卖出 |
### 2.5 API 与 DTO
| 序号 | 任务 | 说明 |
|------|------|------|
| B15 | 策略 CRUD API | 列表(分页/筛选)、创建、删除;统一 ApiResponse;错误码与 MessageSource |
| B16 | 策略 DTO | 创建请求:accountId, conditionId, marketTitle, eventSlug, triggerPrice, amountMode, amountValue, takeProfitPrice(可选), stopLossPrice(可选) |
| B17 | 触发记录 API | 全局触发记录列表;支持 accountId、status、时间筛选;返回市场信息、成交价、数量、盈亏等 |
| B18 | 市场搜索 API | 体育类别列表、市场搜索(支持筛选)、市场详情(含实时价格) |
---
## 三、前端(React + TypeScript
### 3.1 路由与导航
| 序号 | 任务 | 说明 |
|------|------|------|
| F1 | 路由 | App.tsx 增加 `/sports-tail-strategy` |
| F2 | 菜单 | Layout 中增加「体育尾盘策略」菜单项 |
### 3.2 列表页
| 序号 | 任务 | 说明 |
|------|------|------|
| F3 | 列表页组件 | SportsTailStrategyList.tsx;页面标题、新增按钮、筛选(账户、类别) |
| F4 | 列表展示 | 桌面 Table / 移动 Card:市场标题、账户、触发价、金额、止盈止损、成交价/数量(未成交显示实时价格)、操作(查看记录、删除) |
| F5 | 实时价格显示 | 未成交策略通过 WebSocket 获取实时价格并显示 |
### 3.3 新增/编辑表单
| 序号 | 任务 | 说明 |
|------|------|------|
| F6 | 表单弹窗 | 账户选择、市场搜索(支持筛选)、触发价格、下注金额、止盈止损(可选) |
| F7 | 市场筛选器 | 体育类别、市场类型、结束时间、最小流动性、搜索关键词 |
| F8 | 市场选择器 | 显示搜索结果列表,包含市场标题、当前价格、结束时间、流动性 |
| F9 | 预估收益 | 根据触发价、金额计算预估份额和收益 |
| F10 | 表单校验 | 触发价 0-1,止盈 > 触发价,止损 < 触发价 |
### 3.4 触发记录
| 序号 | 任务 | 说明 |
|------|------|------|
| F11 | 触发记录列表 | 全局记录列表(非单个市场);支持账户、状态、时间筛选 |
| F12 | 记录详情 | 市场标题、成交价格/方向、数量、卖出价格/类型、盈亏 |
### 3.5 通用
| 序号 | 任务 | 说明 |
|------|------|------|
| F13 | 类型定义 | 策略、触发记录、市场、体育类别等 TypeScript 类型 |
| F14 | API 封装 | apiService 中 sportsTailStrategy.* 方法 |
| F15 | 多语言 | zh-CN、zh-TW、en 的 sportsTailStrategy.* 文案 |
---
## 四、依赖关系简图
```
D1,D2 数据库
B1-B3 实体与 Repository
B4-B6 Gamma API 扩展
B7-B9 订阅管理
B10-B14 执行逻辑
B15-B18 API 与 DTO
F1-F2 路由与菜单
F13-F15 类型与 API 封装
F3-F5 列表与实时价格
F6-F10 表单(含市场筛选)
F11-F12 触发记录
```
---
## 五、验收要点
- **不区分方向**:只设置触发价格,系统自动监控两个方向,任意方向达到即买入
- **实时订阅**:通过 WebSocket 订阅订单簿,实时监控价格,不使用轮询
- **订阅管理**:同一市场多策略共享一个订阅;无策略或策略完成且无止盈止损时取消订阅
- **止盈止损**:有止盈止损的策略成交后保持订阅,直到卖出
- **列表显示**:已成交显示成交价和数量,未成交显示实时价格
- **触发记录**:全局记录列表,每条记录包含完整市场信息
@@ -0,0 +1,259 @@
# 体育尾盘策略 - UI 规格
## 一、策略列表页
### 1.1 页面布局
**路由**`/sports-tail-strategy`
**桌面端**
```
┌─────────────────────────────────────────────────────────────────────┐
│ 体育尾盘策略 [+ 新增策略] │
├─────────────────────────────────────────────────────────────────────┤
│ 筛选: [账户选择▼] [类别: 全部▼] │
├─────────────────────────────────────────────────────────────────────┤
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ Lakers vs Bulls - Who will win? │ │
│ │ 账户: Account 1 │ │
│ │ 触发价: >=0.90 | 金额: 10 USDC │ │
│ │ 止盈: 0.98 | 止损: 0.85 │ │
│ │ ─────────────────────────────────────────────────────────── │ │
│ │ 成交价: 0.91 YES | 数量: 10.99 份 | 盈亏: 待结算 │ │
│ │ [查看记录] [删除] │ │
│ └───────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
```
**移动端**:使用卡片布局,信息折叠展示
### 1.2 列表字段
| 字段 | 说明 |
|------|------|
| 市场标题 | `marketTitle`,可点击跳转 Polymarket |
| 账户 | 关联的账户名称 |
| 触发价 | `>= {triggerPrice}` |
| 金额 | 固定金额 USDC 或 余额比例 % |
| 止盈/止损 | 配置的止盈止损价格,未配置显示 `-` |
| 成交信息 | 已成交:`{filledPrice} {YES/NO} \| {filledShares} 份`<br>未成交:`实时价格: YES {price} \| NO {price}` |
| 盈亏 | 已卖出:`+{realizedPnl} USDC`<br>已成交未卖出:`待结算`<br>未成交:`-` |
### 1.3 操作按钮
| 按钮 | 说明 |
|------|------|
| 查看记录 | 打开该策略的触发记录详情 |
| 删除 | 删除策略(需二次确认) |
**注意**:不支持启用/禁用功能,无状态列
---
## 二、新增策略表单
### 2.1 表单字段
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| 账户选择 | 下拉 | 是 | 选择账户 |
| 选择市场 | 搜索选择 | 是 | 从市场列表中选择(支持筛选) |
| 触发条件 | 复合输入 | 是 | `当价格 [>=] [0.90] 时触发买入` |
| 下注金额 | 单选+输入 | 是 | 固定金额 USDC 或 余额比例 % |
| 启用自动卖出 | 开关 | 否 | 开启后显示止盈止损 |
| 止盈价格 | 输入 | 否 | 价格上涨到此值时自动卖出 |
| 止损价格 | 输入 | 否 | 价格下跌到此值时自动卖出 |
**注意**
- 不选择方向(YES/NO),只设置触发价格
- 系统自动监控两个方向,任意方向达到触发价即买入
- 默认只触发一次
### 2.2 市场选择器
**筛选条件**
```
┌─────────────────────────────────────────────────────────────┐
│ 体育类别 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ [全部 ▼] │ │
│ │ 选项: 全部 / NBA / NFL / 英超 / 西甲 / 棒球... │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 市场类型 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ [全部 ▼] │ │
│ │ 选项: 全部 / 胜负 / 让分 / 大小分 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 结束时间 │
│ ○ 全部 ○ 今天 ○ 未来24小时 ○ 未来7天 │
│ │
│ 最小流动性 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ [ ] USDC(留空表示不限制) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 搜索关键词 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ [搜索球队、比赛...] [搜索] │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
**市场列表**
```
┌─────────────────────────────────────────────────────────────┐
│ ○ Lakers vs Bulls - Who will win? │
│ YES: 0.92 NO: 0.08 | 流动性: 50,000 USDC │
│ 结束: 2024-03-07 15:30 (剩余2小时30分) │
│ │
│ ○ Warriors @ Heat - O/U 220.5 │
│ Over: 0.55 Under: 0.45 | 流动性: 30,000 USDC │
│ 结束: 2024-03-07 18:00 (剩余5小时) │
└─────────────────────────────────────────────────────────────┘
```
### 2.3 预估收益展示
```
┌─────────────────────────────────────────────────────────────┐
│ 预估收益 │
│ ──────────────────────────────────────────────────────── │
│ 买入价格: 0.90 | 买入金额: 10 USDC │
│ 预计份额: 11.11 | 预计收益: +1.11 USDC (11.1%) │
│ │
│ 止盈 (0.98): 收益 +8.89 USDC (88.9%) │
│ 止损 (0.85): 亏损 -1.67 USDC (-16.7%) │
└─────────────────────────────────────────────────────────────┘
```
---
## 三、触发记录页(全局)
### 3.1 页面布局
**路由**`/sports-tail-strategy/records`
**说明**:全局记录列表,不是单个策略的记录
**桌面端表格**
```
┌────────────────────────────────────────────────────────────────────────────────────────┐
│ 触发记录 │
├────────────────────────────────────────────────────────────────────────────────────────┤
│ 筛选: [账户选择▼] [状态: 全部▼] [时间范围: 最近7天▼] │
├────────────────────────────────────────────────────────────────────────────────────────┤
│ 时间 │ 市场标题 │ 方向 │ 买入价 │ 金额 │ 卖出价 │ 盈亏 │
│ 03-07 15:30 │ Lakers vs Bulls │ YES │ 0.91 │ 10.00 │ 0.98 │ +7.89 USDC │
│ 03-07 14:20 │ Warriors @ Heat │ Over │ 0.55 │ 5.00 │ - │ 待结算 │
│ 03-06 18:45 │ Celtics vs Heat │ NO │ 0.92 │ 20.00 │ 0.85 │ -7.00 USDC │
└────────────────────────────────────────────────────────────────────────────────────────┘
```
**移动端**:使用卡片布局
### 3.2 记录字段
| 字段 | 说明 |
|------|------|
| 时间 | `triggeredAt` 格式化显示 |
| 市场标题 | `marketTitle`,可点击跳转 Polymarket |
| 方向 | `outcomeName` 或 YES/NO/Over/Under |
| 买入价 | `buyPrice` |
| 金额 | `buyAmount` USDC |
| 卖出价 | `sellPrice`,未卖出显示 `-` |
| 盈亏 | `realizedPnl`,未结算显示 `待结算` |
### 3.3 筛选条件
| 条件 | 说明 |
|------|------|
| 账户 | 按账户筛选 |
| 状态 | 全部 / 待结算 / 已止盈 / 已止损 / 已完成 |
| 时间范围 | 最近24小时 / 最近7天 / 最近30天 / 全部 |
---
## 四、响应式适配
### 4.1 断点
- 移动端: < 768px
- 桌面端: >= 768px
### 4.2 移动端适配
1. 列表使用卡片布局,信息可折叠
2. 表单使用分步或滚动布局
3. 触发记录使用卡片列表
4. 按钮最小触摸目标 44x44px
---
## 五、多语言 Key
```
sportsTailStrategy.list.title=体育尾盘策略
sportsTailStrategy.list.addStrategy=新增策略
sportsTailStrategy.list.filter.account=账户
sportsTailStrategy.list.filter.category=类别
sportsTailStrategy.list.filter.allCategory=全部
sportsTailStrategy.list.triggerPrice=触发价
sportsTailStrategy.list.amount=金额
sportsTailStrategy.list.takeProfitStopLoss=止盈/止损
sportsTailStrategy.list.filledPrice=成交价
sportsTailStrategy.list.realtimePrice=实时价格
sportsTailStrategy.list.shares=份
sportsTailStrategy.list.pnl=盈亏
sportsTailStrategy.list.pending=待结算
sportsTailStrategy.list.viewRecords=查看记录
sportsTailStrategy.list.delete=删除
sportsTailStrategy.list.deleteConfirm=确定删除该策略吗?
sportsTailStrategy.form.title=新增体育尾盘策略
sportsTailStrategy.form.account=账户
sportsTailStrategy.form.selectAccount=选择账户
sportsTailStrategy.form.selectMarket=选择市场
sportsTailStrategy.form.triggerCondition=触发条件
sportsTailStrategy.form.triggerPriceHelp=当任意方向价格达到触发价时买入
sportsTailStrategy.form.amount=下注金额
sportsTailStrategy.form.fixedAmount=固定金额
sportsTailStrategy.form.ratio=余额比例
sportsTailStrategy.form.autoSell=启用自动卖出
sportsTailStrategy.form.takeProfitPrice=止盈价格
sportsTailStrategy.form.takeProfitHelp=价格上涨到此值时自动卖出
sportsTailStrategy.form.stopLossPrice=止损价格
sportsTailStrategy.form.stopLossHelp=价格下跌到此值时自动卖出
sportsTailStrategy.form.estimatedReturn=预估收益
sportsTailStrategy.form.buyPrice=买入价格
sportsTailStrategy.form.buyAmount=买入金额
sportsTailStrategy.form.estimatedShares=预计份额
sportsTailStrategy.form.estimatedPnl=预计收益
sportsTailStrategy.marketSearch.sport=体育类别
sportsTailStrategy.marketSearch.marketType=市场类型
sportsTailStrategy.marketSearch.endTime=结束时间
sportsTailStrategy.marketSearch.minLiquidity=最小流动性
sportsTailStrategy.marketSearch.keyword=搜索关键词
sportsTailStrategy.marketSearch.search=搜索
sportsTailStrategy.marketSearch.liquidity=流动性
sportsTailStrategy.marketSearch.remaining=剩余
sportsTailStrategy.records.title=触发记录
sportsTailStrategy.records.time=时间
sportsTailStrategy.records.market=市场
sportsTailStrategy.records.direction=方向
sportsTailStrategy.records.buyPrice=买入价
sportsTailStrategy.records.amount=金额
sportsTailStrategy.records.sellPrice=卖出价
sportsTailStrategy.records.pnl=盈亏
sportsTailStrategy.records.pending=待结算
sportsTailStrategy.records.takeProfit=已止盈
sportsTailStrategy.records.stopLoss=已止损
```
+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
}
// ==================== 消息模板相关类型 ====================
/**