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>"""
}
/**
* 发送测试消息
*/