fix: 修复未匹配订单更新逻辑和前端API类型定义
- 修复 PositionCheckService 中未匹配订单更新逻辑,当仓位数量大于等于订单数量总和时也能正确处理 - 修复前端 API 类型定义,添加 remark 和 website 字段支持 - 添加 Leader 备注和网站字段支持
This commit is contained in:
@@ -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 或 crypto,null 表示不筛选
|
||||
|
||||
@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 网站(可选)';
|
||||
|
||||
Reference in New Issue
Block a user