feat: add copy trading safety and leader pool

This commit is contained in:
codyhhchen
2026-05-04 09:53:07 +08:00
parent 09f6e5346d
commit a7bfe660fc
64 changed files with 7006 additions and 29 deletions
@@ -112,6 +112,43 @@ class CopyTradingController(
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_COPY_TRADING_UPDATE_FAILED, e.message, messageSource))
}
}
/**
* 应用诊断建议的保守风控配置。
*/
@PostMapping("/apply-conservative-config")
fun applyConservativeConfig(@RequestBody request: ApplyConservativeConfigRequest): ResponseEntity<ApiResponse<CopyTradingDto>> {
return try {
if (request.copyTradingId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_COPY_TRADING_ID_INVALID, messageSource = messageSource))
}
val result = copyTradingService.applyConservativeConfig(request)
result.fold(
onSuccess = { copyTrading ->
ResponseEntity.ok(ApiResponse.success(copyTrading))
},
onFailure = { e ->
logger.error("应用保守配置失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> {
val errorCode = if (e.message == "跟单配置不存在") {
ErrorCode.COPY_TRADING_NOT_FOUND
} else {
ErrorCode.PARAM_ERROR
}
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
}
is IllegalStateException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.BUSINESS_ERROR, e.message, messageSource))
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_COPY_TRADING_UPDATE_FAILED, e.message, messageSource))
}
}
)
} catch (e: Exception) {
logger.error("应用保守配置异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_COPY_TRADING_UPDATE_FAILED, e.message, messageSource))
}
}
/**
* 更新跟单状态(兼容旧接口)
@@ -219,4 +256,3 @@ class CopyTradingController(
}
}
}
@@ -0,0 +1,172 @@
package com.wrbug.polymarketbot.controller.copytrading.leaderpool
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.copytrading.leaderpool.LeaderPoolAlreadyExistsException
import com.wrbug.polymarketbot.service.copytrading.leaderpool.LeaderPoolConfirmRequiredException
import com.wrbug.polymarketbot.service.copytrading.leaderpool.LeaderPoolDuplicateTrialConfigException
import com.wrbug.polymarketbot.service.copytrading.leaderpool.LeaderPoolNotFoundException
import com.wrbug.polymarketbot.service.copytrading.leaderpool.LeaderPoolService
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/copy-trading/leader-pool")
class LeaderPoolController(
private val leaderPoolService: LeaderPoolService,
private val messageSource: MessageSource
) {
private val logger = LoggerFactory.getLogger(LeaderPoolController::class.java)
@PostMapping("/list")
fun list(@RequestBody request: LeaderPoolListRequest): ResponseEntity<ApiResponse<LeaderPoolListResponse>> {
return try {
leaderPoolService.getPoolList(request).fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("查询 Leader 池失败: ${e.message}", e)
errorResponse(e, ErrorCode.SERVER_LEADER_POOL_LIST_FETCH_FAILED)
}
)
} catch (e: Exception) {
logger.error("查询 Leader 池异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_LEADER_POOL_LIST_FETCH_FAILED, e.message, messageSource))
}
}
@PostMapping("/add")
fun add(@RequestBody request: LeaderPoolAddRequest): ResponseEntity<ApiResponse<LeaderPoolItemDto>> {
return try {
if (request.leaderId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_LEADER_ID_INVALID, messageSource = messageSource))
}
leaderPoolService.addToPool(request).fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("加入 Leader 池失败: ${e.message}", e)
errorResponse(e, ErrorCode.SERVER_LEADER_POOL_SAVE_FAILED)
}
)
} catch (e: Exception) {
logger.error("加入 Leader 池异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_LEADER_POOL_SAVE_FAILED, e.message, messageSource))
}
}
@PostMapping("/update-status")
fun updateStatus(@RequestBody request: LeaderPoolUpdateStatusRequest): ResponseEntity<ApiResponse<LeaderPoolItemDto>> {
return try {
if (request.poolId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_INVALID, "poolId 无效", messageSource))
}
if (request.status.isBlank()) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, "status 不能为空", messageSource))
}
leaderPoolService.updateStatus(request).fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("更新 Leader 池状态失败: ${e.message}", e)
errorResponse(e, ErrorCode.SERVER_LEADER_POOL_SAVE_FAILED)
}
)
} catch (e: Exception) {
logger.error("更新 Leader 池状态异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_LEADER_POOL_SAVE_FAILED, e.message, messageSource))
}
}
@PostMapping("/update-plan")
fun updatePlan(@RequestBody request: LeaderPoolUpdatePlanRequest): ResponseEntity<ApiResponse<LeaderPoolItemDto>> {
return try {
if (request.poolId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_INVALID, "poolId 无效", messageSource))
}
leaderPoolService.updatePlan(request).fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("更新 Leader 池建议配置失败: ${e.message}", e)
errorResponse(e, ErrorCode.SERVER_LEADER_POOL_SAVE_FAILED)
}
)
} catch (e: Exception) {
logger.error("更新 Leader 池建议配置异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_LEADER_POOL_SAVE_FAILED, e.message, messageSource))
}
}
@PostMapping("/create-trial-config")
fun createTrialConfig(@RequestBody request: LeaderPoolCreateTrialConfigRequest): ResponseEntity<ApiResponse<CopyTradingDto>> {
return try {
if (request.poolId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_INVALID, "poolId 无效", messageSource))
}
if (request.accountId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ACCOUNT_ID_INVALID, messageSource = messageSource))
}
leaderPoolService.createTrialConfig(request).fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("创建 Leader 池试跟配置失败: ${e.message}", e)
errorResponse(e, ErrorCode.SERVER_LEADER_POOL_CREATE_TRIAL_FAILED)
}
)
} catch (e: Exception) {
logger.error("创建 Leader 池试跟配置异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_LEADER_POOL_CREATE_TRIAL_FAILED, e.message, messageSource))
}
}
@PostMapping("/remove")
fun remove(@RequestBody request: LeaderPoolRemoveRequest): ResponseEntity<ApiResponse<Unit>> {
return try {
if (request.poolId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_INVALID, "poolId 无效", messageSource))
}
leaderPoolService.remove(request).fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(Unit)) },
onFailure = { e ->
logger.error("移除 Leader 池项失败: ${e.message}", e)
errorResponse(e, ErrorCode.SERVER_LEADER_POOL_SAVE_FAILED)
}
)
} catch (e: Exception) {
logger.error("移除 Leader 池项异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_LEADER_POOL_SAVE_FAILED, e.message, messageSource))
}
}
private fun mapErrorCode(e: Throwable, fallback: ErrorCode): ErrorCode {
return when (e) {
is LeaderPoolNotFoundException -> ErrorCode.LEADER_POOL_NOT_FOUND
is LeaderPoolAlreadyExistsException -> ErrorCode.LEADER_POOL_ALREADY_EXISTS
is LeaderPoolDuplicateTrialConfigException -> ErrorCode.LEADER_POOL_DUPLICATE_TRIAL_CONFIG
is LeaderPoolConfirmRequiredException -> ErrorCode.LEADER_POOL_CONFIRM_REQUIRED
is IllegalArgumentException -> when (e.message) {
"账户不存在" -> ErrorCode.ACCOUNT_NOT_FOUND
"Leader 不存在" -> ErrorCode.LEADER_NOT_FOUND
else -> ErrorCode.PARAM_ERROR
}
else -> fallback
}
}
private fun <T> errorResponse(e: Throwable, fallback: ErrorCode): ResponseEntity<ApiResponse<T>> {
val errorCode = mapErrorCode(e, fallback)
val customMsg = if (usesI18nMessage(errorCode)) null else e.message
return ResponseEntity.ok(ApiResponse.error(errorCode, customMsg, messageSource))
}
private fun usesI18nMessage(errorCode: ErrorCode): Boolean {
return errorCode == ErrorCode.LEADER_POOL_NOT_FOUND ||
errorCode == ErrorCode.LEADER_POOL_ALREADY_EXISTS ||
errorCode == ErrorCode.LEADER_POOL_DUPLICATE_TRIAL_CONFIG ||
errorCode == ErrorCode.LEADER_POOL_CONFIRM_REQUIRED ||
errorCode == ErrorCode.ACCOUNT_NOT_FOUND ||
errorCode == ErrorCode.LEADER_NOT_FOUND
}
}
@@ -84,6 +84,24 @@ data class CopyTradingUpdateRequest(
val maxMarketEndDate: Long? = null // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
)
/**
* 应用保守风控配置请求。
*
* 只暴露安全带允许修改的白名单字段,避免误改 leader、启用状态、金额模式等真实交易行为。
*/
data class ApplyConservativeConfigRequest(
val copyTradingId: Long,
val confirm: Boolean = false,
val maxDailyOrders: Int? = null,
val maxDailyLoss: String? = null,
val minPrice: String? = null,
val maxPrice: String? = null,
val maxPositionValue: String? = null,
val minOrderDepth: String? = null,
val maxSpread: String? = null,
val priceTolerance: String? = null
)
/**
* 跟单列表请求
*/
@@ -189,4 +207,3 @@ data class AccountTemplatesResponse(
val list: List<AccountTemplateDto>,
val total: Long
)
@@ -26,6 +26,14 @@ data class CopyTradingStatisticsResponse(
val currentPositionQuantity: String,
val currentPositionCost: String,
val currentPositionValue: String,
val zeroValuePositionCost: String = "0",
val confirmedZeroValuePositionCost: String = "0",
val quoteOverallStatus: String = "AVAILABLE",
val quoteAvailableCount: Int = 0,
val quoteNoMatchCount: Int = 0,
val quoteUnavailableCount: Int = 0,
val quoteIncomplete: Boolean = false,
val riskDiagnosis: CopyTradingRiskDiagnosisDto? = null,
// 盈亏统计
val totalRealizedPnl: String,
@@ -34,6 +42,49 @@ data class CopyTradingStatisticsResponse(
val totalPnlPercent: String
)
data class CopyTradingRiskDiagnosisDto(
val copyTradingId: Long,
val totalRealizedPnl: String,
val totalUnrealizedPnl: String,
val totalPnl: String,
val currentPositionCost: String,
val currentPositionValue: String,
val zeroValuePositionCost: String,
val confirmedZeroValuePositionCost: String,
val zeroSellLoss: String,
val openPositionQuantity: String,
val totalBuyOrders: Int,
val totalSellRecords: Int,
val totalMatchDetails: Int,
val filteredOrderCount: Long,
val sampleSize: Int,
val lowConfidence: Boolean,
val confidenceReason: String,
val quoteOverallStatus: String,
val quoteAvailableCount: Int,
val quoteNoMatchCount: Int,
val quoteUnavailableCount: Int,
val dataIncomplete: Boolean,
val missingSources: List<String>,
val topLosingMarkets: List<TopLosingMarketDto>,
val riskWarnings: List<RiskWarningDto>,
val generatedAt: Long
)
data class TopLosingMarketDto(
val marketId: String,
val realizedPnl: String,
val matchedOrders: Int
)
data class RiskWarningDto(
val field: String,
val currentValue: String?,
val suggestedValue: String,
val severity: String,
val reason: String
)
/**
* 买入订单信息
*/
@@ -209,4 +260,3 @@ data class StatisticsResponse(
val maxProfit: String,
val maxLoss: String
)
@@ -0,0 +1,86 @@
package com.wrbug.polymarketbot.dto
data class LeaderPoolListRequest(
val status: String? = null
)
data class LeaderPoolAddRequest(
val leaderId: Long,
val source: String? = null,
val reason: String? = null,
val notes: String? = null
)
data class LeaderPoolUpdateStatusRequest(
val poolId: Long,
val status: String,
val cooldownUntil: Long? = null,
val locked: Boolean? = null
)
data class LeaderPoolUpdatePlanRequest(
val poolId: Long,
val suggestedFixedAmount: String? = null,
val suggestedMaxDailyOrders: Int? = null,
val suggestedMaxDailyLoss: String? = null,
val suggestedMinPrice: String? = null,
val suggestedMaxPrice: String? = null,
val suggestedMaxPositionValue: String? = null,
val reason: String? = null,
val notes: String? = null
)
data class LeaderPoolCreateTrialConfigRequest(
val poolId: Long,
val accountId: Long,
val enableImmediately: Boolean = false,
val confirm: Boolean = false
)
data class LeaderPoolRemoveRequest(
val poolId: Long
)
data class LeaderPoolSummaryDto(
val totalCount: Int,
val trialCount: Int,
val estimatedWorstExposure: String,
val pendingRiskCount: Int,
val defaultExperimentBudget: String = "50"
)
data class LeaderPoolItemDto(
val id: Long,
val leaderId: Long,
val leaderName: String?,
val leaderAddress: String,
val category: String?,
val profileUrl: String,
val status: String,
val source: String,
val sourceRank: Int?,
val score: String?,
val reason: String?,
val notes: String?,
val suggestedFixedAmount: String,
val suggestedMaxDailyOrders: Int,
val suggestedMaxDailyLoss: String,
val suggestedMinPrice: String?,
val suggestedMaxPrice: String?,
val suggestedMaxPositionValue: String?,
val copyTradingCount: Int,
val hasEnabledCopyTrading: Boolean,
val estimatedWorstExposure: String,
val lastReviewedAt: Long?,
val lastPromotedAt: Long?,
val cooldownUntil: Long?,
val locked: Boolean,
val createdAt: Long,
val updatedAt: Long
)
data class LeaderPoolListResponse(
val summary: LeaderPoolSummaryDto,
val list: List<LeaderPoolItemDto>,
val total: Int
)
@@ -0,0 +1,71 @@
package com.wrbug.polymarketbot.entity
import com.wrbug.polymarketbot.enums.LeaderPoolStatus
import jakarta.persistence.*
import java.math.BigDecimal
@Entity
@Table(name = "copy_trading_leader_pool")
data class LeaderPool(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "leader_id", nullable = false)
val leaderId: Long,
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20, columnDefinition = "VARCHAR(20)")
val status: LeaderPoolStatus = LeaderPoolStatus.CANDIDATE,
@Column(name = "source", nullable = false, length = 50)
val source: String = "MANUAL",
@Column(name = "source_rank")
val sourceRank: Int? = null,
@Column(name = "score", precision = 20, scale = 8)
val score: BigDecimal? = null,
@Column(name = "reason", columnDefinition = "TEXT")
val reason: String? = null,
@Column(name = "notes", columnDefinition = "TEXT")
val notes: String? = null,
@Column(name = "suggested_fixed_amount", nullable = false, precision = 20, scale = 8)
val suggestedFixedAmount: BigDecimal = BigDecimal("1.00000000"),
@Column(name = "suggested_max_daily_orders", nullable = false)
val suggestedMaxDailyOrders: Int = 10,
@Column(name = "suggested_max_daily_loss", nullable = false, precision = 20, scale = 8)
val suggestedMaxDailyLoss: BigDecimal = BigDecimal("5.00000000"),
@Column(name = "suggested_min_price", precision = 20, scale = 8)
val suggestedMinPrice: BigDecimal? = BigDecimal("0.10000000"),
@Column(name = "suggested_max_price", precision = 20, scale = 8)
val suggestedMaxPrice: BigDecimal? = BigDecimal("0.80000000"),
@Column(name = "suggested_max_position_value", precision = 20, scale = 8)
val suggestedMaxPositionValue: BigDecimal? = BigDecimal("5.00000000"),
@Column(name = "last_reviewed_at")
val lastReviewedAt: Long? = null,
@Column(name = "last_promoted_at")
val lastPromotedAt: Long? = null,
@Column(name = "cooldown_until")
val cooldownUntil: Long? = null,
@Column(name = "locked", nullable = false)
val locked: Boolean = false,
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@Column(name = "updated_at", nullable = false)
var updatedAt: Long = System.currentTimeMillis()
)
@@ -110,6 +110,10 @@ enum class ErrorCode(
COPY_TRADING_DISABLED(4202, "跟单关系已禁用", "error.copy_trading_disabled"),
COPY_TRADING_ENABLED(4203, "跟单关系已启用", "error.copy_trading_enabled"),
NO_ENABLED_COPY_TRADINGS(4204, "没有启用的跟单关系", "error.no_enabled_copy_tradings"),
LEADER_POOL_NOT_FOUND(4251, "Leader 池项不存在", "error.leader_pool_not_found"),
LEADER_POOL_ALREADY_EXISTS(4252, "Leader 已在池子中", "error.leader_pool_already_exists"),
LEADER_POOL_DUPLICATE_TRIAL_CONFIG(4253, "该账户已存在此 Leader 的跟单配置", "error.leader_pool_duplicate_trial_config"),
LEADER_POOL_CONFIRM_REQUIRED(4254, "立即启用试跟配置需要显式确认", "error.leader_pool_confirm_required"),
// 订单相关 (4301-4399)
ORDER_CREATE_FAILED(4301, "创建订单失败", "error.order_create_failed"),
@@ -213,6 +217,9 @@ enum class ErrorCode(
SERVER_COPY_TRADING_DELETE_FAILED(5403, "删除跟单失败", "error.server.copy_trading_delete_failed"),
SERVER_COPY_TRADING_LIST_FETCH_FAILED(5404, "查询跟单列表失败", "error.server.copy_trading_list_fetch_failed"),
SERVER_COPY_TRADING_TEMPLATES_FETCH_FAILED(5405, "查询钱包绑定的模板失败", "error.server.copy_trading_templates_fetch_failed"),
SERVER_LEADER_POOL_LIST_FETCH_FAILED(5451, "查询 Leader 池失败", "error.server.leader_pool_list_fetch_failed"),
SERVER_LEADER_POOL_SAVE_FAILED(5452, "保存 Leader 池失败", "error.server.leader_pool_save_failed"),
SERVER_LEADER_POOL_CREATE_TRIAL_FAILED(5453, "创建 Leader 池试跟配置失败", "error.server.leader_pool_create_trial_failed"),
// 市场服务错误 (5501-5599)
SERVER_MARKET_PRICE_FETCH_FAILED(5501, "获取市场价格失败", "error.server.market_price_fetch_failed"),
@@ -283,4 +290,3 @@ enum class ErrorCode(
}
}
}
@@ -0,0 +1,11 @@
package com.wrbug.polymarketbot.enums
enum class LeaderPoolStatus {
CANDIDATE,
WATCH,
PAPER,
TRIAL,
ACTIVE,
COOLDOWN,
RETIRED
}
@@ -19,6 +19,11 @@ interface CopyTradingRepository : JpaRepository<CopyTrading, Long> {
* 根据 Leader ID 查找跟单列表
*/
fun findByLeaderId(leaderId: Long): List<CopyTrading>
/**
* 根据 Leader ID 批量查找跟单列表,用于聚合页面避免 N+1 查询。
*/
fun findByLeaderIdIn(leaderIds: Collection<Long>): List<CopyTrading>
/**
* 根据账户ID和Leader ID查找跟单列表
@@ -48,4 +53,3 @@ interface CopyTradingRepository : JpaRepository<CopyTrading, Long> {
*/
fun countByLeaderId(leaderId: Long): Long
}
@@ -0,0 +1,19 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.LeaderPool
import com.wrbug.polymarketbot.enums.LeaderPoolStatus
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface LeaderPoolRepository : JpaRepository<LeaderPool, Long> {
fun findByLeaderId(leaderId: Long): LeaderPool?
fun existsByLeaderId(leaderId: Long): Boolean
fun findByStatus(status: LeaderPoolStatus): List<LeaderPool>
fun findAllByOrderByCreatedAtDesc(): List<LeaderPool>
fun deleteByLeaderId(leaderId: Long)
}
@@ -0,0 +1,87 @@
package com.wrbug.polymarketbot.service.copytrading.configs
import com.wrbug.polymarketbot.dto.ApplyConservativeConfigRequest
import com.wrbug.polymarketbot.entity.CopyTrading
import java.math.BigDecimal
object CopyTradingSafetyConfigService {
private val MAX_DAILY_LOSS_LIMIT = BigDecimal("10")
private val MAX_POSITION_VALUE_LIMIT = BigDecimal("10")
private val MIN_ORDER_DEPTH_LIMIT = BigDecimal("100")
private val MAX_SPREAD_LIMIT = BigDecimal("0.03")
private val PRICE_TOLERANCE_LIMIT = BigDecimal("3")
fun applyConservativeConfig(
current: CopyTrading,
request: ApplyConservativeConfigRequest
): CopyTrading {
if (!request.confirm) {
throw IllegalStateException("应用保守配置需要显式确认")
}
val maxDailyOrders = request.maxDailyOrders?.also {
if (it !in 1..20) {
throw IllegalArgumentException("maxDailyOrders 必须在 1 到 20 之间")
}
} ?: current.maxDailyOrders
val maxDailyLoss = request.maxDailyLoss?.asPositiveDecimalAtMost("maxDailyLoss", MAX_DAILY_LOSS_LIMIT)
?: current.maxDailyLoss
val minPrice = request.minPrice?.asPrice("minPrice") ?: current.minPrice
val maxPrice = request.maxPrice?.asPrice("maxPrice") ?: current.maxPrice
if (minPrice != null && maxPrice != null && minPrice > maxPrice) {
throw IllegalArgumentException("minPrice 不能大于 maxPrice")
}
return current.copy(
maxDailyOrders = maxDailyOrders,
maxDailyLoss = maxDailyLoss,
minPrice = minPrice,
maxPrice = maxPrice,
maxPositionValue = request.maxPositionValue?.asPositiveDecimalAtMost("maxPositionValue", MAX_POSITION_VALUE_LIMIT)
?: current.maxPositionValue,
minOrderDepth = request.minOrderDepth?.asPositiveDecimalAtLeast("minOrderDepth", MIN_ORDER_DEPTH_LIMIT)
?: current.minOrderDepth,
maxSpread = request.maxSpread?.asPositiveDecimalAtMost("maxSpread", MAX_SPREAD_LIMIT)
?: current.maxSpread,
priceTolerance = request.priceTolerance?.asPositiveDecimalAtMost("priceTolerance", PRICE_TOLERANCE_LIMIT)
?: current.priceTolerance,
updatedAt = System.currentTimeMillis()
)
}
private fun String.asPositiveDecimal(field: String): BigDecimal {
val value = trim().toBigDecimalOrNull()
?: throw IllegalArgumentException("$field 必须是有效数字")
if (value <= BigDecimal.ZERO) {
throw IllegalArgumentException("$field 必须大于 0")
}
return value
}
private fun String.asPositiveDecimalAtMost(field: String, max: BigDecimal): BigDecimal {
val value = asPositiveDecimal(field)
if (value > max) {
throw IllegalArgumentException("$field 必须大于 0 且不超过 ${max.strip()}")
}
return value
}
private fun String.asPositiveDecimalAtLeast(field: String, min: BigDecimal): BigDecimal {
val value = asPositiveDecimal(field)
if (value < min) {
throw IllegalArgumentException("$field 必须不小于 ${min.strip()}")
}
return value
}
private fun String.asPrice(field: String): BigDecimal {
val value = asPositiveDecimal(field)
if (value > BigDecimal.ONE) {
throw IllegalArgumentException("$field 必须在 0 到 1 之间")
}
return value
}
private fun BigDecimal.strip(): String = stripTrailingZeros().toPlainString()
}
@@ -328,6 +328,36 @@ class CopyTradingService(
Result.failure(e)
}
}
@Transactional
fun applyConservativeConfig(request: ApplyConservativeConfigRequest): Result<CopyTradingDto> {
return try {
val copyTrading = copyTradingRepository.findById(request.copyTradingId).orElse(null)
?: return Result.failure(IllegalArgumentException("跟单配置不存在"))
val updated = CopyTradingSafetyConfigService.applyConservativeConfig(copyTrading, request)
val saved = copyTradingRepository.save(updated)
kotlinx.coroutines.runBlocking {
try {
monitorService.updateLeaderMonitoring(saved.leaderId)
monitorService.updateAccountMonitoring(saved.accountId)
} catch (e: Exception) {
logger.error("更新监听失败", e)
}
}
val account = accountRepository.findById(saved.accountId).orElse(null)
val leader = leaderRepository.findById(saved.leaderId).orElse(null)
if (account == null || leader == null) {
return Result.failure(IllegalStateException("跟单配置数据不完整"))
}
Result.success(toDto(saved, account, leader))
} catch (e: Exception) {
logger.error("应用保守配置失败", e)
Result.failure(e)
}
}
/**
* 更新跟单状态(兼容旧接口)
@@ -0,0 +1,9 @@
package com.wrbug.polymarketbot.service.copytrading.leaderpool
class LeaderPoolNotFoundException(message: String = "Leader 池项不存在") : RuntimeException(message)
class LeaderPoolAlreadyExistsException(message: String = "Leader 已在池子中") : RuntimeException(message)
class LeaderPoolDuplicateTrialConfigException(message: String = "该账户已存在此 Leader 的跟单配置") : RuntimeException(message)
class LeaderPoolConfirmRequiredException(message: String = "立即启用试跟配置需要显式确认") : RuntimeException(message)
@@ -0,0 +1,435 @@
package com.wrbug.polymarketbot.service.copytrading.leaderpool
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.entity.CopyTrading
import com.wrbug.polymarketbot.entity.Leader
import com.wrbug.polymarketbot.entity.LeaderPool
import com.wrbug.polymarketbot.enums.LeaderPoolStatus
import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.repository.CopyTradingRepository
import com.wrbug.polymarketbot.repository.LeaderPoolRepository
import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.service.copytrading.configs.CopyTradingService
import org.slf4j.LoggerFactory
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
@Service
class LeaderPoolService(
private val leaderPoolRepository: LeaderPoolRepository,
private val leaderRepository: LeaderRepository,
private val copyTradingRepository: CopyTradingRepository,
private val accountRepository: AccountRepository,
private val copyTradingService: CopyTradingService
) {
private val logger = LoggerFactory.getLogger(LeaderPoolService::class.java)
@Transactional
open fun addToPool(request: LeaderPoolAddRequest): Result<LeaderPoolItemDto> {
return try {
val leader = leaderRepository.findById(request.leaderId).orElse(null)
if (leader == null) {
logger.warn("拒绝加入 Leader 池,Leader 不存在: leaderId={}", request.leaderId)
return Result.failure(IllegalArgumentException("Leader 不存在"))
}
leaderPoolRepository.findByLeaderId(request.leaderId)?.let {
logger.warn("Leader 已在池子中: leaderId={}, poolId={}", request.leaderId, it.id)
return Result.failure(LeaderPoolAlreadyExistsException())
}
val now = System.currentTimeMillis()
val pool = LeaderPool(
leaderId = request.leaderId,
source = request.source?.trim().takeUnless { it.isNullOrBlank() } ?: "MANUAL",
reason = request.reason?.trim().takeUnless { it.isNullOrBlank() },
notes = request.notes?.trim().takeUnless { it.isNullOrBlank() },
createdAt = now,
updatedAt = now
)
val saved = try {
leaderPoolRepository.saveAndFlush(pool)
} catch (e: DataIntegrityViolationException) {
logger.warn("并发重复加入 Leader 池: leaderId={}", request.leaderId, e)
return Result.failure(LeaderPoolAlreadyExistsException())
}
logger.info("Leader 加入池子: leaderId={}, poolId={}, status={}", saved.leaderId, saved.id, saved.status)
Result.success(toDto(saved, leader, emptyList()))
} catch (e: Exception) {
logger.error("加入 Leader 池失败: leaderId=${request.leaderId}", e)
Result.failure(e)
}
}
open fun getPoolList(request: LeaderPoolListRequest): Result<LeaderPoolListResponse> {
return try {
val status = request.status?.trim().takeUnless { it.isNullOrBlank() }?.let { parseStatus(it) }
val pools = if (status != null) {
leaderPoolRepository.findByStatus(status).sortedByDescending { it.createdAt }
} else {
leaderPoolRepository.findAllByOrderByCreatedAtDesc()
}
val leaderIds = pools.map { it.leaderId }.distinct()
val leaders = if (leaderIds.isEmpty()) {
emptyMap()
} else {
leaderRepository.findAllById(leaderIds).associateBy { it.id!! }
}
val copyTradingsByLeader = if (leaderIds.isEmpty()) {
emptyMap()
} else {
copyTradingRepository.findByLeaderIdIn(leaderIds).groupBy { it.leaderId }
}
val items = pools.mapNotNull { pool ->
val leader = leaders[pool.leaderId]
if (leader == null) {
logger.warn("Leader 池项缺少 leader 记录: poolId={}, leaderId={}", pool.id, pool.leaderId)
null
} else {
toDto(pool, leader, copyTradingsByLeader[pool.leaderId].orEmpty())
}
}
val estimatedWorstExposure = items.fold(BigDecimal.ZERO) { acc, item ->
acc + BigDecimal(item.estimatedWorstExposure)
}
val summary = LeaderPoolSummaryDto(
totalCount = items.size,
trialCount = items.count { it.status == LeaderPoolStatus.TRIAL.name || it.status == LeaderPoolStatus.ACTIVE.name },
estimatedWorstExposure = estimatedWorstExposure.strip(),
pendingRiskCount = items.count { it.status == LeaderPoolStatus.COOLDOWN.name || it.hasEnabledCopyTrading },
)
Result.success(
LeaderPoolListResponse(
summary = summary,
list = items,
total = items.size
)
)
} catch (e: Exception) {
logger.error("查询 Leader 池列表失败", e)
Result.failure(e)
}
}
@Transactional
open fun updateStatus(request: LeaderPoolUpdateStatusRequest): Result<LeaderPoolItemDto> {
return try {
val pool = findPool(request.poolId)
val leader = findLeader(pool.leaderId)
val newStatus = parseStatus(request.status)
val now = System.currentTimeMillis()
val updated = pool.copy(
status = newStatus,
cooldownUntil = if (newStatus == LeaderPoolStatus.COOLDOWN) request.cooldownUntil else request.cooldownUntil ?: pool.cooldownUntil,
locked = request.locked ?: pool.locked,
lastReviewedAt = now,
updatedAt = now
)
val saved = leaderPoolRepository.save(updated)
logger.info(
"Leader 池状态变化: poolId={}, leaderId={}, status={}, cooldownUntil={}",
saved.id,
saved.leaderId,
saved.status,
saved.cooldownUntil
)
Result.success(toDto(saved, leader, copyTradingRepository.findByLeaderId(saved.leaderId)))
} catch (e: Exception) {
logger.error("更新 Leader 池状态失败: poolId=${request.poolId}", e)
Result.failure(e)
}
}
@Transactional
open fun updatePlan(request: LeaderPoolUpdatePlanRequest): Result<LeaderPoolItemDto> {
return try {
val pool = findPool(request.poolId)
val leader = findLeader(pool.leaderId)
val nextFixedAmount = parseDecimal("suggestedFixedAmount", request.suggestedFixedAmount) ?: pool.suggestedFixedAmount
val nextMaxDailyOrders = request.suggestedMaxDailyOrders ?: pool.suggestedMaxDailyOrders
val nextMaxDailyLoss = parseDecimal("suggestedMaxDailyLoss", request.suggestedMaxDailyLoss) ?: pool.suggestedMaxDailyLoss
val nextMinPrice = parseNullableDecimal("suggestedMinPrice", request.suggestedMinPrice, pool.suggestedMinPrice)
val nextMaxPrice = parseNullableDecimal("suggestedMaxPrice", request.suggestedMaxPrice, pool.suggestedMaxPrice)
val nextMaxPositionValue = parseNullableDecimal(
"suggestedMaxPositionValue",
request.suggestedMaxPositionValue,
pool.suggestedMaxPositionValue
)
validateSuggestedPlan(
fixedAmount = nextFixedAmount,
maxDailyOrders = nextMaxDailyOrders,
maxDailyLoss = nextMaxDailyLoss,
minPrice = nextMinPrice,
maxPrice = nextMaxPrice,
maxPositionValue = nextMaxPositionValue
)
val now = System.currentTimeMillis()
val updated = pool.copy(
suggestedFixedAmount = nextFixedAmount,
suggestedMaxDailyOrders = nextMaxDailyOrders,
suggestedMaxDailyLoss = nextMaxDailyLoss,
suggestedMinPrice = nextMinPrice,
suggestedMaxPrice = nextMaxPrice,
suggestedMaxPositionValue = nextMaxPositionValue,
reason = request.reason?.trim().takeUnless { it.isNullOrBlank() } ?: pool.reason,
notes = request.notes?.trim().takeUnless { it.isNullOrBlank() } ?: pool.notes,
lastReviewedAt = now,
updatedAt = now
)
val saved = leaderPoolRepository.save(updated)
logger.info("Leader 池建议配置更新: poolId={}, leaderId={}", saved.id, saved.leaderId)
Result.success(toDto(saved, leader, copyTradingRepository.findByLeaderId(saved.leaderId)))
} catch (e: Exception) {
logger.error("更新 Leader 池建议配置失败: poolId=${request.poolId}", e)
Result.failure(e)
}
}
@Transactional
open fun createTrialConfig(request: LeaderPoolCreateTrialConfigRequest): Result<CopyTradingDto> {
val pool = try {
findPool(request.poolId)
} catch (e: Exception) {
logger.error("创建 Leader 池试跟配置失败: poolId=${request.poolId}", e)
return Result.failure(e)
}
return try {
if (request.enableImmediately && !request.confirm) {
logger.warn("拒绝未确认的立即启用试跟配置: poolId={}, leaderId={}", pool.id, pool.leaderId)
return Result.failure(LeaderPoolConfirmRequiredException())
}
val account = accountRepository.findById(request.accountId).orElse(null)
if (account == null) {
logger.warn(
"拒绝创建 Leader 池试跟配置,账户不存在: poolId={}, leaderId={}, accountId={}",
pool.id,
pool.leaderId,
request.accountId
)
return Result.failure(IllegalArgumentException("账户不存在"))
}
val leader = findLeader(pool.leaderId)
validateSuggestedPlan(
fixedAmount = pool.suggestedFixedAmount,
maxDailyOrders = pool.suggestedMaxDailyOrders,
maxDailyLoss = pool.suggestedMaxDailyLoss,
minPrice = pool.suggestedMinPrice,
maxPrice = pool.suggestedMaxPrice,
maxPositionValue = pool.suggestedMaxPositionValue
)
if (copyTradingRepository.findByAccountIdAndLeaderId(request.accountId, pool.leaderId).isNotEmpty()) {
logger.warn(
"拒绝重复创建 Leader 池试跟配置: poolId={}, leaderId={}, accountId={}",
pool.id,
pool.leaderId,
request.accountId
)
return Result.failure(LeaderPoolDuplicateTrialConfigException())
}
val copyTradingRequest = CopyTradingCreateRequest(
accountId = request.accountId,
leaderId = pool.leaderId,
enabled = request.enableImmediately && request.confirm,
copyMode = "FIXED",
copyRatio = "1",
fixedAmount = pool.suggestedFixedAmount.strip(),
maxOrderSize = pool.suggestedFixedAmount.strip(),
minOrderSize = "1",
maxDailyLoss = pool.suggestedMaxDailyLoss.strip(),
maxDailyOrders = pool.suggestedMaxDailyOrders,
priceTolerance = "1",
delaySeconds = 0,
pollIntervalSeconds = 5,
useWebSocket = true,
websocketReconnectInterval = 5000,
websocketMaxRetries = 10,
supportSell = true,
minPrice = pool.suggestedMinPrice?.strip(),
maxPrice = pool.suggestedMaxPrice?.strip(),
maxPositionValue = pool.suggestedMaxPositionValue?.strip(),
keywordFilterMode = "DISABLED",
keywords = null,
configName = buildTrialConfigName(leader),
pushFailedOrders = true,
pushFilteredOrders = true
)
val result = copyTradingService.createCopyTrading(copyTradingRequest)
result.onSuccess {
val now = System.currentTimeMillis()
leaderPoolRepository.save(
pool.copy(
status = LeaderPoolStatus.TRIAL,
lastPromotedAt = now,
lastReviewedAt = now,
updatedAt = now
)
)
logger.info(
"Leader 池试跟配置创建成功: poolId={}, leaderId={}, accountId={}, copyTradingId={}",
pool.id,
pool.leaderId,
request.accountId,
it.id
)
}.onFailure {
logger.error(
"Leader 池试跟配置创建失败: poolId=${pool.id}, leaderId=${pool.leaderId}, accountId=${request.accountId}, error=${it.message}",
it
)
}
} catch (e: Exception) {
logger.error(
"创建 Leader 池试跟配置异常: poolId=${pool.id}, leaderId=${pool.leaderId}, accountId=${request.accountId}",
e
)
Result.failure(e)
}
}
@Transactional
open fun remove(request: LeaderPoolRemoveRequest): Result<Unit> {
return try {
val pool = findPool(request.poolId)
leaderPoolRepository.delete(pool)
logger.info("Leader 池项移除: poolId={}, leaderId={}", pool.id, pool.leaderId)
Result.success(Unit)
} catch (e: Exception) {
logger.error("移除 Leader 池项失败: poolId=${request.poolId}", e)
Result.failure(e)
}
}
private fun findPool(poolId: Long): LeaderPool {
return leaderPoolRepository.findById(poolId).orElse(null) ?: throw LeaderPoolNotFoundException()
}
private fun findLeader(leaderId: Long): Leader {
return leaderRepository.findById(leaderId).orElse(null) ?: throw IllegalArgumentException("Leader 不存在")
}
private fun parseStatus(status: String): LeaderPoolStatus {
return try {
LeaderPoolStatus.valueOf(status.trim().uppercase())
} catch (e: Exception) {
throw IllegalArgumentException("Leader 池状态无效")
}
}
private fun parseDecimal(fieldName: String, value: String?): BigDecimal? {
if (value == null) return null
return value.trim().takeUnless { it.isBlank() }?.toBigDecimalOrNull()
?: throw IllegalArgumentException("$fieldName 必须是有效数字")
}
private fun parseNullableDecimal(fieldName: String, value: String?, current: BigDecimal?): BigDecimal? {
if (value == null) return current
val trimmed = value.trim()
if (trimmed.isBlank()) return null
return trimmed.toBigDecimalOrNull() ?: throw IllegalArgumentException("$fieldName 必须是有效数字")
}
private fun validateSuggestedPlan(
fixedAmount: BigDecimal,
maxDailyOrders: Int,
maxDailyLoss: BigDecimal,
minPrice: BigDecimal?,
maxPrice: BigDecimal?,
maxPositionValue: BigDecimal?
) {
if (fixedAmount <= BigDecimal.ZERO) {
throw IllegalArgumentException("suggestedFixedAmount 必须大于 0")
}
if (maxDailyOrders !in 1..100) {
throw IllegalArgumentException("suggestedMaxDailyOrders 必须在 1 到 100 之间")
}
if (maxDailyLoss <= BigDecimal.ZERO) {
throw IllegalArgumentException("suggestedMaxDailyLoss 必须大于 0")
}
minPrice?.let {
if (it < BigDecimal.ZERO || it > BigDecimal.ONE) {
throw IllegalArgumentException("suggestedMinPrice 必须在 0 到 1 之间")
}
}
maxPrice?.let {
if (it < BigDecimal.ZERO || it > BigDecimal.ONE) {
throw IllegalArgumentException("suggestedMaxPrice 必须在 0 到 1 之间")
}
}
if (minPrice != null && maxPrice != null && minPrice > maxPrice) {
throw IllegalArgumentException("suggestedMinPrice 不能大于 suggestedMaxPrice")
}
maxPositionValue?.let {
if (it <= BigDecimal.ZERO) {
throw IllegalArgumentException("suggestedMaxPositionValue 必须大于 0")
}
}
}
private fun toDto(pool: LeaderPool, leader: Leader, copyTradings: List<CopyTrading>): LeaderPoolItemDto {
val isTrialOrActive = pool.status == LeaderPoolStatus.TRIAL || pool.status == LeaderPoolStatus.ACTIVE
val estimatedWorstExposure = if (isTrialOrActive) {
pool.suggestedMaxPositionValue ?: DEFAULT_MAX_POSITION_VALUE
} else {
BigDecimal.ZERO
}
val leaderAddress = leader.leaderAddress
return LeaderPoolItemDto(
id = pool.id!!,
leaderId = pool.leaderId,
leaderName = leader.leaderName,
leaderAddress = leaderAddress,
category = leader.category,
profileUrl = "https://polymarket.com/profile/$leaderAddress",
status = pool.status.name,
source = pool.source,
sourceRank = pool.sourceRank,
score = pool.score?.strip(),
reason = pool.reason,
notes = pool.notes,
suggestedFixedAmount = pool.suggestedFixedAmount.strip(),
suggestedMaxDailyOrders = pool.suggestedMaxDailyOrders,
suggestedMaxDailyLoss = pool.suggestedMaxDailyLoss.strip(),
suggestedMinPrice = pool.suggestedMinPrice?.strip(),
suggestedMaxPrice = pool.suggestedMaxPrice?.strip(),
suggestedMaxPositionValue = pool.suggestedMaxPositionValue?.strip(),
copyTradingCount = copyTradings.size,
hasEnabledCopyTrading = copyTradings.any { it.enabled },
estimatedWorstExposure = estimatedWorstExposure.strip(),
lastReviewedAt = pool.lastReviewedAt,
lastPromotedAt = pool.lastPromotedAt,
cooldownUntil = pool.cooldownUntil,
locked = pool.locked,
createdAt = pool.createdAt,
updatedAt = pool.updatedAt
)
}
private fun buildTrialConfigName(leader: Leader): String {
val baseName = leader.leaderName?.trim().takeUnless { it.isNullOrBlank() }
?: leader.leaderAddress.takeLast(6)
return "Leader池-$baseName"
}
private fun BigDecimal.strip(): String = stripTrailingZeros().toPlainString()
companion object {
private val DEFAULT_MAX_POSITION_VALUE = BigDecimal("5")
}
}
@@ -46,10 +46,30 @@ object CopyTradingPnlCalculator {
val openOrders = buyOrders.filter { it.remainingQuantity.toSafeBigDecimal().gt(BigDecimal.ZERO) }
val currentPositionQuantity = openOrders.sumOf { it.remainingQuantity.toSafeBigDecimal() }
val currentPositionCost = openOrders.sumOf { it.remainingQuantity.toSafeBigDecimal().multi(it.price) }
val currentPositionValue = openOrders.sumOf { order ->
val currentPrice = findQuote(order, quotes)?.currentPrice ?: BigDecimal.ZERO
order.remainingQuantity.toSafeBigDecimal().multi(currentPrice)
val hasUnavailableQuotes = quotes.any { it.status == PositionQuoteStatus.UNAVAILABLE }
val quotedOpenPositions = openOrders.map { order ->
val quote = findQuote(order, quotes)
val status = when {
quote?.status == PositionQuoteStatus.AVAILABLE -> PositionQuoteStatus.AVAILABLE
hasUnavailableQuotes -> PositionQuoteStatus.UNAVAILABLE
else -> PositionQuoteStatus.NO_MATCH
}
QuotedOpenPosition(
order = order,
status = status,
currentPrice = quote?.currentPrice ?: BigDecimal.ZERO
)
}
val currentPositionValue = quotedOpenPositions.sumOf { position ->
position.order.remainingQuantity.toSafeBigDecimal().multi(position.currentPrice)
}
val zeroValuePositionCost = quotedOpenPositions
.filter { it.currentPrice.lte(BigDecimal.ZERO) }
.sumOf { it.order.remainingQuantity.toSafeBigDecimal().multi(it.order.price) }
val confirmedZeroValuePositionCost = quotedOpenPositions
.filter { it.status == PositionQuoteStatus.AVAILABLE && it.currentPrice.lte(BigDecimal.ZERO) }
.sumOf { it.order.remainingQuantity.toSafeBigDecimal().multi(it.order.price) }
val quoteStatusSummary = QuoteStatusSummary.from(quotedOpenPositions.map { it.status })
val totalRealizedPnl = matchDetails.sumOf { it.realizedPnl.toSafeBigDecimal() }
val totalUnrealizedPnl = currentPositionValue.subtract(currentPositionCost)
@@ -66,6 +86,9 @@ object CopyTradingPnlCalculator {
currentPositionQuantity = currentPositionQuantity,
currentPositionCost = currentPositionCost,
currentPositionValue = currentPositionValue,
zeroValuePositionCost = zeroValuePositionCost,
confirmedZeroValuePositionCost = confirmedZeroValuePositionCost,
quoteStatusSummary = quoteStatusSummary,
totalRealizedPnl = totalRealizedPnl,
totalUnrealizedPnl = totalUnrealizedPnl,
totalPnl = totalPnl,
@@ -99,6 +122,59 @@ data class PositionValuationQuote(
val marketId: String,
val outcomeIndex: Int?,
val side: String?,
val currentPrice: BigDecimal,
val status: PositionQuoteStatus = PositionQuoteStatus.AVAILABLE,
val failureReason: String? = null
) {
companion object {
fun unavailable(reason: String? = null): PositionValuationQuote {
return PositionValuationQuote(
marketId = "__unavailable__",
outcomeIndex = null,
side = null,
currentPrice = BigDecimal.ZERO,
status = PositionQuoteStatus.UNAVAILABLE,
failureReason = reason
)
}
}
}
enum class PositionQuoteStatus {
AVAILABLE,
NO_MATCH,
UNAVAILABLE
}
data class QuoteStatusSummary(
val overallStatus: PositionQuoteStatus,
val availableCount: Int,
val noMatchCount: Int,
val unavailableCount: Int
) {
companion object {
fun from(statuses: List<PositionQuoteStatus>): QuoteStatusSummary {
val unavailableCount = statuses.count { it == PositionQuoteStatus.UNAVAILABLE }
val noMatchCount = statuses.count { it == PositionQuoteStatus.NO_MATCH }
val availableCount = statuses.count { it == PositionQuoteStatus.AVAILABLE }
val overallStatus = when {
unavailableCount > 0 -> PositionQuoteStatus.UNAVAILABLE
noMatchCount > 0 -> PositionQuoteStatus.NO_MATCH
else -> PositionQuoteStatus.AVAILABLE
}
return QuoteStatusSummary(
overallStatus = overallStatus,
availableCount = availableCount,
noMatchCount = noMatchCount,
unavailableCount = unavailableCount
)
}
}
}
private data class QuotedOpenPosition(
val order: CopyOrderTracking,
val status: PositionQuoteStatus,
val currentPrice: BigDecimal
)
@@ -113,6 +189,9 @@ data class CopyTradingPnlStatistics(
val currentPositionQuantity: BigDecimal,
val currentPositionCost: BigDecimal,
val currentPositionValue: BigDecimal,
val zeroValuePositionCost: BigDecimal,
val confirmedZeroValuePositionCost: BigDecimal,
val quoteStatusSummary: QuoteStatusSummary,
val totalRealizedPnl: BigDecimal,
val totalUnrealizedPnl: BigDecimal,
val totalPnl: BigDecimal,
@@ -0,0 +1,155 @@
package com.wrbug.polymarketbot.service.copytrading.statistics
import com.wrbug.polymarketbot.dto.CopyTradingRiskDiagnosisDto
import com.wrbug.polymarketbot.dto.RiskWarningDto
import com.wrbug.polymarketbot.dto.TopLosingMarketDto
import com.wrbug.polymarketbot.entity.CopyOrderTracking
import com.wrbug.polymarketbot.entity.CopyTrading
import com.wrbug.polymarketbot.entity.SellMatchDetail
import com.wrbug.polymarketbot.util.gt
import com.wrbug.polymarketbot.util.lte
import java.math.BigDecimal
object CopyTradingRiskDiagnosisService {
private const val MIN_CONFIDENCE_SAMPLE_SIZE = 10
fun buildDiagnosis(
copyTrading: CopyTrading,
buyOrders: List<CopyOrderTracking>,
sellRecordsCount: Int,
matchDetails: List<SellMatchDetail>,
filteredOrderCount: Long,
pnl: CopyTradingPnlStatistics,
generatedAt: Long = System.currentTimeMillis()
): CopyTradingRiskDiagnosisDto {
val trackingByBuyOrderId = buyOrders.associateBy { it.buyOrderId }
val topLosingMarkets = matchDetails
.groupBy { detail -> trackingByBuyOrderId[detail.buyOrderId]?.marketId ?: detail.buyOrderId }
.map { (marketId, details) ->
TopLosingMarketDto(
marketId = marketId,
realizedPnl = details.sumOf { it.realizedPnl }.toPlainString(),
matchedOrders = details.size
)
}
.filter { it.realizedPnl.toBigDecimalOrNull()?.lt(BigDecimal.ZERO) == true }
.sortedBy { it.realizedPnl.toBigDecimalOrNull() ?: BigDecimal.ZERO }
.take(10)
val zeroSellLoss = matchDetails
.filter { it.sellPrice.lte(BigDecimal.ZERO) }
.sumOf { it.realizedPnl.abs() }
val sampleSize = buyOrders.size
val lowConfidence = pnl.totalPnl.gt(BigDecimal.ZERO) && sampleSize < MIN_CONFIDENCE_SAMPLE_SIZE
val missingSources = buildList {
if (pnl.quoteStatusSummary.overallStatus == PositionQuoteStatus.UNAVAILABLE) add("position-quotes")
}
return CopyTradingRiskDiagnosisDto(
copyTradingId = copyTrading.id ?: 0,
totalRealizedPnl = pnl.totalRealizedPnl.toPlainString(),
totalUnrealizedPnl = pnl.totalUnrealizedPnl.toPlainString(),
totalPnl = pnl.totalPnl.toPlainString(),
currentPositionCost = pnl.currentPositionCost.toPlainString(),
currentPositionValue = pnl.currentPositionValue.toPlainString(),
zeroValuePositionCost = pnl.zeroValuePositionCost.toPlainString(),
confirmedZeroValuePositionCost = pnl.confirmedZeroValuePositionCost.toPlainString(),
zeroSellLoss = zeroSellLoss.toPlainString(),
openPositionQuantity = pnl.currentPositionQuantity.toPlainString(),
totalBuyOrders = buyOrders.size,
totalSellRecords = sellRecordsCount,
totalMatchDetails = matchDetails.size,
filteredOrderCount = filteredOrderCount,
sampleSize = sampleSize,
lowConfidence = lowConfidence,
confidenceReason = if (lowConfidence) {
"样本量 ${sampleSize} 笔,低于 ${MIN_CONFIDENCE_SAMPLE_SIZE} 笔,不能视为已验证盈利"
} else {
"样本量满足第一版诊断阈值"
},
quoteOverallStatus = pnl.quoteStatusSummary.overallStatus.name,
quoteAvailableCount = pnl.quoteStatusSummary.availableCount,
quoteNoMatchCount = pnl.quoteStatusSummary.noMatchCount,
quoteUnavailableCount = pnl.quoteStatusSummary.unavailableCount,
dataIncomplete = missingSources.isNotEmpty(),
missingSources = missingSources,
topLosingMarkets = topLosingMarkets,
riskWarnings = inspectRiskConfig(copyTrading),
generatedAt = generatedAt
)
}
fun inspectRiskConfig(copyTrading: CopyTrading): List<RiskWarningDto> {
return buildList {
if (copyTrading.maxDailyOrders > 20) {
add(
RiskWarningDto(
field = "maxDailyOrders",
currentValue = copyTrading.maxDailyOrders.toString(),
suggestedValue = "20",
severity = RiskSeverity.HIGH.name,
reason = "每日订单数过高,短周期市场会快速放大亏损"
)
)
}
if (copyTrading.maxDailyLoss > BigDecimal.TEN) {
add(
RiskWarningDto(
field = "maxDailyLoss",
currentValue = copyTrading.maxDailyLoss.strip(),
suggestedValue = "10",
severity = RiskSeverity.HIGH.name,
reason = "每日最大亏损过高,不能起到止血作用"
)
)
}
if (copyTrading.minPrice == null) {
addMissingGuard("minPrice", "0.10", "缺少最低价格限制,容易跟到极端赔率订单")
}
if (copyTrading.maxPrice == null) {
addMissingGuard("maxPrice", "0.80", "缺少最高价格限制,容易在高价位承担不对称下行")
}
if (copyTrading.maxPositionValue == null) {
addMissingGuard("maxPositionValue", "10", "缺少单市场仓位上限,同一市场可以堆出过大暴露")
}
if (copyTrading.minOrderDepth == null) {
addMissingGuard("minOrderDepth", "100", "缺少深度过滤,薄盘口订单更容易滑点")
}
if (copyTrading.maxSpread == null) {
addMissingGuard("maxSpread", "0.03", "缺少价差过滤,宽价差市场成交质量更差")
}
if (copyTrading.priceTolerance > BigDecimal("3")) {
add(
RiskWarningDto(
field = "priceTolerance",
currentValue = copyTrading.priceTolerance.strip(),
suggestedValue = "3",
severity = RiskSeverity.MEDIUM.name,
reason = "价格容忍度偏宽,实际成交可能偏离 leader 价格"
)
)
}
}
}
private fun MutableList<RiskWarningDto>.addMissingGuard(field: String, suggestedValue: String, reason: String) {
add(
RiskWarningDto(
field = field,
currentValue = null,
suggestedValue = suggestedValue,
severity = RiskSeverity.HIGH.name,
reason = reason
)
)
}
private fun BigDecimal.strip(): String = stripTrailingZeros().toPlainString()
private fun BigDecimal.lt(other: BigDecimal): Boolean = compareTo(other) < 0
}
enum class RiskSeverity {
LOW,
MEDIUM,
HIGH
}
@@ -30,6 +30,7 @@ class CopyTradingStatisticsService(
private val sellMatchDetailRepository: SellMatchDetailRepository,
private val accountRepository: AccountRepository,
private val leaderRepository: LeaderRepository,
private val filteredOrderRepository: FilteredOrderRepository,
private val marketService: com.wrbug.polymarketbot.service.common.MarketService,
private val blockchainService: BlockchainService
) {
@@ -62,8 +63,8 @@ class CopyTradingStatisticsService(
// 6. 获取当前价格并计算真实口径统计
// currentPositionCost 使用跟单系统记录的剩余仓位成本;currentPositionValue 使用
// Polymarket Data API 当前价格按剩余份额估值。若某个未平仓仓位没有报价按 0
// 估值,避免已归零/待赎回仓位继续被统计成成本价
// Polymarket Data API 当前价格按剩余份额估值。缺失报价按 0 参与旧字段计算,
// 但必须通过 quote status 告诉 UI 这是已确认归零、未匹配还是接口不可用
val hasOpenPosition = buyOrders.any { it.remainingQuantity.toSafeBigDecimal().gt(BigDecimal.ZERO) }
val quotes = if (hasOpenPosition) {
buildPositionValuationQuotes(account?.proxyAddress)
@@ -71,6 +72,15 @@ class CopyTradingStatisticsService(
emptyList()
}
val statistics = CopyTradingPnlCalculator.calculate(buyOrders, sellRecords, matchDetails, quotes)
val filteredOrderCount = filteredOrderRepository.countByCopyTradingId(copyTradingId)
val diagnosis = CopyTradingRiskDiagnosisService.buildDiagnosis(
copyTrading = copyTrading,
buyOrders = buyOrders,
sellRecordsCount = sellRecords.size,
matchDetails = matchDetails,
filteredOrderCount = filteredOrderCount,
pnl = statistics
)
// 7. 构建响应(总盈亏 = 已实现盈亏 + 未实现盈亏)
val response = CopyTradingStatisticsResponse(
@@ -90,6 +100,14 @@ class CopyTradingStatisticsService(
currentPositionQuantity = statistics.currentPositionQuantity.toString(),
currentPositionCost = statistics.currentPositionCost.toString(),
currentPositionValue = statistics.currentPositionValue.toString(),
zeroValuePositionCost = statistics.zeroValuePositionCost.toString(),
confirmedZeroValuePositionCost = statistics.confirmedZeroValuePositionCost.toString(),
quoteOverallStatus = statistics.quoteStatusSummary.overallStatus.name,
quoteAvailableCount = statistics.quoteStatusSummary.availableCount,
quoteNoMatchCount = statistics.quoteStatusSummary.noMatchCount,
quoteUnavailableCount = statistics.quoteStatusSummary.unavailableCount,
quoteIncomplete = statistics.quoteStatusSummary.overallStatus != PositionQuoteStatus.AVAILABLE,
riskDiagnosis = diagnosis,
totalRealizedPnl = statistics.totalRealizedPnl.toString(),
totalUnrealizedPnl = statistics.totalUnrealizedPnl.toString(),
totalPnl = statistics.totalPnl.toString(),
@@ -121,8 +139,9 @@ class CopyTradingStatisticsService(
return try {
val positionsResult = blockchainService.getPositions(normalizedProxyAddress)
if (positionsResult.isFailure) {
logger.warn("获取持仓报价失败: proxyAddress=${normalizedProxyAddress.take(10)}..., error=${positionsResult.exceptionOrNull()?.message}")
return emptyList()
val reason = positionsResult.exceptionOrNull()?.message
logger.warn("获取持仓报价失败: proxyAddress=${normalizedProxyAddress.take(10)}..., error=$reason")
return listOf(PositionValuationQuote.unavailable(reason = reason))
}
val quotes = positionsResult.getOrNull().orEmpty().mapNotNull { position ->
@@ -145,7 +164,7 @@ class CopyTradingStatisticsService(
quotes
} catch (e: Exception) {
logger.warn("获取持仓报价异常: proxyAddress=${normalizedProxyAddress.take(10)}..., error=${e.message}", e)
emptyList()
listOf(PositionValuationQuote.unavailable(reason = e.message))
}
}
@@ -0,0 +1,29 @@
-- ============================================
-- V41: 创建 Leader 池表
-- ============================================
CREATE TABLE IF NOT EXISTS copy_trading_leader_pool (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'Leader 池项ID',
leader_id BIGINT NOT NULL COMMENT 'Leader ID',
status VARCHAR(20) NOT NULL DEFAULT 'CANDIDATE' COMMENT '池子状态',
source VARCHAR(50) NOT NULL DEFAULT 'MANUAL' COMMENT '来源',
source_rank INT DEFAULT NULL COMMENT '来源排名',
score DECIMAL(20, 8) DEFAULT NULL COMMENT '来源评分',
reason TEXT DEFAULT NULL COMMENT '加入原因',
notes TEXT DEFAULT NULL COMMENT '备注',
suggested_fixed_amount DECIMAL(20, 8) NOT NULL DEFAULT 1.00000000 COMMENT '建议固定跟单金额',
suggested_max_daily_orders INT NOT NULL DEFAULT 10 COMMENT '建议每日最大订单数',
suggested_max_daily_loss DECIMAL(20, 8) NOT NULL DEFAULT 5.00000000 COMMENT '建议每日最大亏损',
suggested_min_price DECIMAL(20, 8) DEFAULT 0.10000000 COMMENT '建议最低价格',
suggested_max_price DECIMAL(20, 8) DEFAULT 0.80000000 COMMENT '建议最高价格',
suggested_max_position_value DECIMAL(20, 8) DEFAULT 5.00000000 COMMENT '建议最大持仓价值',
last_reviewed_at BIGINT DEFAULT NULL COMMENT '最后复核时间',
last_promoted_at BIGINT DEFAULT NULL COMMENT '最后晋升/试跟时间',
cooldown_until BIGINT DEFAULT NULL COMMENT '冷却截止时间',
locked TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否锁定,避免后续自动任务修改',
created_at BIGINT NOT NULL COMMENT '创建时间',
updated_at BIGINT NOT NULL COMMENT '更新时间',
UNIQUE KEY uk_leader_pool_leader_id (leader_id),
INDEX idx_leader_pool_status (status),
INDEX idx_leader_pool_source (source),
FOREIGN KEY (leader_id) REFERENCES copy_trading_leaders(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Copy Trading Leader 池';
@@ -151,6 +151,10 @@ error.copy_trading_already_exists=This copy trading relationship already exists
error.copy_trading_disabled=Copy trading relationship is disabled
error.copy_trading_enabled=Copy trading relationship is enabled
error.no_enabled_copy_tradings=No enabled copy trading relationships
error.leader_pool_not_found=Leader pool item not found
error.leader_pool_already_exists=Leader is already in the pool
error.leader_pool_duplicate_trial_config=This account already has a copy trading config for this Leader
error.leader_pool_confirm_required=Immediate trial enablement requires explicit confirmation
# Order related
error.order_create_failed=Failed to create order
@@ -247,6 +251,9 @@ error.server.copy_trading_update_failed=Failed to update copy trading
error.server.copy_trading_delete_failed=Failed to delete copy trading
error.server.copy_trading_list_fetch_failed=Failed to query copy trading list
error.server.copy_trading_templates_fetch_failed=Failed to query templates bound to wallet
error.server.leader_pool_list_fetch_failed=Failed to fetch Leader pool
error.server.leader_pool_save_failed=Failed to save Leader pool
error.server.leader_pool_create_trial_failed=Failed to create Leader pool trial config
# Market service errors
error.server.market_price_fetch_failed=Failed to fetch market price
@@ -151,6 +151,10 @@ error.copy_trading_already_exists=该跟单关系已存在
error.copy_trading_disabled=跟单关系已禁用
error.copy_trading_enabled=跟单关系已启用
error.no_enabled_copy_tradings=没有启用的跟单关系
error.leader_pool_not_found=Leader 池项不存在
error.leader_pool_already_exists=Leader 已在池子中
error.leader_pool_duplicate_trial_config=该账户已存在此 Leader 的跟单配置
error.leader_pool_confirm_required=立即启用试跟配置需要显式确认
# 订单相关
error.order_create_failed=创建订单失败
@@ -247,6 +251,9 @@ error.server.copy_trading_update_failed=更新跟单失败
error.server.copy_trading_delete_failed=删除跟单失败
error.server.copy_trading_list_fetch_failed=查询跟单列表失败
error.server.copy_trading_templates_fetch_failed=查询钱包绑定的模板失败
error.server.leader_pool_list_fetch_failed=查询 Leader 池失败
error.server.leader_pool_save_failed=保存 Leader 池失败
error.server.leader_pool_create_trial_failed=创建 Leader 池试跟配置失败
# 市场服务错误
error.server.market_price_fetch_failed=获取市场价格失败
@@ -151,6 +151,10 @@ error.copy_trading_already_exists=該跟單關係已存在
error.copy_trading_disabled=跟單關係已禁用
error.copy_trading_enabled=跟單關係已啟用
error.no_enabled_copy_tradings=沒有啟用的跟單關係
error.leader_pool_not_found=Leader 池項不存在
error.leader_pool_already_exists=Leader 已在池子中
error.leader_pool_duplicate_trial_config=該帳戶已存在此 Leader 的跟單配置
error.leader_pool_confirm_required=立即啟用試跟配置需要明確確認
# 訂單相關
error.order_create_failed=創建訂單失敗
@@ -247,6 +251,9 @@ error.server.copy_trading_update_failed=更新跟單失敗
error.server.copy_trading_delete_failed=刪除跟單失敗
error.server.copy_trading_list_fetch_failed=查詢跟單列表失敗
error.server.copy_trading_templates_fetch_failed=查詢錢包綁定的模板失敗
error.server.leader_pool_list_fetch_failed=查詢 Leader 池失敗
error.server.leader_pool_save_failed=保存 Leader 池失敗
error.server.leader_pool_create_trial_failed=創建 Leader 池試跟配置失敗
# 市場服務錯誤
error.server.market_price_fetch_failed=獲取市場價格失敗
@@ -0,0 +1,169 @@
package com.wrbug.polymarketbot.controller.copytrading.configs
import com.google.gson.Gson
import com.wrbug.polymarketbot.dto.ApplyConservativeConfigRequest
import com.wrbug.polymarketbot.dto.CopyTradingDto
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.repository.CopyTradingRepository
import com.wrbug.polymarketbot.repository.CopyTradingTemplateRepository
import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.service.copytrading.configs.CopyTradingService
import com.wrbug.polymarketbot.service.copytrading.configs.FilteredOrderService
import com.wrbug.polymarketbot.service.copytrading.monitor.CopyTradingMonitorService
import com.wrbug.polymarketbot.util.JsonUtils
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.context.support.StaticMessageSource
class CopyTradingControllerTest {
@Test
fun `apply conservative config returns success response`() {
val service = StubCopyTradingService(Result.success(sampleCopyTradingDto()))
val controller = controller(service)
val response = controller.applyConservativeConfig(
ApplyConservativeConfigRequest(
copyTradingId = 7,
confirm = true,
maxDailyOrders = 20
)
)
assertEquals(0, response.body!!.code)
assertEquals(7, response.body!!.data!!.id)
assertEquals(1, service.callCount)
assertEquals(true, service.lastRequest!!.confirm)
assertEquals(20, service.lastRequest!!.maxDailyOrders)
}
@Test
fun `apply conservative config rejects invalid id before service call`() {
val service = StubCopyTradingService(Result.success(sampleCopyTradingDto()))
val controller = controller(service)
val response = controller.applyConservativeConfig(
ApplyConservativeConfigRequest(copyTradingId = 0, confirm = true)
)
assertEquals(ErrorCode.PARAM_COPY_TRADING_ID_INVALID.code, response.body!!.code)
assertEquals(0, service.callCount)
}
@Test
fun `apply conservative config maps missing copy trading to not found`() {
val service = StubCopyTradingService(Result.failure(IllegalArgumentException("跟单配置不存在")))
val controller = controller(service)
val response = controller.applyConservativeConfig(
ApplyConservativeConfigRequest(copyTradingId = 7, confirm = true)
)
assertEquals(ErrorCode.COPY_TRADING_NOT_FOUND.code, response.body!!.code)
assertEquals("跟单配置不存在", response.body!!.msg)
}
@Test
fun `apply conservative config maps missing confirmation to business error`() {
val service = StubCopyTradingService(Result.failure(IllegalStateException("应用保守配置需要显式确认")))
val controller = controller(service)
val response = controller.applyConservativeConfig(
ApplyConservativeConfigRequest(copyTradingId = 7, confirm = false)
)
assertEquals(ErrorCode.BUSINESS_ERROR.code, response.body!!.code)
assertEquals("应用保守配置需要显式确认", response.body!!.msg)
}
@Test
fun `apply conservative config maps validation failure to parameter error`() {
val service = StubCopyTradingService(Result.failure(IllegalArgumentException("maxDailyOrders 必须在 1 到 20 之间")))
val controller = controller(service)
val response = controller.applyConservativeConfig(
ApplyConservativeConfigRequest(copyTradingId = 7, confirm = true, maxDailyOrders = 0)
)
assertEquals(ErrorCode.PARAM_ERROR.code, response.body!!.code)
assertEquals("maxDailyOrders 必须在 1 到 20 之间", response.body!!.msg)
}
@Test
fun `apply conservative config maps unexpected service failure to update server error`() {
val service = StubCopyTradingService(Result.failure(RuntimeException("外部数据不可用")))
val controller = controller(service)
val response = controller.applyConservativeConfig(
ApplyConservativeConfigRequest(copyTradingId = 7, confirm = true)
)
assertEquals(ErrorCode.SERVER_COPY_TRADING_UPDATE_FAILED.code, response.body!!.code)
assertEquals("外部数据不可用", response.body!!.msg)
}
private fun controller(copyTradingService: CopyTradingService) = CopyTradingController(
copyTradingService = copyTradingService,
filteredOrderService = mock(),
messageSource = StaticMessageSource()
)
private class StubCopyTradingService(
private val nextResult: Result<CopyTradingDto>
) : CopyTradingService(
copyTradingRepository = mock(),
accountRepository = mock(),
templateRepository = mock(),
leaderRepository = mock(),
monitorService = mock(),
jsonUtils = mock(),
gson = Gson()
) {
var callCount = 0
var lastRequest: ApplyConservativeConfigRequest? = null
override fun applyConservativeConfig(request: ApplyConservativeConfigRequest): Result<CopyTradingDto> {
callCount++
lastRequest = request
return nextResult
}
}
companion object {
private inline fun <reified T> mock(): T = Mockito.mock(T::class.java)
private fun sampleCopyTradingDto() = CopyTradingDto(
id = 7,
accountId = 1,
accountName = "账户 A",
walletAddress = "0xaccount",
leaderId = 2,
leaderName = "Leader A",
leaderAddress = "0xleader",
enabled = true,
copyMode = "FIXED",
copyRatio = "1",
fixedAmount = "10",
maxOrderSize = "10",
minOrderSize = "1",
maxDailyLoss = "10",
maxDailyOrders = 20,
priceTolerance = "3",
delaySeconds = 0,
pollIntervalSeconds = 5,
useWebSocket = true,
websocketReconnectInterval = 5000,
websocketMaxRetries = 10,
supportSell = true,
minOrderDepth = "100",
maxSpread = "0.03",
minPrice = "0.10",
maxPrice = "0.80",
maxPositionValue = "10",
createdAt = 1,
updatedAt = 2
)
}
}
@@ -0,0 +1,201 @@
package com.wrbug.polymarketbot.controller.copytrading.leaderpool
import com.wrbug.polymarketbot.dto.CopyTradingDto
import com.wrbug.polymarketbot.dto.LeaderPoolAddRequest
import com.wrbug.polymarketbot.dto.LeaderPoolCreateTrialConfigRequest
import com.wrbug.polymarketbot.dto.LeaderPoolItemDto
import com.wrbug.polymarketbot.dto.LeaderPoolListRequest
import com.wrbug.polymarketbot.dto.LeaderPoolListResponse
import com.wrbug.polymarketbot.dto.LeaderPoolRemoveRequest
import com.wrbug.polymarketbot.dto.LeaderPoolSummaryDto
import com.wrbug.polymarketbot.dto.LeaderPoolUpdatePlanRequest
import com.wrbug.polymarketbot.dto.LeaderPoolUpdateStatusRequest
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.copytrading.leaderpool.LeaderPoolAlreadyExistsException
import com.wrbug.polymarketbot.service.copytrading.leaderpool.LeaderPoolConfirmRequiredException
import com.wrbug.polymarketbot.service.copytrading.leaderpool.LeaderPoolDuplicateTrialConfigException
import com.wrbug.polymarketbot.service.copytrading.leaderpool.LeaderPoolNotFoundException
import com.wrbug.polymarketbot.service.copytrading.leaderpool.LeaderPoolService
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.context.support.StaticMessageSource
class LeaderPoolControllerTest {
@Test
fun `list returns pool response`() {
val service = StubLeaderPoolService(listResult = Result.success(sampleListResponse()))
val controller = controller(service)
val response = controller.list(LeaderPoolListRequest())
assertEquals(0, response.body!!.code)
assertEquals(1, response.body!!.data!!.total)
}
@Test
fun `add maps duplicate to leader pool already exists code`() {
val service = StubLeaderPoolService(addResult = Result.failure(LeaderPoolAlreadyExistsException()))
val controller = controller(service)
val response = controller.add(LeaderPoolAddRequest(leaderId = 1))
assertEquals(ErrorCode.LEADER_POOL_ALREADY_EXISTS.code, response.body!!.code)
}
@Test
fun `update status maps missing pool to not found code`() {
val service = StubLeaderPoolService(itemResult = Result.failure(LeaderPoolNotFoundException()))
val controller = controller(service)
val response = controller.updateStatus(LeaderPoolUpdateStatusRequest(poolId = 1, status = "WATCH"))
assertEquals(ErrorCode.LEADER_POOL_NOT_FOUND.code, response.body!!.code)
}
@Test
fun `update plan maps validation failure to param error`() {
val service = StubLeaderPoolService(itemResult = Result.failure(IllegalArgumentException("suggestedFixedAmount 必须大于 0")))
val controller = controller(service)
val response = controller.updatePlan(LeaderPoolUpdatePlanRequest(poolId = 1, suggestedFixedAmount = "-1"))
assertEquals(ErrorCode.PARAM_ERROR.code, response.body!!.code)
}
@Test
fun `create trial maps duplicate config and confirm errors`() {
val duplicateController = controller(
StubLeaderPoolService(trialResult = Result.failure(LeaderPoolDuplicateTrialConfigException()))
)
val duplicate = duplicateController.createTrialConfig(LeaderPoolCreateTrialConfigRequest(poolId = 1, accountId = 2))
assertEquals(ErrorCode.LEADER_POOL_DUPLICATE_TRIAL_CONFIG.code, duplicate.body!!.code)
val confirmController = controller(
StubLeaderPoolService(trialResult = Result.failure(LeaderPoolConfirmRequiredException()))
)
val confirm = confirmController.createTrialConfig(
LeaderPoolCreateTrialConfigRequest(poolId = 1, accountId = 2, enableImmediately = true, confirm = false)
)
assertEquals(ErrorCode.LEADER_POOL_CONFIRM_REQUIRED.code, confirm.body!!.code)
}
@Test
fun `remove returns success response`() {
val service = StubLeaderPoolService(removeResult = Result.success(Unit))
val controller = controller(service)
val response = controller.remove(LeaderPoolRemoveRequest(poolId = 1))
assertEquals(0, response.body!!.code)
}
private fun controller(service: LeaderPoolService) = LeaderPoolController(
leaderPoolService = service,
messageSource = StaticMessageSource()
)
private class StubLeaderPoolService(
private val listResult: Result<LeaderPoolListResponse> = Result.success(sampleListResponse()),
private val addResult: Result<LeaderPoolItemDto> = Result.success(sampleItem()),
private val itemResult: Result<LeaderPoolItemDto> = Result.success(sampleItem()),
private val trialResult: Result<CopyTradingDto> = Result.success(sampleCopyTradingDto()),
private val removeResult: Result<Unit> = Result.success(Unit)
) : LeaderPoolService(
leaderPoolRepository = mock(),
leaderRepository = mock(),
copyTradingRepository = mock(),
accountRepository = mock(),
copyTradingService = mock()
) {
override fun getPoolList(request: LeaderPoolListRequest): Result<LeaderPoolListResponse> = listResult
override fun addToPool(request: LeaderPoolAddRequest): Result<LeaderPoolItemDto> = addResult
override fun updateStatus(request: LeaderPoolUpdateStatusRequest): Result<LeaderPoolItemDto> = itemResult
override fun updatePlan(request: LeaderPoolUpdatePlanRequest): Result<LeaderPoolItemDto> = itemResult
override fun createTrialConfig(request: LeaderPoolCreateTrialConfigRequest): Result<CopyTradingDto> = trialResult
override fun remove(request: LeaderPoolRemoveRequest): Result<Unit> = removeResult
}
companion object {
private inline fun <reified T> mock(): T = Mockito.mock(T::class.java)
private fun sampleListResponse() = LeaderPoolListResponse(
summary = LeaderPoolSummaryDto(
totalCount = 1,
trialCount = 0,
estimatedWorstExposure = "0",
pendingRiskCount = 0
),
list = listOf(sampleItem()),
total = 1
)
private fun sampleItem() = LeaderPoolItemDto(
id = 1,
leaderId = 2,
leaderName = "Leader",
leaderAddress = "0xleader",
category = null,
profileUrl = "https://polymarket.com/profile/0xleader",
status = "CANDIDATE",
source = "MANUAL",
sourceRank = null,
score = null,
reason = null,
notes = null,
suggestedFixedAmount = "1",
suggestedMaxDailyOrders = 10,
suggestedMaxDailyLoss = "5",
suggestedMinPrice = "0.1",
suggestedMaxPrice = "0.8",
suggestedMaxPositionValue = "5",
copyTradingCount = 0,
hasEnabledCopyTrading = false,
estimatedWorstExposure = "0",
lastReviewedAt = null,
lastPromotedAt = null,
cooldownUntil = null,
locked = false,
createdAt = 1,
updatedAt = 1
)
private fun sampleCopyTradingDto() = CopyTradingDto(
id = 3,
accountId = 2,
accountName = "Account",
walletAddress = "0xaccount",
leaderId = 1,
leaderName = "Leader",
leaderAddress = "0xleader",
enabled = false,
copyMode = "FIXED",
copyRatio = "1",
fixedAmount = "1",
maxOrderSize = "1",
minOrderSize = "1",
maxDailyLoss = "5",
maxDailyOrders = 10,
priceTolerance = "1",
delaySeconds = 0,
pollIntervalSeconds = 5,
useWebSocket = true,
websocketReconnectInterval = 5000,
websocketMaxRetries = 10,
supportSell = true,
minOrderDepth = null,
maxSpread = null,
minPrice = "0.1",
maxPrice = "0.8",
maxPositionValue = "5",
createdAt = 1,
updatedAt = 1
)
}
}
@@ -0,0 +1,116 @@
package com.wrbug.polymarketbot.service.copytrading.configs
import com.wrbug.polymarketbot.dto.ApplyConservativeConfigRequest
import com.wrbug.polymarketbot.entity.CopyTrading
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Test
import java.math.BigDecimal
class CopyTradingSafetyConfigServiceTest {
@Test
fun `requires explicit confirmation before applying conservative config`() {
val error = assertThrows(IllegalStateException::class.java) {
CopyTradingSafetyConfigService.applyConservativeConfig(
current = riskyCopyTrading(),
request = ApplyConservativeConfigRequest(copyTradingId = 1, confirm = false)
)
}
assertEquals("应用保守配置需要显式确认", error.message)
}
@Test
fun `applies only whitelisted risk fields`() {
val updated = CopyTradingSafetyConfigService.applyConservativeConfig(
current = riskyCopyTrading(),
request = ApplyConservativeConfigRequest(
copyTradingId = 1,
confirm = true,
maxDailyOrders = 20,
maxDailyLoss = "10",
minPrice = "0.10",
maxPrice = "0.80",
maxPositionValue = "10",
minOrderDepth = "100",
maxSpread = "0.03",
priceTolerance = "3"
)
)
assertEquals(20, updated.maxDailyOrders)
assertEquals("10", updated.maxDailyLoss.strip())
assertEquals(0, BigDecimal("0.10").compareTo(updated.minPrice))
assertEquals(0, BigDecimal("0.80").compareTo(updated.maxPrice))
assertEquals("10", updated.maxPositionValue?.strip())
assertEquals("100", updated.minOrderDepth?.strip())
assertEquals("0.03", updated.maxSpread?.strip())
assertEquals("3", updated.priceTolerance.strip())
assertEquals(true, updated.enabled)
assertEquals(1L, updated.leaderId)
assertEquals("FIXED", updated.copyMode)
}
@Test
fun `rejects unsafe values before saving`() {
val error = assertThrows(IllegalArgumentException::class.java) {
CopyTradingSafetyConfigService.applyConservativeConfig(
current = riskyCopyTrading(),
request = ApplyConservativeConfigRequest(
copyTradingId = 1,
confirm = true,
maxDailyOrders = 0
)
)
}
assertEquals("maxDailyOrders 必须在 1 到 20 之间", error.message)
}
@Test
fun `rejects values outside conservative guardrails`() {
val error = assertThrows(IllegalArgumentException::class.java) {
CopyTradingSafetyConfigService.applyConservativeConfig(
current = riskyCopyTrading(),
request = ApplyConservativeConfigRequest(
copyTradingId = 1,
confirm = true,
maxDailyLoss = "100"
)
)
}
assertEquals("maxDailyLoss 必须大于 0 且不超过 10", error.message)
}
@Test
fun `rejects invalid decimal strings before saving`() {
val error = assertThrows(IllegalArgumentException::class.java) {
CopyTradingSafetyConfigService.applyConservativeConfig(
current = riskyCopyTrading(),
request = ApplyConservativeConfigRequest(
copyTradingId = 1,
confirm = true,
maxSpread = "not-a-number"
)
)
}
assertEquals("maxSpread 必须是有效数字", error.message)
}
private fun riskyCopyTrading() = CopyTrading(
id = 1,
accountId = 1,
leaderId = 1,
enabled = true,
copyMode = "FIXED",
fixedAmount = BigDecimal.ONE,
maxDailyLoss = BigDecimal("10000"),
maxDailyOrders = 100,
priceTolerance = BigDecimal("5")
)
private fun BigDecimal.strip(): String = stripTrailingZeros().toPlainString()
}
@@ -0,0 +1,351 @@
package com.wrbug.polymarketbot.service.copytrading.leaderpool
import com.wrbug.polymarketbot.dto.CopyTradingDto
import com.wrbug.polymarketbot.dto.CopyTradingCreateRequest
import com.wrbug.polymarketbot.dto.LeaderPoolAddRequest
import com.wrbug.polymarketbot.dto.LeaderPoolCreateTrialConfigRequest
import com.wrbug.polymarketbot.dto.LeaderPoolListRequest
import com.wrbug.polymarketbot.dto.LeaderPoolUpdatePlanRequest
import com.wrbug.polymarketbot.dto.LeaderPoolUpdateStatusRequest
import com.wrbug.polymarketbot.entity.Account
import com.wrbug.polymarketbot.entity.CopyTrading
import com.wrbug.polymarketbot.entity.Leader
import com.wrbug.polymarketbot.entity.LeaderPool
import com.wrbug.polymarketbot.enums.LeaderPoolStatus
import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.repository.CopyTradingRepository
import com.wrbug.polymarketbot.repository.LeaderPoolRepository
import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.service.copytrading.configs.CopyTradingService
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.Mockito
import org.springframework.dao.DataIntegrityViolationException
import java.math.BigDecimal
import java.util.Optional
class LeaderPoolServiceTest {
private val leaderPoolRepository: LeaderPoolRepository = mock()
private val leaderRepository: LeaderRepository = mock()
private val copyTradingRepository: CopyTradingRepository = mock()
private val accountRepository: AccountRepository = mock()
private val copyTradingService: CopyTradingService = mock()
private val service = LeaderPoolService(
leaderPoolRepository = leaderPoolRepository,
leaderRepository = leaderRepository,
copyTradingRepository = copyTradingRepository,
accountRepository = accountRepository,
copyTradingService = copyTradingService
)
@Test
fun `adds existing leader to pool as candidate`() {
Mockito.`when`(leaderRepository.findById(1L)).thenReturn(Optional.of(leader()))
Mockito.`when`(leaderPoolRepository.findByLeaderId(1L)).thenReturn(null)
Mockito.`when`(leaderPoolRepository.saveAndFlush(anyLeaderPool())).thenAnswer {
(it.arguments[0] as LeaderPool).copy(id = 10)
}
val result = service.addToPool(LeaderPoolAddRequest(leaderId = 1))
assertTrue(result.isSuccess)
assertEquals("CANDIDATE", result.getOrThrow().status)
Mockito.verify(leaderPoolRepository).saveAndFlush(anyLeaderPool())
}
@Test
fun `duplicate add does not create another pool item`() {
Mockito.`when`(leaderRepository.findById(1L)).thenReturn(Optional.of(leader()))
Mockito.`when`(leaderPoolRepository.findByLeaderId(1L)).thenReturn(pool())
val result = service.addToPool(LeaderPoolAddRequest(leaderId = 1))
assertTrue(result.isFailure)
assertTrue(result.exceptionOrNull() is LeaderPoolAlreadyExistsException)
Mockito.verify(leaderPoolRepository, Mockito.never()).saveAndFlush(anyLeaderPool())
}
@Test
fun `missing leader returns error`() {
Mockito.`when`(leaderRepository.findById(404L)).thenReturn(Optional.empty())
val result = service.addToPool(LeaderPoolAddRequest(leaderId = 404))
assertTrue(result.isFailure)
assertEquals("Leader 不存在", result.exceptionOrNull()?.message)
}
@Test
fun `unique constraint conflict is mapped to already exists`() {
Mockito.`when`(leaderRepository.findById(1L)).thenReturn(Optional.of(leader()))
Mockito.`when`(leaderPoolRepository.findByLeaderId(1L)).thenReturn(null)
Mockito.`when`(leaderPoolRepository.saveAndFlush(anyLeaderPool()))
.thenThrow(DataIntegrityViolationException("duplicate"))
val result = service.addToPool(LeaderPoolAddRequest(leaderId = 1))
assertTrue(result.isFailure)
assertTrue(result.exceptionOrNull() is LeaderPoolAlreadyExistsException)
}
@Test
fun `updates status and saves cooldown without deleting leader`() {
Mockito.`when`(leaderPoolRepository.findById(10L)).thenReturn(Optional.of(pool()))
Mockito.`when`(leaderRepository.findById(1L)).thenReturn(Optional.of(leader()))
Mockito.`when`(leaderPoolRepository.save(anyLeaderPool())).thenAnswer { it.arguments[0] }
Mockito.`when`(copyTradingRepository.findByLeaderId(1L)).thenReturn(emptyList())
val result = service.updateStatus(
LeaderPoolUpdateStatusRequest(
poolId = 10,
status = "COOLDOWN",
cooldownUntil = 123456L
)
)
assertTrue(result.isSuccess)
assertEquals("COOLDOWN", result.getOrThrow().status)
assertEquals(123456L, result.getOrThrow().cooldownUntil)
Mockito.verify(leaderRepository, Mockito.never()).delete(anyLeader())
}
@Test
fun `update plan does not modify existing copy trading`() {
Mockito.`when`(leaderPoolRepository.findById(10L)).thenReturn(Optional.of(pool()))
Mockito.`when`(leaderRepository.findById(1L)).thenReturn(Optional.of(leader()))
Mockito.`when`(leaderPoolRepository.save(anyLeaderPool())).thenAnswer { it.arguments[0] }
Mockito.`when`(copyTradingRepository.findByLeaderId(1L)).thenReturn(listOf(copyTrading()))
val result = service.updatePlan(
LeaderPoolUpdatePlanRequest(
poolId = 10,
suggestedFixedAmount = "2",
suggestedMaxDailyOrders = 8,
suggestedMaxDailyLoss = "4",
suggestedMinPrice = "0.2",
suggestedMaxPrice = "0.7",
suggestedMaxPositionValue = "6"
)
)
assertTrue(result.isSuccess)
assertEquals("2", result.getOrThrow().suggestedFixedAmount)
Mockito.verify(copyTradingRepository, Mockito.never()).save(anyCopyTrading())
}
@Test
fun `invalid suggested plan is rejected without saving`() {
Mockito.`when`(leaderPoolRepository.findById(10L)).thenReturn(Optional.of(pool()))
Mockito.`when`(leaderRepository.findById(1L)).thenReturn(Optional.of(leader()))
val result = service.updatePlan(
LeaderPoolUpdatePlanRequest(
poolId = 10,
suggestedFixedAmount = "-1"
)
)
assertTrue(result.isFailure)
assertEquals("suggestedFixedAmount 必须大于 0", result.exceptionOrNull()?.message)
Mockito.verify(leaderPoolRepository, Mockito.never()).save(anyLeaderPool())
}
@Test
fun `creates disabled conservative trial config and promotes pool after success`() {
Mockito.`when`(leaderPoolRepository.findById(10L)).thenReturn(Optional.of(pool()))
Mockito.`when`(accountRepository.findById(2L)).thenReturn(Optional.of(account()))
Mockito.`when`(leaderRepository.findById(1L)).thenReturn(Optional.of(leader()))
Mockito.`when`(copyTradingRepository.findByAccountIdAndLeaderId(2L, 1L)).thenReturn(emptyList())
Mockito.`when`(copyTradingService.createCopyTrading(anyCreateRequest())).thenReturn(Result.success(copyTradingDto()))
Mockito.`when`(leaderPoolRepository.save(anyLeaderPool())).thenAnswer { it.arguments[0] }
val result = service.createTrialConfig(LeaderPoolCreateTrialConfigRequest(poolId = 10, accountId = 2))
assertTrue(result.isSuccess)
val requestCaptor = ArgumentCaptor.forClass(com.wrbug.polymarketbot.dto.CopyTradingCreateRequest::class.java)
Mockito.verify(copyTradingService).createCopyTrading(captureCreateRequest(requestCaptor))
assertEquals(false, requestCaptor.value.enabled)
assertEquals("FIXED", requestCaptor.value.copyMode)
assertEquals("1", requestCaptor.value.fixedAmount)
assertEquals(10, requestCaptor.value.maxDailyOrders)
assertEquals("5", requestCaptor.value.maxDailyLoss)
assertEquals("0.1", requestCaptor.value.minPrice)
assertEquals("0.8", requestCaptor.value.maxPrice)
assertEquals("5", requestCaptor.value.maxPositionValue)
val poolCaptor = ArgumentCaptor.forClass(LeaderPool::class.java)
Mockito.verify(leaderPoolRepository).save(captureLeaderPool(poolCaptor))
assertEquals(LeaderPoolStatus.TRIAL, poolCaptor.value.status)
}
@Test
fun `create trial failure leaves pool status unchanged`() {
Mockito.`when`(leaderPoolRepository.findById(10L)).thenReturn(Optional.of(pool()))
Mockito.`when`(accountRepository.findById(2L)).thenReturn(Optional.of(account()))
Mockito.`when`(leaderRepository.findById(1L)).thenReturn(Optional.of(leader()))
Mockito.`when`(copyTradingRepository.findByAccountIdAndLeaderId(2L, 1L)).thenReturn(emptyList())
Mockito.`when`(copyTradingService.createCopyTrading(anyCreateRequest()))
.thenReturn(Result.failure(RuntimeException("create failed")))
val result = service.createTrialConfig(LeaderPoolCreateTrialConfigRequest(poolId = 10, accountId = 2))
assertTrue(result.isFailure)
Mockito.verify(leaderPoolRepository, Mockito.never()).save(anyLeaderPool())
}
@Test
fun `existing account leader config rejects duplicate trial creation`() {
Mockito.`when`(leaderPoolRepository.findById(10L)).thenReturn(Optional.of(pool()))
Mockito.`when`(accountRepository.findById(2L)).thenReturn(Optional.of(account()))
Mockito.`when`(leaderRepository.findById(1L)).thenReturn(Optional.of(leader()))
Mockito.`when`(copyTradingRepository.findByAccountIdAndLeaderId(2L, 1L)).thenReturn(listOf(copyTrading()))
val result = service.createTrialConfig(LeaderPoolCreateTrialConfigRequest(poolId = 10, accountId = 2))
assertTrue(result.isFailure)
assertTrue(result.exceptionOrNull() is LeaderPoolDuplicateTrialConfigException)
Mockito.verify(copyTradingService, Mockito.never()).createCopyTrading(anyCreateRequest())
}
@Test
fun `immediate enable without confirmation rejects creation`() {
Mockito.`when`(leaderPoolRepository.findById(10L)).thenReturn(Optional.of(pool()))
val result = service.createTrialConfig(
LeaderPoolCreateTrialConfigRequest(
poolId = 10,
accountId = 2,
enableImmediately = true,
confirm = false
)
)
assertTrue(result.isFailure)
assertTrue(result.exceptionOrNull() is LeaderPoolConfirmRequiredException)
Mockito.verify(copyTradingService, Mockito.never()).createCopyTrading(anyCreateRequest())
}
@Test
fun `pool list uses bulk leader and copy trading queries`() {
val pools = listOf(pool(id = 10, leaderId = 1), pool(id = 11, leaderId = 2, status = LeaderPoolStatus.TRIAL))
Mockito.`when`(leaderPoolRepository.findAllByOrderByCreatedAtDesc()).thenReturn(pools)
Mockito.`when`(leaderRepository.findAllById(listOf(1L, 2L))).thenReturn(listOf(leader(1), leader(2)))
Mockito.`when`(copyTradingRepository.findByLeaderIdIn(listOf(1L, 2L))).thenReturn(listOf(copyTrading(leaderId = 2)))
val result = service.getPoolList(LeaderPoolListRequest())
assertTrue(result.isSuccess)
assertEquals(2, result.getOrThrow().total)
assertEquals("5", result.getOrThrow().summary.estimatedWorstExposure)
Mockito.verify(leaderRepository).findAllById(listOf(1L, 2L))
Mockito.verify(copyTradingRepository).findByLeaderIdIn(listOf(1L, 2L))
Mockito.verify(copyTradingRepository, Mockito.never()).findByLeaderId(1L)
Mockito.verify(copyTradingRepository, Mockito.never()).findByLeaderId(2L)
}
private fun leader(id: Long = 1) = Leader(
id = id,
leaderAddress = "0x${id.toString().padStart(40, '0')}",
leaderName = "Leader $id"
)
private fun account() = Account(
id = 2,
privateKey = "encrypted",
walletAddress = "0xaccount",
proxyAddress = "0xproxy"
)
private fun pool(
id: Long = 10,
leaderId: Long = 1,
status: LeaderPoolStatus = LeaderPoolStatus.CANDIDATE
) = LeaderPool(
id = id,
leaderId = leaderId,
status = status,
suggestedFixedAmount = BigDecimal("1"),
suggestedMaxDailyOrders = 10,
suggestedMaxDailyLoss = BigDecimal("5"),
suggestedMinPrice = BigDecimal("0.1"),
suggestedMaxPrice = BigDecimal("0.8"),
suggestedMaxPositionValue = BigDecimal("5")
)
private fun copyTrading(leaderId: Long = 1) = CopyTrading(
id = 3,
accountId = 2,
leaderId = leaderId,
enabled = true,
copyMode = "FIXED",
fixedAmount = BigDecimal.ONE
)
private fun copyTradingDto() = CopyTradingDto(
id = 3,
accountId = 2,
accountName = "Account",
walletAddress = "0xaccount",
leaderId = 1,
leaderName = "Leader 1",
leaderAddress = "0x0000000000000000000000000000000000000001",
enabled = false,
copyMode = "FIXED",
copyRatio = "1",
fixedAmount = "1",
maxOrderSize = "1",
minOrderSize = "1",
maxDailyLoss = "5",
maxDailyOrders = 10,
priceTolerance = "1",
delaySeconds = 0,
pollIntervalSeconds = 5,
useWebSocket = true,
websocketReconnectInterval = 5000,
websocketMaxRetries = 10,
supportSell = true,
minOrderDepth = null,
maxSpread = null,
minPrice = "0.1",
maxPrice = "0.8",
maxPositionValue = "5",
createdAt = 1,
updatedAt = 1
)
private fun anyLeader(): Leader {
Mockito.any(Leader::class.java)
return leader()
}
private fun anyLeaderPool(): LeaderPool {
Mockito.any(LeaderPool::class.java)
return pool()
}
private fun anyCopyTrading(): CopyTrading {
Mockito.any(CopyTrading::class.java)
return copyTrading()
}
private fun anyCreateRequest(): CopyTradingCreateRequest {
Mockito.any(CopyTradingCreateRequest::class.java)
return CopyTradingCreateRequest(accountId = 2, leaderId = 1)
}
private fun captureCreateRequest(captor: ArgumentCaptor<CopyTradingCreateRequest>): CopyTradingCreateRequest {
captor.capture()
return CopyTradingCreateRequest(accountId = 2, leaderId = 1)
}
private fun captureLeaderPool(captor: ArgumentCaptor<LeaderPool>): LeaderPool {
captor.capture()
return pool()
}
companion object {
private inline fun <reified T> mock(): T = Mockito.mock(T::class.java)
}
}
@@ -50,10 +50,14 @@ class CopyTradingPnlCalculatorTest {
assertEquals("1.50", stats.totalRealizedPnl.toPlainString())
assertEquals("-0.05", stats.totalPnl.toPlainString())
assertEquals("-0.71", stats.totalPnlPercent.toPlainString())
assertEquals(PositionQuoteStatus.AVAILABLE, stats.quoteStatusSummary.overallStatus)
assertEquals(2, stats.quoteStatusSummary.availableCount)
assertEquals(0, stats.quoteStatusSummary.noMatchCount)
assertEquals(0, stats.quoteStatusSummary.unavailableCount)
}
@Test
fun `treats tracked open positions without a quote as zero current value`() {
fun `reports no match separately from available zero valuation`() {
val buyOrders = listOf(
buyOrder(
id = 1,
@@ -78,6 +82,121 @@ class CopyTradingPnlCalculatorTest {
assertEquals("-2.00", stats.totalUnrealizedPnl.toPlainString())
assertEquals("-2.00", stats.totalPnl.toPlainString())
assertEquals("-100.00", stats.totalPnlPercent.toPlainString())
assertEquals(PositionQuoteStatus.NO_MATCH, stats.quoteStatusSummary.overallStatus)
assertEquals(0, stats.quoteStatusSummary.availableCount)
assertEquals(1, stats.quoteStatusSummary.noMatchCount)
assertEquals(0, stats.quoteStatusSummary.unavailableCount)
assertEquals("2.00", stats.zeroValuePositionCost.toPlainString())
assertEquals("0", stats.confirmedZeroValuePositionCost.toPlainString())
}
@Test
fun `reports unavailable quotes separately from confirmed zero valuation`() {
val buyOrders = listOf(
buyOrder(
id = 1,
marketId = "unavailable-market",
outcomeIndex = 0,
quantity = "8",
price = "0.25",
matchedQuantity = "0",
remainingQuantity = "8"
)
)
val stats = CopyTradingPnlCalculator.calculate(
buyOrders = buyOrders,
sellRecords = emptyList(),
matchDetails = emptyList(),
quotes = listOf(
PositionValuationQuote.unavailable(reason = "positions timeout")
)
)
assertEquals("2.00", stats.currentPositionCost.toPlainString())
assertEquals("0", stats.currentPositionValue.toPlainString())
assertEquals(PositionQuoteStatus.UNAVAILABLE, stats.quoteStatusSummary.overallStatus)
assertEquals(0, stats.quoteStatusSummary.availableCount)
assertEquals(0, stats.quoteStatusSummary.noMatchCount)
assertEquals(1, stats.quoteStatusSummary.unavailableCount)
assertEquals("2.00", stats.zeroValuePositionCost.toPlainString())
assertEquals("0", stats.confirmedZeroValuePositionCost.toPlainString())
}
@Test
fun `counts available zero price as confirmed zero valuation`() {
val buyOrders = listOf(
buyOrder(
id = 1,
marketId = "settled-market",
outcomeIndex = 0,
quantity = "8",
price = "0.25",
matchedQuantity = "0",
remainingQuantity = "8"
)
)
val stats = CopyTradingPnlCalculator.calculate(
buyOrders = buyOrders,
sellRecords = emptyList(),
matchDetails = emptyList(),
quotes = listOf(
PositionValuationQuote(marketId = "settled-market", outcomeIndex = 0, side = "0", currentPrice = BigDecimal.ZERO)
)
)
assertEquals("2.00", stats.currentPositionCost.toPlainString())
assertEquals("0", stats.currentPositionValue.toPlainString())
assertEquals(PositionQuoteStatus.AVAILABLE, stats.quoteStatusSummary.overallStatus)
assertEquals(1, stats.quoteStatusSummary.availableCount)
assertEquals(0, stats.quoteStatusSummary.noMatchCount)
assertEquals(0, stats.quoteStatusSummary.unavailableCount)
assertEquals("2.00", stats.zeroValuePositionCost.toPlainString())
assertEquals("2.00", stats.confirmedZeroValuePositionCost.toPlainString())
}
@Test
fun `summarizes mixed available no match and unavailable quote states`() {
val buyOrders = listOf(
buyOrder(
id = 1,
marketId = "settled-market",
outcomeIndex = 0,
quantity = "8",
price = "0.25",
matchedQuantity = "0",
remainingQuantity = "8"
),
buyOrder(
id = 2,
marketId = "missing-market",
outcomeIndex = 0,
quantity = "4",
price = "0.50",
matchedQuantity = "0",
remainingQuantity = "4"
)
)
val stats = CopyTradingPnlCalculator.calculate(
buyOrders = buyOrders,
sellRecords = emptyList(),
matchDetails = emptyList(),
quotes = listOf(
PositionValuationQuote(marketId = "settled-market", outcomeIndex = 0, side = "0", currentPrice = BigDecimal.ZERO),
PositionValuationQuote.unavailable(reason = "positions timeout")
)
)
assertEquals("4.00", stats.currentPositionCost.toPlainString())
assertEquals("0", stats.currentPositionValue.toPlainString())
assertEquals(PositionQuoteStatus.UNAVAILABLE, stats.quoteStatusSummary.overallStatus)
assertEquals(1, stats.quoteStatusSummary.availableCount)
assertEquals(0, stats.quoteStatusSummary.noMatchCount)
assertEquals(1, stats.quoteStatusSummary.unavailableCount)
assertEquals("4.00", stats.zeroValuePositionCost.toPlainString())
assertEquals("2.00", stats.confirmedZeroValuePositionCost.toPlainString())
}
private fun buyOrder(
@@ -0,0 +1,177 @@
package com.wrbug.polymarketbot.service.copytrading.statistics
import com.wrbug.polymarketbot.entity.CopyOrderTracking
import com.wrbug.polymarketbot.entity.CopyTrading
import com.wrbug.polymarketbot.entity.SellMatchDetail
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.math.BigDecimal
class CopyTradingRiskDiagnosisServiceTest {
@Test
fun `builds loss attribution with top losing markets and quote completeness`() {
val buyOrders = listOf(
buyOrder(id = 1, marketId = "market-a", quantity = "10", price = "0.60", remainingQuantity = "4"),
buyOrder(id = 2, marketId = "market-b", quantity = "5", price = "0.20", remainingQuantity = "5")
)
val matchDetails = listOf(
matchDetail(trackingId = 1, buyOrderId = "buy-1", sellPrice = "0", pnl = "-3.60"),
matchDetail(trackingId = 2, buyOrderId = "buy-2", sellPrice = "0.50", pnl = "1.50")
)
val pnl = CopyTradingPnlCalculator.calculate(
buyOrders = buyOrders,
sellRecords = emptyList(),
matchDetails = matchDetails,
quotes = listOf(
PositionValuationQuote(marketId = "market-a", outcomeIndex = 0, side = "0", currentPrice = BigDecimal.ZERO),
PositionValuationQuote.unavailable("positions timeout")
)
)
val diagnosis = CopyTradingRiskDiagnosisService.buildDiagnosis(
copyTrading = riskyCopyTrading(),
buyOrders = buyOrders,
sellRecordsCount = 0,
matchDetails = matchDetails,
filteredOrderCount = 0,
pnl = pnl,
generatedAt = 1234
)
assertEquals("UNAVAILABLE", diagnosis.quoteOverallStatus)
assertTrue(diagnosis.dataIncomplete)
assertEquals(2, diagnosis.totalBuyOrders)
assertEquals(2, diagnosis.sampleSize)
assertEquals("3.40", diagnosis.zeroValuePositionCost)
assertEquals("2.40", diagnosis.confirmedZeroValuePositionCost)
assertEquals("3.60", diagnosis.zeroSellLoss)
assertEquals("market-a", diagnosis.topLosingMarkets.first().marketId)
assertEquals("-3.60", diagnosis.topLosingMarkets.first().realizedPnl)
assertTrue(diagnosis.riskWarnings.any { it.field == "maxDailyOrders" && it.severity == RiskSeverity.HIGH.name })
assertEquals(1234, diagnosis.generatedAt)
}
@Test
fun `marks profitable tiny samples as low confidence`() {
val buyOrders = listOf(
buyOrder(id = 1, marketId = "market-a", quantity = "10", price = "0.40", remainingQuantity = "0"),
buyOrder(id = 2, marketId = "market-b", quantity = "10", price = "0.40", remainingQuantity = "0")
)
val matchDetails = listOf(
matchDetail(trackingId = 1, buyOrderId = "buy-1", sellPrice = "0.80", pnl = "4.00"),
matchDetail(trackingId = 2, buyOrderId = "buy-2", sellPrice = "0.60", pnl = "2.00")
)
val pnl = CopyTradingPnlCalculator.calculate(
buyOrders = buyOrders,
sellRecords = emptyList(),
matchDetails = matchDetails,
quotes = emptyList()
)
val diagnosis = CopyTradingRiskDiagnosisService.buildDiagnosis(
copyTrading = conservativeCopyTrading(),
buyOrders = buyOrders,
sellRecordsCount = 0,
matchDetails = matchDetails,
filteredOrderCount = 3,
pnl = pnl,
generatedAt = 1234
)
assertTrue(diagnosis.lowConfidence)
assertTrue(diagnosis.confidenceReason.contains("样本"))
assertEquals("6.00", diagnosis.totalPnl)
assertEquals(3, diagnosis.filteredOrderCount)
assertTrue(diagnosis.riskWarnings.all { it.severity != RiskSeverity.HIGH.name })
}
@Test
fun `returns field level conservative suggestions for unsafe config`() {
val warnings = CopyTradingRiskDiagnosisService.inspectRiskConfig(riskyCopyTrading())
assertTrue(warnings.any { it.field == "maxDailyLoss" && it.currentValue == "10000" && it.suggestedValue == "10" })
assertTrue(warnings.any { it.field == "minPrice" && it.currentValue == null && it.suggestedValue == "0.10" })
assertTrue(warnings.any { it.field == "maxPrice" && it.currentValue == null && it.suggestedValue == "0.80" })
assertTrue(warnings.any { it.field == "maxPositionValue" && it.currentValue == null && it.suggestedValue == "10" })
assertTrue(warnings.any { it.field == "minOrderDepth" })
assertTrue(warnings.any { it.field == "maxSpread" })
}
private fun riskyCopyTrading() = CopyTrading(
id = 1,
accountId = 1,
leaderId = 1,
fixedAmount = bd("1"),
maxDailyLoss = bd("10000"),
maxDailyOrders = 100,
priceTolerance = bd("5"),
minPrice = null,
maxPrice = null,
maxPositionValue = null,
minOrderDepth = null,
maxSpread = null
)
private fun conservativeCopyTrading() = CopyTrading(
id = 1,
accountId = 1,
leaderId = 1,
fixedAmount = bd("1"),
maxDailyLoss = bd("5"),
maxDailyOrders = 10,
priceTolerance = bd("2"),
minPrice = bd("0.10"),
maxPrice = bd("0.80"),
maxPositionValue = bd("5"),
minOrderDepth = bd("100"),
maxSpread = bd("0.03")
)
private fun buyOrder(
id: Long,
marketId: String,
quantity: String,
price: String,
remainingQuantity: String
) = CopyOrderTracking(
id = id,
copyTradingId = 1,
accountId = 1,
leaderId = 1,
marketId = marketId,
side = "0",
outcomeIndex = 0,
buyOrderId = "buy-$id",
leaderBuyTradeId = "leader-buy-$id",
leaderBuyQuantity = null,
quantity = bd(quantity),
price = bd(price),
matchedQuantity = bd(quantity).subtract(bd(remainingQuantity)),
remainingQuantity = bd(remainingQuantity),
status = if (bd(remainingQuantity).signum() == 0) "fully_matched" else "filled",
source = "test",
createdAt = id,
updatedAt = id
)
private fun matchDetail(
trackingId: Long,
buyOrderId: String,
sellPrice: String,
pnl: String
) = SellMatchDetail(
id = trackingId,
matchRecordId = 1,
trackingId = trackingId,
buyOrderId = buyOrderId,
matchedQuantity = bd("10"),
buyPrice = bd("0.40"),
sellPrice = bd(sellPrice),
realizedPnl = bd(pnl),
createdAt = 1
)
private fun bd(value: String) = BigDecimal(value)
}