feat: 实现跟单筛选条件记录和展示功能
- 新增 FilteredOrder 实体和数据库表,记录被过滤的订单信息 - 在筛选失败时自动记录到数据库并发送 Telegram 通知 - 创建 FilteredOrderService 和 API 接口,支持查询被过滤订单列表 - 前端新增被过滤订单列表页面,支持按过滤类型筛选 - 修复价差计算逻辑,使用数组最大值和最小值而非第一个元素 - 优化编辑页面 UI,钱包和 Leader 显示与创建页面一致(只读) - 添加多语言支持(中文、繁体中文、英文)
This commit is contained in:
+59
-5
@@ -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()
|
||||
)
|
||||
|
||||
+2
-18
@@ -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
|
||||
}
|
||||
|
||||
+330
-194
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?
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
-4
@@ -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,
|
||||
|
||||
+18
-14
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+187
@@ -184,6 +184,193 @@ class TelegramNotificationService(
|
||||
sendMessage(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送订单被过滤通知
|
||||
* @param locale 语言设置(可选,如果提供则使用,否则使用 LocaleContextHolder 获取)
|
||||
*/
|
||||
suspend fun sendOrderFilteredNotification(
|
||||
marketTitle: String,
|
||||
marketId: String? = null, // 市场ID(conditionId),用于生成链接
|
||||
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 × size(USDC)
|
||||
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("<", "<").replace(">", ">")
|
||||
val escapedAccountInfo = accountInfo.replace("<", "<").replace(">", ">")
|
||||
val escapedFilterReason = filterReason.replace("<", "<").replace(">", ">")
|
||||
|
||||
// 格式化金额显示
|
||||
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("<", "<").replace(">", ">")
|
||||
"\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='被过滤订单表';
|
||||
|
||||
Reference in New Issue
Block a user