feat: 添加价格区间过滤功能并优化UI

- 添加价格区间过滤功能(minPrice/maxPrice)
  - 数据库迁移:添加价格区间字段到 copy_trading 和 copy_trading_templates 表
  - 后端:实体类、DTO、服务层支持价格区间配置和过滤
  - 前端:模板和配置页面添加价格区间配置UI
  - 过滤逻辑:在订单创建前检查价格是否在配置区间内

- 优化价格区间文案,使其更易懂
  - 明确说明是 Leader 交易价格
  - 详细说明三种配置方式(区间、只填最低价、只填最高价)

- 补充多语言翻译
  - 为 zh-CN、zh-TW、en 添加价格区间相关翻译
  - 添加价格区间过滤类型翻译
  - 添加通用翻译(prev、next、items、total)

- 修复复制模板功能
  - 补充缺失的过滤条件字段(minOrderDepth、maxSpread、minOrderbookDepth、minPrice、maxPrice)
  - 在复制模板表单中添加所有过滤条件配置项

- 已过滤订单列表优化
  - 添加移动端卡片样式布局
  - 在筛选下拉菜单中添加价格区间过滤类型选项

- 订单页面返回按钮优化
  - 买入订单、卖出订单、匹配关系页面的返回按钮改为返回上一页(navigate(-1))
  - 使用多语言支持返回按钮文本
This commit is contained in:
WrBug
2025-12-05 23:52:49 +08:00
parent 6c13362b17
commit 3369dbb248
26 changed files with 753 additions and 127 deletions
@@ -32,7 +32,9 @@ data class CopyTradingCreateRequest(
// 过滤条件
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
val minOrderbookDepth: String? = null // 最小订单簿深度(USDC金额),NULL表示不启用
val minOrderbookDepth: String? = null, // 最小订单簿深度(USDC金额),NULL表示不启用
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价
)
/**
@@ -59,7 +61,9 @@ data class CopyTradingUpdateRequest(
// 过滤条件
val minOrderDepth: String? = null,
val maxSpread: String? = null,
val minOrderbookDepth: String? = null
val minOrderbookDepth: String? = null,
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价
)
/**
@@ -124,6 +128,8 @@ data class CopyTradingDto(
val minOrderDepth: String?,
val maxSpread: String?,
val minOrderbookDepth: String?,
val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价
val createdAt: Long,
val updatedAt: Long
)
@@ -22,7 +22,9 @@ data class TemplateCreateRequest(
// 过滤条件
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
val minOrderbookDepth: String? = null // 最小订单簿深度(USDC金额),NULL表示不启用
val minOrderbookDepth: String? = null, // 最小订单簿深度(USDC金额),NULL表示不启用
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价
)
/**
@@ -48,7 +50,9 @@ data class TemplateUpdateRequest(
// 过滤条件
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
val minOrderbookDepth: String? = null // 最小订单簿深度(USDC金额),NULL表示不启用
val minOrderbookDepth: String? = null, // 最小订单簿深度(USDC金额),NULL表示不启用
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价
)
/**
@@ -81,7 +85,9 @@ data class TemplateCopyRequest(
// 过滤条件
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
val minOrderbookDepth: String? = null // 最小订单簿深度(USDC金额),NULL表示不启用
val minOrderbookDepth: String? = null, // 最小订单簿深度(USDC金额),NULL表示不启用
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价
)
/**
@@ -115,6 +121,8 @@ data class TemplateDto(
val minOrderDepth: String?,
val maxSpread: String?,
val minOrderbookDepth: String?,
val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价
val createdAt: Long,
val updatedAt: Long
)
@@ -81,6 +81,12 @@ data class CopyTrading(
@Column(name = "min_orderbook_depth", precision = 20, scale = 8)
val minOrderbookDepth: BigDecimal? = null, // 最小订单簿深度(USDC金额),NULL表示不启用
@Column(name = "min_price", precision = 20, scale = 8)
val minPrice: BigDecimal? = null, // 最低价格(可选),NULL表示不限制最低价
@Column(name = "max_price", precision = 20, scale = 8)
val maxPrice: BigDecimal? = null, // 最高价格(可选),NULL表示不限制最高价
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@@ -69,6 +69,12 @@ data class CopyTradingTemplate(
@Column(name = "min_orderbook_depth", precision = 20, scale = 8)
val minOrderbookDepth: BigDecimal? = null, // 最小订单簿深度(USDC金额),NULL表示不启用
@Column(name = "min_price", precision = 20, scale = 8)
val minPrice: BigDecimal? = null, // 最低价格(可选),NULL表示不限制最低价
@Column(name = "max_price", precision = 20, scale = 8)
val maxPrice: BigDecimal? = null, // 最高价格(可选),NULL表示不限制最高价
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@@ -208,7 +208,9 @@ class CopyOrderTrackingService(
val tokenId = tokenIdResult.getOrNull() ?: continue
// 过滤条件检查(在计算订单参数之前)
val filterCheck = filterService.checkFilters(copyTrading, tokenId, isBuyOrder = true)
// 传入 Leader 交易价格,用于价格区间检查
val tradePrice = trade.price.toSafeBigDecimal()
val filterCheck = filterService.checkFilters(copyTrading, tokenId, isBuyOrder = true, tradePrice = tradePrice)
if (!filterCheck.first) {
logger.warn("过滤条件检查失败,跳过创建订单: copyTradingId=${copyTrading.id}, reason=${filterCheck.second}")
@@ -1139,6 +1141,7 @@ class CopyOrderTrackingService(
*/
private fun extractFilterType(filterReason: String): String {
return when {
filterReason.contains("价格低于最低限制", ignoreCase = true) || filterReason.contains("价格高于最高限制", ignoreCase = true) -> "PRICE_RANGE"
filterReason.contains("订单深度不足", ignoreCase = true) -> "ORDER_DEPTH"
filterReason.contains("价差过大", ignoreCase = true) -> "SPREAD"
filterReason.contains("订单簿深度不足", ignoreCase = true) -> "ORDERBOOK_DEPTH"
@@ -30,12 +30,21 @@ class CopyTradingFilterService(
suspend fun checkFilters(
copyTrading: CopyTrading,
tokenId: String,
isBuyOrder: Boolean
isBuyOrder: Boolean,
tradePrice: BigDecimal? = null // Leader 交易价格,用于价格区间检查
): Pair<Boolean, String> {
// 1. 价格合理性检查(基础检查,无需配置
// 1. 价格区间检查(如果配置了价格区间
if (tradePrice != null) {
val priceRangeCheck = checkPriceRange(copyTrading, tradePrice)
if (!priceRangeCheck.first) {
return priceRangeCheck
}
}
// 2. 价格合理性检查(基础检查,无需配置)
// 这个检查在获取订单簿时进行,如果价格不在 0.01-0.99 范围内,订单簿获取会失败
// 2. 获取订单簿
// 3. 获取订单簿
val orderbookResult = clobService.getOrderbookByTokenId(tokenId)
if (!orderbookResult.isSuccess) {
val error = orderbookResult.exceptionOrNull()
@@ -47,19 +56,19 @@ class CopyTradingFilterService(
return Pair(false, "订单簿为空")
}
// 3. 买一卖一价差过滤
// 4. 买一卖一价差过滤
val spreadCheck = checkSpread(copyTrading, orderbook)
if (!spreadCheck.first) {
return spreadCheck
}
// 4. 订单深度过滤
// 5. 订单深度过滤
val depthCheck = checkOrderDepth(copyTrading, orderbook, isBuyOrder)
if (!depthCheck.first) {
return depthCheck
}
// 5. 最小订单簿深度过滤(可选)
// 6. 最小订单簿深度过滤(可选)
val orderbookDepthCheck = checkOrderbookDepth(copyTrading, orderbook, isBuyOrder)
if (!orderbookDepthCheck.first) {
return orderbookDepthCheck
@@ -68,6 +77,34 @@ class CopyTradingFilterService(
return Pair(true, "")
}
/**
* 检查价格区间
* @param copyTrading 跟单配置
* @param tradePrice Leader 交易价格
* @return Pair<是否通过, 失败原因>
*/
private fun checkPriceRange(
copyTrading: CopyTrading,
tradePrice: BigDecimal
): Pair<Boolean, String> {
// 如果未配置价格区间,直接通过
if (copyTrading.minPrice == null && copyTrading.maxPrice == null) {
return Pair(true, "")
}
// 检查最低价格
if (copyTrading.minPrice != null && tradePrice.lt(copyTrading.minPrice)) {
return Pair(false, "价格低于最低限制: $tradePrice < ${copyTrading.minPrice}")
}
// 检查最高价格
if (copyTrading.maxPrice != null && tradePrice.gt(copyTrading.maxPrice)) {
return Pair(false, "价格高于最高限制: $tradePrice > ${copyTrading.maxPrice}")
}
return Pair(true, "")
}
/**
* 检查买一卖一价差
* bestBid: 买盘中的最高价格(最大值)
@@ -76,7 +76,9 @@ class CopyTradingService(
supportSell = request.supportSell ?: template.supportSell,
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: template.minOrderDepth,
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: template.maxSpread,
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal() ?: template.minOrderbookDepth
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal() ?: template.minOrderbookDepth,
minPrice = request.minPrice?.toSafeBigDecimal() ?: template.minPrice,
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice
)
} else {
// 手动输入(所有字段必须提供)
@@ -101,7 +103,9 @@ class CopyTradingService(
supportSell = request.supportSell ?: true,
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal(),
maxSpread = request.maxSpread?.toSafeBigDecimal(),
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal()
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal(),
minPrice = request.minPrice?.toSafeBigDecimal(),
maxPrice = request.maxPrice?.toSafeBigDecimal()
)
}
@@ -126,7 +130,9 @@ class CopyTradingService(
supportSell = config.supportSell,
minOrderDepth = config.minOrderDepth,
maxSpread = config.maxSpread,
minOrderbookDepth = config.minOrderbookDepth
minOrderbookDepth = config.minOrderbookDepth,
minPrice = config.minPrice,
maxPrice = config.maxPrice
)
val saved = copyTradingRepository.save(copyTrading)
@@ -178,6 +184,8 @@ class CopyTradingService(
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: copyTrading.minOrderDepth,
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: copyTrading.maxSpread,
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal() ?: copyTrading.minOrderbookDepth,
minPrice = request.minPrice?.toSafeBigDecimal() ?: copyTrading.minPrice,
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: copyTrading.maxPrice,
updatedAt = System.currentTimeMillis()
)
@@ -380,6 +388,8 @@ class CopyTradingService(
minOrderDepth = copyTrading.minOrderDepth?.toPlainString(),
maxSpread = copyTrading.maxSpread?.toPlainString(),
minOrderbookDepth = copyTrading.minOrderbookDepth?.toPlainString(),
minPrice = copyTrading.minPrice?.toPlainString(),
maxPrice = copyTrading.maxPrice?.toPlainString(),
createdAt = copyTrading.createdAt,
updatedAt = copyTrading.updatedAt
)
@@ -405,6 +415,8 @@ class CopyTradingService(
val supportSell: Boolean,
val minOrderDepth: BigDecimal?,
val maxSpread: BigDecimal?,
val minOrderbookDepth: BigDecimal?
val minOrderbookDepth: BigDecimal?,
val minPrice: BigDecimal?,
val maxPrice: BigDecimal?
)
}
@@ -29,7 +29,8 @@ class CopyTradingStatisticsService(
private val sellMatchDetailRepository: SellMatchDetailRepository,
private val accountRepository: AccountRepository,
private val leaderRepository: LeaderRepository,
private val accountService: AccountService
private val accountService: AccountService,
private val blockchainService: com.wrbug.polymarketbot.service.BlockchainService
) {
private val logger = LoggerFactory.getLogger(CopyTradingStatisticsService::class.java)
@@ -59,11 +60,14 @@ class CopyTradingStatisticsService(
// 6. 计算统计信息
val statistics = calculateStatistics(buyOrders, sellRecords, matchDetails)
// 7. 获取当前市场价格(用于计算未实现盈亏)
// 7. 获取链上实际持仓(用于准确计算未实现盈亏,考虑手动卖出的情况
val actualPositions = getActualPositions(account)
// 8. 获取当前市场价格(用于计算未实现盈亏)
val currentPrice = getCurrentMarketPrice(buyOrders)
// 8. 计算未实现盈亏
val unrealizedPnl = calculateUnrealizedPnl(buyOrders, currentPrice)
// 9. 计算未实现盈亏(使用链上实际持仓,而不是 remainingQuantity
val unrealizedPnl = calculateUnrealizedPnl(buyOrders, currentPrice, actualPositions)
// 9. 构建响应
val response = CopyTradingStatisticsResponse(
@@ -314,49 +318,104 @@ class CopyTradingStatisticsService(
/**
* 获取当前市场价格
* 按 (marketId, outcomeIndex) 组合获取价格,支持多元市场
*/
private suspend fun getCurrentMarketPrice(buyOrders: List<CopyOrderTracking>): Map<String, String> {
val prices = mutableMapOf<String, String>()
// 获取所有不同的市场ID
val marketIds = buyOrders.map { it.marketId }.distinct()
// 获取所有不同的 (marketId, outcomeIndex) 组合
val marketOutcomePairs = buyOrders
.filter { it.outcomeIndex != null }
.map { Pair(it.marketId, it.outcomeIndex!!) }
.distinct()
for (marketId in marketIds) {
for ((marketId, outcomeIndex) in marketOutcomePairs) {
try {
val result = accountService.getMarketPrice(marketId)
// 传递 outcomeIndex 参数,确保获取对应 outcome 的价格
val result = accountService.getMarketPrice(marketId, outcomeIndex)
result.onSuccess { response ->
// 使用中间价,如果没有则使用最后价格
val price = response.midpoint ?: response.lastPrice
if (price != null) {
prices[marketId] = price
// 使用 "marketId:outcomeIndex" 作为 key
val key = "$marketId:$outcomeIndex"
prices[key] = price
}
}
} catch (e: Exception) {
logger.warn("获取市场价格失败: marketId=$marketId", e)
logger.warn("获取市场价格失败: marketId=$marketId, outcomeIndex=$outcomeIndex", e)
}
}
return prices
}
/**
* 获取链上实际持仓
* 按 (marketId, outcomeIndex) 组合返回实际持仓数量
*/
private suspend fun getActualPositions(account: com.wrbug.polymarketbot.entity.Account?): Map<String, BigDecimal> {
val positions = mutableMapOf<String, BigDecimal>()
if (account == null || account.proxyAddress.isBlank()) {
return positions
}
try {
val positionsResult = blockchainService.getPositions(account.proxyAddress)
if (positionsResult.isSuccess) {
val positionList = positionsResult.getOrNull() ?: emptyList()
for (pos in positionList) {
// 只处理有 conditionId 和 outcomeIndex 的仓位
if (pos.conditionId != null && pos.outcomeIndex != null && pos.size != null) {
val key = "${pos.conditionId}:${pos.outcomeIndex}"
val size = pos.size.toSafeBigDecimal()
// 如果 size > 0,表示有持仓;如果 size < 0,表示做空(取绝对值)
positions[key] = size.abs()
}
}
}
} catch (e: Exception) {
logger.warn("获取链上持仓失败: accountId=${account.id}, error=${e.message}", e)
}
return positions
}
/**
* 计算未实现盈亏
* 使用链上实际持仓数量,而不是 remainingQuantity(考虑手动卖出的情况)
*/
private fun calculateUnrealizedPnl(
buyOrders: List<CopyOrderTracking>,
currentPrices: Map<String, String>
currentPrices: Map<String, String>,
actualPositions: Map<String, BigDecimal>
): String {
var totalUnrealizedPnl = BigDecimal.ZERO
for (order in buyOrders) {
val remainingQty = order.remainingQuantity.toSafeBigDecimal()
if (remainingQty.lte(BigDecimal.ZERO)) continue
// 如果没有 outcomeIndex,跳过(无法确定价格和持仓)
if (order.outcomeIndex == null) {
logger.warn("订单缺少 outcomeIndex,跳过未实现盈亏计算: orderId=${order.buyOrderId}, marketId=${order.marketId}")
continue
}
val currentPrice = currentPrices[order.marketId]?.toSafeBigDecimal()
// 使用 "marketId:outcomeIndex" 作为 key
val key = "${order.marketId}:${order.outcomeIndex}"
// 获取链上实际持仓数量(如果存在),否则使用 remainingQuantity
val actualQty = actualPositions[key] ?: order.remainingQuantity.toSafeBigDecimal()
// 如果实际持仓 <= 0,说明已全部卖出(包括手动卖出),跳过未实现盈亏计算
if (actualQty.lte(BigDecimal.ZERO)) continue
// 获取当前市场价格
val currentPrice = currentPrices[key]?.toSafeBigDecimal()
?: continue // 如果没有当前价格,跳过
val buyPrice = order.price.toSafeBigDecimal()
val unrealizedPnl = currentPrice.subtract(buyPrice).multi(remainingQty)
// 使用实际持仓数量计算未实现盈亏
val unrealizedPnl = currentPrice.subtract(buyPrice).multi(actualQty)
totalUnrealizedPnl = totalUnrealizedPnl.add(unrealizedPnl)
}
@@ -61,7 +61,9 @@ class CopyTradingTemplateService(
supportSell = request.supportSell ?: true,
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal(),
maxSpread = request.maxSpread?.toSafeBigDecimal(),
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal()
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal(),
minPrice = request.minPrice?.toSafeBigDecimal(),
maxPrice = request.maxPrice?.toSafeBigDecimal()
)
val saved = templateRepository.save(template)
@@ -119,6 +121,8 @@ class CopyTradingTemplateService(
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: template.minOrderDepth,
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: template.maxSpread,
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal() ?: template.minOrderbookDepth,
minPrice = request.minPrice?.toSafeBigDecimal() ?: template.minPrice,
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice,
updatedAt = System.currentTimeMillis()
)
@@ -183,7 +187,9 @@ class CopyTradingTemplateService(
supportSell = request.supportSell ?: sourceTemplate.supportSell,
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: sourceTemplate.minOrderDepth,
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: sourceTemplate.maxSpread,
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal() ?: sourceTemplate.minOrderbookDepth
minOrderbookDepth = request.minOrderbookDepth?.toSafeBigDecimal() ?: sourceTemplate.minOrderbookDepth,
minPrice = request.minPrice?.toSafeBigDecimal() ?: sourceTemplate.minPrice,
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: sourceTemplate.maxPrice
)
val saved = templateRepository.save(newTemplate)
@@ -256,6 +262,8 @@ class CopyTradingTemplateService(
minOrderDepth = template.minOrderDepth?.toPlainString(),
maxSpread = template.maxSpread?.toPlainString(),
minOrderbookDepth = template.minOrderbookDepth?.toPlainString(),
minPrice = template.minPrice?.toPlainString(),
maxPrice = template.maxPrice?.toPlainString(),
createdAt = template.createdAt,
updatedAt = template.updatedAt
)
@@ -0,0 +1,15 @@
-- ============================================
-- V6: 添加价格区间过滤字段
-- 用于配置价格区间,仅在指定价格区间内的订单才会下单
-- ============================================
-- 添加价格区间字段到跟单配置表
ALTER TABLE copy_trading
ADD COLUMN min_price DECIMAL(20, 8) NULL COMMENT '最低价格(可选),NULL表示不限制最低价',
ADD COLUMN max_price DECIMAL(20, 8) NULL COMMENT '最高价格(可选),NULL表示不限制最高价';
-- 添加价格区间字段到跟单模板表
ALTER TABLE copy_trading_templates
ADD COLUMN min_price DECIMAL(20, 8) NULL COMMENT '最低价格(可选),NULL表示不限制最低价',
ADD COLUMN max_price DECIMAL(20, 8) NULL COMMENT '最高价格(可选),NULL表示不限制最高价';