feat: add copy trading safety and leader pool
This commit is contained in:
+37
-1
@@ -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(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+172
@@ -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)
|
||||
}
|
||||
+87
@@ -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()
|
||||
}
|
||||
+30
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新跟单状态(兼容旧接口)
|
||||
|
||||
+9
@@ -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)
|
||||
+435
@@ -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")
|
||||
}
|
||||
}
|
||||
+82
-3
@@ -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,
|
||||
|
||||
+155
@@ -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
|
||||
}
|
||||
+24
-5
@@ -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=獲取市場價格失敗
|
||||
|
||||
+169
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+201
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+116
@@ -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()
|
||||
}
|
||||
+351
@@ -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)
|
||||
}
|
||||
}
|
||||
+120
-1
@@ -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(
|
||||
|
||||
+177
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user