feat: 实现跟单筛选条件记录和展示功能

- 新增 FilteredOrder 实体和数据库表,记录被过滤的订单信息
- 在筛选失败时自动记录到数据库并发送 Telegram 通知
- 创建 FilteredOrderService 和 API 接口,支持查询被过滤订单列表
- 前端新增被过滤订单列表页面,支持按过滤类型筛选
- 修复价差计算逻辑,使用数组最大值和最小值而非第一个元素
- 优化编辑页面 UI,钱包和 Leader 显示与创建页面一致(只读)
- 添加多语言支持(中文、繁体中文、英文)
This commit is contained in:
WrBug
2025-12-05 05:18:20 +08:00
parent e68309ada0
commit 9b8150cf92
33 changed files with 3801 additions and 549 deletions
@@ -3,6 +3,7 @@ package com.wrbug.polymarketbot.controller
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.CopyTradingService
import com.wrbug.polymarketbot.service.FilteredOrderService
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
import org.springframework.http.ResponseEntity
@@ -15,13 +16,17 @@ import org.springframework.web.bind.annotation.*
@RequestMapping("/api/copy-trading")
class CopyTradingController(
private val copyTradingService: CopyTradingService,
private val filteredOrderService: FilteredOrderService,
private val messageSource: MessageSource
) {
private val logger = LoggerFactory.getLogger(CopyTradingController::class.java)
/**
* 创建跟单
* 创建跟单配置
* 支持两种方式:
* 1. 提供 templateId:从模板填充配置,可以覆盖部分字段
* 2. 不提供 templateId:手动输入所有配置参数
*/
@PostMapping("/create")
fun createCopyTrading(@RequestBody request: CopyTradingCreateRequest): ResponseEntity<ApiResponse<CopyTradingDto>> {
@@ -29,12 +34,13 @@ class CopyTradingController(
if (request.accountId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ACCOUNT_ID_INVALID, messageSource = messageSource))
}
if (request.templateId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_TEMPLATE_ID_INVALID, messageSource = messageSource))
}
if (request.leaderId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_LEADER_ID_INVALID, messageSource = messageSource))
}
// templateId 现在是可选的,如果提供则必须 > 0
if (request.templateId != null && request.templateId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_TEMPLATE_ID_INVALID, messageSource = messageSource))
}
val result = copyTradingService.createCopyTrading(request)
result.fold(
@@ -78,7 +84,37 @@ class CopyTradingController(
}
/**
* 更新跟单状态
* 更新跟单配置
*/
@PostMapping("/update")
fun updateCopyTrading(@RequestBody request: CopyTradingUpdateRequest): 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.updateCopyTrading(request)
result.fold(
onSuccess = { copyTrading ->
ResponseEntity.ok(ApiResponse.success(copyTrading))
},
onFailure = { e ->
logger.error("更新跟单配置失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, 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))
}
}
/**
* 更新跟单状态(兼容旧接口)
*/
@PostMapping("/update-status")
fun updateCopyTradingStatus(@RequestBody request: CopyTradingUpdateStatusRequest): ResponseEntity<ApiResponse<CopyTradingDto>> {
@@ -164,5 +200,23 @@ class CopyTradingController(
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_COPY_TRADING_TEMPLATES_FETCH_FAILED, e.message, messageSource))
}
}
/**
* 查询被过滤订单列表
*/
@PostMapping("/filtered-orders")
fun getFilteredOrders(@RequestBody request: FilteredOrderListRequest): ResponseEntity<ApiResponse<FilteredOrderListResponse>> {
return try {
if (request.copyTradingId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_COPY_TRADING_ID_INVALID, messageSource = messageSource))
}
val response = filteredOrderService.getFilteredOrders(request)
ResponseEntity.ok(ApiResponse.success(response))
} catch (e: Exception) {
logger.error("查询被过滤订单列表异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
}
@@ -1,13 +1,65 @@
package com.wrbug.polymarketbot.dto
import java.math.BigDecimal
/**
* 跟单创建请求
* 支持两种方式:
* 1. 提供 templateId:从模板填充配置,可以覆盖部分字段
* 2. 不提供 templateId:手动输入所有配置参数
*/
data class CopyTradingCreateRequest(
val accountId: Long,
val templateId: Long,
val leaderId: Long,
val enabled: Boolean = true
val enabled: Boolean = true,
// 可选:如果提供 templateId,则从模板填充配置(可以覆盖)
val templateId: Long? = null,
// 跟单配置参数(如果提供 templateId,这些字段可选,用于覆盖模板值)
val copyMode: String? = null, // "RATIO" 或 "FIXED"
val copyRatio: String? = null, // 仅在 copyMode="RATIO" 时生效
val fixedAmount: String? = null, // 仅在 copyMode="FIXED" 时生效
val maxOrderSize: String? = null,
val minOrderSize: String? = null,
val maxDailyLoss: String? = null,
val maxDailyOrders: Int? = null,
val priceTolerance: String? = null, // 百分比
val delaySeconds: Int? = null,
val pollIntervalSeconds: Int? = null,
val useWebSocket: Boolean? = null,
val websocketReconnectInterval: Int? = null,
val websocketMaxRetries: Int? = null,
val supportSell: Boolean? = null,
// 过滤条件
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
val minOrderbookDepth: String? = null // 最小订单簿深度(USDC金额),NULL表示不启用
)
/**
* 跟单更新请求
*/
data class CopyTradingUpdateRequest(
val copyTradingId: Long,
val enabled: Boolean? = null,
// 跟单配置参数(可选,只更新提供的字段)
val copyMode: String? = null,
val copyRatio: String? = null,
val fixedAmount: String? = null,
val maxOrderSize: String? = null,
val minOrderSize: String? = null,
val maxDailyLoss: String? = null,
val maxDailyOrders: Int? = null,
val priceTolerance: String? = null,
val delaySeconds: Int? = null,
val pollIntervalSeconds: Int? = null,
val useWebSocket: Boolean? = null,
val websocketReconnectInterval: Int? = null,
val websocketMaxRetries: Int? = null,
val supportSell: Boolean? = null,
// 过滤条件
val minOrderDepth: String? = null,
val maxSpread: String? = null,
val minOrderbookDepth: String? = null
)
/**
@@ -15,7 +67,6 @@ data class CopyTradingCreateRequest(
*/
data class CopyTradingListRequest(
val accountId: Long? = null,
val templateId: Long? = null,
val leaderId: Long? = null,
val enabled: Boolean? = null
)
@@ -50,12 +101,29 @@ data class CopyTradingDto(
val accountId: Long,
val accountName: String?,
val walletAddress: String,
val templateId: Long,
val templateName: String,
val leaderId: Long,
val leaderName: String?,
val leaderAddress: String,
val enabled: Boolean,
// 跟单配置参数
val copyMode: String,
val copyRatio: String,
val fixedAmount: String?,
val maxOrderSize: String,
val minOrderSize: String,
val maxDailyLoss: String,
val maxDailyOrders: Int,
val priceTolerance: String,
val delaySeconds: Int,
val pollIntervalSeconds: Int,
val useWebSocket: Boolean,
val websocketReconnectInterval: Int,
val websocketMaxRetries: Int,
val supportSell: Boolean,
// 过滤条件
val minOrderDepth: String?,
val maxSpread: String?,
val minOrderbookDepth: String?,
val createdAt: Long,
val updatedAt: Long
)
@@ -69,11 +137,11 @@ data class CopyTradingListResponse(
)
/**
* 钱包绑定的模板信息
* 钱包绑定的跟单配置信息(已废弃,保留用于兼容)
*/
data class AccountTemplateDto(
val templateId: Long,
val templateName: String,
val templateId: Long? = null, // 已废弃
val templateName: String? = null, // 已废弃
val copyTradingId: Long,
val leaderId: Long,
val leaderName: String?,
@@ -9,8 +9,6 @@ data class CopyTradingStatisticsResponse(
val accountName: String?,
val leaderId: Long,
val leaderName: String?,
val templateId: Long,
val templateName: String?,
val enabled: Boolean,
// 买入统计
@@ -18,7 +18,11 @@ data class TemplateCreateRequest(
val useWebSocket: Boolean? = null,
val websocketReconnectInterval: Int? = null,
val websocketMaxRetries: Int? = null,
val supportSell: Boolean? = null
val supportSell: Boolean? = null,
// 过滤条件
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
val minOrderbookDepth: String? = null // 最小订单簿深度(USDC金额),NULL表示不启用
)
/**
@@ -40,7 +44,11 @@ data class TemplateUpdateRequest(
val useWebSocket: Boolean? = null,
val websocketReconnectInterval: Int? = null,
val websocketMaxRetries: Int? = null,
val supportSell: Boolean? = null
val supportSell: Boolean? = null,
// 过滤条件
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
val minOrderbookDepth: String? = null // 最小订单簿深度(USDC金额),NULL表示不启用
)
/**
@@ -69,7 +77,11 @@ data class TemplateCopyRequest(
val useWebSocket: Boolean? = null,
val websocketReconnectInterval: Int? = null,
val websocketMaxRetries: Int? = null,
val supportSell: Boolean? = null
val supportSell: Boolean? = null,
// 过滤条件
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
val minOrderbookDepth: String? = null // 最小订单簿深度(USDC金额),NULL表示不启用
)
/**
@@ -99,7 +111,10 @@ data class TemplateDto(
val websocketReconnectInterval: Int,
val websocketMaxRetries: Int,
val supportSell: Boolean,
val useCount: Long = 0, // 使用该模板的跟单数量
// 过滤条件
val minOrderDepth: String?,
val maxSpread: String?,
val minOrderbookDepth: String?,
val createdAt: Long,
val updatedAt: Long
)
@@ -0,0 +1,49 @@
package com.wrbug.polymarketbot.dto
/**
* 被过滤订单列表请求
*/
data class FilteredOrderListRequest(
val copyTradingId: Long,
val filterType: String? = null, // 过滤类型(可选)
val page: Int? = 1,
val limit: Int? = 20,
val startTime: Long? = null, // 开始时间(毫秒时间戳,可选)
val endTime: Long? = null // 结束时间(毫秒时间戳,可选)
)
/**
* 被过滤订单信息响应
*/
data class FilteredOrderDto(
val id: Long,
val copyTradingId: Long,
val accountId: Long,
val accountName: String?,
val leaderId: Long,
val leaderName: String?,
val leaderTradeId: String,
val marketId: String,
val marketTitle: String?,
val marketSlug: String?,
val side: String, // BUY 或 SELL
val outcomeIndex: Int?,
val outcome: String?,
val price: String,
val size: String,
val calculatedQuantity: String?,
val filterReason: String,
val filterType: String,
val createdAt: Long
)
/**
* 被过滤订单列表响应
*/
data class FilteredOrderListResponse(
val list: List<FilteredOrderDto>,
val total: Long,
val page: Int,
val limit: Int
)
@@ -23,9 +23,6 @@ data class CopyOrderTracking(
@Column(name = "leader_id", nullable = false)
val leaderId: Long,
@Column(name = "template_id", nullable = false)
val templateId: Long,
@Column(name = "market_id", nullable = false, length = 100)
val marketId: String,
@@ -1,15 +1,17 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
import java.math.BigDecimal
import com.wrbug.polymarketbot.util.toSafeBigDecimal
/**
* 跟单关系实体(钱包-模板关联,多对多关系
* 跟单配置实体(独立配置,不再绑定模板
*/
@Entity
@Table(
name = "copy_trading",
uniqueConstraints = [
UniqueConstraint(columnNames = ["account_id", "template_id", "leader_id"])
UniqueConstraint(columnNames = ["account_id", "leader_id"])
]
)
data class CopyTrading(
@@ -20,15 +22,65 @@ data class CopyTrading(
@Column(name = "account_id", nullable = false)
val accountId: Long, // 钱包账户ID
@Column(name = "template_id", nullable = false)
val templateId: Long, // 模板ID
@Column(name = "leader_id", nullable = false)
val leaderId: Long, // Leader ID
@Column(name = "enabled", nullable = false)
val enabled: Boolean = true, // 是否启用
// 跟单配置参数
@Column(name = "copy_mode", nullable = false, length = 10)
val copyMode: String = "RATIO", // "RATIO" 或 "FIXED"
@Column(name = "copy_ratio", nullable = false, precision = 10, scale = 2)
val copyRatio: BigDecimal = BigDecimal.ONE, // 仅在 copyMode="RATIO" 时生效
@Column(name = "fixed_amount", precision = 20, scale = 8)
val fixedAmount: BigDecimal? = null, // 仅在 copyMode="FIXED" 时生效
@Column(name = "max_order_size", nullable = false, precision = 20, scale = 8)
val maxOrderSize: BigDecimal = "1000".toSafeBigDecimal(),
@Column(name = "min_order_size", nullable = false, precision = 20, scale = 8)
val minOrderSize: BigDecimal = "1".toSafeBigDecimal(),
@Column(name = "max_daily_loss", nullable = false, precision = 20, scale = 8)
val maxDailyLoss: BigDecimal = "10000".toSafeBigDecimal(),
@Column(name = "max_daily_orders", nullable = false)
val maxDailyOrders: Int = 100,
@Column(name = "price_tolerance", nullable = false, precision = 5, scale = 2)
val priceTolerance: BigDecimal = "5".toSafeBigDecimal(), // 百分比
@Column(name = "delay_seconds", nullable = false)
val delaySeconds: Int = 0,
@Column(name = "poll_interval_seconds", nullable = false)
val pollIntervalSeconds: Int = 5, // 轮询间隔(仅在 WebSocket 不可用时使用)
@Column(name = "use_websocket", nullable = false)
val useWebSocket: Boolean = true, // 是否优先使用 WebSocket 推送
@Column(name = "websocket_reconnect_interval", nullable = false)
val websocketReconnectInterval: Int = 5000, // WebSocket 重连间隔(毫秒)
@Column(name = "websocket_max_retries", nullable = false)
val websocketMaxRetries: Int = 10, // WebSocket 最大重试次数
@Column(name = "support_sell", nullable = false)
val supportSell: Boolean = true, // 是否支持跟单卖出
// 过滤条件字段
@Column(name = "min_order_depth", precision = 20, scale = 8)
val minOrderDepth: BigDecimal? = null, // 最小订单深度(USDC金额),NULL表示不启用
@Column(name = "max_spread", precision = 20, scale = 8)
val maxSpread: BigDecimal? = null, // 最大价差(绝对价格),NULL表示不启用
@Column(name = "min_orderbook_depth", precision = 20, scale = 8)
val minOrderbookDepth: BigDecimal? = null, // 最小订单簿深度(USDC金额),NULL表示不启用
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@@ -59,6 +59,16 @@ data class CopyTradingTemplate(
@Column(name = "support_sell", nullable = false)
val supportSell: Boolean = true, // 是否支持跟单卖出
// 过滤条件字段
@Column(name = "min_order_depth", precision = 20, scale = 8)
val minOrderDepth: BigDecimal? = null, // 最小订单深度(USDC金额),NULL表示不启用
@Column(name = "max_spread", precision = 20, scale = 8)
val maxSpread: BigDecimal? = null, // 最大价差(绝对价格),NULL表示不启用
@Column(name = "min_orderbook_depth", precision = 20, scale = 8)
val minOrderbookDepth: BigDecimal? = null, // 最小订单簿深度(USDC金额),NULL表示不启用
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@@ -0,0 +1,65 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
import java.math.BigDecimal
/**
* 被过滤订单实体
* 记录因筛选条件不满足而被过滤的订单信息
*/
@Entity
@Table(name = "filtered_order")
data class FilteredOrder(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "copy_trading_id", nullable = false)
val copyTradingId: Long,
@Column(name = "account_id", nullable = false)
val accountId: Long,
@Column(name = "leader_id", nullable = false)
val leaderId: Long,
@Column(name = "leader_trade_id", nullable = false, length = 100)
val leaderTradeId: String, // Leader 的交易ID
@Column(name = "market_id", nullable = false, length = 100)
val marketId: String,
@Column(name = "market_title", length = 500)
val marketTitle: String? = null, // 市场标题(从 API 获取)
@Column(name = "market_slug", length = 200)
val marketSlug: String? = null, // 市场 slug(用于生成链接)
@Column(name = "side", nullable = false, length = 10)
val side: String, // BUY 或 SELL
@Column(name = "outcome_index", nullable = true)
val outcomeIndex: Int? = null, // 结果索引(0, 1, 2, ...),支持多元市场
@Column(name = "outcome", length = 50)
val outcome: String? = null, // 市场方向(如 YES, NO 等)
@Column(name = "price", nullable = false, precision = 20, scale = 8)
val price: BigDecimal, // Leader 交易价格
@Column(name = "size", nullable = false, precision = 20, scale = 8)
val size: BigDecimal, // Leader 交易数量
@Column(name = "calculated_quantity", precision = 20, scale = 8)
val calculatedQuantity: BigDecimal? = null, // 计算出的跟单数量(如果已计算)
@Column(name = "filter_reason", nullable = false, columnDefinition = "TEXT")
val filterReason: String, // 过滤原因(详细说明)
@Column(name = "filter_type", nullable = false, length = 50)
val filterType: String, // 过滤类型(如 ORDER_DEPTH, SPREAD, ORDERBOOK_DEPTH 等)
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis()
)
@@ -15,27 +15,16 @@ interface CopyTradingRepository : JpaRepository<CopyTrading, Long> {
*/
fun findByAccountId(accountId: Long): List<CopyTrading>
/**
* 根据模板ID查找跟单列表
*/
fun findByTemplateId(templateId: Long): List<CopyTrading>
/**
* 根据 Leader ID 查找跟单列表
*/
fun findByLeaderId(leaderId: Long): List<CopyTrading>
/**
* 根据账户ID和模板ID查找跟单列表
* 根据账户ID和Leader ID查找跟单
*/
fun findByAccountIdAndTemplateId(accountId: Long, templateId: Long): List<CopyTrading>
/**
* 根据账户ID、模板ID和Leader ID查找跟单
*/
fun findByAccountIdAndTemplateIdAndLeaderId(
fun findByAccountIdAndLeaderId(
accountId: Long,
templateId: Long,
leaderId: Long
): CopyTrading?
@@ -54,11 +43,6 @@ interface CopyTradingRepository : JpaRepository<CopyTrading, Long> {
*/
fun findByLeaderIdAndEnabledTrue(leaderId: Long): List<CopyTrading>
/**
* 统计使用指定模板的跟单数量
*/
fun countByTemplateId(templateId: Long): Long
/**
* 统计指定 Leader 的跟单数量
*/
@@ -0,0 +1,52 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.FilteredOrder
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository
@Repository
interface FilteredOrderRepository : JpaRepository<FilteredOrder, Long> {
/**
* 根据跟单配置ID查询被过滤的订单(分页)
*/
fun findByCopyTradingIdOrderByCreatedAtDesc(
copyTradingId: Long,
pageable: Pageable
): Page<FilteredOrder>
/**
* 根据跟单配置ID和过滤类型查询被过滤的订单(分页)
*/
fun findByCopyTradingIdAndFilterTypeOrderByCreatedAtDesc(
copyTradingId: Long,
filterType: String,
pageable: Pageable
): Page<FilteredOrder>
/**
* 根据跟单配置ID和时间范围查询被过滤的订单(分页)
*/
@Query("SELECT f FROM FilteredOrder f WHERE f.copyTradingId = :copyTradingId AND f.createdAt >= :startTime AND f.createdAt <= :endTime ORDER BY f.createdAt DESC")
fun findByCopyTradingIdAndTimeRange(
@Param("copyTradingId") copyTradingId: Long,
@Param("startTime") startTime: Long,
@Param("endTime") endTime: Long,
pageable: Pageable
): Page<FilteredOrder>
/**
* 统计某个跟单配置的被过滤订单数量
*/
fun countByCopyTradingId(copyTradingId: Long): Long
/**
* 统计某个跟单配置的某个过滤类型的被过滤订单数量
*/
fun countByCopyTradingIdAndFilterType(copyTradingId: Long, filterType: String): Long
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,176 @@
package com.wrbug.polymarketbot.service
import com.wrbug.polymarketbot.api.OrderbookResponse
import com.wrbug.polymarketbot.entity.CopyTrading
import com.wrbug.polymarketbot.util.gt
import com.wrbug.polymarketbot.util.lt
import com.wrbug.polymarketbot.util.multi
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.math.BigDecimal
/**
* 跟单过滤条件检查服务
*/
@Service
class CopyTradingFilterService(
private val clobService: PolymarketClobService
) {
private val logger = LoggerFactory.getLogger(CopyTradingFilterService::class.java)
/**
* 检查过滤条件
* @param copyTrading 跟单配置
* @param tokenId token ID(用于获取订单簿)
* @param isBuyOrder 是否为买入订单(true=买入,false=卖出)
* @return Pair<是否通过, 失败原因>
*/
suspend fun checkFilters(
copyTrading: CopyTrading,
tokenId: String,
isBuyOrder: Boolean
): Pair<Boolean, String> {
// 1. 价格合理性检查(基础检查,无需配置)
// 这个检查在获取订单簿时进行,如果价格不在 0.01-0.99 范围内,订单簿获取会失败
// 2. 获取订单簿
val orderbookResult = clobService.getOrderbookByTokenId(tokenId)
if (!orderbookResult.isSuccess) {
val error = orderbookResult.exceptionOrNull()
return Pair(false, "获取订单簿失败: ${error?.message ?: "未知错误"}")
}
val orderbook = orderbookResult.getOrNull()
if (orderbook == null) {
return Pair(false, "订单簿为空")
}
// 3. 买一卖一价差过滤
val spreadCheck = checkSpread(copyTrading, orderbook)
if (!spreadCheck.first) {
return spreadCheck
}
// 4. 订单深度过滤
val depthCheck = checkOrderDepth(copyTrading, orderbook, isBuyOrder)
if (!depthCheck.first) {
return depthCheck
}
// 5. 最小订单簿深度过滤(可选)
val orderbookDepthCheck = checkOrderbookDepth(copyTrading, orderbook, isBuyOrder)
if (!orderbookDepthCheck.first) {
return orderbookDepthCheck
}
return Pair(true, "")
}
/**
* 检查买一卖一价差
* bestBid: 买盘中的最高价格(最大值)
* bestAsk: 卖盘中的最低价格(最小值)
*/
private fun checkSpread(
copyTrading: CopyTrading,
orderbook: OrderbookResponse
): Pair<Boolean, String> {
// 如果未启用价差过滤,直接通过
if (copyTrading.maxSpread == null) {
return Pair(true, "")
}
// 获取买盘中的最高价格(bestBid = bids 中的最大值)
val bestBid = orderbook.bids
.mapNotNull { it.price.toSafeBigDecimal() }
.maxOrNull()
// 获取卖盘中的最低价格(bestAsk = asks 中的最小值)
val bestAsk = orderbook.asks
.mapNotNull { it.price.toSafeBigDecimal() }
.minOrNull()
if (bestBid == null || bestAsk == null) {
return Pair(false, "订单簿缺少买一或卖一价格")
}
// 计算价差(绝对价格)
val spread = bestAsk.subtract(bestBid)
if (spread.gt(copyTrading.maxSpread)) {
return Pair(false, "价差过大: $spread > ${copyTrading.maxSpread}")
}
return Pair(true, "")
}
/**
* 检查订单深度
*/
private fun checkOrderDepth(
copyTrading: CopyTrading,
orderbook: OrderbookResponse,
isBuyOrder: Boolean
): Pair<Boolean, String> {
// 如果未启用订单深度过滤,直接通过
if (copyTrading.minOrderDepth == null) {
return Pair(true, "")
}
// 对于买入订单,检查卖盘(asks)深度
// 对于卖出订单,检查买盘(bids)深度
val orders = if (isBuyOrder) orderbook.asks else orderbook.bids
// 计算总深度(累计订单金额)
var totalDepth = BigDecimal.ZERO
for (order in orders) {
val price = order.price.toSafeBigDecimal()
val size = order.size.toSafeBigDecimal()
val orderAmount = price.multi(size)
totalDepth = totalDepth.add(orderAmount)
}
if (totalDepth.lt(copyTrading.minOrderDepth)) {
return Pair(false, "订单深度不足: $totalDepth < ${copyTrading.minOrderDepth}")
}
return Pair(true, "")
}
/**
* 检查最小订单簿深度(前 N 档深度)
*/
private fun checkOrderbookDepth(
copyTrading: CopyTrading,
orderbook: OrderbookResponse,
isBuyOrder: Boolean
): Pair<Boolean, String> {
// 如果未启用最小订单簿深度过滤,直接通过
if (copyTrading.minOrderbookDepth == null) {
return Pair(true, "")
}
// 对于买入订单,检查卖盘(asks)前 3 档深度
// 对于卖出订单,检查买盘(bids)前 3 档深度
val orders = if (isBuyOrder) orderbook.asks else orderbook.bids
val topNOrders = orders.take(3) // 前 3 档
// 计算前 N 档总深度
var totalDepth = BigDecimal.ZERO
for (order in topNOrders) {
val price = order.price.toSafeBigDecimal()
val size = order.size.toSafeBigDecimal()
val orderAmount = price.multi(size)
totalDepth = totalDepth.add(orderAmount)
}
if (totalDepth.lt(copyTrading.minOrderbookDepth)) {
return Pair(false, "订单簿深度不足: $totalDepth < ${copyTrading.minOrderbookDepth}")
}
return Pair(true, "")
}
}
@@ -6,12 +6,14 @@ 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.util.toSafeBigDecimal
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
/**
* 跟单配置管理服务(钱包-模板关联
* 跟单配置管理服务(独立配置,不再绑定模板
*/
@Service
class CopyTradingService(
@@ -25,7 +27,10 @@ class CopyTradingService(
private val logger = LoggerFactory.getLogger(CopyTradingService::class.java)
/**
* 创建跟单
* 创建跟单配置
* 支持两种方式:
* 1. 提供 templateId:从模板填充配置,可以覆盖部分字段
* 2. 不提供 templateId:手动输入所有配置参数
*/
@Transactional
fun createCopyTrading(request: CopyTradingCreateRequest): Result<CopyTradingDto> {
@@ -34,30 +39,94 @@ class CopyTradingService(
val account = accountRepository.findById(request.accountId).orElse(null)
?: return Result.failure(IllegalArgumentException("账户不存在"))
// 2. 验证模板是否存在
val template = templateRepository.findById(request.templateId).orElse(null)
?: return Result.failure(IllegalArgumentException("模板不存在"))
// 3. 验证 Leader 是否存在
// 2. 验证 Leader 是否存在
val leader = leaderRepository.findById(request.leaderId).orElse(null)
?: return Result.failure(IllegalArgumentException("Leader 不存在"))
// 4. 检查是否已存在相同的跟单关系
val existing = copyTradingRepository.findByAccountIdAndTemplateIdAndLeaderId(
// 3. 检查是否已存在相同的跟单关系accountId + leaderId
val existing = copyTradingRepository.findByAccountIdAndLeaderId(
request.accountId,
request.templateId,
request.leaderId
)
if (existing != null) {
return Result.failure(IllegalArgumentException("该跟单关系已存在"))
}
// 5. 创建跟单关系
// 4. 获取配置参数(从模板填充或手动输入)
val config = if (request.templateId != null) {
// 从模板填充
val template = templateRepository.findById(request.templateId).orElse(null)
?: return Result.failure(IllegalArgumentException("模板不存在"))
// 使用模板值,但允许请求中的字段覆盖
CopyTradingConfig(
copyMode = request.copyMode ?: template.copyMode,
copyRatio = request.copyRatio?.toSafeBigDecimal() ?: template.copyRatio,
fixedAmount = request.fixedAmount?.toSafeBigDecimal() ?: template.fixedAmount,
maxOrderSize = request.maxOrderSize?.toSafeBigDecimal() ?: template.maxOrderSize,
minOrderSize = request.minOrderSize?.toSafeBigDecimal() ?: template.minOrderSize,
maxDailyLoss = request.maxDailyLoss?.toSafeBigDecimal() ?: template.maxDailyLoss,
maxDailyOrders = request.maxDailyOrders ?: template.maxDailyOrders,
priceTolerance = request.priceTolerance?.toSafeBigDecimal() ?: template.priceTolerance,
delaySeconds = request.delaySeconds ?: template.delaySeconds,
pollIntervalSeconds = request.pollIntervalSeconds ?: template.pollIntervalSeconds,
useWebSocket = request.useWebSocket ?: template.useWebSocket,
websocketReconnectInterval = request.websocketReconnectInterval ?: template.websocketReconnectInterval,
websocketMaxRetries = request.websocketMaxRetries ?: template.websocketMaxRetries,
supportSell = request.supportSell ?: template.supportSell,
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: template.minOrderDepth,
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: template.maxSpread,
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal() ?: template.minOrderbookDepth
)
} else {
// 手动输入(所有字段必须提供)
if (request.copyMode == null) {
return Result.failure(IllegalArgumentException("copyMode 不能为空"))
}
CopyTradingConfig(
copyMode = request.copyMode,
copyRatio = request.copyRatio?.toSafeBigDecimal() ?: BigDecimal.ONE,
fixedAmount = request.fixedAmount?.toSafeBigDecimal(),
maxOrderSize = request.maxOrderSize?.toSafeBigDecimal() ?: "1000".toSafeBigDecimal(),
minOrderSize = request.minOrderSize?.toSafeBigDecimal() ?: "1".toSafeBigDecimal(),
maxDailyLoss = request.maxDailyLoss?.toSafeBigDecimal() ?: "10000".toSafeBigDecimal(),
maxDailyOrders = request.maxDailyOrders ?: 100,
priceTolerance = request.priceTolerance?.toSafeBigDecimal() ?: "5".toSafeBigDecimal(),
delaySeconds = request.delaySeconds ?: 0,
pollIntervalSeconds = request.pollIntervalSeconds ?: 5,
useWebSocket = request.useWebSocket ?: true,
websocketReconnectInterval = request.websocketReconnectInterval ?: 5000,
websocketMaxRetries = request.websocketMaxRetries ?: 10,
supportSell = request.supportSell ?: true,
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal(),
maxSpread = request.maxSpread?.toSafeBigDecimal(),
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal()
)
}
// 5. 创建跟单配置
val copyTrading = CopyTrading(
accountId = request.accountId,
templateId = request.templateId,
leaderId = request.leaderId,
enabled = request.enabled
enabled = request.enabled,
copyMode = config.copyMode,
copyRatio = config.copyRatio,
fixedAmount = config.fixedAmount,
maxOrderSize = config.maxOrderSize,
minOrderSize = config.minOrderSize,
maxDailyLoss = config.maxDailyLoss,
maxDailyOrders = config.maxDailyOrders,
priceTolerance = config.priceTolerance,
delaySeconds = config.delaySeconds,
pollIntervalSeconds = config.pollIntervalSeconds,
useWebSocket = config.useWebSocket,
websocketReconnectInterval = config.websocketReconnectInterval,
websocketMaxRetries = config.websocketMaxRetries,
supportSell = config.supportSell,
minOrderDepth = config.minOrderDepth,
maxSpread = config.maxSpread,
minOrderbookDepth = config.minOrderbookDepth
)
val saved = copyTradingRepository.save(copyTrading)
@@ -73,36 +142,99 @@ class CopyTradingService(
}
}
Result.success(toDto(saved, account, template, leader))
Result.success(toDto(saved, account, leader))
} catch (e: Exception) {
logger.error("创建跟单失败", e)
Result.failure(e)
}
}
/**
* 更新跟单配置
*/
@Transactional
fun updateCopyTrading(request: CopyTradingUpdateRequest): Result<CopyTradingDto> {
return try {
val copyTrading = copyTradingRepository.findById(request.copyTradingId).orElse(null)
?: return Result.failure(IllegalArgumentException("跟单配置不存在"))
// 更新字段(只更新提供的字段)
val updated = copyTrading.copy(
enabled = request.enabled ?: copyTrading.enabled,
copyMode = request.copyMode ?: copyTrading.copyMode,
copyRatio = request.copyRatio?.toSafeBigDecimal() ?: copyTrading.copyRatio,
fixedAmount = request.fixedAmount?.toSafeBigDecimal() ?: copyTrading.fixedAmount,
maxOrderSize = request.maxOrderSize?.toSafeBigDecimal() ?: copyTrading.maxOrderSize,
minOrderSize = request.minOrderSize?.toSafeBigDecimal() ?: copyTrading.minOrderSize,
maxDailyLoss = request.maxDailyLoss?.toSafeBigDecimal() ?: copyTrading.maxDailyLoss,
maxDailyOrders = request.maxDailyOrders ?: copyTrading.maxDailyOrders,
priceTolerance = request.priceTolerance?.toSafeBigDecimal() ?: copyTrading.priceTolerance,
delaySeconds = request.delaySeconds ?: copyTrading.delaySeconds,
pollIntervalSeconds = request.pollIntervalSeconds ?: copyTrading.pollIntervalSeconds,
useWebSocket = request.useWebSocket ?: copyTrading.useWebSocket,
websocketReconnectInterval = request.websocketReconnectInterval ?: copyTrading.websocketReconnectInterval,
websocketMaxRetries = request.websocketMaxRetries ?: copyTrading.websocketMaxRetries,
supportSell = request.supportSell ?: copyTrading.supportSell,
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: copyTrading.minOrderDepth,
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: copyTrading.maxSpread,
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal() ?: copyTrading.minOrderbookDepth,
updatedAt = System.currentTimeMillis()
)
val saved = copyTradingRepository.save(updated)
// 重新启动监听(确保状态完全同步)
kotlinx.coroutines.runBlocking {
try {
monitorService.restartMonitoring()
} 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)
}
}
/**
* 更新跟单状态(兼容旧接口)
*/
@Transactional
fun updateCopyTradingStatus(request: CopyTradingUpdateStatusRequest): Result<CopyTradingDto> {
return updateCopyTrading(
CopyTradingUpdateRequest(
copyTradingId = request.copyTradingId,
enabled = request.enabled
)
)
}
/**
* 查询跟单列表
*/
fun getCopyTradingList(request: CopyTradingListRequest): Result<CopyTradingListResponse> {
return try {
val copyTradings = when {
request.accountId != null && request.templateId != null && request.leaderId != null -> {
val found = copyTradingRepository.findByAccountIdAndTemplateIdAndLeaderId(
request.accountId != null && request.leaderId != null -> {
val found = copyTradingRepository.findByAccountIdAndLeaderId(
request.accountId,
request.templateId,
request.leaderId
)
if (found != null) listOf(found) else emptyList()
}
request.accountId != null && request.templateId != null -> {
copyTradingRepository.findByAccountIdAndTemplateId(request.accountId, request.templateId)
}
request.accountId != null -> {
copyTradingRepository.findByAccountId(request.accountId)
}
request.templateId != null -> {
copyTradingRepository.findByTemplateId(request.templateId)
}
request.leaderId != null -> {
copyTradingRepository.findByLeaderId(request.leaderId)
}
@@ -121,18 +253,17 @@ class CopyTradingService(
copyTradings
}
val dtos = filtered.map { copyTrading ->
val dtos = filtered.mapNotNull { copyTrading ->
val account = accountRepository.findById(copyTrading.accountId).orElse(null)
val template = templateRepository.findById(copyTrading.templateId).orElse(null)
val leader = leaderRepository.findById(copyTrading.leaderId).orElse(null)
if (account == null || template == null || leader == null) {
logger.warn("跟单关系数据不完整: ${copyTrading.id}")
if (account == null || leader == null) {
logger.warn("跟单配置数据不完整: ${copyTrading.id}")
null
} else {
toDto(copyTrading, account, template, leader)
toDto(copyTrading, account, leader)
}
}.filterNotNull()
}
Result.success(
CopyTradingListResponse(
@@ -146,46 +277,6 @@ class CopyTradingService(
}
}
/**
* 更新跟单状态
*/
@Transactional
fun updateCopyTradingStatus(request: CopyTradingUpdateStatusRequest): Result<CopyTradingDto> {
return try {
val copyTrading = copyTradingRepository.findById(request.copyTradingId).orElse(null)
?: return Result.failure(IllegalArgumentException("跟单关系不存在"))
val updated = copyTrading.copy(
enabled = request.enabled,
updatedAt = System.currentTimeMillis()
)
val saved = copyTradingRepository.save(updated)
// 重新启动监听(确保状态完全同步)
kotlinx.coroutines.runBlocking {
try {
monitorService.restartMonitoring()
} catch (e: Exception) {
logger.error("重新启动跟单监听失败", e)
}
}
val account = accountRepository.findById(saved.accountId).orElse(null)
val template = templateRepository.findById(saved.templateId).orElse(null)
val leader = leaderRepository.findById(saved.leaderId).orElse(null)
if (account == null || template == null || leader == null) {
return Result.failure(IllegalStateException("跟单关系数据不完整"))
}
Result.success(toDto(saved, account, template, leader))
} catch (e: Exception) {
logger.error("更新跟单状态失败", e)
Result.failure(e)
}
}
/**
* 删除跟单
*/
@@ -193,7 +284,7 @@ class CopyTradingService(
fun deleteCopyTrading(copyTradingId: Long): Result<Unit> {
return try {
val copyTrading = copyTradingRepository.findById(copyTradingId).orElse(null)
?: return Result.failure(IllegalArgumentException("跟单关系不存在"))
?: return Result.failure(IllegalArgumentException("跟单配置不存在"))
copyTradingRepository.delete(copyTrading)
@@ -214,7 +305,7 @@ class CopyTradingService(
}
/**
* 查询钱包绑定的模板
* 查询钱包绑定的跟单配置(兼容旧接口)
*/
fun getAccountTemplates(accountId: Long): Result<AccountTemplatesResponse> {
return try {
@@ -225,16 +316,15 @@ class CopyTradingService(
val copyTradings = copyTradingRepository.findByAccountId(accountId)
val dtos = copyTradings.mapNotNull { copyTrading ->
val template = templateRepository.findById(copyTrading.templateId).orElse(null)
val leader = leaderRepository.findById(copyTrading.leaderId).orElse(null)
if (template == null || leader == null) {
logger.warn("跟单关系数据不完整: ${copyTrading.id}")
if (leader == null) {
logger.warn("跟单配置数据不完整: ${copyTrading.id}")
null
} else {
AccountTemplateDto(
templateId = template.id!!,
templateName = template.templateName,
templateId = null, // 已废弃
templateName = null, // 已废弃
copyTradingId = copyTrading.id!!,
leaderId = leader.id!!,
leaderName = leader.leaderName,
@@ -251,7 +341,7 @@ class CopyTradingService(
)
)
} catch (e: Exception) {
logger.error("查询钱包绑定的模板失败", e)
logger.error("查询钱包绑定的跟单配置失败", e)
Result.failure(e)
}
}
@@ -262,7 +352,6 @@ class CopyTradingService(
private fun toDto(
copyTrading: CopyTrading,
account: com.wrbug.polymarketbot.entity.Account,
template: com.wrbug.polymarketbot.entity.CopyTradingTemplate,
leader: com.wrbug.polymarketbot.entity.Leader
): CopyTradingDto {
return CopyTradingDto(
@@ -270,15 +359,52 @@ class CopyTradingService(
accountId = account.id!!,
accountName = account.accountName,
walletAddress = account.walletAddress,
templateId = template.id!!,
templateName = template.templateName,
leaderId = leader.id!!,
leaderName = leader.leaderName,
leaderAddress = leader.leaderAddress,
enabled = copyTrading.enabled,
copyMode = copyTrading.copyMode,
copyRatio = copyTrading.copyRatio.toPlainString(),
fixedAmount = copyTrading.fixedAmount?.toPlainString(),
maxOrderSize = copyTrading.maxOrderSize.toPlainString(),
minOrderSize = copyTrading.minOrderSize.toPlainString(),
maxDailyLoss = copyTrading.maxDailyLoss.toPlainString(),
maxDailyOrders = copyTrading.maxDailyOrders,
priceTolerance = copyTrading.priceTolerance.toPlainString(),
delaySeconds = copyTrading.delaySeconds,
pollIntervalSeconds = copyTrading.pollIntervalSeconds,
useWebSocket = copyTrading.useWebSocket,
websocketReconnectInterval = copyTrading.websocketReconnectInterval,
websocketMaxRetries = copyTrading.websocketMaxRetries,
supportSell = copyTrading.supportSell,
minOrderDepth = copyTrading.minOrderDepth?.toPlainString(),
maxSpread = copyTrading.maxSpread?.toPlainString(),
minOrderbookDepth = copyTrading.minOrderbookDepth?.toPlainString(),
createdAt = copyTrading.createdAt,
updatedAt = copyTrading.updatedAt
)
}
/**
* 内部配置类(用于构建 CopyTrading 实体)
*/
private data class CopyTradingConfig(
val copyMode: String,
val copyRatio: BigDecimal,
val fixedAmount: BigDecimal?,
val maxOrderSize: BigDecimal,
val minOrderSize: BigDecimal,
val maxDailyLoss: BigDecimal,
val maxDailyOrders: Int,
val priceTolerance: BigDecimal,
val delaySeconds: Int,
val pollIntervalSeconds: Int,
val useWebSocket: Boolean,
val websocketReconnectInterval: Int,
val websocketMaxRetries: Int,
val supportSell: Boolean,
val minOrderDepth: BigDecimal?,
val maxSpread: BigDecimal?,
val minOrderbookDepth: BigDecimal?
)
}
@@ -29,7 +29,6 @@ class CopyTradingStatisticsService(
private val sellMatchDetailRepository: SellMatchDetailRepository,
private val accountRepository: AccountRepository,
private val leaderRepository: LeaderRepository,
private val templateRepository: CopyTradingTemplateRepository,
private val accountService: AccountService
) {
@@ -47,7 +46,6 @@ class CopyTradingStatisticsService(
// 2. 获取关联信息
val account = accountRepository.findById(copyTrading.accountId).orElse(null)
val leader = leaderRepository.findById(copyTrading.leaderId).orElse(null)
val template = templateRepository.findById(copyTrading.templateId).orElse(null)
// 3. 获取买入订单
val buyOrders = copyOrderTrackingRepository.findByCopyTradingId(copyTradingId)
@@ -74,8 +72,6 @@ class CopyTradingStatisticsService(
accountName = account?.accountName,
leaderId = copyTrading.leaderId,
leaderName = leader?.leaderName,
templateId = copyTrading.templateId,
templateName = template?.templateName,
enabled = copyTrading.enabled,
totalBuyQuantity = statistics.totalBuyQuantity,
totalBuyOrders = statistics.totalBuyOrders,
@@ -58,7 +58,10 @@ class CopyTradingTemplateService(
useWebSocket = request.useWebSocket ?: true,
websocketReconnectInterval = request.websocketReconnectInterval ?: 5000,
websocketMaxRetries = request.websocketMaxRetries ?: 10,
supportSell = request.supportSell ?: true
supportSell = request.supportSell ?: true,
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal(),
maxSpread = request.maxSpread?.toSafeBigDecimal(),
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal()
)
val saved = templateRepository.save(template)
@@ -113,6 +116,9 @@ class CopyTradingTemplateService(
websocketReconnectInterval = request.websocketReconnectInterval ?: template.websocketReconnectInterval,
websocketMaxRetries = request.websocketMaxRetries ?: template.websocketMaxRetries,
supportSell = request.supportSell ?: template.supportSell,
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: template.minOrderDepth,
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: template.maxSpread,
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal() ?: template.minOrderbookDepth,
updatedAt = System.currentTimeMillis()
)
@@ -134,12 +140,7 @@ class CopyTradingTemplateService(
val template = templateRepository.findById(templateId).orElse(null)
?: return Result.failure(IllegalArgumentException("模板不存在"))
// 检查是否有跟单正在使用该模板
val useCount = copyTradingRepository.countByTemplateId(templateId)
if (useCount > 0) {
return Result.failure(IllegalStateException("该模板还有 $useCount 个跟单关系在使用,请先删除跟单关系"))
}
// 模板不再绑定跟单配置,可以直接删除,无需检查使用情况
templateRepository.delete(template)
Result.success(Unit)
@@ -179,7 +180,10 @@ class CopyTradingTemplateService(
useWebSocket = request.useWebSocket ?: sourceTemplate.useWebSocket,
websocketReconnectInterval = request.websocketReconnectInterval ?: sourceTemplate.websocketReconnectInterval,
websocketMaxRetries = request.websocketMaxRetries ?: sourceTemplate.websocketMaxRetries,
supportSell = request.supportSell ?: sourceTemplate.supportSell
supportSell = request.supportSell ?: sourceTemplate.supportSell,
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: sourceTemplate.minOrderDepth,
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: sourceTemplate.maxSpread,
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal() ?: sourceTemplate.minOrderbookDepth
)
val saved = templateRepository.save(newTemplate)
@@ -198,8 +202,7 @@ class CopyTradingTemplateService(
return try {
val templates = templateRepository.findAllByOrderByCreatedAtDesc()
val templateDtos = templates.map { template ->
val useCount = copyTradingRepository.countByTemplateId(template.id!!)
toDto(template, useCount)
toDto(template)
}
Result.success(
@@ -222,8 +225,7 @@ class CopyTradingTemplateService(
val template = templateRepository.findById(templateId).orElse(null)
?: return Result.failure(IllegalArgumentException("模板不存在"))
val useCount = copyTradingRepository.countByTemplateId(templateId)
Result.success(toDto(template, useCount))
Result.success(toDto(template))
} catch (e: Exception) {
logger.error("查询模板详情失败", e)
Result.failure(e)
@@ -233,7 +235,7 @@ class CopyTradingTemplateService(
/**
* 转换为 DTO
*/
private fun toDto(template: CopyTradingTemplate, useCount: Long = 0): TemplateDto {
private fun toDto(template: CopyTradingTemplate): TemplateDto {
return TemplateDto(
id = template.id!!,
templateName = template.templateName,
@@ -251,7 +253,9 @@ class CopyTradingTemplateService(
websocketReconnectInterval = template.websocketReconnectInterval,
websocketMaxRetries = template.websocketMaxRetries,
supportSell = template.supportSell,
useCount = useCount,
minOrderDepth = template.minOrderDepth?.toPlainString(),
maxSpread = template.maxSpread?.toPlainString(),
minOrderbookDepth = template.minOrderbookDepth?.toPlainString(),
createdAt = template.createdAt,
updatedAt = template.updatedAt
)
@@ -0,0 +1,111 @@
package com.wrbug.polymarketbot.service
import com.wrbug.polymarketbot.dto.FilteredOrderDto
import com.wrbug.polymarketbot.dto.FilteredOrderListRequest
import com.wrbug.polymarketbot.dto.FilteredOrderListResponse
import com.wrbug.polymarketbot.entity.FilteredOrder
import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.repository.FilteredOrderRepository
import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import org.springframework.stereotype.Service
import java.math.BigDecimal
/**
* 被过滤订单服务
*/
@Service
class FilteredOrderService(
private val filteredOrderRepository: FilteredOrderRepository,
private val accountRepository: AccountRepository,
private val leaderRepository: LeaderRepository
) {
/**
* 查询被过滤订单列表
*/
fun getFilteredOrders(request: FilteredOrderListRequest): FilteredOrderListResponse {
val page = (request.page ?: 1).coerceAtLeast(1)
val limit = (request.limit ?: 20).coerceAtMost(100).coerceAtLeast(1)
val pageable: Pageable = PageRequest.of(page - 1, limit, Sort.by(Sort.Direction.DESC, "createdAt"))
val pageResult = if (request.startTime != null && request.endTime != null) {
// 按时间范围查询
filteredOrderRepository.findByCopyTradingIdAndTimeRange(
copyTradingId = request.copyTradingId,
startTime = request.startTime,
endTime = request.endTime,
pageable = pageable
)
} else if (request.filterType != null) {
// 按过滤类型查询
filteredOrderRepository.findByCopyTradingIdAndFilterTypeOrderByCreatedAtDesc(
copyTradingId = request.copyTradingId,
filterType = request.filterType,
pageable = pageable
)
} else {
// 查询所有
filteredOrderRepository.findByCopyTradingIdOrderByCreatedAtDesc(
copyTradingId = request.copyTradingId,
pageable = pageable
)
}
val dtos = pageResult.content.map { entity ->
convertToDto(entity)
}
return FilteredOrderListResponse(
list = dtos,
total = pageResult.totalElements,
page = page,
limit = limit
)
}
/**
* 转换为 DTO
*/
private fun convertToDto(entity: FilteredOrder): FilteredOrderDto {
val account = accountRepository.findById(entity.accountId).orElse(null)
val leader = leaderRepository.findById(entity.leaderId).orElse(null)
return FilteredOrderDto(
id = entity.id!!,
copyTradingId = entity.copyTradingId,
accountId = entity.accountId,
accountName = account?.accountName,
leaderId = entity.leaderId,
leaderName = leader?.leaderName,
leaderTradeId = entity.leaderTradeId,
marketId = entity.marketId,
marketTitle = entity.marketTitle,
marketSlug = entity.marketSlug,
side = entity.side,
outcomeIndex = entity.outcomeIndex,
outcome = entity.outcome,
price = entity.price.toString(),
size = entity.size.toString(),
calculatedQuantity = entity.calculatedQuantity?.toString(),
filterReason = entity.filterReason,
filterType = entity.filterType,
createdAt = entity.createdAt
)
}
/**
* 统计被过滤订单数量
*/
fun countFilteredOrders(copyTradingId: Long, filterType: String? = null): Long {
return if (filterType != null) {
filteredOrderRepository.countByCopyTradingIdAndFilterType(copyTradingId, filterType)
} else {
filteredOrderRepository.countByCopyTradingId(copyTradingId)
}
}
}
@@ -184,6 +184,193 @@ class TelegramNotificationService(
sendMessage(message)
}
/**
* 发送订单被过滤通知
* @param locale 语言设置可选如果提供则使用否则使用 LocaleContextHolder 获取
*/
suspend fun sendOrderFilteredNotification(
marketTitle: String,
marketId: String? = null, // 市场IDconditionId),用于生成链接
marketSlug: String? = null, // 市场slug,用于生成链接
side: String,
outcome: String? = null, // 市场方向(outcome,如 "YES", "NO" 等)
price: String,
size: String,
filterReason: String, // 过滤原因
filterType: String, // 过滤类型
accountName: String? = null,
walletAddress: String? = null,
locale: java.util.Locale? = null
) {
// 获取语言设置(优先使用传入的 locale,否则从 LocaleContextHolder 获取)
val currentLocale = locale ?: try {
LocaleContextHolder.getLocale()
} catch (e: Exception) {
logger.warn("获取语言设置失败,使用默认语言: ${e.message}", e)
java.util.Locale("zh", "CN") // 默认简体中文
}
// 计算订单金额 = price × sizeUSDC
val amount = try {
val priceDecimal = price.toSafeBigDecimal()
val sizeDecimal = size.toSafeBigDecimal()
priceDecimal.multiply(sizeDecimal).toString()
} catch (e: Exception) {
logger.warn("计算订单金额失败: ${e.message}", e)
null
}
val message = buildOrderFilteredMessage(
marketTitle = marketTitle,
marketId = marketId,
marketSlug = marketSlug,
side = side,
outcome = outcome,
price = price,
size = size,
amount = amount,
filterReason = filterReason,
filterType = filterType,
accountName = accountName,
walletAddress = walletAddress,
locale = currentLocale
)
sendMessage(message)
}
/**
* 构建订单被过滤消息
*/
private fun buildOrderFilteredMessage(
marketTitle: String,
marketId: String?,
marketSlug: String?,
side: String,
outcome: String?,
price: String,
size: String,
amount: String?,
filterReason: String,
filterType: String,
accountName: String?,
walletAddress: String?,
locale: java.util.Locale
): String {
// 获取多语言文本
val orderFiltered = messageSource.getMessage("notification.order.filtered", null, "订单被过滤", locale)
val orderInfo = messageSource.getMessage("notification.order.info", null, "订单信息", locale)
val marketLabel = messageSource.getMessage("notification.order.market", null, "市场", locale)
val sideLabel = messageSource.getMessage("notification.order.side", null, "方向", locale)
val outcomeLabel = messageSource.getMessage("notification.order.outcome", null, "市场方向", locale)
val priceLabel = messageSource.getMessage("notification.order.price", null, "价格", locale)
val quantityLabel = messageSource.getMessage("notification.order.quantity", null, "数量", locale)
val amountLabel = messageSource.getMessage("notification.order.amount", null, "金额", locale)
val accountLabel = messageSource.getMessage("notification.order.account", null, "账户", locale)
val filterReasonLabel = messageSource.getMessage("notification.order.filter_reason", null, "过滤原因", locale)
val filterTypeLabel = messageSource.getMessage("notification.order.filter_type", null, "过滤类型", locale)
val timeLabel = messageSource.getMessage("notification.order.time", null, "时间", locale)
val unknownAccount: String = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", locale) ?: "未知账户"
val calculateFailed = messageSource.getMessage("notification.order.calculate_failed", null, "计算失败", locale)
// 获取方向的多语言文本
val sideDisplay = when (side.uppercase()) {
"BUY" -> messageSource.getMessage("notification.order.side.buy", null, "买入", locale)
"SELL" -> messageSource.getMessage("notification.order.side.sell", null, "卖出", locale)
else -> side
}
// 获取过滤类型的多语言文本
val filterTypeDisplay = when (filterType.uppercase()) {
"ORDER_DEPTH" -> messageSource.getMessage("notification.filter.type.order_depth", null, "订单深度不足", locale)
"SPREAD" -> messageSource.getMessage("notification.filter.type.spread", null, "价差过大", locale)
"ORDERBOOK_DEPTH" -> messageSource.getMessage("notification.filter.type.orderbook_depth", null, "订单簿深度不足", locale)
"PRICE_VALIDITY" -> messageSource.getMessage("notification.filter.type.price_validity", null, "价格不合理", locale)
"MARKET_STATUS" -> messageSource.getMessage("notification.filter.type.market_status", null, "市场状态不可交易", locale)
else -> filterType
}
// 优先使用账户名称,如果没有账户名称才显示钱包地址
val accountInfo: String = when {
!accountName.isNullOrBlank() -> {
accountName!!
}
!walletAddress.isNullOrBlank() -> {
maskAddress(walletAddress!!)
}
else -> {
unknownAccount
}
}
val time = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(java.util.Date())
// 转义 HTML 特殊字符
val escapedMarketTitle = marketTitle.replace("<", "&lt;").replace(">", "&gt;")
val escapedAccountInfo = accountInfo.replace("<", "&lt;").replace(">", "&gt;")
val escapedFilterReason = filterReason.replace("<", "&lt;").replace(">", "&gt;")
// 格式化金额显示
val amountDisplay = if (amount != null) {
try {
// 保留最多4位小数,去除尾随零
val amountDecimal = amount.toSafeBigDecimal()
val formatted = if (amountDecimal.scale() > 4) {
amountDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros()
} else {
amountDecimal.stripTrailingZeros()
}
formatted.toPlainString()
} catch (e: Exception) {
amount
}
} else {
calculateFailed
}
// 生成市场链接
val marketLink = when {
!marketSlug.isNullOrBlank() -> {
"https://polymarket.com/event/$marketSlug"
}
!marketId.isNullOrBlank() && marketId.startsWith("0x") -> {
"https://polymarket.com/condition/$marketId"
}
else -> null
}
val marketDisplay = if (marketLink != null) {
"<a href=\"$marketLink\">$escapedMarketTitle</a>"
} else {
escapedMarketTitle
}
// 显示市场方向(outcome
val outcomeDisplay = if (!outcome.isNullOrBlank()) {
val escapedOutcome = outcome.replace("<", "&lt;").replace(">", "&gt;")
"\n$outcomeLabel: <b>$escapedOutcome</b>"
} else {
""
}
return """🚫 <b>$orderFiltered</b>
📊 <b>$orderInfo</b>
$marketLabel: $marketDisplay$outcomeDisplay
$sideLabel: <b>$sideDisplay</b>
$priceLabel: <code>$price</code>
$quantityLabel: <code>$size</code> shares
$amountLabel: <code>$amountDisplay</code> USDC
$accountLabel: $escapedAccountInfo
<b>$filterTypeLabel</b> <code>$filterTypeDisplay</code>
📝 <b>$filterReasonLabel</b>
<code>$escapedFilterReason</code>
$timeLabel: <code>$time</code>"""
}
/**
* 发送测试消息
*/
@@ -0,0 +1,109 @@
-- ============================================
-- V4: 重构跟单系统
-- 1. 删除现有 copy_trading 记录
-- 2. 移除 template_id 字段,添加所有配置参数字段和过滤条件字段
-- 3. 在 copy_trading_templates 表中添加过滤条件字段
-- 4. 修改 copy_order_tracking 表,移除 template_id 字段
-- ============================================
-- 1. 删除现有 copy_trading 记录(根据需求直接删除)
DELETE FROM copy_trading;
-- 2. 删除外键约束(先查询外键名称,如果存在则删除)
SET @fk_name = (SELECT CONSTRAINT_NAME FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'copy_trading'
AND REFERENCED_TABLE_NAME = 'copy_trading_templates'
LIMIT 1);
SET @sql = IF(@fk_name IS NOT NULL,
CONCAT('ALTER TABLE copy_trading DROP FOREIGN KEY ', @fk_name),
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 3. 删除 template_id 相关的索引(先查询索引是否存在)
SET @idx_name = (SELECT INDEX_NAME FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'copy_trading'
AND INDEX_NAME = 'idx_template_id'
LIMIT 1);
SET @sql = IF(@idx_name IS NOT NULL,
CONCAT('DROP INDEX ', @idx_name, ' ON copy_trading'),
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 删除唯一约束 uk_account_template_leader
SET @uk_name = (SELECT CONSTRAINT_NAME FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'copy_trading'
AND CONSTRAINT_TYPE = 'UNIQUE'
AND CONSTRAINT_NAME = 'uk_account_template_leader'
LIMIT 1);
SET @sql = IF(@uk_name IS NOT NULL,
CONCAT('ALTER TABLE copy_trading DROP INDEX ', @uk_name),
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 4. 删除 template_id 字段(如果存在)
SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'copy_trading'
AND COLUMN_NAME = 'template_id');
SET @sql = IF(@col_exists > 0,
'ALTER TABLE copy_trading DROP COLUMN template_id',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 5. 添加所有配置参数字段到 copy_trading 表
ALTER TABLE copy_trading
ADD COLUMN copy_mode VARCHAR(10) NOT NULL DEFAULT 'RATIO' COMMENT '跟单金额模式(RATIO/FIXED' AFTER leader_id,
ADD COLUMN copy_ratio DECIMAL(10, 2) NOT NULL DEFAULT 1.00 COMMENT '跟单比例(仅在copyMode=RATIO时生效)' AFTER copy_mode,
ADD COLUMN fixed_amount DECIMAL(20, 8) NULL COMMENT '固定跟单金额(仅在copyMode=FIXED时生效)' AFTER copy_ratio,
ADD COLUMN max_order_size DECIMAL(20, 8) NOT NULL DEFAULT 1000.00000000 COMMENT '单笔订单最大金额(USDC' AFTER fixed_amount,
ADD COLUMN min_order_size DECIMAL(20, 8) NOT NULL DEFAULT 1.00000000 COMMENT '单笔订单最小金额(USDC' AFTER max_order_size,
ADD COLUMN max_daily_loss DECIMAL(20, 8) NOT NULL DEFAULT 10000.00000000 COMMENT '每日最大亏损限制(USDC' AFTER min_order_size,
ADD COLUMN max_daily_orders INT NOT NULL DEFAULT 100 COMMENT '每日最大跟单订单数' AFTER max_daily_loss,
ADD COLUMN price_tolerance DECIMAL(5, 2) NOT NULL DEFAULT 5.00 COMMENT '价格容忍度(百分比,0-100' AFTER max_daily_orders,
ADD COLUMN delay_seconds INT NOT NULL DEFAULT 0 COMMENT '跟单延迟(秒,默认0立即跟单)' AFTER price_tolerance,
ADD COLUMN poll_interval_seconds INT NOT NULL DEFAULT 5 COMMENT '轮询间隔(秒,仅在WebSocket不可用时使用)' AFTER delay_seconds,
ADD COLUMN use_websocket BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否优先使用WebSocket推送' AFTER poll_interval_seconds,
ADD COLUMN websocket_reconnect_interval INT NOT NULL DEFAULT 5000 COMMENT 'WebSocket重连间隔(毫秒)' AFTER use_websocket,
ADD COLUMN websocket_max_retries INT NOT NULL DEFAULT 10 COMMENT 'WebSocket最大重试次数' AFTER websocket_reconnect_interval,
ADD COLUMN support_sell BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否支持跟单卖出' AFTER websocket_max_retries,
-- 过滤条件字段
ADD COLUMN min_order_depth DECIMAL(20, 8) NULL COMMENT '最小订单深度(USDC金额),NULL表示不启用此过滤' AFTER support_sell,
ADD COLUMN max_spread DECIMAL(20, 8) NULL COMMENT '最大价差(绝对价格),NULL表示不启用此过滤' AFTER min_order_depth,
ADD COLUMN min_orderbook_depth DECIMAL(20, 8) NULL COMMENT '最小订单簿深度(USDC金额),NULL表示不启用此过滤' AFTER max_spread;
-- 6. 添加新的唯一约束(account_id + leader_id,不再包含 template_id
ALTER TABLE copy_trading
ADD UNIQUE KEY uk_account_leader (account_id, leader_id);
-- 7. 在 copy_trading_templates 表中添加过滤条件字段
ALTER TABLE copy_trading_templates
ADD COLUMN min_order_depth DECIMAL(20, 8) NULL COMMENT '最小订单深度(USDC金额),NULL表示不启用此过滤' AFTER support_sell,
ADD COLUMN max_spread DECIMAL(20, 8) NULL COMMENT '最大价差(绝对价格),NULL表示不启用此过滤' AFTER min_order_depth,
ADD COLUMN min_orderbook_depth DECIMAL(20, 8) NULL COMMENT '最小订单簿深度(USDC金额),NULL表示不启用此过滤' AFTER max_spread;
-- 8. 修改 copy_order_tracking 表,移除 template_id 字段(如果存在)
SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'copy_order_tracking'
AND COLUMN_NAME = 'template_id');
SET @sql = IF(@col_exists > 0,
'ALTER TABLE copy_order_tracking DROP COLUMN template_id',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 9. 更新表注释
ALTER TABLE copy_trading COMMENT='跟单配置表(独立配置,不再绑定模板)';
@@ -0,0 +1,32 @@
-- ============================================
-- V5: 添加被过滤订单表
-- 用于记录因筛选条件不满足而被过滤的订单信息
-- ============================================
CREATE TABLE IF NOT EXISTS filtered_order (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
copy_trading_id BIGINT NOT NULL COMMENT '跟单关系ID',
account_id BIGINT NOT NULL COMMENT '账户ID',
leader_id BIGINT NOT NULL COMMENT 'Leader ID',
leader_trade_id VARCHAR(100) NOT NULL COMMENT 'Leader 的交易ID',
market_id VARCHAR(100) NOT NULL COMMENT '市场地址',
market_title VARCHAR(500) NULL COMMENT '市场标题(从 API 获取)',
market_slug VARCHAR(200) NULL COMMENT '市场 slug(用于生成链接)',
side VARCHAR(10) NOT NULL COMMENT '订单方向:BUY 或 SELL',
outcome_index INT NULL COMMENT '结果索引(0, 1, 2, ...),支持多元市场',
outcome VARCHAR(50) NULL COMMENT '市场方向(如 YES, NO 等)',
price DECIMAL(20, 8) NOT NULL COMMENT 'Leader 交易价格',
size DECIMAL(20, 8) NOT NULL COMMENT 'Leader 交易数量',
calculated_quantity DECIMAL(20, 8) NULL COMMENT '计算出的跟单数量(如果已计算)',
filter_reason TEXT NOT NULL COMMENT '过滤原因(详细说明)',
filter_type VARCHAR(50) NOT NULL COMMENT '过滤类型(如 ORDER_DEPTH, SPREAD, ORDERBOOK_DEPTH 等)',
created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)',
INDEX idx_copy_trading (copy_trading_id),
INDEX idx_leader_trade (leader_id, leader_trade_id),
INDEX idx_market (market_id),
INDEX idx_created_at (created_at),
INDEX idx_filter_type (filter_type),
FOREIGN KEY (copy_trading_id) REFERENCES copy_trading(id) ON DELETE CASCADE,
FOREIGN KEY (leader_id) REFERENCES copy_trading_leaders(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='被过滤订单表';