feat: 实现 NBA 量化交易系统

- 后端实现:
  - 实现 NBA 比赛数据服务,从 Polymarket API 获取数据
  - 实现数据库存储和增量拉取逻辑(优先从 DB 获取,数据不足时增量拉取)
  - 使用 sports_market_types 参数直接筛选 moneyline 类型
  - 实现分页拉取逻辑(基于 gameStartTime 和 createdAt)
  - 移除 nba_markets 相关的外键约束(V12 迁移)
  - 修复数据拉取逻辑:超过 3 天的数据不拉取

- 前端实现:
  - 实现策略创建/编辑/列表页面
  - 实现交易信号展示页面和统计页面
  - 修复重复请求问题(使用 useCallback 包装 fetchGames)
  - 支持选择单场比赛进行配置
  - 使用西8区时间格式化显示

- 数据库:
  - 创建 NBA 量化交易相关表(V11 迁移)
  - 移除外键约束(V12 迁移)

- 文档:
  - 添加产品需求文档、技术方案、算法文档等
This commit is contained in:
WrBug
2025-12-13 08:05:08 +08:00
parent 20acc72ed6
commit e9e1111df9
50 changed files with 11274 additions and 7 deletions
@@ -0,0 +1,102 @@
package com.wrbug.polymarketbot.api
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Query
/**
* NBA Stats API 接口
* Base URL: https://stats.nba.com/stats/
*
* 注意:NBA Stats API 需要设置正确的请求头:
* - User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
* - Referer: https://www.nba.com/
* - Accept: application/json
*/
interface NbaStatsApi {
/**
* 获取赛程和比分
* @param GameDate 比赛日期,格式:YYYY-MM-DD,不传则获取今天的比赛
* @param LeagueID 联盟ID,默认:00 (NBA)
* @param DayOffset 日期偏移,默认:0
* @return ScoreboardResponse
*/
@GET("Scoreboard")
suspend fun getScoreboard(
@Query("GameDate") gameDate: String? = null,
@Query("LeagueID") leagueId: String = "00",
@Query("DayOffset") dayOffset: Int = 0
): Response<ScoreboardResponse>
}
/**
* NBA Stats API Scoreboard 响应
*/
data class ScoreboardResponse(
val resultSets: List<ResultSet>
)
/**
* Result Set
*/
data class ResultSet(
val name: String,
val headers: List<String>,
val rowSet: List<List<Any?>>
)
/**
* Game Header (从 Scoreboard 的 resultSets[0] 获取)
* Headers: ["GAME_DATE_EST", "GAME_SEQUENCE", "GAME_ID", "GAME_STATUS_ID", "GAME_STATUS_TEXT",
* "GAMECODE", "HOME_TEAM_ID", "VISITOR_TEAM_ID", "SEASON", "LIVE_PERIOD",
* "LIVE_PC_TIME", "NATL_TV_BROADCASTER_ABBREV", "LIVE_PERIOD_TIME_BCAST", "WH_STATUS"]
*/
data class GameHeader(
val gameDateEst: String,
val gameSequence: Int,
val gameId: String,
val gameStatusId: Int,
val gameStatusText: String,
val gameCode: String,
val homeTeamId: Int,
val visitorTeamId: Int,
val season: String,
val livePeriod: Int?,
val livePcTime: String?,
val natlTvBroadcasterAbbrev: String?,
val livePeriodTimeBcast: String?,
val whStatus: Int?
)
/**
* Line Score (从 Scoreboard 的 resultSets[1] 获取)
* Headers: ["GAME_DATE_EST", "GAME_SEQUENCE", "GAME_ID", "TEAM_ID", "TEAM_ABBREVIATION",
* "TEAM_NAME", "PTS_QTR1", "PTS_QTR2", "PTS_QTR3", "PTS_QTR4", "PTS_OT1",
* "PTS_OT2", "PTS_OT3", "PTS_OT4", "PTS", "FG_PCT", "FT_PCT", "FG3_PCT",
* "AST", "REB", "TOV"]
*/
data class LineScore(
val gameDateEst: String,
val gameSequence: Int,
val gameId: String,
val teamId: Int,
val teamAbbreviation: String,
val teamName: String,
val ptsQtr1: Int?,
val ptsQtr2: Int?,
val ptsQtr3: Int?,
val ptsQtr4: Int?,
val ptsOt1: Int?,
val ptsOt2: Int?,
val ptsOt3: Int?,
val ptsOt4: Int?,
val pts: Int,
val fgPct: Double?,
val ftPct: Double?,
val fg3Pct: Double?,
val ast: Int?,
val reb: Int?,
val tov: Int?
)
@@ -13,19 +13,54 @@ import retrofit2.http.Query
interface PolymarketGammaApi {
/**
* 根据 condition ID 列表获取市场信息
* 获取体育元数据信息
* 文档: https://docs.polymarket.com/api-reference/sports/get-sports-metadata-information
* @return 体育元数据数组
*/
@GET("/sports")
suspend fun getSports(): Response<List<SportsMetadataResponse>>
/**
* 根据条件获取市场信息
* 文档: https://docs.polymarket.com/api-reference/markets/list-markets
* @param conditionIds condition ID 数组(16 进制字符串,如 "0x..."
* @param includeTag 是否包含标签信息
* @param tags 标签 ID 数组,用于过滤市场(如 NBA 的 tag ID)
* @param active 是否只返回活跃的市场
* @param closed 是否包含已关闭的市场
* @param archived 是否包含已归档的市场
* @param limit 返回的市场数量限制
* @param startDateMin 最小开始日期(ISO 8601 格式,UTC 时区,如 "2025-12-01T00:00:00Z"
* @param sportsMarketTypes 体育市场类型数组(如 ["moneyline"] 用于筛选 moneyline 类型)
* @return 市场信息数组
*/
@GET("/markets")
suspend fun listMarkets(
@Query("condition_ids") conditionIds: List<String>? = null,
@Query("include_tag") includeTag: Boolean? = null
@Query("include_tag") includeTag: Boolean? = null,
@Query("tags") tags: List<String>? = null,
@Query("active") active: Boolean? = null,
@Query("closed") closed: Boolean? = null,
@Query("archived") archived: Boolean? = null,
@Query("limit") limit: Int? = null,
@Query("start_date_min") startDateMin: String? = null,
@Query("sports_market_types") sportsMarketTypes: List<String>? = null,
): Response<List<MarketResponse>>
}
/**
* 体育元数据响应
* 文档: https://docs.polymarket.com/api-reference/sports/get-sports-metadata-information
*/
data class SportsMetadataResponse(
val sport: String? = null, // 体育标识符或缩写(如 "NBA"
val image: String? = null, // 体育 logo 或图片 URL
val resolution: String? = null, // 官方决议源 URL
val ordering: String? = null, // 显示顺序(通常是 "home" 或 "away"
val tags: String? = null, // 逗号分隔的标签 ID 列表
val series: String? = null // 系列标识符
)
/**
* 市场响应(根据 Gamma API 文档)
*/
@@ -51,6 +86,20 @@ data class MarketResponse(
val liquidityNum: Double? = null,
val lastTradePrice: Double? = null,
val bestBid: Double? = null,
val bestAsk: Double? = null
val bestAsk: Double? = null,
val tags: List<MarketTag>? = null, // 市场标签列表
val sportsMarketType: String? = null, // 市场类型:moneyline, spread 等
val gameStartTime: String? = null, // 比赛开始时间(格式:2025-12-13 00:00:00+00
val createdAt: String? = null, // 市场创建时间(ISO 8601 格式)
val resolutionSource: String? = null // 决议源 URL(如 "https://www.nba.com/"
)
/**
* 市场标签
*/
data class MarketTag(
val id: String? = null,
val label: String? = null,
val slug: String? = null
)
@@ -0,0 +1,69 @@
package com.wrbug.polymarketbot.controller.nba
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.nba.NbaGameService
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
/**
* NBA 比赛控制器
*/
@RestController
@RequestMapping("/api/nba/games")
class NbaGameController(
private val nbaGameService: NbaGameService,
private val messageSource: MessageSource
) {
private val logger = LoggerFactory.getLogger(NbaGameController::class.java)
/**
* 获取 NBA 比赛列表
*/
@PostMapping("/list")
fun getNbaGames(@RequestBody request: NbaGameListRequest): ResponseEntity<ApiResponse<NbaGameListResponse>> {
return try {
val result = runBlocking {
nbaGameService.getNbaGames(request)
}
result.fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("获取 NBA 比赛列表失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("获取 NBA 比赛列表异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
/**
* 获取 7 天内的所有球队(用于策略配置)
*/
@PostMapping("/teams")
fun getTeamsInNext7Days(): ResponseEntity<ApiResponse<List<String>>> {
return try {
val result = runBlocking {
nbaGameService.getTeamsInNext7Days()
}
result.fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("获取球队列表失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("获取球队列表异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
}
@@ -0,0 +1,70 @@
package com.wrbug.polymarketbot.controller.nba
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.nba.NbaMarketService
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
/**
* NBA 市场控制器
*/
@RestController
@RequestMapping("/api/nba/markets")
class NbaMarketController(
private val nbaMarketService: NbaMarketService,
private val messageSource: MessageSource
) {
private val logger = LoggerFactory.getLogger(NbaMarketController::class.java)
/**
* 获取 NBA 市场列表
*/
@PostMapping("/list")
fun getNbaMarkets(@RequestBody request: NbaMarketListRequest): ResponseEntity<ApiResponse<NbaMarketListResponse>> {
return try {
val result = runBlocking {
nbaMarketService.getNbaMarkets(request)
}
result.fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("获取 NBA 市场列表失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("获取 NBA 市场列表异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
/**
* 从 NBA 市场中获取球队列表(用于策略配置)
* 从市场名称中解析出所有唯一的球队名称
*/
@PostMapping("/teams")
fun getTeamsFromMarkets(): ResponseEntity<ApiResponse<List<String>>> {
return try {
val result = runBlocking {
nbaMarketService.getTeamsFromMarkets(active = true)
}
result.fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("获取球队列表失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("获取球队列表异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
}
@@ -0,0 +1,166 @@
package com.wrbug.polymarketbot.controller.nba
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.nba.NbaQuantitativeStrategyService
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
/**
* NBA 量化策略控制器
*/
@RestController
@RequestMapping("/api/nba/strategies")
class NbaQuantitativeStrategyController(
private val strategyService: NbaQuantitativeStrategyService,
private val messageSource: MessageSource
) {
private val logger = LoggerFactory.getLogger(NbaQuantitativeStrategyController::class.java)
/**
* 创建策略
*/
@PostMapping("/create")
fun createStrategy(@RequestBody request: NbaQuantitativeStrategyCreateRequest): ResponseEntity<ApiResponse<NbaQuantitativeStrategyDto>> {
return try {
if (request.strategyName.isBlank()) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, customMsg = "策略名称不能为空", messageSource = messageSource))
}
if (request.accountId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, customMsg = "账户ID无效", messageSource = messageSource))
}
val result = runBlocking { strategyService.createStrategy(request) }
result.fold(
onSuccess = { strategy ->
ResponseEntity.ok(ApiResponse.success(strategy))
},
onFailure = { e ->
logger.error("创建策略失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("创建策略异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
/**
* 更新策略
*/
@PostMapping("/update")
fun updateStrategy(@RequestBody request: NbaQuantitativeStrategyUpdateRequest): ResponseEntity<ApiResponse<NbaQuantitativeStrategyDto>> {
return try {
if (request.id <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, customMsg = "策略ID无效", messageSource = messageSource))
}
val result = runBlocking { strategyService.updateStrategy(request) }
result.fold(
onSuccess = { strategy ->
ResponseEntity.ok(ApiResponse.success(strategy))
},
onFailure = { e ->
logger.error("更新策略失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("更新策略异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
/**
* 获取策略列表
*/
@PostMapping("/list")
fun getStrategyList(@RequestBody request: NbaQuantitativeStrategyListRequest): ResponseEntity<ApiResponse<NbaQuantitativeStrategyListResponse>> {
return try {
val result = runBlocking { strategyService.getStrategyList(request) }
result.fold(
onSuccess = { response ->
ResponseEntity.ok(ApiResponse.success(response))
},
onFailure = { e ->
logger.error("获取策略列表失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("获取策略列表异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
/**
* 获取策略详情
*/
@PostMapping("/detail")
fun getStrategyDetail(@RequestBody request: NbaQuantitativeStrategyDetailRequest): ResponseEntity<ApiResponse<NbaQuantitativeStrategyDto>> {
return try {
if (request.id == null || request.id <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, customMsg = "策略ID不能为空", messageSource = messageSource))
}
val result = runBlocking { strategyService.getStrategyDetail(request.id) }
result.fold(
onSuccess = { strategy ->
ResponseEntity.ok(ApiResponse.success(strategy))
},
onFailure = { e ->
logger.error("获取策略详情失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("获取策略详情异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
/**
* 删除策略
*/
@PostMapping("/delete")
fun deleteStrategy(@RequestBody request: NbaQuantitativeStrategyDeleteRequest): ResponseEntity<ApiResponse<Unit>> {
return try {
if (request.id == null || request.id <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, customMsg = "策略ID不能为空", messageSource = messageSource))
}
val result = runBlocking { strategyService.deleteStrategy(request.id) }
result.fold(
onSuccess = {
ResponseEntity.ok(ApiResponse.success(Unit))
},
onFailure = { e ->
logger.error("删除策略失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("删除策略异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
}
/**
* 策略详情请求
*/
data class NbaQuantitativeStrategyDetailRequest(
val id: Long?
)
/**
* 策略删除请求
*/
data class NbaQuantitativeStrategyDeleteRequest(
val id: Long?
)
@@ -0,0 +1,40 @@
package com.wrbug.polymarketbot.dto
import java.time.LocalDate
/**
* NBA 比赛 DTO
*/
data class NbaGameDto(
val id: Long?,
val nbaGameId: String?,
val homeTeam: String,
val awayTeam: String,
val gameDate: LocalDate,
val gameTime: Long?,
val gameStatus: String,
val homeScore: Int,
val awayScore: Int,
val period: Int,
val timeRemaining: String?,
val polymarketMarketId: String?
)
/**
* NBA 比赛列表响应
*/
data class NbaGameListResponse(
val list: List<NbaGameDto>,
val total: Long
)
/**
* NBA 比赛列表请求
* 前端传递时间戳(毫秒),后端转换为西8区时间
*/
data class NbaGameListRequest(
val startTimestamp: Long? = null, // 开始时间戳(毫秒)
val endTimestamp: Long? = null, // 结束时间戳(毫秒)
val gameStatus: String? = null
)
@@ -0,0 +1,45 @@
package com.wrbug.polymarketbot.dto
/**
* NBA 市场 DTO
*/
data class NbaMarketDto(
val id: String? = null,
val question: String? = null,
val conditionId: String? = null,
val slug: String? = null,
val description: String? = null,
val category: String? = null,
val active: Boolean? = null,
val closed: Boolean? = null,
val archived: Boolean? = null,
val volume: String? = null,
val liquidity: String? = null,
val endDate: String? = null,
val startDate: String? = null,
val outcomes: String? = null,
val outcomePrices: String? = null,
val volumeNum: Double? = null,
val liquidityNum: Double? = null,
val lastTradePrice: Double? = null,
val bestBid: Double? = null,
val bestAsk: Double? = null
)
/**
* NBA 市场列表响应
*/
data class NbaMarketListResponse(
val list: List<NbaMarketDto>,
val total: Long
)
/**
* NBA 市场列表请求
*/
data class NbaMarketListRequest(
val active: Boolean? = true,
val closed: Boolean? = false,
val archived: Boolean? = false
)
@@ -0,0 +1,213 @@
package com.wrbug.polymarketbot.dto
import java.math.BigDecimal
import java.time.LocalDate
/**
* NBA 量化策略 DTO
*/
data class NbaQuantitativeStrategyDto(
val id: Long?,
val strategyName: String,
val strategyDescription: String?,
val accountId: Long,
val accountName: String?,
val enabled: Boolean,
val filterTeams: List<String>?,
val filterDateFrom: LocalDate?,
val filterDateTo: LocalDate?,
val filterGameImportance: String?,
val minWinProbabilityDiff: BigDecimal,
val minWinProbability: BigDecimal?,
val maxWinProbability: BigDecimal?,
val minTradeValue: BigDecimal,
val minRemainingTime: Int?,
val maxRemainingTime: Int?,
val minScoreDiff: Int?,
val maxScoreDiff: Int?,
val buyAmountStrategy: String,
val fixedBuyAmount: BigDecimal?,
val buyRatio: BigDecimal?,
val baseBuyAmount: BigDecimal?,
val buyTiming: String,
val delayBuySeconds: Int,
val buyDirection: String,
val enableSell: Boolean,
val takeProfitThreshold: BigDecimal?,
val stopLossThreshold: BigDecimal?,
val probabilityReversalThreshold: BigDecimal?,
val sellRatio: BigDecimal,
val sellTiming: String,
val delaySellSeconds: Int,
val priceStrategy: String,
val fixedPrice: BigDecimal?,
val priceOffset: BigDecimal,
val maxPosition: BigDecimal,
val minPosition: BigDecimal,
val maxGamePosition: BigDecimal?,
val maxDailyLoss: BigDecimal?,
val maxDailyOrders: Int?,
val maxDailyProfit: BigDecimal?,
val priceTolerance: BigDecimal,
val minProbabilityThreshold: BigDecimal?,
val maxProbabilityThreshold: BigDecimal?,
val baseStrengthWeight: BigDecimal,
val recentFormWeight: BigDecimal,
val lineupIntegrityWeight: BigDecimal,
val starStatusWeight: BigDecimal,
val environmentWeight: BigDecimal,
val matchupAdvantageWeight: BigDecimal,
val scoreDiffWeight: BigDecimal,
val momentumWeight: BigDecimal,
val dataUpdateFrequency: Int,
val analysisFrequency: Int,
val pushFailedOrders: Boolean,
val pushFrequency: String,
val batchPushInterval: Int,
val createdAt: Long,
val updatedAt: Long
)
/**
* NBA 量化策略创建请求
*/
data class NbaQuantitativeStrategyCreateRequest(
val strategyName: String,
val strategyDescription: String? = null,
val accountId: Long,
val enabled: Boolean = true,
val filterTeams: List<String>? = null,
val filterDateFrom: LocalDate? = null,
val filterDateTo: LocalDate? = null,
val filterGameImportance: String? = null,
val minWinProbabilityDiff: BigDecimal? = null,
val minWinProbability: BigDecimal? = null,
val maxWinProbability: BigDecimal? = null,
val minTradeValue: BigDecimal? = null,
val minRemainingTime: Int? = null,
val maxRemainingTime: Int? = null,
val minScoreDiff: Int? = null,
val maxScoreDiff: Int? = null,
val buyAmountStrategy: String? = null,
val fixedBuyAmount: BigDecimal? = null,
val buyRatio: BigDecimal? = null,
val baseBuyAmount: BigDecimal? = null,
val buyTiming: String? = null,
val delayBuySeconds: Int? = null,
val buyDirection: String? = null,
val enableSell: Boolean? = null,
val takeProfitThreshold: BigDecimal? = null,
val stopLossThreshold: BigDecimal? = null,
val probabilityReversalThreshold: BigDecimal? = null,
val sellRatio: BigDecimal? = null,
val sellTiming: String? = null,
val delaySellSeconds: Int? = null,
val priceStrategy: String? = null,
val fixedPrice: BigDecimal? = null,
val priceOffset: BigDecimal? = null,
val maxPosition: BigDecimal? = null,
val minPosition: BigDecimal? = null,
val maxGamePosition: BigDecimal? = null,
val maxDailyLoss: BigDecimal? = null,
val maxDailyOrders: Int? = null,
val maxDailyProfit: BigDecimal? = null,
val priceTolerance: BigDecimal? = null,
val minProbabilityThreshold: BigDecimal? = null,
val maxProbabilityThreshold: BigDecimal? = null,
val baseStrengthWeight: BigDecimal? = null,
val recentFormWeight: BigDecimal? = null,
val lineupIntegrityWeight: BigDecimal? = null,
val starStatusWeight: BigDecimal? = null,
val environmentWeight: BigDecimal? = null,
val matchupAdvantageWeight: BigDecimal? = null,
val scoreDiffWeight: BigDecimal? = null,
val momentumWeight: BigDecimal? = null,
val dataUpdateFrequency: Int? = null,
val analysisFrequency: Int? = null,
val pushFailedOrders: Boolean? = null,
val pushFrequency: String? = null,
val batchPushInterval: Int? = null
)
/**
* NBA 量化策略更新请求
*/
data class NbaQuantitativeStrategyUpdateRequest(
val id: Long,
val strategyName: String? = null,
val strategyDescription: String? = null,
val enabled: Boolean? = null,
val filterTeams: List<String>? = null,
val filterDateFrom: LocalDate? = null,
val filterDateTo: LocalDate? = null,
val filterGameImportance: String? = null,
val minWinProbabilityDiff: BigDecimal? = null,
val minWinProbability: BigDecimal? = null,
val maxWinProbability: BigDecimal? = null,
val minTradeValue: BigDecimal? = null,
val minRemainingTime: Int? = null,
val maxRemainingTime: Int? = null,
val minScoreDiff: Int? = null,
val maxScoreDiff: Int? = null,
val buyAmountStrategy: String? = null,
val fixedBuyAmount: BigDecimal? = null,
val buyRatio: BigDecimal? = null,
val baseBuyAmount: BigDecimal? = null,
val buyTiming: String? = null,
val delayBuySeconds: Int? = null,
val buyDirection: String? = null,
val enableSell: Boolean? = null,
val takeProfitThreshold: BigDecimal? = null,
val stopLossThreshold: BigDecimal? = null,
val probabilityReversalThreshold: BigDecimal? = null,
val sellRatio: BigDecimal? = null,
val sellTiming: String? = null,
val delaySellSeconds: Int? = null,
val priceStrategy: String? = null,
val fixedPrice: BigDecimal? = null,
val priceOffset: BigDecimal? = null,
val maxPosition: BigDecimal? = null,
val minPosition: BigDecimal? = null,
val maxGamePosition: BigDecimal? = null,
val maxDailyLoss: BigDecimal? = null,
val maxDailyOrders: Int? = null,
val maxDailyProfit: BigDecimal? = null,
val priceTolerance: BigDecimal? = null,
val minProbabilityThreshold: BigDecimal? = null,
val maxProbabilityThreshold: BigDecimal? = null,
val baseStrengthWeight: BigDecimal? = null,
val recentFormWeight: BigDecimal? = null,
val lineupIntegrityWeight: BigDecimal? = null,
val starStatusWeight: BigDecimal? = null,
val environmentWeight: BigDecimal? = null,
val matchupAdvantageWeight: BigDecimal? = null,
val scoreDiffWeight: BigDecimal? = null,
val momentumWeight: BigDecimal? = null,
val dataUpdateFrequency: Int? = null,
val analysisFrequency: Int? = null,
val pushFailedOrders: Boolean? = null,
val pushFrequency: String? = null,
val batchPushInterval: Int? = null
)
/**
* NBA 量化策略列表请求
*/
data class NbaQuantitativeStrategyListRequest(
val accountId: Long? = null,
val enabled: Boolean? = null,
val strategyName: String? = null,
val page: Int? = 1,
val limit: Int? = 20
)
/**
* NBA 量化策略列表响应
*/
data class NbaQuantitativeStrategyListResponse(
val list: List<NbaQuantitativeStrategyDto>,
val total: Long,
val page: Int,
val limit: Int
)
@@ -0,0 +1,55 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
import java.time.LocalDate
/**
* NBA 比赛实体
*/
@Entity
@Table(name = "nba_games")
data class NbaGame(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "nba_game_id", unique = true, length = 100)
val nbaGameId: String? = null,
@Column(name = "home_team", nullable = false, length = 100)
val homeTeam: String,
@Column(name = "away_team", nullable = false, length = 100)
val awayTeam: String,
@Column(name = "game_date", nullable = false)
val gameDate: LocalDate,
@Column(name = "game_time")
val gameTime: Long? = null,
@Column(name = "game_status", length = 50)
val gameStatus: String = "scheduled",
@Column(name = "home_score")
val homeScore: Int = 0,
@Column(name = "away_score")
val awayScore: Int = 0,
@Column(name = "period")
val period: Int = 0,
@Column(name = "time_remaining", length = 50)
val timeRemaining: String? = null,
@Column(name = "polymarket_market_id", length = 100)
val polymarketMarketId: String? = 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,63 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
/**
* NBA 市场实体(Polymarket 市场信息)
*/
@Entity
@Table(name = "nba_markets")
data class NbaMarket(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "polymarket_market_id", unique = true, nullable = false, length = 100)
val polymarketMarketId: String,
@Column(name = "condition_id", unique = true, nullable = false, length = 100)
val conditionId: String,
@Column(name = "market_slug", length = 255)
val marketSlug: String? = null,
@Column(name = "market_question", columnDefinition = "TEXT")
val marketQuestion: String? = null,
@Column(name = "market_description", columnDefinition = "TEXT")
val marketDescription: String? = null,
@Column(name = "category", length = 50)
val category: String = "sports",
@Column(name = "active")
val active: Boolean = true,
@Column(name = "closed")
val closed: Boolean = false,
@Column(name = "archived")
val archived: Boolean = false,
@Column(name = "volume", length = 50)
val volume: String? = null,
@Column(name = "liquidity", length = 50)
val liquidity: String? = null,
@Column(name = "outcomes", columnDefinition = "TEXT")
val outcomes: String? = null,
@Column(name = "end_date", length = 50)
val endDate: String? = null,
@Column(name = "start_date", length = 50)
val startDate: String? = 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,196 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
import java.math.BigDecimal
import java.time.LocalDate
/**
* NBA 量化策略配置实体
*/
@Entity
@Table(name = "nba_quantitative_strategies")
data class NbaQuantitativeStrategy(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "strategy_name", nullable = false, length = 100)
val strategyName: String,
@Column(name = "strategy_description", columnDefinition = "TEXT")
val strategyDescription: String? = null,
@Column(name = "account_id", nullable = false)
val accountId: Long,
@Column(name = "enabled")
val enabled: Boolean = true,
// 比赛筛选参数
@Column(name = "filter_teams", columnDefinition = "TEXT")
val filterTeams: String? = null,
@Column(name = "filter_date_from")
val filterDateFrom: LocalDate? = null,
@Column(name = "filter_date_to")
val filterDateTo: LocalDate? = null,
@Column(name = "filter_game_importance", length = 50)
val filterGameImportance: String? = null,
// 触发条件参数
@Column(name = "min_win_probability_diff", precision = 5, scale = 4)
val minWinProbabilityDiff: BigDecimal = BigDecimal("0.1"),
@Column(name = "min_win_probability", precision = 5, scale = 4)
val minWinProbability: BigDecimal? = null,
@Column(name = "max_win_probability", precision = 5, scale = 4)
val maxWinProbability: BigDecimal? = null,
@Column(name = "min_trade_value", precision = 5, scale = 4)
val minTradeValue: BigDecimal = BigDecimal("0.05"),
@Column(name = "min_remaining_time")
val minRemainingTime: Int? = null,
@Column(name = "max_remaining_time")
val maxRemainingTime: Int? = null,
@Column(name = "min_score_diff")
val minScoreDiff: Int? = null,
@Column(name = "max_score_diff")
val maxScoreDiff: Int? = null,
// 买入规则参数
@Column(name = "buy_amount_strategy", length = 20)
val buyAmountStrategy: String = "FIXED",
@Column(name = "fixed_buy_amount", precision = 20, scale = 8)
val fixedBuyAmount: BigDecimal? = null,
@Column(name = "buy_ratio", precision = 5, scale = 4)
val buyRatio: BigDecimal? = null,
@Column(name = "base_buy_amount", precision = 20, scale = 8)
val baseBuyAmount: BigDecimal? = null,
@Column(name = "buy_timing", length = 20)
val buyTiming: String = "IMMEDIATE",
@Column(name = "delay_buy_seconds")
val delayBuySeconds: Int = 0,
@Column(name = "buy_direction", length = 10)
val buyDirection: String = "AUTO",
// 卖出规则参数
@Column(name = "enable_sell")
val enableSell: Boolean = true,
@Column(name = "take_profit_threshold", precision = 5, scale = 4)
val takeProfitThreshold: BigDecimal? = null,
@Column(name = "stop_loss_threshold", precision = 5, scale = 4)
val stopLossThreshold: BigDecimal? = null,
@Column(name = "probability_reversal_threshold", precision = 5, scale = 4)
val probabilityReversalThreshold: BigDecimal? = null,
@Column(name = "sell_ratio", precision = 5, scale = 4)
val sellRatio: BigDecimal = BigDecimal("1.0"),
@Column(name = "sell_timing", length = 20)
val sellTiming: String = "IMMEDIATE",
@Column(name = "delay_sell_seconds")
val delaySellSeconds: Int = 0,
// 价格策略参数
@Column(name = "price_strategy", length = 20)
val priceStrategy: String = "MARKET",
@Column(name = "fixed_price", precision = 5, scale = 4)
val fixedPrice: BigDecimal? = null,
@Column(name = "price_offset", precision = 5, scale = 4)
val priceOffset: BigDecimal = BigDecimal.ZERO,
// 风险控制参数
@Column(name = "max_position", precision = 20, scale = 8)
val maxPosition: BigDecimal = BigDecimal("50"),
@Column(name = "min_position", precision = 20, scale = 8)
val minPosition: BigDecimal = BigDecimal("5"),
@Column(name = "max_game_position", precision = 20, scale = 8)
val maxGamePosition: BigDecimal? = null,
@Column(name = "max_daily_loss", precision = 20, scale = 8)
val maxDailyLoss: BigDecimal? = null,
@Column(name = "max_daily_orders")
val maxDailyOrders: Int? = null,
@Column(name = "max_daily_profit", precision = 20, scale = 8)
val maxDailyProfit: BigDecimal? = null,
@Column(name = "price_tolerance", precision = 5, scale = 4)
val priceTolerance: BigDecimal = BigDecimal("0.05"),
@Column(name = "min_probability_threshold", precision = 5, scale = 4)
val minProbabilityThreshold: BigDecimal? = null,
@Column(name = "max_probability_threshold", precision = 5, scale = 4)
val maxProbabilityThreshold: BigDecimal? = null,
// 算法权重参数
@Column(name = "base_strength_weight", precision = 5, scale = 4)
val baseStrengthWeight: BigDecimal = BigDecimal("0.3"),
@Column(name = "recent_form_weight", precision = 5, scale = 4)
val recentFormWeight: BigDecimal = BigDecimal("0.25"),
@Column(name = "lineup_integrity_weight", precision = 5, scale = 4)
val lineupIntegrityWeight: BigDecimal = BigDecimal("0.2"),
@Column(name = "star_status_weight", precision = 5, scale = 4)
val starStatusWeight: BigDecimal = BigDecimal("0.15"),
@Column(name = "environment_weight", precision = 5, scale = 4)
val environmentWeight: BigDecimal = BigDecimal("0.1"),
@Column(name = "matchup_advantage_weight", precision = 5, scale = 4)
val matchupAdvantageWeight: BigDecimal = BigDecimal("0.2"),
@Column(name = "score_diff_weight", precision = 5, scale = 4)
val scoreDiffWeight: BigDecimal = BigDecimal("0.3"),
@Column(name = "momentum_weight", precision = 5, scale = 4)
val momentumWeight: BigDecimal = BigDecimal("0.2"),
// 系统配置参数
@Column(name = "data_update_frequency")
val dataUpdateFrequency: Int = 30,
@Column(name = "analysis_frequency")
val analysisFrequency: Int = 30,
@Column(name = "push_failed_orders")
val pushFailedOrders: Boolean = false,
@Column(name = "push_frequency", length = 20)
val pushFrequency: String = "REALTIME",
@Column(name = "batch_push_interval")
val batchPushInterval: Int = 1,
@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,50 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
import java.math.BigDecimal
import java.time.LocalDate
/**
* NBA 策略执行统计实体
*/
@Entity
@Table(name = "nba_strategy_statistics")
data class NbaStrategyStatistics(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "strategy_id", nullable = false)
val strategyId: Long,
@Column(name = "stat_date", nullable = false)
val statDate: LocalDate,
@Column(name = "total_signals")
val totalSignals: Int = 0,
@Column(name = "buy_signals")
val buySignals: Int = 0,
@Column(name = "sell_signals")
val sellSignals: Int = 0,
@Column(name = "success_signals")
val successSignals: Int = 0,
@Column(name = "failed_signals")
val failedSignals: Int = 0,
@Column(name = "total_profit", precision = 20, scale = 8)
val totalProfit: BigDecimal = BigDecimal.ZERO,
@Column(name = "total_volume", precision = 20, scale = 8)
val totalVolume: BigDecimal = BigDecimal.ZERO,
@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,64 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
import java.math.BigDecimal
/**
* NBA 交易信号实体
*/
@Entity
@Table(name = "nba_trading_signals")
data class NbaTradingSignal(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "strategy_id", nullable = false)
val strategyId: Long,
@Column(name = "game_id")
val gameId: Long? = null,
@Column(name = "market_id")
val marketId: Long? = null,
@Column(name = "signal_type", nullable = false, length = 10)
val signalType: String,
@Column(name = "direction", nullable = false, length = 10)
val direction: String,
@Column(name = "price", nullable = false, precision = 5, scale = 4)
val price: BigDecimal,
@Column(name = "quantity", nullable = false, precision = 20, scale = 8)
val quantity: BigDecimal,
@Column(name = "total_amount", nullable = false, precision = 20, scale = 8)
val totalAmount: BigDecimal,
@Column(name = "reason", columnDefinition = "TEXT")
val reason: String? = null,
@Column(name = "win_probability", precision = 5, scale = 4)
val winProbability: BigDecimal? = null,
@Column(name = "trade_value", precision = 5, scale = 4)
val tradeValue: BigDecimal? = null,
@Column(name = "signal_status", length = 20)
val signalStatus: String = "GENERATED",
@Column(name = "execution_result", columnDefinition = "TEXT")
val executionResult: String? = null,
@Column(name = "error_message", columnDefinition = "TEXT")
val errorMessage: String? = 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,66 @@
package com.wrbug.polymarketbot.enums
/**
* Polymarket 体育项目 Tag ID 枚举
* 用于标识不同体育项目在 Polymarket 中的 tag ID
*/
enum class SportsTagId(val tagId: String, val displayName: String) {
/**
* 美国职业篮球联赛
*/
NBA("745", "NBA"),
/**
* 美国职业棒球大联盟
*/
MLB("100381", "MLB"),
/**
* 美国国家橄榄球联盟
*/
NFL("450", "NFL"),
/**
* 美国大学橄榄球
*/
CFB("100351", "CFB"),
/**
* 美国国家冰球联盟
*/
NHL("899", "NHL"),
/**
* 游戏/电子竞技
*/
GAMES("100639", "GAMES"),
/**
* 美国大学篮球
*/
CBB("101178", "CBB");
companion object {
/**
* 根据 tag ID 查找枚举
*/
fun fromTagId(tagId: String): SportsTagId? {
return values().find { it.tagId == tagId }
}
/**
* 根据显示名称查找枚举
*/
fun fromDisplayName(displayName: String): SportsTagId? {
return values().find { it.displayName.equals(displayName, ignoreCase = true) }
}
/**
* 获取所有 tag IDs 列表
*/
fun getAllTagIds(): List<String> {
return values().map { it.tagId }
}
}
}
@@ -0,0 +1,27 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.NbaGame
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
import java.time.LocalDate
@Repository
interface NbaGameRepository : JpaRepository<NbaGame, Long> {
fun findByNbaGameId(nbaGameId: String): NbaGame?
fun findByGameDate(gameDate: LocalDate): List<NbaGame>
fun findByGameDateBetween(startDate: LocalDate, endDate: LocalDate): List<NbaGame>
fun findByGameStatus(gameStatus: String): List<NbaGame>
fun findByHomeTeamAndAwayTeamAndGameDate(homeTeam: String, awayTeam: String, gameDate: LocalDate): NbaGame?
fun findByPolymarketMarketId(polymarketMarketId: String): NbaGame?
/**
* 查询最新的比赛(按创建时间倒序)
*/
fun findFirstByOrderByCreatedAtDesc(): NbaGame?
/**
* 根据创建时间查询比赛
*/
fun findByCreatedAtGreaterThan(createdAt: Long): List<NbaGame>
}
@@ -0,0 +1,15 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.NbaMarket
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface NbaMarketRepository : JpaRepository<NbaMarket, Long> {
fun findByConditionId(conditionId: String): NbaMarket?
fun findByPolymarketMarketId(polymarketMarketId: String): NbaMarket?
fun findByActiveAndClosed(active: Boolean, closed: Boolean): List<NbaMarket>
fun findByCategory(category: String): List<NbaMarket>
fun findByActive(active: Boolean): List<NbaMarket>
}
@@ -0,0 +1,14 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.NbaQuantitativeStrategy
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface NbaQuantitativeStrategyRepository : JpaRepository<NbaQuantitativeStrategy, Long> {
fun findByAccountId(accountId: Long): List<NbaQuantitativeStrategy>
fun findByAccountIdAndEnabled(accountId: Long, enabled: Boolean): List<NbaQuantitativeStrategy>
fun findByEnabled(enabled: Boolean): List<NbaQuantitativeStrategy>
fun findByStrategyName(strategyName: String): NbaQuantitativeStrategy?
}
@@ -0,0 +1,14 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.NbaStrategyStatistics
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
import java.time.LocalDate
@Repository
interface NbaStrategyStatisticsRepository : JpaRepository<NbaStrategyStatistics, Long> {
fun findByStrategyId(strategyId: Long): List<NbaStrategyStatistics>
fun findByStrategyIdAndStatDate(strategyId: Long, statDate: LocalDate): NbaStrategyStatistics?
fun findByStrategyIdAndStatDateBetween(strategyId: Long, startDate: LocalDate, endDate: LocalDate): List<NbaStrategyStatistics>
}
@@ -0,0 +1,18 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.NbaTradingSignal
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
import java.time.Instant
@Repository
interface NbaTradingSignalRepository : JpaRepository<NbaTradingSignal, Long> {
fun findByStrategyId(strategyId: Long): List<NbaTradingSignal>
fun findByGameId(gameId: Long): List<NbaTradingSignal>
fun findByMarketId(marketId: Long): List<NbaTradingSignal>
fun findBySignalType(signalType: String): List<NbaTradingSignal>
fun findBySignalStatus(signalStatus: String): List<NbaTradingSignal>
fun findByStrategyIdAndSignalType(strategyId: Long, signalType: String): List<NbaTradingSignal>
fun findByStrategyIdAndCreatedAtBetween(strategyId: Long, startTime: Long, endTime: Long): List<NbaTradingSignal>
}
@@ -0,0 +1,579 @@
package com.wrbug.polymarketbot.service.nba
import com.wrbug.polymarketbot.api.PolymarketGammaApi
import com.wrbug.polymarketbot.dto.NbaGameDto
import com.wrbug.polymarketbot.dto.NbaGameListRequest
import com.wrbug.polymarketbot.dto.NbaGameListResponse
import com.wrbug.polymarketbot.entity.NbaGame
import com.wrbug.polymarketbot.enums.SportsTagId
import com.wrbug.polymarketbot.repository.NbaGameRepository
import com.wrbug.polymarketbot.util.RetrofitFactory
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
/**
* NBA 比赛服务
* 从数据库和 Polymarket API 获取比赛数据
* 优先从数据库获取,如果数据不足则增量拉取 API 数据
*/
@Service
class NbaGameService(
private val retrofitFactory: RetrofitFactory,
private val nbaGameRepository: NbaGameRepository
) {
private val logger = LoggerFactory.getLogger(NbaGameService::class.java)
/**
* 获取 NBA 比赛列表
* 优先从数据库获取,如果数据不足则增量拉取 API 数据
* 前端传递时间戳,后端转换为西8区时间用于过滤
*/
suspend fun getNbaGames(request: NbaGameListRequest): Result<NbaGameListResponse> {
return try {
// 将时间戳转换为西8区(PST/PDT)的日期范围
val pstZone = ZoneId.of("America/Los_Angeles")
val startTimestamp = request.startTimestamp ?: ZonedDateTime.now(pstZone).toInstant().toEpochMilli()
val endTimestamp = request.endTimestamp ?: ZonedDateTime.now(pstZone).plusDays(7).toInstant().toEpochMilli()
val startDate = Instant.ofEpochMilli(startTimestamp).atZone(pstZone).toLocalDate()
val endDate = Instant.ofEpochMilli(endTimestamp).atZone(pstZone).toLocalDate()
// 1. 先从数据库获取数据
val dbGames = nbaGameRepository.findByGameDateBetween(startDate, endDate)
logger.info("从数据库获取到 ${dbGames.size} 个比赛(日期范围:$startDate$endDate")
// 2. 检查是否需要增量拉取
val needFetch = shouldFetchFromApi(dbGames)
if (needFetch) {
logger.info("数据库数据不足,开始增量拉取 API 数据")
// 3. 获取数据库最新的 createdAt,用于增量拉取
val latestGame = nbaGameRepository.findFirstByOrderByCreatedAtDesc()
val incrementalStartDateMin = latestGame?.createdAt?.let {
// 将数据库的 createdAt(时间戳)转换为 UTC ISO 8601 格式
Instant.ofEpochMilli(it)
.atZone(java.time.ZoneOffset.UTC)
.format(DateTimeFormatter.ISO_INSTANT)
} ?: run {
// 如果没有数据库数据,使用一周前的时间
Instant.now()
.minusSeconds(7 * 24 * 60 * 60)
.atZone(java.time.ZoneOffset.UTC)
.format(DateTimeFormatter.ISO_INSTANT)
}
logger.info("使用增量拉取起始时间: $incrementalStartDateMin")
// 4. 增量拉取 API 数据
val apiGames = fetchGamesFromApi(startDate, endDate, incrementalStartDateMin)
// 5. 保存新数据到数据库
if (apiGames.isNotEmpty()) {
saveGamesToDatabase(apiGames)
}
// 6. 合并数据库数据和 API 数据
val allGames = (dbGames + apiGames.map { dtoToEntity(it) }).distinctBy {
"${it.homeTeam}_${it.awayTeam}_${it.gameDate}"
}
// 转换为 DTO
val gameDtos = allGames.map { entityToDto(it) }
// 根据状态过滤
val filteredGames = if (request.gameStatus != null) {
gameDtos.filter { it.gameStatus == request.gameStatus }
} else {
gameDtos
}
Result.success(
NbaGameListResponse(
list = filteredGames,
total = filteredGames.size.toLong()
)
)
} else {
// 数据库数据充足,直接返回
val gameDtos = dbGames.map { entityToDto(it) }
// 根据状态过滤
val filteredGames = if (request.gameStatus != null) {
gameDtos.filter { it.gameStatus == request.gameStatus }
} else {
gameDtos
}
Result.success(
NbaGameListResponse(
list = filteredGames,
total = filteredGames.size.toLong()
)
)
}
} catch (e: Exception) {
logger.error("获取 NBA 比赛列表失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 判断是否需要从 API 拉取数据
* 逻辑:
* 1. 如果数据库没有数据,需要拉取
* 2. 如果数据库最新数据的 gameTime 在未来 3 天内(0-3 天),不需要拉取
* 3. 如果数据库最新数据的 gameTime 超过 3 天(>3 天),不需要拉取(数据太远)
* 4. 如果数据库最新数据的 gameTime 已经过去(<0),需要拉取(数据过期)
*/
private fun shouldFetchFromApi(dbGames: List<NbaGame>): Boolean {
if (dbGames.isEmpty()) {
logger.info("数据库没有数据,需要从 API 拉取")
return true
}
// 检查最新数据的 gameTime(未来最远的比赛)
val latestGame = dbGames.maxByOrNull { it.gameTime ?: 0L }
if (latestGame?.gameTime == null) {
logger.info("数据库最新数据没有 gameTime,需要从 API 拉取")
return true
}
// 计算最新数据的 gameTime 距离现在的时间(以天为单位)
val now = Instant.now().toEpochMilli()
val gameTime = latestGame.gameTime
val daysDiff = (gameTime - now) / (24 * 60 * 60 * 1000)
// 如果数据已经过去(daysDiff < 0),需要拉取
if (daysDiff < 0) {
logger.info("数据库最新数据的 gameTime 已经过去(${daysDiff} 天前),需要从 API 拉取")
return true
}
// 如果数据在未来 3 天内(0 <= daysDiff <= 3),不需要拉取
if (daysDiff >= 0 && daysDiff <= 3) {
logger.info("数据库数据充足(最新数据 ${daysDiff} 天后,在未来 3 天内),无需从 API 拉取")
return false
}
// 如果数据超过 3 天(daysDiff > 3),不需要拉取(数据太远)
logger.info("数据库最新数据的 gameTime 超过 3 天(${daysDiff} 天后),数据太远,不需要从 API 拉取")
return false
}
/**
* 从 API 拉取比赛数据
*/
private suspend fun fetchGamesFromApi(
startDate: LocalDate,
endDate: LocalDate,
startDateMin: String
): List<NbaGameDto> {
// 从 Polymarket API 获取 NBA 市场(分页拉取)
val gammaApi = retrofitFactory.createGammaApi()
val nbaTagId = SportsTagId.NBA.tagId
// 计算未来3天的时间点(UTC
val threeDaysLater = Instant.now()
.plusSeconds(3 * 24 * 60 * 60) // 加上3天(秒数)
val allMarkets = mutableListOf<com.wrbug.polymarketbot.api.MarketResponse>()
var hasMore = true
var pageCount = 0
var currentStartDateMin = startDateMin
while (hasMore) {
pageCount++
logger.debug("分页拉取第 $pageCount 页,start_date_min: $currentStartDateMin")
val response = gammaApi.listMarkets(
conditionIds = null,
includeTag = true,
tags = listOf(nbaTagId),
active = true, // 只获取活跃的市场
closed = false,
archived = false,
limit = 500, // 使用 500 作为 limit
startDateMin = currentStartDateMin,
sportsMarketTypes = listOf("moneyline") // 直接通过 API 筛选 moneyline 类型
)
if (!response.isSuccessful || response.body() == null) {
logger.error("获取 NBA 市场失败: ${response.code()} ${response.message()}")
break
}
val markets = response.body()!!
logger.info("$pageCount 页获取到 ${markets.size} 个市场")
if (markets.isEmpty()) {
// 没有更多数据了
hasMore = false
break
}
// 先记录最后一项的 createdAt(用于下一次分页)
val lastMarket = markets.last()
val lastCreatedAt = lastMarket.createdAt
if (lastCreatedAt == null) {
// 如果最后一个元素没有 createdAt,停止分页
hasMore = false
logger.warn("数组最后一个元素缺少 createdAt,停止分页")
break
}
// 移除非 NBA 项(根据 resolutionSource 判断)
val nbaMarkets = markets.filter { market ->
!market.resolutionSource.isNullOrBlank() &&
market.resolutionSource!!.lowercase().contains("nba")
}
logger.info("$pageCount 页过滤后剩余 ${nbaMarkets.size} 个 NBA 市场")
// 添加到总列表(只添加 NBA 市场)
allMarkets.addAll(nbaMarkets)
// 从后往前遍历,找到第一个有 gameStartTime 字段的数据(在 NBA 市场中查找)
var foundGameStartTime: String? = null
for (i in nbaMarkets.size - 1 downTo 0) {
val market = nbaMarkets[i]
if (!market.gameStartTime.isNullOrBlank()) {
foundGameStartTime = market.gameStartTime
logger.debug("从后往前找到第 ${i + 1} 个有 gameStartTime 的 NBA 市场: $foundGameStartTime")
break
}
}
if (foundGameStartTime == null) {
// 如果整页都没有 gameStartTime,使用 createdAt 继续分页
currentStartDateMin = lastCreatedAt
logger.debug("本页没有找到 gameStartTime,使用最后一个元素的 createdAt 继续分页")
continue
}
// 解析 gameStartTime(格式:2025-12-13 00:00:00+00
val gameStartDate = try {
// 尝试解析格式 "2025-12-13 00:00:00+00"
val dateTimeStr = foundGameStartTime.replace(" ", "T")
// 如果时区是 +00,转换为 Z
val normalizedStr = if (dateTimeStr.endsWith("+00")) {
dateTimeStr.replace("+00", "Z")
} else if (dateTimeStr.endsWith("-00")) {
dateTimeStr.replace("-00", "Z")
} else {
dateTimeStr
}
val instant = Instant.parse(normalizedStr)
// 转换为日期(以天为单位,不考虑时间)
instant.atZone(java.time.ZoneOffset.UTC).toLocalDate()
} catch (e: Exception) {
logger.warn("解析 gameStartTime 失败: $foundGameStartTime, error: ${e.message}")
null
}
if (gameStartDate == null) {
// 无法解析 gameStartTime,使用 createdAt 继续分页
currentStartDateMin = lastCreatedAt
logger.debug("无法解析 gameStartTime,使用最后一个元素的 createdAt 继续分页")
continue
}
// 计算未来 3 天的日期(以天为单位,不考虑时间)
val threeDaysLaterDate = Instant.now()
.plusSeconds(3 * 24 * 60 * 60) // 加上3天(秒数)
.atZone(java.time.ZoneOffset.UTC)
.toLocalDate()
// 判断 gameStartDate 是否在未来 3 天以内(包括第 3 天)
val daysBetween = java.time.temporal.ChronoUnit.DAYS.between(
Instant.now().atZone(java.time.ZoneOffset.UTC).toLocalDate(),
gameStartDate
)
if (daysBetween <= 3 && daysBetween >= 0) {
// 如果在 3 天内(包括第 3 天),使用数组最后一个元素的 createdAt 继续分页
currentStartDateMin = lastCreatedAt
logger.info("找到的 gameStartTime ($foundGameStartTime, 日期: $gameStartDate) 在未来 ${daysBetween} 天内,继续分页")
} else {
// 如果不在 3 天内,停止分页
hasMore = false
logger.info("找到的 gameStartTime ($foundGameStartTime, 日期: $gameStartDate) 不在未来 3 天内(相差 ${daysBetween} 天),停止分页")
}
}
logger.info("分页拉取完成,共获取 ${allMarkets.size} 个 NBA moneyline 市场(${pageCount} 页)")
// 注意:allMarkets 已经通过 API 的 sports_market_types 参数过滤了 moneyline 类型
// 并且已经过滤了非 NBA 项(根据 resolutionSource),这里直接使用即可
// 将市场转换为比赛数据
val games = allMarkets.mapNotNull { market ->
convertMarketToGame(market, startDate, endDate)
}
// 去重:相同的主队、客队和日期只保留一个
val uniqueGames = games.groupBy { "${it.homeTeam}_${it.awayTeam}_${it.gameDate}" }
.map { it.value.first() }
return uniqueGames
}
/**
* 保存比赛数据到数据库
*/
@Transactional
private fun saveGamesToDatabase(games: List<NbaGameDto>) {
if (games.isEmpty()) {
return
}
var savedCount = 0
var updatedCount = 0
games.forEach { dto ->
try {
// 尝试根据 nbaGameId 或 polymarketMarketId 查找现有记录
val existing = dto.nbaGameId?.let {
nbaGameRepository.findByNbaGameId(it)
} ?: dto.polymarketMarketId?.let {
nbaGameRepository.findByPolymarketMarketId(it)
}
if (existing != null) {
// 更新现有记录(data class 的 copy 方法)
val updated = NbaGame(
id = existing.id,
nbaGameId = existing.nbaGameId,
homeTeam = dto.homeTeam,
awayTeam = dto.awayTeam,
gameDate = dto.gameDate,
gameTime = dto.gameTime,
gameStatus = dto.gameStatus,
homeScore = dto.homeScore,
awayScore = dto.awayScore,
period = dto.period,
timeRemaining = dto.timeRemaining,
polymarketMarketId = dto.polymarketMarketId,
createdAt = existing.createdAt,
updatedAt = System.currentTimeMillis()
)
nbaGameRepository.save(updated)
updatedCount++
} else {
// 创建新记录
val entity = dtoToEntity(dto)
nbaGameRepository.save(entity)
savedCount++
}
} catch (e: Exception) {
logger.error("保存比赛数据失败: ${dto.nbaGameId}, error: ${e.message}", e)
}
}
logger.info("保存比赛数据完成:新增 $savedCount 条,更新 $updatedCount")
}
/**
* DTO 转实体
*/
private fun dtoToEntity(dto: NbaGameDto): NbaGame {
return NbaGame(
id = null,
nbaGameId = dto.nbaGameId,
homeTeam = dto.homeTeam,
awayTeam = dto.awayTeam,
gameDate = dto.gameDate,
gameTime = dto.gameTime,
gameStatus = dto.gameStatus,
homeScore = dto.homeScore,
awayScore = dto.awayScore,
period = dto.period,
timeRemaining = dto.timeRemaining,
polymarketMarketId = dto.polymarketMarketId,
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis()
)
}
/**
* 实体转 DTO
*/
private fun entityToDto(entity: NbaGame): NbaGameDto {
return NbaGameDto(
id = entity.id,
nbaGameId = entity.nbaGameId,
homeTeam = entity.homeTeam,
awayTeam = entity.awayTeam,
gameDate = entity.gameDate,
gameTime = entity.gameTime,
gameStatus = entity.gameStatus,
homeScore = entity.homeScore,
awayScore = entity.awayScore,
period = entity.period,
timeRemaining = entity.timeRemaining,
polymarketMarketId = entity.polymarketMarketId
)
}
/**
* 获取 7 天内的所有球队(去重)
*/
suspend fun getTeamsInNext7Days(): Result<List<String>> {
return try {
// 使用当前西8区时间计算7天范围
val pstZone = ZoneId.of("America/Los_Angeles")
val now = ZonedDateTime.now(pstZone)
val startTimestamp = now.toInstant().toEpochMilli()
val endTimestamp = now.plusDays(7).toInstant().toEpochMilli()
val gamesResult = getNbaGames(
NbaGameListRequest(
startTimestamp = startTimestamp,
endTimestamp = endTimestamp
)
)
gamesResult.fold(
onSuccess = { response ->
val teams = mutableSetOf<String>()
response.list.forEach { game ->
teams.add(game.homeTeam)
teams.add(game.awayTeam)
}
Result.success(teams.sorted())
},
onFailure = { exception -> Result.failure(exception) }
)
} catch (e: Exception) {
logger.error("获取球队列表失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 将 Polymarket 市场转换为比赛数据
*/
private fun convertMarketToGame(
market: com.wrbug.polymarketbot.api.MarketResponse,
startDate: LocalDate,
endDate: LocalDate
): NbaGameDto? {
if (market.question.isNullOrBlank()) {
return null
}
// 解析市场名称,提取球队和日期信息
val parsed = NbaMarketNameParser.parse(market.question)
if (parsed.homeTeam == null || parsed.awayTeam == null) {
// 无法解析出两个球队,跳过
return null
}
// 确定比赛日期
val gameDate = parsed.gameDate ?: run {
// 如果没有解析出日期,尝试从 startDate 或 endDate 中提取
parseDateFromMarketDates(market.startDate, market.endDate) ?: return null
}
// 检查日期是否在请求范围内
if (gameDate.isBefore(startDate) || gameDate.isAfter(endDate)) {
return null
}
// 解析比赛时间(从 endDate 或 startDate 中提取,转换为西8区时间戳)
val gameTime = parseGameTimeFromMarket(market.startDate, market.endDate, gameDate)
// 确定比赛状态
val gameStatus = when {
market.closed == true -> "finished"
market.archived == true -> "finished"
market.active == true -> "scheduled"
else -> "scheduled"
}
return NbaGameDto(
id = null,
nbaGameId = market.conditionId ?: market.id, // 使用 conditionId 或 id 作为 gameId
homeTeam = parsed.homeTeam,
awayTeam = parsed.awayTeam,
gameDate = gameDate,
gameTime = gameTime, // 西8区时间戳(毫秒)
gameStatus = gameStatus,
homeScore = 0, // Polymarket 不提供比分
awayScore = 0,
period = 0,
timeRemaining = null,
polymarketMarketId = market.id
)
}
/**
* 从市场的 startDate 或 endDate 中解析日期
*/
private fun parseDateFromMarketDates(startDate: String?, endDate: String?): LocalDate? {
val dateStr = endDate ?: startDate ?: return null
return try {
// 尝试解析 ISO 8601 格式
if (dateStr.contains("T")) {
val instant = Instant.parse(dateStr)
val pstZone = ZoneId.of("America/Los_Angeles")
instant.atZone(pstZone).toLocalDate()
} else {
// 尝试解析日期字符串
LocalDate.parse(dateStr, DateTimeFormatter.ISO_DATE)
}
} catch (e: Exception) {
logger.debug("解析市场日期失败: $dateStr, error: ${e.message}")
null
}
}
/**
* 从市场的日期时间中解析比赛时间,转换为西8区时间戳
*/
private fun parseGameTimeFromMarket(
startDate: String?,
endDate: String?,
gameDate: LocalDate
): Long? {
val dateTimeStr = endDate ?: startDate ?: return null
return try {
val pstZone = ZoneId.of("America/Los_Angeles")
// 尝试解析 ISO 8601 格式
val instant = if (dateTimeStr.contains("T")) {
Instant.parse(dateTimeStr)
} else {
// 如果没有时间部分,使用默认时间(晚上8点)
val defaultTime = gameDate.atTime(20, 0)
defaultTime.atZone(pstZone).toInstant()
}
// 转换为西8区时间戳
instant.atZone(pstZone).toInstant().toEpochMilli()
} catch (e: Exception) {
logger.debug("解析比赛时间失败: $dateTimeStr, error: ${e.message}")
// 解析失败时,使用默认时间(晚上8点 PST)
try {
val defaultTime = gameDate.atTime(20, 0)
val pstZone = ZoneId.of("America/Los_Angeles")
defaultTime.atZone(pstZone).toInstant().toEpochMilli()
} catch (e2: Exception) {
null
}
}
}
}
@@ -0,0 +1,314 @@
package com.wrbug.polymarketbot.service.nba
import org.slf4j.LoggerFactory
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.regex.Pattern
/**
* NBA 市场名称解析器
* 从 Polymarket 市场名称中提取球队和日期信息
*/
object NbaMarketNameParser {
private val logger = LoggerFactory.getLogger(NbaMarketNameParser::class.java)
// NBA 球队名称映射(支持多种格式)
private val teamNameMapping = mapOf(
// 完整名称
"atlanta hawks" to "Atlanta Hawks",
"boston celtics" to "Boston Celtics",
"brooklyn nets" to "Brooklyn Nets",
"charlotte hornets" to "Charlotte Hornets",
"chicago bulls" to "Chicago Bulls",
"cleveland cavaliers" to "Cleveland Cavaliers",
"dallas mavericks" to "Dallas Mavericks",
"denver nuggets" to "Denver Nuggets",
"detroit pistons" to "Detroit Pistons",
"golden state warriors" to "Golden State Warriors",
"houston rockets" to "Houston Rockets",
"indiana pacers" to "Indiana Pacers",
"la clippers" to "LA Clippers",
"los angeles lakers" to "Los Angeles Lakers",
"memphis grizzlies" to "Memphis Grizzlies",
"miami heat" to "Miami Heat",
"milwaukee bucks" to "Milwaukee Bucks",
"minnesota timberwolves" to "Minnesota Timberwolves",
"new orleans pelicans" to "New Orleans Pelicans",
"new york knicks" to "New York Knicks",
"oklahoma city thunder" to "Oklahoma City Thunder",
"orlando magic" to "Orlando Magic",
"philadelphia 76ers" to "Philadelphia 76ers",
"phoenix suns" to "Phoenix Suns",
"portland trail blazers" to "Portland Trail Blazers",
"sacramento kings" to "Sacramento Kings",
"san antonio spurs" to "San Antonio Spurs",
"toronto raptors" to "Toronto Raptors",
"utah jazz" to "Utah Jazz",
"washington wizards" to "Washington Wizards",
// 常见缩写和别名
"hawks" to "Atlanta Hawks",
"celtics" to "Boston Celtics",
"nets" to "Brooklyn Nets",
"hornets" to "Charlotte Hornets",
"bulls" to "Chicago Bulls",
"cavaliers" to "Cleveland Cavaliers",
"cavs" to "Cleveland Cavaliers",
"mavericks" to "Dallas Mavericks",
"mavs" to "Dallas Mavericks",
"nuggets" to "Denver Nuggets",
"pistons" to "Detroit Pistons",
"warriors" to "Golden State Warriors",
"rockets" to "Houston Rockets",
"pacers" to "Indiana Pacers",
"clippers" to "LA Clippers",
"lakers" to "Los Angeles Lakers",
"grizzlies" to "Memphis Grizzlies",
"heat" to "Miami Heat",
"bucks" to "Milwaukee Bucks",
"timberwolves" to "Minnesota Timberwolves",
"wolves" to "Minnesota Timberwolves",
"pelicans" to "New Orleans Pelicans",
"knicks" to "New York Knicks",
"thunder" to "Oklahoma City Thunder",
"magic" to "Orlando Magic",
"76ers" to "Philadelphia 76ers",
"sixers" to "Philadelphia 76ers",
"suns" to "Phoenix Suns",
"trail blazers" to "Portland Trail Blazers",
"blazers" to "Portland Trail Blazers",
"kings" to "Sacramento Kings",
"spurs" to "San Antonio Spurs",
"raptors" to "Toronto Raptors",
"jazz" to "Utah Jazz",
"wizards" to "Washington Wizards",
"wiz" to "Washington Wizards"
)
/**
* 解析结果
*/
data class ParsedMarketInfo(
val homeTeam: String?,
val awayTeam: String?,
val gameDate: LocalDate?,
val confidence: Double // 置信度 0.0-1.0
)
/**
* 解析市场名称
* @param marketName 市场名称
* @return 解析结果
*/
fun parse(marketName: String?): ParsedMarketInfo {
if (marketName.isNullOrBlank()) {
return ParsedMarketInfo(null, null, null, 0.0)
}
val normalized = marketName.lowercase()
var homeTeam: String? = null
var awayTeam: String? = null
var gameDate: LocalDate? = null
var confidence = 0.0
// 尝试提取球队名称
val teams = extractTeams(normalized)
if (teams.size >= 2) {
// 通常第一个是客队,第二个是主队
awayTeam = teams[0]
homeTeam = teams[1]
confidence += 0.5
} else if (teams.size == 1) {
// 只有一个球队,无法确定主客场
awayTeam = teams[0]
confidence += 0.2
}
// 尝试提取日期
val date = extractDate(normalized)
if (date != null) {
gameDate = date
confidence += 0.3
}
return ParsedMarketInfo(homeTeam, awayTeam, gameDate, confidence.coerceAtMost(1.0))
}
/**
* 提取球队名称
*/
private fun extractTeams(text: String): List<String> {
val teams = mutableListOf<String>()
// 常见的球队名称模式
val patterns = listOf(
// "Team1 vs Team2" 或 "Team1 @ Team2"
Pattern.compile("(\\w+(?:\\s+\\w+)*?)\\s+(?:vs|@|v\\.?|versus)\\s+(\\w+(?:\\s+\\w+)*?)", Pattern.CASE_INSENSITIVE),
// "Will Team1 beat Team2"
Pattern.compile("will\\s+(\\w+(?:\\s+\\w+)*?)\\s+beat\\s+(\\w+(?:\\s+\\w+)*?)", Pattern.CASE_INSENSITIVE),
// "Team1 win" 或 "Team1 wins"
Pattern.compile("(\\w+(?:\\s+\\w+)*?)\\s+win", Pattern.CASE_INSENSITIVE)
)
for (pattern in patterns) {
val matcher = pattern.matcher(text)
if (matcher.find()) {
val team1 = normalizeTeamName(matcher.group(1) ?: "")
val team2 = if (matcher.groupCount() >= 2) {
normalizeTeamName(matcher.group(2) ?: "")
} else null
if (team1 != null) {
teams.add(team1)
}
if (team2 != null) {
teams.add(team2)
}
if (teams.size >= 2) {
break
}
}
}
// 如果模式匹配失败,尝试直接查找球队名称
if (teams.isEmpty()) {
for ((key, value) in teamNameMapping) {
if (text.contains(key, ignoreCase = true)) {
if (!teams.contains(value)) {
teams.add(value)
}
}
}
}
return teams.distinct()
}
/**
* 标准化球队名称
*/
private fun normalizeTeamName(name: String): String? {
val normalized = name.trim().lowercase()
return teamNameMapping[normalized] ?: teamNameMapping.entries.firstOrNull {
normalized.contains(it.key, ignoreCase = true)
}?.value
}
/**
* 提取日期
*/
private fun extractDate(text: String): LocalDate? {
// 尝试多种日期格式
try {
// 格式1: "Dec 15, 2024" 或 "December 15, 2024"
val pattern1 = Pattern.compile("(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\\s+(\\d{1,2}),?\\s+(\\d{4})", Pattern.CASE_INSENSITIVE)
val matcher1 = pattern1.matcher(text)
if (matcher1.find()) {
val monthStr = matcher1.group(1)?.lowercase() ?: return null
val day = matcher1.group(2)?.toIntOrNull() ?: return null
val year = matcher1.group(3)?.toIntOrNull() ?: return null
val monthMap = mapOf(
"jan" to 1, "january" to 1,
"feb" to 2, "february" to 2,
"mar" to 3, "march" to 3,
"apr" to 4, "april" to 4,
"may" to 5,
"jun" to 6, "june" to 6,
"jul" to 7, "july" to 7,
"aug" to 8, "august" to 8,
"sep" to 9, "september" to 9,
"oct" to 10, "october" to 10,
"nov" to 11, "november" to 11,
"dec" to 12, "december" to 12
)
val month = monthMap.entries.firstOrNull { monthStr.startsWith(it.key) }?.value
if (month != null) {
return try {
LocalDate.of(year, month, day)
} catch (e: Exception) {
null
}
}
}
// 格式2: "2024-12-15"
val pattern2 = Pattern.compile("(\\d{4})[-/](\\d{1,2})[-/](\\d{1,2})")
val matcher2 = pattern2.matcher(text)
if (matcher2.find()) {
val year = matcher2.group(1)?.toIntOrNull() ?: return null
val month = matcher2.group(2)?.toIntOrNull() ?: return null
val day = matcher2.group(3)?.toIntOrNull() ?: return null
return try {
LocalDate.of(year, month, day)
} catch (e: Exception) {
null
}
}
// 格式3: "12/15/2024" 或 "12/15/24"
val pattern3 = Pattern.compile("(\\d{1,2})/(\\d{1,2})/(\\d{2,4})")
val matcher3 = pattern3.matcher(text)
if (matcher3.find()) {
val month = matcher3.group(1)?.toIntOrNull() ?: return null
val day = matcher3.group(2)?.toIntOrNull() ?: return null
val yearStr = matcher3.group(3) ?: return null
val year = if (yearStr.length == 2) {
// 两位年份,假设是 2000-2099
val y = yearStr.toIntOrNull() ?: return null
if (y < 50) 2000 + y else 1900 + y
} else {
yearStr.toIntOrNull() ?: return null
}
return try {
LocalDate.of(year, month, day)
} catch (e: Exception) {
null
}
}
} catch (e: Exception) {
logger.debug("解析日期失败: ${e.message}")
}
return null
}
/**
* 匹配比赛和市场
* @param homeTeam 主队名称
* @param awayTeam 客队名称
* @param gameDate 比赛日期
* @param parsedMarket 解析的市场信息
* @return 是否匹配
*/
fun matchGame(
homeTeam: String,
awayTeam: String,
gameDate: LocalDate,
parsedMarket: ParsedMarketInfo
): Boolean {
// 检查日期是否匹配(允许1天误差)
val dateMatch = parsedMarket.gameDate?.let { marketDate ->
val daysDiff = kotlin.math.abs(java.time.temporal.ChronoUnit.DAYS.between(gameDate, marketDate))
daysDiff <= 1
} ?: false
if (!dateMatch && parsedMarket.gameDate != null) {
return false
}
// 检查球队是否匹配
val homeMatch = parsedMarket.homeTeam?.let {
normalizeTeamName(it)?.equals(normalizeTeamName(homeTeam), ignoreCase = true)
} ?: false
val awayMatch = parsedMarket.awayTeam?.let {
normalizeTeamName(it)?.equals(normalizeTeamName(awayTeam), ignoreCase = true)
} ?: false
// 如果两个球队都匹配,或者至少一个匹配且日期匹配
return (homeMatch && awayMatch) || ((homeMatch || awayMatch) && dateMatch)
}
}
@@ -0,0 +1,165 @@
package com.wrbug.polymarketbot.service.nba
import com.wrbug.polymarketbot.api.MarketResponse
import com.wrbug.polymarketbot.api.PolymarketGammaApi
import com.wrbug.polymarketbot.dto.NbaMarketDto
import com.wrbug.polymarketbot.dto.NbaMarketListRequest
import com.wrbug.polymarketbot.dto.NbaMarketListResponse
import com.wrbug.polymarketbot.enums.SportsTagId
import com.wrbug.polymarketbot.util.RetrofitFactory
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
/**
* NBA 市场服务
* 用于从 Polymarket 获取 NBA 相关的市场信息
*/
@Service
class NbaMarketService(
private val retrofitFactory: RetrofitFactory
) {
private val logger = LoggerFactory.getLogger(NbaMarketService::class.java)
/**
* 获取 NBA 的 tag ID 列表
* 直接使用枚举中定义的已知 tag ID,无需调用 API
*/
suspend fun getNbaTagIds(): Result<List<String>> {
// 直接使用枚举中定义的 NBA tag ID
val nbaTagId = SportsTagId.NBA.tagId
logger.debug("使用枚举中的 NBA tag ID: $nbaTagId")
return Result.success(listOf(nbaTagId))
}
/**
* 获取 NBA 市场列表
* 使用 NBA 的 tag IDs 过滤市场
*
* @param request 请求参数
* @return NBA 市场列表响应
*/
suspend fun getNbaMarkets(request: NbaMarketListRequest): Result<NbaMarketListResponse> {
return try {
// 先获取 NBA 的 tag IDs
val tagIdsResult = getNbaTagIds()
if (tagIdsResult.isFailure) {
return Result.failure(tagIdsResult.exceptionOrNull() ?: Exception("无法获取 NBA tag IDs"))
}
val tagIds = tagIdsResult.getOrNull() ?: return Result.failure(IllegalStateException("NBA tag IDs 为空"))
if (tagIds.isEmpty()) {
logger.warn("NBA tag IDs 为空,无法过滤市场")
return Result.success(NbaMarketListResponse(
list = emptyList(),
total = 0L
))
}
// 调用 /markets 接口,使用 tag IDs 过滤
val gammaApi = retrofitFactory.createGammaApi()
val response = gammaApi.listMarkets(
conditionIds = null,
includeTag = true,
tags = tagIds,
active = request.active,
closed = request.closed,
archived = request.archived
)
if (response.isSuccessful && response.body() != null) {
val markets = response.body()!!
logger.info("获取到 ${markets.size} 个 NBA 市场")
// 转换为 DTO
val marketDtos = markets.map { market ->
NbaMarketDto(
id = market.id,
question = market.question,
conditionId = market.conditionId,
slug = market.slug,
description = market.description,
category = market.category,
active = market.active,
closed = market.closed,
archived = market.archived,
volume = market.volume,
liquidity = market.liquidity,
endDate = market.endDate,
startDate = market.startDate,
outcomes = market.outcomes,
outcomePrices = market.outcomePrices,
volumeNum = market.volumeNum,
liquidityNum = market.liquidityNum,
lastTradePrice = market.lastTradePrice,
bestBid = market.bestBid,
bestAsk = market.bestAsk
)
}
Result.success(NbaMarketListResponse(
list = marketDtos,
total = marketDtos.size.toLong()
))
} else {
logger.error("获取 NBA 市场失败: ${response.code()} ${response.message()}")
val errorBody = response.errorBody()?.string()
logger.error("错误响应体: $errorBody")
Result.failure(Exception("获取 NBA 市场失败: ${response.code()} ${response.message()}"))
}
} catch (e: Exception) {
logger.error("获取 NBA 市场异常: ${e.message}", e)
Result.failure(e)
}
}
/**
* 从 NBA 市场中提取球队列表
* 解析市场名称,提取所有唯一的球队名称
*
* @param active 是否只从活跃市场提取(默认 true)
* @return 球队名称列表(去重、排序)
*/
suspend fun getTeamsFromMarkets(active: Boolean = true): Result<List<String>> {
return try {
// 获取 NBA 市场列表
val marketsResult = getNbaMarkets(
NbaMarketListRequest(
active = active,
closed = false,
archived = false
)
)
if (marketsResult.isFailure) {
return Result.failure(marketsResult.exceptionOrNull() ?: Exception("无法获取 NBA 市场"))
}
val markets = marketsResult.getOrNull()?.list ?: return Result.success(emptyList())
// 使用市场名称解析器提取球队
val teams = mutableSetOf<String>()
markets.forEach { market ->
if (!market.question.isNullOrBlank()) {
val parsed = NbaMarketNameParser.parse(market.question)
if (parsed != null) {
// 提取主队和客队
parsed.homeTeam?.let { teams.add(it) }
parsed.awayTeam?.let { teams.add(it) }
}
}
}
// 排序并返回
val sortedTeams = teams.sorted()
logger.info("${markets.size} 个市场中提取到 ${sortedTeams.size} 个球队")
Result.success(sortedTeams)
} catch (e: Exception) {
logger.error("从市场提取球队列表失败: ${e.message}", e)
Result.failure(e)
}
}
}
@@ -0,0 +1,346 @@
package com.wrbug.polymarketbot.service.nba
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.entity.NbaQuantitativeStrategy
import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.repository.NbaQuantitativeStrategyRepository
import com.wrbug.polymarketbot.util.JsonUtils
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import org.slf4j.LoggerFactory
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
/**
* NBA 量化策略服务
*/
@Service
class NbaQuantitativeStrategyService(
private val strategyRepository: NbaQuantitativeStrategyRepository,
private val accountRepository: AccountRepository
) {
private val logger = LoggerFactory.getLogger(NbaQuantitativeStrategyService::class.java)
/**
* 创建策略
*/
@Transactional
suspend fun createStrategy(request: NbaQuantitativeStrategyCreateRequest): Result<NbaQuantitativeStrategyDto> {
return try {
// 验证账户是否存在
val account = accountRepository.findById(request.accountId).orElse(null)
if (account == null) {
return Result.failure(IllegalArgumentException("账户不存在"))
}
// 验证策略名称是否重复
val existing = strategyRepository.findByStrategyName(request.strategyName)
if (existing != null) {
return Result.failure(IllegalArgumentException("策略名称已存在"))
}
// 创建策略实体
val strategy = NbaQuantitativeStrategy(
strategyName = request.strategyName,
strategyDescription = request.strategyDescription,
accountId = request.accountId,
enabled = request.enabled,
filterTeams = request.filterTeams?.let { JsonUtils.toJson(it) },
filterDateFrom = request.filterDateFrom,
filterDateTo = request.filterDateTo,
filterGameImportance = request.filterGameImportance,
minWinProbabilityDiff = request.minWinProbabilityDiff ?: BigDecimal("0.1"),
minWinProbability = request.minWinProbability,
maxWinProbability = request.maxWinProbability,
minTradeValue = request.minTradeValue ?: BigDecimal("0.05"),
minRemainingTime = request.minRemainingTime,
maxRemainingTime = request.maxRemainingTime,
minScoreDiff = request.minScoreDiff,
maxScoreDiff = request.maxScoreDiff,
buyAmountStrategy = request.buyAmountStrategy ?: "FIXED",
fixedBuyAmount = request.fixedBuyAmount,
buyRatio = request.buyRatio,
baseBuyAmount = request.baseBuyAmount,
buyTiming = request.buyTiming ?: "IMMEDIATE",
delayBuySeconds = request.delayBuySeconds ?: 0,
buyDirection = request.buyDirection ?: "AUTO",
enableSell = request.enableSell ?: true,
takeProfitThreshold = request.takeProfitThreshold,
stopLossThreshold = request.stopLossThreshold,
probabilityReversalThreshold = request.probabilityReversalThreshold,
sellRatio = request.sellRatio ?: BigDecimal("1.0"),
sellTiming = request.sellTiming ?: "IMMEDIATE",
delaySellSeconds = request.delaySellSeconds ?: 0,
priceStrategy = request.priceStrategy ?: "MARKET",
fixedPrice = request.fixedPrice,
priceOffset = request.priceOffset ?: BigDecimal.ZERO,
maxPosition = request.maxPosition ?: BigDecimal("50"),
minPosition = request.minPosition ?: BigDecimal("5"),
maxGamePosition = request.maxGamePosition,
maxDailyLoss = request.maxDailyLoss,
maxDailyOrders = request.maxDailyOrders,
maxDailyProfit = request.maxDailyProfit,
priceTolerance = request.priceTolerance ?: BigDecimal("0.05"),
minProbabilityThreshold = request.minProbabilityThreshold,
maxProbabilityThreshold = request.maxProbabilityThreshold,
baseStrengthWeight = request.baseStrengthWeight ?: BigDecimal("0.3"),
recentFormWeight = request.recentFormWeight ?: BigDecimal("0.25"),
lineupIntegrityWeight = request.lineupIntegrityWeight ?: BigDecimal("0.2"),
starStatusWeight = request.starStatusWeight ?: BigDecimal("0.15"),
environmentWeight = request.environmentWeight ?: BigDecimal("0.1"),
matchupAdvantageWeight = request.matchupAdvantageWeight ?: BigDecimal("0.2"),
scoreDiffWeight = request.scoreDiffWeight ?: BigDecimal("0.3"),
momentumWeight = request.momentumWeight ?: BigDecimal("0.2"),
dataUpdateFrequency = request.dataUpdateFrequency ?: 30,
analysisFrequency = request.analysisFrequency ?: 30,
pushFailedOrders = request.pushFailedOrders ?: false,
pushFrequency = request.pushFrequency ?: "REALTIME",
batchPushInterval = request.batchPushInterval ?: 1
)
val saved = strategyRepository.save(strategy)
Result.success(toDto(saved))
} catch (e: Exception) {
logger.error("创建策略失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 更新策略
*/
@Transactional
suspend fun updateStrategy(request: NbaQuantitativeStrategyUpdateRequest): Result<NbaQuantitativeStrategyDto> {
return try {
val strategy = strategyRepository.findById(request.id).orElse(null)
if (strategy == null) {
return Result.failure(IllegalArgumentException("策略不存在"))
}
// 更新字段(只更新提供的字段)
val updated = strategy.copy(
strategyName = request.strategyName ?: strategy.strategyName,
strategyDescription = request.strategyDescription ?: strategy.strategyDescription,
enabled = request.enabled ?: strategy.enabled,
filterTeams = request.filterTeams?.let { JsonUtils.toJson(it) } ?: strategy.filterTeams,
filterDateFrom = request.filterDateFrom ?: strategy.filterDateFrom,
filterDateTo = request.filterDateTo ?: strategy.filterDateTo,
filterGameImportance = request.filterGameImportance ?: strategy.filterGameImportance,
minWinProbabilityDiff = request.minWinProbabilityDiff ?: strategy.minWinProbabilityDiff,
minWinProbability = request.minWinProbability ?: strategy.minWinProbability,
maxWinProbability = request.maxWinProbability ?: strategy.maxWinProbability,
minTradeValue = request.minTradeValue ?: strategy.minTradeValue,
minRemainingTime = request.minRemainingTime ?: strategy.minRemainingTime,
maxRemainingTime = request.maxRemainingTime ?: strategy.maxRemainingTime,
minScoreDiff = request.minScoreDiff ?: strategy.minScoreDiff,
maxScoreDiff = request.maxScoreDiff ?: strategy.maxScoreDiff,
buyAmountStrategy = request.buyAmountStrategy ?: strategy.buyAmountStrategy,
fixedBuyAmount = request.fixedBuyAmount ?: strategy.fixedBuyAmount,
buyRatio = request.buyRatio ?: strategy.buyRatio,
baseBuyAmount = request.baseBuyAmount ?: strategy.baseBuyAmount,
buyTiming = request.buyTiming ?: strategy.buyTiming,
delayBuySeconds = request.delayBuySeconds ?: strategy.delayBuySeconds,
buyDirection = request.buyDirection ?: strategy.buyDirection,
enableSell = request.enableSell ?: strategy.enableSell,
takeProfitThreshold = request.takeProfitThreshold ?: strategy.takeProfitThreshold,
stopLossThreshold = request.stopLossThreshold ?: strategy.stopLossThreshold,
probabilityReversalThreshold = request.probabilityReversalThreshold ?: strategy.probabilityReversalThreshold,
sellRatio = request.sellRatio ?: strategy.sellRatio,
sellTiming = request.sellTiming ?: strategy.sellTiming,
delaySellSeconds = request.delaySellSeconds ?: strategy.delaySellSeconds,
priceStrategy = request.priceStrategy ?: strategy.priceStrategy,
fixedPrice = request.fixedPrice ?: strategy.fixedPrice,
priceOffset = request.priceOffset ?: strategy.priceOffset,
maxPosition = request.maxPosition ?: strategy.maxPosition,
minPosition = request.minPosition ?: strategy.minPosition,
maxGamePosition = request.maxGamePosition ?: strategy.maxGamePosition,
maxDailyLoss = request.maxDailyLoss ?: strategy.maxDailyLoss,
maxDailyOrders = request.maxDailyOrders ?: strategy.maxDailyOrders,
maxDailyProfit = request.maxDailyProfit ?: strategy.maxDailyProfit,
priceTolerance = request.priceTolerance ?: strategy.priceTolerance,
minProbabilityThreshold = request.minProbabilityThreshold ?: strategy.minProbabilityThreshold,
maxProbabilityThreshold = request.maxProbabilityThreshold ?: strategy.maxProbabilityThreshold,
baseStrengthWeight = request.baseStrengthWeight ?: strategy.baseStrengthWeight,
recentFormWeight = request.recentFormWeight ?: strategy.recentFormWeight,
lineupIntegrityWeight = request.lineupIntegrityWeight ?: strategy.lineupIntegrityWeight,
starStatusWeight = request.starStatusWeight ?: strategy.starStatusWeight,
environmentWeight = request.environmentWeight ?: strategy.environmentWeight,
matchupAdvantageWeight = request.matchupAdvantageWeight ?: strategy.matchupAdvantageWeight,
scoreDiffWeight = request.scoreDiffWeight ?: strategy.scoreDiffWeight,
momentumWeight = request.momentumWeight ?: strategy.momentumWeight,
dataUpdateFrequency = request.dataUpdateFrequency ?: strategy.dataUpdateFrequency,
analysisFrequency = request.analysisFrequency ?: strategy.analysisFrequency,
pushFailedOrders = request.pushFailedOrders ?: strategy.pushFailedOrders,
pushFrequency = request.pushFrequency ?: strategy.pushFrequency,
batchPushInterval = request.batchPushInterval ?: strategy.batchPushInterval,
updatedAt = System.currentTimeMillis()
)
val saved = strategyRepository.save(updated)
Result.success(toDto(saved))
} catch (e: Exception) {
logger.error("更新策略失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 获取策略列表
*/
suspend fun getStrategyList(request: NbaQuantitativeStrategyListRequest): Result<NbaQuantitativeStrategyListResponse> {
return try {
val page = request.page ?: 1
val limit = request.limit ?: 20
val pageable = PageRequest.of(page - 1, limit, Sort.by(Sort.Direction.DESC, "createdAt"))
val strategies = when {
request.accountId != null && request.enabled != null -> {
strategyRepository.findByAccountIdAndEnabled(request.accountId, request.enabled)
}
request.accountId != null -> {
strategyRepository.findByAccountId(request.accountId)
}
request.enabled != null -> {
strategyRepository.findByEnabled(request.enabled)
}
else -> {
strategyRepository.findAll(pageable).content
}
}
// 过滤策略名称(如果提供)
val filtered = if (request.strategyName != null) {
strategies.filter { it.strategyName.contains(request.strategyName, ignoreCase = true) }
} else {
strategies
}
val total = filtered.size.toLong()
val dtoList = filtered.map { toDto(it) }
Result.success(
NbaQuantitativeStrategyListResponse(
list = dtoList,
total = total,
page = page,
limit = limit
)
)
} catch (e: Exception) {
logger.error("获取策略列表失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 获取策略详情
*/
suspend fun getStrategyDetail(id: Long): Result<NbaQuantitativeStrategyDto> {
return try {
val strategy = strategyRepository.findById(id).orElse(null)
if (strategy == null) {
return Result.failure(IllegalArgumentException("策略不存在"))
}
Result.success(toDto(strategy))
} catch (e: Exception) {
logger.error("获取策略详情失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 删除策略
*/
@Transactional
suspend fun deleteStrategy(id: Long): Result<Unit> {
return try {
val strategy = strategyRepository.findById(id).orElse(null)
if (strategy == null) {
return Result.failure(IllegalArgumentException("策略不存在"))
}
strategyRepository.delete(strategy)
Result.success(Unit)
} catch (e: Exception) {
logger.error("删除策略失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 获取启用的策略列表
*/
suspend fun getEnabledStrategies(): List<NbaQuantitativeStrategy> {
return strategyRepository.findByEnabled(true)
}
/**
* 转换为 DTO
*/
private fun toDto(strategy: NbaQuantitativeStrategy): NbaQuantitativeStrategyDto {
val account = accountRepository.findById(strategy.accountId).orElse(null)
return NbaQuantitativeStrategyDto(
id = strategy.id,
strategyName = strategy.strategyName,
strategyDescription = strategy.strategyDescription,
accountId = strategy.accountId,
accountName = account?.accountName,
enabled = strategy.enabled,
filterTeams = strategy.filterTeams?.let { JsonUtils.parseStringList(it) },
filterDateFrom = strategy.filterDateFrom,
filterDateTo = strategy.filterDateTo,
filterGameImportance = strategy.filterGameImportance,
minWinProbabilityDiff = strategy.minWinProbabilityDiff,
minWinProbability = strategy.minWinProbability,
maxWinProbability = strategy.maxWinProbability,
minTradeValue = strategy.minTradeValue,
minRemainingTime = strategy.minRemainingTime,
maxRemainingTime = strategy.maxRemainingTime,
minScoreDiff = strategy.minScoreDiff,
maxScoreDiff = strategy.maxScoreDiff,
buyAmountStrategy = strategy.buyAmountStrategy,
fixedBuyAmount = strategy.fixedBuyAmount,
buyRatio = strategy.buyRatio,
baseBuyAmount = strategy.baseBuyAmount,
buyTiming = strategy.buyTiming,
delayBuySeconds = strategy.delayBuySeconds,
buyDirection = strategy.buyDirection,
enableSell = strategy.enableSell,
takeProfitThreshold = strategy.takeProfitThreshold,
stopLossThreshold = strategy.stopLossThreshold,
probabilityReversalThreshold = strategy.probabilityReversalThreshold,
sellRatio = strategy.sellRatio,
sellTiming = strategy.sellTiming,
delaySellSeconds = strategy.delaySellSeconds,
priceStrategy = strategy.priceStrategy,
fixedPrice = strategy.fixedPrice,
priceOffset = strategy.priceOffset,
maxPosition = strategy.maxPosition,
minPosition = strategy.minPosition,
maxGamePosition = strategy.maxGamePosition,
maxDailyLoss = strategy.maxDailyLoss,
maxDailyOrders = strategy.maxDailyOrders,
maxDailyProfit = strategy.maxDailyProfit,
priceTolerance = strategy.priceTolerance,
minProbabilityThreshold = strategy.minProbabilityThreshold,
maxProbabilityThreshold = strategy.maxProbabilityThreshold,
baseStrengthWeight = strategy.baseStrengthWeight,
recentFormWeight = strategy.recentFormWeight,
lineupIntegrityWeight = strategy.lineupIntegrityWeight,
starStatusWeight = strategy.starStatusWeight,
environmentWeight = strategy.environmentWeight,
matchupAdvantageWeight = strategy.matchupAdvantageWeight,
scoreDiffWeight = strategy.scoreDiffWeight,
momentumWeight = strategy.momentumWeight,
dataUpdateFrequency = strategy.dataUpdateFrequency,
analysisFrequency = strategy.analysisFrequency,
pushFailedOrders = strategy.pushFailedOrders,
pushFrequency = strategy.pushFrequency,
batchPushInterval = strategy.batchPushInterval,
createdAt = strategy.createdAt,
updatedAt = strategy.updatedAt
)
}
}
@@ -28,5 +28,29 @@ object JsonUtils {
emptyList()
}
}
/**
* 解析 JSON 字符串列表(parseStringArray 的别名)
*/
fun parseStringList(jsonString: String?): List<String> {
return parseStringArray(jsonString)
}
/**
* 将对象转换为 JSON 字符串
* @param obj 要转换的对象
* @return JSON 字符串
*/
fun toJson(obj: Any?): String? {
if (obj == null) {
return null
}
return try {
gson.toJson(obj)
} catch (e: Exception) {
null
}
}
}
@@ -0,0 +1,64 @@
package com.wrbug.polymarketbot.util
import com.wrbug.polymarketbot.api.NbaStatsApi
import org.slf4j.LoggerFactory
import java.time.LocalDate
import java.time.format.DateTimeFormatter
/**
* NBA API 验证工具
* 用于验证 API 调用是否正确
*/
object NbaApiValidator {
private val logger = LoggerFactory.getLogger(NbaApiValidator::class.java)
/**
* 验证 API 调用
*/
suspend fun validateApi(nbaStatsApi: NbaStatsApi): Boolean {
return try {
val today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))
logger.info("验证 NBA Stats API,日期: $today")
val response = nbaStatsApi.getScoreboard(gameDate = today)
logger.info("API 响应状态码: ${response.code()}")
logger.info("API 响应消息: ${response.message()}")
if (response.isSuccessful && response.body() != null) {
val scoreboard = response.body()!!
logger.info("ResultSets 数量: ${scoreboard.resultSets.size}")
scoreboard.resultSets.forEachIndexed { index, resultSet ->
logger.info("ResultSet[$index]: name=${resultSet.name}, headers=${resultSet.headers.size}, rows=${resultSet.rowSet.size}")
if (resultSet.headers.isNotEmpty()) {
logger.info(" Headers: ${resultSet.headers.take(10)}")
}
if (resultSet.rowSet.isNotEmpty()) {
val firstRow = resultSet.rowSet.first()
logger.info(" First row size: ${firstRow.size}")
logger.info(" First row (first 5): ${firstRow.take(5)}")
}
}
// 检查是否有 GameHeader 和 LineScore
val hasGameHeader = scoreboard.resultSets.any { it.name == "GameHeader" }
val hasLineScore = scoreboard.resultSets.any { it.name == "LineScore" }
logger.info("包含 GameHeader: $hasGameHeader")
logger.info("包含 LineScore: $hasLineScore")
hasGameHeader && hasLineScore
} else {
logger.error("API 调用失败")
val errorBody = response.errorBody()?.string()
logger.error("错误响应体: $errorBody")
false
}
} catch (e: Exception) {
logger.error("验证 API 异常: ${e.message}", e)
false
}
}
}
@@ -5,6 +5,7 @@ import com.google.gson.GsonBuilder
import com.wrbug.polymarketbot.api.BuilderRelayerApi
import com.wrbug.polymarketbot.api.EthereumRpcApi
import com.wrbug.polymarketbot.api.GitHubApi
import com.wrbug.polymarketbot.api.NbaStatsApi
import com.wrbug.polymarketbot.api.PolymarketClobApi
import com.wrbug.polymarketbot.api.PolymarketDataApi
import com.wrbug.polymarketbot.api.PolymarketGammaApi
@@ -237,6 +238,44 @@ class RetrofitFactory(
.build()
.create(GitHubApi::class.java)
}
/**
* 创建 NBA Stats API 客户端
* NBA Stats API 是公开 API,但需要设置正确的请求头
* @return NbaStatsApi 客户端
*/
fun createNbaStatsApi(): NbaStatsApi {
val baseUrl = "https://stats.nba.com/stats/"
// 添加拦截器,设置 NBA Stats API 需要的请求头
val nbaStatsInterceptor = object : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().newBuilder()
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
.header("Referer", "https://www.nba.com/")
.header("Accept", "application/json")
.header("Accept-Language", "en-US,en;q=0.9")
.header("Origin", "https://www.nba.com")
.build()
return chain.proceed(request)
}
}
val okHttpClient = createClient()
.addInterceptor(nbaStatsInterceptor)
.build()
val gson = GsonBuilder()
.setLenient()
.create()
return Retrofit.Builder()
.baseUrl(baseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(NbaStatsApi::class.java)
}
}
/**
@@ -0,0 +1,184 @@
-- NBA 量化交易系统数据库表
-- 1. NBA 市场表(Polymarket 市场信息)
CREATE TABLE IF NOT EXISTS nba_markets (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
polymarket_market_id VARCHAR(100) UNIQUE NOT NULL COMMENT 'Polymarket 市场 ID',
condition_id VARCHAR(100) UNIQUE NOT NULL COMMENT 'Condition ID',
market_slug VARCHAR(255) COMMENT '市场 slug',
market_question TEXT COMMENT '市场名称/问题',
market_description TEXT COMMENT '市场描述',
category VARCHAR(50) DEFAULT 'sports' COMMENT '分类',
active BOOLEAN DEFAULT true COMMENT '是否活跃',
closed BOOLEAN DEFAULT false COMMENT '是否已关闭',
archived BOOLEAN DEFAULT false COMMENT '是否已归档',
volume VARCHAR(50) COMMENT '交易量',
liquidity VARCHAR(50) COMMENT '流动性',
outcomes TEXT COMMENT '结果选项(JSON',
end_date VARCHAR(50) COMMENT '结束日期',
start_date VARCHAR(50) COMMENT '开始日期',
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
INDEX idx_condition_id (condition_id),
INDEX idx_active (active),
INDEX idx_closed (closed),
INDEX idx_category (category)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='NBA市场表(Polymarket市场)';
-- 2. NBA 比赛表(NBA 比赛信息)
CREATE TABLE IF NOT EXISTS nba_games (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
nba_game_id VARCHAR(100) UNIQUE COMMENT 'NBA 比赛 ID(来自 NBA API',
home_team VARCHAR(100) NOT NULL COMMENT '主队名称',
away_team VARCHAR(100) NOT NULL COMMENT '客队名称',
game_date DATE NOT NULL COMMENT '比赛日期',
game_time BIGINT COMMENT '比赛时间(时间戳,毫秒)',
game_status VARCHAR(50) DEFAULT 'scheduled' COMMENT '比赛状态:scheduled/active/finished',
home_score INT DEFAULT 0 COMMENT '主队得分',
away_score INT DEFAULT 0 COMMENT '客队得分',
period INT DEFAULT 0 COMMENT '当前节次',
time_remaining VARCHAR(50) COMMENT '剩余时间',
polymarket_market_id VARCHAR(100) COMMENT '关联的 Polymarket 市场 ID',
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
INDEX idx_game_date (game_date),
INDEX idx_game_status (game_status),
INDEX idx_home_team (home_team),
INDEX idx_away_team (away_team),
INDEX idx_polymarket_market_id (polymarket_market_id),
FOREIGN KEY (polymarket_market_id) REFERENCES nba_markets(polymarket_market_id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='NBA比赛表';
-- 3. 量化策略配置表
CREATE TABLE IF NOT EXISTS nba_quantitative_strategies (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
strategy_name VARCHAR(100) NOT NULL COMMENT '策略名称',
strategy_description TEXT COMMENT '策略描述',
account_id BIGINT NOT NULL COMMENT '关联账户 ID',
enabled BOOLEAN DEFAULT true COMMENT '是否启用',
-- 比赛筛选参数
filter_teams TEXT COMMENT '关注的球队列表(JSON',
filter_date_from DATE COMMENT '日期范围开始',
filter_date_to DATE COMMENT '日期范围结束',
filter_game_importance VARCHAR(50) COMMENT '比赛重要性:all/regular/playoff/key',
-- 触发条件参数
min_win_probability_diff DECIMAL(5, 4) DEFAULT 0.1000 COMMENT '最小获胜概率差异',
min_win_probability DECIMAL(5, 4) COMMENT '最小获胜概率',
max_win_probability DECIMAL(5, 4) COMMENT '最大获胜概率',
min_trade_value DECIMAL(5, 4) DEFAULT 0.0500 COMMENT '最小交易价值',
min_remaining_time INT COMMENT '最小剩余时间(分钟)',
max_remaining_time INT COMMENT '最大剩余时间(分钟)',
min_score_diff INT COMMENT '最小分差',
max_score_diff INT COMMENT '最大分差',
-- 买入规则参数
buy_amount_strategy VARCHAR(20) DEFAULT 'FIXED' COMMENT '买入金额策略:FIXED/RATIO/DYNAMIC',
fixed_buy_amount DECIMAL(20, 8) COMMENT '固定买入金额(USDC',
buy_ratio DECIMAL(5, 4) COMMENT '买入比例(0-1',
base_buy_amount DECIMAL(20, 8) COMMENT '基础买入金额(USDC',
buy_timing VARCHAR(20) DEFAULT 'IMMEDIATE' COMMENT '买入时机:IMMEDIATE/DELAYED',
delay_buy_seconds INT DEFAULT 0 COMMENT '延迟买入时间(秒)',
buy_direction VARCHAR(10) DEFAULT 'AUTO' COMMENT '买入方向:AUTO/YES/NO',
-- 卖出规则参数
enable_sell BOOLEAN DEFAULT true COMMENT '是否启用卖出',
take_profit_threshold DECIMAL(5, 4) COMMENT '止盈阈值(0-1',
stop_loss_threshold DECIMAL(5, 4) COMMENT '止损阈值(-1-0',
probability_reversal_threshold DECIMAL(5, 4) COMMENT '概率反转阈值(0-1',
sell_ratio DECIMAL(5, 4) DEFAULT 1.0000 COMMENT '卖出比例(0-1',
sell_timing VARCHAR(20) DEFAULT 'IMMEDIATE' COMMENT '卖出时机:IMMEDIATE/DELAYED',
delay_sell_seconds INT DEFAULT 0 COMMENT '延迟卖出时间(秒)',
-- 价格策略参数
price_strategy VARCHAR(20) DEFAULT 'MARKET' COMMENT '价格策略:FIXED/MARKET/DYNAMIC',
fixed_price DECIMAL(5, 4) COMMENT '固定价格(0-1',
price_offset DECIMAL(5, 4) DEFAULT 0.0000 COMMENT '价格偏移(-0.1-0.1',
-- 风险控制参数
max_position DECIMAL(20, 8) DEFAULT 50.00000000 COMMENT '最大持仓(USDC',
min_position DECIMAL(20, 8) DEFAULT 5.00000000 COMMENT '最小持仓(USDC',
max_game_position DECIMAL(20, 8) COMMENT '单场比赛最大持仓(USDC',
max_daily_loss DECIMAL(20, 8) COMMENT '每日亏损限制(USDC',
max_daily_orders INT COMMENT '每日订单限制',
max_daily_profit DECIMAL(20, 8) COMMENT '每日盈利目标(USDC',
price_tolerance DECIMAL(5, 4) DEFAULT 0.0500 COMMENT '价格容忍度(0-1',
min_probability_threshold DECIMAL(5, 4) COMMENT '最小概率阈值(0.5-1.0',
max_probability_threshold DECIMAL(5, 4) COMMENT '最大概率阈值(0.0-0.5',
-- 算法权重参数(高级)
base_strength_weight DECIMAL(5, 4) DEFAULT 0.3000 COMMENT '基础实力权重',
recent_form_weight DECIMAL(5, 4) DEFAULT 0.2500 COMMENT '近期状态权重',
lineup_integrity_weight DECIMAL(5, 4) DEFAULT 0.2000 COMMENT '阵容完整度权重',
star_status_weight DECIMAL(5, 4) DEFAULT 0.1500 COMMENT '球星状态权重',
environment_weight DECIMAL(5, 4) DEFAULT 0.1000 COMMENT '环境因素权重',
matchup_advantage_weight DECIMAL(5, 4) DEFAULT 0.2000 COMMENT '对位优势权重',
score_diff_weight DECIMAL(5, 4) DEFAULT 0.3000 COMMENT '分差调整权重',
momentum_weight DECIMAL(5, 4) DEFAULT 0.2000 COMMENT '势头调整权重',
-- 系统配置参数
data_update_frequency INT DEFAULT 30 COMMENT '数据更新频率(秒)',
analysis_frequency INT DEFAULT 30 COMMENT '分析频率(秒)',
push_failed_orders BOOLEAN DEFAULT false COMMENT '是否推送失败订单',
push_frequency VARCHAR(20) DEFAULT 'REALTIME' COMMENT '推送频率:REALTIME/BATCH',
batch_push_interval INT DEFAULT 1 COMMENT '批量推送间隔(秒)',
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
INDEX idx_account_id (account_id),
INDEX idx_enabled (enabled),
INDEX idx_strategy_name (strategy_name),
FOREIGN KEY (account_id) REFERENCES wallet_accounts(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='NBA量化策略配置表';
-- 4. 交易信号表
CREATE TABLE IF NOT EXISTS nba_trading_signals (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
strategy_id BIGINT NOT NULL COMMENT '策略 ID',
game_id BIGINT COMMENT '比赛 ID',
market_id BIGINT COMMENT '市场 ID',
signal_type VARCHAR(10) NOT NULL COMMENT '信号类型:BUY/SELL',
direction VARCHAR(10) NOT NULL COMMENT '方向:YES/NO',
price DECIMAL(5, 4) NOT NULL COMMENT '价格(0-1',
quantity DECIMAL(20, 8) NOT NULL COMMENT '数量',
total_amount DECIMAL(20, 8) NOT NULL COMMENT '总金额(USDC',
reason TEXT COMMENT '触发原因',
win_probability DECIMAL(5, 4) COMMENT '获胜概率',
trade_value DECIMAL(5, 4) COMMENT '交易价值',
signal_status VARCHAR(20) DEFAULT 'GENERATED' COMMENT '信号状态:GENERATED/EXECUTING/SUCCESS/FAILED',
execution_result TEXT COMMENT '执行结果',
error_message TEXT COMMENT '错误信息',
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
INDEX idx_strategy_id (strategy_id),
INDEX idx_game_id (game_id),
INDEX idx_market_id (market_id),
INDEX idx_signal_type (signal_type),
INDEX idx_signal_status (signal_status),
INDEX idx_created_at (created_at),
FOREIGN KEY (strategy_id) REFERENCES nba_quantitative_strategies(id) ON DELETE CASCADE,
FOREIGN KEY (game_id) REFERENCES nba_games(id) ON DELETE SET NULL,
FOREIGN KEY (market_id) REFERENCES nba_markets(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='NBA交易信号表';
-- 5. 策略执行统计表
CREATE TABLE IF NOT EXISTS nba_strategy_statistics (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
strategy_id BIGINT NOT NULL COMMENT '策略 ID',
stat_date DATE NOT NULL COMMENT '统计日期',
total_signals INT DEFAULT 0 COMMENT '总信号数',
buy_signals INT DEFAULT 0 COMMENT '买入信号数',
sell_signals INT DEFAULT 0 COMMENT '卖出信号数',
success_signals INT DEFAULT 0 COMMENT '成功信号数',
failed_signals INT DEFAULT 0 COMMENT '失败信号数',
total_profit DECIMAL(20, 8) DEFAULT 0.00000000 COMMENT '总盈亏(USDC',
total_volume DECIMAL(20, 8) DEFAULT 0.00000000 COMMENT '总交易量(USDC',
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
UNIQUE KEY uk_strategy_date (strategy_id, stat_date),
INDEX idx_strategy_id (strategy_id),
INDEX idx_stat_date (stat_date),
FOREIGN KEY (strategy_id) REFERENCES nba_quantitative_strategies(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='NBA策略执行统计表';
@@ -0,0 +1,37 @@
-- 移除 NBA 市场相关的外键约束
-- 由于不再在数据库中存储市场信息,需要移除这些外键约束
-- 1. 移除 nba_games 表的外键约束
SET @fk_name = (SELECT CONSTRAINT_NAME
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'nba_games'
AND COLUMN_NAME = 'polymarket_market_id'
AND REFERENCED_TABLE_NAME = 'nba_markets'
LIMIT 1);
SET @sql = IF(@fk_name IS NOT NULL,
CONCAT('ALTER TABLE nba_games DROP FOREIGN KEY ', @fk_name),
'SELECT "Foreign key constraint not found"');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 2. 移除 nba_trading_signals 表的外键约束(如果存在)
SET @fk_name = (SELECT CONSTRAINT_NAME
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'nba_trading_signals'
AND COLUMN_NAME = 'market_id'
AND REFERENCED_TABLE_NAME = 'nba_markets'
LIMIT 1);
SET @sql = IF(@fk_name IS NOT NULL,
CONCAT('ALTER TABLE nba_trading_signals DROP FOREIGN KEY ', @fk_name),
'SELECT "Foreign key constraint not found"');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
@@ -0,0 +1,59 @@
package com.wrbug.polymarketbot.service.nba
import com.wrbug.polymarketbot.util.RetrofitFactory
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
/**
* NBA 比赛服务测试
* 用于验证 API 调用是否正确
*/
@SpringBootTest
class NbaGameServiceTest {
@Autowired
private lateinit var retrofitFactory: RetrofitFactory
@Test
fun testNbaStatsApi() {
runBlocking {
try {
val nbaStatsApi = retrofitFactory.createNbaStatsApi()
// 测试获取今天的比赛
val today = java.time.LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd"))
println("测试日期: $today")
val response = nbaStatsApi.getScoreboard(gameDate = today)
println("响应状态码: ${response.code()}")
println("响应消息: ${response.message()}")
if (response.isSuccessful && response.body() != null) {
val scoreboard = response.body()!!
println("ResultSets 数量: ${scoreboard.resultSets.size}")
scoreboard.resultSets.forEachIndexed { index, resultSet ->
println("ResultSet[$index]: name=${resultSet.name}, headers=${resultSet.headers.size}, rows=${resultSet.rowSet.size}")
if (resultSet.headers.isNotEmpty()) {
println(" Headers: ${resultSet.headers.take(5)}...")
}
if (resultSet.rowSet.isNotEmpty()) {
println(" First row size: ${resultSet.rowSet.first().size}")
println(" First row: ${resultSet.rowSet.first().take(5)}...")
}
}
} else {
println("API 调用失败")
println("错误响应体: ${response.errorBody()?.string()}")
}
} catch (e: Exception) {
println("测试异常: ${e.message}")
e.printStackTrace()
}
}
}
}