diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/CopyTradingController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/CopyTradingController.kt new file mode 100644 index 0000000..1d0db10 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/CopyTradingController.kt @@ -0,0 +1,168 @@ +package com.wrbug.polymarketbot.controller + +import com.wrbug.polymarketbot.dto.* +import com.wrbug.polymarketbot.service.CopyTradingService +import org.slf4j.LoggerFactory +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +/** + * 跟单配置管理控制器(钱包-模板关联) + */ +@RestController +@RequestMapping("/api/copy-trading") +class CopyTradingController( + private val copyTradingService: CopyTradingService +) { + + private val logger = LoggerFactory.getLogger(CopyTradingController::class.java) + + /** + * 创建跟单 + */ + @PostMapping("/create") + fun createCopyTrading(@RequestBody request: CopyTradingCreateRequest): ResponseEntity> { + return try { + if (request.accountId <= 0) { + return ResponseEntity.ok(ApiResponse.paramError("账户 ID 无效")) + } + if (request.templateId <= 0) { + return ResponseEntity.ok(ApiResponse.paramError("模板 ID 无效")) + } + if (request.leaderId <= 0) { + return ResponseEntity.ok(ApiResponse.paramError("Leader ID 无效")) + } + + val result = copyTradingService.createCopyTrading(request) + result.fold( + onSuccess = { copyTrading -> + logger.info("成功创建跟单: ${copyTrading.id}") + ResponseEntity.ok(ApiResponse.success(copyTrading)) + }, + onFailure = { e -> + logger.error("创建跟单失败: ${e.message}", e) + when (e) { + is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误")) + else -> ResponseEntity.ok(ApiResponse.serverError("创建跟单失败: ${e.message}")) + } + } + ) + } catch (e: Exception) { + logger.error("创建跟单异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.serverError("创建跟单失败: ${e.message}")) + } + } + + /** + * 查询跟单列表 + */ + @PostMapping("/list") + fun getCopyTradingList(@RequestBody request: CopyTradingListRequest): ResponseEntity> { + return try { + val result = copyTradingService.getCopyTradingList(request) + result.fold( + onSuccess = { response -> + ResponseEntity.ok(ApiResponse.success(response)) + }, + onFailure = { e -> + logger.error("查询跟单列表失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.serverError("查询跟单列表失败: ${e.message}")) + } + ) + } catch (e: Exception) { + logger.error("查询跟单列表异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.serverError("查询跟单列表失败: ${e.message}")) + } + } + + /** + * 更新跟单状态 + */ + @PostMapping("/update-status") + fun updateCopyTradingStatus(@RequestBody request: CopyTradingUpdateStatusRequest): ResponseEntity> { + return try { + if (request.copyTradingId <= 0) { + return ResponseEntity.ok(ApiResponse.paramError("跟单 ID 无效")) + } + + val result = copyTradingService.updateCopyTradingStatus(request) + result.fold( + onSuccess = { copyTrading -> + logger.info("成功更新跟单状态: ${copyTrading.id}, enabled=${copyTrading.enabled}") + ResponseEntity.ok(ApiResponse.success(copyTrading)) + }, + onFailure = { e -> + logger.error("更新跟单状态失败: ${e.message}", e) + when (e) { + is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误")) + is IllegalStateException -> ResponseEntity.ok(ApiResponse.businessError(e.message ?: "业务逻辑错误")) + else -> ResponseEntity.ok(ApiResponse.serverError("更新跟单状态失败: ${e.message}")) + } + } + ) + } catch (e: Exception) { + logger.error("更新跟单状态异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.serverError("更新跟单状态失败: ${e.message}")) + } + } + + /** + * 删除跟单 + */ + @PostMapping("/delete") + fun deleteCopyTrading(@RequestBody request: CopyTradingDeleteRequest): ResponseEntity> { + return try { + if (request.copyTradingId <= 0) { + return ResponseEntity.ok(ApiResponse.paramError("跟单 ID 无效")) + } + + val result = copyTradingService.deleteCopyTrading(request.copyTradingId) + result.fold( + onSuccess = { + logger.info("成功删除跟单: ${request.copyTradingId}") + ResponseEntity.ok(ApiResponse.success(Unit)) + }, + onFailure = { e -> + logger.error("删除跟单失败: ${e.message}", e) + when (e) { + is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误")) + else -> ResponseEntity.ok(ApiResponse.serverError("删除跟单失败: ${e.message}")) + } + } + ) + } catch (e: Exception) { + logger.error("删除跟单异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.serverError("删除跟单失败: ${e.message}")) + } + } + + /** + * 查询钱包绑定的模板 + */ + @PostMapping("/account-templates") + fun getAccountTemplates(@RequestBody request: AccountTemplatesRequest): ResponseEntity> { + return try { + if (request.accountId <= 0) { + return ResponseEntity.ok(ApiResponse.paramError("账户 ID 无效")) + } + + val result = copyTradingService.getAccountTemplates(request.accountId) + result.fold( + onSuccess = { response -> + ResponseEntity.ok(ApiResponse.success(response)) + }, + onFailure = { e -> + logger.error("查询钱包绑定的模板失败: ${e.message}", e) + when (e) { + is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误")) + else -> ResponseEntity.ok(ApiResponse.serverError("查询钱包绑定的模板失败: ${e.message}")) + } + } + ) + } catch (e: Exception) { + logger.error("查询钱包绑定的模板异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.serverError("查询钱包绑定的模板失败: ${e.message}")) + } + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/CopyTradingTemplateController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/CopyTradingTemplateController.kt new file mode 100644 index 0000000..2149bdb --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/CopyTradingTemplateController.kt @@ -0,0 +1,195 @@ +package com.wrbug.polymarketbot.controller + +import com.wrbug.polymarketbot.dto.* +import com.wrbug.polymarketbot.service.CopyTradingTemplateService +import org.slf4j.LoggerFactory +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +/** + * 跟单模板管理控制器 + */ +@RestController +@RequestMapping("/api/copy-trading/templates") +class CopyTradingTemplateController( + private val templateService: CopyTradingTemplateService +) { + + private val logger = LoggerFactory.getLogger(CopyTradingTemplateController::class.java) + + /** + * 创建模板 + */ + @PostMapping("/create") + fun createTemplate(@RequestBody request: TemplateCreateRequest): ResponseEntity> { + return try { + if (request.templateName.isBlank()) { + return ResponseEntity.ok(ApiResponse.paramError("模板名称不能为空")) + } + + val result = templateService.createTemplate(request) + result.fold( + onSuccess = { template -> + logger.info("成功创建模板: ${template.id}") + ResponseEntity.ok(ApiResponse.success(template)) + }, + onFailure = { e -> + logger.error("创建模板失败: ${e.message}", e) + when (e) { + is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误")) + else -> ResponseEntity.ok(ApiResponse.serverError("创建模板失败: ${e.message}")) + } + } + ) + } catch (e: Exception) { + logger.error("创建模板异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.serverError("创建模板失败: ${e.message}")) + } + } + + /** + * 更新模板 + */ + @PostMapping("/update") + fun updateTemplate(@RequestBody request: TemplateUpdateRequest): ResponseEntity> { + return try { + if (request.templateId <= 0) { + return ResponseEntity.ok(ApiResponse.paramError("模板 ID 无效")) + } + + val result = templateService.updateTemplate(request) + result.fold( + onSuccess = { template -> + logger.info("成功更新模板: ${template.id}") + ResponseEntity.ok(ApiResponse.success(template)) + }, + onFailure = { e -> + logger.error("更新模板失败: ${e.message}", e) + when (e) { + is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误")) + else -> ResponseEntity.ok(ApiResponse.serverError("更新模板失败: ${e.message}")) + } + } + ) + } catch (e: Exception) { + logger.error("更新模板异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.serverError("更新模板失败: ${e.message}")) + } + } + + /** + * 删除模板 + */ + @PostMapping("/delete") + fun deleteTemplate(@RequestBody request: TemplateDeleteRequest): ResponseEntity> { + return try { + if (request.templateId <= 0) { + return ResponseEntity.ok(ApiResponse.paramError("模板 ID 无效")) + } + + val result = templateService.deleteTemplate(request.templateId) + result.fold( + onSuccess = { + logger.info("成功删除模板: ${request.templateId}") + ResponseEntity.ok(ApiResponse.success(Unit)) + }, + onFailure = { e -> + logger.error("删除模板失败: ${e.message}", e) + when (e) { + is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误")) + is IllegalStateException -> ResponseEntity.ok(ApiResponse.businessError(e.message ?: "业务逻辑错误")) + else -> ResponseEntity.ok(ApiResponse.serverError("删除模板失败: ${e.message}")) + } + } + ) + } catch (e: Exception) { + logger.error("删除模板异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.serverError("删除模板失败: ${e.message}")) + } + } + + /** + * 复制模板 + */ + @PostMapping("/copy") + fun copyTemplate(@RequestBody request: TemplateCopyRequest): ResponseEntity> { + return try { + if (request.templateId <= 0) { + return ResponseEntity.ok(ApiResponse.paramError("模板 ID 无效")) + } + if (request.templateName.isBlank()) { + return ResponseEntity.ok(ApiResponse.paramError("新模板名称不能为空")) + } + + val result = templateService.copyTemplate(request) + result.fold( + onSuccess = { template -> + logger.info("成功复制模板: ${template.id}") + ResponseEntity.ok(ApiResponse.success(template)) + }, + onFailure = { e -> + logger.error("复制模板失败: ${e.message}", e) + when (e) { + is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误")) + else -> ResponseEntity.ok(ApiResponse.serverError("复制模板失败: ${e.message}")) + } + } + ) + } catch (e: Exception) { + logger.error("复制模板异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.serverError("复制模板失败: ${e.message}")) + } + } + + /** + * 查询模板列表 + */ + @PostMapping("/list") + fun getTemplateList(): ResponseEntity> { + return try { + val result = templateService.getTemplateList() + result.fold( + onSuccess = { response -> + ResponseEntity.ok(ApiResponse.success(response)) + }, + onFailure = { e -> + logger.error("查询模板列表失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.serverError("查询模板列表失败: ${e.message}")) + } + ) + } catch (e: Exception) { + logger.error("查询模板列表异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.serverError("查询模板列表失败: ${e.message}")) + } + } + + /** + * 查询模板详情 + */ + @PostMapping("/detail") + fun getTemplateDetail(@RequestBody request: TemplateDetailRequest): ResponseEntity> { + return try { + if (request.templateId <= 0) { + return ResponseEntity.ok(ApiResponse.paramError("模板 ID 无效")) + } + + val result = templateService.getTemplateDetail(request.templateId) + result.fold( + onSuccess = { template -> + ResponseEntity.ok(ApiResponse.success(template)) + }, + onFailure = { e -> + logger.error("查询模板详情失败: ${e.message}", e) + when (e) { + is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误")) + else -> ResponseEntity.ok(ApiResponse.serverError("查询模板详情失败: ${e.message}")) + } + } + ) + } catch (e: Exception) { + logger.error("查询模板详情异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.serverError("查询模板详情失败: ${e.message}")) + } + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/LeaderController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/LeaderController.kt new file mode 100644 index 0000000..dd83d9d --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/LeaderController.kt @@ -0,0 +1,170 @@ +package com.wrbug.polymarketbot.controller + +import com.wrbug.polymarketbot.dto.* +import com.wrbug.polymarketbot.service.LeaderService +import org.slf4j.LoggerFactory +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +/** + * Leader 管理控制器 + */ +@RestController +@RequestMapping("/api/copy-trading/leaders") +class LeaderController( + private val leaderService: LeaderService +) { + + private val logger = LoggerFactory.getLogger(LeaderController::class.java) + + /** + * 添加被跟单者 + */ + @PostMapping("/add") + fun addLeader(@RequestBody request: LeaderAddRequest): ResponseEntity> { + return try { + if (request.leaderAddress.isBlank()) { + return ResponseEntity.ok(ApiResponse.paramError("Leader 地址不能为空")) + } + + val result = leaderService.addLeader(request) + result.fold( + onSuccess = { leader -> + logger.info("成功添加 Leader: ${leader.id}") + ResponseEntity.ok(ApiResponse.success(leader)) + }, + onFailure = { e -> + logger.error("添加 Leader 失败: ${e.message}", e) + when (e) { + is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误")) + is IllegalStateException -> ResponseEntity.ok(ApiResponse.businessError(e.message ?: "业务逻辑错误")) + else -> ResponseEntity.ok(ApiResponse.serverError("添加 Leader 失败: ${e.message}")) + } + } + ) + } catch (e: Exception) { + logger.error("添加 Leader 异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.serverError("添加 Leader 失败: ${e.message}")) + } + } + + /** + * 更新被跟单者 + */ + @PostMapping("/update") + fun updateLeader(@RequestBody request: LeaderUpdateRequest): ResponseEntity> { + return try { + if (request.leaderId <= 0) { + return ResponseEntity.ok(ApiResponse.paramError("Leader ID 无效")) + } + + val result = leaderService.updateLeader(request) + result.fold( + onSuccess = { leader -> + logger.info("成功更新 Leader: ${leader.id}") + ResponseEntity.ok(ApiResponse.success(leader)) + }, + onFailure = { e -> + logger.error("更新 Leader 失败: ${e.message}", e) + when (e) { + is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误")) + else -> ResponseEntity.ok(ApiResponse.serverError("更新 Leader 失败: ${e.message}")) + } + } + ) + } catch (e: Exception) { + logger.error("更新 Leader 异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.serverError("更新 Leader 失败: ${e.message}")) + } + } + + /** + * 删除被跟单者 + */ + @PostMapping("/delete") + fun deleteLeader(@RequestBody request: LeaderDeleteRequest): ResponseEntity> { + return try { + if (request.leaderId <= 0) { + return ResponseEntity.ok(ApiResponse.paramError("Leader ID 无效")) + } + + val result = leaderService.deleteLeader(request.leaderId) + result.fold( + onSuccess = { + logger.info("成功删除 Leader: ${request.leaderId}") + ResponseEntity.ok(ApiResponse.success(Unit)) + }, + onFailure = { e -> + logger.error("删除 Leader 失败: ${e.message}", e) + when (e) { + is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误")) + is IllegalStateException -> ResponseEntity.ok(ApiResponse.businessError(e.message ?: "业务逻辑错误")) + else -> ResponseEntity.ok(ApiResponse.serverError("删除 Leader 失败: ${e.message}")) + } + } + ) + } catch (e: Exception) { + logger.error("删除 Leader 异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.serverError("删除 Leader 失败: ${e.message}")) + } + } + + /** + * 查询被跟单者列表 + */ + @PostMapping("/list") + fun getLeaderList(@RequestBody request: LeaderListRequest): ResponseEntity> { + return try { + val result = leaderService.getLeaderList(request) + result.fold( + onSuccess = { response -> + ResponseEntity.ok(ApiResponse.success(response)) + }, + onFailure = { e -> + logger.error("查询 Leader 列表失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.serverError("查询 Leader 列表失败: ${e.message}")) + } + ) + } catch (e: Exception) { + logger.error("查询 Leader 列表异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.serverError("查询 Leader 列表失败: ${e.message}")) + } + } + + /** + * 查询被跟单者详情 + */ + @PostMapping("/detail") + fun getLeaderDetail(@RequestBody request: LeaderDetailRequest): ResponseEntity> { + return try { + if (request.leaderId <= 0) { + return ResponseEntity.ok(ApiResponse.paramError("Leader ID 无效")) + } + + val result = leaderService.getLeaderDetail(request.leaderId) + result.fold( + onSuccess = { leader -> + ResponseEntity.ok(ApiResponse.success(leader)) + }, + onFailure = { e -> + logger.error("查询 Leader 详情失败: ${e.message}", e) + when (e) { + is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误")) + else -> ResponseEntity.ok(ApiResponse.serverError("查询 Leader 详情失败: ${e.message}")) + } + } + ) + } catch (e: Exception) { + logger.error("查询 Leader 详情异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.serverError("查询 Leader 详情失败: ${e.message}")) + } + } +} + +/** + * Leader 详情请求 + */ +data class LeaderDetailRequest( + val leaderId: Long +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingDto.kt new file mode 100644 index 0000000..36bd61f --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingDto.kt @@ -0,0 +1,91 @@ +package com.wrbug.polymarketbot.dto + +/** + * 跟单创建请求 + */ +data class CopyTradingCreateRequest( + val accountId: Long, + val templateId: Long, + val leaderId: Long, + val enabled: Boolean = true +) + +/** + * 跟单列表请求 + */ +data class CopyTradingListRequest( + val accountId: Long? = null, + val templateId: Long? = null, + val leaderId: Long? = null, + val enabled: Boolean? = null +) + +/** + * 跟单状态更新请求 + */ +data class CopyTradingUpdateStatusRequest( + val copyTradingId: Long, + val enabled: Boolean +) + +/** + * 跟单删除请求 + */ +data class CopyTradingDeleteRequest( + val copyTradingId: Long +) + +/** + * 查询钱包绑定的模板请求 + */ +data class AccountTemplatesRequest( + val accountId: Long +) + +/** + * 跟单信息响应 + */ +data class CopyTradingDto( + val id: Long, + 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 createdAt: Long, + val updatedAt: Long +) + +/** + * 跟单列表响应 + */ +data class CopyTradingListResponse( + val list: List, + val total: Long +) + +/** + * 钱包绑定的模板信息 + */ +data class AccountTemplateDto( + val templateId: Long, + val templateName: String, + val copyTradingId: Long, + val leaderId: Long, + val leaderName: String?, + val leaderAddress: String, + val enabled: Boolean +) + +/** + * 钱包绑定的模板列表响应 + */ +data class AccountTemplatesResponse( + val list: List, + val total: Long +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingTemplateDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingTemplateDto.kt new file mode 100644 index 0000000..caeaec6 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingTemplateDto.kt @@ -0,0 +1,114 @@ +package com.wrbug.polymarketbot.dto + +/** + * 模板创建请求 + */ +data class TemplateCreateRequest( + val templateName: String, + val copyMode: String = "RATIO", // "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 +) + +/** + * 模板更新请求 + */ +data class TemplateUpdateRequest( + val templateId: Long, + val templateName: String? = 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 +) + +/** + * 模板删除请求 + */ +data class TemplateDeleteRequest( + val templateId: Long +) + +/** + * 模板复制请求 + */ +data class TemplateCopyRequest( + val templateId: Long, + val templateName: String, + 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 +) + +/** + * 模板详情请求 + */ +data class TemplateDetailRequest( + val templateId: Long +) + +/** + * 模板信息响应 + */ +data class TemplateDto( + val id: Long, + val templateName: String, + 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 useCount: Long = 0, // 使用该模板的跟单数量 + val createdAt: Long, + val updatedAt: Long +) + +/** + * 模板列表响应 + */ +data class TemplateListResponse( + val list: List, + val total: Long +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/LeaderDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/LeaderDto.kt new file mode 100644 index 0000000..a7761dd --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/LeaderDto.kt @@ -0,0 +1,57 @@ +package com.wrbug.polymarketbot.dto + +/** + * Leader 添加请求 + */ +data class LeaderAddRequest( + val leaderAddress: String, + val leaderName: String? = null, + val category: String? = null // sports 或 crypto +) + +/** + * Leader 更新请求 + */ +data class LeaderUpdateRequest( + val leaderId: Long, + val leaderName: String? = null, + val category: String? = null +) + +/** + * Leader 删除请求 + */ +data class LeaderDeleteRequest( + val leaderId: Long +) + +/** + * Leader 列表请求 + */ +data class LeaderListRequest( + val category: String? = null // sports 或 crypto +) + +/** + * Leader 信息响应 + */ +data class LeaderDto( + val id: Long, + val leaderAddress: String, + val leaderName: String?, + val category: String?, + val copyTradingCount: Long = 0, // 跟单关系数量 + val totalOrders: Long? = null, // 总订单数(可选) + val totalPnl: String? = null, // 总盈亏(可选) + val createdAt: Long, + val updatedAt: Long +) + +/** + * Leader 列表响应 + */ +data class LeaderListResponse( + val list: List, + val total: Long +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyTrading.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyTrading.kt new file mode 100644 index 0000000..10b6ffc --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyTrading.kt @@ -0,0 +1,38 @@ +package com.wrbug.polymarketbot.entity + +import jakarta.persistence.* + +/** + * 跟单关系实体(钱包-模板关联,多对多关系) + */ +@Entity +@Table( + name = "copy_trading", + uniqueConstraints = [ + UniqueConstraint(columnNames = ["account_id", "template_id", "leader_id"]) + ] +) +data class CopyTrading( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @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 = "created_at", nullable = false) + val createdAt: Long = System.currentTimeMillis(), + + @Column(name = "updated_at", nullable = false) + var updatedAt: 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 new file mode 100644 index 0000000..90d2d7d --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyTradingTemplate.kt @@ -0,0 +1,68 @@ +package com.wrbug.polymarketbot.entity + +import jakarta.persistence.* +import java.math.BigDecimal +import com.wrbug.polymarketbot.util.toSafeBigDecimal + +/** + * 跟单模板实体 + */ +@Entity +@Table(name = "copy_trading_templates") +data class CopyTradingTemplate( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @Column(name = "template_name", unique = true, nullable = false, length = 100) + val templateName: String, // 模板名称 + + @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 = "created_at", nullable = false) + val createdAt: Long = System.currentTimeMillis(), + + @Column(name = "updated_at", nullable = false) + var updatedAt: Long = System.currentTimeMillis() +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/Leader.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/Leader.kt new file mode 100644 index 0000000..0f3e7ed --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/Leader.kt @@ -0,0 +1,38 @@ +package com.wrbug.polymarketbot.entity + +import jakarta.persistence.* +import com.wrbug.polymarketbot.util.CategoryValidator + +/** + * 被跟单者(Leader)实体 + */ +@Entity +@Table(name = "copy_trading_leaders") +data class Leader( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @Column(name = "leader_address", unique = true, nullable = false, length = 42) + val leaderAddress: String, // 钱包地址 + + @Column(name = "leader_name", length = 100) + val leaderName: String? = null, + + @Column(name = "category", length = 20) + val category: String? = null, // sports 或 crypto,null 表示不筛选 + + @Column(name = "created_at", nullable = false) + val createdAt: Long = System.currentTimeMillis(), + + @Column(name = "updated_at", nullable = false) + var updatedAt: Long = System.currentTimeMillis() +) { + init { + // 验证分类 + if (category != null) { + CategoryValidator.validate(category) + } + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CopyTradingRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CopyTradingRepository.kt new file mode 100644 index 0000000..9598c84 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CopyTradingRepository.kt @@ -0,0 +1,62 @@ +package com.wrbug.polymarketbot.repository + +import com.wrbug.polymarketbot.entity.CopyTrading +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +/** + * 跟单关系 Repository + */ +@Repository +interface CopyTradingRepository : JpaRepository { + + /** + * 根据账户ID查找跟单列表 + */ + fun findByAccountId(accountId: Long): List + + /** + * 根据模板ID查找跟单列表 + */ + fun findByTemplateId(templateId: Long): List + + /** + * 根据 Leader ID 查找跟单列表 + */ + fun findByLeaderId(leaderId: Long): List + + /** + * 根据账户ID和模板ID查找跟单列表 + */ + fun findByAccountIdAndTemplateId(accountId: Long, templateId: Long): List + + /** + * 根据账户ID、模板ID和Leader ID查找跟单 + */ + fun findByAccountIdAndTemplateIdAndLeaderId( + accountId: Long, + templateId: Long, + leaderId: Long + ): CopyTrading? + + /** + * 查找所有启用的跟单 + */ + fun findByEnabledTrue(): List + + /** + * 根据账户ID查找启用的跟单 + */ + fun findByAccountIdAndEnabledTrue(accountId: Long): List + + /** + * 统计使用指定模板的跟单数量 + */ + fun countByTemplateId(templateId: Long): Long + + /** + * 统计指定 Leader 的跟单数量 + */ + fun countByLeaderId(leaderId: Long): Long +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CopyTradingTemplateRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CopyTradingTemplateRepository.kt new file mode 100644 index 0000000..64e2b0a --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CopyTradingTemplateRepository.kt @@ -0,0 +1,28 @@ +package com.wrbug.polymarketbot.repository + +import com.wrbug.polymarketbot.entity.CopyTradingTemplate +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +/** + * 跟单模板 Repository + */ +@Repository +interface CopyTradingTemplateRepository : JpaRepository { + + /** + * 根据模板名称查找模板 + */ + fun findByTemplateName(templateName: String): CopyTradingTemplate? + + /** + * 检查模板名称是否存在 + */ + fun existsByTemplateName(templateName: String): Boolean + + /** + * 查找所有模板,按创建时间降序排序(最新的在前) + */ + fun findAllByOrderByCreatedAtDesc(): List +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/LeaderRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/LeaderRepository.kt new file mode 100644 index 0000000..f59f0bd --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/LeaderRepository.kt @@ -0,0 +1,33 @@ +package com.wrbug.polymarketbot.repository + +import com.wrbug.polymarketbot.entity.Leader +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +/** + * Leader Repository + */ +@Repository +interface LeaderRepository : JpaRepository { + + /** + * 根据钱包地址查找 Leader + */ + fun findByLeaderAddress(leaderAddress: String): Leader? + + /** + * 检查钱包地址是否存在 + */ + fun existsByLeaderAddress(leaderAddress: String): Boolean + + /** + * 根据分类查找 Leader 列表 + */ + fun findByCategory(category: String?): List + + /** + * 查找所有 Leader,按创建时间排序 + */ + fun findAllByOrderByCreatedAtAsc(): List +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingService.kt new file mode 100644 index 0000000..194bf63 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingService.kt @@ -0,0 +1,257 @@ +package com.wrbug.polymarketbot.service + +import com.wrbug.polymarketbot.dto.* +import com.wrbug.polymarketbot.entity.CopyTrading +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 org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * 跟单配置管理服务(钱包-模板关联) + */ +@Service +class CopyTradingService( + private val copyTradingRepository: CopyTradingRepository, + private val accountRepository: AccountRepository, + private val templateRepository: CopyTradingTemplateRepository, + private val leaderRepository: LeaderRepository +) { + + private val logger = LoggerFactory.getLogger(CopyTradingService::class.java) + + /** + * 创建跟单 + */ + @Transactional + fun createCopyTrading(request: CopyTradingCreateRequest): Result { + return try { + // 1. 验证账户是否存在 + 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 是否存在 + val leader = leaderRepository.findById(request.leaderId).orElse(null) + ?: return Result.failure(IllegalArgumentException("Leader 不存在")) + + // 4. 检查是否已存在相同的跟单关系 + val existing = copyTradingRepository.findByAccountIdAndTemplateIdAndLeaderId( + request.accountId, + request.templateId, + request.leaderId + ) + if (existing != null) { + return Result.failure(IllegalArgumentException("该跟单关系已存在")) + } + + // 5. 创建跟单关系 + val copyTrading = CopyTrading( + accountId = request.accountId, + templateId = request.templateId, + leaderId = request.leaderId, + enabled = request.enabled + ) + + val saved = copyTradingRepository.save(copyTrading) + logger.info("成功创建跟单: ${saved.id}, account=${request.accountId}, template=${request.templateId}, leader=${request.leaderId}") + + Result.success(toDto(saved, account, template, leader)) + } catch (e: Exception) { + logger.error("创建跟单失败", e) + Result.failure(e) + } + } + + /** + * 查询跟单列表 + */ + 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, + 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) + } + request.enabled != null && request.enabled -> { + copyTradingRepository.findByEnabledTrue() + } + else -> { + copyTradingRepository.findAll() + } + } + + // 过滤启用状态 + val filtered = if (request.enabled != null) { + copyTradings.filter { it.enabled == request.enabled } + } else { + copyTradings + } + + val dtos = filtered.map { 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}") + null + } else { + toDto(copyTrading, account, template, leader) + } + }.filterNotNull() + + Result.success( + CopyTradingListResponse( + list = dtos, + total = dtos.size.toLong() + ) + ) + } catch (e: Exception) { + logger.error("查询跟单列表失败", e) + Result.failure(e) + } + } + + /** + * 更新跟单状态 + */ + @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) + logger.info("成功更新跟单状态: ${saved.id}, enabled=${saved.enabled}") + + 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) + } + } + + /** + * 删除跟单 + */ + @Transactional + fun deleteCopyTrading(copyTradingId: Long): Result { + return try { + val copyTrading = copyTradingRepository.findById(copyTradingId).orElse(null) + ?: return Result.failure(IllegalArgumentException("跟单关系不存在")) + + copyTradingRepository.delete(copyTrading) + logger.info("成功删除跟单: $copyTradingId") + + Result.success(Unit) + } catch (e: Exception) { + logger.error("删除跟单失败", e) + Result.failure(e) + } + } + + /** + * 查询钱包绑定的模板 + */ + fun getAccountTemplates(accountId: Long): Result { + return try { + // 验证账户是否存在 + val account = accountRepository.findById(accountId).orElse(null) + ?: return Result.failure(IllegalArgumentException("账户不存在")) + + 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}") + null + } else { + AccountTemplateDto( + templateId = template.id!!, + templateName = template.templateName, + copyTradingId = copyTrading.id!!, + leaderId = leader.id!!, + leaderName = leader.leaderName, + leaderAddress = leader.leaderAddress, + enabled = copyTrading.enabled + ) + } + } + + Result.success( + AccountTemplatesResponse( + list = dtos, + total = dtos.size.toLong() + ) + ) + } catch (e: Exception) { + logger.error("查询钱包绑定的模板失败", e) + Result.failure(e) + } + } + + /** + * 转换为 DTO + */ + 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( + id = copyTrading.id!!, + 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, + createdAt = copyTrading.createdAt, + updatedAt = copyTrading.updatedAt + ) + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingTemplateService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingTemplateService.kt new file mode 100644 index 0000000..9d63a2e --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyTradingTemplateService.kt @@ -0,0 +1,264 @@ +package com.wrbug.polymarketbot.service + +import com.wrbug.polymarketbot.dto.* +import com.wrbug.polymarketbot.entity.CopyTradingTemplate +import com.wrbug.polymarketbot.repository.CopyTradingRepository +import com.wrbug.polymarketbot.repository.CopyTradingTemplateRepository +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 CopyTradingTemplateService( + private val templateRepository: CopyTradingTemplateRepository, + private val copyTradingRepository: CopyTradingRepository +) { + + private val logger = LoggerFactory.getLogger(CopyTradingTemplateService::class.java) + + /** + * 创建模板 + */ + @Transactional + fun createTemplate(request: TemplateCreateRequest): Result { + return try { + // 1. 验证模板名称 + if (request.templateName.isBlank()) { + return Result.failure(IllegalArgumentException("模板名称不能为空")) + } + + // 2. 检查模板名称是否已存在 + if (templateRepository.existsByTemplateName(request.templateName)) { + return Result.failure(IllegalArgumentException("模板名称已存在")) + } + + // 3. 验证 copyMode + if (request.copyMode !in listOf("RATIO", "FIXED")) { + return Result.failure(IllegalArgumentException("copyMode 必须是 RATIO 或 FIXED")) + } + + // 4. 创建模板 + val template = CopyTradingTemplate( + templateName = request.templateName, + 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 + ) + + val saved = templateRepository.save(template) + logger.info("成功创建模板: ${saved.id}, ${saved.templateName}") + + Result.success(toDto(saved)) + } catch (e: Exception) { + logger.error("创建模板失败", e) + Result.failure(e) + } + } + + /** + * 更新模板 + */ + @Transactional + fun updateTemplate(request: TemplateUpdateRequest): Result { + return try { + val template = templateRepository.findById(request.templateId).orElse(null) + ?: return Result.failure(IllegalArgumentException("模板不存在")) + + // 如果提供了模板名称,验证名称唯一性 + if (request.templateName != null) { + if (request.templateName.isBlank()) { + return Result.failure(IllegalArgumentException("模板名称不能为空")) + } + // 如果新名称与当前名称不同,检查是否已存在 + if (request.templateName != template.templateName) { + if (templateRepository.existsByTemplateName(request.templateName)) { + return Result.failure(IllegalArgumentException("模板名称已存在")) + } + } + } + + // 验证 copyMode + if (request.copyMode != null && request.copyMode !in listOf("RATIO", "FIXED")) { + return Result.failure(IllegalArgumentException("copyMode 必须是 RATIO 或 FIXED")) + } + + val updated = template.copy( + templateName = request.templateName ?: template.templateName, + 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, + updatedAt = System.currentTimeMillis() + ) + + val saved = templateRepository.save(updated) + logger.info("成功更新模板: ${saved.id}") + + Result.success(toDto(saved)) + } catch (e: Exception) { + logger.error("更新模板失败", e) + Result.failure(e) + } + } + + /** + * 删除模板 + */ + @Transactional + fun deleteTemplate(templateId: Long): Result { + return try { + 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) + logger.info("成功删除模板: $templateId") + + Result.success(Unit) + } catch (e: Exception) { + logger.error("删除模板失败", e) + Result.failure(e) + } + } + + /** + * 复制模板 + */ + @Transactional + fun copyTemplate(request: TemplateCopyRequest): Result { + return try { + val sourceTemplate = templateRepository.findById(request.templateId).orElse(null) + ?: return Result.failure(IllegalArgumentException("源模板不存在")) + + // 检查新模板名称是否已存在 + if (templateRepository.existsByTemplateName(request.templateName)) { + return Result.failure(IllegalArgumentException("模板名称已存在")) + } + + // 创建新模板 + val newTemplate = CopyTradingTemplate( + templateName = request.templateName, + copyMode = request.copyMode ?: sourceTemplate.copyMode, + copyRatio = request.copyRatio?.toSafeBigDecimal() ?: sourceTemplate.copyRatio, + fixedAmount = request.fixedAmount?.toSafeBigDecimal() ?: sourceTemplate.fixedAmount, + maxOrderSize = request.maxOrderSize?.toSafeBigDecimal() ?: sourceTemplate.maxOrderSize, + minOrderSize = request.minOrderSize?.toSafeBigDecimal() ?: sourceTemplate.minOrderSize, + maxDailyLoss = request.maxDailyLoss?.toSafeBigDecimal() ?: sourceTemplate.maxDailyLoss, + maxDailyOrders = request.maxDailyOrders ?: sourceTemplate.maxDailyOrders, + priceTolerance = request.priceTolerance?.toSafeBigDecimal() ?: sourceTemplate.priceTolerance, + delaySeconds = request.delaySeconds ?: sourceTemplate.delaySeconds, + pollIntervalSeconds = request.pollIntervalSeconds ?: sourceTemplate.pollIntervalSeconds, + useWebSocket = request.useWebSocket ?: sourceTemplate.useWebSocket, + websocketReconnectInterval = request.websocketReconnectInterval ?: sourceTemplate.websocketReconnectInterval, + websocketMaxRetries = request.websocketMaxRetries ?: sourceTemplate.websocketMaxRetries, + supportSell = request.supportSell ?: sourceTemplate.supportSell + ) + + val saved = templateRepository.save(newTemplate) + logger.info("成功复制模板: ${sourceTemplate.id} -> ${saved.id}") + + Result.success(toDto(saved)) + } catch (e: Exception) { + logger.error("复制模板失败", e) + Result.failure(e) + } + } + + /** + * 查询模板列表 + */ + fun getTemplateList(): Result { + return try { + val templates = templateRepository.findAllByOrderByCreatedAtDesc() + val templateDtos = templates.map { template -> + val useCount = copyTradingRepository.countByTemplateId(template.id!!) + toDto(template, useCount) + } + + Result.success( + TemplateListResponse( + list = templateDtos, + total = templateDtos.size.toLong() + ) + ) + } catch (e: Exception) { + logger.error("查询模板列表失败", e) + Result.failure(e) + } + } + + /** + * 查询模板详情 + */ + fun getTemplateDetail(templateId: Long): Result { + return try { + val template = templateRepository.findById(templateId).orElse(null) + ?: return Result.failure(IllegalArgumentException("模板不存在")) + + val useCount = copyTradingRepository.countByTemplateId(templateId) + Result.success(toDto(template, useCount)) + } catch (e: Exception) { + logger.error("查询模板详情失败", e) + Result.failure(e) + } + } + + /** + * 转换为 DTO + */ + private fun toDto(template: CopyTradingTemplate, useCount: Long = 0): TemplateDto { + return TemplateDto( + id = template.id!!, + templateName = template.templateName, + copyMode = template.copyMode, + copyRatio = template.copyRatio.toPlainString(), + fixedAmount = template.fixedAmount?.toPlainString(), + maxOrderSize = template.maxOrderSize.toPlainString(), + minOrderSize = template.minOrderSize.toPlainString(), + maxDailyLoss = template.maxDailyLoss.toPlainString(), + maxDailyOrders = template.maxDailyOrders, + priceTolerance = template.priceTolerance.toPlainString(), + delaySeconds = template.delaySeconds, + pollIntervalSeconds = template.pollIntervalSeconds, + useWebSocket = template.useWebSocket, + websocketReconnectInterval = template.websocketReconnectInterval, + websocketMaxRetries = template.websocketMaxRetries, + supportSell = template.supportSell, + useCount = useCount, + createdAt = template.createdAt, + updatedAt = template.updatedAt + ) + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/LeaderService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/LeaderService.kt new file mode 100644 index 0000000..7035181 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/LeaderService.kt @@ -0,0 +1,194 @@ +package com.wrbug.polymarketbot.service + +import com.wrbug.polymarketbot.dto.* +import com.wrbug.polymarketbot.entity.Leader +import com.wrbug.polymarketbot.repository.AccountRepository +import com.wrbug.polymarketbot.repository.CopyTradingRepository +import com.wrbug.polymarketbot.repository.LeaderRepository +import com.wrbug.polymarketbot.util.CategoryValidator +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * Leader 管理服务 + */ +@Service +class LeaderService( + private val leaderRepository: LeaderRepository, + private val accountRepository: AccountRepository, + private val copyTradingRepository: CopyTradingRepository +) { + + private val logger = LoggerFactory.getLogger(LeaderService::class.java) + + /** + * 添加被跟单者 + */ + @Transactional + fun addLeader(request: LeaderAddRequest): Result { + return try { + // 1. 验证地址格式 + if (!isValidWalletAddress(request.leaderAddress)) { + return Result.failure(IllegalArgumentException("无效的钱包地址格式")) + } + + // 2. 验证分类 + if (request.category != null) { + CategoryValidator.validate(request.category) + } + + // 3. 检查是否已存在 + if (leaderRepository.existsByLeaderAddress(request.leaderAddress)) { + return Result.failure(IllegalArgumentException("该 Leader 地址已存在")) + } + + // 4. 验证 Leader 地址不能与自己的地址相同 + if (accountRepository.existsByWalletAddress(request.leaderAddress)) { + return Result.failure(IllegalArgumentException("Leader 地址不能与自己的账户地址相同")) + } + + // 5. 创建 Leader + val leader = Leader( + leaderAddress = request.leaderAddress, + leaderName = request.leaderName, + category = request.category + ) + + val saved = leaderRepository.save(leader) + logger.info("成功添加 Leader: ${saved.id}, ${saved.leaderAddress}") + + Result.success(toDto(saved)) + } catch (e: Exception) { + logger.error("添加 Leader 失败", e) + Result.failure(e) + } + } + + /** + * 更新被跟单者 + */ + @Transactional + fun updateLeader(request: LeaderUpdateRequest): Result { + return try { + val leader = leaderRepository.findById(request.leaderId).orElse(null) + ?: return Result.failure(IllegalArgumentException("Leader 不存在")) + + // 验证分类 + if (request.category != null) { + CategoryValidator.validate(request.category) + } + + val updated = leader.copy( + leaderName = request.leaderName ?: leader.leaderName, + category = request.category ?: leader.category, + updatedAt = System.currentTimeMillis() + ) + + val saved = leaderRepository.save(updated) + logger.info("成功更新 Leader: ${saved.id}") + + Result.success(toDto(saved)) + } catch (e: Exception) { + logger.error("更新 Leader 失败", e) + Result.failure(e) + } + } + + /** + * 删除被跟单者 + */ + @Transactional + fun deleteLeader(leaderId: Long): Result { + return try { + val leader = leaderRepository.findById(leaderId).orElse(null) + ?: return Result.failure(IllegalArgumentException("Leader 不存在")) + + // 检查是否有跟单关系 + val copyTradingCount = copyTradingRepository.countByLeaderId(leaderId) + if (copyTradingCount > 0) { + return Result.failure(IllegalStateException("该 Leader 还有 $copyTradingCount 个跟单关系,请先删除跟单关系")) + } + + leaderRepository.delete(leader) + logger.info("成功删除 Leader: $leaderId") + + Result.success(Unit) + } catch (e: Exception) { + logger.error("删除 Leader 失败", e) + Result.failure(e) + } + } + + /** + * 查询 Leader 列表 + */ + fun getLeaderList(request: LeaderListRequest): Result { + return try { + // 验证分类 + if (request.category != null) { + CategoryValidator.validate(request.category) + } + + val leaders = if (request.category != null) { + leaderRepository.findByCategory(request.category) + } else { + leaderRepository.findAllByOrderByCreatedAtAsc() + } + + val leaderDtos = leaders.map { leader -> + val copyTradingCount = copyTradingRepository.countByLeaderId(leader.id!!) + toDto(leader, copyTradingCount) + } + + Result.success( + LeaderListResponse( + list = leaderDtos, + total = leaderDtos.size.toLong() + ) + ) + } catch (e: Exception) { + logger.error("查询 Leader 列表失败", e) + Result.failure(e) + } + } + + /** + * 查询 Leader 详情 + */ + fun getLeaderDetail(leaderId: Long): Result { + return try { + val leader = leaderRepository.findById(leaderId).orElse(null) + ?: return Result.failure(IllegalArgumentException("Leader 不存在")) + + val copyTradingCount = copyTradingRepository.countByLeaderId(leaderId) + Result.success(toDto(leader, copyTradingCount)) + } catch (e: Exception) { + logger.error("查询 Leader 详情失败", e) + Result.failure(e) + } + } + + /** + * 转换为 DTO + */ + private fun toDto(leader: Leader, copyTradingCount: Long = 0): LeaderDto { + return LeaderDto( + id = leader.id!!, + leaderAddress = leader.leaderAddress, + leaderName = leader.leaderName, + category = leader.category, + copyTradingCount = copyTradingCount, + createdAt = leader.createdAt, + updatedAt = leader.updatedAt + ) + } + + /** + * 验证钱包地址格式 + */ + private fun isValidWalletAddress(address: String): Boolean { + return address.startsWith("0x") && address.length == 42 + } +} + diff --git a/backend/src/main/resources/db/migration/V5__create_copy_trading_leaders_table.sql b/backend/src/main/resources/db/migration/V5__create_copy_trading_leaders_table.sql new file mode 100644 index 0000000..fcbd644 --- /dev/null +++ b/backend/src/main/resources/db/migration/V5__create_copy_trading_leaders_table.sql @@ -0,0 +1,12 @@ +-- 创建被跟单者(Leader)表 +CREATE TABLE IF NOT EXISTS copy_trading_leaders ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + leader_address VARCHAR(42) NOT NULL UNIQUE COMMENT '被跟单者的钱包地址', + leader_name VARCHAR(100) NULL COMMENT '被跟单者名称', + category VARCHAR(20) NULL COMMENT '分类筛选(sports/crypto),null表示不筛选', + created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)', + updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)', + INDEX idx_leader_address (leader_address), + INDEX idx_category (category) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='被跟单者表'; + diff --git a/backend/src/main/resources/db/migration/V6__create_copy_trading_templates_table.sql b/backend/src/main/resources/db/migration/V6__create_copy_trading_templates_table.sql new file mode 100644 index 0000000..49c0a12 --- /dev/null +++ b/backend/src/main/resources/db/migration/V6__create_copy_trading_templates_table.sql @@ -0,0 +1,23 @@ +-- 创建跟单模板表 +CREATE TABLE IF NOT EXISTS copy_trading_templates ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + template_name VARCHAR(100) NOT NULL UNIQUE COMMENT '模板名称', + copy_mode VARCHAR(10) NOT NULL DEFAULT 'RATIO' COMMENT '跟单金额模式(RATIO/FIXED)', + copy_ratio DECIMAL(10, 2) NOT NULL DEFAULT 1.00 COMMENT '跟单比例(仅在copyMode=RATIO时生效)', + fixed_amount DECIMAL(20, 8) NULL COMMENT '固定跟单金额(仅在copyMode=FIXED时生效)', + max_order_size DECIMAL(20, 8) NOT NULL DEFAULT 1000.00000000 COMMENT '单笔订单最大金额(USDC)', + min_order_size DECIMAL(20, 8) NOT NULL DEFAULT 1.00000000 COMMENT '单笔订单最小金额(USDC)', + max_daily_loss DECIMAL(20, 8) NOT NULL DEFAULT 10000.00000000 COMMENT '每日最大亏损限制(USDC)', + max_daily_orders INT NOT NULL DEFAULT 100 COMMENT '每日最大跟单订单数', + price_tolerance DECIMAL(5, 2) NOT NULL DEFAULT 5.00 COMMENT '价格容忍度(百分比,0-100)', + delay_seconds INT NOT NULL DEFAULT 0 COMMENT '跟单延迟(秒,默认0立即跟单)', + poll_interval_seconds INT NOT NULL DEFAULT 5 COMMENT '轮询间隔(秒,仅在WebSocket不可用时使用)', + use_websocket BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否优先使用WebSocket推送', + websocket_reconnect_interval INT NOT NULL DEFAULT 5000 COMMENT 'WebSocket重连间隔(毫秒)', + websocket_max_retries INT NOT NULL DEFAULT 10 COMMENT 'WebSocket最大重试次数', + support_sell BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否支持跟单卖出', + created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)', + updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)', + INDEX idx_template_name (template_name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='跟单模板表'; + diff --git a/backend/src/main/resources/db/migration/V7__create_copy_trading_table.sql b/backend/src/main/resources/db/migration/V7__create_copy_trading_table.sql new file mode 100644 index 0000000..6672a31 --- /dev/null +++ b/backend/src/main/resources/db/migration/V7__create_copy_trading_table.sql @@ -0,0 +1,19 @@ +-- 创建跟单关系表(钱包-模板关联,多对多关系) +CREATE TABLE IF NOT EXISTS copy_trading ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + account_id BIGINT NOT NULL COMMENT '钱包账户ID', + template_id BIGINT NOT NULL COMMENT '模板ID', + leader_id BIGINT NOT NULL COMMENT 'Leader ID', + enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用', + created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)', + updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)', + UNIQUE KEY uk_account_template_leader (account_id, template_id, leader_id), + INDEX idx_account_id (account_id), + INDEX idx_template_id (template_id), + INDEX idx_leader_id (leader_id), + INDEX idx_enabled (enabled), + FOREIGN KEY (account_id) REFERENCES copy_trading_accounts(id) ON DELETE CASCADE, + FOREIGN KEY (template_id) REFERENCES copy_trading_templates(id) ON DELETE RESTRICT, + FOREIGN KEY (leader_id) REFERENCES copy_trading_leaders(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='跟单关系表(钱包-模板关联)'; + diff --git a/docs/copy-trading-requirements.md b/docs/copy-trading-requirements.md index d17de90..a4f3d29 100644 --- a/docs/copy-trading-requirements.md +++ b/docs/copy-trading-requirements.md @@ -146,18 +146,18 @@ ### 2.2 被跟单者(Leader)管理 -#### 2.1.1 添加被跟单者 +#### 2.2.1 添加被跟单者 - **功能**:添加新的被跟单者 - **输入参数**: - `leaderAddress`: 被跟单者的钱包地址(必需) - `leaderName`: 被跟单者名称(可选,用于显示) - `category`: 分类筛选(sports/crypto,可选,仅跟单该分类的交易) - - `enabled`: 是否启用跟单(默认 true) - **业务规则**: - 验证地址格式 - 检查是否已存在 - 支持分类筛选 - 验证 Leader 地址不能与自己的地址相同 + - **注意**:Leader 的启用状态由跟单关系管理,不再在 Leader 层面设置 #### 2.2.2 删除被跟单者 - **功能**:删除被跟单者,停止跟单 @@ -167,60 +167,207 @@ - 删除前取消所有相关的跟单订单 - 保留历史跟单记录 -#### 2.1.3 更新被跟单者 -- **功能**:更新被跟单者配置 +#### 2.2.3 更新被跟单者 +- **功能**:更新被跟单者信息 - **输入参数**: - `leaderId`: 被跟单者ID - `leaderName`: 名称(可选) - `category`: 分类筛选(可选) - - `enabled`: 是否启用(可选) +- **业务规则**: + - 不能修改地址 + - 启用状态由跟单关系管理,不在此处设置 -#### 2.1.4 查询被跟单者列表 +#### 2.2.4 查询被跟单者列表 - **功能**:获取所有被跟单者列表 - **输入参数**: - - `enabled`: 是否只返回启用的(可选) - `category`: 分类筛选(可选) - **返回数据**: - 被跟单者列表 - 每个 Leader 的统计信息(跟单订单数、盈亏等) + - 每个 Leader 的跟单关系数量 -### 2.3 跟单配置管理 +### 2.3 跟单模板管理 -#### 2.2.1 全局跟单配置 -- **功能**:设置全局跟单参数 -- **配置项**: +#### 2.3.1 模板概念 +- **功能**:创建和管理跟单模板,模板包含跟单参数配置 +- **模板作用**:模板可以被多个钱包复用,实现配置的统一管理 +- **模板与钱包关系**:多对多关系,一个钱包可以绑定多个模板,一个模板可以被多个钱包使用 + +#### 2.3.2 创建模板 +- **功能**:创建新的跟单模板 +- **输入参数**: + - `templateName`: 模板名称(必需,用于标识模板) + - 说明:模板的唯一标识名称,用于区分不同的跟单配置模板 + - 限制:模板名称必须唯一,不能与其他模板重名 - `copyMode`: 跟单金额模式("RATIO" 或 "FIXED",默认 "RATIO") - - `RATIO`: 比例模式,跟单金额 = Leader 订单金额 × copyRatio + - 说明:选择跟单金额的计算方式 + - `RATIO`: 比例模式,跟单金额 = Leader 订单金额 × (copyRatio / 100) + - 适用场景:希望跟单金额随 Leader 订单大小按比例变化 + - 例如:Leader 买入 100 USDC,跟单比例 50%,则跟单金额为 50 USDC - `FIXED`: 固定金额模式,跟单金额 = fixedAmount(固定值) - - `copyRatio`: 跟单比例(0.1-10.0,默认 1.0,仅在 copyMode="RATIO" 时生效) - - `fixedAmount`: 固定跟单金额(USDC,仅在 copyMode="FIXED" 时生效) - - `maxOrderSize`: 单笔订单最大金额(USDC) - - `minOrderSize`: 单笔订单最小金额(USDC) - - `maxDailyLoss`: 每日最大亏损限制(USDC) - - `maxDailyOrders`: 每日最大跟单订单数 - - `priceTolerance`: 价格容忍度(百分比,0-100,默认 5%) - - `delaySeconds`: 跟单延迟(秒,默认 0,立即跟单) - - `useWebSocket`: 是否优先使用 WebSocket 推送(默认 true) - - `websocketReconnectInterval`: WebSocket 重连间隔(毫秒,默认 5000) - - `websocketMaxRetries`: WebSocket 最大重试次数(默认 10) - - `enabled`: 是否启用全局跟单(默认 true) + - 适用场景:希望无论 Leader 订单大小如何,跟单金额都固定不变 + - 例如:无论 Leader 买入多少,跟单金额始终为 10 USDC + - `copyRatio`: 跟单比例(可选,百分比格式,10%-1000%,默认 100%,仅在 copyMode="RATIO" 时生效) + - 说明:跟单比例表示跟单金额相对于 Leader 订单金额的百分比 + - 例如:100% 表示跟单金额 = Leader 订单金额 × 1.0(1:1 跟单) + - 例如:50% 表示跟单金额 = Leader 订单金额 × 0.5(半仓跟单) + - 例如:200% 表示跟单金额 = Leader 订单金额 × 2.0(双倍跟单) + - 后端存储:百分比值除以 100 后存储为小数(如 100% 存储为 1.0) + - `fixedAmount`: 固定跟单金额(可选,USDC,仅在 copyMode="FIXED" 时生效,必须 >= 1) + - 说明:固定金额模式下,每次跟单的固定金额,不随 Leader 订单大小变化 + - 限制:必须 >= 1 USDC + - 例如:设置为 10,则无论 Leader 买入多少,跟单金额始终为 10 USDC + - `maxOrderSize`: 单笔订单最大金额(可选,USDC,仅在 copyMode="RATIO" 时生效) + - 说明:比例模式下,限制单笔跟单订单的最大金额上限 + - 作用:防止跟单金额过大,控制风险 + - 例如:设置为 1000,即使计算出的跟单金额超过 1000,也会限制为 1000 USDC + - `minOrderSize`: 单笔订单最小金额(可选,USDC,仅在 copyMode="RATIO" 时生效,必须 >= 1) + - 说明:比例模式下,限制单笔跟单订单的最小金额下限 + - 作用:过滤掉金额过小的订单,避免频繁小额交易 + - 限制:如果填写,必须 >= 1 USDC + - 例如:设置为 10,如果计算出的跟单金额小于 10,则跳过该订单 + - `maxDailyOrders`: 每日最大跟单订单数(可选,默认 100) + - 说明:限制每日最多跟单的订单数量,用于风险控制 + - 作用:防止过度交易,控制每日交易频率 + - 例如:设置为 50,当日跟单订单数达到 50 后,停止跟单,次日重置 + - `priceTolerance`: 价格容忍度(可选,百分比,0-100,默认 5%) + - 说明:允许跟单价格在 Leader 价格基础上的调整范围 + - 作用:在 Leader 价格 ± 容忍度范围内调整价格,提高成交率 + - 例如:设置为 5%,Leader 价格为 0.5,则跟单价格可在 0.475-0.525 范围内 + - `supportSell`: 跟单卖出(可选,默认 true) + - 说明:是否跟单 Leader 的卖出订单 + - true: 跟单 Leader 的买入和卖出订单 + - false: 只跟单 Leader 的买入订单,忽略卖出订单 +- **业务规则**: + - 模板名称必须唯一 + - 模板创建后可以被多个钱包使用 + - 修改模板会影响所有使用该模板的跟单 + - **金额校验**(前后端都需要校验,校验失败时不允许创建/更新模板): + - `minOrderSize` 必须 >= 1(仅在 copyMode="RATIO" 时生效,如果填写了该字段) + - 前端校验:表单提交前检查,如果填写了 `minOrderSize` 且值 < 1,显示错误提示并阻止提交 + - 后端校验:接收请求时检查,如果 `minOrderSize` < 1,返回错误响应,不允许创建模板 + - `fixedAmount` 必须 >= 1(仅在 copyMode="FIXED" 时生效,必填) + - 前端校验:表单提交前检查,如果 `fixedAmount` < 1,显示错误提示并阻止提交 + - 后端校验:接收请求时检查,如果 `fixedAmount` < 1,返回错误响应,不允许创建模板 + - **固定金额模式限制**: + - 固定金额模式下,不应用 `maxOrderSize` 和 `minOrderSize` 限制 + - 固定金额模式下,跟单金额始终等于 `fixedAmount` -#### 2.2.2 单个 Leader 的跟单配置 -- **功能**:为每个 Leader 设置独立的跟单参数 -- **配置项**: - - `leaderId`: 被跟单者ID - - `accountId`: 指定使用的账户ID(可选,不指定则使用默认账户) - - `copyMode`: 跟单金额模式("RATIO" 或 "FIXED",覆盖全局配置) - - `copyRatio`: 跟单比例(覆盖全局配置,仅在 copyMode="RATIO" 时生效) - - `fixedAmount`: 固定跟单金额(覆盖全局配置,仅在 copyMode="FIXED" 时生效) - - `maxOrderSize`: 单笔订单最大金额(覆盖全局配置) - - `minOrderSize`: 单笔订单最小金额(覆盖全局配置) - - `enabled`: 是否启用该 Leader 的跟单(覆盖全局配置) - - `category`: 分类筛选(sports/crypto) +#### 2.3.3 更新模板 +- **功能**:修改现有模板的配置 +- **输入参数**: + - `templateId`: 模板ID(必需) + - `templateName`: 模板名称(可选,用于修改模板名称) + - 其他参数同创建模板(可选,只更新提供的字段) +- **业务规则**: + - 可以修改模板名称,但新名称必须唯一,不能与其他模板重名 + - 修改模板会影响所有使用该模板的跟单 + - 建议在修改前提示用户影响范围 + - **金额校验**(前后端都需要校验): + - `minOrderSize` 必须 >= 1(仅在 copyMode="RATIO" 时生效) + - `fixedAmount` 必须 >= 1(仅在 copyMode="FIXED" 时生效) + - **固定金额模式限制**: + - 固定金额模式下,不应用 `maxOrderSize` 和 `minOrderSize` 限制 -### 2.4 订单同步与执行 +#### 2.3.4 删除模板 +- **功能**:删除模板 +- **输入参数**: + - `templateId`: 模板ID(必需) +- **业务规则**: + - 删除前检查是否有跟单正在使用该模板 + - 如果有跟单使用,提示用户先解除绑定或删除跟单 + - 删除模板不会影响历史跟单记录 -#### 2.3.1 监控 Leader 交易 +#### 2.3.5 复制模板 +- **功能**:基于现有模板创建新模板 +- **输入参数**: + - `templateId`: 源模板ID(必需) + - `templateName`: 新模板名称(必需) + - 其他参数可选(覆盖源模板的配置) +- **业务规则**: + - 新模板名称必须唯一 + - 复制后可以独立修改,不影响源模板 + +#### 2.3.6 查询模板列表 +- **功能**:获取所有模板列表 +- **返回数据**: + - 模板列表(每个模板包含): + - 模板ID + - 模板名称 + - 所有配置参数 + - 使用该模板的跟单数量 + - 创建时间 + - 更新时间 + +#### 2.3.7 查询模板详情 +- **功能**:获取指定模板的详细信息 +- **输入参数**: + - `templateId`: 模板ID(必需) +- **返回数据**: + - 模板的所有配置参数 + - 使用该模板的跟单列表 + +### 2.4 跟单配置管理(钱包-模板关联) + +#### 2.4.1 创建跟单 +- **功能**:为钱包绑定模板,创建跟单关系 +- **输入参数**: + - `accountId`: 钱包账户ID(必需) + - `templateId`: 模板ID(必需) + - `leaderId`: Leader ID(必需,指定跟单哪个 Leader) + - `enabled`: 是否启用(默认 true) +- **业务规则**: + - 一个钱包可以绑定多个模板(多对多关系) + - 一个模板可以被多个钱包使用 + - 每个跟单关系对应一个 Leader + - 创建后默认启用状态 + +#### 2.4.2 查询跟单列表 +- **功能**:查询所有跟单关系 +- **筛选条件**: + - `accountId`: 按钱包筛选(可选) + - `templateId`: 按模板筛选(可选) + - `leaderId`: 按 Leader 筛选(可选) + - `enabled`: 按启用状态筛选(可选) +- **返回数据**: + - 跟单列表(每个跟单包含): + - 跟单ID + - 钱包信息(账户ID、钱包地址、账户名称) + - 模板信息(模板ID、模板名称) + - Leader 信息(Leader ID、Leader 地址、Leader 名称) + - 启用状态 + - 创建时间 + - 更新时间 + +#### 2.4.3 更新跟单状态 +- **功能**:开启或停止跟单 +- **输入参数**: + - `copyTradingId`: 跟单ID(必需) + - `enabled`: 启用状态(true/false) +- **业务规则**: + - 停止跟单后,不再监控该 Leader 的交易 + - 已创建的跟单订单不受影响 + - 可以随时开启或停止 + +#### 2.4.4 删除跟单 +- **功能**:删除跟单关系 +- **输入参数**: + - `copyTradingId`: 跟单ID(必需) +- **业务规则**: + - 删除前自动停止跟单 + - 删除后不再监控该 Leader 的交易 + - 保留历史跟单记录 + +#### 2.4.5 查询钱包绑定的模板 +- **功能**:查询指定钱包绑定的所有模板 +- **输入参数**: + - `accountId`: 钱包账户ID(必需) +- **返回数据**: + - 模板列表(包含模板信息和对应的跟单状态) + +### 2.5 订单同步与执行 + +#### 2.5.1 监控 Leader 交易 - **功能**:实时监控 Leader 的交易活动 - **实现方式**(优先级从高到低): - **方式1(优先)**:使用 WebSocket 推送(RTDS API) @@ -231,12 +378,13 @@ - **方式2(备选)**:定期轮询 CLOB API - 当 WebSocket 不可用或不支持时,使用轮询方式 - 调用 CLOB API `/trades?user={leaderAddress}` 获取最新交易 - - 默认每 5 秒轮询一次(可配置轮询间隔) + - 使用系统默认的轮询间隔配置 - **实现策略**: - 系统启动时尝试连接 WebSocket - 如果 WebSocket 连接成功且支持订阅用户交易,使用推送模式 - 如果 WebSocket 不可用或不支持,自动降级到轮询模式 - 支持运行时切换(WebSocket 断开时自动切换到轮询) + - 使用系统默认的 WebSocket 重连和轮询配置 - **业务规则**: - 只监控已启用的 Leader - 根据分类筛选(如果设置了 category) @@ -244,81 +392,75 @@ - WebSocket 模式下,每个 Leader 需要单独订阅 - 轮询模式下,批量查询多个 Leader 的交易 -#### 2.3.2 订单复制逻辑 +#### 2.5.2 订单复制逻辑 - **触发条件**: - Leader 创建新订单(通过交易记录判断) - Leader 取消订单(需要监控订单状态变化) - **复制流程**: 1. 检测到 Leader 的新交易 - 2. 验证跟单配置(是否启用、分类筛选、金额限制等) - 3. 确定使用的账户: - - 如果 Leader 配置了指定账户,使用指定账户 - - 否则使用默认账户 - 4. 计算跟单订单参数: + 2. 查找所有启用状态的跟单关系(该 Leader 对应的跟单) + 3. 对每个跟单关系: + a. 验证跟单状态(是否启用) + b. 获取模板配置 + c. 验证风险控制(每日亏损、订单数限制等) + d. 确定使用的账户(跟单关系中的账户) + e. 计算跟单订单参数: - `market`: 与 Leader 相同 - - `side`: 与 Leader 相同(BUY/SELL) + - `side`: 与 Leader 相同(BUY/SELL),如果模板不支持卖出且 Leader 是卖出,则跳过 - `price`: 根据价格容忍度调整(可选) - - `size`: 根据跟单比例计算 - 5. 使用指定账户的 API Key 或私钥签名创建订单 - 6. 记录跟单记录(包含使用的账户ID) + - `size`: 根据模板配置计算(比例或固定金额) + f. 使用账户的 API Key 或私钥签名创建订单 + g. 记录跟单记录(包含使用的账户ID、模板ID) -#### 2.3.3 价格调整策略 +#### 2.5.3 价格调整策略 - **固定价格**:完全复制 Leader 的价格 - **市场价**:使用当前市场最优价格 - **价格容忍度**:在 Leader 价格 ± 容忍度范围内调整 - **默认策略**:固定价格(完全复制) -#### 2.3.4 订单大小计算 -- **计算模式**: +#### 2.5.4 订单大小计算 +- **计算模式**(基于模板配置): - **比例模式(copyMode = "RATIO")**: ``` - 跟单订单大小 = Leader 订单大小 × copyRatio + 跟单订单大小 = Leader 订单大小 × (copyRatio / 100) ``` + - 说明:`copyRatio` 为百分比值(如 100 表示 100%),需要除以 100 转换为小数进行计算 - **固定金额模式(copyMode = "FIXED")**: ``` 跟单订单大小 = fixedAmount(固定值,不随 Leader 订单大小变化) ``` -- **限制检查**: - - 不能超过 `maxOrderSize` - - 不能低于 `minOrderSize` +- **限制检查**(仅在比例模式下生效): + - 比例模式下,不能超过模板配置的 `maxOrderSize` + - 比例模式下,不能低于模板配置的 `minOrderSize` - 如果超出限制,调整到边界值 - **业务规则**: - - 如果 Leader 配置了 `copyMode`,使用 Leader 的配置 - - 否则使用全局配置的 `copyMode` - - 固定金额模式下,无论 Leader 订单大小如何,跟单金额都固定 - - 比例模式下,跟单金额随 Leader 订单大小按比例变化 + - 使用跟单关系绑定的模板配置 + - **固定金额模式**:无论 Leader 订单大小如何,跟单金额都固定为 `fixedAmount`,不应用 `maxOrderSize` 和 `minOrderSize` 限制 + - **比例模式**:跟单金额随 Leader 订单大小按比例变化,需要检查 `maxOrderSize` 和 `minOrderSize` 限制 -#### 2.3.5 订单取消同步 +#### 2.5.5 订单取消同步 - **功能**:当 Leader 取消订单时,同步取消对应的跟单订单 - **实现方式**: - 监控 Leader 的活跃订单列表 - 检测到订单消失或状态变为 cancelled - 查找对应的跟单订单并取消 -### 2.4 风险控制 +### 2.6 风险控制 -#### 2.4.1 每日亏损限制 -- **功能**:当日累计亏损达到限制时,停止跟单 -- **计算方式**: - - 统计当日所有已平仓订单的盈亏 - - 如果累计亏损 >= `maxDailyLoss`,暂停跟单 -- **恢复机制**: - - 次日自动恢复 - - 或手动重置 - -#### 2.4.2 每日订单数限制 +#### 2.6.1 每日订单数限制 - **功能**:限制每日跟单订单数量 - **规则**: - 当日跟单订单数 >= `maxDailyOrders` 时,停止跟单 - 次日自动重置 -#### 2.4.3 单笔订单金额限制 -- **功能**:限制单笔跟单订单的最大和最小金额 +#### 2.6.2 单笔订单金额限制(仅比例模式) +- **功能**:限制单笔跟单订单的最大和最小金额(仅在比例模式下生效) - **规则**: - - 订单金额必须在 `minOrderSize` 和 `maxOrderSize` 之间 + - 比例模式下,订单金额必须在 `minOrderSize`(>= 1)和 `maxOrderSize` 之间 + - 固定金额模式下,不应用此限制 - 超出范围时,调整到边界值或跳过 -#### 2.4.4 市场状态检查 +#### 2.6.3 市场状态检查 - **功能**:在跟单前检查市场状态 - **检查项**: - 市场是否活跃(active) @@ -484,30 +626,9 @@ data class Leader( @Column(name = "leader_name", length = 100) val leaderName: String? = null, - @Column(name = "account_id") - val accountId: Long? = null, // 指定使用的账户ID(null 表示使用默认账户) - @Column(name = "category", length = 20) val category: String? = null, // sports 或 crypto,null 表示不筛选 - @Column(name = "enabled", nullable = false) - val enabled: Boolean = true, - - @Column(name = "copy_mode", length = 10) - val copyMode: String? = null, // "RATIO" 或 "FIXED",null 表示使用全局配置 - - @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", precision = 20, scale = 8) - val maxOrderSize: BigDecimal? = null, // 单笔最大金额 - - @Column(name = "min_order_size", precision = 20, scale = 8) - val minOrderSize: BigDecimal? = null, // 单笔最小金额 - @Column(name = "created_at", nullable = false) val createdAt: Long = System.currentTimeMillis(), @@ -516,15 +637,18 @@ data class Leader( ) ``` -### 4.2 CopyTradingConfig(全局跟单配置) +### 4.2 CopyTradingTemplate(跟单模板) ```kotlin @Entity -@Table(name = "copy_trading_config") -data class CopyTradingConfig( +@Table(name = "copy_trading_templates") +data class CopyTradingTemplate( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Long? = null, + @Column(name = "template_name", unique = true, nullable = false, length = 100) + val templateName: String, // 模板名称 + @Column(name = "copy_mode", nullable = false, length = 10) val copyMode: String = "RATIO", // "RATIO" 或 "FIXED" @@ -532,16 +656,13 @@ data class CopyTradingConfig( val copyRatio: BigDecimal = BigDecimal.ONE, // 仅在 copyMode="RATIO" 时生效 @Column(name = "fixed_amount", precision = 20, scale = 8) - val fixedAmount: BigDecimal? = null, // 仅在 copyMode="FIXED" 时生效 + val fixedAmount: BigDecimal? = null, // 仅在 copyMode="FIXED" 时生效,必须 >= 1 - @Column(name = "max_order_size", nullable = false, precision = 20, scale = 8) - val maxOrderSize: BigDecimal = "1000".toSafeBigDecimal(), + @Column(name = "max_order_size", precision = 20, scale = 8) + val maxOrderSize: BigDecimal? = null, // 仅在 copyMode="RATIO" 时生效 - @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 = "min_order_size", precision = 20, scale = 8) + val minOrderSize: BigDecimal? = null, // 仅在 copyMode="RATIO" 时生效,必须 >= 1 @Column(name = "max_daily_orders", nullable = false) val maxDailyOrders: Int = 100, @@ -549,30 +670,50 @@ data class CopyTradingConfig( @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 = "support_sell", nullable = false) + val supportSell: Boolean = true, // 跟单卖出 - @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 = "enabled", nullable = false) - val enabled: Boolean = true, + @Column(name = "created_at", nullable = false) + val createdAt: Long = System.currentTimeMillis(), @Column(name = "updated_at", nullable = false) var updatedAt: Long = System.currentTimeMillis() ) ``` -### 4.3 CopyOrder(跟单订单) +### 4.3 CopyTrading(跟单关系,钱包-模板关联) +```kotlin +@Entity +@Table(name = "copy_trading") +data class CopyTrading( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @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 = "created_at", nullable = false) + val createdAt: Long = System.currentTimeMillis(), + + @Column(name = "updated_at", nullable = false) + var updatedAt: Long = System.currentTimeMillis(), + + // 唯一约束:同一个钱包、模板、Leader 的组合只能有一条记录 + @UniqueConstraint(columnNames = ["account_id", "template_id", "leader_id"]) +) +``` + +### 4.4 CopyOrder(跟单订单) ```kotlin @Entity @Table(name = "copy_orders") @@ -584,6 +725,12 @@ data class CopyOrder( @Column(name = "account_id", nullable = false) val accountId: Long, // 使用的账户ID + @Column(name = "template_id", nullable = false) + val templateId: Long, // 使用的模板ID + + @Column(name = "copy_trading_id", nullable = false) + val copyTradingId: Long, // 跟单关系ID + @Column(name = "leader_id", nullable = false) val leaderId: Long, @@ -634,7 +781,7 @@ data class CopyOrder( ) ``` -### 4.4 DailyStatistics(每日统计) +### 4.5 DailyStatistics(每日统计) ```kotlin @Entity @Table(name = "copy_trading_daily_stats") @@ -831,9 +978,7 @@ data class DailyStatistics( { "leaderAddress": "0x...", "leaderName": "Trader A", - "accountId": 1, - "category": "sports", - "enabled": true + "category": "sports" } ``` - **响应**: @@ -844,15 +989,16 @@ data class DailyStatistics( "id": 1, "leaderAddress": "0x...", "leaderName": "Trader A", - "accountId": 1, "category": "sports", - "enabled": true + "createdAt": 1234567890000, + "updatedAt": 1234567890000 }, "msg": "" } ``` - **说明**: - - `accountId`: 可选,指定使用哪个账户跟单该 Leader,不提供则使用默认账户 + - 账户和模板的关联通过跟单管理接口创建 + - 启用状态由跟单关系管理 #### 5.2.2 删除被跟单者 - **接口**: `POST /api/copy-trading/leaders/delete` @@ -870,22 +1016,18 @@ data class DailyStatistics( { "leaderId": 1, "leaderName": "Trader A Updated", - "accountId": 2, - "category": "crypto", - "enabled": false, - "copyRatio": "2.0", - "maxOrderSize": "500" + "category": "crypto" } ``` - **说明**: - - `accountId`: 可选,更新该 Leader 使用的账户 + - 只能更新名称和分类 + - 账户和模板关联通过跟单管理接口管理 #### 5.2.4 查询被跟单者列表 - **接口**: `POST /api/copy-trading/leaders/list` - **请求体**: ```json { - "enabled": true, "category": "sports" } ``` @@ -900,10 +1042,11 @@ data class DailyStatistics( "leaderAddress": "0x...", "leaderName": "Trader A", "category": "sports", - "enabled": true, - "copyRatio": "1.0", + "copyTradingCount": 3, "totalOrders": 10, - "totalPnl": "50.5" + "totalPnl": "50.5", + "createdAt": 1234567890000, + "updatedAt": 1234567890000 } ], "total": 1 @@ -912,74 +1055,231 @@ data class DailyStatistics( } ``` -### 5.3 配置管理接口 +### 5.3 跟单模板管理接口(子菜单:跟单模板) -#### 5.3.1 获取全局配置 -- **接口**: `POST /api/copy-trading/config/get` +#### 5.3.1 创建模板 +- **接口**: `POST /api/copy-trading/templates/create` +- **请求体**: +```json +{ + "templateName": "保守型模板", + "copyMode": "RATIO", + "copyRatio": "0.5", // 后端存储为小数(前端输入 50% 转换为 0.5) + "fixedAmount": null, + "maxOrderSize": "1000", + "minOrderSize": "1", + "maxDailyOrders": 100, + "priceTolerance": "5", + "supportSell": true +} +``` - **响应**: ```json { "code": 0, "data": { + "id": 1, + "templateName": "保守型模板", "copyMode": "RATIO", - "copyRatio": "1.0", + "copyRatio": "0.5", // 后端存储为小数(50% 存储为 0.5) "fixedAmount": null, "maxOrderSize": "1000", "minOrderSize": "1", - "maxDailyLoss": "10000", "maxDailyOrders": 100, "priceTolerance": "5", - "delaySeconds": 0, - "pollIntervalSeconds": 5, - "useWebSocket": true, - "websocketReconnectInterval": 5000, - "websocketMaxRetries": 10, - "enabled": true + "supportSell": true, + "createdAt": 1234567890000, + "updatedAt": 1234567890000 }, "msg": "" } ``` -#### 5.3.2 更新全局配置 -- **接口**: `POST /api/copy-trading/config/update` -- **请求体**(比例模式示例): +#### 5.3.2 更新模板 +- **接口**: `POST /api/copy-trading/templates/update` +- **请求体**: ```json { + "templateId": 1, + "templateName": "新模板名称", // 可选,用于修改模板名称 + "copyRatio": "0.6", // 后端存储为小数(前端输入 60% 转换为 0.6 存储) + "maxOrderSize": "2000" +} +``` +- **注意**:可以修改模板名称,但新名称必须唯一,不能与其他模板重名 + +#### 5.3.3 删除模板 +- **接口**: `POST /api/copy-trading/templates/delete` +- **请求体**: +```json +{ + "templateId": 1 +} +``` + +#### 5.3.4 复制模板 +- **接口**: `POST /api/copy-trading/templates/copy` +- **请求体**: +```json +{ + "templateId": 1, + "templateName": "保守型模板-副本", + "copyRatio": "0.7" // 后端存储为小数(前端输入 70% 转换为 0.7 存储) +} +``` + +#### 5.3.5 查询模板列表 +- **接口**: `POST /api/copy-trading/templates/list` +- **响应**: +```json +{ + "code": 0, + "data": { + "list": [ + { + "id": 1, + "templateName": "保守型模板", "copyMode": "RATIO", - "copyRatio": "1.5", - "fixedAmount": null, - "maxOrderSize": "2000", - "minOrderSize": "5", - "maxDailyLoss": "20000", - "maxDailyOrders": 200, - "priceTolerance": "3", - "delaySeconds": 2, - "pollIntervalSeconds": 3, - "useWebSocket": true, - "websocketReconnectInterval": 5000, - "websocketMaxRetries": 10, + "copyRatio": "0.5", // 后端存储为小数(前端显示为 50%) // 后端存储为小数(50% 存储为 0.5) + "useCount": 3, + "createdAt": 1234567890000, + "updatedAt": 1234567890000 + } + ], + "total": 1 + }, + "msg": "" +} +``` + +#### 5.3.6 查询模板详情 +- **接口**: `POST /api/copy-trading/templates/detail` +- **请求体**: +```json +{ + "templateId": 1 +} +``` + +### 5.4 跟单配置管理接口(子菜单:跟单配置) + +#### 5.4.1 创建跟单 +- **接口**: `POST /api/copy-trading/create` +- **请求体**: +```json +{ + "accountId": 1, + "templateId": 1, + "leaderId": 1, "enabled": true } ``` -- **请求体**(固定金额模式示例): +- **响应**: ```json { - "copyMode": "FIXED", - "copyRatio": null, - "fixedAmount": "100", - "maxOrderSize": "2000", - "minOrderSize": "5", - "maxDailyLoss": "20000", - "maxDailyOrders": 200, - "priceTolerance": "3", - "delaySeconds": 2, - "pollIntervalSeconds": 3, - "useWebSocket": true, - "websocketReconnectInterval": 5000, - "websocketMaxRetries": 10, + "code": 0, + "data": { + "id": 1, + "accountId": 1, + "accountName": "Account 1", + "walletAddress": "0x1234...5678", + "templateId": 1, + "templateName": "保守型模板", + "leaderId": 1, + "leaderName": "Trader A", + "leaderAddress": "0x...", + "enabled": true, + "createdAt": 1234567890000 + }, + "msg": "" +} +``` + +#### 5.4.2 查询跟单列表 +- **接口**: `POST /api/copy-trading/list` +- **请求体**: +```json +{ + "accountId": 1, + "templateId": 1, + "leaderId": 1, "enabled": true } ``` +- **响应**: +```json +{ + "code": 0, + "data": { + "list": [ + { + "id": 1, + "accountId": 1, + "accountName": "Account 1", + "walletAddress": "0x1234...5678", + "templateId": 1, + "templateName": "保守型模板", + "leaderId": 1, + "leaderName": "Trader A", + "leaderAddress": "0x...", + "enabled": true, + "createdAt": 1234567890000, + "updatedAt": 1234567890000 + } + ], + "total": 1 + }, + "msg": "" +} +``` + +#### 5.4.3 更新跟单状态 +- **接口**: `POST /api/copy-trading/update-status` +- **请求体**: +```json +{ + "copyTradingId": 1, + "enabled": false +} +``` + +#### 5.4.4 删除跟单 +- **接口**: `POST /api/copy-trading/delete` +- **请求体**: +```json +{ + "copyTradingId": 1 +} +``` + +#### 5.4.5 查询钱包绑定的模板 +- **接口**: `POST /api/copy-trading/account-templates` +- **请求体**: +```json +{ + "accountId": 1 +} +``` +- **响应**: +```json +{ + "code": 0, + "data": { + "list": [ + { + "templateId": 1, + "templateName": "保守型模板", + "copyTradingId": 1, + "leaderId": 1, + "leaderName": "Trader A", + "enabled": true + } + ], + "total": 1 + }, + "msg": "" +} +``` ### 5.4 自己的订单管理接口 @@ -1285,9 +1585,7 @@ const address = account.address; - 系统启动时优先尝试 WebSocket 连接 - WebSocket 连接成功且可用时,使用推送模式 - WebSocket 不可用或断开时,自动切换到轮询模式 -- **配置控制**: - - 支持配置强制使用轮询模式(用于调试或测试) - - 支持配置 WebSocket 重连间隔和最大重试次数 + - 使用系统默认的 WebSocket 重连和轮询配置 #### 6.2.4 去重机制 - **实现方式**: @@ -1325,15 +1623,23 @@ const address = account.address; ## 7. 数据库设计 ### 7.1 表结构 +- `copy_trading_accounts`: 账户信息表 - `copy_trading_leaders`: 被跟单者表 -- `copy_trading_config`: 全局配置表(单条记录) +- `copy_trading_templates`: 跟单模板表 +- `copy_trading`: 跟单关系表(钱包-模板关联,多对多) - `copy_orders`: 跟单订单表 - `copy_trading_daily_stats`: 每日统计表 - `copy_trading_processed_trades`: 已处理交易表(用于去重) -- `copy_trading_account`: 账户信息表(单条记录) ### 7.2 索引设计 +- `copy_trading_accounts.wallet_address`: UNIQUE 索引 - `copy_trading_leaders.leader_address`: UNIQUE 索引 +- `copy_trading_templates.template_name`: UNIQUE 索引 +- `copy_trading.account_id + template_id + leader_id`: 联合唯一索引 +- `copy_trading.account_id`: 索引 +- `copy_trading.template_id`: 索引 +- `copy_trading.leader_id`: 索引 +- `copy_orders.copy_trading_id`: 索引 - `copy_orders.leader_id`: 索引 - `copy_orders.market_id`: 索引 - `copy_orders.created_at`: 索引 @@ -1381,7 +1687,6 @@ const address = account.address; - 智能跟单(根据 Leader 历史表现筛选) - 反向跟单(反向操作 Leader 的订单) - 部分跟单(只跟单特定市场或条件) -- 跟单延迟策略(延迟 N 秒后跟单) ### 9.2 分析功能 - Leader 表现分析 @@ -1403,10 +1708,18 @@ const address = account.address; - 账户信息查询 - API Key 管理(可选) 2. Leader 管理(增删改查) -3. 全局配置管理 -4. 订单监控和同步(基础版本) -5. 跟单订单记录(包含账户ID) -6. **前端移动端适配**(必须) +3. **跟单模板管理**(新增) + - 创建、更新、删除、复制模板 + - 查询模板列表和详情 +4. **跟单管理**(新增) + - 创建跟单(钱包-模板关联) + - 查询跟单列表 + - 开启/停止跟单 + - 删除跟单 + - 查询钱包绑定的模板 +5. 订单监控和同步(基础版本) +6. 跟单订单记录(包含账户ID、模板ID) +7. **前端移动端适配**(必须) - 响应式布局设计 - 移动端 UI 组件适配 - 触摸操作优化 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ab8d9bd..effee75 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,11 @@ import LeaderAdd from './pages/LeaderAdd' import ConfigPage from './pages/ConfigPage' import PositionList from './pages/PositionList' import Statistics from './pages/Statistics' +import TemplateList from './pages/TemplateList' +import TemplateAdd from './pages/TemplateAdd' +import TemplateEdit from './pages/TemplateEdit' +import CopyTradingList from './pages/CopyTradingList' +import CopyTradingAdd from './pages/CopyTradingAdd' import { wsManager } from './services/websocket' import type { OrderPushMessage } from './types' @@ -124,6 +129,11 @@ function App() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 89d31e6..baae66e 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,15 +1,18 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' import { useNavigate, useLocation } from 'react-router-dom' import { Layout as AntLayout, Menu, Drawer, Button } from 'antd' import { useMediaQuery } from 'react-responsive' import { WalletOutlined, UserOutlined, - SettingOutlined, UnorderedListOutlined, BarChartOutlined, - MenuOutlined + MenuOutlined, + FileTextOutlined, + LinkOutlined, + AppstoreOutlined } from '@ant-design/icons' +import type { MenuProps } from 'antd' import type { ReactNode } from 'react' const { Header, Content, Sider } = AntLayout @@ -24,7 +27,31 @@ const Layout: React.FC = ({ children }) => { const isMobile = useMediaQuery({ maxWidth: 768 }) const [mobileMenuOpen, setMobileMenuOpen] = useState(false) - const menuItems = [ + // 获取当前选中的菜单项 + const getSelectedKeys = (): string[] => { + return [location.pathname] + } + + // 获取当前应该打开的父菜单 + const getInitialOpenKeys = (): string[] => { + const path = location.pathname + if (path.startsWith('/templates') || path.startsWith('/copy-trading')) { + return ['/copy-trading-management'] + } + return [] + } + + const [openKeys, setOpenKeys] = useState(getInitialOpenKeys()) + + // 当路径变化时,自动打开对应的父菜单 + useEffect(() => { + const path = location.pathname + if (path.startsWith('/templates') || path.startsWith('/copy-trading')) { + setOpenKeys(['/copy-trading-management']) + } + }, [location.pathname]) + + const menuItems: MenuProps['items'] = [ { key: '/accounts', icon: , @@ -36,9 +63,21 @@ const Layout: React.FC = ({ children }) => { label: 'Leader 管理' }, { - key: '/config', - icon: , - label: '跟单配置' + key: '/copy-trading-management', + icon: , + label: '跟单管理', + children: [ + { + key: '/templates', + icon: , + label: '跟单模板' + }, + { + key: '/copy-trading', + icon: , + label: '跟单配置' + } + ] }, { key: '/positions', @@ -52,13 +91,21 @@ const Layout: React.FC = ({ children }) => { } ] - const handleMenuClick = (key: string) => { + const handleMenuClick = ({ key }: { key: string }) => { + // 如果是父菜单,不导航 + if (key === '/copy-trading-management') { + return + } navigate(key) if (isMobile) { setMobileMenuOpen(false) } } + const handleOpenChange = (keys: string[]) => { + setOpenKeys(keys) + } + if (isMobile) { // 移动端布局 return ( @@ -96,9 +143,11 @@ const Layout: React.FC = ({ children }) => { > handleMenuClick(key)} + onClick={handleMenuClick} style={{ border: 'none' }} /> @@ -134,9 +183,11 @@ const Layout: React.FC = ({ children }) => { handleMenuClick(key)} + onClick={handleMenuClick} style={{ height: 'calc(100vh - 64px)', borderRight: 0, diff --git a/frontend/src/pages/CopyTradingAdd.tsx b/frontend/src/pages/CopyTradingAdd.tsx new file mode 100644 index 0000000..c0354a3 --- /dev/null +++ b/frontend/src/pages/CopyTradingAdd.tsx @@ -0,0 +1,167 @@ +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 { apiService } from '../services/api' +import { useAccountStore } from '../store/accountStore' +import type { Account, Leader, CopyTradingTemplate } from '../types' +import { useMediaQuery } from 'react-responsive' + +const { Title } = Typography +const { Option } = Select + +const CopyTradingAdd: React.FC = () => { + const navigate = useNavigate() + const isMobile = useMediaQuery({ maxWidth: 768 }) + const { accounts, fetchAccounts } = useAccountStore() + const [form] = Form.useForm() + const [loading, setLoading] = useState(false) + const [leaders, setLeaders] = useState([]) + const [templates, setTemplates] = useState([]) + + useEffect(() => { + fetchAccounts() + fetchLeaders() + fetchTemplates() + }, []) + + const fetchLeaders = async () => { + try { + const response = await apiService.leaders.list({}) + if (response.data.code === 0 && response.data.data) { + setLeaders(response.data.data.list || []) + } + } catch (error: any) { + message.error(error.message || '获取 Leader 列表失败') + } + } + + 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) { + message.error(error.message || '获取模板列表失败') + } + } + + const handleSubmit = async (values: any) => { + setLoading(true) + try { + const response = await apiService.copyTrading.create({ + accountId: values.accountId, + templateId: values.templateId, + leaderId: values.leaderId, + enabled: values.enabled !== false + }) + + if (response.data.code === 0) { + message.success('创建跟单成功') + navigate('/copy-trading') + } else { + message.error(response.data.msg || '创建跟单失败') + } + } catch (error: any) { + message.error(error.message || '创建跟单失败') + } finally { + setLoading(false) + } + } + + return ( +
+
+ +
+ + + 新增跟单 + +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+ ) +} + +export default CopyTradingAdd + diff --git a/frontend/src/pages/CopyTradingList.tsx b/frontend/src/pages/CopyTradingList.tsx new file mode 100644 index 0000000..9477876 --- /dev/null +++ b/frontend/src/pages/CopyTradingList.tsx @@ -0,0 +1,262 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { Card, Table, Button, Space, Tag, Popconfirm, Switch, message, Select, Input } from 'antd' +import { PlusOutlined, DeleteOutlined } from '@ant-design/icons' +import { apiService } from '../services/api' +import { useAccountStore } from '../store/accountStore' +import type { CopyTrading, Account, Leader, CopyTradingTemplate } from '../types' +import { useMediaQuery } from 'react-responsive' + +const { Option } = Select + +const CopyTradingList: React.FC = () => { + const navigate = useNavigate() + const isMobile = useMediaQuery({ maxWidth: 768 }) + const { accounts, fetchAccounts } = useAccountStore() + const [copyTradings, setCopyTradings] = useState([]) + const [leaders, setLeaders] = useState([]) + const [templates, setTemplates] = useState([]) + const [loading, setLoading] = useState(false) + const [filters, setFilters] = useState<{ + accountId?: number + templateId?: number + leaderId?: number + enabled?: boolean + }>({}) + + useEffect(() => { + fetchAccounts() + fetchLeaders() + fetchTemplates() + fetchCopyTradings() + }, []) + + useEffect(() => { + fetchCopyTradings() + }, [filters]) + + const fetchLeaders = async () => { + try { + const response = await apiService.leaders.list({}) + if (response.data.code === 0 && response.data.data) { + setLeaders(response.data.data.list || []) + } + } catch (error: any) { + console.error('获取 Leader 列表失败:', error) + } + } + + 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 { + const response = await apiService.copyTrading.list(filters) + if (response.data.code === 0 && response.data.data) { + setCopyTradings(response.data.data.list || []) + } else { + message.error(response.data.msg || '获取跟单列表失败') + } + } catch (error: any) { + message.error(error.message || '获取跟单列表失败') + } finally { + setLoading(false) + } + } + + const handleToggleStatus = async (copyTrading: CopyTrading) => { + try { + const response = await apiService.copyTrading.updateStatus({ + copyTradingId: copyTrading.id, + enabled: !copyTrading.enabled + }) + if (response.data.code === 0) { + message.success(`${copyTrading.enabled ? '停止' : '开启'}跟单成功`) + fetchCopyTradings() + } else { + message.error(response.data.msg || '更新跟单状态失败') + } + } catch (error: any) { + message.error(error.message || '更新跟单状态失败') + } + } + + const handleDelete = async (copyTradingId: number) => { + try { + const response = await apiService.copyTrading.delete({ copyTradingId }) + if (response.data.code === 0) { + message.success('删除跟单成功') + fetchCopyTradings() + } else { + message.error(response.data.msg || '删除跟单失败') + } + } catch (error: any) { + message.error(error.message || '删除跟单失败') + } + } + + const columns = [ + { + title: '钱包', + key: 'account', + render: (_: any, record: CopyTrading) => ( +
+
{record.accountName || `账户 ${record.accountId}`}
+
+ {record.walletAddress.slice(0, 6)}...{record.walletAddress.slice(-4)} +
+
+ ) + }, + { + title: '模板', + dataIndex: 'templateName', + key: 'templateName', + render: (text: string) => {text} + }, + { + title: 'Leader', + key: 'leader', + render: (_: any, record: CopyTrading) => ( +
+
{record.leaderName || `Leader ${record.leaderId}`}
+
+ {record.leaderAddress.slice(0, 6)}...{record.leaderAddress.slice(-4)} +
+
+ ) + }, + { + title: '状态', + dataIndex: 'enabled', + key: 'enabled', + render: (enabled: boolean, record: CopyTrading) => ( + handleToggleStatus(record)} + checkedChildren="开启" + unCheckedChildren="停止" + /> + ) + }, + { + title: '操作', + key: 'action', + width: isMobile ? 80 : 100, + render: (_: any, record: CopyTrading) => ( + handleDelete(record.id)} + okText="确定" + cancelText="取消" + > + + + ) + } + ] + + return ( +
+ +
+

跟单配置管理

+ +
+ +
+ + + + + + + +
+ + `共 ${total} 条` + }} + scroll={{ x: isMobile ? 800 : 'auto' }} + /> + + + ) +} + +export default CopyTradingList + diff --git a/frontend/src/pages/TemplateAdd.tsx b/frontend/src/pages/TemplateAdd.tsx new file mode 100644 index 0000000..5d2ce34 --- /dev/null +++ b/frontend/src/pages/TemplateAdd.tsx @@ -0,0 +1,289 @@ +import { useState } from 'react' +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' + +const { Title } = Typography + +const TemplateAdd: React.FC = () => { + const navigate = useNavigate() + const [form] = Form.useForm() + const [loading, setLoading] = useState(false) + const [copyMode, setCopyMode] = useState<'RATIO' | 'FIXED'>('RATIO') + + 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') + return + } + + // 前端校验:固定金额模式下,fixedAmount 必填且必须 >= 1 + if (values.copyMode === 'FIXED') { + const fixedAmount = values.fixedAmount + if (fixedAmount === undefined || fixedAmount === null || fixedAmount === '') { + message.error('请输入固定跟单金额') + return + } + const amount = Number(fixedAmount) + if (isNaN(amount)) { + message.error('请输入有效的数字') + return + } + if (amount < 1) { + message.error('固定金额必须 >= 1,请重新输入') + return + } + } + + setLoading(true) + try { + const response = await apiService.templates.create({ + templateName: values.templateName, + copyMode: values.copyMode || 'RATIO', + // 将百分比转换为小数:100% -> 1.0 + copyRatio: values.copyMode === 'RATIO' && values.copyRatio ? (values.copyRatio / 100).toString() : undefined, + fixedAmount: values.copyMode === 'FIXED' ? values.fixedAmount?.toString() : undefined, + maxOrderSize: values.copyMode === 'RATIO' ? values.maxOrderSize?.toString() : undefined, + minOrderSize: values.copyMode === 'RATIO' ? values.minOrderSize?.toString() : undefined, + maxDailyOrders: values.maxDailyOrders, + priceTolerance: values.priceTolerance?.toString(), + supportSell: values.supportSell !== false + }) + + if (response.data.code === 0) { + message.success('创建模板成功') + navigate('/templates') + } else { + message.error(response.data.msg || '创建模板失败') + } + } catch (error: any) { + message.error(error.message || '创建模板失败') + } finally { + setLoading(false) + } + } + + return ( +
+
+ +
+ + + 创建跟单模板 + +
+ + + + + + setCopyMode(e.target.value)}> + 比例模式 + 固定金额模式 + + + + {copyMode === 'RATIO' && ( + + { + const parsed = parseFloat(value || '0') + if (parsed > 1000) return 1000 + return parsed + }} + formatter={(value) => { + if (!value) return '' + const num = parseFloat(value.toString()) + if (num > 1000) return '1000' + return value.toString() + }} + /> + + )} + + {copyMode === 'FIXED' && ( + { + // required 已经处理了空值情况,这里只处理非空值的校验 + if (value !== undefined && value !== null && value !== '') { + const amount = Number(value) + if (isNaN(amount)) { + return Promise.reject(new Error('请输入有效的数字')) + } + if (amount < 1) { + return Promise.reject(new Error('固定金额必须 >= 1,请重新输入')) + } + } + return Promise.resolve() + } + } + ]} + > + + + )} + + {copyMode === 'RATIO' && ( + <> + + + + + { + if (value === undefined || value === null || value === '') { + return Promise.resolve() // 可选字段,允许为空 + } + if (typeof value === 'number' && value < 1) { + return Promise.reject(new Error('最小金额必须 >= 1')) + } + return Promise.resolve() + } + } + ]} + > + + + + )} + + + + + + + + + + + + + + + {({ getFieldsError }) => { + const errors = getFieldsError() + const hasErrors = errors.some(({ errors }) => errors && errors.length > 0) + return ( + + + + + ) + }} + + +
+
+ ) +} + +export default TemplateAdd + diff --git a/frontend/src/pages/TemplateEdit.tsx b/frontend/src/pages/TemplateEdit.tsx new file mode 100644 index 0000000..b57c35d --- /dev/null +++ b/frontend/src/pages/TemplateEdit.tsx @@ -0,0 +1,323 @@ +import { useEffect, useState } from 'react' +import { useNavigate, useParams } 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 type { CopyTradingTemplate } from '../types' +import { useMediaQuery } from 'react-responsive' + +const { Title } = Typography + +const TemplateEdit: React.FC = () => { + const navigate = useNavigate() + const { id } = useParams<{ id: string }>() + const isMobile = useMediaQuery({ maxWidth: 768 }) + const [form] = Form.useForm() + const [loading, setLoading] = useState(false) + const [fetching, setFetching] = useState(false) + const [copyMode, setCopyMode] = useState<'RATIO' | 'FIXED'>('RATIO') + + useEffect(() => { + if (id) { + fetchTemplate(parseInt(id)) + } + }, [id]) + + const fetchTemplate = async (templateId: number) => { + setFetching(true) + try { + const response = await apiService.templates.detail({ templateId }) + if (response.data.code === 0 && response.data.data) { + const template: CopyTradingTemplate = response.data.data + setCopyMode(template.copyMode) + form.setFieldsValue({ + ...template, + // 将小数转换为百分比:1.0 -> 100% + 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, + priceTolerance: parseFloat(template.priceTolerance) + }) + } else { + message.error(response.data.msg || '获取模板详情失败') + navigate('/templates') + } + } catch (error: any) { + message.error(error.message || '获取模板详情失败') + navigate('/templates') + } finally { + setFetching(false) + } + } + + const handleSubmit = async (values: any) => { + if (!id) return + + // 前端校验:如果填写了 minOrderSize,必须 >= 1 + if (values.copyMode === 'RATIO' && values.minOrderSize !== undefined && values.minOrderSize !== null && values.minOrderSize !== '' && Number(values.minOrderSize) < 1) { + message.error('最小金额必须 >= 1') + return + } + + // 前端校验:固定金额模式下,fixedAmount 必填且必须 >= 1 + if (values.copyMode === 'FIXED') { + const fixedAmount = values.fixedAmount + if (fixedAmount === undefined || fixedAmount === null || fixedAmount === '') { + message.error('请输入固定跟单金额') + return + } + const amount = Number(fixedAmount) + if (isNaN(amount)) { + message.error('请输入有效的数字') + return + } + if (amount < 1) { + message.error('固定金额必须 >= 1,请重新输入') + return + } + } + + setLoading(true) + try { + const response = await apiService.templates.update({ + templateId: parseInt(id), + templateName: values.templateName, + copyMode: values.copyMode, + // 将百分比转换为小数:100% -> 1.0 + copyRatio: values.copyMode === 'RATIO' && values.copyRatio ? (values.copyRatio / 100).toString() : undefined, + fixedAmount: values.copyMode === 'FIXED' ? values.fixedAmount?.toString() : undefined, + maxOrderSize: values.copyMode === 'RATIO' ? values.maxOrderSize?.toString() : undefined, + minOrderSize: values.copyMode === 'RATIO' ? values.minOrderSize?.toString() : undefined, + maxDailyOrders: values.maxDailyOrders, + priceTolerance: values.priceTolerance?.toString(), + supportSell: values.supportSell + }) + + if (response.data.code === 0) { + message.success('更新模板成功') + navigate('/templates') + } else { + message.error(response.data.msg || '更新模板失败') + } + } catch (error: any) { + message.error(error.message || '更新模板失败') + } finally { + setLoading(false) + } + } + + return ( +
+
+ +
+ + + 编辑跟单模板 + +
+ + + + + + setCopyMode(e.target.value)}> + 比例模式 + 固定金额模式 + + + + {copyMode === 'RATIO' && ( + + { + const parsed = parseFloat(value || '0') + if (parsed > 1000) return 1000 + return parsed + }} + formatter={(value) => { + if (!value) return '' + const num = parseFloat(value.toString()) + if (num > 1000) return '1000' + return value.toString() + }} + /> + + )} + + {copyMode === 'FIXED' && ( + { + // required 已经处理了空值情况,这里只处理非空值的校验 + if (value !== undefined && value !== null && value !== '') { + const amount = Number(value) + if (isNaN(amount)) { + return Promise.reject(new Error('请输入有效的数字')) + } + if (amount < 1) { + return Promise.reject(new Error('固定金额必须 >= 1,请重新输入')) + } + } + return Promise.resolve() + } + } + ]} + > + + + )} + + {copyMode === 'RATIO' && ( + <> + + + + + { + if (value === undefined || value === null || value === '') { + return Promise.resolve() // 可选字段,允许为空 + } + if (typeof value === 'number' && value < 1) { + return Promise.reject(new Error('最小金额必须 >= 1')) + } + return Promise.resolve() + } + } + ]} + > + + + + )} + + + + + + + + + + + + + + + {({ getFieldsError }) => { + const errors = getFieldsError() + const hasErrors = errors.some(({ errors }) => errors && errors.length > 0) + return ( + + + + + ) + }} + + +
+
+ ) +} + +export default TemplateEdit + diff --git a/frontend/src/pages/TemplateList.tsx b/frontend/src/pages/TemplateList.tsx new file mode 100644 index 0000000..11df1b9 --- /dev/null +++ b/frontend/src/pages/TemplateList.tsx @@ -0,0 +1,633 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { Card, Table, Button, Space, Tag, Popconfirm, message, Input, Modal, Form, Radio, InputNumber, Switch, Row, Col, Divider, Spin } from 'antd' +import { PlusOutlined, EditOutlined, DeleteOutlined, CopyOutlined } from '@ant-design/icons' +import { apiService } from '../services/api' +import type { CopyTradingTemplate } from '../types' +import { useMediaQuery } from 'react-responsive' + +const { Search } = Input + +const TemplateList: React.FC = () => { + const navigate = useNavigate() + const isMobile = useMediaQuery({ maxWidth: 768 }) + const [templates, setTemplates] = useState([]) + const [loading, setLoading] = useState(false) + const [searchText, setSearchText] = useState('') + const [copyModalVisible, setCopyModalVisible] = useState(false) + const [copyForm] = Form.useForm() + const [copyLoading, setCopyLoading] = useState(false) + const [copyMode, setCopyMode] = useState<'RATIO' | 'FIXED'>('RATIO') + const [sourceTemplate, setSourceTemplate] = useState(null) + + useEffect(() => { + fetchTemplates() + }, []) + + const fetchTemplates = async () => { + setLoading(true) + try { + const response = await apiService.templates.list() + if (response.data.code === 0 && response.data.data) { + setTemplates(response.data.data.list || []) + } else { + message.error(response.data.msg || '获取模板列表失败') + } + } catch (error: any) { + message.error(error.message || '获取模板列表失败') + } finally { + setLoading(false) + } + } + + const handleDelete = async (templateId: number) => { + try { + const response = await apiService.templates.delete({ templateId }) + if (response.data.code === 0) { + message.success('删除模板成功') + fetchTemplates() + } else { + message.error(response.data.msg || '删除模板失败') + } + } catch (error: any) { + message.error(error.message || '删除模板失败') + } + } + + const handleCopy = (template: CopyTradingTemplate) => { + setSourceTemplate(template) + setCopyMode(template.copyMode) + + // 填充表单数据 + copyForm.setFieldsValue({ + templateName: `${template.templateName}-副本`, + 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: parseFloat(template.priceTolerance), + supportSell: template.supportSell + }) + + setCopyModalVisible(true) + } + + const handleCopySubmit = async (values: any) => { + // 前端校验:如果填写了 minOrderSize,必须 >= 1 + if (values.copyMode === 'RATIO' && values.minOrderSize !== undefined && values.minOrderSize !== null && values.minOrderSize !== '' && Number(values.minOrderSize) < 1) { + message.error('最小金额必须 >= 1') + return + } + + // 前端校验:固定金额模式下,fixedAmount 必填且必须 >= 1 + if (values.copyMode === 'FIXED') { + const fixedAmount = values.fixedAmount + if (fixedAmount === undefined || fixedAmount === null || fixedAmount === '') { + message.error('请输入固定跟单金额') + return + } + const amount = Number(fixedAmount) + if (isNaN(amount)) { + message.error('请输入有效的数字') + return + } + if (amount < 1) { + message.error('固定金额必须 >= 1,请重新输入') + return + } + } + + setCopyLoading(true) + try { + const response = await apiService.templates.create({ + templateName: values.templateName, + copyMode: values.copyMode || 'RATIO', + // 将百分比转换为小数:100% -> 1.0 + copyRatio: values.copyMode === 'RATIO' && values.copyRatio ? (values.copyRatio / 100).toString() : undefined, + fixedAmount: values.copyMode === 'FIXED' ? values.fixedAmount?.toString() : undefined, + maxOrderSize: values.copyMode === 'RATIO' ? values.maxOrderSize?.toString() : undefined, + minOrderSize: values.copyMode === 'RATIO' ? values.minOrderSize?.toString() : undefined, + maxDailyOrders: values.maxDailyOrders, + priceTolerance: values.priceTolerance?.toString(), + supportSell: values.supportSell !== false + }) + + if (response.data.code === 0) { + message.success('复制模板成功') + setCopyModalVisible(false) + copyForm.resetFields() + fetchTemplates() + } else { + message.error(response.data.msg || '复制模板失败') + } + } catch (error: any) { + message.error(error.message || '复制模板失败') + } finally { + setCopyLoading(false) + } + } + + const handleCopyCancel = () => { + setCopyModalVisible(false) + copyForm.resetFields() + setSourceTemplate(null) + } + + const filteredTemplates = templates.filter(template => + template.templateName.toLowerCase().includes(searchText.toLowerCase()) + ) + + const columns = [ + { + title: '模板名称', + dataIndex: 'templateName', + key: 'templateName', + render: (text: string) => {text} + }, + { + title: '跟单模式', + dataIndex: 'copyMode', + key: 'copyMode', + render: (mode: string) => ( + + {mode === 'RATIO' ? '比例' : '固定金额'} + + ) + }, + { + title: '跟单配置', + key: 'copyConfig', + render: (_: any, record: CopyTradingTemplate) => { + if (record.copyMode === 'RATIO') { + return `比例 ${record.copyRatio}x` + } else if (record.copyMode === 'FIXED' && record.fixedAmount) { + return `固定 ${parseFloat(record.fixedAmount).toFixed(4)} USDC` + } + return '-' + } + }, + { + title: '跟单卖出', + dataIndex: 'supportSell', + key: 'supportSell', + render: (support: boolean) => ( + + {support ? '是' : '否'} + + ) + }, + { + title: '使用次数', + dataIndex: 'useCount', + key: 'useCount', + render: (count: number) => {count} + }, + { + title: '创建时间', + dataIndex: 'createdAt', + key: 'createdAt', + render: (timestamp: number) => { + const date = new Date(timestamp) + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }) + }, + sorter: (a: CopyTradingTemplate, b: CopyTradingTemplate) => a.createdAt - b.createdAt, + defaultSortOrder: 'descend' as const + }, + { + title: '操作', + key: 'action', + width: isMobile ? 120 : 200, + render: (_: any, record: CopyTradingTemplate) => ( + + + + handleDelete(record.id)} + okText="确定" + cancelText="取消" + > + + + + ) + } + ] + + return ( +
+ +
+

跟单模板管理

+ + setSearchText(e.target.value)} + /> + + +
+ + {isMobile ? ( + // 移动端卡片布局 +
+ {loading ? ( +
+ +
+ ) : filteredTemplates.length === 0 ? ( +
+ 暂无模板数据 +
+ ) : ( +
+ {filteredTemplates.map((template) => { + const date = new Date(template.createdAt) + const formattedDate = date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }) + + return ( + + {/* 模板名称和模式 */} +
+
+ {template.templateName} +
+
+ + {template.copyMode === 'RATIO' ? '比例模式' : '固定金额模式'} + + + {template.supportSell ? '跟单卖出' : '不跟单卖出'} + + {template.useCount} 次使用 +
+
+ + + + {/* 跟单配置 */} +
+
跟单配置
+
+ {template.copyMode === 'RATIO' + ? `比例 ${template.copyRatio}x` + : template.fixedAmount + ? `固定 ${parseFloat(template.fixedAmount).toFixed(4)} USDC` + : '-' + } +
+
+ + {/* 其他配置信息 */} + {template.copyMode === 'RATIO' && ( +
+
金额限制
+
+ {template.maxOrderSize && ( + 最大: {parseFloat(template.maxOrderSize).toFixed(4)} USDC + )} + {template.maxOrderSize && template.minOrderSize && | } + {template.minOrderSize && ( + 最小: {parseFloat(template.minOrderSize).toFixed(4)} USDC + )} + {!template.maxOrderSize && !template.minOrderSize && 未设置} +
+
+ )} + +
+
其他配置
+
+ 每日最大订单: {template.maxDailyOrders} | 价格容忍度: {template.priceTolerance}% +
+
+ + {/* 创建时间 */} +
+
+ 创建时间: {formattedDate} +
+
+ + {/* 操作按钮 */} +
+ + + handleDelete(template.id)} + okText="确定" + cancelText="取消" + > + + +
+
+ ) + })} +
+ )} +
+ ) : ( + // 桌面端表格布局 +
`共 ${total} 条` + }} + /> + )} + + + +
+ + + + + + + 比例模式 + 固定金额模式 + + + + {copyMode === 'RATIO' && ( + + { + const parsed = parseFloat(value || '0') + if (parsed > 1000) return 1000 + return parsed + }} + formatter={(value) => { + if (!value) return '' + const num = parseFloat(value.toString()) + if (num > 1000) return '1000' + return value.toString() + }} + /> + + )} + + {copyMode === 'FIXED' && ( + { + if (value !== undefined && value !== null && value !== '') { + const amount = Number(value) + if (isNaN(amount)) { + return Promise.reject(new Error('请输入有效的数字')) + } + if (amount < 1) { + return Promise.reject(new Error('固定金额必须 >= 1,请重新输入')) + } + } + return Promise.resolve() + } + } + ]} + > + + + )} + + {copyMode === 'RATIO' && ( + <> + + + + + { + if (value === undefined || value === null || value === '') { + return Promise.resolve() + } + if (typeof value === 'number' && value < 1) { + return Promise.reject(new Error('最小金额必须 >= 1')) + } + return Promise.resolve() + } + } + ]} + > + + + + )} + + + + + + + + + + + + + + + {({ getFieldsError }) => { + const errors = getFieldsError() + const hasErrors = errors.some(({ errors }) => errors && errors.length > 0) + return ( + + + + + ) + }} + + +
+ + ) +} + +export default TemplateList + diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 81acd31..a979c8a 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -143,13 +143,13 @@ export const apiService = { /** * 添加 Leader */ - add: (data: any) => + add: (data: { leaderAddress: string; leaderName?: string; category?: string }) => apiClient.post>('/copy-trading/leaders/add', data), /** * 更新 Leader */ - update: (data: any) => + update: (data: { leaderId: number; leaderName?: string; category?: string }) => apiClient.post>('/copy-trading/leaders/update', data), /** @@ -161,25 +161,90 @@ export const apiService = { /** * 查询 Leader 列表 */ - list: (data: { enabled?: boolean; category?: string } = {}) => - apiClient.post>('/copy-trading/leaders/list', data) + list: (data: { category?: string } = {}) => + apiClient.post>('/copy-trading/leaders/list', data), + + /** + * 查询 Leader 详情 + */ + detail: (data: { leaderId: number }) => + apiClient.post>('/copy-trading/leaders/detail', data) }, /** - * 配置管理 API + * 跟单模板管理 API(子菜单:跟单模板) */ - config: { + templates: { /** - * 获取全局配置 + * 创建模板 */ - get: () => - apiClient.post>('/copy-trading/config/get', {}), + create: (data: any) => + apiClient.post>('/copy-trading/templates/create', data), /** - * 更新全局配置 + * 更新模板 */ update: (data: any) => - apiClient.post>('/copy-trading/config/update', data) + apiClient.post>('/copy-trading/templates/update', data), + + /** + * 删除模板 + */ + delete: (data: { templateId: number }) => + apiClient.post>('/copy-trading/templates/delete', data), + + /** + * 复制模板 + */ + copy: (data: any) => + apiClient.post>('/copy-trading/templates/copy', data), + + /** + * 查询模板列表 + */ + list: () => + apiClient.post>('/copy-trading/templates/list', {}), + + /** + * 查询模板详情 + */ + detail: (data: { templateId: number }) => + apiClient.post>('/copy-trading/templates/detail', data) + }, + + /** + * 跟单配置管理 API(子菜单:跟单配置) + */ + copyTrading: { + /** + * 创建跟单 + */ + create: (data: { accountId: number; templateId: number; leaderId: number; enabled?: boolean }) => + apiClient.post>('/copy-trading/create', data), + + /** + * 查询跟单列表 + */ + list: (data: { accountId?: number; templateId?: number; leaderId?: number; enabled?: boolean } = {}) => + apiClient.post>('/copy-trading/list', data), + + /** + * 更新跟单状态 + */ + updateStatus: (data: { copyTradingId: number; enabled: boolean }) => + apiClient.post>('/copy-trading/update-status', data), + + /** + * 删除跟单 + */ + delete: (data: { copyTradingId: number }) => + apiClient.post>('/copy-trading/delete', data), + + /** + * 查询钱包绑定的模板 + */ + getAccountTemplates: (data: { accountId: number }) => + apiClient.post>('/copy-trading/account-templates', data) }, /** diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 635493b..c606574 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -60,12 +60,12 @@ export interface Leader { id: number leaderAddress: string leaderName?: string - accountId?: number category?: string - enabled: boolean - copyRatio: string + copyTradingCount: number totalOrders?: number totalPnl?: string + createdAt: number + updatedAt: number } /** @@ -82,37 +82,157 @@ export interface LeaderListResponse { export interface LeaderAddRequest { leaderAddress: string leaderName?: string - accountId?: number category?: string +} + +/** + * Leader 更新请求 + */ +export interface LeaderUpdateRequest { + leaderId: number + leaderName?: string + category?: string +} + +/** + * 跟单模板 + */ +export interface CopyTradingTemplate { + id: number + templateName: string + copyMode: 'RATIO' | 'FIXED' + copyRatio: string + fixedAmount?: string + maxOrderSize?: string + minOrderSize?: string + maxDailyOrders: number + priceTolerance: string + supportSell: boolean + useCount: number + createdAt: number + updatedAt: number +} + +/** + * 模板列表响应 + */ +export interface TemplateListResponse { + list: CopyTradingTemplate[] + total: number +} + +/** + * 模板创建请求 + */ +export interface TemplateCreateRequest { + templateName: string + copyMode?: 'RATIO' | 'FIXED' + copyRatio?: string + fixedAmount?: string + maxOrderSize?: string + minOrderSize?: string + maxDailyOrders?: number + priceTolerance?: string + supportSell?: boolean +} + +/** + * 模板更新请求 + */ +export interface TemplateUpdateRequest { + templateId: number + templateName?: string + copyMode?: 'RATIO' | 'FIXED' + copyRatio?: string + fixedAmount?: string + maxOrderSize?: string + minOrderSize?: string + maxDailyOrders?: number + priceTolerance?: string + supportSell?: boolean +} + +/** + * 模板复制请求 + */ +export interface TemplateCopyRequest { + templateId: number + templateName: string + copyMode?: 'RATIO' | 'FIXED' + copyRatio?: string + fixedAmount?: string + maxOrderSize?: string + minOrderSize?: string + maxDailyOrders?: number + priceTolerance?: string + supportSell?: boolean +} + +/** + * 跟单关系(钱包-模板关联) + */ +export interface CopyTrading { + id: number + accountId: number + accountName?: string + walletAddress: string + templateId: number + templateName: string + leaderId: number + leaderName?: string + leaderAddress: string + enabled: boolean + createdAt: number + updatedAt: number +} + +/** + * 跟单列表响应 + */ +export interface CopyTradingListResponse { + list: CopyTrading[] + total: number +} + +/** + * 跟单创建请求 + */ +export interface CopyTradingCreateRequest { + accountId: number + templateId: number + leaderId: number enabled?: boolean } /** - * 跟单配置 + * 钱包绑定的模板信息 */ -export interface CopyTradingConfig { - 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 +export interface AccountTemplate { + templateId: number + templateName: string + copyTradingId: number + leaderId: number + leaderName?: string + leaderAddress: string enabled: boolean } +/** + * 钱包绑定的模板列表响应 + */ +export interface AccountTemplatesResponse { + list: AccountTemplate[] + total: number +} + /** * 跟单订单 */ export interface CopyOrder { id: number accountId: number + templateId: number + copyTradingId: number leaderId: number leaderAddress: string leaderName?: string