From 9b8150cf92f7b1856ce9b7826d7f0a6073e99d53 Mon Sep 17 00:00:00 2001 From: WrBug Date: Fri, 5 Dec 2025 05:18:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E8=B7=9F=E5=8D=95?= =?UTF-8?q?=E7=AD=9B=E9=80=89=E6=9D=A1=E4=BB=B6=E8=AE=B0=E5=BD=95=E5=92=8C?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 FilteredOrder 实体和数据库表,记录被过滤的订单信息 - 在筛选失败时自动记录到数据库并发送 Telegram 通知 - 创建 FilteredOrderService 和 API 接口,支持查询被过滤订单列表 - 前端新增被过滤订单列表页面,支持按过滤类型筛选 - 修复价差计算逻辑,使用数组最大值和最小值而非第一个元素 - 优化编辑页面 UI,钱包和 Leader 显示与创建页面一致(只读) - 添加多语言支持(中文、繁体中文、英文) --- .../controller/CopyTradingController.kt | 64 ++- .../wrbug/polymarketbot/dto/CopyTradingDto.kt | 84 ++- .../dto/CopyTradingStatisticsDto.kt | 2 - .../dto/CopyTradingTemplateDto.kt | 23 +- .../polymarketbot/dto/FilteredOrderDto.kt | 49 ++ .../polymarketbot/entity/CopyOrderTracking.kt | 3 - .../wrbug/polymarketbot/entity/CopyTrading.kt | 62 ++- .../entity/CopyTradingTemplate.kt | 10 + .../polymarketbot/entity/FilteredOrder.kt | 65 +++ .../repository/CopyTradingRepository.kt | 20 +- .../repository/FilteredOrderRepository.kt | 52 ++ .../service/CopyOrderTrackingService.kt | 524 +++++++++++------- .../service/CopyTradingFilterService.kt | 176 ++++++ .../service/CopyTradingService.kt | 288 +++++++--- .../service/CopyTradingStatisticsService.kt | 4 - .../service/CopyTradingTemplateService.kt | 32 +- .../service/FilteredOrderService.kt | 111 ++++ .../service/TelegramNotificationService.kt | 187 +++++++ .../migration/V4__refactor_copy_trading.sql | 109 ++++ .../V5__add_filtered_order_table.sql | 32 ++ frontend/src/App.tsx | 4 + frontend/src/locales/en/common.json | 295 +++++++++- frontend/src/locales/zh-CN/common.json | 247 +++++++++ frontend/src/locales/zh-TW/common.json | 295 +++++++++- frontend/src/pages/CopyTradingAdd.tsx | 404 ++++++++++++-- frontend/src/pages/CopyTradingEdit.tsx | 432 +++++++++++++++ frontend/src/pages/CopyTradingList.tsx | 92 +-- frontend/src/pages/FilteredOrdersList.tsx | 239 ++++++++ frontend/src/pages/TemplateAdd.tsx | 134 +++-- frontend/src/pages/TemplateEdit.tsx | 147 +++-- frontend/src/pages/TemplateList.tsx | 7 - frontend/src/services/api.ts | 34 +- frontend/src/types/index.ts | 123 +++- 33 files changed, 3801 insertions(+), 549 deletions(-) create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/dto/FilteredOrderDto.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/entity/FilteredOrder.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/repository/FilteredOrderRepository.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingFilterService.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/FilteredOrderService.kt create mode 100644 backend/src/main/resources/db/migration/V4__refactor_copy_trading.sql create mode 100644 backend/src/main/resources/db/migration/V5__add_filtered_order_table.sql create mode 100644 frontend/src/pages/CopyTradingEdit.tsx create mode 100644 frontend/src/pages/FilteredOrdersList.tsx diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/CopyTradingController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/CopyTradingController.kt index e62020b..8be18a8 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/CopyTradingController.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/CopyTradingController.kt @@ -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> { @@ -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> { + 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> { @@ -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> { + 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)) + } + } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingDto.kt index 36bd61f..11bc2bb 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingDto.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingDto.kt @@ -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?, diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingStatisticsDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingStatisticsDto.kt index 80ea394..4a2f07d 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingStatisticsDto.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingStatisticsDto.kt @@ -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, // 买入统计 diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingTemplateDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingTemplateDto.kt index caeaec6..796164e 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingTemplateDto.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingTemplateDto.kt @@ -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 ) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/FilteredOrderDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/FilteredOrderDto.kt new file mode 100644 index 0000000..cb16ee3 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/FilteredOrderDto.kt @@ -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, + val total: Long, + val page: Int, + val limit: Int +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyOrderTracking.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyOrderTracking.kt index dbe7e80..10bdd14 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyOrderTracking.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyOrderTracking.kt @@ -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, diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyTrading.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyTrading.kt index 10b6ffc..6065d5e 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyTrading.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyTrading.kt @@ -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(), diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyTradingTemplate.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyTradingTemplate.kt index 90d2d7d..ac0c665 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyTradingTemplate.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyTradingTemplate.kt @@ -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(), diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/FilteredOrder.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/FilteredOrder.kt new file mode 100644 index 0000000..9c2abd6 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/FilteredOrder.kt @@ -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() +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CopyTradingRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CopyTradingRepository.kt index 8350d56..b5ee3b4 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CopyTradingRepository.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CopyTradingRepository.kt @@ -15,27 +15,16 @@ interface CopyTradingRepository : JpaRepository { */ fun findByAccountId(accountId: Long): List - /** - * 根据模板ID查找跟单列表 - */ - fun findByTemplateId(templateId: Long): List - /** * 根据 Leader ID 查找跟单列表 */ fun findByLeaderId(leaderId: Long): List /** - * 根据账户ID和模板ID查找跟单列表 + * 根据账户ID和Leader ID查找跟单 */ - fun findByAccountIdAndTemplateId(accountId: Long, templateId: Long): List - - /** - * 根据账户ID、模板ID和Leader ID查找跟单 - */ - fun findByAccountIdAndTemplateIdAndLeaderId( + fun findByAccountIdAndLeaderId( accountId: Long, - templateId: Long, leaderId: Long ): CopyTrading? @@ -54,11 +43,6 @@ interface CopyTradingRepository : JpaRepository { */ fun findByLeaderIdAndEnabledTrue(leaderId: Long): List - /** - * 统计使用指定模板的跟单数量 - */ - fun countByTemplateId(templateId: Long): Long - /** * 统计指定 Leader 的跟单数量 */ diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/FilteredOrderRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/FilteredOrderRepository.kt new file mode 100644 index 0000000..d3ca7de --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/FilteredOrderRepository.kt @@ -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 { + + /** + * 根据跟单配置ID查询被过滤的订单(分页) + */ + fun findByCopyTradingIdOrderByCreatedAtDesc( + copyTradingId: Long, + pageable: Pageable + ): Page + + /** + * 根据跟单配置ID和过滤类型查询被过滤的订单(分页) + */ + fun findByCopyTradingIdAndFilterTypeOrderByCreatedAtDesc( + copyTradingId: Long, + filterType: String, + pageable: Pageable + ): Page + + /** + * 根据跟单配置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 + + /** + * 统计某个跟单配置的被过滤订单数量 + */ + fun countByCopyTradingId(copyTradingId: Long): Long + + /** + * 统计某个跟单配置的某个过滤类型的被过滤订单数量 + */ + fun countByCopyTradingIdAndFilterType(copyTradingId: Long, filterType: String): Long +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyOrderTrackingService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyOrderTrackingService.kt index 16485f0..79a9786 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyOrderTrackingService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyOrderTrackingService.kt @@ -1,7 +1,6 @@ package com.wrbug.polymarketbot.service import com.wrbug.polymarketbot.api.NewOrderRequest -import com.wrbug.polymarketbot.api.NewOrderResponse import com.wrbug.polymarketbot.api.TradeResponse import com.wrbug.polymarketbot.entity.* import com.wrbug.polymarketbot.repository.* @@ -13,7 +12,6 @@ import org.springframework.dao.DataIntegrityViolationException import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.math.BigDecimal -import java.math.RoundingMode /** * 订单跟踪服务 @@ -27,9 +25,10 @@ class CopyOrderTrackingService( private val sellMatchDetailRepository: SellMatchDetailRepository, private val processedTradeRepository: ProcessedTradeRepository, private val failedTradeRepository: FailedTradeRepository, + private val filteredOrderRepository: FilteredOrderRepository, private val copyTradingRepository: CopyTradingRepository, - private val templateRepository: CopyTradingTemplateRepository, private val accountRepository: AccountRepository, + private val filterService: CopyTradingFilterService, private val leaderRepository: LeaderRepository, private val orderSigningService: OrderSigningService, private val blockchainService: BlockchainService, @@ -37,12 +36,12 @@ class CopyOrderTrackingService( private val cryptoUtils: com.wrbug.polymarketbot.util.CryptoUtils, private val telegramNotificationService: TelegramNotificationService? = null // 可选,避免循环依赖 ) { - + private val logger = LoggerFactory.getLogger(CopyOrderTrackingService::class.java) - + // 协程作用域(用于异步发送通知) private val notificationScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - + /** * 解密账户私钥 */ @@ -54,7 +53,7 @@ class CopyOrderTrackingService( throw RuntimeException("解密私钥失败: ${e.message}", e) } } - + /** * 解密账户 API Secret */ @@ -68,7 +67,7 @@ class CopyOrderTrackingService( } } ?: throw IllegalStateException("账户未配置 API Secret") } - + /** * 解密账户 API Passphrase */ @@ -82,7 +81,7 @@ class CopyOrderTrackingService( } } ?: throw IllegalStateException("账户未配置 API Passphrase") } - + /** * 处理交易事件(WebSocket 或轮询) * 根据交易方向调用相应的处理方法 @@ -92,20 +91,20 @@ class CopyOrderTrackingService( return try { // 1. 检查是否已处理(去重,包括失败状态) val existingProcessed = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id) - + if (existingProcessed != null) { if (existingProcessed.status == "FAILED") { return Result.success(Unit) } return Result.success(Unit) } - + // 检查是否已记录为失败交易 val failedTrade = failedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id) if (failedTrade != null) { return Result.success(Unit) } - + // 2. 处理交易逻辑 val result = when (trade.side.uppercase()) { "BUY" -> processBuyTrade(leaderId, trade) @@ -115,12 +114,15 @@ class CopyOrderTrackingService( Result.failure(IllegalArgumentException("未知的交易方向: ${trade.side}")) } } - + if (result.isFailure) { - logger.error("处理交易失败: leaderId=$leaderId, tradeId=${trade.id}, side=${trade.side}", result.exceptionOrNull()) + logger.error( + "处理交易失败: leaderId=$leaderId, tradeId=${trade.id}, side=${trade.side}", + result.exceptionOrNull() + ) return result } - + // 3. 标记为已处理(成功状态) // 注意:并发情况下可能多个请求同时处理同一笔交易,需要处理唯一约束冲突 try { @@ -144,18 +146,21 @@ class CopyOrderTrackingService( return Result.success(Unit) } else { // 如果检查不到,说明可能是其他约束冲突,重新抛出异常 - logger.warn("保存ProcessedTrade时发生唯一约束冲突,但查询不到记录: leaderId=$leaderId, tradeId=${trade.id}", e) + logger.warn( + "保存ProcessedTrade时发生唯一约束冲突,但查询不到记录: leaderId=$leaderId, tradeId=${trade.id}", + e + ) throw e } } - + Result.success(Unit) } catch (e: Exception) { logger.error("处理交易异常: leaderId=$leaderId, tradeId=${trade.id}", e) Result.failure(e) } } - + /** * 处理买入交易 * 创建跟单买入订单并记录到跟踪表 @@ -165,53 +170,156 @@ class CopyOrderTrackingService( return try { // 1. 查找所有启用且支持该Leader的跟单关系 val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId) - + if (copyTradings.isEmpty()) { return Result.success(Unit) } - + // 2. 为每个跟单关系创建买入订单跟踪 for (copyTrading in copyTradings) { try { - // 获取模板 - val template = templateRepository.findById(copyTrading.templateId).orElse(null) - ?: continue - // 获取账户 val account = accountRepository.findById(copyTrading.accountId).orElse(null) ?: continue - + // 验证账户API凭证 if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) { logger.warn("账户未配置API凭证,跳过创建订单: accountId=${account.id}, copyTradingId=${copyTrading.id}") continue } - + // 验证账户是否启用 if (!account.isEnabled) { continue } - + + // 直接使用outcomeIndex获取tokenId(支持多元市场) + if (trade.outcomeIndex == null) { + logger.warn("交易缺少outcomeIndex,无法确定tokenId: tradeId=${trade.id}, market=${trade.market}") + continue + } + + // 获取tokenId(直接使用outcomeIndex,不转换为YES/NO) + val tokenIdResult = blockchainService.getTokenId(trade.market, trade.outcomeIndex) + if (tokenIdResult.isFailure) { + logger.error("获取tokenId失败: market=${trade.market}, outcomeIndex=${trade.outcomeIndex}, error=${tokenIdResult.exceptionOrNull()?.message}") + continue + } + val tokenId = tokenIdResult.getOrNull() ?: continue + + // 过滤条件检查(在计算订单参数之前) + val filterCheck = filterService.checkFilters(copyTrading, tokenId, isBuyOrder = true) + if (!filterCheck.first) { + logger.warn("过滤条件检查失败,跳过创建订单: copyTradingId=${copyTrading.id}, reason=${filterCheck.second}") + + // 记录被过滤的订单并发送通知(异步,不阻塞) + notificationScope.launch { + try { + // 获取市场信息(标题和slug) + val marketInfo = withContext(Dispatchers.IO) { + try { + val gammaApi = retrofitFactory.createGammaApi() + val marketResponse = gammaApi.listMarkets(conditionIds = listOf(trade.market)) + if (marketResponse.isSuccessful && marketResponse.body() != null) { + marketResponse.body()!!.firstOrNull() + } else { + null + } + } catch (e: Exception) { + logger.warn("获取市场信息失败: ${e.message}", e) + null + } + } + + val marketTitle = marketInfo?.question ?: trade.market + val marketSlug = marketInfo?.slug + + // 从 filterReason 中提取 filterType + val filterType = extractFilterType(filterCheck.second) + + // 计算买入数量(用于记录,即使被过滤也记录) + val calculatedQuantity = try { + calculateBuyQuantity(trade, copyTrading) + } catch (e: Exception) { + logger.warn("计算买入数量失败: ${e.message}", e) + null + } + + // 记录到数据库 + val filteredOrder = FilteredOrder( + copyTradingId = copyTrading.id!!, + accountId = copyTrading.accountId, + leaderId = copyTrading.leaderId, + leaderTradeId = trade.id, + marketId = trade.market, + marketTitle = marketTitle, + marketSlug = marketSlug, + side = "BUY", + outcomeIndex = trade.outcomeIndex, + outcome = trade.outcome, + price = trade.price.toSafeBigDecimal(), + size = trade.size.toSafeBigDecimal(), + calculatedQuantity = calculatedQuantity, + filterReason = filterCheck.second, + filterType = filterType + ) + + try { + filteredOrderRepository.save(filteredOrder) + logger.info("已记录被过滤的订单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}, filterType=$filterType") + } catch (e: Exception) { + logger.error("保存被过滤订单失败: ${e.message}", e) + } + + // 发送 Telegram 通知 + val locale = try { + org.springframework.context.i18n.LocaleContextHolder.getLocale() + } catch (e: Exception) { + java.util.Locale("zh", "CN") // 默认简体中文 + } + + telegramNotificationService?.sendOrderFilteredNotification( + marketTitle = marketTitle, + marketId = trade.market, + marketSlug = marketSlug, + side = "BUY", + outcome = trade.outcome, + price = trade.price, + size = trade.size, + filterReason = filterCheck.second, + filterType = filterType, + accountName = account.accountName, + walletAddress = account.walletAddress, + locale = locale + ) + } catch (e: Exception) { + logger.error("处理被过滤订单通知失败: ${e.message}", e) + } + } + + continue + } + // 计算买入数量 - val buyQuantity = calculateBuyQuantity(trade, template) - + val buyQuantity = calculateBuyQuantity(trade, copyTrading) + if (buyQuantity.lte(BigDecimal.ZERO)) { logger.warn("计算出的买入数量为0或负数,跳过: copyTradingId=${copyTrading.id}, tradeId=${trade.id}") continue } - + // 验证订单数量限制(仅比例模式) var finalBuyQuantity = buyQuantity - if (template.copyMode == "RATIO") { + if (copyTrading.copyMode == "RATIO") { val orderAmount = buyQuantity.multi(trade.price.toSafeBigDecimal()) - if (orderAmount.lt(template.minOrderSize)) { - logger.warn("订单金额低于最小限制,跳过: copyTradingId=${copyTrading.id}, amount=$orderAmount, min=${template.minOrderSize}") + if (orderAmount.lt(copyTrading.minOrderSize)) { + logger.warn("订单金额低于最小限制,跳过: copyTradingId=${copyTrading.id}, amount=$orderAmount, min=${copyTrading.minOrderSize}") continue } - if (orderAmount.gt(template.maxOrderSize)) { - logger.warn("订单金额超过最大限制,调整数量: copyTradingId=${copyTrading.id}, amount=$orderAmount, max=${template.maxOrderSize}") + if (orderAmount.gt(copyTrading.maxOrderSize)) { + logger.warn("订单金额超过最大限制,调整数量: copyTradingId=${copyTrading.id}, amount=$orderAmount, max=${copyTrading.maxOrderSize}") // 调整数量到最大值 - val adjustedQuantity = template.maxOrderSize.div(trade.price.toSafeBigDecimal()) + val adjustedQuantity = copyTrading.maxOrderSize.div(trade.price.toSafeBigDecimal()) if (adjustedQuantity.lte(BigDecimal.ZERO)) { logger.warn("调整后的数量为0或负数,跳过: copyTradingId=${copyTrading.id}") continue @@ -220,37 +328,23 @@ class CopyOrderTrackingService( finalBuyQuantity = adjustedQuantity } } - + // 风险控制检查 - val riskCheckResult = checkRiskControls(copyTrading, template, finalBuyQuantity, trade.price.toSafeBigDecimal()) + val riskCheckResult = checkRiskControls(copyTrading) if (!riskCheckResult.first) { logger.warn("风险控制检查失败,跳过创建订单: copyTradingId=${copyTrading.id}, reason=${riskCheckResult.second}") continue } - + // 延迟跟单(如果配置了延迟) - if (template.delaySeconds > 0) { - logger.info("延迟跟单: copyTradingId=${copyTrading.id}, delaySeconds=${template.delaySeconds}") - delay(template.delaySeconds * 1000L) // 转换为毫秒 + if (copyTrading.delaySeconds > 0) { + logger.info("延迟跟单: copyTradingId=${copyTrading.id}, delaySeconds=${copyTrading.delaySeconds}") + delay(copyTrading.delaySeconds * 1000L) // 转换为毫秒 } - - // 直接使用outcomeIndex获取tokenId(支持多元市场) - if (trade.outcomeIndex == null) { - logger.warn("交易缺少outcomeIndex,无法确定tokenId: tradeId=${trade.id}, market=${trade.market}") - continue - } - - // 获取tokenId(直接使用outcomeIndex,不转换为YES/NO) - val tokenIdResult = blockchainService.getTokenId(trade.market, trade.outcomeIndex) - if (tokenIdResult.isFailure) { - logger.error("获取tokenId失败: market=${trade.market}, outcomeIndex=${trade.outcomeIndex}, error=${tokenIdResult.exceptionOrNull()?.message}") - continue - } - val tokenId = tokenIdResult.getOrNull() ?: continue - + // 计算价格(应用价格容忍度) - val buyPrice = calculateAdjustedPrice(trade.price.toSafeBigDecimal(), template, isBuy = true) - + val buyPrice = calculateAdjustedPrice(trade.price.toSafeBigDecimal(), copyTrading, isBuy = true) + // 解密 API 凭证 val apiSecret = try { decryptApiSecret(account) @@ -264,18 +358,18 @@ class CopyOrderTrackingService( logger.warn("解密 API 凭证失败,跳过创建订单: accountId=${account.id}, error=${e.message}") continue } - + // 创建带认证的CLOB API客户端 val clobApi = retrofitFactory.createClobApi( - account.apiKey!!, + account.apiKey, apiSecret, apiPassphrase, account.walletAddress ) - + // 解密私钥 val decryptedPrivateKey = decryptPrivateKey(account) - + // 调用API创建订单(带重试机制,重试时会重新生成salt并重新签名) val createOrderResult = createOrderWithRetry( clobApi = clobApi, @@ -289,11 +383,17 @@ class CopyOrderTrackingService( copyTradingId = copyTrading.id!!, tradeId = trade.id ) - + if (createOrderResult.isFailure) { // 创建订单失败,记录到失败表 val exception = createOrderResult.exceptionOrNull() - val errorMsg = buildFullErrorMessage(exception, "BUY", buyPrice.toString(), finalBuyQuantity.toString(), trade.id) + val errorMsg = buildFullErrorMessage( + exception, + "BUY", + buyPrice.toString(), + finalBuyQuantity.toString(), + trade.id + ) recordFailedTrade( leaderId = leaderId, trade = trade, @@ -305,7 +405,7 @@ class CopyOrderTrackingService( errorMessage = errorMsg, retryCount = 1 // 已重试一次 ) - + // 发送订单失败通知(异步,不阻塞) notificationScope.launch { try { @@ -324,17 +424,17 @@ class CopyOrderTrackingService( null } } - + val marketTitle = marketInfo?.question ?: trade.market val marketSlug = marketInfo?.slug - + // 获取当前语言设置(从 LocaleContextHolder) val locale = try { org.springframework.context.i18n.LocaleContextHolder.getLocale() } catch (e: Exception) { java.util.Locale("zh", "CN") // 默认简体中文 } - + telegramNotificationService?.sendOrderFailureNotification( marketTitle = marketTitle, marketId = trade.market, @@ -343,7 +443,7 @@ class CopyOrderTrackingService( outcome = null, // 失败时可能没有 outcome price = buyPrice.toString(), size = finalBuyQuantity.toString(), - errorMessage = errorMsg, // 只传递后端返回的 msg + errorMessage = exception?.message.orEmpty(), // 只传递后端返回的 msg accountName = account.accountName, walletAddress = account.walletAddress, locale = locale @@ -352,20 +452,19 @@ class CopyOrderTrackingService( logger.warn("发送订单失败通知失败: ${e.message}", e) } } - + continue } - + val realOrderId = createOrderResult.getOrNull() ?: continue - + // 创建买入订单跟踪记录(使用真实订单ID,使用outcomeIndex) val tracking = CopyOrderTracking( - copyTradingId = copyTrading.id!!, + copyTradingId = copyTrading.id, accountId = copyTrading.accountId, leaderId = copyTrading.leaderId, - templateId = copyTrading.templateId, marketId = trade.market, - side = trade.outcomeIndex?.toString() ?: "0", // 使用outcomeIndex作为side(兼容旧数据) + side = trade.outcomeIndex.toString(), // 使用outcomeIndex作为side(兼容旧数据) outcomeIndex = trade.outcomeIndex, // 新增字段 buyOrderId = realOrderId, // 使用真实订单ID leaderBuyTradeId = trade.id, @@ -374,9 +473,9 @@ class CopyOrderTrackingService( remainingQuantity = finalBuyQuantity, status = "filled" ) - + copyOrderTrackingRepository.save(tracking) - + // 发送订单成功通知(异步,不阻塞) notificationScope.launch { try { @@ -395,10 +494,10 @@ class CopyOrderTrackingService( null } } - + val marketTitle = marketInfo?.question ?: trade.market val marketSlug = marketInfo?.slug - + // 重新创建 CLOB API 客户端用于查询订单详情 val apiSecret = try { decryptApiSecret(account) @@ -412,10 +511,10 @@ class CopyOrderTrackingService( logger.warn("解密 API Passphrase 失败: ${e.message}", e) null } - - val clobApiForQuery = if (account.apiKey != null && apiSecret != null && apiPassphrase != null) { + + val clobApiForQuery = if (apiSecret != null && apiPassphrase != null) { retrofitFactory.createClobApi( - account.apiKey!!, + account.apiKey, apiSecret, apiPassphrase, account.walletAddress @@ -423,14 +522,14 @@ class CopyOrderTrackingService( } else { null } - + // 获取当前语言设置(从 LocaleContextHolder) val locale = try { org.springframework.context.i18n.LocaleContextHolder.getLocale() } catch (e: Exception) { java.util.Locale("zh", "CN") // 默认简体中文 } - + telegramNotificationService?.sendOrderSuccessNotification( orderId = realOrderId, marketTitle = marketTitle, @@ -455,14 +554,14 @@ class CopyOrderTrackingService( // 继续处理下一个跟单关系 } } - + Result.success(Unit) } catch (e: Exception) { logger.error("处理买入交易异常: leaderId=$leaderId, tradeId=${trade.id}", e) Result.failure(e) } } - + /** * 处理卖出交易 * 查找未匹配的买入订单并进行匹配 @@ -472,59 +571,57 @@ class CopyOrderTrackingService( return try { // 1. 查找所有启用且支持该Leader的跟单关系 val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId) - + if (copyTradings.isEmpty()) { return Result.success(Unit) } - + // 2. 为每个跟单关系处理卖出匹配 for (copyTrading in copyTradings) { try { - // 获取模板 - val template = templateRepository.findById(copyTrading.templateId).orElse(null) - ?: continue - // 检查是否支持卖出 - if (!template.supportSell) { + if (!copyTrading.supportSell) { continue } - + // 执行卖出匹配 - matchSellOrder(copyTrading, trade, template) + matchSellOrder(copyTrading, trade) } catch (e: Exception) { logger.error("处理卖出交易失败: copyTradingId=${copyTrading.id}, tradeId=${trade.id}", e) // 继续处理下一个跟单关系 } } - + Result.success(Unit) } catch (e: Exception) { logger.error("处理卖出交易异常: leaderId=$leaderId, tradeId=${trade.id}", e) Result.failure(e) } } - + /** * 计算买入数量 * 根据模板的copyMode计算 */ - private fun calculateBuyQuantity(trade: TradeResponse, template: CopyTradingTemplate): BigDecimal { - return when (template.copyMode) { + private fun calculateBuyQuantity(trade: TradeResponse, copyTrading: CopyTrading): BigDecimal { + return when (copyTrading.copyMode) { "RATIO" -> { // 比例模式:Leader 数量 × 比例 - trade.size.toSafeBigDecimal().multi(template.copyRatio) + trade.size.toSafeBigDecimal().multi(copyTrading.copyRatio) } + "FIXED" -> { // 固定金额模式:固定金额 / 买入价格 - val fixedAmount = template.fixedAmount + val fixedAmount = copyTrading.fixedAmount ?: throw IllegalStateException("固定金额模式下 fixedAmount 不能为空") val buyPrice = trade.price.toSafeBigDecimal() fixedAmount.div(buyPrice) } - else -> throw IllegalArgumentException("不支持的 copyMode: ${template.copyMode}") + + else -> throw IllegalArgumentException("不支持的 copyMode: ${copyTrading.copyMode}") } } - + /** * 卖出订单匹配 * 统一按比例计算,不区分RATIO或FIXED模式 @@ -533,8 +630,7 @@ class CopyOrderTrackingService( @Transactional private suspend fun matchSellOrder( copyTrading: CopyTrading, - leaderSellTrade: TradeResponse, - template: CopyTradingTemplate + leaderSellTrade: TradeResponse ) { // 1. 获取账户 val account = accountRepository.findById(copyTrading.accountId).orElse(null) @@ -542,59 +638,59 @@ class CopyOrderTrackingService( logger.warn("账户不存在,跳过卖出匹配: accountId=${copyTrading.accountId}, copyTradingId=${copyTrading.id}") return } - + // 验证账户API凭证 if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) { logger.warn("账户未配置API凭证,跳过创建卖出订单: accountId=${account.id}, copyTradingId=${copyTrading.id}") return } - + // 验证账户是否启用 if (!account.isEnabled) { return } - + // 2. 计算需要匹配的数量(统一按比例计算) - val needMatch = leaderSellTrade.size.toSafeBigDecimal().multi(template.copyRatio) - + val needMatch = leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio) + // 3. 查找未匹配的买入订单(FIFO顺序) // 直接使用outcomeIndex匹配,而不是转换为YES/NO if (leaderSellTrade.outcomeIndex == null) { logger.warn("卖出交易缺少outcomeIndex,无法匹配: tradeId=${leaderSellTrade.id}, market=${leaderSellTrade.market}") return } - + // 使用outcomeIndex查找匹配的买入订单(存储在CopyOrderTracking中的outcomeIndex) val unmatchedOrders = copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndex( copyTrading.id!!, leaderSellTrade.market, leaderSellTrade.outcomeIndex ) - + if (unmatchedOrders.isEmpty()) { return } - + // 4. 按FIFO顺序匹配,计算实际可以卖出的数量 var totalMatched = BigDecimal.ZERO var remaining = needMatch val matchDetails = mutableListOf() - + for (order in unmatchedOrders) { if (remaining.lte(BigDecimal.ZERO)) break - + val matchQty = minOf( order.remainingQuantity.toSafeBigDecimal(), remaining ) - + if (matchQty.lte(BigDecimal.ZERO)) continue - + // 计算盈亏 val buyPrice = order.price.toSafeBigDecimal() val sellPrice = leaderSellTrade.price.toSafeBigDecimal() val realizedPnl = sellPrice.subtract(buyPrice).multi(matchQty) - + // 创建匹配明细(稍后保存) val detail = SellMatchDetail( matchRecordId = 0, // 稍后设置 @@ -606,15 +702,15 @@ class CopyOrderTrackingService( realizedPnl = realizedPnl ) matchDetails.add(detail) - + totalMatched = totalMatched.add(matchQty) remaining = remaining.subtract(matchQty) } - + if (totalMatched.lte(BigDecimal.ZERO)) { return } - + // 5. 获取tokenId(直接使用outcomeIndex,支持多元市场) val tokenIdResult = blockchainService.getTokenId(leaderSellTrade.market, leaderSellTrade.outcomeIndex) if (tokenIdResult.isFailure) { @@ -622,10 +718,10 @@ class CopyOrderTrackingService( return } val tokenId = tokenIdResult.getOrNull() ?: return - + // 6. 计算卖出价格(应用价格容忍度) - val sellPrice = calculateAdjustedPrice(leaderSellTrade.price.toSafeBigDecimal(), template, isBuy = false) - + val sellPrice = calculateAdjustedPrice(leaderSellTrade.price.toSafeBigDecimal(), copyTrading, isBuy = false) + // 7. 解密私钥(在方法开始时解密一次,后续复用) val decryptedPrivateKey = decryptPrivateKey(account) // 8. 创建并签名卖出订单 @@ -646,7 +742,7 @@ class CopyOrderTrackingService( logger.error("创建并签名卖出订单失败: copyTradingId=${copyTrading.id}, tradeId=${leaderSellTrade.id}", e) return } - + // 9. 构建订单请求 // 跟单订单使用 FAK (Fill-And-Kill),允许部分成交,未成交部分立即取消 // 这样可以快速响应 Leader 的交易,避免订单长期挂单导致价格不匹配 @@ -656,17 +752,17 @@ class CopyOrderTrackingService( orderType = "FAK", // Fill-And-Kill deferExec = false ) - + // 10. 创建带认证的CLOB API客户端 val clobApi = retrofitFactory.createClobApi( - account.apiKey!!, - account.apiSecret!!, - account.apiPassphrase!!, + account.apiKey, + account.apiSecret, + account.apiPassphrase, account.walletAddress ) - + // 11. 调用API创建卖出订单(带重试机制,重试时会重新生成salt并重新签名) - + val createOrderResult = createOrderWithRetry( clobApi = clobApi, privateKey = decryptedPrivateKey, @@ -679,11 +775,17 @@ class CopyOrderTrackingService( copyTradingId = copyTrading.id!!, tradeId = leaderSellTrade.id ) - + if (createOrderResult.isFailure) { // 创建订单失败,记录到失败表 val exception = createOrderResult.exceptionOrNull() - val errorMsg = buildFullErrorMessage(exception, "SELL", sellPrice.toString(), totalMatched.toString(), leaderSellTrade.id) + val errorMsg = buildFullErrorMessage( + exception, + "SELL", + sellPrice.toString(), + totalMatched.toString(), + leaderSellTrade.id + ) recordFailedTrade( leaderId = copyTrading.leaderId, trade = leaderSellTrade, @@ -697,9 +799,9 @@ class CopyOrderTrackingService( ) return } - + val realSellOrderId = createOrderResult.getOrNull() ?: return - + // 12. 更新买入订单跟踪状态 for (order in unmatchedOrders) { val detail = matchDetails.find { it.trackingId == order.id } @@ -709,13 +811,13 @@ class CopyOrderTrackingService( updateOrderStatus(order) order.updatedAt = System.currentTimeMillis() copyOrderTrackingRepository.save(order) - + } } - + // 13. 创建卖出匹配记录(使用真实订单ID,使用outcomeIndex) val totalRealizedPnl = matchDetails.sumOf { it.realizedPnl.toSafeBigDecimal() } - + val matchRecord = SellMatchRecord( copyTradingId = copyTrading.id!!, sellOrderId = realSellOrderId, // 使用真实订单ID @@ -727,17 +829,17 @@ class CopyOrderTrackingService( sellPrice = sellPrice, totalRealizedPnl = totalRealizedPnl ) - + val savedRecord = sellMatchRecordRepository.save(matchRecord) - + // 14. 保存匹配明细 for (detail in matchDetails) { val savedDetail = detail.copy(matchRecordId = savedRecord.id!!) sellMatchDetailRepository.save(savedDetail) } - + } - + /** * 创建订单(带重试机制) * 失败后重试一次,如果仍然失败则返回失败结果 @@ -756,7 +858,7 @@ class CopyOrderTrackingService( tradeId: String ): Result { var lastError: Exception? = null - + // 最多重试2次(首次 + 1次重试) for (attempt in 1..2) { try { @@ -773,7 +875,7 @@ class CopyOrderTrackingService( feeRateBps = "0", expiration = "0" ) - + // 构建订单请求(每次重试都使用新签名的订单) // 跟单订单使用 FAK (Fill-And-Kill),允许部分成交,未成交部分立即取消 // 这样可以快速响应 Leader 的交易,避免订单长期挂单导致价格不匹配 @@ -783,16 +885,17 @@ class CopyOrderTrackingService( orderType = "FAK", // Fill-And-Kill deferExec = false ) - + val orderResponse = clobApi.createOrder(orderRequest) - + if (!orderResponse.isSuccessful || orderResponse.body() == null) { val errorBody = try { orderResponse.errorBody()?.string() } catch (e: Exception) { null } - val errorMsg = "创建订单失败: copyTradingId=$copyTradingId, tradeId=$tradeId, attempt=$attempt, side=$side, price=$price, size=$size, tokenId=$tokenId, code=${orderResponse.code()}, message=${orderResponse.message()}${if (errorBody != null) ", errorBody=$errorBody" else ""}" + val errorMsg = + "创建订单失败: copyTradingId=$copyTradingId, tradeId=$tradeId, attempt=$attempt, side=$side, price=$price, size=$size, tokenId=$tokenId, code=${orderResponse.code()}, message=${orderResponse.message()}${if (errorBody != null) ", errorBody=$errorBody" else ""}" lastError = Exception(errorMsg) // 所有失败都记录详细日志 logger.error(errorMsg) @@ -802,10 +905,11 @@ class CopyOrderTrackingService( } return Result.failure(lastError!!) } - + val response = orderResponse.body()!! if (!response.success || response.orderId == null) { - val errorMsg = "创建订单失败: copyTradingId=$copyTradingId, tradeId=$tradeId, attempt=$attempt, side=$side, price=$price, size=$size, tokenId=$tokenId, errorMsg=${response.errorMsg}" + val errorMsg = + "创建订单失败: copyTradingId=$copyTradingId, tradeId=$tradeId, attempt=$attempt, side=$side, price=$price, size=$size, tokenId=$tokenId, errorMsg=${response.errorMsg}" lastError = Exception(errorMsg) // 所有失败都记录详细日志 logger.error(errorMsg) @@ -815,11 +919,12 @@ class CopyOrderTrackingService( } return Result.failure(lastError!!) } - + // 成功 return Result.success(response.orderId) } catch (e: Exception) { - val errorMsg = "调用创建订单API异常: copyTradingId=$copyTradingId, tradeId=$tradeId, attempt=$attempt, side=$side, price=$price, size=$size, tokenId=$tokenId, error=${e.message}" + val errorMsg = + "调用创建订单API异常: copyTradingId=$copyTradingId, tradeId=$tradeId, attempt=$attempt, side=$side, price=$price, size=$size, tokenId=$tokenId, error=${e.message}" lastError = Exception(errorMsg, e) // 所有失败都记录详细日志(包括堆栈) logger.error(errorMsg, e) @@ -830,24 +935,33 @@ class CopyOrderTrackingService( return Result.failure(lastError!!) } } - + val finalError = lastError ?: Exception("创建订单失败:未知错误") - logger.error("创建订单失败(所有重试都失败): copyTradingId=$copyTradingId, tradeId=$tradeId, side=$side, price=$price, size=$size, tokenId=$tokenId", finalError) + logger.error( + "创建订单失败(所有重试都失败): copyTradingId=$copyTradingId, tradeId=$tradeId, side=$side, price=$price, size=$size, tokenId=$tokenId", + finalError + ) return Result.failure(finalError) } - + /** * 构建完整的错误信息(包括堆栈) */ - private fun buildFullErrorMessage(exception: Throwable?, side: String, price: String, size: String, tradeId: String): String { + private fun buildFullErrorMessage( + exception: Throwable?, + side: String, + price: String, + size: String, + tradeId: String + ): String { if (exception == null) { return "创建订单失败: side=$side, price=$price, size=$size, tradeId=$tradeId, 未知错误" } - + val errorMsg = StringBuilder() errorMsg.append("创建订单失败: side=$side, price=$price, size=$size, tradeId=$tradeId") errorMsg.append(", error=${exception.message}") - + // 添加堆栈信息(限制长度,避免过长) val stackTrace = exception.stackTraceToString() val maxLength = 2000 // 限制错误信息最大长度为2000字符 @@ -856,15 +970,15 @@ class CopyOrderTrackingService( } else { errorMsg.append(", stackTrace=$stackTrace") } - + // 如果有 cause,也添加 exception.cause?.let { cause -> errorMsg.append(", cause=${cause.message}") } - + return errorMsg.toString() } - + /** * 记录失败交易到数据库 */ @@ -888,7 +1002,7 @@ class CopyOrderTrackingService( } else { errorMessage } - + val failedTrade = FailedTrade( leaderId = leaderId, leaderTradeId = trade.id, @@ -904,10 +1018,10 @@ class CopyOrderTrackingService( failedAt = System.currentTimeMillis() ) failedTradeRepository.save(failedTrade) - + // 记录日志,确认已保存到数据库 logger.info("失败交易已保存到数据库: leaderId=$leaderId, tradeId=${trade.id}, errorMessageLength=${finalErrorMessage.length}") - + // 标记为已处理(失败状态),避免重复处理 // 注意:并发情况下可能多个请求同时处理同一笔交易,需要处理唯一约束冲突 try { @@ -931,16 +1045,19 @@ class CopyOrderTrackingService( logger.debug("交易已标记为失败(并发检测): leaderId=$leaderId, tradeId=${trade.id}") } } else { - logger.warn("保存ProcessedTrade失败记录时发生唯一约束冲突,但查询不到记录: leaderId=$leaderId, tradeId=${trade.id}", e) + logger.warn( + "保存ProcessedTrade失败记录时发生唯一约束冲突,但查询不到记录: leaderId=$leaderId, tradeId=${trade.id}", + e + ) } } - + logger.warn("已记录失败交易: leaderId=$leaderId, tradeId=${trade.id}, error=$errorMessage") } catch (e: Exception) { logger.error("记录失败交易异常: leaderId=$leaderId, tradeId=${trade.id}", e) } } - + /** * 更新订单状态 */ @@ -949,66 +1066,65 @@ class CopyOrderTrackingService( tracking.remainingQuantity.toSafeBigDecimal().eq(BigDecimal.ZERO) -> { tracking.status = "fully_matched" } + tracking.matchedQuantity.toSafeBigDecimal().gt(BigDecimal.ZERO) -> { tracking.status = "partially_matched" } + else -> { tracking.status = "filled" } } } - + /** * 风险控制检查 * 返回 Pair<是否通过, 失败原因> */ private fun checkRiskControls( - copyTrading: CopyTrading, - template: CopyTradingTemplate, - quantity: BigDecimal, - price: BigDecimal + copyTrading: CopyTrading ): Pair { // 1. 检查每日订单数限制 val todayStart = System.currentTimeMillis() - (System.currentTimeMillis() % 86400000) // 今天0点的时间戳 val todayBuyOrders = copyOrderTrackingRepository.findByCopyTradingId(copyTrading.id!!) .filter { it.createdAt >= todayStart } - - if (todayBuyOrders.size >= template.maxDailyOrders) { - return Pair(false, "今日订单数已达上限: ${todayBuyOrders.size}/${template.maxDailyOrders}") + + if (todayBuyOrders.size >= copyTrading.maxDailyOrders) { + return Pair(false, "今日订单数已达上限: ${todayBuyOrders.size}/${copyTrading.maxDailyOrders}") } - + // 2. 检查每日亏损限制(需要计算今日已实现盈亏) val todaySellRecords = sellMatchRecordRepository.findByCopyTradingId(copyTrading.id!!) .filter { it.createdAt >= todayStart } - + val todayRealizedPnl = todaySellRecords.sumOf { it.totalRealizedPnl.toSafeBigDecimal() } if (todayRealizedPnl.lt(BigDecimal.ZERO)) { val todayLoss = todayRealizedPnl.abs() - if (todayLoss.gte(template.maxDailyLoss)) { - return Pair(false, "今日亏损已达上限: ${todayLoss}/${template.maxDailyLoss}") + if (todayLoss.gte(copyTrading.maxDailyLoss)) { + return Pair(false, "今日亏损已达上限: ${todayLoss}/${copyTrading.maxDailyLoss}") } } - + return Pair(true, "") } - + /** * 计算调整后的价格(应用价格容忍度) */ private fun calculateAdjustedPrice( originalPrice: BigDecimal, - template: CopyTradingTemplate, + copyTrading: CopyTrading, isBuy: Boolean ): BigDecimal { // 如果价格容忍度为0,直接返回原价格 - if (template.priceTolerance.eq(BigDecimal.ZERO)) { + if (copyTrading.priceTolerance.eq(BigDecimal.ZERO)) { return originalPrice } - + // 计算价格调整范围(百分比) - val tolerancePercent = template.priceTolerance.div(100) + val tolerancePercent = copyTrading.priceTolerance.div(100) val adjustment = originalPrice.multi(tolerancePercent) - + return if (isBuy) { // 买入:可以稍微加价以确保成交(在原价格基础上加容忍度) originalPrice.add(adjustment).coerceAtMost(BigDecimal("0.99")) @@ -1017,17 +1133,37 @@ class CopyOrderTrackingService( originalPrice.subtract(adjustment).coerceAtLeast(BigDecimal("0.01")) } } - + + /** + * 从过滤原因中提取过滤类型 + */ + private fun extractFilterType(filterReason: String): String { + return when { + filterReason.contains("订单深度不足", ignoreCase = true) -> "ORDER_DEPTH" + filterReason.contains("价差过大", ignoreCase = true) -> "SPREAD" + filterReason.contains("订单簿深度不足", ignoreCase = true) -> "ORDERBOOK_DEPTH" + filterReason.contains("价格", ignoreCase = true) && filterReason.contains( + "合理", + ignoreCase = true + ) -> "PRICE_VALIDITY" + + filterReason.contains("市场状态", ignoreCase = true) -> "MARKET_STATUS" + filterReason.contains("获取订单簿失败", ignoreCase = true) -> "ORDERBOOK_ERROR" + filterReason.contains("订单簿为空", ignoreCase = true) -> "ORDERBOOK_EMPTY" + else -> "UNKNOWN" + } + } + /** * 从trade中提取side(结果名称) - * + * * 说明: * - 根据设计文档,系统只支持sports和crypto分类,这些通常是二元市场(YES/NO) * - TradeResponse中的side是BUY/SELL(订单方向),不是YES/NO(outcome) * - 在二元市场中: * - outcomeIndex 0 = 第一个 outcome(通常是 YES) * - outcomeIndex 1 = 第二个 outcome(通常是 NO) - * + * * 判断逻辑(禁止使用 "YES"/"NO" 字符串判断): * 1. 优先使用 outcomeIndex:根据 outcomeIndex 返回对应的结果名称 * 2. 如果有 outcome 名称,直接返回 outcome 名称 @@ -1035,8 +1171,8 @@ class CopyOrderTrackingService( * 4. 否则,返回默认值(兼容旧逻辑,但不使用 YES/NO 字符串判断) */ private fun extractSide( - marketId: String, - tradeSide: String, + marketId: String, + tradeSide: String, outcomeIndex: Int? = null, outcome: String? = null ): String { @@ -1059,17 +1195,17 @@ class CopyOrderTrackingService( } } } - + // 2. 如果有 outcome 名称,直接返回 if (outcome != null) { return outcome } - + // 3. 如果 tradeSide 不是 BUY/SELL,可能是结果名称,直接返回 if (tradeSide.uppercase() !in listOf("BUY", "SELL")) { return tradeSide } - + // 4. 无法确定,返回默认值(兼容旧逻辑) logger.warn("无法确定 side,默认返回第一个outcome: marketId=$marketId, tradeSide=$tradeSide, outcomeIndex=$outcomeIndex, outcome=$outcome") return "YES" // 默认返回第一个 outcome diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingFilterService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingFilterService.kt new file mode 100644 index 0000000..d551cb0 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingFilterService.kt @@ -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 { + // 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 { + // 如果未启用价差过滤,直接通过 + 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 { + // 如果未启用订单深度过滤,直接通过 + 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 { + // 如果未启用最小订单簿深度过滤,直接通过 + 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, "") + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingService.kt index 129e202..c257090 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingService.kt @@ -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 { @@ -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 { + 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 { + return updateCopyTrading( + CopyTradingUpdateRequest( + copyTradingId = request.copyTradingId, + enabled = request.enabled + ) + ) + } + /** * 查询跟单列表 */ fun getCopyTradingList(request: CopyTradingListRequest): Result { 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 { - 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 { 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 { 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? + ) } - diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingStatisticsService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingStatisticsService.kt index 8059515..8cfa339 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingStatisticsService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingStatisticsService.kt @@ -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, diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingTemplateService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingTemplateService.kt index 84bc938..0671af9 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingTemplateService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingTemplateService.kt @@ -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 ) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/FilteredOrderService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/FilteredOrderService.kt new file mode 100644 index 0000000..bbabbde --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/FilteredOrderService.kt @@ -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) + } + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/TelegramNotificationService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/TelegramNotificationService.kt index 7e631d4..d13230e 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/TelegramNotificationService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/TelegramNotificationService.kt @@ -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) { + "$escapedMarketTitle" + } else { + escapedMarketTitle + } + + // 显示市场方向(outcome) + val outcomeDisplay = if (!outcome.isNullOrBlank()) { + val escapedOutcome = outcome.replace("<", "<").replace(">", ">") + "\n• $outcomeLabel: $escapedOutcome" + } else { + "" + } + + return """🚫 $orderFiltered + +📊 $orderInfo: +• $marketLabel: $marketDisplay$outcomeDisplay +• $sideLabel: $sideDisplay +• $priceLabel: $price +• $quantityLabel: $size shares +• $amountLabel: $amountDisplay USDC +• $accountLabel: $escapedAccountInfo + +⚠️ $filterTypeLabel: $filterTypeDisplay + +📝 $filterReasonLabel: +$escapedFilterReason + +⏰ $timeLabel: $time""" + } + /** * 发送测试消息 */ diff --git a/backend/src/main/resources/db/migration/V4__refactor_copy_trading.sql b/backend/src/main/resources/db/migration/V4__refactor_copy_trading.sql new file mode 100644 index 0000000..7327830 --- /dev/null +++ b/backend/src/main/resources/db/migration/V4__refactor_copy_trading.sql @@ -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='跟单配置表(独立配置,不再绑定模板)'; + diff --git a/backend/src/main/resources/db/migration/V5__add_filtered_order_table.sql b/backend/src/main/resources/db/migration/V5__add_filtered_order_table.sql new file mode 100644 index 0000000..c284e99 --- /dev/null +++ b/backend/src/main/resources/db/migration/V5__add_filtered_order_table.sql @@ -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='被过滤订单表'; + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1ba5481..dc05001 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -24,10 +24,12 @@ import TemplateAdd from './pages/TemplateAdd' import TemplateEdit from './pages/TemplateEdit' import CopyTradingList from './pages/CopyTradingList' import CopyTradingAdd from './pages/CopyTradingAdd' +import CopyTradingEdit from './pages/CopyTradingEdit' import CopyTradingStatistics from './pages/CopyTradingStatistics' import CopyTradingBuyOrders from './pages/CopyTradingBuyOrders' import CopyTradingSellOrders from './pages/CopyTradingSellOrders' import CopyTradingMatchedOrders from './pages/CopyTradingMatchedOrders' +import FilteredOrdersList from './pages/FilteredOrdersList' import SystemSettings from './pages/SystemSettings' import LanguageSettings from './pages/LanguageSettings' import ApiHealthStatus from './pages/ApiHealthStatus' @@ -236,10 +238,12 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index ba86045..b6bcbdf 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -16,7 +16,17 @@ "reset": "Reset", "close": "Close", "yes": "Yes", - "no": "No" + "no": "No", + "actions": "Actions", + "all": "All", + "createdAt": "Created At", + "updatedAt": "Updated At", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "noData": "No Data", + "saveConfig": "Save Config", + "refreshConfig": "Refresh Config" }, "account": { "title": "Account Management", @@ -54,6 +64,10 @@ "accountIdRequired": "Account ID cannot be empty" }, "message": { + "success": "Operation successful", + "error": "Operation failed", + "loading": "Loading...", + "noData": "No Data", "loginSuccess": "Login successful", "loginFailed": "Login failed", "createUserSuccess": "User created successfully", @@ -65,11 +79,13 @@ "title": "Login", "username": "Username", "password": "Password", - "usernamePlaceholder": "Username", - "passwordPlaceholder": "Password", "usernameRequired": "Please enter username", "passwordRequired": "Please enter password", - "forgotPassword": "Forgot password? Reset password" + "usernamePlaceholder": "Please enter username", + "passwordPlaceholder": "Please enter password", + "forgotPassword": "Forgot password?", + "loginFailed": "Login failed", + "loginSuccess": "Login successful" }, "order": { "create": "Order Created", @@ -85,6 +101,8 @@ }, "accountList": { "title": "Account Management", + "addAccount": "Add Account", + "actions": "Actions", "importAccount": "Import Account", "accountName": "Account Name", "walletAddress": "Wallet Address", @@ -138,7 +156,8 @@ "updateSuccess": "Account updated successfully", "updateFailed": "Failed to update account", "getDetailFailedForEdit": "Failed to get account detail", - "loading": "Loading..." + "loading": "Loading...", + "fetchFailed": "Failed to get account list" }, "accountImport": { "title": "Import Account", @@ -163,7 +182,13 @@ "mnemonicInvalid": "Invalid mnemonic format (should be 12 or 24 words, space-separated)", "walletAddressMismatchMnemonic": "Wallet address does not match mnemonic", "accountName": "Account Name", + "accountNameRequired": "Please enter account name", "accountNamePlaceholder": "Optional, for identifying account", + "accountNameHelp": "Used to identify account for management", + "privateKeyHelp": "Private key will be encrypted and stored, only used for signing transactions", + "submit": "Import", + "invalidPrivateKey": "Invalid private key", + "duplicateAccount": "Account already exists", "importAccount": "Import Account", "importSuccess": "Account imported successfully", "importFailed": "Failed to import account", @@ -174,6 +199,7 @@ "leader": { "title": "Leader Management", "leaderName": "Leader Name", + "leaderAddress": "Wallet Address", "walletAddress": "Wallet Address", "category": "Category", "all": "All", @@ -181,8 +207,11 @@ "createdAt": "Created At", "action": "Actions", "add": "Add", + "addLeader": "Add Leader", "edit": "Edit", + "editLeader": "Edit Leader", "delete": "Delete", + "deleteLeader": "Delete Leader", "listFailed": "Failed to get Leader list", "deleteSuccess": "Leader deleted successfully", "deleteFailed": "Failed to delete Leader", @@ -220,10 +249,16 @@ }, "apiHealthStatus": { "title": "API Health Status", + "checkFailed": "Check Failed", + "status": "Status", + "responseTime": "Response Time", + "lastCheck": "Last Check", + "healthy": "Healthy", + "unhealthy": "Unhealthy", + "unknown": "Unknown", "normal": "Normal", "notConfigured": "Not Configured", - "abnormal": "Abnormal", - "responseTime": "Response Time" + "abnormal": "Abnormal" }, "proxySettings": { "title": "Proxy Settings", @@ -403,6 +438,248 @@ "invalidNumber": "Please enter a valid number", "fixedAmountError": "Fixed amount must be >= 1, please re-enter" }, + "templateAdd": { + "title": "Create Copy Trading Template", + "back": "Back", + "templateName": "Template Name", + "templateNamePlaceholder": "Please enter template name", + "templateNameRequired": "Please enter template name", + "templateNameTooltip": "Unique identifier name for the template, used to distinguish different copy trading configuration templates. Template name must be unique and cannot duplicate other templates.", + "copyMode": "Copy Amount Mode", + "copyModeTooltip": "Select the calculation method for copy amount. Ratio mode: copy amount changes proportionally with Leader order size; Fixed amount mode: copy amount remains fixed regardless of Leader order size.", + "ratioMode": "Ratio Mode", + "fixedAmountMode": "Fixed Amount Mode", + "copyRatio": "Copy Ratio", + "copyRatioTooltip": "Copy ratio represents the percentage of copy amount relative to Leader order amount. For example: 100% means 1:1 copy, 50% means half position copy, 200% means double copy", + "copyRatioPlaceholder": "For example: 100 means 100% (1:1 copy), default 100%", + "fixedAmount": "Fixed Copy Amount (USDC)", + "fixedAmountPlaceholder": "Fixed amount, does not change with Leader order size, must be >= 1", + "fixedAmountRequired": "Please enter fixed copy amount", + "fixedAmountError": "Fixed amount must be >= 1, please re-enter", + "maxOrderSize": "Max Order Size (USDC)", + "maxOrderSizeTooltip": "In ratio mode, limits the maximum amount cap for single copy order, used to prevent excessive copy amount and control risk. For example: set to 1000, even if calculated copy amount exceeds 1000, it will be limited to 1000 USDC.", + "maxOrderSizePlaceholder": "Only effective in ratio mode (optional)", + "minOrderSize": "Min Order Size (USDC)", + "minOrderSizeTooltip": "In ratio mode, limits the minimum amount floor for single copy order, used to filter out orders with too small amounts, avoiding frequent small trades. If filled, must be >= 1 USDC. For example: set to 10, if calculated copy amount is less than 10, skip this order.", + "minOrderSizePlaceholder": "Only effective in ratio mode, must be >= 1 (optional)", + "minOrderSizeError": "Minimum amount must be >= 1", + "maxDailyOrders": "Max Daily Copy Orders", + "maxDailyOrdersTooltip": "Limits the maximum number of copy orders per day, used for risk control and preventing overtrading. For example: set to 50, when daily copy orders reach 50, stop copying, reset next day.", + "maxDailyOrdersPlaceholder": "Default 100 (optional)", + "priceTolerance": "Price Tolerance (%)", + "priceToleranceTooltip": "Allowed adjustment range for copy price based on Leader price, used to adjust price within Leader price ± tolerance range to improve fill rate. For example: set to 5%, Leader price is 0.5, then copy price can be in 0.475-0.525 range.", + "priceTolerancePlaceholder": "Default 5% (optional)", + "minOrderDepth": "Min Order Depth (USDC)", + "minOrderDepthTooltip": "Minimum order depth (USDC amount), NULL means this filter is not enabled. Ensures market has sufficient liquidity", + "minOrderDepthPlaceholder": "For example: 100 (optional, leave empty to disable)", + "maxSpread": "Max Spread (Absolute Price)", + "maxSpreadTooltip": "Maximum spread (absolute price), NULL means this filter is not enabled. Avoid copying in markets with excessive spreads", + "maxSpreadPlaceholder": "For example: 0.05 (5 cents, optional, leave empty to disable)", + "minOrderbookDepth": "Min Orderbook Depth (USDC)", + "minOrderbookDepthTooltip": "Minimum orderbook depth (USDC amount), NULL means this filter is not enabled. Check depth of first N levels", + "minOrderbookDepthPlaceholder": "For example: 50 (optional, leave empty to disable)", + "supportSell": "Support Sell", + "supportSellTooltip": "Whether to copy Leader's sell orders. Enabled: copy both Leader's buy and sell orders; Disabled: only copy Leader's buy orders, ignore sell orders.", + "create": "Create Template", + "createSuccess": "Template created successfully", + "createFailed": "Failed to create template", + "invalidNumber": "Please enter a valid number" + }, + "templateEdit": { + "title": "Edit Copy Trading Template", + "back": "Back", + "save": "Save Changes", + "saveSuccess": "Template updated successfully", + "saveFailed": "Failed to update template", + "fetchFailed": "Failed to get template details", + "templateName": "Template Name", + "templateNamePlaceholder": "Please enter template name", + "templateNameRequired": "Please enter template name", + "templateNameTooltip": "Unique identifier name for the template, used to distinguish different copy trading configuration templates. Template name must be unique and cannot duplicate other templates.", + "copyMode": "Copy Amount Mode", + "copyModeTooltip": "Select the calculation method for copy amount. Ratio mode: copy amount changes proportionally with Leader order size; Fixed amount mode: copy amount remains fixed regardless of Leader order size.", + "ratioMode": "Ratio Mode", + "fixedAmountMode": "Fixed Amount Mode", + "copyRatio": "Copy Ratio", + "copyRatioTooltip": "Copy ratio represents the percentage of copy amount relative to Leader order amount. For example: 100% means 1:1 copy, 50% means half position copy, 200% means double copy", + "copyRatioPlaceholder": "For example: 100 means 100% (1:1 copy), default 100%", + "fixedAmount": "Fixed Copy Amount (USDC)", + "fixedAmountPlaceholder": "Fixed amount, does not change with Leader order size, must be >= 1", + "fixedAmountRequired": "Please enter fixed copy amount", + "fixedAmountError": "Fixed amount must be >= 1, please re-enter", + "fixedAmountTooltip": "In fixed amount mode, the fixed amount for each copy, does not change with Leader order size. Must be >= 1 USDC. For example: set to 10, then regardless of how much Leader buys, copy amount is always 10 USDC.", + "maxOrderSize": "Max Order Size (USDC)", + "maxOrderSizeTooltip": "In ratio mode, limits the maximum amount cap for single copy order, used to prevent excessive copy amount and control risk. For example: set to 1000, even if calculated copy amount exceeds 1000, it will be limited to 1000 USDC.", + "maxOrderSizePlaceholder": "Only effective in ratio mode (optional)", + "minOrderSize": "Min Order Size (USDC)", + "minOrderSizeTooltip": "In ratio mode, limits the minimum amount floor for single copy order, used to filter out orders with too small amounts, avoiding frequent small trades. If filled, must be >= 1 USDC. For example: set to 10, if calculated copy amount is less than 10, skip this order.", + "minOrderSizePlaceholder": "Only effective in ratio mode, must be >= 1 (optional)", + "minOrderSizeError": "Minimum amount must be >= 1", + "maxDailyOrders": "Max Daily Copy Orders", + "maxDailyOrdersTooltip": "Limits the maximum number of copy orders per day, used for risk control and preventing overtrading. For example: set to 50, when daily copy orders reach 50, stop copying, reset next day.", + "maxDailyOrdersPlaceholder": "Default 100 (optional)", + "priceTolerance": "Price Tolerance (%)", + "priceToleranceTooltip": "Allowed adjustment range for copy price based on Leader price, used to adjust price within Leader price ± tolerance range to improve fill rate. For example: set to 5%, Leader price is 0.5, then copy price can be in 0.475-0.525 range.", + "priceTolerancePlaceholder": "Default 5% (optional)", + "minOrderDepth": "Min Order Depth (USDC)", + "minOrderDepthTooltip": "Minimum order depth (USDC amount), NULL means this filter is not enabled. Ensures market has sufficient liquidity", + "minOrderDepthPlaceholder": "For example: 100 (optional, leave empty to disable)", + "maxSpread": "Max Spread (Absolute Price)", + "maxSpreadTooltip": "Maximum spread (absolute price), NULL means this filter is not enabled. Avoid copying in markets with excessive spreads", + "maxSpreadPlaceholder": "For example: 0.05 (5 cents, optional, leave empty to disable)", + "minOrderbookDepth": "Min Orderbook Depth (USDC)", + "minOrderbookDepthTooltip": "Minimum orderbook depth (USDC amount), NULL means this filter is not enabled. Check depth of first N levels", + "minOrderbookDepthPlaceholder": "For example: 50 (optional, leave empty to disable)", + "supportSell": "Support Sell", + "supportSellTooltip": "Whether to copy Leader's sell orders. Enabled: copy both Leader's buy and sell orders; Disabled: only copy Leader's buy orders, ignore sell orders.", + "invalidNumber": "Please enter a valid number" + }, + "copyTradingAdd": { + "title": "Add Copy Trading Config", + "back": "Back", + "selectWallet": "Select Wallet", + "selectWalletPlaceholder": "Please select wallet", + "walletRequired": "Please select wallet", + "selectLeader": "Select Leader", + "selectLeaderPlaceholder": "Please select Leader", + "leaderRequired": "Please select Leader", + "enabled": "Enabled Status", + "selectTemplate": "Select Template", + "selectTemplateFromModal": "Fill Config from Template", + "templateFilled": "Template content filled, you can modify", + "basicConfig": "Basic Config", + "copyMode": "Copy Amount Mode", + "copyModeTooltip": "Select the calculation method for copy amount. Ratio mode: copy amount changes proportionally with Leader order size; Fixed amount mode: copy amount remains fixed regardless of Leader order size.", + "ratioMode": "Ratio Mode", + "fixedAmountMode": "Fixed Amount Mode", + "ratio": "Ratio", + "fixed": "Fixed", + "copyRatio": "Copy Ratio", + "copyRatioTooltip": "Copy ratio represents the percentage of copy amount relative to Leader order amount. For example: 100% means 1:1 copy, 50% means half position copy, 200% means double copy", + "copyRatioPlaceholder": "For example: 100 means 100% (1:1 copy), default 100%", + "fixedAmount": "Fixed Copy Amount (USDC)", + "fixedAmountPlaceholder": "Fixed amount, does not change with Leader order size, must be >= 1", + "fixedAmountRequired": "Please enter fixed copy amount", + "fixedAmountMin": "Fixed amount must be >= 1", + "maxOrderSize": "Max Order Size (USDC)", + "maxOrderSizeTooltip": "In ratio mode, limits the maximum amount cap for single copy order", + "maxOrderSizePlaceholder": "Only effective in ratio mode (optional)", + "minOrderSize": "Min Order Size (USDC)", + "minOrderSizeTooltip": "In ratio mode, limits the minimum amount floor for single copy order, must be >= 1", + "minOrderSizePlaceholder": "Only effective in ratio mode, must be >= 1 (optional)", + "minOrderSizeMin": "Minimum amount must be >= 1", + "maxDailyLoss": "Max Daily Loss Limit (USDC)", + "maxDailyLossTooltip": "Limits the maximum daily loss amount, used for risk control", + "maxDailyLossPlaceholder": "Default 10000 USDC (optional)", + "maxDailyOrders": "Max Daily Copy Orders", + "maxDailyOrdersTooltip": "Limits the maximum number of copy orders per day", + "maxDailyOrdersPlaceholder": "Default 100 (optional)", + "priceTolerance": "Price Tolerance (%)", + "priceToleranceTooltip": "Allowed adjustment range for copy price based on Leader price", + "priceTolerancePlaceholder": "Default 5% (optional)", + "delaySeconds": "Copy Delay (seconds)", + "delaySecondsTooltip": "Copy delay time, 0 means copy immediately", + "delaySecondsPlaceholder": "Default 0 (copy immediately)", + "filterConditions": "Filter Conditions (Optional)", + "minOrderDepth": "Min Order Depth (USDC)", + "minOrderDepthTooltip": "Minimum order depth (USDC amount), NULL means this filter is not enabled. Ensures market has sufficient liquidity", + "minOrderDepthPlaceholder": "For example: 100 (optional, leave empty to disable)", + "maxSpread": "Max Spread (Absolute Price)", + "maxSpreadTooltip": "Maximum spread (absolute price), NULL means this filter is not enabled. Avoid copying in markets with excessive spreads", + "maxSpreadPlaceholder": "For example: 0.05 (5 cents, optional, leave empty to disable)", + "minOrderbookDepth": "Min Orderbook Depth (USDC)", + "minOrderbookDepthTooltip": "Minimum orderbook depth (USDC amount), NULL means this filter is not enabled. Check depth of first N levels", + "minOrderbookDepthPlaceholder": "For example: 50 (optional, leave empty to disable)", + "supportSell": "Support Sell", + "supportSellTooltip": "Whether to copy Leader's sell orders", + "create": "Create Copy Trading Config", + "createSuccess": "Copy trading config created successfully", + "createFailed": "Failed to create copy trading config", + "invalidNumber": "Please enter a valid number", + "fetchLeaderFailed": "Failed to get Leader list", + "fetchTemplateFailed": "Failed to get template list", + "templateName": "Template Name" + }, + "copyTradingEdit": { + "title": "Edit Copy Trading Config", + "back": "Back", + "wallet": "Wallet", + "leader": "Leader", + "selectWallet": "Wallet", + "selectLeader": "Leader", + "basicConfig": "Basic Config", + "copyMode": "Copy Amount Mode", + "copyModeTooltip": "Select the calculation method for copy amount", + "ratioMode": "Ratio Mode", + "fixedAmountMode": "Fixed Amount Mode", + "copyRatio": "Copy Ratio", + "copyRatioTooltip": "Copy ratio represents the percentage of copy amount relative to Leader order amount", + "copyRatioPlaceholder": "For example: 100 means 100% (1:1 copy)", + "fixedAmount": "Fixed Copy Amount (USDC)", + "fixedAmountPlaceholder": "Fixed amount, does not change with Leader order size, must be >= 1", + "fixedAmountRequired": "Please enter fixed copy amount", + "fixedAmountMin": "Fixed amount must be >= 1", + "maxOrderSize": "Max Order Size (USDC)", + "maxOrderSizeTooltip": "In ratio mode, limits the maximum amount cap for single copy order", + "maxOrderSizePlaceholder": "Only effective in ratio mode (optional)", + "minOrderSize": "Min Order Size (USDC)", + "minOrderSizeTooltip": "In ratio mode, limits the minimum amount floor for single copy order, must be >= 1", + "minOrderSizePlaceholder": "Only effective in ratio mode, must be >= 1 (optional)", + "minOrderSizeMin": "Minimum amount must be >= 1", + "maxDailyLoss": "Max Daily Loss Limit (USDC)", + "maxDailyLossTooltip": "Limits the maximum daily loss amount, used for risk control", + "maxDailyLossPlaceholder": "Default 10000 USDC (optional)", + "maxDailyOrders": "Max Daily Copy Orders", + "maxDailyOrdersTooltip": "Limits the maximum number of copy orders per day", + "maxDailyOrdersPlaceholder": "Default 100 (optional)", + "priceTolerance": "Price Tolerance (%)", + "priceToleranceTooltip": "Allowed adjustment range for copy price based on Leader price", + "priceTolerancePlaceholder": "Default 5% (optional)", + "delaySeconds": "Copy Delay (seconds)", + "delaySecondsTooltip": "Copy delay time, 0 means copy immediately", + "delaySecondsPlaceholder": "Default 0 (copy immediately)", + "filterConditions": "Filter Conditions (Optional)", + "minOrderDepth": "Min Order Depth (USDC)", + "minOrderDepthTooltip": "Minimum order depth (USDC amount), NULL means this filter is not enabled", + "minOrderDepthPlaceholder": "For example: 100 (optional, leave empty to disable)", + "maxSpread": "Max Spread (Absolute Price)", + "maxSpreadTooltip": "Maximum spread (absolute price), NULL means this filter is not enabled", + "maxSpreadPlaceholder": "For example: 0.05 (5 cents, optional, leave empty to disable)", + "minOrderbookDepth": "Min Orderbook Depth (USDC)", + "minOrderbookDepthTooltip": "Minimum orderbook depth (USDC amount), NULL means this filter is not enabled", + "minOrderbookDepthPlaceholder": "For example: 50 (optional, leave empty to disable)", + "supportSell": "Support Sell", + "supportSellTooltip": "Whether to copy Leader's sell orders", + "save": "Save", + "saveSuccess": "Copy trading config updated successfully", + "saveFailed": "Failed to update copy trading config", + "fetchFailed": "Failed to get copy trading config", + "invalidNumber": "Please enter a valid number" + }, + "filteredOrdersList": { + "title": "Filtered Orders List", + "fetchFailed": "Failed to fetch filtered orders list", + "market": "Market", + "outcome": "Outcome", + "price": "Price", + "size": "Size", + "side": "Order Side", + "calculatedQuantity": "Calculated Quantity", + "filterType": "Filter Type", + "filterReason": "Filter Reason", + "createdAt": "Time", + "filterByType": "Filter by Type", + "filterTypes": { + "orderDepth": "Insufficient Order Depth", + "spread": "Spread Too Large", + "orderbookDepth": "Insufficient Orderbook Depth", + "priceValidity": "Invalid Price", + "marketStatus": "Market Not Tradable", + "orderbookError": "Orderbook Fetch Failed", + "orderbookEmpty": "Orderbook Empty", + "unknown": "Unknown Reason" + } + }, "copyTradingList": { "title": "Copy Trading Config Management", "addCopyTrading": "Add Copy Trading", @@ -410,6 +687,9 @@ "account": "Account", "template": "Template", "leader": "Leader", + "copyMode": "Copy Mode", + "ratioMode": "Ratio", + "fixedAmountMode": "Fixed", "enabled": "Enabled", "disabled": "Disabled", "totalPnl": "Total P&L", @@ -419,6 +699,7 @@ "buyOrders": "Buy Orders", "sellOrders": "Sell Orders", "matchedOrders": "Matched Orders", + "filteredOrders": "Filtered Orders", "filterWallet": "Filter Wallet", "filterTemplate": "Filter Template", "filterLeader": "Filter Leader", diff --git a/frontend/src/locales/zh-CN/common.json b/frontend/src/locales/zh-CN/common.json index afa1e00..59696a3 100644 --- a/frontend/src/locales/zh-CN/common.json +++ b/frontend/src/locales/zh-CN/common.json @@ -1,5 +1,6 @@ { "common": { + "back": "返回", "save": "保存", "cancel": "取消", "confirm": "确定", @@ -349,6 +350,248 @@ "invalidNumber": "请输入有效的数字", "fixedAmountError": "固定金额必须 >= 1,请重新输入" }, + "templateAdd": { + "title": "创建跟单模板", + "back": "返回", + "templateName": "模板名称", + "templateNamePlaceholder": "请输入模板名称", + "templateNameRequired": "请输入模板名称", + "templateNameTooltip": "模板的唯一标识名称,用于区分不同的跟单配置模板。模板名称必须唯一,不能与其他模板重名。", + "copyMode": "跟单金额模式", + "copyModeTooltip": "选择跟单金额的计算方式。比例模式:跟单金额随 Leader 订单大小按比例变化;固定金额模式:无论 Leader 订单大小如何,跟单金额都固定不变。", + "ratioMode": "比例模式", + "fixedAmountMode": "固定金额模式", + "copyRatio": "跟单比例", + "copyRatioTooltip": "跟单比例表示跟单金额相对于 Leader 订单金额的百分比。例如:100% 表示 1:1 跟单,50% 表示半仓跟单,200% 表示双倍跟单", + "copyRatioPlaceholder": "例如:100 表示 100%(1:1 跟单),默认 100%", + "fixedAmount": "固定跟单金额 (USDC)", + "fixedAmountPlaceholder": "固定金额,不随 Leader 订单大小变化,必须 >= 1", + "fixedAmountRequired": "请输入固定跟单金额", + "fixedAmountError": "固定金额必须 >= 1,请重新输入", + "maxOrderSize": "单笔订单最大金额 (USDC)", + "maxOrderSizeTooltip": "比例模式下,限制单笔跟单订单的最大金额上限,用于防止跟单金额过大,控制风险。例如:设置为 1000,即使计算出的跟单金额超过 1000,也会限制为 1000 USDC。", + "maxOrderSizePlaceholder": "仅在比例模式下生效(可选)", + "minOrderSize": "单笔订单最小金额 (USDC)", + "minOrderSizeTooltip": "比例模式下,限制单笔跟单订单的最小金额下限,用于过滤掉金额过小的订单,避免频繁小额交易。如果填写,必须 >= 1 USDC。例如:设置为 10,如果计算出的跟单金额小于 10,则跳过该订单。", + "minOrderSizePlaceholder": "仅在比例模式下生效,必须 >= 1(可选)", + "minOrderSizeError": "最小金额必须 >= 1", + "maxDailyOrders": "每日最大跟单订单数", + "maxDailyOrdersTooltip": "限制每日最多跟单的订单数量,用于风险控制,防止过度交易。例如:设置为 50,当日跟单订单数达到 50 后,停止跟单,次日重置。", + "maxDailyOrdersPlaceholder": "默认 100(可选)", + "priceTolerance": "价格容忍度 (%)", + "priceToleranceTooltip": "允许跟单价格在 Leader 价格基础上的调整范围,用于在 Leader 价格 ± 容忍度范围内调整价格,提高成交率。例如:设置为 5%,Leader 价格为 0.5,则跟单价格可在 0.475-0.525 范围内。", + "priceTolerancePlaceholder": "默认 5%(可选)", + "minOrderDepth": "最小订单深度 (USDC)", + "minOrderDepthTooltip": "最小订单深度(USDC金额),NULL表示不启用此过滤。确保市场有足够的流动性", + "minOrderDepthPlaceholder": "例如:100(可选,不填写表示不启用)", + "maxSpread": "最大价差(绝对价格)", + "maxSpreadTooltip": "最大价差(绝对价格),NULL表示不启用此过滤。避免在价差过大的市场跟单", + "maxSpreadPlaceholder": "例如:0.05(5美分,可选,不填写表示不启用)", + "minOrderbookDepth": "最小订单簿深度 (USDC)", + "minOrderbookDepthTooltip": "最小订单簿深度(USDC金额),NULL表示不启用此过滤。检查前 N 档的深度", + "minOrderbookDepthPlaceholder": "例如:50(可选,不填写表示不启用)", + "supportSell": "跟单卖出", + "supportSellTooltip": "是否跟单 Leader 的卖出订单。开启:跟单 Leader 的买入和卖出订单;关闭:只跟单 Leader 的买入订单,忽略卖出订单。", + "create": "创建模板", + "createSuccess": "创建模板成功", + "createFailed": "创建模板失败", + "invalidNumber": "请输入有效的数字" + }, + "templateEdit": { + "title": "编辑跟单模板", + "back": "返回", + "save": "保存修改", + "saveSuccess": "更新模板成功", + "saveFailed": "更新模板失败", + "fetchFailed": "获取模板详情失败", + "templateName": "模板名称", + "templateNamePlaceholder": "请输入模板名称", + "templateNameRequired": "请输入模板名称", + "templateNameTooltip": "模板的唯一标识名称,用于区分不同的跟单配置模板。模板名称必须唯一,不能与其他模板重名。", + "copyMode": "跟单金额模式", + "copyModeTooltip": "选择跟单金额的计算方式。比例模式:跟单金额随 Leader 订单大小按比例变化;固定金额模式:无论 Leader 订单大小如何,跟单金额都固定不变。", + "ratioMode": "比例模式", + "fixedAmountMode": "固定金额模式", + "copyRatio": "跟单比例", + "copyRatioTooltip": "跟单比例表示跟单金额相对于 Leader 订单金额的百分比。例如:100% 表示 1:1 跟单,50% 表示半仓跟单,200% 表示双倍跟单", + "copyRatioPlaceholder": "例如:100 表示 100%(1:1 跟单),默认 100%", + "fixedAmount": "固定跟单金额 (USDC)", + "fixedAmountPlaceholder": "固定金额,不随 Leader 订单大小变化,必须 >= 1", + "fixedAmountRequired": "请输入固定跟单金额", + "fixedAmountError": "固定金额必须 >= 1,请重新输入", + "fixedAmountTooltip": "固定金额模式下,每次跟单的固定金额,不随 Leader 订单大小变化。必须 >= 1 USDC。例如:设置为 10,则无论 Leader 买入多少,跟单金额始终为 10 USDC。", + "maxOrderSize": "单笔订单最大金额 (USDC)", + "maxOrderSizeTooltip": "比例模式下,限制单笔跟单订单的最大金额上限,用于防止跟单金额过大,控制风险。例如:设置为 1000,即使计算出的跟单金额超过 1000,也会限制为 1000 USDC。", + "maxOrderSizePlaceholder": "仅在比例模式下生效(可选)", + "minOrderSize": "单笔订单最小金额 (USDC)", + "minOrderSizeTooltip": "比例模式下,限制单笔跟单订单的最小金额下限,用于过滤掉金额过小的订单,避免频繁小额交易。如果填写,必须 >= 1 USDC。例如:设置为 10,如果计算出的跟单金额小于 10,则跳过该订单。", + "minOrderSizePlaceholder": "仅在比例模式下生效,必须 >= 1(可选)", + "minOrderSizeError": "最小金额必须 >= 1", + "maxDailyOrders": "每日最大跟单订单数", + "maxDailyOrdersTooltip": "限制每日最多跟单的订单数量,用于风险控制,防止过度交易。例如:设置为 50,当日跟单订单数达到 50 后,停止跟单,次日重置。", + "maxDailyOrdersPlaceholder": "默认 100(可选)", + "priceTolerance": "价格容忍度 (%)", + "priceToleranceTooltip": "允许跟单价格在 Leader 价格基础上的调整范围,用于在 Leader 价格 ± 容忍度范围内调整价格,提高成交率。例如:设置为 5%,Leader 价格为 0.5,则跟单价格可在 0.475-0.525 范围内。", + "priceTolerancePlaceholder": "默认 5%(可选)", + "minOrderDepth": "最小订单深度 (USDC)", + "minOrderDepthTooltip": "最小订单深度(USDC金额),NULL表示不启用此过滤。确保市场有足够的流动性", + "minOrderDepthPlaceholder": "例如:100(可选,不填写表示不启用)", + "maxSpread": "最大价差(绝对价格)", + "maxSpreadTooltip": "最大价差(绝对价格),NULL表示不启用此过滤。避免在价差过大的市场跟单", + "maxSpreadPlaceholder": "例如:0.05(5美分,可选,不填写表示不启用)", + "minOrderbookDepth": "最小订单簿深度 (USDC)", + "minOrderbookDepthTooltip": "最小订单簿深度(USDC金额),NULL表示不启用此过滤。检查前 N 档的深度", + "minOrderbookDepthPlaceholder": "例如:50(可选,不填写表示不启用)", + "supportSell": "跟单卖出", + "supportSellTooltip": "是否跟单 Leader 的卖出订单。开启:跟单 Leader 的买入和卖出订单;关闭:只跟单 Leader 的买入订单,忽略卖出订单。", + "invalidNumber": "请输入有效的数字" + }, + "copyTradingAdd": { + "title": "新增跟单配置", + "back": "返回", + "selectWallet": "选择钱包", + "selectWalletPlaceholder": "请选择钱包", + "walletRequired": "请选择钱包", + "selectLeader": "选择 Leader", + "selectLeaderPlaceholder": "请选择 Leader", + "leaderRequired": "请选择 Leader", + "enabled": "启用状态", + "selectTemplate": "选择模板", + "selectTemplateFromModal": "从模板填充配置", + "templateFilled": "模板内容已填充,您可以修改", + "basicConfig": "基础配置", + "copyMode": "跟单金额模式", + "copyModeTooltip": "选择跟单金额的计算方式。比例模式:跟单金额随 Leader 订单大小按比例变化;固定金额模式:无论 Leader 订单大小如何,跟单金额都固定不变。", + "ratioMode": "比例模式", + "fixedAmountMode": "固定金额模式", + "ratio": "比例", + "fixed": "固定", + "copyRatio": "跟单比例", + "copyRatioTooltip": "跟单比例表示跟单金额相对于 Leader 订单金额的百分比。例如:100% 表示 1:1 跟单,50% 表示半仓跟单,200% 表示双倍跟单", + "copyRatioPlaceholder": "例如:100 表示 100%(1:1 跟单),默认 100%", + "fixedAmount": "固定跟单金额 (USDC)", + "fixedAmountPlaceholder": "固定金额,不随 Leader 订单大小变化,必须 >= 1", + "fixedAmountRequired": "请输入固定跟单金额", + "fixedAmountMin": "固定金额必须 >= 1", + "maxOrderSize": "单笔订单最大金额 (USDC)", + "maxOrderSizeTooltip": "比例模式下,限制单笔跟单订单的最大金额上限", + "maxOrderSizePlaceholder": "仅在比例模式下生效(可选)", + "minOrderSize": "单笔订单最小金额 (USDC)", + "minOrderSizeTooltip": "比例模式下,限制单笔跟单订单的最小金额下限,必须 >= 1", + "minOrderSizePlaceholder": "仅在比例模式下生效,必须 >= 1(可选)", + "minOrderSizeMin": "最小金额必须 >= 1", + "maxDailyLoss": "每日最大亏损限制 (USDC)", + "maxDailyLossTooltip": "限制每日最大亏损金额,用于风险控制", + "maxDailyLossPlaceholder": "默认 10000 USDC(可选)", + "maxDailyOrders": "每日最大跟单订单数", + "maxDailyOrdersTooltip": "限制每日最多跟单的订单数量", + "maxDailyOrdersPlaceholder": "默认 100(可选)", + "priceTolerance": "价格容忍度 (%)", + "priceToleranceTooltip": "允许跟单价格在 Leader 价格基础上的调整范围", + "priceTolerancePlaceholder": "默认 5%(可选)", + "delaySeconds": "跟单延迟 (秒)", + "delaySecondsTooltip": "跟单延迟时间,0 表示立即跟单", + "delaySecondsPlaceholder": "默认 0(立即跟单)", + "filterConditions": "过滤条件(可选)", + "minOrderDepth": "最小订单深度 (USDC)", + "minOrderDepthTooltip": "最小订单深度(USDC金额),NULL表示不启用此过滤。确保市场有足够的流动性", + "minOrderDepthPlaceholder": "例如:100(可选,不填写表示不启用)", + "maxSpread": "最大价差(绝对价格)", + "maxSpreadTooltip": "最大价差(绝对价格),NULL表示不启用此过滤。避免在价差过大的市场跟单", + "maxSpreadPlaceholder": "例如:0.05(5美分,可选,不填写表示不启用)", + "minOrderbookDepth": "最小订单簿深度 (USDC)", + "minOrderbookDepthTooltip": "最小订单簿深度(USDC金额),NULL表示不启用此过滤。检查前 N 档的深度", + "minOrderbookDepthPlaceholder": "例如:50(可选,不填写表示不启用)", + "supportSell": "跟单卖出", + "supportSellTooltip": "是否跟单 Leader 的卖出订单", + "create": "创建跟单配置", + "createSuccess": "创建跟单配置成功", + "createFailed": "创建跟单配置失败", + "invalidNumber": "请输入有效的数字", + "fetchLeaderFailed": "获取 Leader 列表失败", + "fetchTemplateFailed": "获取模板列表失败", + "templateName": "模板名称" + }, + "copyTradingEdit": { + "title": "编辑跟单配置", + "back": "返回", + "wallet": "钱包", + "leader": "Leader", + "selectWallet": "钱包", + "selectLeader": "Leader", + "basicConfig": "基础配置", + "copyMode": "跟单金额模式", + "copyModeTooltip": "选择跟单金额的计算方式", + "ratioMode": "比例模式", + "fixedAmountMode": "固定金额模式", + "copyRatio": "跟单比例", + "copyRatioTooltip": "跟单比例表示跟单金额相对于 Leader 订单金额的百分比", + "copyRatioPlaceholder": "例如:100 表示 100%(1:1 跟单)", + "fixedAmount": "固定跟单金额 (USDC)", + "fixedAmountPlaceholder": "固定金额,不随 Leader 订单大小变化,必须 >= 1", + "fixedAmountRequired": "请输入固定跟单金额", + "fixedAmountMin": "固定金额必须 >= 1", + "maxOrderSize": "单笔订单最大金额 (USDC)", + "maxOrderSizeTooltip": "比例模式下,限制单笔跟单订单的最大金额上限", + "maxOrderSizePlaceholder": "仅在比例模式下生效(可选)", + "minOrderSize": "单笔订单最小金额 (USDC)", + "minOrderSizeTooltip": "比例模式下,限制单笔跟单订单的最小金额下限,必须 >= 1", + "minOrderSizePlaceholder": "仅在比例模式下生效,必须 >= 1(可选)", + "minOrderSizeMin": "最小金额必须 >= 1", + "maxDailyLoss": "每日最大亏损限制 (USDC)", + "maxDailyLossTooltip": "限制每日最大亏损金额,用于风险控制", + "maxDailyLossPlaceholder": "默认 10000 USDC(可选)", + "maxDailyOrders": "每日最大跟单订单数", + "maxDailyOrdersTooltip": "限制每日最多跟单的订单数量", + "maxDailyOrdersPlaceholder": "默认 100(可选)", + "priceTolerance": "价格容忍度 (%)", + "priceToleranceTooltip": "允许跟单价格在 Leader 价格基础上的调整范围", + "priceTolerancePlaceholder": "默认 5%(可选)", + "delaySeconds": "跟单延迟 (秒)", + "delaySecondsTooltip": "跟单延迟时间,0 表示立即跟单", + "delaySecondsPlaceholder": "默认 0(立即跟单)", + "filterConditions": "过滤条件(可选)", + "minOrderDepth": "最小订单深度 (USDC)", + "minOrderDepthTooltip": "最小订单深度(USDC金额),NULL表示不启用此过滤", + "minOrderDepthPlaceholder": "例如:100(可选,不填写表示不启用)", + "maxSpread": "最大价差(绝对价格)", + "maxSpreadTooltip": "最大价差(绝对价格),NULL表示不启用此过滤", + "maxSpreadPlaceholder": "例如:0.05(5美分,可选,不填写表示不启用)", + "minOrderbookDepth": "最小订单簿深度 (USDC)", + "minOrderbookDepthTooltip": "最小订单簿深度(USDC金额),NULL表示不启用此过滤", + "minOrderbookDepthPlaceholder": "例如:50(可选,不填写表示不启用)", + "supportSell": "跟单卖出", + "supportSellTooltip": "是否跟单 Leader 的卖出订单", + "save": "保存", + "saveSuccess": "更新跟单配置成功", + "saveFailed": "更新跟单配置失败", + "fetchFailed": "获取跟单配置失败", + "invalidNumber": "请输入有效的数字" + }, + "filteredOrdersList": { + "title": "被过滤订单列表", + "fetchFailed": "获取被过滤订单列表失败", + "market": "市场", + "outcome": "方向", + "price": "价格", + "size": "数量", + "side": "订单方向", + "calculatedQuantity": "计算数量", + "filterType": "过滤类型", + "filterReason": "过滤原因", + "createdAt": "时间", + "filterByType": "按类型筛选", + "filterTypes": { + "orderDepth": "订单深度不足", + "spread": "价差过大", + "orderbookDepth": "订单簿深度不足", + "priceValidity": "价格不合理", + "marketStatus": "市场状态不可交易", + "orderbookError": "订单簿获取失败", + "orderbookEmpty": "订单簿为空", + "unknown": "未知原因" + } + }, "copyTradingList": { "title": "跟单配置管理", "addCopyTrading": "新增跟单", @@ -356,6 +599,9 @@ "account": "账户", "template": "模板", "leader": "Leader", + "copyMode": "跟单模式", + "ratioMode": "比例", + "fixedAmountMode": "固定", "enabled": "开启", "disabled": "停止", "totalPnl": "总盈亏", @@ -365,6 +611,7 @@ "buyOrders": "买入订单", "sellOrders": "卖出订单", "matchedOrders": "匹配关系", + "filteredOrders": "被过滤订单", "filterWallet": "筛选钱包", "filterTemplate": "筛选模板", "filterLeader": "筛选 Leader", diff --git a/frontend/src/locales/zh-TW/common.json b/frontend/src/locales/zh-TW/common.json index 9e0f41e..a800920 100644 --- a/frontend/src/locales/zh-TW/common.json +++ b/frontend/src/locales/zh-TW/common.json @@ -16,7 +16,17 @@ "reset": "重置", "close": "關閉", "yes": "是", - "no": "否" + "no": "否", + "actions": "操作", + "all": "全部", + "createdAt": "創建時間", + "updatedAt": "更新時間", + "status": "狀態", + "enabled": "啟用", + "disabled": "禁用", + "noData": "暫無數據", + "saveConfig": "保存配置", + "refreshConfig": "刷新配置" }, "account": { "title": "賬戶管理", @@ -54,6 +64,10 @@ "accountIdRequired": "賬戶ID不能為空" }, "message": { + "success": "操作成功", + "error": "操作失敗", + "loading": "加載中...", + "noData": "暫無數據", "loginSuccess": "登錄成功", "loginFailed": "登錄失敗", "createUserSuccess": "創建用戶成功", @@ -65,11 +79,13 @@ "title": "登錄", "username": "用戶名", "password": "密碼", - "usernamePlaceholder": "用戶名", - "passwordPlaceholder": "密碼", "usernameRequired": "請輸入用戶名", "passwordRequired": "請輸入密碼", - "forgotPassword": "忘記密碼?重置密碼" + "usernamePlaceholder": "請輸入用戶名", + "passwordPlaceholder": "請輸入密碼", + "forgotPassword": "忘記密碼?", + "loginFailed": "登錄失敗", + "loginSuccess": "登錄成功" }, "order": { "create": "訂單創建", @@ -85,6 +101,8 @@ }, "accountList": { "title": "賬戶管理", + "addAccount": "添加賬戶", + "actions": "操作", "importAccount": "導入賬戶", "accountName": "賬戶名稱", "walletAddress": "錢包地址", @@ -138,7 +156,8 @@ "updateSuccess": "更新賬戶成功", "updateFailed": "更新賬戶失敗", "getDetailFailedForEdit": "獲取賬戶詳情失敗", - "loading": "加載中..." + "loading": "加載中...", + "fetchFailed": "獲取賬戶列表失敗" }, "accountImport": { "title": "導入賬戶", @@ -163,7 +182,13 @@ "mnemonicInvalid": "助記詞格式不正確(應為12或24個單詞,用空格分隔)", "walletAddressMismatchMnemonic": "錢包地址與助記詞不匹配", "accountName": "賬戶名稱", + "accountNameRequired": "請輸入賬戶名稱", "accountNamePlaceholder": "可選,用於標識賬戶", + "accountNameHelp": "用於標識賬戶,便於管理", + "privateKeyHelp": "私鑰將加密存儲,僅用於簽名交易", + "submit": "導入", + "invalidPrivateKey": "無效的私鑰", + "duplicateAccount": "賬戶已存在", "importAccount": "導入賬戶", "importSuccess": "導入賬戶成功", "importFailed": "導入賬戶失敗", @@ -174,6 +199,7 @@ "leader": { "title": "Leader 管理", "leaderName": "Leader 名稱", + "leaderAddress": "錢包地址", "walletAddress": "錢包地址", "category": "分類", "all": "全部", @@ -181,8 +207,11 @@ "createdAt": "創建時間", "action": "操作", "add": "添加", + "addLeader": "添加 Leader", "edit": "編輯", + "editLeader": "編輯 Leader", "delete": "刪除", + "deleteLeader": "刪除 Leader", "listFailed": "獲取 Leader 列表失敗", "deleteSuccess": "刪除 Leader 成功", "deleteFailed": "刪除 Leader 失敗", @@ -220,10 +249,16 @@ }, "apiHealthStatus": { "title": "API 健康狀態", + "checkFailed": "檢查失敗", + "status": "狀態", + "responseTime": "響應時間", + "lastCheck": "最後檢查", + "healthy": "健康", + "unhealthy": "不健康", + "unknown": "未知", "normal": "正常", "notConfigured": "未配置", - "abnormal": "異常", - "responseTime": "響應時間" + "abnormal": "異常" }, "proxySettings": { "title": "代理設置", @@ -403,6 +438,248 @@ "invalidNumber": "請輸入有效的數字", "fixedAmountError": "固定金額必須 >= 1,請重新輸入" }, + "templateAdd": { + "title": "創建跟單模板", + "back": "返回", + "templateName": "模板名稱", + "templateNamePlaceholder": "請輸入模板名稱", + "templateNameRequired": "請輸入模板名稱", + "templateNameTooltip": "模板的唯一標識名稱,用於區分不同的跟單配置模板。模板名稱必須唯一,不能與其他模板重名。", + "copyMode": "跟單金額模式", + "copyModeTooltip": "選擇跟單金額的計算方式。比例模式:跟單金額隨 Leader 訂單大小按比例變化;固定金額模式:無論 Leader 訂單大小如何,跟單金額都固定不變。", + "ratioMode": "比例模式", + "fixedAmountMode": "固定金額模式", + "copyRatio": "跟單比例", + "copyRatioTooltip": "跟單比例表示跟單金額相對於 Leader 訂單金額的百分比。例如:100% 表示 1:1 跟單,50% 表示半倉跟單,200% 表示雙倍跟單", + "copyRatioPlaceholder": "例如:100 表示 100%(1:1 跟單),默認 100%", + "fixedAmount": "固定跟單金額 (USDC)", + "fixedAmountPlaceholder": "固定金額,不隨 Leader 訂單大小變化,必須 >= 1", + "fixedAmountRequired": "請輸入固定跟單金額", + "fixedAmountError": "固定金額必須 >= 1,請重新輸入", + "maxOrderSize": "單筆訂單最大金額 (USDC)", + "maxOrderSizeTooltip": "比例模式下,限制單筆跟單訂單的最大金額上限,用於防止跟單金額過大,控制風險。例如:設置為 1000,即使計算出的跟單金額超過 1000,也會限制為 1000 USDC。", + "maxOrderSizePlaceholder": "僅在比例模式下生效(可選)", + "minOrderSize": "單筆訂單最小金額 (USDC)", + "minOrderSizeTooltip": "比例模式下,限制單筆跟單訂單的最小金額下限,用於過濾掉金額過小的訂單,避免頻繁小額交易。如果填寫,必須 >= 1 USDC。例如:設置為 10,如果計算出的跟單金額小於 10,則跳過該訂單。", + "minOrderSizePlaceholder": "僅在比例模式下生效,必須 >= 1(可選)", + "minOrderSizeError": "最小金額必須 >= 1", + "maxDailyOrders": "每日最大跟單訂單數", + "maxDailyOrdersTooltip": "限制每日最多跟單的訂單數量,用於風險控制,防止過度交易。例如:設置為 50,當日跟單訂單數達到 50 後,停止跟單,次日重置。", + "maxDailyOrdersPlaceholder": "默認 100(可選)", + "priceTolerance": "價格容忍度 (%)", + "priceToleranceTooltip": "允許跟單價格在 Leader 價格基礎上的調整範圍,用於在 Leader 價格 ± 容忍度範圍內調整價格,提高成交率。例如:設置為 5%,Leader 價格為 0.5,則跟單價格可在 0.475-0.525 範圍內。", + "priceTolerancePlaceholder": "默認 5%(可選)", + "minOrderDepth": "最小訂單深度 (USDC)", + "minOrderDepthTooltip": "最小訂單深度(USDC金額),NULL表示不啟用此過濾。確保市場有足夠的流動性", + "minOrderDepthPlaceholder": "例如:100(可選,不填寫表示不啟用)", + "maxSpread": "最大價差(絕對價格)", + "maxSpreadTooltip": "最大價差(絕對價格),NULL表示不啟用此過濾。避免在價差過大的市場跟單", + "maxSpreadPlaceholder": "例如:0.05(5美分,可選,不填寫表示不啟用)", + "minOrderbookDepth": "最小訂單簿深度 (USDC)", + "minOrderbookDepthTooltip": "最小訂單簿深度(USDC金額),NULL表示不啟用此過濾。檢查前 N 檔的深度", + "minOrderbookDepthPlaceholder": "例如:50(可選,不填寫表示不啟用)", + "supportSell": "跟單賣出", + "supportSellTooltip": "是否跟單 Leader 的賣出訂單。開啟:跟單 Leader 的買入和賣出訂單;關閉:只跟單 Leader 的買入訂單,忽略賣出訂單。", + "create": "創建模板", + "createSuccess": "創建模板成功", + "createFailed": "創建模板失敗", + "invalidNumber": "請輸入有效的數字" + }, + "templateEdit": { + "title": "編輯跟單模板", + "back": "返回", + "save": "保存修改", + "saveSuccess": "更新模板成功", + "saveFailed": "更新模板失敗", + "fetchFailed": "獲取模板詳情失敗", + "templateName": "模板名稱", + "templateNamePlaceholder": "請輸入模板名稱", + "templateNameRequired": "請輸入模板名稱", + "templateNameTooltip": "模板的唯一標識名稱,用於區分不同的跟單配置模板。模板名稱必須唯一,不能與其他模板重名。", + "copyMode": "跟單金額模式", + "copyModeTooltip": "選擇跟單金額的計算方式。比例模式:跟單金額隨 Leader 訂單大小按比例變化;固定金額模式:無論 Leader 訂單大小如何,跟單金額都固定不變。", + "ratioMode": "比例模式", + "fixedAmountMode": "固定金額模式", + "copyRatio": "跟單比例", + "copyRatioTooltip": "跟單比例表示跟單金額相對於 Leader 訂單金額的百分比。例如:100% 表示 1:1 跟單,50% 表示半倉跟單,200% 表示雙倍跟單", + "copyRatioPlaceholder": "例如:100 表示 100%(1:1 跟單),默認 100%", + "fixedAmount": "固定跟單金額 (USDC)", + "fixedAmountPlaceholder": "固定金額,不隨 Leader 訂單大小變化,必須 >= 1", + "fixedAmountRequired": "請輸入固定跟單金額", + "fixedAmountError": "固定金額必須 >= 1,請重新輸入", + "fixedAmountTooltip": "固定金額模式下,每次跟單的固定金額,不隨 Leader 訂單大小變化。必須 >= 1 USDC。例如:設置為 10,則無論 Leader 買入多少,跟單金額始終為 10 USDC。", + "maxOrderSize": "單筆訂單最大金額 (USDC)", + "maxOrderSizeTooltip": "比例模式下,限制單筆跟單訂單的最大金額上限,用於防止跟單金額過大,控制風險。例如:設置為 1000,即使計算出的跟單金額超過 1000,也會限制為 1000 USDC。", + "maxOrderSizePlaceholder": "僅在比例模式下生效(可選)", + "minOrderSize": "單筆訂單最小金額 (USDC)", + "minOrderSizeTooltip": "比例模式下,限制單筆跟單訂單的最小金額下限,用於過濾掉金額過小的訂單,避免頻繁小額交易。如果填寫,必須 >= 1 USDC。例如:設置為 10,如果計算出的跟單金額小於 10,則跳過該訂單。", + "minOrderSizePlaceholder": "僅在比例模式下生效,必須 >= 1(可選)", + "minOrderSizeError": "最小金額必須 >= 1", + "maxDailyOrders": "每日最大跟單訂單數", + "maxDailyOrdersTooltip": "限制每日最多跟單的訂單數量,用於風險控制,防止過度交易。例如:設置為 50,當日跟單訂單數達到 50 後,停止跟單,次日重置。", + "maxDailyOrdersPlaceholder": "默認 100(可選)", + "priceTolerance": "價格容忍度 (%)", + "priceToleranceTooltip": "允許跟單價格在 Leader 價格基礎上的調整範圍,用於在 Leader 價格 ± 容忍度範圍內調整價格,提高成交率。例如:設置為 5%,Leader 價格為 0.5,則跟單價格可在 0.475-0.525 範圍內。", + "priceTolerancePlaceholder": "默認 5%(可選)", + "minOrderDepth": "最小訂單深度 (USDC)", + "minOrderDepthTooltip": "最小訂單深度(USDC金額),NULL表示不啟用此過濾。確保市場有足夠的流動性", + "minOrderDepthPlaceholder": "例如:100(可選,不填寫表示不啟用)", + "maxSpread": "最大價差(絕對價格)", + "maxSpreadTooltip": "最大價差(絕對價格),NULL表示不啟用此過濾。避免在價差過大的市場跟單", + "maxSpreadPlaceholder": "例如:0.05(5美分,可選,不填寫表示不啟用)", + "minOrderbookDepth": "最小訂單簿深度 (USDC)", + "minOrderbookDepthTooltip": "最小訂單簿深度(USDC金額),NULL表示不啟用此過濾。檢查前 N 檔的深度", + "minOrderbookDepthPlaceholder": "例如:50(可選,不填寫表示不啟用)", + "supportSell": "跟單賣出", + "supportSellTooltip": "是否跟單 Leader 的賣出訂單。開啟:跟單 Leader 的買入和賣出訂單;關閉:只跟單 Leader 的買入訂單,忽略賣出訂單。", + "invalidNumber": "請輸入有效的數字" + }, + "copyTradingAdd": { + "title": "新增跟單配置", + "back": "返回", + "selectWallet": "選擇錢包", + "selectWalletPlaceholder": "請選擇錢包", + "walletRequired": "請選擇錢包", + "selectLeader": "選擇 Leader", + "selectLeaderPlaceholder": "請選擇 Leader", + "leaderRequired": "請選擇 Leader", + "enabled": "啟用狀態", + "selectTemplate": "選擇模板", + "selectTemplateFromModal": "從模板填充配置", + "templateFilled": "模板內容已填充,您可以修改", + "basicConfig": "基礎配置", + "copyMode": "跟單金額模式", + "copyModeTooltip": "選擇跟單金額的計算方式。比例模式:跟單金額隨 Leader 訂單大小按比例變化;固定金額模式:無論 Leader 訂單大小如何,跟單金額都固定不變。", + "ratioMode": "比例模式", + "fixedAmountMode": "固定金額模式", + "ratio": "比例", + "fixed": "固定", + "copyRatio": "跟單比例", + "copyRatioTooltip": "跟單比例表示跟單金額相對於 Leader 訂單金額的百分比。例如:100% 表示 1:1 跟單,50% 表示半倉跟單,200% 表示雙倍跟單", + "copyRatioPlaceholder": "例如:100 表示 100%(1:1 跟單),默認 100%", + "fixedAmount": "固定跟單金額 (USDC)", + "fixedAmountPlaceholder": "固定金額,不隨 Leader 訂單大小變化,必須 >= 1", + "fixedAmountRequired": "請輸入固定跟單金額", + "fixedAmountMin": "固定金額必須 >= 1", + "maxOrderSize": "單筆訂單最大金額 (USDC)", + "maxOrderSizeTooltip": "比例模式下,限制單筆跟單訂單的最大金額上限", + "maxOrderSizePlaceholder": "僅在比例模式下生效(可選)", + "minOrderSize": "單筆訂單最小金額 (USDC)", + "minOrderSizeTooltip": "比例模式下,限制單筆跟單訂單的最小金額下限,必須 >= 1", + "minOrderSizePlaceholder": "僅在比例模式下生效,必須 >= 1(可選)", + "minOrderSizeMin": "最小金額必須 >= 1", + "maxDailyLoss": "每日最大虧損限制 (USDC)", + "maxDailyLossTooltip": "限制每日最大虧損金額,用於風險控制", + "maxDailyLossPlaceholder": "默認 10000 USDC(可選)", + "maxDailyOrders": "每日最大跟單訂單數", + "maxDailyOrdersTooltip": "限制每日最多跟單的訂單數量", + "maxDailyOrdersPlaceholder": "默認 100(可選)", + "priceTolerance": "價格容忍度 (%)", + "priceToleranceTooltip": "允許跟單價格在 Leader 價格基礎上的調整範圍", + "priceTolerancePlaceholder": "默認 5%(可選)", + "delaySeconds": "跟單延遲 (秒)", + "delaySecondsTooltip": "跟單延遲時間,0 表示立即跟單", + "delaySecondsPlaceholder": "默認 0(立即跟單)", + "filterConditions": "過濾條件(可選)", + "minOrderDepth": "最小訂單深度 (USDC)", + "minOrderDepthTooltip": "最小訂單深度(USDC金額),NULL表示不啟用此過濾。確保市場有足夠的流動性", + "minOrderDepthPlaceholder": "例如:100(可選,不填寫表示不啟用)", + "maxSpread": "最大價差(絕對價格)", + "maxSpreadTooltip": "最大價差(絕對價格),NULL表示不啟用此過濾。避免在價差過大的市場跟單", + "maxSpreadPlaceholder": "例如:0.05(5美分,可選,不填寫表示不啟用)", + "minOrderbookDepth": "最小訂單簿深度 (USDC)", + "minOrderbookDepthTooltip": "最小訂單簿深度(USDC金額),NULL表示不啟用此過濾。檢查前 N 檔的深度", + "minOrderbookDepthPlaceholder": "例如:50(可選,不填寫表示不啟用)", + "supportSell": "跟單賣出", + "supportSellTooltip": "是否跟單 Leader 的賣出訂單", + "create": "創建跟單配置", + "createSuccess": "創建跟單配置成功", + "createFailed": "創建跟單配置失敗", + "invalidNumber": "請輸入有效的數字", + "fetchLeaderFailed": "獲取 Leader 列表失敗", + "fetchTemplateFailed": "獲取模板列表失敗", + "templateName": "模板名稱" + }, + "copyTradingEdit": { + "title": "編輯跟單配置", + "back": "返回", + "wallet": "錢包", + "leader": "Leader", + "selectWallet": "錢包", + "selectLeader": "Leader", + "basicConfig": "基礎配置", + "copyMode": "跟單金額模式", + "copyModeTooltip": "選擇跟單金額的計算方式", + "ratioMode": "比例模式", + "fixedAmountMode": "固定金額模式", + "copyRatio": "跟單比例", + "copyRatioTooltip": "跟單比例表示跟單金額相對於 Leader 訂單金額的百分比", + "copyRatioPlaceholder": "例如:100 表示 100%(1:1 跟單)", + "fixedAmount": "固定跟單金額 (USDC)", + "fixedAmountPlaceholder": "固定金額,不隨 Leader 訂單大小變化,必須 >= 1", + "fixedAmountRequired": "請輸入固定跟單金額", + "fixedAmountMin": "固定金額必須 >= 1", + "maxOrderSize": "單筆訂單最大金額 (USDC)", + "maxOrderSizeTooltip": "比例模式下,限制單筆跟單訂單的最大金額上限", + "maxOrderSizePlaceholder": "僅在比例模式下生效(可選)", + "minOrderSize": "單筆訂單最小金額 (USDC)", + "minOrderSizeTooltip": "比例模式下,限制單筆跟單訂單的最小金額下限,必須 >= 1", + "minOrderSizePlaceholder": "僅在比例模式下生效,必須 >= 1(可選)", + "minOrderSizeMin": "最小金額必須 >= 1", + "maxDailyLoss": "每日最大虧損限制 (USDC)", + "maxDailyLossTooltip": "限制每日最大虧損金額,用於風險控制", + "maxDailyLossPlaceholder": "默認 10000 USDC(可選)", + "maxDailyOrders": "每日最大跟單訂單數", + "maxDailyOrdersTooltip": "限制每日最多跟單的訂單數量", + "maxDailyOrdersPlaceholder": "默認 100(可選)", + "priceTolerance": "價格容忍度 (%)", + "priceToleranceTooltip": "允許跟單價格在 Leader 價格基礎上的調整範圍", + "priceTolerancePlaceholder": "默認 5%(可選)", + "delaySeconds": "跟單延遲 (秒)", + "delaySecondsTooltip": "跟單延遲時間,0 表示立即跟單", + "delaySecondsPlaceholder": "默認 0(立即跟單)", + "filterConditions": "過濾條件(可選)", + "minOrderDepth": "最小訂單深度 (USDC)", + "minOrderDepthTooltip": "最小訂單深度(USDC金額),NULL表示不啟用此過濾", + "minOrderDepthPlaceholder": "例如:100(可選,不填寫表示不啟用)", + "maxSpread": "最大價差(絕對價格)", + "maxSpreadTooltip": "最大價差(絕對價格),NULL表示不啟用此過濾", + "maxSpreadPlaceholder": "例如:0.05(5美分,可選,不填寫表示不啟用)", + "minOrderbookDepth": "最小訂單簿深度 (USDC)", + "minOrderbookDepthTooltip": "最小訂單簿深度(USDC金額),NULL表示不啟用此過濾", + "minOrderbookDepthPlaceholder": "例如:50(可選,不填寫表示不啟用)", + "supportSell": "跟單賣出", + "supportSellTooltip": "是否跟單 Leader 的賣出訂單", + "save": "保存", + "saveSuccess": "更新跟單配置成功", + "saveFailed": "更新跟單配置失敗", + "fetchFailed": "獲取跟單配置失敗", + "invalidNumber": "請輸入有效的數字" + }, + "filteredOrdersList": { + "title": "被過濾訂單列表", + "fetchFailed": "獲取被過濾訂單列表失敗", + "market": "市場", + "outcome": "方向", + "price": "價格", + "size": "數量", + "side": "訂單方向", + "calculatedQuantity": "計算數量", + "filterType": "過濾類型", + "filterReason": "過濾原因", + "createdAt": "時間", + "filterByType": "按類型篩選", + "filterTypes": { + "orderDepth": "訂單深度不足", + "spread": "價差過大", + "orderbookDepth": "訂單簿深度不足", + "priceValidity": "價格不合理", + "marketStatus": "市場狀態不可交易", + "orderbookError": "訂單簿獲取失敗", + "orderbookEmpty": "訂單簿為空", + "unknown": "未知原因" + } + }, "copyTradingList": { "title": "跟單配置管理", "addCopyTrading": "新增跟單", @@ -410,6 +687,9 @@ "account": "賬戶", "template": "模板", "leader": "Leader", + "copyMode": "跟單模式", + "ratioMode": "比例", + "fixedAmountMode": "固定", "enabled": "開啟", "disabled": "停止", "totalPnl": "總盈虧", @@ -419,6 +699,7 @@ "buyOrders": "買入訂單", "sellOrders": "賣出訂單", "matchedOrders": "匹配關係", + "filteredOrders": "被過濾訂單", "filterWallet": "篩選錢包", "filterTemplate": "篩選模板", "filterLeader": "篩選 Leader", diff --git a/frontend/src/pages/CopyTradingAdd.tsx b/frontend/src/pages/CopyTradingAdd.tsx index bf98102..7e1735b 100644 --- a/frontend/src/pages/CopyTradingAdd.tsx +++ b/frontend/src/pages/CopyTradingAdd.tsx @@ -1,24 +1,26 @@ import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' -import { Card, Form, Button, Select, Switch, message, Typography, Space } from 'antd' -import { ArrowLeftOutlined, SaveOutlined } from '@ant-design/icons' +import { Card, Form, Button, Switch, message, Typography, Space, Radio, InputNumber, Modal, Table, Select, Divider } from 'antd' +import { ArrowLeftOutlined, SaveOutlined, FileTextOutlined } from '@ant-design/icons' import { apiService } from '../services/api' import { useAccountStore } from '../store/accountStore' -import type { Leader, CopyTradingTemplate } from '../types' -import { useMediaQuery } from 'react-responsive' +import type { Leader, CopyTradingTemplate, CopyTradingCreateRequest } from '../types' import { formatUSDC } from '../utils' +import { useTranslation } from 'react-i18next' const { Title } = Typography const { Option } = Select const CopyTradingAdd: React.FC = () => { + const { t } = useTranslation() const navigate = useNavigate() - useMediaQuery({ maxWidth: 768 }) // 用于响应式布局,但当前页面未使用 const { accounts, fetchAccounts } = useAccountStore() const [form] = Form.useForm() const [loading, setLoading] = useState(false) const [leaders, setLeaders] = useState([]) const [templates, setTemplates] = useState([]) + const [templateModalVisible, setTemplateModalVisible] = useState(false) + const [copyMode, setCopyMode] = useState<'RATIO' | 'FIXED'>('RATIO') useEffect(() => { fetchAccounts() @@ -33,7 +35,7 @@ const CopyTradingAdd: React.FC = () => { setLeaders(response.data.data.list || []) } } catch (error: any) { - message.error(error.message || '获取 Leader 列表失败') + message.error(error.message || t('copyTradingAdd.fetchLeaderFailed') || '获取 Leader 列表失败') } } @@ -44,28 +46,83 @@ const CopyTradingAdd: React.FC = () => { setTemplates(response.data.data.list || []) } } catch (error: any) { - message.error(error.message || '获取模板列表失败') + message.error(error.message || t('copyTradingAdd.fetchTemplateFailed') || '获取模板列表失败') } } + const handleSelectTemplate = (template: CopyTradingTemplate) => { + // 填充模板数据到表单(只填充模板中存在的字段) + form.setFieldsValue({ + copyMode: template.copyMode, + copyRatio: template.copyRatio ? parseFloat(template.copyRatio) * 100 : 100, // 转换为百分比显示 + fixedAmount: template.fixedAmount ? parseFloat(template.fixedAmount) : undefined, + maxOrderSize: template.maxOrderSize ? parseFloat(template.maxOrderSize) : undefined, + minOrderSize: template.minOrderSize ? parseFloat(template.minOrderSize) : undefined, + maxDailyOrders: template.maxDailyOrders, + priceTolerance: template.priceTolerance ? parseFloat(template.priceTolerance) : undefined, + supportSell: template.supportSell, + minOrderDepth: template.minOrderDepth ? parseFloat(template.minOrderDepth) : undefined, + maxSpread: template.maxSpread ? parseFloat(template.maxSpread) : undefined, + minOrderbookDepth: template.minOrderbookDepth ? parseFloat(template.minOrderbookDepth) : undefined + }) + setCopyMode(template.copyMode) + setTemplateModalVisible(false) + message.success(t('copyTradingAdd.templateFilled') || '模板内容已填充,您可以修改') + } + + const handleCopyModeChange = (mode: 'RATIO' | 'FIXED') => { + setCopyMode(mode) + } + const handleSubmit = async (values: any) => { + // 前端校验 + if (values.copyMode === 'FIXED') { + if (!values.fixedAmount || Number(values.fixedAmount) < 1) { + message.error(t('copyTradingAdd.fixedAmountMin') || '固定金额必须 >= 1') + return + } + } + + if (values.copyMode === 'RATIO' && values.minOrderSize !== undefined && values.minOrderSize !== null && Number(values.minOrderSize) < 1) { + message.error(t('copyTradingAdd.minOrderSizeMin') || '最小金额必须 >= 1') + return + } + setLoading(true) try { - const response = await apiService.copyTrading.create({ + const request: CopyTradingCreateRequest = { accountId: values.accountId, - templateId: values.templateId, leaderId: values.leaderId, - enabled: values.enabled !== false - }) + enabled: true, // 默认启用 + copyMode: values.copyMode || 'RATIO', + copyRatio: values.copyMode === 'RATIO' && values.copyRatio ? (values.copyRatio / 100).toString() : undefined, + fixedAmount: values.copyMode === 'FIXED' ? values.fixedAmount?.toString() : undefined, + maxOrderSize: values.maxOrderSize?.toString(), + minOrderSize: values.minOrderSize?.toString(), + maxDailyLoss: values.maxDailyLoss?.toString(), + maxDailyOrders: values.maxDailyOrders, + priceTolerance: values.priceTolerance?.toString(), + delaySeconds: values.delaySeconds, + pollIntervalSeconds: values.pollIntervalSeconds, + useWebSocket: values.useWebSocket, + websocketReconnectInterval: values.websocketReconnectInterval, + websocketMaxRetries: values.websocketMaxRetries, + supportSell: values.supportSell !== false, + minOrderDepth: values.minOrderDepth?.toString(), + maxSpread: values.maxSpread?.toString(), + minOrderbookDepth: values.minOrderbookDepth?.toString() + } + + const response = await apiService.copyTrading.create(request) if (response.data.code === 0) { - message.success('创建跟单成功') + message.success(t('copyTradingAdd.createSuccess') || '创建跟单配置成功') navigate('/copy-trading') } else { - message.error(response.data.msg || '创建跟单失败') + message.error(response.data.msg || t('copyTradingAdd.createFailed') || '创建跟单配置失败') } } catch (error: any) { - message.error(error.message || '创建跟单失败') + message.error(error.message || t('copyTradingAdd.createFailed') || '创建跟单配置失败') } finally { setLoading(false) } @@ -78,27 +135,40 @@ const CopyTradingAdd: React.FC = () => { icon={} onClick={() => navigate('/copy-trading')} > - 返回 + {t('common.back') || '返回'} - 新增跟单 + {t('copyTradingAdd.title') || '新增跟单配置'}
+ {/* 基础信息 */} - {accounts.map(account => ( - - - - - {leaders.map(leader => ( + {/* 模板填充按钮 */} + + + + + {/* 跟单金额模式 */} + handleCopyModeChange(e.target.value)}> + {t('copyTradingAdd.ratioMode') || '比例模式'} + {t('copyTradingAdd.fixedAmountMode') || '固定金额模式'} + + + + {copyMode === 'RATIO' && ( + + + + )} + + {copyMode === 'FIXED' && ( + { + if (value !== undefined && value !== null && value !== '') { + const amount = Number(value) + if (isNaN(amount)) { + return Promise.reject(new Error(t('copyTradingAdd.invalidNumber') || '请输入有效的数字')) + } + if (amount < 1) { + return Promise.reject(new Error(t('copyTradingAdd.fixedAmountMin') || '固定金额必须 >= 1')) + } + } + return Promise.resolve() + } + } + ]} + > + = 1'} + /> + + )} + + {copyMode === 'RATIO' && ( + <> + + + + + = 1'} + rules={[ + { + validator: (_, value) => { + if (value === undefined || value === null || value === '') { + return Promise.resolve() + } + if (typeof value === 'number' && value < 1) { + return Promise.reject(new Error(t('copyTradingAdd.minOrderSizeMin') || '最小金额必须 >= 1')) + } + return Promise.resolve() + } + } + ]} + > + = 1(可选)'} + /> + + + )} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* 跟单卖出 - 表单最底部 */} + - + @@ -151,18 +426,61 @@ const CopyTradingAdd: React.FC = () => { icon={} loading={loading} > - 创建跟单 + {t('copyTradingAdd.create') || '创建跟单配置'}
+ + {/* 模板选择 Modal */} + setTemplateModalVisible(false)} + footer={null} + width={800} + > + ({ + onClick: () => handleSelectTemplate(record), + style: { cursor: 'pointer' } + })} + columns={[ + { + title: t('copyTradingAdd.templateName') || '模板名称', + dataIndex: 'templateName', + key: 'templateName' + }, + { + title: t('copyTradingAdd.copyMode') || '跟单模式', + key: 'copyMode', + render: (_: any, record: CopyTradingTemplate) => ( + + {record.copyMode === 'RATIO' + ? `${t('copyTradingAdd.ratioMode') || '比例'} ${record.copyRatio}x` + : `${t('copyTradingAdd.fixedAmountMode') || '固定'} ${formatUSDC(record.fixedAmount || '0')} USDC` + } + + ) + }, + { + title: t('copyTradingAdd.supportSell') || '跟单卖出', + dataIndex: 'supportSell', + key: 'supportSell', + render: (supportSell: boolean) => supportSell ? (t('common.yes') || '是') : (t('common.no') || '否') + } + ]} + /> + ) } export default CopyTradingAdd - diff --git a/frontend/src/pages/CopyTradingEdit.tsx b/frontend/src/pages/CopyTradingEdit.tsx new file mode 100644 index 0000000..8c7f8f1 --- /dev/null +++ b/frontend/src/pages/CopyTradingEdit.tsx @@ -0,0 +1,432 @@ +import { useEffect, useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { Card, Form, Button, Switch, message, Typography, Space, Radio, InputNumber, Divider, Spin, Select } from 'antd' +import { ArrowLeftOutlined, SaveOutlined } from '@ant-design/icons' +import { apiService } from '../services/api' +import type { CopyTrading, CopyTradingUpdateRequest } from '../types' +import { useTranslation } from 'react-i18next' + +const { Title } = Typography +const { Option } = Select + +const CopyTradingEdit: React.FC = () => { + const { t } = useTranslation() + const navigate = useNavigate() + const { id } = useParams<{ id: string }>() + const [form] = Form.useForm() + const [loading, setLoading] = useState(false) + const [fetching, setFetching] = useState(true) + const [copyTrading, setCopyTrading] = useState(null) + const [copyMode, setCopyMode] = useState<'RATIO' | 'FIXED'>('RATIO') + const [originalEnabled, setOriginalEnabled] = useState(true) + + useEffect(() => { + if (id) { + fetchCopyTrading(parseInt(id)) + } + }, [id]) + + const fetchCopyTrading = async (copyTradingId: number) => { + setFetching(true) + try { + const response = await apiService.copyTrading.list({}) + if (response.data.code === 0 && response.data.data) { + const found = response.data.data.list.find((ct: CopyTrading) => ct.id === copyTradingId) + if (found) { + setCopyTrading(found) + setCopyMode(found.copyMode) + setOriginalEnabled(found.enabled) // 保存原始的enabled状态 + // 填充表单数据 + form.setFieldsValue({ + accountId: found.accountId, + leaderId: found.leaderId, + copyMode: found.copyMode, + copyRatio: found.copyRatio ? parseFloat(found.copyRatio) * 100 : 100, // 转换为百分比显示 + fixedAmount: found.fixedAmount ? parseFloat(found.fixedAmount) : undefined, + maxOrderSize: found.maxOrderSize ? parseFloat(found.maxOrderSize) : undefined, + minOrderSize: found.minOrderSize ? parseFloat(found.minOrderSize) : undefined, + maxDailyLoss: found.maxDailyLoss ? parseFloat(found.maxDailyLoss) : undefined, + maxDailyOrders: found.maxDailyOrders, + priceTolerance: found.priceTolerance ? parseFloat(found.priceTolerance) : undefined, + delaySeconds: found.delaySeconds, + pollIntervalSeconds: found.pollIntervalSeconds, + useWebSocket: found.useWebSocket, + websocketReconnectInterval: found.websocketReconnectInterval, + websocketMaxRetries: found.websocketMaxRetries, + supportSell: found.supportSell, + minOrderDepth: found.minOrderDepth ? parseFloat(found.minOrderDepth) : undefined, + maxSpread: found.maxSpread ? parseFloat(found.maxSpread) : undefined, + minOrderbookDepth: found.minOrderbookDepth ? parseFloat(found.minOrderbookDepth) : undefined + }) + } else { + message.error(t('copyTradingEdit.fetchFailed') || '跟单配置不存在') + navigate('/copy-trading') + } + } else { + message.error(response.data.msg || t('copyTradingEdit.fetchFailed') || '获取跟单配置失败') + navigate('/copy-trading') + } + } catch (error: any) { + message.error(error.message || t('copyTradingEdit.fetchFailed') || '获取跟单配置失败') + navigate('/copy-trading') + } finally { + setFetching(false) + } + } + + const handleCopyModeChange = (mode: 'RATIO' | 'FIXED') => { + setCopyMode(mode) + } + + const handleSubmit = async (values: any) => { + // 前端校验 + if (values.copyMode === 'FIXED') { + if (!values.fixedAmount || Number(values.fixedAmount) < 1) { + message.error('固定金额必须 >= 1') + return + } + } + + if (values.copyMode === 'RATIO' && values.minOrderSize !== undefined && values.minOrderSize !== null && Number(values.minOrderSize) < 1) { + message.error('最小金额必须 >= 1') + return + } + + if (!id) { + message.error('配置ID不存在') + return + } + + setLoading(true) + try { + const request: CopyTradingUpdateRequest = { + copyTradingId: parseInt(id), + enabled: originalEnabled, // 保持原有的enabled状态,不修改 + copyMode: values.copyMode, + copyRatio: values.copyMode === 'RATIO' && values.copyRatio ? (values.copyRatio / 100).toString() : undefined, + fixedAmount: values.copyMode === 'FIXED' ? values.fixedAmount?.toString() : undefined, + maxOrderSize: values.maxOrderSize?.toString(), + minOrderSize: values.minOrderSize?.toString(), + maxDailyLoss: values.maxDailyLoss?.toString(), + maxDailyOrders: values.maxDailyOrders, + priceTolerance: values.priceTolerance?.toString(), + delaySeconds: values.delaySeconds, + pollIntervalSeconds: values.pollIntervalSeconds, + useWebSocket: values.useWebSocket, + websocketReconnectInterval: values.websocketReconnectInterval, + websocketMaxRetries: values.websocketMaxRetries, + supportSell: values.supportSell, + minOrderDepth: values.minOrderDepth?.toString(), + maxSpread: values.maxSpread?.toString(), + minOrderbookDepth: values.minOrderbookDepth?.toString() + } + + const response = await apiService.copyTrading.update(request) + + if (response.data.code === 0) { + message.success(t('copyTradingEdit.saveSuccess') || '更新跟单配置成功') + navigate('/copy-trading') + } else { + message.error(response.data.msg || t('copyTradingEdit.saveFailed') || '更新跟单配置失败') + } + } catch (error: any) { + message.error(error.message || t('copyTradingEdit.saveFailed') || '更新跟单配置失败') + } finally { + setLoading(false) + } + } + + if (fetching) { + return ( +
+ +
+ ) + } + + if (!copyTrading) { + return null + } + + return ( +
+
+ +
+ + + {t('copyTradingEdit.title') || '编辑跟单配置'} + +
+ {/* 基础信息(只读) */} + + + + + + + + + {t('copyTradingEdit.basicConfig') || '基础配置'} + + {/* 跟单金额模式 */} + + handleCopyModeChange(e.target.value)}> + {t('copyTradingEdit.ratioMode') || '比例模式'} + {t('copyTradingEdit.fixedAmountMode') || '固定金额模式'} + + + + {copyMode === 'RATIO' && ( + + + + )} + + {copyMode === 'FIXED' && ( + { + if (value !== undefined && value !== null && value !== '') { + const amount = Number(value) + if (isNaN(amount)) { + return Promise.reject(new Error(t('copyTradingEdit.invalidNumber') || '请输入有效的数字')) + } + if (amount < 1) { + return Promise.reject(new Error(t('copyTradingEdit.fixedAmountMin') || '固定金额必须 >= 1')) + } + } + return Promise.resolve() + } + } + ]} + > + = 1'} + /> + + )} + + {copyMode === 'RATIO' && ( + <> + + + + + = 1'} + rules={[ + { + validator: (_, value) => { + if (value === undefined || value === null || value === '') { + return Promise.resolve() + } + if (typeof value === 'number' && value < 1) { + return Promise.reject(new Error(t('copyTradingEdit.minOrderSizeMin') || '最小金额必须 >= 1')) + } + return Promise.resolve() + } + } + ]} + > + = 1(可选)'} + /> + + + )} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* 跟单卖出 - 表单最底部 */} + + + + + + + + + + + +
+
+ ) +} + +export default CopyTradingEdit + diff --git a/frontend/src/pages/CopyTradingList.tsx b/frontend/src/pages/CopyTradingList.tsx index e904907..37748c0 100644 --- a/frontend/src/pages/CopyTradingList.tsx +++ b/frontend/src/pages/CopyTradingList.tsx @@ -1,12 +1,12 @@ import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' import { Card, Table, Button, Space, Tag, Popconfirm, Switch, message, Select, Dropdown, Divider, Spin } from 'antd' -import { PlusOutlined, DeleteOutlined, BarChartOutlined, UnorderedListOutlined, ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons' +import { PlusOutlined, DeleteOutlined, BarChartOutlined, UnorderedListOutlined, ArrowUpOutlined, ArrowDownOutlined, EditOutlined } from '@ant-design/icons' import { useTranslation } from 'react-i18next' import type { MenuProps } from 'antd' import { apiService } from '../services/api' import { useAccountStore } from '../store/accountStore' -import type { CopyTrading, Leader, CopyTradingTemplate, CopyTradingStatistics } from '../types' +import type { CopyTrading, Leader, CopyTradingStatistics } from '../types' import { useMediaQuery } from 'react-responsive' import { formatUSDC } from '../utils' @@ -19,13 +19,11 @@ const CopyTradingList: React.FC = () => { const { accounts, fetchAccounts } = useAccountStore() const [copyTradings, setCopyTradings] = useState([]) const [leaders, setLeaders] = useState([]) - const [templates, setTemplates] = useState([]) const [loading, setLoading] = useState(false) const [statisticsMap, setStatisticsMap] = useState>({}) const [loadingStatistics, setLoadingStatistics] = useState>(new Set()) const [filters, setFilters] = useState<{ accountId?: number - templateId?: number leaderId?: number enabled?: boolean }>({}) @@ -33,7 +31,6 @@ const CopyTradingList: React.FC = () => { useEffect(() => { fetchAccounts() fetchLeaders() - fetchTemplates() fetchCopyTradings() }, []) @@ -52,17 +49,6 @@ const CopyTradingList: React.FC = () => { } } - const fetchTemplates = async () => { - try { - const response = await apiService.templates.list() - if (response.data.code === 0 && response.data.data) { - setTemplates(response.data.data.list || []) - } - } catch (error: any) { - console.error('获取模板列表失败:', error) - } - } - const fetchCopyTradings = async () => { setLoading(true) try { @@ -179,12 +165,16 @@ const CopyTradingList: React.FC = () => { ) }, { - title: t('copyTradingList.template') || '模板', - dataIndex: 'templateName', - key: 'templateName', + title: t('copyTradingList.copyMode') || '跟单模式', + key: 'copyMode', width: isMobile ? 100 : 120, - render: (text: string) => ( - {text} + render: (_: any, record: CopyTrading) => ( + + {record.copyMode === 'RATIO' + ? `${t('copyTradingList.ratioMode') || '比例'} ${record.copyRatio}x` + : `${t('copyTradingList.fixedAmountMode') || '固定'} ${formatUSDC(record.fixedAmount || '0')}` + } + ) }, { @@ -265,6 +255,15 @@ const CopyTradingList: React.FC = () => { fixed: 'right' as const, render: (_: any, record: CopyTrading) => { const menuItems: MenuProps['items'] = [ + { + key: 'edit', + label: t('common.edit') || '编辑', + icon: , + onClick: () => navigate(`/copy-trading/edit/${record.id}`) + }, + { + type: 'divider' + }, { key: 'statistics', label: t('copyTradingList.viewStatistics') || '查看统计', @@ -289,6 +288,12 @@ const CopyTradingList: React.FC = () => { icon: , onClick: () => navigate(`/copy-trading/orders/matched/${record.id}`) }, + { + key: 'filteredOrders', + label: t('copyTradingList.filteredOrders') || '被过滤订单', + icon: , + onClick: () => navigate(`/copy-trading/filtered-orders/${record.id}`) + }, { type: 'divider' }, @@ -312,14 +317,24 @@ const CopyTradingList: React.FC = () => { return ( {!isMobile && ( - + <> + + + )}
t('common.total') + `: ${total}`, + onChange: (page) => setPage(page) + }} + scroll={{ x: isMobile ? 800 : 'auto' }} + size={isMobile ? 'small' : 'middle'} + /> + + + ) +} + +export default FilteredOrdersList + diff --git a/frontend/src/pages/TemplateAdd.tsx b/frontend/src/pages/TemplateAdd.tsx index 5d2ce34..39846c1 100644 --- a/frontend/src/pages/TemplateAdd.tsx +++ b/frontend/src/pages/TemplateAdd.tsx @@ -3,10 +3,12 @@ import { useNavigate } from 'react-router-dom' import { Card, Form, Input, Button, Radio, InputNumber, Switch, message, Typography, Space } from 'antd' import { ArrowLeftOutlined, SaveOutlined } from '@ant-design/icons' import { apiService } from '../services/api' +import { useTranslation } from 'react-i18next' const { Title } = Typography const TemplateAdd: React.FC = () => { + const { t } = useTranslation() const navigate = useNavigate() const [form] = Form.useForm() const [loading, setLoading] = useState(false) @@ -15,7 +17,7 @@ const TemplateAdd: React.FC = () => { const handleSubmit = async (values: any) => { // 前端校验:如果填写了 minOrderSize,必须 >= 1 if (values.copyMode === 'RATIO' && values.minOrderSize !== undefined && values.minOrderSize !== null && values.minOrderSize !== '' && Number(values.minOrderSize) < 1) { - message.error('最小金额必须 >= 1') + message.error(t('templateAdd.minOrderSizeError') || '最小金额必须 >= 1') return } @@ -23,16 +25,16 @@ const TemplateAdd: React.FC = () => { if (values.copyMode === 'FIXED') { const fixedAmount = values.fixedAmount if (fixedAmount === undefined || fixedAmount === null || fixedAmount === '') { - message.error('请输入固定跟单金额') + message.error(t('templateAdd.fixedAmountRequired') || '请输入固定跟单金额') return } const amount = Number(fixedAmount) if (isNaN(amount)) { - message.error('请输入有效的数字') + message.error(t('templateAdd.invalidNumber') || '请输入有效的数字') return } if (amount < 1) { - message.error('固定金额必须 >= 1,请重新输入') + message.error(t('templateAdd.fixedAmountError') || '固定金额必须 >= 1,请重新输入') return } } @@ -49,17 +51,20 @@ const TemplateAdd: React.FC = () => { minOrderSize: values.copyMode === 'RATIO' ? values.minOrderSize?.toString() : undefined, maxDailyOrders: values.maxDailyOrders, priceTolerance: values.priceTolerance?.toString(), - supportSell: values.supportSell !== false + supportSell: values.supportSell !== false, + minOrderDepth: values.minOrderDepth?.toString(), + maxSpread: values.maxSpread?.toString(), + minOrderbookDepth: values.minOrderbookDepth?.toString() }) if (response.data.code === 0) { - message.success('创建模板成功') + message.success(t('templateAdd.createSuccess') || '创建模板成功') navigate('/templates') } else { - message.error(response.data.msg || '创建模板失败') + message.error(response.data.msg || t('templateAdd.createFailed') || '创建模板失败') } } catch (error: any) { - message.error(error.message || '创建模板失败') + message.error(error.message || t('templateAdd.createFailed') || '创建模板失败') } finally { setLoading(false) } @@ -72,12 +77,12 @@ const TemplateAdd: React.FC = () => { icon={} onClick={() => navigate('/templates')} > - 返回 + {t('templateAdd.back') || t('common.back') || '返回'} - 创建跟单模板 + {t('templateAdd.title') || '创建跟单模板'}
{ }} > - + setCopyMode(e.target.value)}> - 比例模式 - 固定金额模式 + {t('templateAdd.ratioMode') || '比例模式'} + {t('templateAdd.fixedAmountMode') || '固定金额模式'} {copyMode === 'RATIO' && ( { precision={0} style={{ width: '100%' }} addonAfter="%" - placeholder="例如:100 表示 100%(1:1 跟单),默认 100%" + placeholder={t('templateAdd.copyRatioPlaceholder') || '例如:100 表示 100%(1:1 跟单),默认 100%'} parser={(value) => { const parsed = parseFloat(value || '0') if (parsed > 1000) return 1000 @@ -145,20 +150,20 @@ const TemplateAdd: React.FC = () => { {copyMode === 'FIXED' && ( { // required 已经处理了空值情况,这里只处理非空值的校验 if (value !== undefined && value !== null && value !== '') { const amount = Number(value) if (isNaN(amount)) { - return Promise.reject(new Error('请输入有效的数字')) + return Promise.reject(new Error(t('templateAdd.invalidNumber') || '请输入有效的数字')) } if (amount < 1) { - return Promise.reject(new Error('固定金额必须 >= 1,请重新输入')) + return Promise.reject(new Error(t('templateAdd.fixedAmountError') || '固定金额必须 >= 1,请重新输入')) } } return Promise.resolve() @@ -170,7 +175,7 @@ const TemplateAdd: React.FC = () => { step={0.0001} precision={4} style={{ width: '100%' }} - placeholder="固定金额,不随 Leader 订单大小变化,必须 >= 1" + placeholder={t('templateAdd.fixedAmountPlaceholder') || '固定金额,不随 Leader 订单大小变化,必须 >= 1'} /> )} @@ -178,23 +183,23 @@ const TemplateAdd: React.FC = () => { {copyMode === 'RATIO' && ( <> = 1 USDC。例如:设置为 10,如果计算出的跟单金额小于 10,则跳过该订单。'} rules={[ { validator: (_, value) => { @@ -202,7 +207,7 @@ const TemplateAdd: React.FC = () => { return Promise.resolve() // 可选字段,允许为空 } if (typeof value === 'number' && value < 1) { - return Promise.reject(new Error('最小金额必须 >= 1')) + return Promise.reject(new Error(t('templateAdd.minOrderSizeError') || '最小金额必须 >= 1')) } return Promise.resolve() } @@ -214,29 +219,29 @@ const TemplateAdd: React.FC = () => { step={0.0001} precision={4} style={{ width: '100%' }} - placeholder="仅在比例模式下生效,必须 >= 1(可选)" + placeholder={t('templateAdd.minOrderSizePlaceholder') || '仅在比例模式下生效,必须 >= 1(可选)'} /> )} { step={0.1} precision={2} style={{ width: '100%' }} - placeholder="默认 5%(可选)" + placeholder={t('templateAdd.priceTolerancePlaceholder') || '默认 5%(可选)'} /> + + + + + + + + + + + + {/* 跟单卖出 - 表单最底部 */} + @@ -270,10 +318,10 @@ const TemplateAdd: React.FC = () => { loading={loading} disabled={hasErrors} > - 创建模板 + {t('templateAdd.create') || '创建模板'} ) diff --git a/frontend/src/pages/TemplateEdit.tsx b/frontend/src/pages/TemplateEdit.tsx index a380e0b..3cdb8d0 100644 --- a/frontend/src/pages/TemplateEdit.tsx +++ b/frontend/src/pages/TemplateEdit.tsx @@ -4,14 +4,14 @@ import { Card, Form, Input, Button, Radio, InputNumber, Switch, message, Typogra import { ArrowLeftOutlined, SaveOutlined } from '@ant-design/icons' import { apiService } from '../services/api' import type { CopyTradingTemplate } from '../types' -import { useMediaQuery } from 'react-responsive' +import { useTranslation } from 'react-i18next' const { Title } = Typography const TemplateEdit: React.FC = () => { + const { t } = useTranslation() const navigate = useNavigate() const { id } = useParams<{ id: string }>() - useMediaQuery({ maxWidth: 768 }) // 用于响应式布局,但当前页面未使用 const [form] = Form.useForm() const [loading, setLoading] = useState(false) const [fetching, setFetching] = useState(false) @@ -37,14 +37,17 @@ const TemplateEdit: React.FC = () => { fixedAmount: template.fixedAmount ? parseFloat(template.fixedAmount) : undefined, maxOrderSize: template.maxOrderSize ? parseFloat(template.maxOrderSize) : undefined, minOrderSize: template.minOrderSize ? parseFloat(template.minOrderSize) : undefined, - priceTolerance: parseFloat(template.priceTolerance) + priceTolerance: parseFloat(template.priceTolerance), + minOrderDepth: template.minOrderDepth ? parseFloat(template.minOrderDepth) : undefined, + maxSpread: template.maxSpread ? parseFloat(template.maxSpread) : undefined, + minOrderbookDepth: template.minOrderbookDepth ? parseFloat(template.minOrderbookDepth) : undefined }) } else { - message.error(response.data.msg || '获取模板详情失败') + message.error(response.data.msg || t('templateEdit.fetchFailed') || '获取模板详情失败') navigate('/templates') } } catch (error: any) { - message.error(error.message || '获取模板详情失败') + message.error(error.message || t('templateEdit.fetchFailed') || '获取模板详情失败') navigate('/templates') } finally { setFetching(false) @@ -56,7 +59,7 @@ const TemplateEdit: React.FC = () => { // 前端校验:如果填写了 minOrderSize,必须 >= 1 if (values.copyMode === 'RATIO' && values.minOrderSize !== undefined && values.minOrderSize !== null && values.minOrderSize !== '' && Number(values.minOrderSize) < 1) { - message.error('最小金额必须 >= 1') + message.error(t('templateEdit.minOrderSizeError') || '最小金额必须 >= 1') return } @@ -64,16 +67,16 @@ const TemplateEdit: React.FC = () => { if (values.copyMode === 'FIXED') { const fixedAmount = values.fixedAmount if (fixedAmount === undefined || fixedAmount === null || fixedAmount === '') { - message.error('请输入固定跟单金额') + message.error(t('templateEdit.fixedAmountRequired') || '请输入固定跟单金额') return } const amount = Number(fixedAmount) if (isNaN(amount)) { - message.error('请输入有效的数字') + message.error(t('templateEdit.invalidNumber') || '请输入有效的数字') return } if (amount < 1) { - message.error('固定金额必须 >= 1,请重新输入') + message.error(t('templateEdit.fixedAmountError') || '固定金额必须 >= 1,请重新输入') return } } @@ -91,17 +94,20 @@ const TemplateEdit: React.FC = () => { minOrderSize: values.copyMode === 'RATIO' ? values.minOrderSize?.toString() : undefined, maxDailyOrders: values.maxDailyOrders, priceTolerance: values.priceTolerance?.toString(), - supportSell: values.supportSell + supportSell: values.supportSell, + minOrderDepth: values.minOrderDepth?.toString(), + maxSpread: values.maxSpread?.toString(), + minOrderbookDepth: values.minOrderbookDepth?.toString() }) if (response.data.code === 0) { - message.success('更新模板成功') + message.success(t('templateEdit.saveSuccess') || '更新模板成功') navigate('/templates') } else { - message.error(response.data.msg || '更新模板失败') + message.error(response.data.msg || t('templateEdit.saveFailed') || '更新模板失败') } } catch (error: any) { - message.error(error.message || '更新模板失败') + message.error(error.message || t('templateEdit.saveFailed') || '更新模板失败') } finally { setLoading(false) } @@ -114,12 +120,12 @@ const TemplateEdit: React.FC = () => { icon={} onClick={() => navigate('/templates')} > - 返回 + {t('templateEdit.back') || t('common.back') || '返回'} - 编辑跟单模板 + {t('templateEdit.title') || '编辑跟单模板'} { onFinish={handleSubmit} > - + setCopyMode(e.target.value)}> - 比例模式 - 固定金额模式 + {t('templateEdit.ratioMode') || '比例模式'} + {t('templateEdit.fixedAmountMode') || '固定金额模式'} {copyMode === 'RATIO' && ( { precision={0} style={{ width: '100%' }} addonAfter="%" - placeholder="例如:100 表示 100%(1:1 跟单),默认 100%" + placeholder={t('templateEdit.copyRatioPlaceholder') || '例如:100 表示 100%(1:1 跟单),默认 100%'} parser={(value) => { const parsed = parseFloat(value || '0') if (parsed > 1000) return 1000 @@ -178,21 +184,21 @@ const TemplateEdit: React.FC = () => { {copyMode === 'FIXED' && ( = 1 USDC。例如:设置为 10,则无论 Leader 买入多少,跟单金额始终为 10 USDC。'} rules={[ - { required: true, message: '请输入固定跟单金额' }, + { required: true, message: t('templateEdit.fixedAmountRequired') || '请输入固定跟单金额' }, { validator: (_, value) => { // required 已经处理了空值情况,这里只处理非空值的校验 if (value !== undefined && value !== null && value !== '') { const amount = Number(value) if (isNaN(amount)) { - return Promise.reject(new Error('请输入有效的数字')) + return Promise.reject(new Error(t('templateEdit.invalidNumber') || '请输入有效的数字')) } if (amount < 1) { - return Promise.reject(new Error('固定金额必须 >= 1,请重新输入')) + return Promise.reject(new Error(t('templateEdit.fixedAmountError') || '固定金额必须 >= 1,请重新输入')) } } return Promise.resolve() @@ -204,7 +210,7 @@ const TemplateEdit: React.FC = () => { step={0.0001} precision={4} style={{ width: '100%' }} - placeholder="固定金额,不随 Leader 订单大小变化,必须 >= 1" + placeholder={t('templateEdit.fixedAmountPlaceholder') || '固定金额,不随 Leader 订单大小变化,必须 >= 1'} /> )} @@ -212,23 +218,23 @@ const TemplateEdit: React.FC = () => { {copyMode === 'RATIO' && ( <> = 1 USDC。例如:设置为 10,如果计算出的跟单金额小于 10,则跳过该订单。'} rules={[ { validator: (_, value) => { @@ -236,7 +242,7 @@ const TemplateEdit: React.FC = () => { return Promise.resolve() // 可选字段,允许为空 } if (typeof value === 'number' && value < 1) { - return Promise.reject(new Error('最小金额必须 >= 1')) + return Promise.reject(new Error(t('templateEdit.minOrderSizeError') || '最小金额必须 >= 1')) } return Promise.resolve() } @@ -248,29 +254,29 @@ const TemplateEdit: React.FC = () => { step={0.0001} precision={4} style={{ width: '100%' }} - placeholder="仅在比例模式下生效,必须 >= 1(可选)" + placeholder={t('templateEdit.minOrderSizePlaceholder') || '仅在比例模式下生效,必须 >= 1(可选)'} /> )} { step={0.1} precision={2} style={{ width: '100%' }} - placeholder="默认 5%(可选)" + placeholder={t('templateEdit.priceTolerancePlaceholder') || '默认 5%(可选)'} /> + + + + + + + + + + + + {/* 跟单卖出 - 表单最底部 */} + @@ -304,10 +353,10 @@ const TemplateEdit: React.FC = () => { loading={loading} disabled={hasErrors} > - 保存修改 + {t('templateEdit.save') || '保存修改'} ) diff --git a/frontend/src/pages/TemplateList.tsx b/frontend/src/pages/TemplateList.tsx index 4b5e0d2..eda8558 100644 --- a/frontend/src/pages/TemplateList.tsx +++ b/frontend/src/pages/TemplateList.tsx @@ -181,12 +181,6 @@ const TemplateList: React.FC = () => { ) }, - { - title: t('templateList.useCount') || '使用次数', - dataIndex: 'useCount', - key: 'useCount', - render: (count: number) => {count} - }, { title: t('common.createdAt') || '创建时间', dataIndex: 'createdAt', @@ -321,7 +315,6 @@ const TemplateList: React.FC = () => { {template.supportSell ? (t('templateList.supportSell') || '跟单卖出') : (t('templateList.notSupportSell') || '不跟单卖出')} - {template.useCount} {t('templateList.timesUsed') || '次使用'} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 4a6b01a..69b4fe1 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -359,19 +359,28 @@ export const apiService = { */ copyTrading: { /** - * 创建跟单 + * 创建跟单配置 + * 支持两种方式: + * 1. 提供 templateId:从模板填充配置,可以覆盖部分字段 + * 2. 不提供 templateId:手动输入所有配置参数 */ - create: (data: { accountId: number; templateId: number; leaderId: number; enabled?: boolean }) => + create: (data: any) => apiClient.post>('/copy-trading/create', data), + /** + * 更新跟单配置 + */ + update: (data: any) => + apiClient.post>('/copy-trading/update', data), + /** * 查询跟单列表 */ - list: (data: { accountId?: number; templateId?: number; leaderId?: number; enabled?: boolean } = {}) => + list: (data: { accountId?: number; leaderId?: number; enabled?: boolean } = {}) => apiClient.post>('/copy-trading/list', data), /** - * 更新跟单状态 + * 更新跟单状态(兼容旧接口) */ updateStatus: (data: { copyTradingId: number; enabled: boolean }) => apiClient.post>('/copy-trading/update-status', data), @@ -383,10 +392,23 @@ export const apiService = { apiClient.post>('/copy-trading/delete', data), /** - * 查询钱包绑定的模板 + * 查询钱包绑定的跟单配置(兼容旧接口) */ getAccountTemplates: (data: { accountId: number }) => - apiClient.post>('/copy-trading/account-templates', data) + apiClient.post>('/copy-trading/account-templates', data), + + /** + * 查询被过滤订单列表 + */ + getFilteredOrders: (data: { + copyTradingId: number + filterType?: string + page?: number + limit?: number + startTime?: number + endTime?: number + }) => + apiClient.post>('/copy-trading/filtered-orders', data) }, /** diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 4ba6b1d..d97e854 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -107,7 +107,10 @@ export interface CopyTradingTemplate { maxDailyOrders: number priceTolerance: string supportSell: boolean - useCount: number + // 过滤条件 + minOrderDepth?: string + maxSpread?: string + minOrderbookDepth?: string createdAt: number updatedAt: number } @@ -168,19 +171,36 @@ export interface TemplateCopyRequest { } /** - * 跟单关系(钱包-模板关联) + * 跟单配置(独立配置,不再绑定模板) */ export interface CopyTrading { id: number accountId: number accountName?: string walletAddress: string - templateId: number - templateName: string leaderId: number leaderName?: string leaderAddress: string enabled: boolean + // 跟单配置参数 + copyMode: 'RATIO' | 'FIXED' + copyRatio: string + fixedAmount?: string + maxOrderSize: string + minOrderSize: string + maxDailyLoss: string + maxDailyOrders: number + priceTolerance: string + delaySeconds: number + pollIntervalSeconds: number + useWebSocket: boolean + websocketReconnectInterval: number + websocketMaxRetries: number + supportSell: boolean + // 过滤条件 + minOrderDepth?: string + maxSpread?: string + minOrderbookDepth?: string createdAt: number updatedAt: number } @@ -195,12 +215,58 @@ export interface CopyTradingListResponse { /** * 跟单创建请求 + * 所有配置参数都需要手动输入,模板仅用于前端快速填充表单 */ export interface CopyTradingCreateRequest { accountId: number - templateId: number leaderId: number enabled?: boolean + // 跟单配置参数 + copyMode?: 'RATIO' | 'FIXED' + copyRatio?: string + fixedAmount?: string + maxOrderSize?: string + minOrderSize?: string + maxDailyLoss?: string + maxDailyOrders?: number + priceTolerance?: string + delaySeconds?: number + pollIntervalSeconds?: number + useWebSocket?: boolean + websocketReconnectInterval?: number + websocketMaxRetries?: number + supportSell?: boolean + // 过滤条件 + minOrderDepth?: string + maxSpread?: string + minOrderbookDepth?: string +} + +/** + * 跟单更新请求 + */ +export interface CopyTradingUpdateRequest { + copyTradingId: number + enabled?: boolean + // 跟单配置参数(可选,只更新提供的字段) + copyMode?: 'RATIO' | 'FIXED' + copyRatio?: string + fixedAmount?: string + maxOrderSize?: string + minOrderSize?: string + maxDailyLoss?: string + maxDailyOrders?: number + priceTolerance?: string + delaySeconds?: number + pollIntervalSeconds?: number + useWebSocket?: boolean + websocketReconnectInterval?: number + websocketMaxRetries?: number + supportSell?: boolean + // 过滤条件 + minOrderDepth?: string + maxSpread?: string + minOrderbookDepth?: string } /** @@ -600,6 +666,53 @@ export interface OrderTrackingRequest { buyOrderId?: string } +/** + * 被过滤订单信息 + */ +export interface FilteredOrder { + id: number + copyTradingId: number + accountId: number + accountName?: string + leaderId: number + leaderName?: string + leaderTradeId: string + marketId: string + marketTitle?: string + marketSlug?: string + side: 'BUY' | 'SELL' + outcomeIndex?: number + outcome?: string + price: string + size: string + calculatedQuantity?: string + filterReason: string + filterType: string + createdAt: number +} + +/** + * 被过滤订单列表请求 + */ +export interface FilteredOrderListRequest { + copyTradingId: number + filterType?: string + page?: number + limit?: number + startTime?: number + endTime?: number +} + +/** + * 被过滤订单列表响应 + */ +export interface FilteredOrderListResponse { + list: FilteredOrder[] + total: number + page: number + limit: number +} + /** * 消息推送配置 */