feat: 添加市场截止时间筛选功能

后端:
- 为 copy_trading 表添加 max_market_end_date 字段(毫秒时间戳)
- 为 markets 表添加 end_date 字段,迁移时清空已有数据
- 实现市场截止时间过滤逻辑(仅跟单截止时间小于设置时间的订单)
- 更新 CopyTradingService 支持 maxMarketEndDate 的创建和更新
- 更新 FilterResult 添加 FAILED_MARKET_END_DATE 状态
- 更新 CopyOrderTrackingService 传递 marketEndDate 参数

前端:
- 添加市场截止时间筛选 UI(支持小时和天两种单位)
- 输入限制:仅支持整数输入(precision=0)
- 更新 AddModal 和 EditModal 支持市场截止时间配置
- 更新过滤类型显示映射(MARKET_END_DATE)
- 添加多语言支持(中文、英文、繁体中文)

数据库迁移:
- V22: 合并添加 max_market_end_date 和 end_date 字段
- 迁移时清空 markets 表数据,确保所有数据都包含 end_date
This commit is contained in:
WrBug
2026-01-09 08:35:05 +08:00
parent b13aef944c
commit 0327eaffe3
19 changed files with 511 additions and 842 deletions
@@ -42,7 +42,8 @@ data class CopyTradingCreateRequest(
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
// 新增配置字段
val configName: String? = null, // 配置名(可选)
val pushFailedOrders: Boolean? = null // 推送失败订单(可选)
val pushFailedOrders: Boolean? = null, // 推送失败订单(可选)
val maxMarketEndDate: Long? = null // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
)
/**
@@ -79,7 +80,8 @@ data class CopyTradingUpdateRequest(
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
// 新增配置字段
val configName: String? = null, // 配置名(可选,但提供时必须非空)
val pushFailedOrders: Boolean? = null // 推送失败订单(可选)
val pushFailedOrders: Boolean? = null, // 推送失败订单(可选)
val maxMarketEndDate: Long? = null // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
)
/**
@@ -154,6 +156,7 @@ data class CopyTradingDto(
// 新增配置字段
val configName: String? = null, // 配置名(可选)
val pushFailedOrders: Boolean = false, // 推送失败订单(默认关闭)
val maxMarketEndDate: Long? = null, // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
val createdAt: Long,
val updatedAt: Long
)
@@ -105,6 +105,9 @@ data class CopyTrading(
@Column(name = "push_failed_orders", nullable = false)
val pushFailedOrders: Boolean = false, // 推送失败订单(默认关闭)
@Column(name = "max_market_end_date")
val maxMarketEndDate: Long? = null, // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@@ -48,6 +48,9 @@ data class Market(
@Column(name = "archived", nullable = false)
val archived: Boolean = false, // 是否已归档
@Column(name = "end_date")
val endDate: Long? = null, // 市场截止时间(毫秒时间戳)
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@@ -11,6 +11,8 @@ import com.wrbug.polymarketbot.util.getEventSlug
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.time.Instant
import java.time.format.DateTimeFormatter
/**
* 市场信息服务
@@ -173,6 +175,7 @@ class MarketService(
active = marketResponse.active ?: existingMarket.active,
closed = marketResponse.closed ?: existingMarket.closed,
archived = marketResponse.archived ?: existingMarket.archived,
endDate = parseEndDate(marketResponse.endDate),
updatedAt = System.currentTimeMillis()
)
} else {
@@ -189,6 +192,7 @@ class MarketService(
active = marketResponse.active ?: true,
closed = marketResponse.closed ?: false,
archived = marketResponse.archived ?: false,
endDate = parseEndDate(marketResponse.endDate),
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis()
)
@@ -209,5 +213,22 @@ class MarketService(
fun clearCache() {
marketCache.invalidateAll()
}
/**
* 解析市场截止时间(ISO 8601 格式)
*/
private fun parseEndDate(endDate: String?): Long? {
if (endDate.isNullOrBlank()) {
return null
}
return try {
// ISO 8601 格式,例如:2025-03-15T12:00:00Z
Instant.parse(endDate).toEpochMilli()
} catch (e: Exception) {
logger.warn("解析市场截止时间失败: endDate=$endDate, error=${e.message}")
null
}
}
}
@@ -35,6 +35,7 @@ class CopyTradingFilterService(
* @param copyOrderAmount 跟单金额(USDC),用于仓位检查,如果为null则不进行仓位检查
* @param marketId 市场ID,用于仓位检查(按市场过滤仓位)
* @param marketTitle 市场标题,用于关键字过滤
* @param marketEndDate 市场截止时间,用于市场截止时间检查
* @return 过滤结果
*/
suspend fun checkFilters(
@@ -43,7 +44,8 @@ class CopyTradingFilterService(
tradePrice: BigDecimal? = null, // Leader 交易价格,用于价格区间检查
copyOrderAmount: BigDecimal? = null, // 跟单金额(USDC),用于仓位检查
marketId: String? = null, // 市场ID,用于仓位检查(按市场过滤仓位)
marketTitle: String? = null // 市场标题,用于关键字过滤
marketTitle: String? = null, // 市场标题,用于关键字过滤
marketEndDate: Long? = null // 市场截止时间,用于市场截止时间检查
): FilterResult {
// 1. 关键字过滤检查(如果配置了关键字过滤)
if (copyTrading.keywordFilterMode != null && copyTrading.keywordFilterMode != "DISABLED") {
@@ -53,6 +55,14 @@ class CopyTradingFilterService(
}
}
// 1.5. 市场截止时间检查(如果配置了市场截止时间限制)
if (copyTrading.maxMarketEndDate != null) {
val marketEndDateCheck = checkMarketEndDate(copyTrading, marketEndDate)
if (!marketEndDateCheck.isPassed) {
return marketEndDateCheck
}
}
// 2. 价格区间检查(如果配置了价格区间)
if (tradePrice != null) {
val priceRangeCheck = checkPriceRange(copyTrading, tradePrice)
@@ -358,5 +368,38 @@ class CopyTradingFilterService(
return FilterResult.maxPositionValueFailed("仓位检查异常: ${e.message}")
}
}
/**
* 检查市场截止时间
* @param copyTrading 跟单配置
* @param marketEndDate 市场截止时间(毫秒时间戳)
* @return 过滤结果
*/
private fun checkMarketEndDate(
copyTrading: CopyTrading,
marketEndDate: Long?
): FilterResult {
// 如果未配置市场截止时间限制,直接通过
if (copyTrading.maxMarketEndDate == null) {
return FilterResult.passed()
}
// 如果没有市场截止时间,无法检查,为了安全起见,不通过
if (marketEndDate == null) {
return FilterResult.marketEndDateFailed("市场缺少截止时间信息,无法进行市场截止时间检查")
}
// 检查:市场截止时间 - 当前时间 <= 最大限制时间
val currentTime = System.currentTimeMillis()
val remainingTime = marketEndDate - currentTime
if (remainingTime > copyTrading.maxMarketEndDate) {
return FilterResult.marketEndDateFailed(
"市场截止时间超出限制: 剩余时间=${remainingTime}ms (${remainingTime / (1000 * 60 * 60)}小时) > 最大限制=${copyTrading.maxMarketEndDate}ms (${copyTrading.maxMarketEndDate / (1000 * 60 * 60)}小时)"
)
}
return FilterResult.passed()
}
}
@@ -94,7 +94,8 @@ class CopyTradingService(
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
maxPositionCount = request.maxPositionCount,
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
keywords = convertKeywordsToJson(request.keywords)
keywords = convertKeywordsToJson(request.keywords),
maxMarketEndDate = request.maxMarketEndDate
)
} else {
// 手动输入(所有字段必须提供)
@@ -124,7 +125,8 @@ class CopyTradingService(
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
maxPositionCount = request.maxPositionCount,
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
keywords = convertKeywordsToJson(request.keywords)
keywords = convertKeywordsToJson(request.keywords),
maxMarketEndDate = request.maxMarketEndDate
)
}
@@ -156,7 +158,8 @@ class CopyTradingService(
keywordFilterMode = config.keywordFilterMode,
keywords = config.keywords,
configName = configName,
pushFailedOrders = request.pushFailedOrders ?: false
pushFailedOrders = request.pushFailedOrders ?: false,
maxMarketEndDate = config.maxMarketEndDate
)
val saved = copyTradingRepository.save(copyTrading)
@@ -233,6 +236,7 @@ class CopyTradingService(
},
configName = configName,
pushFailedOrders = request.pushFailedOrders ?: copyTrading.pushFailedOrders,
maxMarketEndDate = request.maxMarketEndDate ?: copyTrading.maxMarketEndDate,
updatedAt = System.currentTimeMillis()
)
@@ -446,6 +450,7 @@ class CopyTradingService(
keywords = convertJsonToKeywords(copyTrading.keywords),
configName = copyTrading.configName,
pushFailedOrders = copyTrading.pushFailedOrders,
maxMarketEndDate = copyTrading.maxMarketEndDate,
createdAt = copyTrading.createdAt,
updatedAt = copyTrading.updatedAt
)
@@ -506,6 +511,7 @@ class CopyTradingService(
val maxPositionValue: BigDecimal?,
val maxPositionCount: Int?,
val keywordFilterMode: String,
val keywords: String? // JSON 字符串
val keywords: String?, // JSON 字符串
val maxMarketEndDate: Long? // 市场截止时间限制(毫秒时间戳)
)
}
@@ -23,7 +23,9 @@ enum class FilterStatus {
/** 失败:超过最大仓位数量 */
FAILED_MAX_POSITION_COUNT,
/** 失败:关键字过滤 */
FAILED_KEYWORD_FILTER
FAILED_KEYWORD_FILTER,
/** 失败:市场截止时间超出限制 */
FAILED_MARKET_END_DATE
}
/**
@@ -97,6 +99,12 @@ data class FilterResult(
status = FilterStatus.FAILED_KEYWORD_FILTER,
reason = reason
)
/** 市场截止时间超出限制 */
fun marketEndDateFailed(reason: String) = FilterResult(
status = FilterStatus.FAILED_MARKET_END_DATE,
reason = reason
)
}
}
@@ -266,14 +266,18 @@ open class CopyOrderTrackingService(
// 计算跟单金额(USDC)= 买入数量 × 价格
val copyOrderAmount = buyQuantity.multi(tradePrice)
// 如果启用了关键字过滤,需要先获取市场标题
// 如果启用了关键字过滤或市场截止时间过滤,需要先获取市场信息
var marketTitle: String? = null
if (copyTrading.keywordFilterMode != null && copyTrading.keywordFilterMode != "DISABLED") {
var marketEndDate: Long? = null
val needMarketInfo = copyTrading.keywordFilterMode != "DISABLED" || copyTrading.maxMarketEndDate != null
if (needMarketInfo) {
try {
val market = marketService.getMarket(trade.market)
marketTitle = market?.title
marketEndDate = market?.endDate
} catch (e: Exception) {
logger.warn("获取市场标题失败(关键字过滤需要): ${e.message}", e)
logger.warn("获取市场信息失败(关键字过滤/市场截止时间检查需要): ${e.message}", e)
}
}
@@ -281,6 +285,7 @@ open class CopyOrderTrackingService(
// 传入 Leader 交易价格,用于价格区间检查
// 传入跟单金额和市场ID,用于仓位检查(按市场检查仓位)
// 传入市场标题,用于关键字过滤
// 传入市场截止时间,用于市场截止时间检查
// 订单簿只请求一次,返回给后续逻辑使用
val filterResult = filterService.checkFilters(
copyTrading,
@@ -288,7 +293,8 @@ open class CopyOrderTrackingService(
tradePrice = tradePrice,
copyOrderAmount = copyOrderAmount,
marketId = trade.market,
marketTitle = marketTitle
marketTitle = marketTitle,
marketEndDate = marketEndDate
)
val orderbook = filterResult.orderbook // 获取订单簿(如果需要)
if (!filterResult.isPassed) {
@@ -1403,6 +1409,7 @@ open class CopyOrderTrackingService(
FilterStatus.FAILED_MAX_POSITION_VALUE -> "MAX_POSITION_VALUE"
FilterStatus.FAILED_MAX_POSITION_COUNT -> "MAX_POSITION_COUNT"
FilterStatus.FAILED_KEYWORD_FILTER -> "KEYWORD_FILTER"
FilterStatus.FAILED_MARKET_END_DATE -> "MARKET_END_DATE"
}
}
@@ -0,0 +1,20 @@
-- ============================================
-- V22: 添加市场截止时间筛选功能
-- 1. 为 copy_trading 表添加 max_market_end_date 字段
-- 2. 为 markets 表添加 end_date 字段并清空已有数据
-- ============================================
-- 1. 添加市场截止时间筛选字段到 copy_trading 表
-- 仅跟单截止时间小于设置时间的订单
-- 存储毫秒时间戳,NULL 表示不启用此筛选
ALTER TABLE copy_trading
ADD COLUMN max_market_end_date BIGINT NULL COMMENT '市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用';
-- 2. 为 markets 表添加 end_date 字段
-- 删除已有数据,让系统重新从 API 获取,确保所有数据的 end_date 都正确填充
DELETE FROM markets;
-- 添加市场截止时间字段
ALTER TABLE markets
ADD COLUMN end_date BIGINT NULL COMMENT '市场截止时间(毫秒时间戳),NULL表示未设置' AFTER archived;