diff --git a/.cursor/rules/backend.mdc b/.cursor/rules/backend.mdc index d9934c8..dac0ae6 100644 --- a/.cursor/rules/backend.mdc +++ b/.cursor/rules/backend.mdc @@ -5,391 +5,63 @@ path: backend/** # 后端开发规范 -## 代码完成规范 - -### TODO 处理规则 -- **禁止**在代码中添加 TODO 注释 -- **必须**根据 TODO 的内容直接完成代码实现 -- 如果遇到暂时无法完全实现的功能,应该: - 1. 实现一个可用的基础版本 - 2. 添加清晰的注释说明当前实现的限制和后续改进方向 - 3. 确保代码可以正常编译和运行 -- **禁止**使用 `// TODO: 实现XXX` 这样的注释 -- **禁止**使用 `// FIXME:` 或 `// XXX:` 这样的注释 -- 如果某个功能需要依赖外部资源(如 API、库等),应该: - 1. 先实现一个占位或模拟实现 - 2. 在注释中说明依赖关系和实现方式 - 3. 确保代码逻辑完整,不会因为未实现的功能而崩溃 - -### API 调用实现规则 -- **必须**查找相关的 API 文档或接口定义 -- **必须**根据 API 文档完成代码实现 -- **禁止**在 API 调用处添加 TODO 注释 -- **禁止**直接返回 mock 数据或硬编码的假数据 -- **必须**调用真实的 API 或查询真实的数据库 -- 如果 API 文档不完整,应该: - 1. 查找项目中已有的类似 API 调用作为参考 - 2. 查看 API 接口定义(如 `PolymarketClobApi.kt`) - 3. 查看 API 文档(如 `docs/polymarket-api-reference.md`) - 4. 实现一个可用的版本,包含错误处理 -- 如果 API 调用失败,应该: - 1. 返回明确的错误信息 - 2. 记录错误日志 - 3. 返回错误响应,而不是返回 mock 数据 - -### 代码完成示例 - -```kotlin -// ❌ 错误:添加 TODO 注释 -fun getAccountBalance(accountId: Long?): Result { - // TODO: 调用 Polymarket API 查询余额 - return Result.success(AccountBalanceResponse(usdcBalance = "0")) -} - -// ✅ 正确:查找 API 文档并完成实现 -// 1. 查找 API 接口定义:PolymarketClobApi.getActiveOrders() -// 2. 查找 API 文档:docs/polymarket-api-reference.md -// 3. 实现完整的 API 调用逻辑,调用真实的 API -fun getAccountBalance(accountId: Long?): Result { - return try { - val account = getAccount(accountId) ?: return Result.failure(IllegalArgumentException("账户不存在")) - - // 如果账户没有配置 API 凭证,返回错误而不是 mock 数据 - if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) { - return Result.failure(IllegalStateException("账户未配置 API 凭证,无法查询余额")) - } - - // 解密 API 凭证并创建认证客户端 - val apiKey = cryptoUtils.decrypt(account.apiKey!!) - val apiSecret = cryptoUtils.decrypt(account.apiSecret!!) - val apiPassphrase = cryptoUtils.decrypt(account.apiPassphrase!!) - val clobApi = retrofitFactory.createClobApi(apiKey, apiSecret, apiPassphrase) - - // 调用真实的 API 查询余额 - val response = clobApi.getActiveOrders(limit = 100) - if (response.isSuccessful && response.body() != null) { - // 根据 API 响应处理余额数据(从真实响应中解析) - val orders = response.body()!! - // 实际应该调用余额查询接口或从链上查询 - // 这里只是示例,实际应该调用真实的余额查询 API - val balance = queryRealBalanceFromApi(clobApi, account.walletAddress) - Result.success(AccountBalanceResponse(usdcBalance = balance)) - } else { - logger.error("查询余额失败: ${response.code()} ${response.message()}") - Result.failure(Exception("查询余额失败: ${response.code()} ${response.message()}")) - } - } catch (e: Exception) { - logger.error("查询账户余额失败", e) - Result.failure(e) - } -} -``` - -## 需求文档引用 - -### 跟单系统需求 -- **需求文档**: `docs/copy-trading-requirements.md` -- **说明**: 所有跟单系统相关的功能实现必须严格按照需求文档执行 -- **核心功能**: - - 账户管理(通过私钥导入,支持多账户) - - Leader 管理(被跟单者管理) - - 订单同步与执行(监控 Leader 交易并自动复制) - - 跟单配置管理(全局配置和单个 Leader 配置) - - 风险控制(每日亏损限制、订单数限制等) - - 跟单记录与统计 - -**重要提示**: 在实现跟单系统相关功能时,请先查阅 `docs/copy-trading-requirements.md` 了解详细需求,包括: -- 数据模型设计(Account、Leader、CopyOrder 等) -- API 接口设计(请求/响应格式) -- 业务规则和验证逻辑 -- 安全要求(私钥加密存储、API Key 管理等) +## 核心原则 +- **禁止**在代码中添加 TODO/FIXME/XXX 注释 +- **禁止**返回 mock 数据或硬编码的假数据 +- **禁止**在 API 调用失败时返回默认值作为 fallback +- 所有功能必须完整实现,确保代码可以正常运行 ## 项目范围 - -### 平台支持 -- **仅支持**: Polymarket 平台 -- **不支持**: 其他预测市场平台(如 Kalshi 等) - -### 分类支持 -- **仅支持**: - - `sports`: 体育相关市场 - - `crypto`: 加密货币相关市场 -- **不支持**: 其他分类(如 politics、entertainment 等) - -### 分类验证 -- 所有涉及分类的接口、实体、服务必须验证分类参数 -- 分类参数只能是 `sports` 或 `crypto` -- 无效分类应返回明确的错误提示 - -## 包名规范 +- **平台**: 仅支持 Polymarket +- **分类**: 仅支持 `sports` 和 `crypto` - **包名**: `com.wrbug.polymarketbot` -- 所有代码必须使用此包名 ## 实体类规范 - -### ID字段规范 -- **必须**使用 `Long? = null` 作为 `@GeneratedValue` 的 id 字段 -- **禁止**使用 `Long = 0` 或其他默认值 - ```kotlin -// ✅ 正确 @Entity @Table(name = "example_table") data class ExampleEntity( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - val id: Long? = null, - // ... -) - -// ❌ 错误 -@Entity -data class ExampleEntity( - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - val id: Long = 0, // 禁止使用 - // ... + val id: Long? = null, // ✅ 正确:使用可空类型 + + @Column(name = "created_at", nullable = false) + val createdAt: Long = System.currentTimeMillis(), // ✅ 使用 Long 时间戳 + + @Column(name = "amount", nullable = false, precision = 20, scale = 8) + val amount: BigDecimal = BigDecimal.ZERO // ✅ 使用 BigDecimal ) ``` +**规则**: +- ID 字段必须使用 `Long? = null` +- 时间字段使用 `Long` 时间戳(毫秒) +- 数值字段使用 `BigDecimal` +- **禁止**使用 `LocalDateTime` 或 `Double` + ## 配置文件规范 - -### 配置文件格式 -- **必须**使用 `application.properties` 格式 -- **禁止**使用 `application.yml` 格式 -- 配置文件位置: `src/main/resources/application.properties` - -### 配置示例 -```properties -# 应用配置 -spring.application.name=polymarket-bot-backend - -# 数据源配置 -spring.datasource.url=jdbc:mysql://localhost:3306/polymarket_bot?useSSL=false&serverTimezone=UTC&characterEncoding=utf8mb4 -spring.datasource.username=${DB_USERNAME:root} -spring.datasource.password=${DB_PASSWORD:password} -spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver - -# HikariCP 连接池配置 -spring.datasource.hikari.maximum-pool-size=10 -spring.datasource.hikari.minimum-idle=2 -spring.datasource.hikari.connection-timeout=30000 - -# JPA 配置 -spring.jpa.hibernate.ddl-auto=validate -spring.jpa.show-sql=false -spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect - -# Flyway 配置 -spring.flyway.enabled=true -spring.flyway.locations=classpath:db/migration -spring.flyway.baseline-on-migrate=true - -# 服务器配置 -server.port=${SERVER_PORT:8000} - -# 日志配置 -logging.level.root=INFO -logging.level.com.wrbug.polymarketbot=DEBUG -logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} - %msg%n -``` - -### 环境变量引用 -- 使用 `${ENV_VAR:default}` 格式引用环境变量 -- 支持多环境配置: `application-dev.properties`, `application-prod.properties` +- **必须**使用 `application.properties` 格式(禁止 `application.yml`) +- 使用 `${ENV_VAR:default}` 引用环境变量 ## 代码规范 -### Controller规范 -- Controller 方法**禁止**使用 `suspend` -- 如需调用 suspend 方法,使用 `runBlocking`(最小化使用) -- 只作用于 suspend 方法调用 +### Controller +- **禁止**使用 `suspend` +- 使用 `runBlocking` 调用 suspend 方法 +- 统一使用 `@PostMapping` -```kotlin -// ✅ 正确 -@RestController -class ExampleController( - private val exampleService: ExampleService -) { - @PostMapping("/example") - fun getExample(): ResponseEntity> { - val data = runBlocking { exampleService.getData() } - return ResponseEntity.ok(ApiResponse.success(data)) - } -} - -// ❌ 错误 -@RestController -class ExampleController { - @PostMapping("/example") - suspend fun getExample(): ResponseEntity> { // 禁止使用suspend - // ... - } -} -``` - -### Service规范 -- Service 层可以使用 `suspend` 方法 +### Service +- 可以使用 `suspend` 方法 - 使用 `@Transactional` 管理事务 - 使用构造函数注入依赖 -```kotlin -@Service -class ExampleService( - private val exampleRepository: ExampleRepository -) { - suspend fun getAllData(): List { - return exampleRepository.findAll() - } - - @Transactional - fun saveData(entity: ExampleEntity): ExampleEntity { - return exampleRepository.save(entity) - } -} -``` - -### Repository规范 -- Repository 接口继承 `JpaRepository` +### Repository +- 继承 `JpaRepository` - 使用 Spring Data JPA 方法命名规范 -```kotlin -@Repository -interface ExampleRepository : JpaRepository { - fun findByCode(code: String): ExampleEntity? - fun findByCategoryAndStatus(category: String, status: String): List - fun findByCategory(category: String): List // category: sports 或 crypto -} -``` +## API 接口规范 -### Entity规范 -- 使用 `@Entity` 和 `@Table` 注解 -- ID字段使用 `Long? = null` -- 时间字段使用 `Long` 时间戳(毫秒) -- 数值字段使用 `BigDecimal`,使用 `String` 存储 - -```kotlin -@Entity -@Table(name = "example_table") -data class ExampleEntity( - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - val id: Long? = null, - - @Column(name = "code", unique = true, nullable = false, length = 100) - val code: String = "", - - @Column(name = "category", nullable = false, length = 20) - val category: String = "", // sports 或 crypto - - @Column(name = "amount", nullable = false, precision = 20, scale = 8) - val amount: BigDecimal = BigDecimal.ZERO, - - @Column(name = "status", nullable = false, length = 20) - val status: String = "", // active, inactive - - @Column(name = "created_at", nullable = false) - val createdAt: Long = System.currentTimeMillis(), - - @Column(name = "updated_at", nullable = false) - var updatedAt: Long = System.currentTimeMillis() -) -``` - -## 数值计算规范 - -### BigDecimal使用 -- 所有数值计算必须使用 `BigDecimal` -- 使用 `String` 存储数值 -- 使用扩展函数进行安全转换和比较 - -```kotlin -// 使用扩展函数 -val amount = "0.5".toSafeBigDecimal() -val total = amount.add("0.4".toSafeBigDecimal()) - -// 比较 -if (total.lt(BigDecimal.ONE)) { - // 业务逻辑 -} -``` - -## 时间字段规范 - -### 时间戳使用 -- 所有时间字段使用 `Long` 类型存储毫秒级时间戳 -- **禁止**使用 `LocalDateTime` 或其他时间类型 - -```kotlin -// ✅ 正确 -@Column(name = "created_at", nullable = false) -val createdAt: Long = System.currentTimeMillis() - -// ❌ 错误 -@Column(name = "created_at") -val createdAt: LocalDateTime = LocalDateTime.now() // 禁止使用 -``` - -## HTTP客户端规范 - -### Retrofit使用 -- 使用 Retrofit 定义 API 接口 -- 使用 OkHttp 作为底层 HTTP 客户端 -- 使用拦截器处理认证 - -```kotlin -// Polymarket CLOB API接口定义(跟单系统需要) -interface PolymarketClobApi { - @POST("/orders") - suspend fun createOrder(@Body order: CreateOrderRequest): Response - - @GET("/orders/active") - suspend fun getActiveOrders( - @Query("market") market: String?, - @Query("limit") limit: Int?, - @Query("offset") offset: Int? - ): Response> - - @DELETE("/orders/{orderId}") - suspend fun cancelOrder(@Path("orderId") orderId: String): Response - - @GET("/trades") - suspend fun getTrades( - @Query("market") market: String?, - @Query("user") user: String?, - @Query("limit") limit: Int?, - @Query("offset") offset: Int? - ): Response> -} - -// Retrofit配置 -@Configuration -class RetrofitConfig { - @Bean - fun polymarketClobApi(): PolymarketClobApi { - val okHttpClient = OkHttpClient.Builder() - .addInterceptor(AuthInterceptor()) - .build() - - return Retrofit.Builder() - .baseUrl("https://clob.polymarket.com") - .client(okHttpClient) - .addConverterFactory(GsonConverterFactory.create()) - .build() - .create(PolymarketClobApi::class.java) - } -} -``` - -## API接口规范 - -### 请求规范 -- **所有接口统一使用POST方法**,包括查询类接口 -- 请求头: `Content-Type: application/json` -- 请求体: JSON格式 - -### 响应规范 -- **统一响应格式**: +### 统一响应格式 ```json { "code": 0, @@ -398,77 +70,7 @@ class RetrofitConfig { } ``` -- **响应字段说明**: - - `code`: 响应码,0表示成功,非0表示失败 - - `data`: 响应数据,可以是任意类型(对象、数组、字符串、数字等) - - `msg`: 响应消息,成功时通常为空,失败时包含错误提示 - -### 响应示例 - -**成功响应**: -```json -{ - "code": 0, - "data": { - "id": "123", - "name": "example" - }, - "msg": "" -} -``` - -**失败响应**: -```json -{ - "code": 1001, - "data": null, - "msg": "参数错误:参数不能为空" -} -``` - -### Controller实现示例 - -```kotlin -@RestController -@RequestMapping("/api/example") -class ExampleController( - private val exampleService: ExampleService -) { - private val logger = LoggerFactory.getLogger(ExampleController::class.java) - - @PostMapping("/list") - fun getList(@RequestBody request: ExampleListRequest): ResponseEntity> { - return try { - val data = runBlocking { exampleService.getList(request) } - val response = ExampleListResponse( - list = data, - total = data.size.toLong(), - page = request.page ?: 1, - limit = request.limit ?: 20 - ) - ResponseEntity.ok(ApiResponse.success(response)) - } catch (e: Exception) { - logger.error("Failed to get list", e) - ResponseEntity.ok(ApiResponse.serverError("获取列表失败:${e.message}")) - } - } -} - -// 统一响应格式 -data class ApiResponse( - val code: Int, - val data: T?, - val msg: String -) { - companion object { - fun success(data: T): ApiResponse = ApiResponse(0, data, "") - fun paramError(msg: String): ApiResponse = ApiResponse(1001, null, msg) - fun serverError(msg: String): ApiResponse = ApiResponse(5001, null, msg) - } -} -``` - -### 错误码规范 +### 错误码范围 - `0`: 成功 - `1001-1999`: 参数错误 - `2001-2999`: 认证/权限错误 @@ -476,7 +78,80 @@ data class ApiResponse( - `4001-4999`: 业务逻辑错误 - `5001-5999`: 服务器内部错误 -详细错误码定义参见需求文档 +### Controller 示例 +```kotlin +@RestController +@RequestMapping("/api/example") +class ExampleController( + private val exampleService: ExampleService, + private val messageSource: MessageSource +) { + @PostMapping("/list") + fun getList(@RequestBody request: ExampleListRequest): ResponseEntity> { + return try { + val data = runBlocking { exampleService.getList(request) } + ResponseEntity.ok(ApiResponse.success(data)) + } catch (e: Exception) { + logger.error("Failed to get list", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource)) + } + } +} +``` + +## 数值计算 +```kotlin +val amount = "0.5".toSafeBigDecimal() +val total = amount.add("0.4".toSafeBigDecimal()) + +if (total.lt(BigDecimal.ONE)) { + // 业务逻辑 +} +``` + +## Side 判断规范 +**禁止**使用 "YES"/"NO" 字符串判断 side + +```kotlin +// ❌ 错误 +if (side != null && side.uppercase() == "NO") { } + +// ✅ 正确 +if (outcomeIndex != null && outcomeIndex == 1) { } +``` + +## 多语言规范 +- **禁止**硬编码中文或英文错误消息 +- **必须**使用 `ErrorCode` 枚举和 `MessageSource` + +```kotlin +// ❌ 错误 +return ResponseEntity.ok(ApiResponse.paramError("配置ID不能为空")) + +// ✅ 正确 +return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, messageSource)) +``` + +### 多语言支持范围 +1. **API 响应消息**: 使用 `ErrorCode` + `MessageSource` +2. **日志消息**: 可使用中文或英文 +3. **代码注释**: 建议使用中文 +4. **数据库字段**: 使用英文(snake_case) + +## HTTP 客户端 +- 使用 Retrofit + OkHttp +- 使用拦截器处理认证 + +## 跟单系统需求 +参考文档: `docs/copy-trading-requirements.md` + +核心功能: +- 账户管理(私钥导入,多账户) +- Leader 管理 +- 订单同步与执行 +- 跟单配置管理 +- 风险控制 +- 跟单记录与统计 ## 禁止事项 @@ -484,173 +159,23 @@ data class ApiResponse( - ❌ 禁止使用 `!!` 除非有明确原因 - ❌ 禁止忽略异常 - ❌ 禁止硬编码配置值 -- ❌ 禁止提交敏感信息到Git -- ❌ Controller 方法禁止使用 `suspend` -- ❌ 实体类ID禁止使用 `Long = 0` -- ❌ **禁止直接返回 mock 数据或硬编码的假数据** -- ❌ **禁止在 API 调用失败时返回 mock 数据作为默认值** -- ❌ **所有返回的数据必须来自真实的 API 调用或数据库查询** - -### 配置 -- ❌ 禁止使用 `application.yml` -- ❌ 禁止在代码中硬编码配置值 +- ❌ 禁止提交敏感信息到 Git ### 类型 - ❌ 禁止使用 `Double` 进行数值计算 - ❌ 禁止使用 `LocalDateTime` 存储时间 -- ❌ 禁止实体类ID使用非空默认值 +- ❌ 禁止实体类 ID 使用非空默认值 -### API接口 -- ❌ 禁止使用GET、PUT、DELETE等方法(统一使用POST) +### API 接口 +- ❌ 禁止使用 GET/PUT/DELETE(统一使用 POST) - ❌ 禁止返回不符合统一格式的响应 -- ❌ 禁止在响应中直接返回Map类型(使用data class) +- ❌ 禁止在响应中直接返回 Map 类型 -### Side 判断规范 -- ❌ **禁止使用 "YES" 或 "NO" 字符串去判断 side** -- ✅ **必须使用 `outcomeIndex` 来判断方向**(0 = 第一个 outcome,1 = 第二个 outcome,以此类推) -- ✅ 如果必须使用 side 字符串,应该从市场的 outcomes 数组中获取,而不是硬编码 "YES"/"NO" -- ✅ 对于二元市场的价格转换,应该通过 `outcomeIndex` 判断是否为第二个 outcome(index = 1),而不是判断 side 是否为 "NO" +### Side 判断 +- ❌ 禁止使用 "YES"/"NO" 字符串判断 side +- ✅ 必须使用 `outcomeIndex` 判断 -```kotlin -// ❌ 错误:使用字符串比较判断 side -if (side != null && side.uppercase() == "NO") { - // 转换价格 -} - -// ❌ 错误:硬编码 "YES"/"NO" 判断 -when (side.uppercase()) { - "YES" -> // ... - "NO" -> // ... -} - -// ✅ 正确:使用 outcomeIndex 判断 -if (outcomeIndex != null && outcomeIndex == 1) { - // 第二个 outcome(在二元市场中通常是 NO),转换价格 -} - -// ✅ 正确:从市场 outcomes 获取 side 信息 -val outcomes = JsonUtils.parseStringArray(market.outcomes) -val targetOutcomeIndex = outcomes.indexOfFirst { it.equals(side, ignoreCase = true) } -if (targetOutcomeIndex >= 0) { - // 使用 targetOutcomeIndex 进行判断 -} -``` - -## 多语言使用规范 - -### 错误消息和响应文本 -- **禁止**在代码中硬编码中文或英文错误消息 -- **必须**使用 `ErrorCode` 枚举定义错误码和消息 -- **必须**使用 `ApiResponse.error(ErrorCode, messageSource)` 或 `MessageUtils.getMessage()` 获取国际化消息 -- **禁止**直接使用 `ApiResponse.paramError("硬编码消息")` 或 `ApiResponse.serverError("硬编码消息")` -- 错误消息的默认语言使用中文(在 `ErrorCode` 枚举中定义),通过 `MessageSource` 支持多语言 - -### 使用 ErrorCode 和 MessageSource -项目已经实现了国际化支持,必须使用以下方式: - -```kotlin -// ❌ 错误:硬编码错误消息 -return ResponseEntity.ok(ApiResponse.paramError("配置ID不能为空")) -return ResponseEntity.ok(ApiResponse.serverError("获取配置列表失败:${e.message}")) - -// ✅ 正确:使用 ErrorCode 枚举 -return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, messageSource = messageSource)) - -// ✅ 正确:使用 ErrorCode 和自定义消息(如果需要动态消息) -return ResponseEntity.ok(ApiResponse.error( - ErrorCode.PARAM_ERROR, - customMsg = "配置ID不能为空", - messageSource = messageSource -)) - -// ✅ 正确:使用 MessageUtils -@Autowired -private lateinit var messageUtils: MessageUtils - -return ResponseEntity.ok(ApiResponse.error( - ErrorCode.PARAM_EMPTY, - messageSource = messageSource -)) -``` - -### 添加新的错误码 -如果需要添加新的错误码,必须在 `ErrorCode` 枚举中定义: - -```kotlin -enum class ErrorCode( - val code: Int, - val message: String, // 默认消息(中文) - val messageKey: String // 国际化消息键 -) { - // 新错误码示例 - NOTIFICATION_CONFIG_NOT_FOUND(3009, "通知配置不存在", "error.notification_config_not_found"), - NOTIFICATION_CONFIG_INVALID(4009, "通知配置无效", "error.notification_config_invalid"), -} -``` - -然后在语言资源文件中添加对应的翻译: -- `src/main/resources/messages_zh_CN.properties` -- `src/main/resources/messages_zh_TW.properties` -- `src/main/resources/messages_en.properties` - -### 日志消息规范 -- **日志消息可以使用中文或英文**,便于开发调试 -- **禁止**在日志中硬编码用户可见的错误消息(应该使用 ErrorCode) -- 日志消息应该清晰、简洁,便于排查问题 -- 日志中的业务数据(如账户名、订单ID等)可以使用原始值 - -### 代码注释规范 -- **代码注释可以使用中文或英文** -- **业务逻辑注释**建议使用中文,便于团队理解 -- **API 文档注释**(KDoc)建议使用中文,但也可以使用英文 -- **类和方法注释**应该清晰说明功能和参数 - -### 数据库字段和配置 -- **数据库字段名**使用英文(snake_case) -- **配置项名称**使用英文(kebab-case 或 dot.notation) -- **枚举值**使用英文(UPPER_SNAKE_CASE) -- **常量定义**使用英文(UPPER_SNAKE_CASE) - -### 多语言支持策略 -1. **API 响应消息**:使用 `ErrorCode` 枚举,通过 `MessageSource` 支持多语言 -2. **错误码**:使用 `ErrorCode` 枚举,包含 `code`、`message`(默认中文)和 `messageKey`(国际化键) -3. **日志消息**:可以使用中文或英文,便于开发调试 -4. **代码注释**:可以使用中文或英文,建议使用中文 -5. **业务数据**:根据实际需求,可以包含多语言内容(如市场标题、描述等) - -### Controller 中使用多语言示例 - -```kotlin -@RestController -class NotificationController( - private val notificationConfigService: NotificationConfigService, - private val messageSource: MessageSource // 注入 MessageSource -) { - @PostMapping("/configs/list") - fun list(@RequestBody request: NotificationConfigListRequest): ResponseEntity>> { - return try { - // ... 业务逻辑 - ResponseEntity.ok(ApiResponse.success(configs)) - } catch (e: Exception) { - logger.error("获取配置列表失败: ${e.message}", e) - // ✅ 正确:使用 ErrorCode 和 MessageSource - ResponseEntity.ok(ApiResponse.error( - ErrorCode.SERVER_ERROR, - messageSource = messageSource - )) - } - } - - @PostMapping("/configs/detail") - fun detail(@RequestBody request: NotificationConfigDetailRequest): ResponseEntity> { - if (request.id == null) { - // ✅ 正确:使用 ErrorCode - return ResponseEntity.ok(ApiResponse.error( - ErrorCode.PARAM_EMPTY, - messageSource = messageSource - )) - } - // ... 业务逻辑 - } -} -``` +### 数据源 +- ❌ 禁止直接返回 mock 数据 +- ❌ 禁止在 API 调用失败时返回 mock 数据 +- ✅ 所有数据必须来自真实 API 或数据库查询 diff --git a/.cursor/rules/frontend.mdc b/.cursor/rules/frontend.mdc index 47a0e94..076a377 100644 --- a/.cursor/rules/frontend.mdc +++ b/.cursor/rules/frontend.mdc @@ -5,107 +5,41 @@ path: frontend/** # 前端开发规范 -## 代码完成规范 - -### TODO 处理规则 -- **禁止**在代码中添加 TODO 注释 -- **必须**根据 TODO 的内容直接完成代码实现 -- 如果遇到暂时无法完全实现的功能,应该: - 1. 实现一个可用的基础版本 - 2. 添加清晰的注释说明当前实现的限制和后续改进方向 - 3. 确保代码可以正常编译和运行 -- **禁止**使用 `// TODO: 实现XXX` 这样的注释 -- **禁止**使用 `// FIXME:` 或 `// XXX:` 这样的注释 -- 如果某个功能需要依赖外部资源(如 API、库等),应该: - 1. 先实现一个占位或模拟实现 - 2. 在注释中说明依赖关系和实现方式 - 3. 确保代码逻辑完整,不会因为未实现的功能而崩溃 - -### API 调用实现规则 -- **必须**查找相关的 API 文档或接口定义 -- **必须**根据 API 文档完成代码实现 -- **禁止**在 API 调用处添加 TODO 注释 -- 如果 API 文档不完整,应该: - 1. 查找项目中已有的类似 API 调用作为参考 - 2. 查看 API 服务定义(如 `services/api.ts`) - 3. 查看 API 文档(如 `docs/copy-trading-requirements.md`) - 4. 实现一个可用的版本,包含错误处理 - -### 代码完成示例 - -```typescript -// ❌ 错误:添加 TODO 注释 -const fetchAccountBalance = async (accountId: number) => { - // TODO: 调用 API 查询余额 - return { balance: '0' } -} - -// ✅ 正确:查找 API 定义并完成实现 -// 1. 查找 API 服务定义:services/api.ts 中的 accounts.balance -// 2. 查找 API 文档:docs/copy-trading-requirements.md -// 3. 实现完整的 API 调用逻辑 -const fetchAccountBalance = async (accountId: number) => { - try { - // 根据 API 服务定义调用接口 - const response = await apiService.accounts.balance({ accountId }) - - // 根据 API 响应格式处理数据 - if (response.data.code === 0 && response.data.data) { - return response.data.data - } else { - // API 调用失败时返回默认值 - console.warn('查询余额失败,返回默认值:', response.data.msg) - return { balance: '0' } - } - } catch (error) { - console.error('查询余额异常:', error) - // 异常时返回默认值,确保代码可以正常运行 - return { balance: '0' } - } -} -``` +## 核心原则 +- **禁止**在代码中添加 TODO/FIXME/XXX 注释 +- **禁止**使用 `any` 类型 +- **禁止**硬编码中文或英文文本 +- **必须**支持移动端和桌面端 ## 技术栈 - **框架**: React + TypeScript -- **UI库**: Ant Design 或 Material-UI(推荐 Ant Design Mobile 用于移动端) -- **HTTP客户端**: axios -- **状态管理**: Zustand 或 Redux -- **响应式设计**: 必须支持移动端和桌面端 +- **UI 库**: Ant Design(移动端使用 Ant Design Mobile) +- **HTTP 客户端**: axios +- **状态管理**: Zustand +- **国际化**: react-i18next -## 移动端适配要求 +## 移动端适配 -### 响应式设计 -- **必须支持移动端和桌面端** -- 使用响应式布局(Responsive Design) -- 移动端优先(Mobile First)设计原则 -- 支持触摸操作和手势 - -### 断点设置 +### 响应式断点 - **移动端**: < 768px - **平板**: 768px - 1024px - **桌面端**: > 1024px -### UI 组件适配 -- 使用 Ant Design 的响应式组件 -- 移动端使用 Ant Design Mobile(如果使用 Ant Design) -- 表格使用虚拟滚动或分页(移动端性能优化) -- 表单使用移动端友好的输入组件 - -### 布局适配 -- 导航栏:移动端使用抽屉菜单,桌面端使用顶部导航 -- 列表:移动端使用卡片布局,桌面端使用表格布局 -- 按钮:移动端按钮尺寸不小于 44x44px(触摸友好) -- 间距:移动端使用更大的间距,提高可点击区域 +### 适配要求 +- 使用响应式布局(Mobile First) +- 导航栏:移动端抽屉菜单,桌面端顶部导航 +- 列表:移动端卡片布局,桌面端表格布局 +- 按钮:移动端不小于 44x44px +- 支持触摸操作和手势 ### 性能优化 - 图片懒加载 - 代码分割(Code Splitting) - 移动端减少动画效果 -- 使用 CSS 媒体查询优化样式 ## 代码规范 -### 组件规范 +### 组件 - 使用函数式组件 - 使用 TypeScript 类型定义 - 组件文件使用 PascalCase 命名 @@ -122,24 +56,18 @@ export const MarketCard: React.FC = ({ marketId, platform }) => { }; // ❌ 错误 -export const marketCard = (props: any) => { // 禁止使用any - // ... -}; +export const marketCard = (props: any) => { } ``` -### API调用规范 -- 使用 axios 进行 HTTP 请求 +### API 调用 +- 使用 axios - 统一错误处理 - 使用 TypeScript 定义响应类型 ```typescript -// API服务 -import axios from 'axios'; - interface Market { id: string; marketId: string; - platform: string; title: string; } @@ -147,24 +75,16 @@ export const marketService = { getMarkets: async (): Promise => { const response = await axios.get('/api/markets'); return response.data; - }, - - getMarketById: async (id: string): Promise => { - const response = await axios.get(`/api/markets/${id}`); - return response.data; } }; ``` -### 状态管理规范 -- 使用 Zustand 或 Redux 管理全局状态 +### 状态管理 +- 全局状态使用 Zustand - 本地状态使用 `useState` - 复杂状态使用 `useReducer` ```typescript -// Zustand Store示例 -import { create } from 'zustand'; - interface MarketStore { markets: Market[]; setMarkets: (markets: Market[]) => void; @@ -176,80 +96,64 @@ export const useMarketStore = create((set) => ({ })); ``` -### USDC 金额格式化规范 -- **必须**使用 `formatUSDC` 函数格式化所有 USDC 金额显示 -- **禁止**直接使用 `toFixed()` 或 `parseFloat().toFixed()` 格式化 USDC -- **禁止**硬编码小数位数格式化 USDC -- 所有 USDC 金额显示必须统一使用 `formatUSDC` 函数 - -#### formatUSDC 函数说明 -- **位置**: `src/utils/index.ts` -- **功能**: 格式化 USDC 金额,最多显示 4 位小数,自动去除尾随零(截断,不四舍五入) -- **参数**: `value: string | number | undefined | null` -- **返回值**: 格式化后的字符串,如果值为空或无效则返回 `'-'` - -#### 使用示例 +### USDC 金额格式化 +**必须**使用 `formatUSDC` 函数(从 `../utils` 导入) ```typescript -// ✅ 正确:使用 formatUSDC 格式化 USDC 金额 import { formatUSDC } from '../utils' -const BalanceDisplay: React.FC<{ balance: string }> = ({ balance }) => { - return {formatUSDC(balance)} USDC -} - -// ✅ 正确:在表格列中使用 -const columns = [ - { - title: '余额', - dataIndex: 'balance', - render: (balance: string) => `${formatUSDC(balance)} USDC` - } -] - -// ✅ 正确:在统计组件中使用 +// ✅ 正确 -// ❌ 错误:直接使用 toFixed -const balance = parseFloat(value).toFixed(4) // 禁止 - -// ❌ 错误:硬编码格式化 -const balance = `${parseFloat(value).toFixed(2)} USDC` // 禁止 - -// ❌ 错误:使用自定义格式化函数 -const formatBalance = (value: string) => parseFloat(value).toFixed(4) // 禁止 +// ❌ 错误 +const balance = parseFloat(value).toFixed(4) ``` -#### 格式化规则 -- **最多显示 4 位小数**:如果金额超过 4 位小数,截断到 4 位(不四舍五入) -- **自动去除尾随零**:去除不必要的尾随零和小数点 -- **示例**: - - `formatUSDC(1.23)` => `"1.23"` - - `formatUSDC(1.23456)` => `"1.2345"`(截断,不四舍五入) - - `formatUSDC(1.2)` => `"1.2"` - - `formatUSDC(1)` => `"1"` - - `formatUSDC(1.23459)` => `"1.2345"`(截断,不四舍五入) - - `formatUSDC(null)` => `"-"` - - `formatUSDC(undefined)` => `"-"` +**格式化规则**: +- 最多显示 4 位小数(截断,不四舍五入) +- 自动去除尾随零 +- 空值返回 `'-'` -#### 工具函数统一导出 -- 所有工具函数统一从 `src/utils/index.ts` 导出 -- **必须**从 `../utils` 导入工具函数,而不是从具体文件导入 -- `ethers.ts` 中的函数也会在 `index.ts` 中统一导出 +## 多语言规范 + +### 使用方式 +**必须**使用 `useTranslation` Hook ```typescript -// ✅ 正确:从 utils 统一导入 -import { formatUSDC, getAddressFromPrivateKey, isValidWalletAddress } from '../utils' +// ✅ 正确 +import { useTranslation } from 'react-i18next' -// ❌ 错误:从具体文件导入 -import { formatUSDC } from '../utils/index' -import { getAddressFromPrivateKey } from '../utils/ethers' +const MyComponent: React.FC = () => { + const { t } = useTranslation() + + return ( +
+

{t('page.title')}

+ +
+ ) +} + +// ❌ 错误:硬编码文本 +

页面标题

+ ``` +### 语言文件 +- 位置: `src/locales/{locale}/common.json` +- 支持: `zh-CN`、`zh-TW`、`en` +- 键名: 点号分隔的层级结构(如 `notificationSettings.title`) + +### 添加新翻译 +1. 在 `src/locales/zh-CN/common.json` 添加中文 +2. 在 `src/locales/zh-TW/common.json` 添加繁体 +3. 在 `src/locales/en/common.json` 添加英文 +4. 在组件中使用 `t('key')` + ## 移动端适配示例 ### 响应式布局 @@ -267,29 +171,6 @@ const MyComponent: React.FC = () => { }; ``` -### 移动端导航 -```typescript -import { Drawer } from 'antd'; - -const MobileNav: React.FC = () => { - const [open, setOpen] = useState(false); - - return ( - <> - - setOpen(false)} - open={open} - > - {/* 导航内容 */} - - - ); -}; -``` - ### 响应式表格 ```typescript import { Table } from 'antd'; @@ -316,63 +197,18 @@ const ResponsiveTable: React.FC = () => { ### 代码质量 - ❌ 禁止使用 `any` 类型 - ❌ 禁止忽略错误处理 -- ❌ 禁止硬编码API地址 +- ❌ 禁止硬编码 API 地址 - ❌ 禁止在组件中直接使用 `fetch` - ❌ 禁止忽略移动端适配 ### 类型安全 -- ❌ 禁止使用 `any` -- ❌ 禁止忽略 TypeScript 类型检查 - ❌ 禁止使用 `@ts-ignore` 除非有明确原因 ### 移动端适配 - ❌ 禁止固定宽度布局 - ❌ 禁止使用过小的触摸目标(< 44x44px) -- ❌ 禁止忽略移动端性能优化 -- ❌ 禁止使用桌面端专用的交互方式(如 hover) +- ❌ 禁止使用桌面端专用交互(如 hover) -### 多语言使用规范 -- **必须**使用多语言(i18n)进行所有文本显示 -- **禁止**在组件中硬编码中文或英文文本 -- **必须**使用 `useTranslation` Hook 获取翻译函数 -- **必须**在语言文件中定义所有显示的文本 -- **必须**支持至少中文(简体/繁体)和英文 - -#### 多语言使用示例 - -```typescript -// ✅ 正确:使用多语言 -import { useTranslation } from 'react-i18next' - -const MyComponent: React.FC = () => { - const { t } = useTranslation() - - return ( -
-

{t('page.title')}

- -
- ) -} - -// ❌ 错误:硬编码文本 -const MyComponent: React.FC = () => { - return ( -
-

页面标题

{/* 禁止硬编码 */} - {/* 禁止硬编码 */} -
- ) -} -``` - -#### 语言文件结构 -- 语言文件位置:`src/locales/{locale}/common.json` -- 支持的语言:`zh-CN`(简体中文)、`zh-TW`(繁体中文)、`en`(英文) -- 键名使用点号分隔的层级结构,如:`notificationSettings.title` - -#### 添加新翻译的步骤 -1. 在 `src/locales/zh-CN/common.json` 中添加中文翻译 -2. 在 `src/locales/zh-TW/common.json` 中添加繁体中文翻译 -3. 在 `src/locales/en/common.json` 中添加英文翻译 -4. 在组件中使用 `t('key')` 获取翻译 +### 多语言 +- ❌ 禁止硬编码中文或英文文本 +- ❌ 禁止绕过 `useTranslation` 直接显示文本 diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingDto.kt index 522a16f..a4ca8a3 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingDto.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingDto.kt @@ -42,7 +42,8 @@ data class CopyTradingCreateRequest( val keywords: List? = 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? = 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 ) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyTrading.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyTrading.kt index 8666907..d31cb03 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyTrading.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyTrading.kt @@ -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(), diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/Market.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/Market.kt index e29f075..c329ab4 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/Market.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/Market.kt @@ -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(), diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/MarketService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/MarketService.kt index 95446ab..af5f000 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/MarketService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/MarketService.kt @@ -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 + } + } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/CopyTradingFilterService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/CopyTradingFilterService.kt index 11e2930..fb67765 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/CopyTradingFilterService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/CopyTradingFilterService.kt @@ -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() + } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/CopyTradingService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/CopyTradingService.kt index ada335f..9af66a4 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/CopyTradingService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/CopyTradingService.kt @@ -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? // 市场截止时间限制(毫秒时间戳) ) } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/FilterResult.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/FilterResult.kt index 416b92f..aff1fe3 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/FilterResult.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/FilterResult.kt @@ -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 + ) } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyOrderTrackingService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyOrderTrackingService.kt index d607deb..9da2419 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyOrderTrackingService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/statistics/CopyOrderTrackingService.kt @@ -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" } } diff --git a/backend/src/main/resources/db/migration/V22__add_max_market_end_date_to_copy_trading.sql b/backend/src/main/resources/db/migration/V22__add_max_market_end_date_to_copy_trading.sql new file mode 100644 index 0000000..cd778cb --- /dev/null +++ b/backend/src/main/resources/db/migration/V22__add_max_market_end_date_to_copy_trading.sql @@ -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; + diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index 0875d47..e430995 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -768,6 +768,14 @@ "keywordExists": "Keyword already exists", "whitelistTooltip": "💡 Whitelist mode: only copy markets whose titles contain any of the above keywords (case-insensitive)", "blacklistTooltip": "💡 Blacklist mode: do not copy markets whose titles contain any of the above keywords (case-insensitive)", + "marketEndDateFilter": "Market End Date Limit", + "maxMarketEndDate": "Max Market End Date", + "maxMarketEndDateTooltip": "Only copy orders with market end time less than the set time. For example: 24 hours means only copy markets with less than 24 hours remaining until settlement", + "maxMarketEndDatePlaceholder": "Enter time value (optional)", + "timeUnit": "Unit", + "hour": "Hours", + "day": "Days", + "maxMarketEndDateNote": "💡 Note: Leave blank to disable this filter", "configName": "Configuration Name", "configNameRequired": "Please enter configuration name", "configNamePlaceholder": "e.g., Copy Trading Config 1", @@ -865,6 +873,14 @@ "keywordExists": "Keyword already exists", "whitelistTooltip": "💡 Whitelist mode: only copy markets whose titles contain any of the above keywords (case-insensitive)", "blacklistTooltip": "💡 Blacklist mode: do not copy markets whose titles contain any of the above keywords (case-insensitive)", + "marketEndDateFilter": "Market End Date Limit", + "maxMarketEndDate": "Max Market End Date", + "maxMarketEndDateTooltip": "Only copy orders with market end time less than the set time. For example: 24 hours means only copy markets with less than 24 hours remaining until settlement", + "maxMarketEndDatePlaceholder": "Enter time value (optional)", + "timeUnit": "Unit", + "hour": "Hours", + "day": "Days", + "maxMarketEndDateNote": "💡 Note: Leave blank to disable this filter", "configName": "Configuration Name", "configNameRequired": "Please enter configuration name", "configNamePlaceholder": "e.g., Copy Trading Config 1", @@ -909,6 +925,7 @@ "priceRange": "Price Range Mismatch", "maxPositionValue": "Exceeds Max Position Value", "maxPositionCount": "Exceeds Max Position Count", + "marketEndDate": "Market End Date Exceeds Limit", "unknown": "Unknown Reason" }, "noData": "No filtered orders" diff --git a/frontend/src/locales/zh-CN/common.json b/frontend/src/locales/zh-CN/common.json index 93ec1bf..5d89968 100644 --- a/frontend/src/locales/zh-CN/common.json +++ b/frontend/src/locales/zh-CN/common.json @@ -780,6 +780,14 @@ "keywordExists": "关键字已存在", "whitelistTooltip": "💡 白名单模式:只跟单包含上述任意关键字的市场标题(不区分大小写)", "blacklistTooltip": "💡 黑名单模式:不跟单包含上述任意关键字的市场标题(不区分大小写)", + "marketEndDateFilter": "市场截止时间限制", + "maxMarketEndDate": "最大市场截止时间", + "maxMarketEndDateTooltip": "仅跟单截止时间小于设定时间的订单。例如:24 小时表示只跟单距离结算还剩24小时以内的市场", + "maxMarketEndDatePlaceholder": "输入时间值(可选)", + "timeUnit": "单位", + "hour": "小时", + "day": "天", + "maxMarketEndDateNote": "💡 说明:不填写表示不启用此限制", "supportSell": "跟单卖出", "supportSellTooltip": "是否跟单 Leader 的卖出订单", "create": "创建跟单配置", @@ -877,6 +885,14 @@ "keywordExists": "关键字已存在", "whitelistTooltip": "💡 白名单模式:只跟单包含上述任意关键字的市场标题(不区分大小写)", "blacklistTooltip": "💡 黑名单模式:不跟单包含上述任意关键字的市场标题(不区分大小写)", + "marketEndDateFilter": "市场截止时间限制", + "maxMarketEndDate": "最大市场截止时间", + "maxMarketEndDateTooltip": "仅跟单截止时间小于设定时间的订单。例如:24 小时表示只跟单距离结算还剩24小时以内的市场", + "maxMarketEndDatePlaceholder": "输入时间值(可选)", + "timeUnit": "单位", + "hour": "小时", + "day": "天", + "maxMarketEndDateNote": "💡 说明:不填写表示不启用此限制", "supportSell": "跟单卖出", "supportSellTooltip": "是否跟单 Leader 的卖出订单", "save": "保存", @@ -909,6 +925,7 @@ "priceRange": "价格区间不符", "maxPositionValue": "超过最大仓位金额", "maxPositionCount": "超过最大仓位数量", + "marketEndDate": "市场截止时间超出限制", "unknown": "未知原因" }, "noData": "暂无已过滤订单" diff --git a/frontend/src/locales/zh-TW/common.json b/frontend/src/locales/zh-TW/common.json index f5e8d76..12bc738 100644 --- a/frontend/src/locales/zh-TW/common.json +++ b/frontend/src/locales/zh-TW/common.json @@ -768,6 +768,14 @@ "keywordExists": "關鍵字已存在", "whitelistTooltip": "💡 白名單模式:只跟單包含上述任意關鍵字的市場標題(不區分大小寫)", "blacklistTooltip": "💡 黑名單模式:不跟單包含上述任意關鍵字的市場標題(不區分大小寫)", + "marketEndDateFilter": "市場截止時間限制", + "maxMarketEndDate": "最大市場截止時間", + "maxMarketEndDateTooltip": "僅跟單截止時間小於設定時間的訂單。例如:24 小時表示只跟單距離結算還剩24小時內的市場", + "maxMarketEndDatePlaceholder": "輸入時間值(可選)", + "timeUnit": "單位", + "hour": "小時", + "day": "天", + "maxMarketEndDateNote": "💡 說明:不填寫表示不啟用此限制", "configName": "配置名", "configNameRequired": "請輸入配置名", "configNamePlaceholder": "例如:跟單配置1", @@ -865,6 +873,14 @@ "keywordExists": "關鍵字已存在", "whitelistTooltip": "💡 白名單模式:只跟單包含上述任意關鍵字的市場標題(不區分大小寫)", "blacklistTooltip": "💡 黑名單模式:不跟單包含上述任意關鍵字的市場標題(不區分大小寫)", + "marketEndDateFilter": "市場截止時間限制", + "maxMarketEndDate": "最大市場截止時間", + "maxMarketEndDateTooltip": "僅跟單截止時間小於設定時間的訂單。例如:24 小時表示只跟單距離結算還剩24小時內的市場", + "maxMarketEndDatePlaceholder": "輸入時間值(可選)", + "timeUnit": "單位", + "hour": "小時", + "day": "天", + "maxMarketEndDateNote": "💡 說明:不填寫表示不啟用此限制", "configName": "配置名", "configNameRequired": "請輸入配置名", "configNamePlaceholder": "例如:跟單配置1", @@ -909,6 +925,7 @@ "priceRange": "價格區間不符", "maxPositionValue": "超過最大倉位金額", "maxPositionCount": "超過最大倉位數量", + "marketEndDate": "市場截止時間超出限制", "unknown": "未知原因" }, "noData": "暫無已過濾訂單" diff --git a/frontend/src/pages/CopyTradingOrders/AddModal.tsx b/frontend/src/pages/CopyTradingOrders/AddModal.tsx index db24952..c4f8c07 100644 --- a/frontend/src/pages/CopyTradingOrders/AddModal.tsx +++ b/frontend/src/pages/CopyTradingOrders/AddModal.tsx @@ -34,6 +34,8 @@ const AddModal: React.FC = ({ const [copyMode, setCopyMode] = useState<'RATIO' | 'FIXED'>('RATIO') const [keywords, setKeywords] = useState([]) const keywordInputRef = useRef(null) + const [maxMarketEndDateValue, setMaxMarketEndDateValue] = useState() + const [maxMarketEndDateUnit, setMaxMarketEndDateUnit] = useState<'HOUR' | 'DAY'>('HOUR') // 导入账户modal相关状态 const [accountImportModalVisible, setAccountImportModalVisible] = useState(false) @@ -198,7 +200,7 @@ const AddModal: React.FC = ({ setKeywords(newKeywords) } - const handleSubmit = async (values: any) => { + const handleSubmit = async (values: any) => { // 前端校验 if (values.copyMode === 'FIXED') { if (!values.fixedAmount || Number(values.fixedAmount) < 1) { @@ -212,6 +214,15 @@ const AddModal: React.FC = ({ return } + // 计算市场截止时间(毫秒) + let maxMarketEndDate: number | undefined + if (maxMarketEndDateValue !== undefined && maxMarketEndDateValue > 0) { + const multiplier = maxMarketEndDateUnit === 'HOUR' + ? 60 * 60 * 1000 // 小时转毫秒 + : 24 * 60 * 60 * 1000 // 天转毫秒 + maxMarketEndDate = maxMarketEndDateValue * multiplier + } + setLoading(true) try { const request: CopyTradingCreateRequest = { @@ -243,7 +254,8 @@ const AddModal: React.FC = ({ ? keywords : undefined, configName: values.configName?.trim(), - pushFailedOrders: values.pushFailedOrders ?? false + pushFailedOrders: values.pushFailedOrders ?? false, + maxMarketEndDate } const response = await apiService.copyTrading.create(request) @@ -783,6 +795,51 @@ const AddModal: React.FC = ({ }} + {/* 市场截止时间限制 */} + {t('copyTradingAdd.marketEndDateFilter') || '市场截止时间限制'} + + + + setMaxMarketEndDateValue(value !== null && value !== undefined ? Math.floor(value) : undefined)} + style={{ width: '60%' }} + placeholder={t('copyTradingAdd.maxMarketEndDatePlaceholder') || '输入时间值(可选)'} + parser={(value) => { + if (!value) return '' + const num = parseInt(value.replace(/\D/g, ''), 10) + return isNaN(num) ? '' : num.toString() + }} + formatter={(value) => { + if (!value && value !== 0) return '' + return Math.floor(value).toString() + }} + /> + + + + + +
+ {t('copyTradingAdd.maxMarketEndDateNote') || '💡 说明:不填写表示不启用此限制'} +
+
+ {t('copyTradingAdd.advancedSettings') || '高级设置'} {/* 跟单卖出 */} diff --git a/frontend/src/pages/CopyTradingOrders/EditModal.tsx b/frontend/src/pages/CopyTradingOrders/EditModal.tsx index 0f3c275..1836f28 100644 --- a/frontend/src/pages/CopyTradingOrders/EditModal.tsx +++ b/frontend/src/pages/CopyTradingOrders/EditModal.tsx @@ -29,6 +29,8 @@ const EditModal: React.FC = ({ const [originalEnabled, setOriginalEnabled] = useState(true) const [keywords, setKeywords] = useState([]) const keywordInputRef = useRef(null) + const [maxMarketEndDateValue, setMaxMarketEndDateValue] = useState() + const [maxMarketEndDateUnit, setMaxMarketEndDateUnit] = useState<'HOUR' | 'DAY'>('HOUR') useEffect(() => { if (open && copyTradingId) { @@ -46,6 +48,24 @@ const EditModal: React.FC = ({ setCopyTrading(found) setCopyMode(found.copyMode) setOriginalEnabled(found.enabled) + + // 解析市场截止时间(毫秒转换为小时或天) + if (found.maxMarketEndDate) { + const hours = found.maxMarketEndDate / (60 * 60 * 1000) + if (hours >= 24 && Number.isInteger(hours / 24)) { + // 大于等于24小时且是24的整数倍,使用天作为单位 + setMaxMarketEndDateUnit('DAY') + setMaxMarketEndDateValue(hours / 24) + } else { + // 使用小时作为单位 + setMaxMarketEndDateUnit('HOUR') + setMaxMarketEndDateValue(hours) + } + } else { + setMaxMarketEndDateValue(undefined) + setMaxMarketEndDateUnit('HOUR') + } + form.setFieldsValue({ accountId: found.accountId, leaderId: found.leaderId, @@ -147,6 +167,15 @@ const EditModal: React.FC = ({ return } + // 计算市场截止时间(毫秒) + let maxMarketEndDate: number | undefined + if (maxMarketEndDateValue !== undefined && maxMarketEndDateValue > 0) { + const multiplier = maxMarketEndDateUnit === 'HOUR' + ? 60 * 60 * 1000 // 小时转毫秒 + : 24 * 60 * 60 * 1000 // 天转毫秒 + maxMarketEndDate = maxMarketEndDateValue * multiplier + } + setLoading(true) try { const request: CopyTradingUpdateRequest = { @@ -177,7 +206,8 @@ const EditModal: React.FC = ({ ? keywords : undefined, configName: values.configName?.trim() || undefined, - pushFailedOrders: values.pushFailedOrders + pushFailedOrders: values.pushFailedOrders, + maxMarketEndDate } const response = await apiService.copyTrading.update(request) @@ -683,6 +713,51 @@ const EditModal: React.FC = ({ }} + {/* 市场截止时间限制 */} + {t('copyTradingEdit.marketEndDateFilter') || '市场截止时间限制'} + + + + setMaxMarketEndDateValue(value !== null && value !== undefined ? Math.floor(value) : undefined)} + style={{ width: '60%' }} + placeholder={t('copyTradingEdit.maxMarketEndDatePlaceholder') || '输入时间值(可选)'} + parser={(value) => { + if (!value) return '' + const num = parseInt(value.replace(/\D/g, ''), 10) + return isNaN(num) ? '' : num.toString() + }} + formatter={(value) => { + if (!value && value !== 0) return '' + return Math.floor(value).toString() + }} + /> + + + + + +
+ {t('copyTradingEdit.maxMarketEndDateNote') || '💡 说明:不填写表示不启用此限制'} +
+
+ {t('copyTradingEdit.advancedSettings') || '高级设置'} = ({ MARKET_STATUS: { color: 'default', text: t('filteredOrdersList.filterTypes.marketStatus') || '市场状态不可交易' }, ORDERBOOK_ERROR: { color: 'default', text: t('filteredOrdersList.filterTypes.orderbookError') || '订单簿获取失败' }, ORDERBOOK_EMPTY: { color: 'default', text: t('filteredOrdersList.filterTypes.orderbookEmpty') || '订单簿为空' }, - PRICE_RANGE: { color: 'purple', text: t('filteredOrdersList.filterTypes.priceRange') || '价格区间不符' } + PRICE_RANGE: { color: 'purple', text: t('filteredOrdersList.filterTypes.priceRange') || '价格区间不符' }, + MAX_POSITION_VALUE: { color: 'volcano', text: t('filteredOrdersList.filterTypes.maxPositionValue') || '超过最大仓位金额' }, + MAX_POSITION_COUNT: { color: 'volcano', text: t('filteredOrdersList.filterTypes.maxPositionCount') || '超过最大仓位数量' }, + MARKET_END_DATE: { color: 'cyan', text: t('filteredOrdersList.filterTypes.marketEndDate') || '市场截止时间超出限制' }, + KEYWORD_FILTER: { color: 'geekblue', text: t('filteredOrdersList.filterTypes.keywordFilter') || '关键字过滤' } } const config = typeMap[filterType] || { color: 'default', text: filterType } return {config.text} diff --git a/frontend/src/pages/FilteredOrdersList.tsx b/frontend/src/pages/FilteredOrdersList.tsx index c2b4fdc..9880297 100644 --- a/frontend/src/pages/FilteredOrdersList.tsx +++ b/frontend/src/pages/FilteredOrdersList.tsx @@ -65,6 +65,10 @@ const FilteredOrdersList: React.FC = () => { 'ORDERBOOK_ERROR': { color: 'default', label: t('filteredOrdersList.filterTypes.orderbookError') || '订单簿获取失败' }, 'ORDERBOOK_EMPTY': { color: 'default', label: t('filteredOrdersList.filterTypes.orderbookEmpty') || '订单簿为空' }, 'PRICE_RANGE': { color: 'purple', label: t('filteredOrdersList.filterTypes.priceRange') || '价格区间不符' }, + 'MAX_POSITION_VALUE': { color: 'volcano', label: t('filteredOrdersList.filterTypes.maxPositionValue') || '超过最大仓位金额' }, + 'MAX_POSITION_COUNT': { color: 'volcano', label: t('filteredOrdersList.filterTypes.maxPositionCount') || '超过最大仓位数量' }, + 'MARKET_END_DATE': { color: 'cyan', label: t('filteredOrdersList.filterTypes.marketEndDate') || '市场截止时间超出限制' }, + 'KEYWORD_FILTER': { color: 'geekblue', label: t('filteredOrdersList.filterTypes.keywordFilter') || '关键字过滤' }, 'UNKNOWN': { color: 'default', label: t('filteredOrdersList.filterTypes.unknown') || '未知原因' } } const config = typeMap[type] || typeMap['UNKNOWN'] diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index c91fced..fcff2ac 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -216,6 +216,7 @@ export interface CopyTrading { // 新增配置字段 configName?: string // 配置名(可选,但提供时必须非空) pushFailedOrders: boolean // 推送失败订单(默认关闭) + maxMarketEndDate?: number // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用 createdAt: number updatedAt: number } @@ -265,6 +266,7 @@ export interface CopyTradingCreateRequest { // 新增配置字段 configName?: string // 配置名(可选,但提供时必须非空) pushFailedOrders?: boolean // 推送失败订单(可选) + maxMarketEndDate?: number // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用 } /** @@ -302,6 +304,7 @@ export interface CopyTradingUpdateRequest { // 新增配置字段 configName?: string // 配置名(可选,但提供时必须非空) pushFailedOrders?: boolean // 推送失败订单(可选) + maxMarketEndDate?: number // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用 } /**