fix: 修复未匹配订单更新逻辑和前端API类型定义

- 修复 PositionCheckService 中未匹配订单更新逻辑,当仓位数量大于等于订单数量总和时也能正确处理
- 修复前端 API 类型定义,添加 remark 和 website 字段支持
- 添加 Leader 备注和网站字段支持
This commit is contained in:
WrBug
2025-12-07 05:07:58 +08:00
parent cb167d442f
commit 5c136ab0f4
22 changed files with 1639 additions and 939 deletions
@@ -424,12 +424,12 @@ class AccountController(
)
} else {
ResponseEntity.ok(
ApiResponse.error(
ErrorCode.BUSINESS_ERROR,
e.message,
messageSource
)
)
ApiResponse.error(
ErrorCode.BUSINESS_ERROR,
e.message,
messageSource
)
)
}
}
@@ -36,7 +36,7 @@ data class SystemConfigDto(
val builderApiKeyConfigured: Boolean, // Builder API Key 是否已配置
val builderSecretConfigured: Boolean, // Builder Secret 是否已配置
val builderPassphraseConfigured: Boolean, // Builder Passphrase 是否已配置
val autoRedeem: Boolean = true // 自动赎回(系统级别配置,默认开启)
val autoRedeemEnabled: Boolean = true // 自动赎回(系统级别配置,默认开启)
)
/**
@@ -6,7 +6,9 @@ package com.wrbug.polymarketbot.dto
data class LeaderAddRequest(
val leaderAddress: String,
val leaderName: String? = null,
val category: String? = null // sports 或 crypto
val category: String? = null, // sports 或 crypto
val remark: String? = null, // Leader 备注(可选)
val website: String? = null // Leader 网站(可选)
)
/**
@@ -15,7 +17,9 @@ data class LeaderAddRequest(
data class LeaderUpdateRequest(
val leaderId: Long,
val leaderName: String? = null,
val category: String? = null
val category: String? = null,
val remark: String? = null, // Leader 备注(可选)
val website: String? = null // Leader 网站(可选)
)
/**
@@ -40,6 +44,8 @@ data class LeaderDto(
val leaderAddress: String,
val leaderName: String?,
val category: String?,
val remark: String? = null, // Leader 备注(可选)
val website: String? = null, // Leader 网站(可选)
val copyTradingCount: Long = 0, // 跟单关系数量
val totalOrders: Long? = null, // 总订单数(可选)
val totalPnl: String? = null, // 总盈亏(可选)
@@ -22,6 +22,12 @@ data class Leader(
@Column(name = "category", length = 20)
val category: String? = null, // sports 或 cryptonull 表示不筛选
@Column(name = "remark", columnDefinition = "TEXT")
val remark: String? = null, // Leader 备注(可选)
@Column(name = "website", length = 500)
val website: String? = null, // Leader 网站(可选)
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@@ -49,10 +49,19 @@ class LeaderService(
}
// 5. 创建 Leader
// 如果 website 为空,自动设置为 polymarket profile 页
val website = if (request.website.isNullOrBlank()) {
"https://polymarket.com/profile/${request.leaderAddress}"
} else {
request.website
}
val leader = Leader(
leaderAddress = request.leaderAddress,
leaderName = request.leaderName,
category = request.category
leaderName = request.leaderName?.takeIf { it.isNotBlank() },
category = request.category,
remark = request.remark?.takeIf { it.isNotBlank() },
website = website
)
val saved = leaderRepository.save(leader)
@@ -78,9 +87,19 @@ class LeaderService(
CategoryValidator.validate(request.category)
}
// 处理更新逻辑:如果请求中的字段为 null 或空字符串,都设置为 null
// 如果 website 为空,自动设置为 polymarket profile 页
val website = if (request.website.isNullOrBlank()) {
"https://polymarket.com/profile/${leader.leaderAddress}"
} else {
request.website
}
val updated = leader.copy(
leaderName = request.leaderName ?: leader.leaderName,
category = request.category ?: leader.category,
leaderName = request.leaderName?.takeIf { it.isNotBlank() },
category = request.category,
remark = request.remark?.takeIf { it.isNotBlank() },
website = website,
updatedAt = System.currentTimeMillis()
)
@@ -175,6 +194,8 @@ class LeaderService(
leaderAddress = leader.leaderAddress,
leaderName = leader.leaderName,
category = leader.category,
remark = leader.remark,
website = leader.website,
copyTradingCount = copyTradingCount,
createdAt = leader.createdAt,
updatedAt = leader.updatedAt
@@ -3,13 +3,19 @@ package com.wrbug.polymarketbot.service
import com.wrbug.polymarketbot.dto.AccountPositionDto
import com.wrbug.polymarketbot.entity.CopyOrderTracking
import com.wrbug.polymarketbot.entity.CopyTrading
import com.wrbug.polymarketbot.entity.SellMatchDetail
import com.wrbug.polymarketbot.entity.SellMatchRecord
import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository
import com.wrbug.polymarketbot.repository.CopyTradingRepository
import com.wrbug.polymarketbot.repository.SellMatchDetailRepository
import com.wrbug.polymarketbot.repository.SellMatchRecordRepository
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import com.wrbug.polymarketbot.util.multi
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import jakarta.annotation.PostConstruct
import jakarta.annotation.PreDestroy
import org.springframework.context.MessageSource
import org.springframework.context.i18n.LocaleContextHolder
import org.springframework.stereotype.Service
@@ -19,12 +25,16 @@ import java.util.concurrent.ConcurrentHashMap
/**
* 仓位检查服务
* 负责检查待赎回仓位和未卖出订单,并执行相应的处理逻辑
* 订阅 PositionPollingService 的事件,处理仓位检查逻辑
*/
@Service
class PositionCheckService(
private val positionPollingService: PositionPollingService,
private val accountService: AccountService,
private val copyTradingRepository: CopyTradingRepository,
private val copyOrderTrackingRepository: CopyOrderTrackingRepository,
private val sellMatchRecordRepository: SellMatchRecordRepository,
private val sellMatchDetailRepository: SellMatchDetailRepository,
private val systemConfigService: SystemConfigService,
private val relayClientService: RelayClientService,
private val telegramNotificationService: TelegramNotificationService?,
@@ -34,8 +44,9 @@ class PositionCheckService(
private val logger = LoggerFactory.getLogger(PositionCheckService::class.java)
// 协程作用域,用于缓存清理任务
// 协程作用域,用于订阅事件和缓存清理任务
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private var subscriptionJob: Job? = null
// 记录已发送通知的仓位(避免重复推送)
private val notifiedRedeemablePositions = ConcurrentHashMap<String, Long>() // "accountId_marketId_outcomeIndex" -> lastNotificationTime
@@ -43,14 +54,60 @@ class PositionCheckService(
// 记录已发送提示的配置(避免重复推送)
private val notifiedConfigs = ConcurrentHashMap<Long, Long>() // accountId/copyTradingId -> lastNotificationTime
// 同步锁,确保订阅任务的启动和停止是线程安全的
private val lock = Any()
/**
* 初始化服务(启动缓存清理任务)
* 初始化服务(订阅 PositionPollingService 的事件,启动缓存清理任务)
*/
@PostConstruct
fun init() {
logger.info("PositionCheckService 初始化,订阅仓位轮训事件")
startSubscription()
startCacheCleanup()
}
/**
* 清理资源
*/
@PreDestroy
fun destroy() {
synchronized(lock) {
subscriptionJob?.cancel()
subscriptionJob = null
}
scope.cancel()
}
/**
* 启动订阅任务(订阅 PositionPollingService 的事件)
*/
private fun startSubscription() {
synchronized(lock) {
// 如果已经有订阅任务在运行,先取消
subscriptionJob?.cancel()
// 启动新的订阅任务(使用专门的线程,避免阻塞)
subscriptionJob = scope.launch(Dispatchers.IO) {
try {
// 订阅仓位轮训事件
positionPollingService.subscribe { positions ->
// 在协程中处理仓位检查逻辑,避免阻塞
scope.launch(Dispatchers.IO) {
try {
checkPositions(positions.currentPositions)
} catch (e: Exception) {
logger.error("处理仓位检查事件失败: ${e.message}", e)
}
}
}
} catch (e: Exception) {
logger.error("订阅仓位轮训事件失败: ${e.message}", e)
}
}
}
}
/**
* 启动缓存清理任务(定期清理过期的通知记录)
*/
@@ -118,16 +175,55 @@ class PositionCheckService(
/**
* 逻辑1:处理待赎回仓位
* 如果有待赎回的仓位,检查是否开启了自动赎回,是否有相同仓位的订单(未卖出的订单)
* 如果有的话,在仓位赎回成功后以该订单卖出逻辑更新所有订单状态(未卖出)
* 如果未开启自动赎回,则发送tg通知,并且记录(内存缓存)该仓位,避免重复发送
* 按照以下逻辑处理:
* 1. 无待赎回仓位:跳过
* 2. (未配置apikey || autoredeem==false) && 有待赎回的仓位:发送通知事件
* 3. (已配置) && 有待赎回的仓位:处理订单逻辑
*/
private suspend fun checkRedeemablePositions(redeemablePositions: List<AccountPositionDto>) {
try {
// 1. 无待赎回仓位:跳过
if (redeemablePositions.isEmpty()) {
return
}
// 检查系统级别的自动赎回配置
val autoRedeemEnabled = systemConfigService.isAutoRedeemEnabled()
val apiKeyConfigured = relayClientService.isBuilderApiKeyConfigured()
// 按账户分组
// 2. (未配置apikey || autoredeem==false) && 有待赎回的仓位:发送通知事件
if (!autoRedeemEnabled || !apiKeyConfigured) {
// 按账户分组发送通知
val positionsByAccount = redeemablePositions.groupBy { it.accountId }
for ((accountId, positions) in positionsByAccount) {
for (position in positions) {
val positionKey = "${accountId}_${position.marketId}_${position.outcomeIndex ?: 0}"
// 检查是否在最近2小时内已发送过提示(避免频繁推送)
val lastNotification = notifiedRedeemablePositions[positionKey]
val now = System.currentTimeMillis()
if (lastNotification == null || (now - lastNotification) >= 7200000) { // 2小时
if (!autoRedeemEnabled) {
// 自动赎回未开启:直接发送通知,不需要查找跟单配置
checkAndNotifyAutoRedeemDisabled(accountId, listOf(position))
} else {
// API Key 未配置:需要查找跟单配置来发送通知
val copyTradings = copyTradingRepository.findByAccountId(accountId)
.filter { it.enabled }
for (copyTrading in copyTradings) {
checkAndNotifyBuilderApiKeyNotConfigured(copyTrading, listOf(position))
}
}
notifiedRedeemablePositions[positionKey] = now
}
}
}
return // 未配置时直接返回,不进行后续处理
}
// 3. (已配置) && 有待赎回的仓位:处理订单逻辑
// 自动赎回已开启且已配置 API Key,按账户分组进行赎回处理
// 先执行赎回,赎回成功后再查找订单并更新订单状态
val positionsByAccount = redeemablePositions.groupBy { it.accountId }
for ((accountId, positions) in positionsByAccount) {
@@ -139,90 +235,52 @@ class PositionCheckService(
continue
}
// 收集所有有未卖出订单的仓位,按账户分组一次性处理
val positionsWithOrders = mutableListOf<Pair<AccountPositionDto, List<CopyOrderTracking>>>()
val positionsWithoutOrders = mutableListOf<AccountPositionDto>()
for (position in positions) {
// 查找相同仓位的未卖出订单(remaining_quantity > 0
val unmatchedOrders = mutableListOf<CopyOrderTracking>()
for (copyTrading in copyTradings) {
if (position.outcomeIndex != null) {
val orders = copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndex(
copyTrading.id!!,
position.marketId,
position.outcomeIndex
)
unmatchedOrders.addAll(orders)
}
}
if (unmatchedOrders.isNotEmpty()) {
positionsWithOrders.add(Pair(position, unmatchedOrders))
} else {
positionsWithoutOrders.add(position)
}
}
// 处理有未卖出订单的仓位
if (positionsWithOrders.isNotEmpty()) {
if (autoRedeemEnabled && relayClientService.isBuilderApiKeyConfigured()) {
// 开启自动赎回且已配置 API Key,执行自动赎回(一次性赎回该账户的所有可赎回仓位)
val redeemRequest = com.wrbug.polymarketbot.dto.PositionRedeemRequest(
positions = positionsWithOrders.map { (position, _) ->
com.wrbug.polymarketbot.dto.AccountRedeemPositionItem(
accountId = accountId,
marketId = position.marketId,
outcomeIndex = position.outcomeIndex ?: 0,
side = position.side
)
}
// 先执行赎回(不查找订单)
val redeemRequest = com.wrbug.polymarketbot.dto.PositionRedeemRequest(
positions = positions.map { position ->
com.wrbug.polymarketbot.dto.AccountRedeemPositionItem(
accountId = accountId,
marketId = position.marketId,
outcomeIndex = position.outcomeIndex ?: 0,
side = position.side
)
}
)
val redeemResult = accountService.redeemPositions(redeemRequest)
redeemResult.fold(
onSuccess = { response ->
logger.info("自动赎回成功: accountId=$accountId, redeemedCount=${positions.size}, totalValue=${response.totalRedeemedValue}")
val redeemResult = accountService.redeemPositions(redeemRequest)
redeemResult.fold(
onSuccess = { response ->
logger.info("自动赎回成功: accountId=$accountId, redeemedCount=${positionsWithOrders.size}, totalValue=${response.totalRedeemedValue}")
// 在仓位赎回成功后,以该订单卖出逻辑更新所有订单状态(未卖出)
for ((position, orders) in positionsWithOrders) {
updateOrdersAsSoldAfterRedeem(orders, position)
// 赎回成功后,再查找订单并更新订单状态
for (position in positions) {
// 查找相同仓位的未卖出订单(remaining_quantity > 0
val unmatchedOrders = mutableListOf<CopyOrderTracking>()
for (copyTrading in copyTradings) {
if (position.outcomeIndex != null) {
val orders = copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndex(
copyTrading.id!!,
position.marketId,
position.outcomeIndex
)
unmatchedOrders.addAll(orders)
}
},
onFailure = { e ->
logger.error("自动赎回失败: accountId=$accountId, error=${e.message}", e)
}
)
} else {
// 未开启自动赎回或未配置 API Key,发送通知
for ((position, _) in positionsWithOrders) {
val positionKey = "${accountId}_${position.marketId}_${position.outcomeIndex ?: 0}"
if (!autoRedeemEnabled) {
checkAndNotifyAutoRedeemDisabled(accountId, listOf(position))
} else {
// API Key 未配置
for (copyTrading in copyTradings) {
checkAndNotifyBuilderApiKeyNotConfigured(copyTrading, listOf(position))
// 如果有未卖出订单,更新订单状态
if (unmatchedOrders.isNotEmpty()) {
// 从订单中获取 copyTradingId(所有订单应该有相同的 copyTradingId
val copyTradingId = unmatchedOrders.firstOrNull()?.copyTradingId
if (copyTradingId != null) {
updateOrdersAsSoldAfterRedeem(unmatchedOrders, position, copyTradingId)
}
}
// 记录已发送通知的仓位(避免重复发送)
notifiedRedeemablePositions[positionKey] = System.currentTimeMillis()
}
},
onFailure = { e ->
logger.error("自动赎回失败: accountId=$accountId, error=${e.message}", e)
}
}
// 处理没有未卖出订单的仓位(如果未开启自动赎回,发送通知)
if (positionsWithoutOrders.isNotEmpty() && !autoRedeemEnabled) {
for (position in positionsWithoutOrders) {
val positionKey = "${accountId}_${position.marketId}_${position.outcomeIndex ?: 0}"
// 检查是否在最近2小时内已发送过提示(避免频繁推送)
val lastNotification = notifiedRedeemablePositions[positionKey]
val now = System.currentTimeMillis()
if (lastNotification == null || (now - lastNotification) >= 7200000) { // 2小时
checkAndNotifyAutoRedeemDisabled(accountId, listOf(position))
notifiedRedeemablePositions[positionKey] = now
}
}
}
)
}
} catch (e: Exception) {
logger.error("处理待赎回仓位异常: ${e.message}", e)
@@ -274,17 +332,15 @@ class PositionCheckService(
if (position == null) {
// 仓位不存在,更新所有订单状态为已卖出
val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex)
updateOrdersAsSold(orders, currentPrice)
updateOrdersAsSold(orders, currentPrice, copyTrading.id!!, marketId, outcomeIndex)
} else {
// 有仓位,检查仓位数量是否小于所有未卖出订单数量总和
// 有仓位,按订单下单顺序(FIFO)更新状态
// 如果仓位数量 >= 订单数量总和,所有订单完全成交
// 如果仓位数量 < 订单数量总和,按FIFO顺序部分成交
val totalUnmatchedQuantity = orders.sumOf { it.remainingQuantity.toSafeBigDecimal() }
val positionQuantity = position.quantity.toSafeBigDecimal()
if (positionQuantity < totalUnmatchedQuantity) {
// 仓位数量小于订单数量总和,按订单下单顺序(FIFO)更新状态
val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex)
updateOrdersAsSoldByFIFO(orders, positionQuantity, currentPrice)
}
val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex)
updateOrdersAsSoldByFIFO(orders, positionQuantity, currentPrice, copyTrading.id!!, marketId, outcomeIndex)
}
}
}
@@ -320,11 +376,12 @@ class PositionCheckService(
*/
private suspend fun updateOrdersAsSoldAfterRedeem(
orders: List<CopyOrderTracking>,
position: AccountPositionDto
position: AccountPositionDto,
copyTradingId: Long
) {
try {
val currentPrice = getCurrentMarketPrice(position.marketId, position.outcomeIndex ?: 0)
updateOrdersAsSold(orders, currentPrice)
updateOrdersAsSold(orders, currentPrice, copyTradingId, position.marketId, position.outcomeIndex ?: 0)
} catch (e: Exception) {
logger.error("更新订单状态为已卖出失败: ${e.message}", e)
}
@@ -332,21 +389,85 @@ class PositionCheckService(
/**
* 更新订单状态为已卖出(使用当前最新价)
* 同时创建卖出记录和匹配明细,用于统计
*/
private suspend fun updateOrdersAsSold(
orders: List<CopyOrderTracking>,
sellPrice: BigDecimal
sellPrice: BigDecimal,
copyTradingId: Long,
marketId: String,
outcomeIndex: Int
) {
if (orders.isEmpty()) {
return
}
try {
// 计算总匹配数量和总盈亏
var totalMatchedQuantity = BigDecimal.ZERO
var totalRealizedPnl = BigDecimal.ZERO
val matchDetails = mutableListOf<SellMatchDetail>()
for (order in orders) {
val remainingQty = order.remainingQuantity.toSafeBigDecimal()
if (remainingQty <= BigDecimal.ZERO) {
continue
}
// 计算盈亏
val buyPrice = order.price.toSafeBigDecimal()
val realizedPnl = sellPrice.subtract(buyPrice).multi(remainingQty)
// 创建匹配明细(稍后保存)
val detail = SellMatchDetail(
matchRecordId = 0, // 稍后设置
trackingId = order.id!!,
buyOrderId = order.buyOrderId,
matchedQuantity = remainingQty,
buyPrice = buyPrice,
sellPrice = sellPrice,
realizedPnl = realizedPnl
)
matchDetails.add(detail)
totalMatchedQuantity = totalMatchedQuantity.add(remainingQty)
totalRealizedPnl = totalRealizedPnl.add(realizedPnl)
// 更新订单状态:将剩余数量标记为已匹配
order.matchedQuantity = order.matchedQuantity.add(order.remainingQuantity)
order.matchedQuantity = order.matchedQuantity.add(remainingQty)
order.remainingQuantity = BigDecimal.ZERO
order.status = "fully_matched"
order.updatedAt = System.currentTimeMillis()
copyOrderTrackingRepository.save(order)
}
// 如果有匹配的订单,创建卖出记录
if (totalMatchedQuantity > BigDecimal.ZERO && matchDetails.isNotEmpty()) {
val timestamp = System.currentTimeMillis()
val sellOrderId = "AUTO_${timestamp}_${copyTradingId}"
val leaderSellTradeId = "AUTO_${timestamp}"
logger.info("更新订单状态为已卖出: orderId=${order.buyOrderId}, marketId=${order.marketId}, sellPrice=$sellPrice")
val matchRecord = SellMatchRecord(
copyTradingId = copyTradingId,
sellOrderId = sellOrderId,
leaderSellTradeId = leaderSellTradeId,
marketId = marketId,
side = outcomeIndex.toString(), // 使用outcomeIndex作为side
outcomeIndex = outcomeIndex,
totalMatchedQuantity = totalMatchedQuantity,
sellPrice = sellPrice,
totalRealizedPnl = totalRealizedPnl
)
val savedRecord = sellMatchRecordRepository.save(matchRecord)
// 保存匹配明细
for (detail in matchDetails) {
val savedDetail = detail.copy(matchRecordId = savedRecord.id!!)
sellMatchDetailRepository.save(savedDetail)
}
logger.info("创建自动卖出记录: copyTradingId=$copyTradingId, marketId=$marketId, totalMatched=$totalMatchedQuantity, totalPnl=$totalRealizedPnl")
}
} catch (e: Exception) {
logger.error("更新订单状态为已卖出异常: ${e.message}", e)
@@ -356,15 +477,26 @@ class PositionCheckService(
/**
* 按 FIFO 顺序更新订单状态为已卖出
* 仓位数量小于订单数量总和时,按订单下单顺序更新
* 同时创建卖出记录和匹配明细,用于统计
*/
private suspend fun updateOrdersAsSoldByFIFO(
orders: List<CopyOrderTracking>,
availableQuantity: BigDecimal,
sellPrice: BigDecimal
sellPrice: BigDecimal,
copyTradingId: Long,
marketId: String,
outcomeIndex: Int
) {
if (orders.isEmpty()) {
return
}
try {
// 订单已经按 createdAt ASC 排序(FIFO
var remaining = availableQuantity
var totalMatchedQuantity = BigDecimal.ZERO
var totalRealizedPnl = BigDecimal.ZERO
val matchDetails = mutableListOf<SellMatchDetail>()
for (order in orders) {
if (remaining <= BigDecimal.ZERO) {
@@ -375,6 +507,25 @@ class PositionCheckService(
val toMatch = minOf(orderRemaining, remaining)
if (toMatch > BigDecimal.ZERO) {
// 计算盈亏
val buyPrice = order.price.toSafeBigDecimal()
val realizedPnl = sellPrice.subtract(buyPrice).multi(toMatch)
// 创建匹配明细(稍后保存)
val detail = SellMatchDetail(
matchRecordId = 0, // 稍后设置
trackingId = order.id!!,
buyOrderId = order.buyOrderId,
matchedQuantity = toMatch,
buyPrice = buyPrice,
sellPrice = sellPrice,
realizedPnl = realizedPnl
)
matchDetails.add(detail)
totalMatchedQuantity = totalMatchedQuantity.add(toMatch)
totalRealizedPnl = totalRealizedPnl.add(realizedPnl)
order.matchedQuantity = order.matchedQuantity.add(toMatch)
order.remainingQuantity = order.remainingQuantity.subtract(toMatch)
@@ -393,6 +544,35 @@ class PositionCheckService(
logger.info("按 FIFO 更新订单状态: orderId=${order.buyOrderId}, matched=$toMatch, remaining=${order.remainingQuantity}")
}
}
// 如果有匹配的订单,创建卖出记录
if (totalMatchedQuantity > BigDecimal.ZERO && matchDetails.isNotEmpty()) {
val timestamp = System.currentTimeMillis()
val sellOrderId = "AUTO_FIFO_${timestamp}_${copyTradingId}"
val leaderSellTradeId = "AUTO_FIFO_${timestamp}"
val matchRecord = SellMatchRecord(
copyTradingId = copyTradingId,
sellOrderId = sellOrderId,
leaderSellTradeId = leaderSellTradeId,
marketId = marketId,
side = outcomeIndex.toString(), // 使用outcomeIndex作为side
outcomeIndex = outcomeIndex,
totalMatchedQuantity = totalMatchedQuantity,
sellPrice = sellPrice,
totalRealizedPnl = totalRealizedPnl
)
val savedRecord = sellMatchRecordRepository.save(matchRecord)
// 保存匹配明细
for (detail in matchDetails) {
val savedDetail = detail.copy(matchRecordId = savedRecord.id!!)
sellMatchDetailRepository.save(savedDetail)
}
logger.info("创建FIFO自动卖出记录: copyTradingId=$copyTradingId, marketId=$marketId, totalMatched=$totalMatchedQuantity, totalPnl=$totalRealizedPnl")
}
} catch (e: Exception) {
logger.error("按 FIFO 更新订单状态异常: ${e.message}", e)
}
@@ -0,0 +1,156 @@
package com.wrbug.polymarketbot.service
import com.wrbug.polymarketbot.dto.PositionListResponse
import jakarta.annotation.PostConstruct
import jakarta.annotation.PreDestroy
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
/**
* 仓位轮训服务
* 独立负责轮训仓位数据,通过事件机制分发给订阅者
* 使用专门的线程处理事件分发,避免阻塞轮训
* 提供丢弃机制:如果消费者处理慢,只保留最新的一份数据
*/
@Service
class PositionPollingService(
private val accountService: AccountService
) {
private val logger = LoggerFactory.getLogger(PositionPollingService::class.java)
@Value("\${position.polling.interval:2000}")
private var pollingInterval: Long = 2000 // 轮训间隔(毫秒),默认2秒
// 订阅者列表(支持多个订阅者)
private val subscribers = CopyOnWriteArrayList<(PositionListResponse) -> Unit>()
// 最新仓位数据(用于丢弃机制)
@Volatile
private var latestPositions: PositionListResponse? = null
// 协程作用域和任务
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private var pollingJob: Job? = null
// 事件分发协程(使用专门的线程,避免阻塞轮训)
private val eventDispatcherScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// 同步锁,确保轮询任务的启动和停止是线程安全的
private val lock = Any()
/**
* 初始化服务(后端启动时直接启动轮训)
*/
@PostConstruct
fun init() {
logger.info("PositionPollingService 初始化,启动仓位轮训任务,轮训间隔: ${pollingInterval}ms")
startPolling()
}
/**
* 清理资源
*/
@PreDestroy
fun destroy() {
synchronized(lock) {
pollingJob?.cancel()
pollingJob = null
}
subscribers.clear()
scope.cancel()
eventDispatcherScope.cancel()
}
/**
* 订阅仓位事件
* @param callback 回调函数,接收最新的仓位数据
*/
fun subscribe(callback: (PositionListResponse) -> Unit) {
synchronized(lock) {
subscribers.add(callback)
// 如果有最新数据,立即发送给新订阅者
latestPositions?.let { callback(it) }
}
}
/**
* 取消订阅仓位事件
*/
fun unsubscribe(callback: (PositionListResponse) -> Unit) {
synchronized(lock) {
subscribers.remove(callback)
}
}
/**
* 启动轮训任务
*/
private fun startPolling() {
synchronized(lock) {
// 如果已经有轮训任务在运行,先取消
pollingJob?.cancel()
// 启动新的轮训任务
pollingJob = scope.launch {
while (isActive) {
try {
pollPositions()
} catch (e: Exception) {
logger.error("轮训仓位数据失败: ${e.message}", e)
}
delay(pollingInterval)
}
}
}
}
/**
* 轮训仓位数据并发布事件
* 使用专门的线程分发事件,避免阻塞轮训
* 实现丢弃机制:只保留最新的一份数据
*/
private suspend fun pollPositions() {
try {
val result = accountService.getAllPositions()
if (result.isSuccess) {
val positions = result.getOrNull()
if (positions != null) {
// 更新最新数据(丢弃旧数据,只保留最新的)
latestPositions = positions
// 在专门的线程中分发事件,避免阻塞轮训
eventDispatcherScope.launch {
try {
// 通知所有订阅者(在专门的线程中执行,避免阻塞)
val currentSubscribers = synchronized(lock) {
subscribers.toList() // 复制列表,避免并发修改
}
currentSubscribers.forEach { callback ->
try {
callback(positions)
} catch (e: Exception) {
logger.error("通知订阅者失败: ${e.message}", e)
}
}
logger.debug("发布仓位数据事件: currentPositions=${positions.currentPositions.size}, historyPositions=${positions.historyPositions.size}, subscribers=${currentSubscribers.size}")
} catch (e: Exception) {
logger.error("分发仓位数据事件失败: ${e.message}", e)
}
}
}
} else {
logger.warn("获取仓位数据失败: ${result.exceptionOrNull()?.message}")
}
} catch (e: Exception) {
logger.error("轮训仓位数据异常: ${e.message}", e)
}
}
}
@@ -8,25 +8,21 @@ import jakarta.annotation.PostConstruct
import jakarta.annotation.PreDestroy
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import java.util.concurrent.ConcurrentHashMap
/**
* 仓位推送服务
* 轮询仓位接口,比较差异并推送增量更新
* 订阅 PositionPollingService 的事件,推送给 WebSocket 客户端
*/
@Service
class PositionPushService(
private val accountService: AccountService,
private val positionCheckService: PositionCheckService
private val positionPollingService: PositionPollingService,
private val accountService: AccountService
) {
private val logger = LoggerFactory.getLogger(PositionPushService::class.java)
@Value("\${position.push.polling-interval:3000}")
private var pollingInterval: Long = 3000 // 轮询间隔(毫秒),默认3秒
// 存储客户端会话和对应的推送回调
private val clientCallbacks = ConcurrentHashMap<String, (PositionPushMessage) -> Unit>()
@@ -36,18 +32,18 @@ class PositionPushService(
// 协程作用域和任务
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private var pollingJob: Job? = null
private var subscriptionJob: Job? = null
// 同步锁,确保轮询任务的启动和停止是线程安全的
// 同步锁,确保订阅任务的启动和停止是线程安全的
private val lock = Any()
/**
* 初始化服务(后端启动时直接启动轮询
* 初始化服务(订阅 PositionPollingService 的事件
*/
@PostConstruct
fun init() {
logger.info("PositionPushService 初始化,启动仓位轮询任务")
startPolling()
logger.info("PositionPushService 初始化,订阅仓位轮训事件")
startSubscription()
}
/**
@@ -56,8 +52,8 @@ class PositionPushService(
@PreDestroy
fun destroy() {
synchronized(lock) {
pollingJob?.cancel()
pollingJob = null
subscriptionJob?.cancel()
subscriptionJob = null
}
scope.cancel()
}
@@ -78,27 +74,23 @@ class PositionPushService(
/**
* 注册客户端会话(兼容旧接口)
* 轮询任务已在后端启动时启动,这里只需要注册回调
*/
fun registerSession(sessionId: String, callback: (PositionPushMessage) -> Unit) {
logger.info("注册仓位推送客户端会话: $sessionId")
synchronized(lock) {
clientCallbacks[sessionId] = callback
// 轮询任务已在后端启动时启动,不需要在这里启动
}
}
/**
* 注销客户端会话(兼容旧接口)
* 轮询任务持续运行,不因客户端断开而停止
*/
fun unregisterSession(sessionId: String) {
logger.info("注销仓位推送客户端会话: $sessionId")
synchronized(lock) {
clientCallbacks.remove(sessionId)
// 轮询任务持续运行,不停止
}
}
@@ -134,179 +126,59 @@ class PositionPushService(
}
/**
* 启动轮询任务
* 启动订阅任务(订阅 PositionPollingService 的事件)
*/
private fun startPolling() {
private fun startSubscription() {
synchronized(lock) {
// 如果已经有轮询任务在运行,先取消
pollingJob?.cancel()
// 如果已经有订阅任务在运行,先取消
subscriptionJob?.cancel()
// 启动新的轮询任务
pollingJob = scope.launch {
while (isActive) {
try {
pollAndPush()
} catch (e: Exception) {
logger.error("轮询仓位数据失败: ${e.message}", e)
}
delay(pollingInterval)
}
}
}
}
/**
* 停止轮询任务
*/
private fun stopPolling() {
synchronized(lock) {
pollingJob?.cancel()
pollingJob = null
}
}
/**
* 轮询仓位数据并推送全量数据
* 根据文档要求:每次轮训完成后向订阅者发送全量数据
*/
private suspend fun pollAndPush() {
try {
val result = accountService.getAllPositions()
if (result.isSuccess) {
val positions = result.getOrNull()
if (positions != null) {
// 更新快照
lastCurrentPositions = positions.currentPositions.associateBy { it.getPositionKey() }
lastHistoryPositions = positions.historyPositions.associateBy { it.getPositionKey() }
// 向所有订阅者发送全量数据
if (clientCallbacks.isNotEmpty()) {
val message = PositionPushMessage(
type = PositionPushMessageType.FULL,
timestamp = System.currentTimeMillis(),
currentPositions = positions.currentPositions,
historyPositions = positions.historyPositions
)
// 推送给所有连接的客户端
clientCallbacks.values.forEach { callback ->
try {
callback(message)
} catch (e: Exception) {
logger.error("推送全量数据失败: ${e.message}", e)
}
// 启动新的订阅任务(使用专门的线程,避免阻塞)
subscriptionJob = scope.launch(Dispatchers.IO) {
try {
// 订阅仓位轮训事件
positionPollingService.subscribe { positions ->
try {
handlePositionUpdate(positions)
} catch (e: Exception) {
logger.error("处理仓位更新事件失败: ${e.message}", e)
}
}
// 仓位检查逻辑(复用仓位轮询)
// 处理待赎回仓位和未卖出订单
positionCheckService.checkPositions(positions.currentPositions)
} catch (e: Exception) {
logger.error("订阅仓位轮训事件失败: ${e.message}", e)
}
} else {
logger.warn("获取仓位数据失败: ${result.exceptionOrNull()?.message}")
}
} catch (e: Exception) {
logger.error("轮询仓位数据异常: ${e.message}", e)
}
}
/**
* 计算增量更新
* 返回 null 表示没有变化
* 处理仓位更新事件
* 根据文档要求:每次轮训完成后向订阅者发送全量数据
*/
private fun calculateIncremental(
newCurrentPositions: List<AccountPositionDto>,
newHistoryPositions: List<AccountPositionDto>
): IncrementalUpdate? {
val newCurrentMap = newCurrentPositions.associateBy { it.getPositionKey() }
val newHistoryMap = newHistoryPositions.associateBy { it.getPositionKey() }
private fun handlePositionUpdate(positions: com.wrbug.polymarketbot.dto.PositionListResponse) {
// 更新快照
lastCurrentPositions = positions.currentPositions.associateBy { it.getPositionKey() }
lastHistoryPositions = positions.historyPositions.associateBy { it.getPositionKey() }
// 找出新增或更新的当前仓位
val updatedCurrentPositions = mutableListOf<AccountPositionDto>()
newCurrentMap.forEach { (key, newPos) ->
val oldPos = lastCurrentPositions[key]
if (oldPos == null || hasChanged(oldPos, newPos)) {
updatedCurrentPositions.add(newPos)
}
}
// 找出新增或更新的历史仓位
val updatedHistoryPositions = mutableListOf<AccountPositionDto>()
newHistoryMap.forEach { (key, newPos) ->
val oldPos = lastHistoryPositions[key]
if (oldPos == null || hasChanged(oldPos, newPos)) {
updatedHistoryPositions.add(newPos)
}
}
// 找出已删除的仓位(从当前仓位变为历史仓位,或完全删除)
val removedKeys = mutableListOf<String>()
// 检查上次的当前仓位是否还在当前仓位列表中
lastCurrentPositions.forEach { (key, _) ->
if (!newCurrentMap.containsKey(key)) {
// 如果不在当前仓位中,检查是否移到了历史仓位
if (!newHistoryMap.containsKey(key)) {
// 完全删除
removedKeys.add(key)
} else {
// 从当前仓位移到历史仓位,需要更新历史仓位
newHistoryMap[key]?.let { updatedHistoryPositions.add(it) }
// 向所有订阅者发送全量数据
if (clientCallbacks.isNotEmpty()) {
val message = PositionPushMessage(
type = PositionPushMessageType.FULL,
timestamp = System.currentTimeMillis(),
currentPositions = positions.currentPositions,
historyPositions = positions.historyPositions
)
// 推送给所有连接的客户端(在专门的线程中执行,避免阻塞)
scope.launch(Dispatchers.IO) {
clientCallbacks.values.forEach { callback ->
try {
callback(message)
} catch (e: Exception) {
logger.error("推送全量数据失败: ${e.message}", e)
}
}
}
}
// 检查上次的历史仓位是否还在历史仓位列表中
lastHistoryPositions.forEach { (key, _) ->
if (!newHistoryMap.containsKey(key)) {
// 如果不在历史仓位中,检查是否移到了当前仓位
if (!newCurrentMap.containsKey(key)) {
// 完全删除
removedKeys.add(key)
} else {
// 从历史仓位移到当前仓位,需要更新当前仓位
newCurrentMap[key]?.let { updatedCurrentPositions.add(it) }
}
}
}
// 如果没有变化,返回 null
if (updatedCurrentPositions.isEmpty() && updatedHistoryPositions.isEmpty() && removedKeys.isEmpty()) {
return null
}
return IncrementalUpdate(
currentPositions = updatedCurrentPositions,
historyPositions = updatedHistoryPositions,
removedKeys = removedKeys
)
}
/**
* 检查仓位是否有变化
* 比较关键字段:数量、价格、价值、盈亏等
*/
private fun hasChanged(old: AccountPositionDto, new: AccountPositionDto): Boolean {
return old.quantity != new.quantity ||
old.avgPrice != new.avgPrice ||
old.currentPrice != new.currentPrice ||
old.currentValue != new.currentValue ||
old.pnl != new.pnl ||
old.percentPnl != new.percentPnl ||
old.realizedPnl != new.realizedPnl ||
old.percentRealizedPnl != new.percentRealizedPnl ||
old.redeemable != new.redeemable ||
old.mergeable != new.mergeable ||
old.isCurrent != new.isCurrent
}
/**
* 增量更新数据
*/
private data class IncrementalUpdate(
val currentPositions: List<AccountPositionDto>,
val historyPositions: List<AccountPositionDto>,
val removedKeys: List<String>
)
}
@@ -17,16 +17,16 @@ class SystemConfigService(
private val systemConfigRepository: SystemConfigRepository,
private val cryptoUtils: CryptoUtils
) {
private val logger = LoggerFactory.getLogger(SystemConfigService::class.java)
companion object {
const val CONFIG_KEY_BUILDER_API_KEY = "builder.api_key"
const val CONFIG_KEY_BUILDER_SECRET = "builder.secret"
const val CONFIG_KEY_BUILDER_PASSPHRASE = "builder.passphrase"
const val CONFIG_KEY_AUTO_REDEEM = "auto_redeem"
}
/**
* 获取系统配置
*/
@@ -35,15 +35,15 @@ class SystemConfigService(
val builderSecret = getConfigValue(CONFIG_KEY_BUILDER_SECRET)
val builderPassphrase = getConfigValue(CONFIG_KEY_BUILDER_PASSPHRASE)
val autoRedeem = isAutoRedeemEnabled()
return SystemConfigDto(
builderApiKeyConfigured = builderApiKey != null,
builderSecretConfigured = builderSecret != null,
builderPassphraseConfigured = builderPassphrase != null,
autoRedeem = autoRedeem
autoRedeemEnabled = autoRedeem
)
}
/**
* 更新 Builder API Key 配置
*/
@@ -61,7 +61,7 @@ class SystemConfigService(
}
)
}
// 更新 Builder Secret
if (request.builderSecret != null) {
updateConfigValue(
@@ -73,7 +73,7 @@ class SystemConfigService(
}
)
}
// 更新 Builder Passphrase
if (request.builderPassphrase != null) {
updateConfigValue(
@@ -85,7 +85,7 @@ class SystemConfigService(
}
)
}
// 更新自动赎回配置
if (request.autoRedeem != null) {
updateConfigValue(
@@ -93,29 +93,29 @@ class SystemConfigService(
request.autoRedeem.toString()
)
}
Result.success(getSystemConfig())
} catch (e: Exception) {
logger.error("更新系统配置失败", e)
Result.failure(e)
}
}
/**
* 获取配置值(解密)
*/
fun getBuilderApiKey(): String? {
return getConfigValue(CONFIG_KEY_BUILDER_API_KEY)?.let { cryptoUtils.decrypt(it) }
}
fun getBuilderSecret(): String? {
return getConfigValue(CONFIG_KEY_BUILDER_SECRET)?.let { cryptoUtils.decrypt(it) }
}
fun getBuilderPassphrase(): String? {
return getConfigValue(CONFIG_KEY_BUILDER_PASSPHRASE)?.let { cryptoUtils.decrypt(it) }
}
/**
* 检查 Builder API Key 是否已配置
*/
@@ -125,7 +125,7 @@ class SystemConfigService(
val passphrase = getConfigValue(CONFIG_KEY_BUILDER_PASSPHRASE)
return apiKey != null && secret != null && passphrase != null
}
/**
* 检查自动赎回是否启用
*/
@@ -134,10 +134,10 @@ class SystemConfigService(
return when (autoRedeemValue?.lowercase()) {
"true" -> true
"false" -> false
else -> true // 默认开启
else -> false // 默认开启
}
}
/**
* 更新自动赎回配置
*/
@@ -151,14 +151,14 @@ class SystemConfigService(
Result.failure(e)
}
}
/**
* 获取配置值(原始值,加密存储)
*/
private fun getConfigValue(configKey: String): String? {
return systemConfigRepository.findByConfigKey(configKey)?.configValue
}
/**
* 更新配置值
*/
@@ -0,0 +1,8 @@
-- ============================================
-- V10: 添加 Leader 备注和网站字段
-- ============================================
ALTER TABLE copy_trading_leaders
ADD COLUMN remark TEXT NULL COMMENT 'Leader 备注(可选)',
ADD COLUMN website VARCHAR(500) NULL COMMENT 'Leader 网站(可选)';