Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f01588221 | |||
| f6fc836e79 | |||
| 9c303e0a82 | |||
| cb8e46919f | |||
| 279806da2b | |||
| 90fa487d1b | |||
| b58bb267a1 | |||
| 6af76c4d80 | |||
| d624eaeb57 | |||
| 6ad40242b3 | |||
| 424153cfc7 | |||
| 19508dcd19 | |||
| a16b6fce54 | |||
| 0327eaffe3 | |||
| b13aef944c | |||
| 9ed5190bfe | |||
| 3d5a923caf | |||
| 2af2c0e86a | |||
| d376a82ccc | |||
| e75c93ddf4 | |||
| 018697935a | |||
| 53ec252609 | |||
| 6a0fcdaef2 | |||
| 9a00bb19dc | |||
| 3d603cac32 | |||
| f257457ad3 | |||
| e48149a19b | |||
| c0e0942404 | |||
| 6b8c11f171 | |||
| 3e667b70fd | |||
| 2bb8cbc564 | |||
| 8b73121c5d | |||
| 95930332df | |||
| 185cade11d | |||
| d9443e0d56 | |||
| a097376db7 |
+171
-594
@@ -5,391 +5,62 @@ 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<AccountBalanceResponse> {
|
||||
// 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<AccountBalanceResponse> {
|
||||
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
|
||||
- **包名**: `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<ApiResponse<ExampleDto>> {
|
||||
val data = runBlocking { exampleService.getData() }
|
||||
return ResponseEntity.ok(ApiResponse.success(data))
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ 错误
|
||||
@RestController
|
||||
class ExampleController {
|
||||
@PostMapping("/example")
|
||||
suspend fun getExample(): ResponseEntity<ApiResponse<ExampleDto>> { // 禁止使用suspend
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Service规范
|
||||
- Service 层可以使用 `suspend` 方法
|
||||
### Service
|
||||
- 可以使用 `suspend` 方法
|
||||
- 使用 `@Transactional` 管理事务
|
||||
- 使用构造函数注入依赖
|
||||
|
||||
```kotlin
|
||||
@Service
|
||||
class ExampleService(
|
||||
private val exampleRepository: ExampleRepository
|
||||
) {
|
||||
suspend fun getAllData(): List<ExampleEntity> {
|
||||
return exampleRepository.findAll()
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun saveData(entity: ExampleEntity): ExampleEntity {
|
||||
return exampleRepository.save(entity)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Repository规范
|
||||
- Repository 接口继承 `JpaRepository`
|
||||
### Repository
|
||||
- 继承 `JpaRepository<Entity, Long>`
|
||||
- 使用 Spring Data JPA 方法命名规范
|
||||
|
||||
```kotlin
|
||||
@Repository
|
||||
interface ExampleRepository : JpaRepository<ExampleEntity, Long> {
|
||||
fun findByCode(code: String): ExampleEntity?
|
||||
fun findByCategoryAndStatus(category: String, status: String): List<ExampleEntity>
|
||||
fun findByCategory(category: String): List<ExampleEntity> // 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<OrderResponse>
|
||||
|
||||
@GET("/orders/active")
|
||||
suspend fun getActiveOrders(
|
||||
@Query("market") market: String?,
|
||||
@Query("limit") limit: Int?,
|
||||
@Query("offset") offset: Int?
|
||||
): Response<List<OrderResponse>>
|
||||
|
||||
@DELETE("/orders/{orderId}")
|
||||
suspend fun cancelOrder(@Path("orderId") orderId: String): Response<CancelOrderResponse>
|
||||
|
||||
@GET("/trades")
|
||||
suspend fun getTrades(
|
||||
@Query("market") market: String?,
|
||||
@Query("user") user: String?,
|
||||
@Query("limit") limit: Int?,
|
||||
@Query("offset") offset: Int?
|
||||
): Response<List<TradeResponse>>
|
||||
}
|
||||
|
||||
// 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 +69,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<ApiResponse<ExampleListResponse>> {
|
||||
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<T>(
|
||||
val code: Int,
|
||||
val data: T?,
|
||||
val msg: String
|
||||
) {
|
||||
companion object {
|
||||
fun <T> success(data: T): ApiResponse<T> = ApiResponse(0, data, "")
|
||||
fun <T> paramError(msg: String): ApiResponse<T> = ApiResponse(1001, null, msg)
|
||||
fun <T> serverError(msg: String): ApiResponse<T> = ApiResponse(5001, null, msg)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 错误码规范
|
||||
### 错误码范围
|
||||
- `0`: 成功
|
||||
- `1001-1999`: 参数错误
|
||||
- `2001-2999`: 认证/权限错误
|
||||
@@ -476,7 +77,131 @@ data class ApiResponse<T>(
|
||||
- `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<ApiResponse<ExampleListResponse>> {
|
||||
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)) {
|
||||
// 业务逻辑
|
||||
}
|
||||
```
|
||||
|
||||
## JSON 解析规范
|
||||
|
||||
### 使用扩展函数(推荐)
|
||||
- **优先**使用扩展函数 `fromJson<T>()` 和 `toJson()`,禁止直接使用 `Gson`
|
||||
- 扩展函数提供了类型安全的解析,使用方便
|
||||
|
||||
```kotlin
|
||||
// ✅ 正确:使用扩展函数(推荐)
|
||||
val json = "{\"name\":\"test\"}"
|
||||
val obj = json.fromJson<MyDataClass>()
|
||||
val jsonStr = obj.toJson()
|
||||
|
||||
// ✅ 也可以:使用 JsonUtils 类(兼容旧代码)
|
||||
val obj = jsonUtils.fromJson<MyDataClass>(json)
|
||||
|
||||
// ❌ 错误:直接使用 Gson
|
||||
val obj = gson.fromJson(json, MyDataClass::class.java)
|
||||
```
|
||||
|
||||
### 扩展函数列表
|
||||
- `String?.fromJson<T>()` - 解析 JSON 字符串为对象(支持泛型)
|
||||
- `JsonElement?.fromJson<T>()` - 解析 JsonElement 为对象(支持泛型)
|
||||
- `Any?.toJson()` - 将对象转换为 JSON 字符串
|
||||
- `String?.parseStringArray()` - 解析 JSON 字符串数组
|
||||
|
||||
### JsonUtils 类
|
||||
- `JsonUtils` 主要用于初始化全局 Gson 实例,供扩展函数使用
|
||||
- 保留 `parseStringArray()` 方法用于兼容旧代码
|
||||
- 不推荐直接使用 `JsonUtils` 的方法,优先使用扩展函数
|
||||
|
||||
### Data Class 规范
|
||||
- **所有 data class 字段必须提供默认值**
|
||||
- 可空字段使用 `? = null`
|
||||
- 非空字段提供合适的默认值(空字符串、空集合、默认数值等)
|
||||
|
||||
```kotlin
|
||||
// ✅ 正确:所有字段都有默认值
|
||||
data class MyDto(
|
||||
val name: String = "",
|
||||
val age: Int = 0,
|
||||
val tags: List<String> = emptyList(),
|
||||
val optional: String? = null
|
||||
)
|
||||
|
||||
// ❌ 错误:缺少默认值
|
||||
data class MyDto(
|
||||
val name: String, // 缺少默认值
|
||||
val age: Int = 0
|
||||
)
|
||||
```
|
||||
|
||||
## 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 +209,25 @@ data class ApiResponse<T>(
|
||||
- ❌ 禁止使用 `!!` 除非有明确原因
|
||||
- ❌ 禁止忽略异常
|
||||
- ❌ 禁止硬编码配置值
|
||||
- ❌ 禁止提交敏感信息到Git
|
||||
- ❌ Controller 方法禁止使用 `suspend`
|
||||
- ❌ 实体类ID禁止使用 `Long = 0`
|
||||
- ❌ **禁止直接返回 mock 数据或硬编码的假数据**
|
||||
- ❌ **禁止在 API 调用失败时返回 mock 数据作为默认值**
|
||||
- ❌ **所有返回的数据必须来自真实的 API 调用或数据库查询**
|
||||
|
||||
### 配置
|
||||
- ❌ 禁止使用 `application.yml`
|
||||
- ❌ 禁止在代码中硬编码配置值
|
||||
- ❌ 禁止提交敏感信息到 Git
|
||||
|
||||
### 类型
|
||||
- ❌ 禁止使用 `Double` 进行数值计算
|
||||
- ❌ 禁止使用 `LocalDateTime` 存储时间
|
||||
- ❌ 禁止实体类ID使用非空默认值
|
||||
- ❌ 禁止实体类 ID 使用非空默认值
|
||||
- ❌ 禁止直接使用 `Gson`(必须使用 `JsonUtils`)
|
||||
- ❌ 禁止 data class 字段缺少默认值
|
||||
|
||||
### 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<ApiResponse<List<NotificationConfigDto>>> {
|
||||
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<ApiResponse<NotificationConfigDto>> {
|
||||
if (request.id == null) {
|
||||
// ✅ 正确:使用 ErrorCode
|
||||
return ResponseEntity.ok(ApiResponse.error(
|
||||
ErrorCode.PARAM_EMPTY,
|
||||
messageSource = messageSource
|
||||
))
|
||||
}
|
||||
// ... 业务逻辑
|
||||
}
|
||||
}
|
||||
```
|
||||
### 数据源
|
||||
- ❌ 禁止直接返回 mock 数据
|
||||
- ❌ 禁止在 API 调用失败时返回 mock 数据
|
||||
- ✅ 所有数据必须来自真实 API 或数据库查询
|
||||
|
||||
+68
-232
@@ -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<MarketProps> = ({ 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<Market[]> => {
|
||||
const response = await axios.get<Market[]>('/api/markets');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getMarketById: async (id: string): Promise<Market> => {
|
||||
const response = await axios.get<Market>(`/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<MarketStore>((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 <span>{formatUSDC(balance)} USDC</span>
|
||||
}
|
||||
|
||||
// ✅ 正确:在表格列中使用
|
||||
const columns = [
|
||||
{
|
||||
title: '余额',
|
||||
dataIndex: 'balance',
|
||||
render: (balance: string) => `${formatUSDC(balance)} USDC`
|
||||
}
|
||||
]
|
||||
|
||||
// ✅ 正确:在统计组件中使用
|
||||
// ✅ 正确
|
||||
<Statistic
|
||||
title="总盈亏"
|
||||
value={formatUSDC(stats?.totalPnl || '0')}
|
||||
suffix="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 (
|
||||
<div>
|
||||
<h1>{t('page.title')}</h1>
|
||||
<Button>{t('common.save')}</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ❌ 错误:硬编码文本
|
||||
<h1>页面标题</h1>
|
||||
<Button>保存</Button>
|
||||
```
|
||||
|
||||
### 语言文件
|
||||
- 位置: `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 (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)}>菜单</Button>
|
||||
<Drawer
|
||||
title="导航"
|
||||
placement="left"
|
||||
onClose={() => setOpen(false)}
|
||||
open={open}
|
||||
>
|
||||
{/* 导航内容 */}
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 响应式表格
|
||||
```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 (
|
||||
<div>
|
||||
<h1>{t('page.title')}</h1>
|
||||
<Button>{t('common.save')}</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ❌ 错误:硬编码文本
|
||||
const MyComponent: React.FC = () => {
|
||||
return (
|
||||
<div>
|
||||
<h1>页面标题</h1> {/* 禁止硬编码 */}
|
||||
<Button>保存</Button> {/* 禁止硬编码 */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
#### 语言文件结构
|
||||
- 语言文件位置:`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` 直接显示文本
|
||||
|
||||
+2
-2
@@ -47,9 +47,9 @@ FROM eclipse-temurin:17-jre-jammy
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装 Nginx 和必要的工具
|
||||
# 安装 Nginx 和必要的工具(包含时区数据)
|
||||
RUN apt-get update && \
|
||||
apt-get install -y nginx curl && \
|
||||
apt-get install -y nginx curl tzdata && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
rm -rf /etc/nginx/sites-enabled/default
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
[](https://github.com/WrBug/PolyHermes)
|
||||
[](https://x.com/polyhermes)
|
||||
[](https://hub.docker.com/r/wrbug/polyhermes)
|
||||
|
||||
> 🌐 **Language**: [English](README_EN.md) | 中文
|
||||
|
||||
@@ -432,6 +433,7 @@ cd frontend
|
||||
|
||||
- [GitHub 仓库](https://github.com/WrBug/PolyHermes)
|
||||
- [Twitter](https://x.com/polyhermes)
|
||||
- [Telegram 群组](https://t.me/polyhermes)
|
||||
- [Polymarket 官网](https://polymarket.com)
|
||||
- [Polymarket API 文档](https://docs.polymarket.com)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
[](https://github.com/WrBug/PolyHermes)
|
||||
[](https://x.com/polyhermes)
|
||||
[](https://hub.docker.com/r/wrbug/polyhermes)
|
||||
|
||||
> 🌐 **Language**: English | [中文](README.md)
|
||||
|
||||
@@ -432,6 +433,7 @@ This project is licensed under the MIT License. See the [LICENSE](LICENSE) file
|
||||
|
||||
- [GitHub Repository](https://github.com/WrBug/PolyHermes)
|
||||
- [Twitter](https://x.com/polyhermes)
|
||||
- [Telegram Group](https://t.me/polyhermes)
|
||||
- [Polymarket Official Website](https://polymarket.com)
|
||||
- [Polymarket API Documentation](https://docs.polymarket.com)
|
||||
|
||||
|
||||
+695
-2
@@ -1,3 +1,696 @@
|
||||
# v1.1.10
|
||||
|
||||
## 🚀 主要功能
|
||||
|
||||
### 📢 推送已过滤订单功能
|
||||
|
||||
- **新增推送已过滤订单功能(pushFilteredOrders)**,默认关闭
|
||||
- 支持在模板和跟单配置中配置是否推送被过滤的订单通知
|
||||
- 开启后,当订单因过滤条件(价格区间、订单深度、价差、仓位限制等)被过滤时,会发送 Telegram 通知
|
||||
- 帮助用户了解哪些订单被过滤以及过滤原因
|
||||
|
||||
- **数据库迁移**:
|
||||
- 添加 `push_filtered_orders` 字段到 `copy_trading_templates` 表
|
||||
- 添加 `push_filtered_orders` 字段到 `copy_trading` 表
|
||||
- 迁移脚本:`V24__add_push_filtered_orders_to_templates.sql`
|
||||
|
||||
- **后端实现**:
|
||||
- 在实体类(`CopyTradingTemplate`、`CopyTrading`)中添加 `pushFilteredOrders` 字段
|
||||
- 在 DTO 中添加字段支持(创建、更新、查询)
|
||||
- 在 Service 中处理字段的创建、更新和传递
|
||||
- 在发送过滤订单通知时检查 `pushFilteredOrders` 字段,只有为 `true` 时才发送
|
||||
|
||||
- **前端实现**:
|
||||
- 在模板新增、编辑、推送页面添加"推送已过滤订单"开关
|
||||
- 在跟单配置新增、编辑页面添加"推送已过滤订单"开关
|
||||
- 从模板创建跟单配置时,自动填充 `pushFilteredOrders` 字段
|
||||
- 支持多语言(中文、繁体中文、英文)
|
||||
|
||||
### 🔍 优化订单列表筛选功能
|
||||
|
||||
- **支持按市场标题搜索**:
|
||||
- 买入订单列表:添加市场标题筛选,移除方向筛选
|
||||
- 卖出订单列表:添加市场标题筛选,将方向筛选改为状态筛选
|
||||
- 已成交订单列表:添加市场标题筛选和市场列显示
|
||||
|
||||
- **改进分组体验**:
|
||||
- 记录用户的分组偏好到 localStorage,跨会话持久化
|
||||
- 所有搜索输入框添加 0.5 秒防抖优化,提升性能
|
||||
|
||||
- **后端优化**:
|
||||
- `MarketGroupedOrdersRequest` 添加 `marketId` 和 `marketTitle` 字段
|
||||
- 买入/卖出订单分组接口支持市场 ID 模糊匹配和市场标题关键字筛选
|
||||
|
||||
### ⚡ 优化跟单关系统计性能
|
||||
|
||||
- **移除未实现盈亏计算**:
|
||||
- 移除未实现盈亏和持仓价值的计算逻辑
|
||||
- 总盈亏现在仅包含已实现盈亏,计算更准确
|
||||
- 简化盈亏百分比计算,仅基于已实现盈亏
|
||||
|
||||
- **性能提升**:
|
||||
- 从 1+N 次网络请求减少到 0 次
|
||||
- 删除不再使用的方法和依赖注入
|
||||
- 统计查询速度显著提升
|
||||
|
||||
### 🐳 Docker 容器时区配置
|
||||
|
||||
- **支持通过 .env 自定义时区**:
|
||||
- 在 `docker-compose.yml` 和 `docker-compose.prod.yml` 中添加时区环境变量配置
|
||||
- 支持通过 `TZ` 环境变量自定义容器时区
|
||||
- 默认使用系统时区
|
||||
|
||||
## 🐛 Bug 修复
|
||||
|
||||
### 修复跟单配置更新时清空可选字段无法保存的问题
|
||||
|
||||
- **问题**:修改跟单配置时,清空价格区间、最大仓位数量、截止时间等可选字段后,无法保存到数据库
|
||||
- **修复**:
|
||||
- 修复价格区间(`minPrice`/`maxPrice`)清空后无法保存的问题
|
||||
- 修复最大仓位数量(`maxPositionCount`)清空后无法保存的问题
|
||||
- 修复市场截止时间(`maxMarketEndDate`)清空后无法保存的问题
|
||||
- 修复其他可选字段(`minOrderDepth`/`maxSpread`/`maxPositionValue`)清空后无法保存的问题
|
||||
|
||||
- **实现方案**:
|
||||
- 前端:清空字段时传空字符串或 `-1` 标记,让后端识别为清空操作
|
||||
- 后端:处理空字符串和 `-1` 标记,正确设置为 `null` 以清空字段
|
||||
|
||||
### 修复新建和编辑页面的截止时间输入框交互问题
|
||||
|
||||
- **问题**:删除截止时间输入框内容后,失去焦点会自动填充 1
|
||||
- **修复**:
|
||||
- 将 `min` 从 `1` 改为 `0`,允许空值
|
||||
- 优化 `onChange` 处理,当值为 `0`、`null` 或 `undefined` 时设置为 `undefined`(清空)
|
||||
- 添加 `onBlur` 处理,确保失去焦点时如果值为空或 `0`,设置为 `undefined`
|
||||
- 修改 `parser`,空值时返回空字符串而不是 `0`
|
||||
|
||||
### 修复订单通知重复发送和时间显示问题
|
||||
|
||||
- **修复并发导致的重复通知问题**:
|
||||
- 在 `OrderStatusUpdateService` 中实现双重检查机制
|
||||
- 先保存订单标记为已发送,再重新查询数据库检查
|
||||
- 防止定时任务并发时重复发送同一订单的通知
|
||||
- 同时修复买入订单和卖出订单的通知逻辑
|
||||
|
||||
- **修复 Telegram 通知时间显示**:
|
||||
- `TelegramNotificationService.sendOrderSuccessNotification` 添加 `orderTime` 参数
|
||||
- 使用订单的 `createdAt` 时间戳作为通知显示时间
|
||||
- 而不是使用当前通知发送时间
|
||||
- 更准确反映订单的实际创建时间
|
||||
|
||||
### 修复新建跟单配置时 pushFilteredOrders 字段未生效的问题
|
||||
|
||||
- **问题**:新建跟单配置时,即使设置了 `pushFilteredOrders: true`,也没有生效
|
||||
- **修复**:
|
||||
- 修复手动输入模式下 `pushFilteredOrders` 被硬编码为 `false` 的问题
|
||||
- 修复从模板填充表单时未加载 `pushFilteredOrders` 的问题
|
||||
- 添加 `CopyTradingTemplate` 接口的 `pushFilteredOrders` 字段定义
|
||||
|
||||
## 📝 技术细节
|
||||
|
||||
### 数据库变更
|
||||
|
||||
- **迁移脚本**:`V24__add_push_filtered_orders_to_templates.sql`
|
||||
- **变更内容**:
|
||||
- `copy_trading_templates.push_filtered_orders`: BOOLEAN NOT NULL DEFAULT FALSE
|
||||
- `copy_trading.push_filtered_orders`: BOOLEAN NOT NULL DEFAULT FALSE
|
||||
- **自动执行**:升级时会自动执行迁移脚本
|
||||
|
||||
### API 变更
|
||||
|
||||
- **无新增接口**
|
||||
- **无移除接口**
|
||||
- **请求/响应变更**:
|
||||
- `CopyTradingCreateRequest` 添加 `pushFilteredOrders` 字段
|
||||
- `CopyTradingUpdateRequest` 添加 `pushFilteredOrders` 字段
|
||||
- `TemplateCreateRequest` 添加 `pushFilteredOrders` 字段
|
||||
- `TemplateUpdateRequest` 添加 `pushFilteredOrders` 字段
|
||||
- `MarketGroupedOrdersRequest` 添加 `marketId` 和 `marketTitle` 字段
|
||||
|
||||
### 前端变更
|
||||
|
||||
- **新增字段**:
|
||||
- `CopyTradingTemplate` 接口添加 `pushFilteredOrders` 字段
|
||||
- **组件更新**:
|
||||
- 模板新增、编辑、推送页面添加"推送已过滤订单"开关
|
||||
- 跟单配置新增、编辑页面添加"推送已过滤订单"开关
|
||||
- 优化截止时间输入框交互逻辑
|
||||
- **多语言支持**:
|
||||
- 添加中文、繁体中文、英文翻译
|
||||
|
||||
## 📊 变更统计
|
||||
|
||||
- **提交数量**:6 个提交
|
||||
- **文件变更**:30 个文件
|
||||
- **代码变更**:+651 行 / -555 行(净增加 96 行)
|
||||
|
||||
### 详细文件变更
|
||||
|
||||
**后端变更**:
|
||||
- `CopyTrading.kt` - 添加 `pushFilteredOrders` 字段(+3 行)
|
||||
- `CopyTradingTemplate.kt` - 添加 `pushFilteredOrders` 字段(+3 行)
|
||||
- `CopyTradingDto.kt` - 添加 `pushFilteredOrders` 字段支持(+3 行)
|
||||
- `CopyTradingTemplateDto.kt` - 添加 `pushFilteredOrders` 字段支持(+10 行)
|
||||
- `CopyTradingService.kt` - 处理 `pushFilteredOrders` 字段和清空字段逻辑(+93 行)
|
||||
- `CopyTradingTemplateService.kt` - 处理 `pushFilteredOrders` 字段(+8 行)
|
||||
- `CopyOrderTrackingService.kt` - 检查 `pushFilteredOrders` 字段发送通知(+42 行)
|
||||
- `CopyTradingStatisticsService.kt` - 优化统计性能(-328 行)
|
||||
- `OrderStatusUpdateService.kt` - 修复重复通知问题(+142 行)
|
||||
- `TelegramNotificationService.kt` - 添加订单时间参数(+17 行)
|
||||
- `V24__add_push_filtered_orders_to_templates.sql` - 数据库迁移脚本(+13 行)
|
||||
|
||||
**前端变更**:
|
||||
- `AddModal.tsx` - 添加 `pushFilteredOrders` 字段和优化截止时间输入框(+40 行)
|
||||
- `EditModal.tsx` - 添加 `pushFilteredOrders` 字段和优化截止时间输入框(+59 行)
|
||||
- `TemplateAdd.tsx` - 添加 `pushFilteredOrders` 字段(+15 行)
|
||||
- `TemplateEdit.tsx` - 添加 `pushFilteredOrders` 字段(+15 行)
|
||||
- `TemplateList.tsx` - 添加 `pushFilteredOrders` 字段(+13 行)
|
||||
- `BuyOrdersTab.tsx` - 优化筛选功能(+94 行)
|
||||
- `SellOrdersTab.tsx` - 优化筛选功能(+77 行)
|
||||
- `MatchedOrdersTab.tsx` - 优化筛选功能(+26 行)
|
||||
- `types/index.ts` - 添加 `pushFilteredOrders` 字段定义(+6 行)
|
||||
- `locales/*/common.json` - 添加多语言翻译(+31 行)
|
||||
|
||||
**配置文件变更**:
|
||||
- `docker-compose.yml` - 添加时区配置(+5 行)
|
||||
- `docker-compose.prod.yml` - 添加时区配置(+5 行)
|
||||
- `Dockerfile` - 优化构建配置(+4 行)
|
||||
|
||||
## 🔄 主要提交
|
||||
|
||||
```
|
||||
9c303e0 feat: 添加推送已过滤订单功能并修复相关问题
|
||||
cb8e469 fix: 修复跟单配置更新时清空可选字段无法保存的问题
|
||||
279806d feat: 优化订单列表筛选功能,支持按市场标题搜索并改进分组体验
|
||||
90fa487 refactor: 移除未实现盈亏计算以优化跟单关系统计性能
|
||||
b58bb26 feat: 添加 Docker 容器时区配置,支持通过 .env 自定义
|
||||
6af76c4 fix: 修复订单通知重复发送和时间显示问题
|
||||
```
|
||||
|
||||
## 🎯 升级建议
|
||||
|
||||
1. **数据库迁移**:本次版本包含数据库迁移脚本,升级时会自动执行
|
||||
- 自动添加 `push_filtered_orders` 字段到模板表和跟单配置表
|
||||
- 现有数据不受影响,新字段默认值为 `false`
|
||||
|
||||
2. **配置更新**:
|
||||
- 可选:在 `.env` 文件中添加 `TZ` 环境变量自定义容器时区
|
||||
- 无需其他配置变更
|
||||
|
||||
3. **兼容性**:
|
||||
- 完全向后兼容,不影响现有功能
|
||||
- API 变更都是新增字段,不影响现有调用
|
||||
|
||||
## 📦 Docker 镜像
|
||||
|
||||
Docker 镜像会自动构建并推送到 Docker Hub:
|
||||
- `wrbug/polyhermes:v1.1.10`
|
||||
- `wrbug/polyhermes:latest`(如果这是最新版本)
|
||||
|
||||
## 🔗 相关链接
|
||||
|
||||
- [GitHub Release](https://github.com/WrBug/PolyHermes/releases/tag/v1.1.10)
|
||||
- [完整更新日志](https://github.com/WrBug/PolyHermes/compare/v1.1.9...v1.1.10)
|
||||
|
||||
---
|
||||
|
||||
**发布日期**:2026-01-12
|
||||
|
||||
---
|
||||
|
||||
# v1.1.9
|
||||
|
||||
## 🐛 Bug 修复
|
||||
|
||||
### 修复盈亏统计计算错误问题
|
||||
|
||||
- **问题**:在仓位检查时,刚创建的订单可能因仓位更新延迟被误判为已卖出,导致盈亏统计计算错误
|
||||
- **修复**:
|
||||
- 优化自动卖出订单筛选逻辑,在 SQL 层直接过滤创建时间超过阈值的订单
|
||||
- 新增 `findUnmatchedBuyOrdersByOutcomeIndexOlderThan` Repository 方法,提高查询效率
|
||||
- 统一使用 2 分钟阈值保护刚创建的订单,避免误判
|
||||
- 两个场景都使用 SQL 过滤:
|
||||
* 场景1:仓位不存在时,延迟检测使用 SQL 过滤
|
||||
* 场景2:仓位部分存在时,FIFO 匹配使用 SQL 过滤
|
||||
|
||||
- **优化效果**:
|
||||
- SQL 层面直接过滤,减少数据传输,提高查询效率
|
||||
- 代码更简洁,逻辑更清晰
|
||||
- 刚创建的订单(< 2 分钟)不会被误判为已卖出
|
||||
- 确保盈亏统计计算的准确性
|
||||
|
||||
## 📝 技术细节
|
||||
|
||||
- **Repository 变更**:
|
||||
- 新增 `findUnmatchedBuyOrdersByOutcomeIndexOlderThan` 方法
|
||||
- 在 SQL 查询中添加 `createdAt < :thresholdTime` 条件过滤
|
||||
|
||||
- **Service 变更**:
|
||||
- `PositionCheckService` 中两个场景都使用 SQL 过滤替代内存过滤
|
||||
- 统一使用 2 分钟(120000 毫秒)作为时间阈值
|
||||
|
||||
## 📊 变更统计
|
||||
|
||||
- **提交数量**:1 个提交
|
||||
- **文件变更**:2 个文件
|
||||
- **代码变更**:+57 行 / -19 行(净增加 38 行)
|
||||
|
||||
### 详细文件变更
|
||||
|
||||
**后端变更**:
|
||||
- `CopyOrderTrackingRepository.kt` - 新增 SQL 查询方法(+18 行)
|
||||
- `PositionCheckService.kt` - 优化订单筛选逻辑(+39 行 / -19 行)
|
||||
|
||||
## 🔄 主要提交
|
||||
|
||||
```
|
||||
6ad4024 fix: 优化自动卖出订单筛选逻辑,避免刚创建的订单被误判
|
||||
```
|
||||
|
||||
## 🎯 升级建议
|
||||
|
||||
1. **数据库迁移**:无需数据库迁移,可直接升级
|
||||
2. **配置更新**:无需配置变更
|
||||
3. **兼容性**:完全向后兼容,不影响现有功能
|
||||
|
||||
## 📦 Docker 镜像
|
||||
|
||||
Docker 镜像会自动构建并推送到 Docker Hub:
|
||||
- `wrbug/polyhermes:v1.1.9`
|
||||
- `wrbug/polyhermes:latest`(如果这是最新版本)
|
||||
|
||||
## 🔗 相关链接
|
||||
|
||||
- [GitHub Release](https://github.com/WrBug/PolyHermes/releases/tag/v1.1.9)
|
||||
|
||||
---
|
||||
|
||||
# v1.1.8
|
||||
|
||||
## 🚀 主要功能
|
||||
|
||||
### ⚡ Polymarket Activity WebSocket 双重监听方案
|
||||
|
||||
- **新增 PolymarketActivityWsService**:通过 Activity WebSocket 实时监听 Leader 交易
|
||||
- 延迟 < 100ms,适合快速跟单场景
|
||||
- 订阅全局 activity 交易流,客户端过滤 Leader 地址
|
||||
- 支持动态添加/移除 Leader 监听
|
||||
- 地址筛选忽略大小写,提高匹配准确性
|
||||
|
||||
- **双重监听机制**:
|
||||
- Activity WebSocket(低延迟,< 100ms):作为主要监听方式
|
||||
- On-Chain WebSocket(高可靠性,~2-3s):作为兜底监听方式
|
||||
- 自动去重,避免重复处理同一笔交易
|
||||
|
||||
- **协议支持**:
|
||||
- 支持 `connection_id` 字段
|
||||
- 优先使用消息中的 `outcomeIndex` 字段,fallback 到从 `outcome` 解析
|
||||
|
||||
- **参考文档**:
|
||||
- [双重监听方案文档](docs/zh/copy-trading-dual-monitoring-plan.md)
|
||||
- [Activity WebSocket API 文档](docs/zh/polymarket-activity-websocket-api.md)
|
||||
|
||||
### 🔧 支持同一用户创建多个相同 Leader 的跟单配置
|
||||
|
||||
- **移除唯一约束**:允许同一用户创建多个跟单同一个 Leader 的配置
|
||||
- **应用场景**:支持不同参数(比例、过滤条件等)的多配置跟单
|
||||
- **数据库迁移**:`V23__remove_unique_constraint_from_copy_trading.sql`
|
||||
|
||||
### 🎯 市场截止时间筛选功能
|
||||
|
||||
- **新增市场截止时间过滤**:支持设置跟单配置的最大市场截止时间
|
||||
- **自动过滤过期市场**:系统会自动跳过超过设定截止时间的市场
|
||||
- **数据库字段**:`max_market_end_date`(时间戳,单位:秒)
|
||||
- **数据库迁移**:`V22__add_max_market_end_date_to_copy_trading.sql`
|
||||
|
||||
### 🔍 关键字过滤功能
|
||||
|
||||
- **新增关键字过滤**:支持在黑名单或白名单模式下过滤市场标题关键字
|
||||
- **过滤模式**:
|
||||
- `DISABLED`:禁用关键字过滤
|
||||
- `BLACKLIST`:黑名单模式(包含关键字的市场会被过滤)
|
||||
- `WHITELIST`:白名单模式(只允许包含关键字的市场)
|
||||
- **多关键字支持**:支持多个关键字,以 JSON 数组格式存储
|
||||
- **数据库字段**:`keyword_filter_mode` 和 `keywords`(JSON 数组)
|
||||
- **数据库迁移**:`V20__add_keyword_filter.sql`
|
||||
|
||||
### 📊 订单列表功能重构
|
||||
|
||||
- **按市场分组显示**:订单列表按市场分组,便于查看和管理
|
||||
- **市场信息展示**:
|
||||
- 显示市场标题和 slug
|
||||
- 支持跳转到 Polymarket 市场页面
|
||||
- 显示市场基本信息(标题、slug、截止时间等)
|
||||
- **订单 ID 复制功能**:一键复制订单 ID,方便查询和调试
|
||||
- **UI/UX 优化**:
|
||||
- 优化订单列表展示样式
|
||||
- 改进移动端适配
|
||||
|
||||
### 📈 市场信息管理和缓存优化
|
||||
|
||||
- **新增 MarketService**:统一管理市场信息查询和缓存
|
||||
- 使用 LRU 缓存提高查询性能
|
||||
- 支持从 Gamma API 和 CLOB API 查询市场信息
|
||||
- 自动缓存市场信息,减少 API 调用
|
||||
- **新增 Market 实体和表**:持久化存储市场信息
|
||||
- 存储市场标题、slug、eventSlug、endDate 等基本信息
|
||||
- 支持通过 marketId 快速查询市场信息
|
||||
- **数据库迁移**:
|
||||
- `V19__create_markets_table.sql`:创建市场信息表
|
||||
- `V21__add_event_slug_to_markets.sql`:添加 eventSlug 字段
|
||||
- **市场信息轮询服务**:定期更新市场信息,保持数据新鲜度
|
||||
|
||||
## 🐛 Bug 修复
|
||||
|
||||
### 前端 TypeScript 类型错误修复
|
||||
|
||||
- **修复 InputNumber parser 类型错误**:`parser` 函数应返回 `number` 而不是 `string`
|
||||
- **修复文件**:
|
||||
- `src/pages/CopyTradingOrders/AddModal.tsx`
|
||||
- `src/pages/CopyTradingOrders/EditModal.tsx`
|
||||
|
||||
## 📝 文档更新
|
||||
|
||||
- **新增双重监听方案文档**:详细说明 Activity WebSocket 和 On-Chain WebSocket 的双重监听机制
|
||||
- **新增 Activity WebSocket API 文档**:详细的 API 格式和消息结构说明
|
||||
- **更新开发规范**:JSON 解析规范和 Data Class 规范
|
||||
|
||||
## 📊 统计信息
|
||||
|
||||
- **61 个文件被修改**
|
||||
- **+5125 行新增代码**
|
||||
- **-1942 行删除代码**
|
||||
- **净增加 3183 行代码**
|
||||
|
||||
## 🔄 主要提交
|
||||
|
||||
```
|
||||
d376a82 feat: 添加市场信息管理和订单ID复制功能
|
||||
2af2c0e feat: 订单列表按市场分组并支持跳转到Polymarket
|
||||
9ed5190 feat: 添加关键字过滤功能并优化市场 slug 处理
|
||||
0327eaf feat: 添加市场截止时间筛选功能
|
||||
a16b6fc feat: 支持同一用户创建多个相同 leader 的跟单配置
|
||||
19508dc feat: 实现 Polymarket Activity WebSocket 双重监听方案
|
||||
```
|
||||
|
||||
## 🎯 升级建议
|
||||
|
||||
1. **数据库迁移**:确保执行所有数据库迁移脚本(V19-V23)
|
||||
2. **配置更新**:新版本的 `application.properties` 中添加了 Activity WebSocket URL 配置
|
||||
3. **环境变量**:如果使用 Docker 部署,建议更新 `docker-compose.yml` 中的配置
|
||||
|
||||
## 📦 Docker 镜像
|
||||
|
||||
Docker 镜像会自动构建并推送到 Docker Hub:
|
||||
- `wrbug/polyhermes:v1.1.8`
|
||||
- `wrbug/polyhermes:latest`(如果这是最新版本)
|
||||
|
||||
## 🔗 相关链接
|
||||
|
||||
- [GitHub Release](https://github.com/WrBug/PolyHermes/releases/tag/v1.1.8)
|
||||
- [双重监听方案文档](docs/zh/copy-trading-dual-monitoring-plan.md)
|
||||
- [Activity WebSocket API 文档](docs/zh/polymarket-activity-websocket-api.md)
|
||||
|
||||
---
|
||||
|
||||
# v1.1.7
|
||||
|
||||
## 🚀 主要功能
|
||||
|
||||
### 💰 Polymarket Maker Rebates Program 费率支持
|
||||
|
||||
- **新增费率查询 API 接口** (`getFeeRate`)
|
||||
- 支持动态查询 Maker Rebates Program 费率
|
||||
- 修正 API 返回字段名:使用 `base_fee` 而非 `fee_rate_bps`(与 TypeScript clob-client 一致)
|
||||
|
||||
- **动态费率获取**
|
||||
- 在所有订单创建处动态获取费率:
|
||||
* 跟单买入订单 (`processBuyTrade`)
|
||||
* 跟单卖出订单 (`matchSellOrder`)
|
||||
* 账户卖出订单 (`sellPosition`)
|
||||
- 费率获取失败时降级到默认值 "0",确保系统可用性
|
||||
- 添加详细的日志记录,便于监控和调试
|
||||
|
||||
- **参考文档**: https://docs.polymarket.com/developers/market-makers/maker-rebates-program
|
||||
|
||||
### 🔧 Docker 部署优化
|
||||
|
||||
- **日志级别环境变量支持**
|
||||
- 在 `application.properties` 中支持通过 `LOG_LEVEL_ROOT` 和 `LOG_LEVEL_APP` 环境变量配置日志级别
|
||||
- 在 `docker-compose.yml` 和 `docker-compose.prod.yml` 中添加日志级别环境变量配置
|
||||
- 在 `deploy.sh` 的 `.env` 模板中添加日志级别配置说明
|
||||
- 支持通过环境变量动态配置日志级别,无需修改配置文件
|
||||
- 默认值:`root=INFO`, `app=DEBUG`
|
||||
|
||||
## 🐛 Bug 修复
|
||||
|
||||
### 修复市场条件查询的 RPC 调用错误
|
||||
|
||||
- **问题**:使用错误的函数签名 `conditions(bytes32)` 导致 RPC 调用失败(execution reverted)
|
||||
- **修复**:
|
||||
- 将错误的 `conditions(bytes32)` 函数调用改为正确的 `getOutcomeSlotCount(bytes32)` 和 `payoutDenominator(bytes32)` 函数调用
|
||||
- 修复 `BlockchainService.getCondition` 方法,使用正确的 ConditionalTokens 合约函数签名
|
||||
- 改进 `MarketPriceService` 的错误处理:当链上查询出现 RPC 错误时,降级到 CLOB API 或 Gamma API 查询,而不是直接抛出异常,提高容错性
|
||||
|
||||
### 修复 RPC 错误时误创建自动卖出记录的问题
|
||||
|
||||
- **问题**:当链上查询市场条件出现 RPC 错误(execution reverted)时,系统会误判为市场已卖出,创建错误的自动卖出记录
|
||||
- **修复**:
|
||||
- 修改 `getPriceFromChainCondition` 返回 `Pair<BigDecimal?, Boolean>`,第二个值表示是否发生 RPC 错误
|
||||
- 在 `getCurrentMarketPrice` 中检测到 RPC 错误时抛出异常,`PositionCheckService` 会捕获并跳过该市场的处理
|
||||
- 避免在市场不存在或尚未创建时误判为已卖出
|
||||
|
||||
## 📝 文档更新
|
||||
|
||||
### 更新 Telegram 群链接
|
||||
|
||||
- 将所有 Telegram 群链接统一更新为 `t.me/polyhermes`
|
||||
- 更新了以下文件:
|
||||
- `frontend/src/components/Layout.tsx` - 桌面端和移动端导航链接
|
||||
- `RELEASE.md` - 相关链接
|
||||
- `README.md` 和 `README_EN.md` - 相关链接部分
|
||||
|
||||
### 添加 Docker 版本徽章
|
||||
|
||||
- 在 README 和 README_EN.md 中添加动态 Docker 版本徽章
|
||||
- 使用 shields.io 自动显示 Docker Hub 上 `wrbug/polyhermes` 镜像的最新版本
|
||||
- 版本信息自动更新,无需手动维护
|
||||
|
||||
## 📊 变更统计
|
||||
|
||||
- **提交数量**:5 个提交
|
||||
- **文件变更**:16 个文件
|
||||
- **代码变更**:+205 行 / -886 行(净减少 681 行)
|
||||
|
||||
### 详细文件变更
|
||||
|
||||
**后端变更**:
|
||||
- `PolymarketClobApi.kt` - 添加费率查询接口(+25 行)
|
||||
- `AccountService.kt` - 在订单创建处添加动态费率获取(+11 行)
|
||||
- `BlockchainService.kt` - 修复市场条件查询的 RPC 调用错误(+84 行)
|
||||
- `MarketPriceService.kt` - 改进错误处理,支持降级到其他数据源(+36 行)
|
||||
- `PolymarketClobService.kt` - 添加费率查询服务(+32 行)
|
||||
- `CopyOrderTrackingService.kt` - 在跟单订单创建处添加费率获取(+34 行)
|
||||
- `PositionCheckService.kt` - 修复 RPC 错误处理逻辑(+2 行)
|
||||
- `application.properties` - 添加日志级别环境变量支持(+6 行)
|
||||
|
||||
**前端变更**:
|
||||
- `Layout.tsx` - 更新 Telegram 群链接(+4 行)
|
||||
|
||||
**配置文件变更**:
|
||||
- `docker-compose.yml` - 添加日志级别环境变量(+4 行)
|
||||
- `docker-compose.prod.yml` - 添加日志级别环境变量(+4 行)
|
||||
- `deploy.sh` - 添加日志级别配置说明(+5 行)
|
||||
|
||||
**文档变更**:
|
||||
- `README.md` - 更新 Telegram 链接,添加 Docker 版本徽章(+2 行)
|
||||
- `README_EN.md` - 更新 Telegram 链接,添加 Docker 版本徽章(+2 行)
|
||||
- `RELEASE.md` - 更新 Telegram 链接(+4 行)
|
||||
- `docs/zh/smart-money-analysis.md` - 删除文档(-836 行)
|
||||
|
||||
## 🔧 技术细节
|
||||
|
||||
### API 变更
|
||||
|
||||
- **新增接口**:
|
||||
- `POST /api/clob/fee-rate` - 获取 Maker Rebates Program 费率(内部使用)
|
||||
- **无移除接口**
|
||||
|
||||
### 环境变量变更
|
||||
|
||||
- **新增环境变量**:
|
||||
- `LOG_LEVEL_ROOT` - Root 日志级别(默认:INFO)
|
||||
- `LOG_LEVEL_APP` - 应用日志级别(默认:DEBUG)
|
||||
|
||||
### 合约调用修复
|
||||
|
||||
- **修复的函数调用**:
|
||||
- 从 `conditions(bytes32)` 改为 `getOutcomeSlotCount(bytes32)` 和 `payoutDenominator(bytes32)`
|
||||
- 使用正确的 ConditionalTokens 合约函数签名
|
||||
- 参考:https://polygonscan.com/address/0x4d97dcd97ec945f40cf65f87097ace5ea0476045#code
|
||||
|
||||
## 📝 升级说明
|
||||
|
||||
### 数据库升级
|
||||
|
||||
- **无需数据库迁移**:本次更新不涉及数据库结构变更
|
||||
|
||||
### 配置更新
|
||||
|
||||
- **可选配置**:新增日志级别环境变量,如不配置将使用默认值
|
||||
- `LOG_LEVEL_ROOT=INFO`(默认)
|
||||
- `LOG_LEVEL_APP=DEBUG`(默认)
|
||||
|
||||
### Docker 部署
|
||||
|
||||
- **推荐更新**:使用 Docker Hub 镜像部署的用户,建议更新到最新版本
|
||||
```bash
|
||||
docker pull wrbug/polyhermes:latest
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
## 🔗 相关链接
|
||||
|
||||
- **GitHub 仓库**:https://github.com/WrBug/PolyHermes
|
||||
- **Twitter**:@polyhermes
|
||||
- **Telegram 群组**:https://t.me/polyhermes
|
||||
|
||||
---
|
||||
|
||||
**发布日期**:2026-01-07
|
||||
|
||||
---
|
||||
|
||||
# v1.1.5
|
||||
|
||||
## 🔧 功能优化与改进
|
||||
|
||||
### 前端优化
|
||||
|
||||
#### 优化 InputNumber 输入框格式化
|
||||
- 优化数值输入框的格式化逻辑,修正正则表达式以正确处理整数显示
|
||||
- 更新所有相关 InputNumber 组件的 formatter 函数,确保显示准确性
|
||||
- 影响的组件:CopyTradingAdd、CopyTradingEdit、EditModal、TemplateAdd、TemplateEdit、TemplateList
|
||||
- 影响范围:跟单配置、模板配置中的所有数值输入框
|
||||
|
||||
#### 优化数字显示格式
|
||||
- 添加 `formatNumber` 工具函数,自动去除小数尾随零(如 100.00 → 100)
|
||||
- 统一所有数值输入框的显示格式,提升用户体验
|
||||
|
||||
### 后端优化
|
||||
|
||||
#### 优化按比例跟单金额计算逻辑
|
||||
- 优化按比例计算的订单金额处理,使用向上取整确保满足最小限制要求
|
||||
- 对订单金额进行向上取整处理(保留 2 位小数精度)
|
||||
- 自动调整订单数量以满足最小限制要求
|
||||
- 使用 `RoundingMode.CEILING` 确保金额满足最小限制
|
||||
- 影响范围:按比例跟单的订单创建逻辑
|
||||
- 技术细节:
|
||||
- 扩展 `BigDecimal.div()` 扩展函数,支持指定精度和舍入模式
|
||||
- 在 `CopyOrderTrackingService` 中优化金额计算和验证逻辑
|
||||
|
||||
#### 增强 copyRatio 精度支持
|
||||
- 将 copyRatio 字段精度从 DECIMAL(10,2) 增加到 DECIMAL(20,8)
|
||||
- 支持更精确的跟单比例设置(最小 0.01%,最大 10000%)
|
||||
- 影响的实体:CopyTrading、CopyTradingTemplate
|
||||
- 数据库迁移:新增 V18 迁移脚本,自动升级数据库字段精度
|
||||
|
||||
## 🔧 功能优化
|
||||
|
||||
### 移除刷新代理钱包接口
|
||||
- **移除接口**:
|
||||
- `POST /api/accounts/refresh-proxy` - 刷新单个账户的代理地址
|
||||
- `POST /api/accounts/refresh-all-proxies` - 刷新所有账户的代理地址
|
||||
- **原因**:代理地址应在账户导入时自动计算,无需手动刷新
|
||||
- **影响范围**:AccountController、AccountService
|
||||
- **向后兼容性**:这些接口已不再使用,移除不影响现有功能
|
||||
|
||||
### 前端跟单比例配置优化
|
||||
- **最小比例**:从 10% 降低到 0.01%,支持更灵活的跟单比例设置
|
||||
- **最大比例**:增加到 10000%,满足大比例跟单需求
|
||||
- **显示格式**:比例模式显示为百分比(如 "100%" 而不是 "1x")
|
||||
- **输入验证**:增强输入验证,确保比例在合理范围内
|
||||
|
||||
## 📊 变更统计
|
||||
|
||||
- **提交数量**:3 个提交
|
||||
- **文件变更**:15 个文件
|
||||
- **代码变更**:+575 行 / -194 行(净增加 381 行)
|
||||
|
||||
### 详细文件变更
|
||||
|
||||
**后端变更**:
|
||||
- `AccountController.kt` - 移除刷新代理钱包接口(-59 行)
|
||||
- `AccountService.kt` - 移除刷新代理钱包方法(-79 行)
|
||||
- `CopyTrading.kt` - 增加 copyRatio 精度
|
||||
- `CopyTradingTemplate.kt` - 增加 copyRatio 精度
|
||||
- `CopyOrderTrackingService.kt` - 优化按比例跟单金额计算逻辑(+45 行)
|
||||
- `MathExt.kt` - 扩展 div 函数支持精度和舍入模式(+20 行)
|
||||
- `V18__increase_copy_ratio_precision.sql` - 数据库迁移脚本(+14 行)
|
||||
|
||||
**前端变更**:
|
||||
- `CopyTradingAdd.tsx` - 优化 formatter、优化比例配置(+106 行)
|
||||
- `CopyTradingEdit.tsx` - 优化 formatter、优化比例配置(+106 行)
|
||||
- `CopyTradingList.tsx` - 优化比例显示格式
|
||||
- `CopyTradingOrders/EditModal.tsx` - 优化 formatter、优化比例配置(+106 行)
|
||||
- `TemplateAdd.tsx` - 优化 formatter、优化比例配置(+65 行)
|
||||
- `TemplateEdit.tsx` - 优化 formatter、优化比例配置(+65 行)
|
||||
- `TemplateList.tsx` - 优化 formatter、优化比例配置(+65 行)
|
||||
- `utils/index.ts` - 添加 formatNumber 工具函数(+31 行)
|
||||
|
||||
## 🔧 技术细节
|
||||
|
||||
### 数据库变更
|
||||
- **迁移脚本**:`V18__increase_copy_ratio_precision.sql`
|
||||
- **变更内容**:
|
||||
- `copy_trading.copy_ratio`: DECIMAL(10,2) → DECIMAL(20,8)
|
||||
- `copy_trading_templates.copy_ratio`: DECIMAL(10,2) → DECIMAL(20,8)
|
||||
- **自动执行**:升级时会自动执行迁移脚本
|
||||
|
||||
### API 变更
|
||||
- **移除接口**:
|
||||
- `POST /api/accounts/refresh-proxy`
|
||||
- `POST /api/accounts/refresh-all-proxies`
|
||||
- **无新增接口**
|
||||
|
||||
### 前端变更
|
||||
- **工具函数**:新增 `formatNumber()` 函数,用于格式化数字显示
|
||||
- **组件更新**:所有数值输入框统一使用新的 formatter 函数
|
||||
- **显示优化**:跟单模式的比例显示为百分比格式
|
||||
|
||||
## 📝 升级说明
|
||||
|
||||
### 数据库升级
|
||||
本次版本包含数据库迁移脚本,升级时会自动执行:
|
||||
- 自动增加 `copy_ratio` 字段的精度
|
||||
- 现有数据不受影响,精度升级是向后兼容的
|
||||
|
||||
### 配置变更
|
||||
无需额外配置变更。
|
||||
|
||||
### 兼容性
|
||||
- **向后兼容**:所有变更都是向后兼容的
|
||||
- **API 兼容**:移除的接口不影响现有功能(这些接口已不再使用)
|
||||
- **数据兼容**:数据库字段精度升级不会影响现有数据
|
||||
|
||||
## 🎯 主要改进
|
||||
|
||||
1. **优化输入框格式化**:优化数值输入框的显示逻辑
|
||||
2. **优化跟单金额计算**:确保按比例跟单的金额满足最小限制要求
|
||||
3. **提升精度支持**:支持更精确的跟单比例设置(0.01% - 10000%)
|
||||
4. **代码清理**:移除不再使用的刷新代理钱包接口
|
||||
|
||||
## 🔗 相关链接
|
||||
|
||||
- [GitHub Tag](https://github.com/WrBug/PolyHermes/releases/tag/v1.1.5)
|
||||
- [变更日志](https://github.com/WrBug/PolyHermes/compare/v1.1.4...v1.1.5)
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
感谢所有贡献者和测试用户的反馈与支持!
|
||||
|
||||
---
|
||||
|
||||
# v1.1.2
|
||||
|
||||
## 🚀 主要功能
|
||||
@@ -102,7 +795,7 @@ docker pull wrbug/polyhermes:v1.1.2
|
||||
|
||||
* **GitHub 仓库**:https://github.com/WrBug/PolyHermes
|
||||
* **Twitter**:@polyhermes
|
||||
* **Telegram 群组**:加入群组
|
||||
* **Telegram 群组**:https://t.me/polyhermes
|
||||
|
||||
---
|
||||
|
||||
@@ -269,7 +962,7 @@ docker pull wrbug/polyhermes:v1.1.1
|
||||
|
||||
* **GitHub 仓库**:https://github.com/WrBug/PolyHermes
|
||||
* **Twitter**:@polyhermes
|
||||
* **Telegram 群组**:加入群组
|
||||
* **Telegram 群组**:https://t.me/polyhermes
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -68,6 +68,9 @@ dependencies {
|
||||
// Logging
|
||||
implementation("org.slf4j:slf4j-api")
|
||||
|
||||
// Caffeine Cache (LRU)
|
||||
implementation("com.github.ben-manes.caffeine:caffeine:3.1.8")
|
||||
|
||||
// Test
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test")
|
||||
|
||||
@@ -9,6 +9,7 @@ services:
|
||||
ports:
|
||||
- "${SERVER_PORT:-8000}:8000"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Shanghai}
|
||||
- SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE:-prod}
|
||||
- DB_URL=${DB_URL:-jdbc:mysql://mysql:3306/polyhermes?useSSL=false&serverTimezone=UTC&characterEncoding=utf8&allowPublicKeyRetrieval=true}
|
||||
- DB_USERNAME=${DB_USERNAME:-root}
|
||||
@@ -16,6 +17,8 @@ services:
|
||||
- SERVER_PORT=8000
|
||||
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
||||
- ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY:-change-me-in-production}
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
@@ -29,12 +32,14 @@ services:
|
||||
ports:
|
||||
- "${MYSQL_PORT:-3306}:3306"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Shanghai}
|
||||
- MYSQL_ROOT_PASSWORD=${DB_PASSWORD:-rootpassword}
|
||||
- MYSQL_DATABASE=polyhermes
|
||||
- MYSQL_CHARACTER_SET_SERVER=utf8mb4
|
||||
- MYSQL_COLLATION_SERVER=utf8mb4_unicode_ci
|
||||
volumes:
|
||||
- mysql-data:/var/lib/mysql
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_PASSWORD:-rootpassword}"]
|
||||
interval: 10s
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
package com.wrbug.polymarketbot.api
|
||||
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* NBA Stats API 接口
|
||||
* Base URL: https://stats.nba.com/stats/
|
||||
*
|
||||
* 注意:NBA Stats API 需要设置正确的请求头:
|
||||
* - User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
|
||||
* - Referer: https://www.nba.com/
|
||||
* - Accept: application/json
|
||||
*/
|
||||
interface NbaStatsApi {
|
||||
|
||||
/**
|
||||
* 获取赛程和比分
|
||||
* @param GameDate 比赛日期,格式:YYYY-MM-DD,不传则获取今天的比赛
|
||||
* @param LeagueID 联盟ID,默认:00 (NBA)
|
||||
* @param DayOffset 日期偏移,默认:0
|
||||
* @return ScoreboardResponse
|
||||
*/
|
||||
@GET("Scoreboard")
|
||||
suspend fun getScoreboard(
|
||||
@Query("GameDate") gameDate: String? = null,
|
||||
@Query("LeagueID") leagueId: String = "00",
|
||||
@Query("DayOffset") dayOffset: Int = 0
|
||||
): Response<ScoreboardResponse>
|
||||
}
|
||||
|
||||
/**
|
||||
* NBA Stats API Scoreboard 响应
|
||||
*/
|
||||
data class ScoreboardResponse(
|
||||
val resultSets: List<ResultSet>
|
||||
)
|
||||
|
||||
/**
|
||||
* Result Set
|
||||
*/
|
||||
data class ResultSet(
|
||||
val name: String,
|
||||
val headers: List<String>,
|
||||
val rowSet: List<List<Any?>>
|
||||
)
|
||||
|
||||
/**
|
||||
* Game Header (从 Scoreboard 的 resultSets[0] 获取)
|
||||
* Headers: ["GAME_DATE_EST", "GAME_SEQUENCE", "GAME_ID", "GAME_STATUS_ID", "GAME_STATUS_TEXT",
|
||||
* "GAMECODE", "HOME_TEAM_ID", "VISITOR_TEAM_ID", "SEASON", "LIVE_PERIOD",
|
||||
* "LIVE_PC_TIME", "NATL_TV_BROADCASTER_ABBREV", "LIVE_PERIOD_TIME_BCAST", "WH_STATUS"]
|
||||
*/
|
||||
data class GameHeader(
|
||||
val gameDateEst: String,
|
||||
val gameSequence: Int,
|
||||
val gameId: String,
|
||||
val gameStatusId: Int,
|
||||
val gameStatusText: String,
|
||||
val gameCode: String,
|
||||
val homeTeamId: Int,
|
||||
val visitorTeamId: Int,
|
||||
val season: String,
|
||||
val livePeriod: Int?,
|
||||
val livePcTime: String?,
|
||||
val natlTvBroadcasterAbbrev: String?,
|
||||
val livePeriodTimeBcast: String?,
|
||||
val whStatus: Int?
|
||||
)
|
||||
|
||||
/**
|
||||
* Line Score (从 Scoreboard 的 resultSets[1] 获取)
|
||||
* Headers: ["GAME_DATE_EST", "GAME_SEQUENCE", "GAME_ID", "TEAM_ID", "TEAM_ABBREVIATION",
|
||||
* "TEAM_NAME", "PTS_QTR1", "PTS_QTR2", "PTS_QTR3", "PTS_QTR4", "PTS_OT1",
|
||||
* "PTS_OT2", "PTS_OT3", "PTS_OT4", "PTS", "FG_PCT", "FT_PCT", "FG3_PCT",
|
||||
* "AST", "REB", "TOV"]
|
||||
*/
|
||||
data class LineScore(
|
||||
val gameDateEst: String,
|
||||
val gameSequence: Int,
|
||||
val gameId: String,
|
||||
val teamId: Int,
|
||||
val teamAbbreviation: String,
|
||||
val teamName: String,
|
||||
val ptsQtr1: Int?,
|
||||
val ptsQtr2: Int?,
|
||||
val ptsQtr3: Int?,
|
||||
val ptsQtr4: Int?,
|
||||
val ptsOt1: Int?,
|
||||
val ptsOt2: Int?,
|
||||
val ptsOt3: Int?,
|
||||
val ptsOt4: Int?,
|
||||
val pts: Int,
|
||||
val fgPct: Double?,
|
||||
val ftPct: Double?,
|
||||
val fg3Pct: Double?,
|
||||
val ast: Int?,
|
||||
val reb: Int?,
|
||||
val tov: Int?
|
||||
)
|
||||
|
||||
@@ -149,6 +149,19 @@ interface PolymarketClobApi {
|
||||
@GET("/auth/derive-api-key")
|
||||
suspend fun deriveApiKey(): Response<ApiKeyResponse>
|
||||
|
||||
/**
|
||||
* 获取费率
|
||||
* 文档: https://docs.polymarket.com/developers/market-makers/maker-rebates-program#1-fetch-the-fee-rate
|
||||
* 端点: GET /fee-rate
|
||||
*
|
||||
* @param tokenId Token ID
|
||||
* @return 费率响应
|
||||
*/
|
||||
@GET("/fee-rate")
|
||||
suspend fun getFeeRate(
|
||||
@Query("token_id") tokenId: String
|
||||
): Response<FeeRateResponse>
|
||||
|
||||
/**
|
||||
* 获取服务器时间
|
||||
* 端点: /time
|
||||
@@ -357,6 +370,18 @@ data class ServerTimeResponse(
|
||||
val timestamp: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* 费率响应
|
||||
* 文档: https://docs.polymarket.com/developers/market-makers/maker-rebates-program#1-fetch-the-fee-rate
|
||||
*
|
||||
* 注意:根据 TypeScript clob-client 源码,API 返回的字段名是 base_fee,而不是文档中的 fee_rate_bps
|
||||
* 参考: clob-client/src/client.ts:312
|
||||
*/
|
||||
data class FeeRateResponse(
|
||||
@SerializedName("base_fee")
|
||||
val baseFee: Int // 费率基点(0 表示无费率,1000 表示 10%)
|
||||
)
|
||||
|
||||
/**
|
||||
* 最新价响应(从订单表获取)
|
||||
*/
|
||||
|
||||
@@ -13,54 +13,36 @@ import retrofit2.http.Query
|
||||
interface PolymarketGammaApi {
|
||||
|
||||
/**
|
||||
* 获取体育元数据信息
|
||||
* 文档: https://docs.polymarket.com/api-reference/sports/get-sports-metadata-information
|
||||
* @return 体育元数据数组
|
||||
*/
|
||||
@GET("/sports")
|
||||
suspend fun getSports(): Response<List<SportsMetadataResponse>>
|
||||
|
||||
/**
|
||||
* 根据条件获取市场信息
|
||||
* 根据 condition ID 列表获取市场信息
|
||||
* 文档: https://docs.polymarket.com/api-reference/markets/list-markets
|
||||
* @param conditionIds condition ID 数组(16 进制字符串,如 "0x...")
|
||||
* @param clobTokenIds CLOB token ID 数组(用于通过 tokenId 查询市场)
|
||||
* @param includeTag 是否包含标签信息
|
||||
* @param tags 标签 ID 数组,用于过滤市场(如 NBA 的 tag ID)
|
||||
* @param active 是否只返回活跃的市场
|
||||
* @param closed 是否包含已关闭的市场
|
||||
* @param archived 是否包含已归档的市场
|
||||
* @param limit 返回的市场数量限制
|
||||
* @param startDateMin 最小开始日期(ISO 8601 格式,UTC 时区,如 "2025-12-01T00:00:00Z")
|
||||
* @param sportsMarketTypes 体育市场类型数组(如 ["moneyline"] 用于筛选 moneyline 类型)
|
||||
* @return 市场信息数组
|
||||
*/
|
||||
@GET("/markets")
|
||||
suspend fun listMarkets(
|
||||
@Query("condition_ids") conditionIds: List<String>? = null,
|
||||
@Query("clob_token_ids") clobTokenIds: List<String>? = null,
|
||||
@Query("include_tag") includeTag: Boolean? = null,
|
||||
@Query("tags") tags: List<String>? = null,
|
||||
@Query("active") active: Boolean? = null,
|
||||
@Query("closed") closed: Boolean? = null,
|
||||
@Query("archived") archived: Boolean? = null,
|
||||
@Query("limit") limit: Int? = null,
|
||||
@Query("start_date_min") startDateMin: String? = null,
|
||||
@Query("sports_market_types") sportsMarketTypes: List<String>? = null,
|
||||
@Query("include_tag") includeTag: Boolean? = null
|
||||
): Response<List<MarketResponse>>
|
||||
}
|
||||
|
||||
/**
|
||||
* 体育元数据响应
|
||||
* 文档: https://docs.polymarket.com/api-reference/sports/get-sports-metadata-information
|
||||
* 事件响应(从 MarketResponse.events 解析)
|
||||
*/
|
||||
data class SportsMetadataResponse(
|
||||
val sport: String? = null, // 体育标识符或缩写(如 "NBA")
|
||||
val image: String? = null, // 体育 logo 或图片 URL
|
||||
val resolution: String? = null, // 官方决议源 URL
|
||||
val ordering: String? = null, // 显示顺序(通常是 "home" 或 "away")
|
||||
val tags: String? = null, // 逗号分隔的标签 ID 列表
|
||||
val series: String? = null // 系列标识符
|
||||
data class EventResponse(
|
||||
val id: String? = null,
|
||||
val ticker: String? = null,
|
||||
val slug: String,
|
||||
val title: String,
|
||||
val category: String? = null,
|
||||
val active: Boolean? = null,
|
||||
val closed: Boolean? = null,
|
||||
val archived: Boolean? = null,
|
||||
val startDate: String? = null,
|
||||
val endDate: String? = null,
|
||||
val createdAt: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -89,22 +71,9 @@ data class MarketResponse(
|
||||
val lastTradePrice: Double? = null,
|
||||
val bestBid: Double? = null,
|
||||
val bestAsk: Double? = null,
|
||||
val events: List<EventResponse>? = null, // 事件列表(从 events[0] 获取 slug)
|
||||
// 以下字段可能存在于响应中,但不在标准文档中
|
||||
val clobTokenIds: String? = null, // CLOB token IDs(可能是 JSON 字符串或数组)
|
||||
val clob_token_ids: String? = null // 下划线格式(兼容不同 API 版本)
|
||||
val tags: List<MarketTag>? = null, // 市场标签列表
|
||||
val sportsMarketType: String? = null, // 市场类型:moneyline, spread 等
|
||||
val gameStartTime: String? = null, // 比赛开始时间(格式:2025-12-13 00:00:00+00)
|
||||
val createdAt: String? = null, // 市场创建时间(ISO 8601 格式)
|
||||
val resolutionSource: String? = null // 决议源 URL(如 "https://www.nba.com/")
|
||||
)
|
||||
|
||||
/**
|
||||
* 市场标签
|
||||
*/
|
||||
data class MarketTag(
|
||||
val id: String? = null,
|
||||
val label: String? = null,
|
||||
val slug: String? = null
|
||||
)
|
||||
|
||||
|
||||
-59
@@ -95,65 +95,6 @@ class AccountController(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新账户的代理地址
|
||||
* 使用最新的代理地址计算逻辑(支持 Magic 和 Safe 两种类型)
|
||||
*/
|
||||
@PostMapping("/refresh-proxy")
|
||||
fun refreshProxyAddress(@RequestBody request: AccountDetailRequest): ResponseEntity<ApiResponse<AccountDto>> {
|
||||
return try {
|
||||
if (request.accountId == null || request.accountId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ACCOUNT_ID_INVALID, messageSource = messageSource))
|
||||
}
|
||||
|
||||
val result = accountService.refreshProxyAddress(request.accountId)
|
||||
result.fold(
|
||||
onSuccess = { account ->
|
||||
ResponseEntity.ok(ApiResponse.success(account))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("刷新代理地址失败: ${e.message}", e)
|
||||
when (e) {
|
||||
is IllegalArgumentException -> ResponseEntity.ok(
|
||||
ApiResponse.error(
|
||||
ErrorCode.PARAM_ERROR,
|
||||
e.message,
|
||||
messageSource
|
||||
)
|
||||
)
|
||||
|
||||
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("刷新代理地址异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量刷新所有账户的代理地址
|
||||
*/
|
||||
@PostMapping("/refresh-all-proxies")
|
||||
fun refreshAllProxyAddresses(): ResponseEntity<ApiResponse<List<AccountDto>>> {
|
||||
return try {
|
||||
val result = accountService.refreshAllProxyAddresses()
|
||||
result.fold(
|
||||
onSuccess = { accounts ->
|
||||
ResponseEntity.ok(ApiResponse.success(accounts))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("批量刷新代理地址失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("批量刷新代理地址异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除账户
|
||||
*/
|
||||
|
||||
+46
@@ -200,5 +200,51 @@ class CopyOrderTrackingController(
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ORDER_TRACKING_LIST_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询按市场分组的订单列表(买入/卖出)
|
||||
* POST /api/copy-trading/orders/grouped-by-market
|
||||
*/
|
||||
@PostMapping("/grouped-by-market")
|
||||
fun getOrderListGroupedByMarket(@RequestBody request: MarketGroupedOrdersRequest): ResponseEntity<ApiResponse<MarketGroupedOrdersResponse>> {
|
||||
return try {
|
||||
if (request.copyTradingId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_COPY_TRADING_ID_INVALID, messageSource = messageSource))
|
||||
}
|
||||
|
||||
if (request.type.isBlank()) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, "订单类型不能为空", messageSource))
|
||||
}
|
||||
|
||||
val validTypes = listOf("buy", "sell")
|
||||
if (!validTypes.contains(request.type.lowercase())) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, "订单类型必须是 buy 或 sell", messageSource))
|
||||
}
|
||||
|
||||
val result: Result<MarketGroupedOrdersResponse> = runBlocking {
|
||||
when (request.type.lowercase()) {
|
||||
"buy" -> statisticsService.getBuyOrderListGroupedByMarket(request)
|
||||
"sell" -> statisticsService.getSellOrderListGroupedByMarket(request)
|
||||
else -> Result.failure(IllegalArgumentException("不支持的订单类型: ${request.type}"))
|
||||
}
|
||||
}
|
||||
|
||||
result.fold(
|
||||
onSuccess = { response: MarketGroupedOrdersResponse ->
|
||||
ResponseEntity.ok(ApiResponse.success(response))
|
||||
},
|
||||
onFailure = { e: Throwable ->
|
||||
logger.error("查询按市场分组的订单列表失败: copyTradingId=${request.copyTradingId}, type=${request.type}", e)
|
||||
when (e) {
|
||||
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, e.message, messageSource))
|
||||
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ORDER_TRACKING_LIST_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询按市场分组的订单列表异常: copyTradingId=${request.copyTradingId}, type=${request.type}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ORDER_TRACKING_LIST_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
package com.wrbug.polymarketbot.controller.nba
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.nba.NbaGameService
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.MessageSource
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.*
|
||||
|
||||
/**
|
||||
* NBA 比赛控制器
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/nba/games")
|
||||
class NbaGameController(
|
||||
private val nbaGameService: NbaGameService,
|
||||
private val messageSource: MessageSource
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(NbaGameController::class.java)
|
||||
|
||||
/**
|
||||
* 获取 NBA 比赛列表
|
||||
*/
|
||||
@PostMapping("/list")
|
||||
fun getNbaGames(@RequestBody request: NbaGameListRequest): ResponseEntity<ApiResponse<NbaGameListResponse>> {
|
||||
return try {
|
||||
val result = runBlocking {
|
||||
nbaGameService.getNbaGames(request)
|
||||
}
|
||||
|
||||
result.fold(
|
||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||
onFailure = { e ->
|
||||
logger.error("获取 NBA 比赛列表失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取 NBA 比赛列表异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 7 天内的所有球队(用于策略配置)
|
||||
*/
|
||||
@PostMapping("/teams")
|
||||
fun getTeamsInNext7Days(): ResponseEntity<ApiResponse<List<String>>> {
|
||||
return try {
|
||||
val result = runBlocking {
|
||||
nbaGameService.getTeamsInNext7Days()
|
||||
}
|
||||
|
||||
result.fold(
|
||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||
onFailure = { e ->
|
||||
logger.error("获取球队列表失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取球队列表异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
package com.wrbug.polymarketbot.controller.nba
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.nba.NbaMarketService
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.MessageSource
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.*
|
||||
|
||||
/**
|
||||
* NBA 市场控制器
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/nba/markets")
|
||||
class NbaMarketController(
|
||||
private val nbaMarketService: NbaMarketService,
|
||||
private val messageSource: MessageSource
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(NbaMarketController::class.java)
|
||||
|
||||
/**
|
||||
* 获取 NBA 市场列表
|
||||
*/
|
||||
@PostMapping("/list")
|
||||
fun getNbaMarkets(@RequestBody request: NbaMarketListRequest): ResponseEntity<ApiResponse<NbaMarketListResponse>> {
|
||||
return try {
|
||||
val result = runBlocking {
|
||||
nbaMarketService.getNbaMarkets(request)
|
||||
}
|
||||
|
||||
result.fold(
|
||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||
onFailure = { e ->
|
||||
logger.error("获取 NBA 市场列表失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取 NBA 市场列表异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 NBA 市场中获取球队列表(用于策略配置)
|
||||
* 从市场名称中解析出所有唯一的球队名称
|
||||
*/
|
||||
@PostMapping("/teams")
|
||||
fun getTeamsFromMarkets(): ResponseEntity<ApiResponse<List<String>>> {
|
||||
return try {
|
||||
val result = runBlocking {
|
||||
nbaMarketService.getTeamsFromMarkets(active = true)
|
||||
}
|
||||
|
||||
result.fold(
|
||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||
onFailure = { e ->
|
||||
logger.error("获取球队列表失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取球队列表异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-166
@@ -1,166 +0,0 @@
|
||||
package com.wrbug.polymarketbot.controller.nba
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.nba.NbaQuantitativeStrategyService
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.MessageSource
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.*
|
||||
|
||||
/**
|
||||
* NBA 量化策略控制器
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/nba/strategies")
|
||||
class NbaQuantitativeStrategyController(
|
||||
private val strategyService: NbaQuantitativeStrategyService,
|
||||
private val messageSource: MessageSource
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(NbaQuantitativeStrategyController::class.java)
|
||||
|
||||
/**
|
||||
* 创建策略
|
||||
*/
|
||||
@PostMapping("/create")
|
||||
fun createStrategy(@RequestBody request: NbaQuantitativeStrategyCreateRequest): ResponseEntity<ApiResponse<NbaQuantitativeStrategyDto>> {
|
||||
return try {
|
||||
if (request.strategyName.isBlank()) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, customMsg = "策略名称不能为空", messageSource = messageSource))
|
||||
}
|
||||
if (request.accountId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, customMsg = "账户ID无效", messageSource = messageSource))
|
||||
}
|
||||
|
||||
val result = runBlocking { strategyService.createStrategy(request) }
|
||||
result.fold(
|
||||
onSuccess = { strategy ->
|
||||
ResponseEntity.ok(ApiResponse.success(strategy))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("创建策略失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("创建策略异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新策略
|
||||
*/
|
||||
@PostMapping("/update")
|
||||
fun updateStrategy(@RequestBody request: NbaQuantitativeStrategyUpdateRequest): ResponseEntity<ApiResponse<NbaQuantitativeStrategyDto>> {
|
||||
return try {
|
||||
if (request.id <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, customMsg = "策略ID无效", messageSource = messageSource))
|
||||
}
|
||||
|
||||
val result = runBlocking { strategyService.updateStrategy(request) }
|
||||
result.fold(
|
||||
onSuccess = { strategy ->
|
||||
ResponseEntity.ok(ApiResponse.success(strategy))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("更新策略失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("更新策略异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取策略列表
|
||||
*/
|
||||
@PostMapping("/list")
|
||||
fun getStrategyList(@RequestBody request: NbaQuantitativeStrategyListRequest): ResponseEntity<ApiResponse<NbaQuantitativeStrategyListResponse>> {
|
||||
return try {
|
||||
val result = runBlocking { strategyService.getStrategyList(request) }
|
||||
result.fold(
|
||||
onSuccess = { response ->
|
||||
ResponseEntity.ok(ApiResponse.success(response))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("获取策略列表失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取策略列表异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取策略详情
|
||||
*/
|
||||
@PostMapping("/detail")
|
||||
fun getStrategyDetail(@RequestBody request: NbaQuantitativeStrategyDetailRequest): ResponseEntity<ApiResponse<NbaQuantitativeStrategyDto>> {
|
||||
return try {
|
||||
if (request.id == null || request.id <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, customMsg = "策略ID不能为空", messageSource = messageSource))
|
||||
}
|
||||
|
||||
val result = runBlocking { strategyService.getStrategyDetail(request.id) }
|
||||
result.fold(
|
||||
onSuccess = { strategy ->
|
||||
ResponseEntity.ok(ApiResponse.success(strategy))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("获取策略详情失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取策略详情异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除策略
|
||||
*/
|
||||
@PostMapping("/delete")
|
||||
fun deleteStrategy(@RequestBody request: NbaQuantitativeStrategyDeleteRequest): ResponseEntity<ApiResponse<Unit>> {
|
||||
return try {
|
||||
if (request.id == null || request.id <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, customMsg = "策略ID不能为空", messageSource = messageSource))
|
||||
}
|
||||
|
||||
val result = runBlocking { strategyService.deleteStrategy(request.id) }
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
ResponseEntity.ok(ApiResponse.success(Unit))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("删除策略失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("删除策略异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 策略详情请求
|
||||
*/
|
||||
data class NbaQuantitativeStrategyDetailRequest(
|
||||
val id: Long?
|
||||
)
|
||||
|
||||
/**
|
||||
* 策略删除请求
|
||||
*/
|
||||
data class NbaQuantitativeStrategyDeleteRequest(
|
||||
val id: Long?
|
||||
)
|
||||
|
||||
@@ -125,7 +125,8 @@ data class AccountPositionDto(
|
||||
val proxyAddress: String,
|
||||
val marketId: String,
|
||||
val marketTitle: String?,
|
||||
val marketSlug: String?,
|
||||
val marketSlug: String?, // 显示用的 slug
|
||||
val eventSlug: String? = null, // 跳转用的 slug(从 events[0].slug 获取)
|
||||
val marketIcon: String?, // 市场图标 URL
|
||||
val side: String, // 结果名称(如 "YES", "NO", "Pakistan" 等)
|
||||
val outcomeIndex: Int? = null, // 结果索引(0, 1, 2...),用于计算 tokenId
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
/**
|
||||
* Activity WebSocket 交易消息 DTO
|
||||
* 根据 Polymarket RTDS Activity WebSocket API 格式定义
|
||||
*/
|
||||
data class ActivityTradeMessage(
|
||||
val topic: String = "", // "activity"
|
||||
val type: String = "", // "trades"
|
||||
val timestamp: Long? = null, // 消息时间戳(可选)
|
||||
@SerializedName("connection_id")
|
||||
val connectionId: String? = null, // 连接 ID(可选,由服务器返回)
|
||||
val payload: ActivityTradePayload = ActivityTradePayload() // 交易数据
|
||||
)
|
||||
|
||||
/**
|
||||
* Activity Trade Payload
|
||||
*/
|
||||
data class ActivityTradePayload(
|
||||
val asset: String = "", // Token ID (用于下单)
|
||||
|
||||
@SerializedName("conditionId")
|
||||
val conditionId: String = "", // Market condition ID
|
||||
|
||||
@SerializedName("eventSlug")
|
||||
val eventSlug: String? = null, // 事件 slug
|
||||
|
||||
val slug: String? = null, // 市场 slug
|
||||
|
||||
val outcome: String? = null, // 结果方向 (Yes/No/Up/Down)
|
||||
|
||||
@SerializedName("outcomeIndex")
|
||||
val outcomeIndex: Int? = null, // 结果索引 (0=Yes/Up, 1=No/Down) - 优先使用此字段
|
||||
|
||||
val side: String = "", // 交易方向 (BUY/SELL)
|
||||
|
||||
// price 和 size 可能是数字或字符串,使用 Any 类型,后续转换为 String
|
||||
val price: Any? = null, // 交易价格
|
||||
|
||||
val size: Any? = null, // 交易数量 (shares)
|
||||
|
||||
val timestamp: Any? = null, // Unix 时间戳(可能是秒或毫秒,可能是数字或字符串)
|
||||
|
||||
@SerializedName("transactionHash")
|
||||
val transactionHash: String? = null, // 交易哈希
|
||||
|
||||
val trader: ActivityTrader? = null, // 交易者信息对象(优先)
|
||||
|
||||
@SerializedName("proxyWallet")
|
||||
val proxyWallet: String? = null, // 交易者地址(fallback,如果 trader 不存在)
|
||||
|
||||
val name: String? = null // 交易者名称(fallback,如果 trader 不存在)
|
||||
)
|
||||
|
||||
/**
|
||||
* Activity Trader 信息
|
||||
*/
|
||||
data class ActivityTrader(
|
||||
val name: String? = null, // 交易者用户名(可选)
|
||||
val address: String? = null // 交易者钱包地址 ⭐ 关键字段
|
||||
)
|
||||
|
||||
@@ -37,9 +37,14 @@ data class CopyTradingCreateRequest(
|
||||
// 最大仓位配置
|
||||
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
|
||||
// 关键字过滤配置
|
||||
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
||||
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
|
||||
// 新增配置字段
|
||||
val configName: String? = null, // 配置名(可选)
|
||||
val pushFailedOrders: Boolean? = null // 推送失败订单(可选)
|
||||
val pushFailedOrders: Boolean? = null, // 推送失败订单(可选)
|
||||
val pushFilteredOrders: Boolean? = null, // 推送已过滤订单(可选)
|
||||
val maxMarketEndDate: Long? = null // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -71,9 +76,14 @@ data class CopyTradingUpdateRequest(
|
||||
// 最大仓位配置
|
||||
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
|
||||
// 关键字过滤配置
|
||||
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
||||
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
|
||||
// 新增配置字段
|
||||
val configName: String? = null, // 配置名(可选,但提供时必须非空)
|
||||
val pushFailedOrders: Boolean? = null // 推送失败订单(可选)
|
||||
val pushFailedOrders: Boolean? = null, // 推送失败订单(可选)
|
||||
val pushFilteredOrders: Boolean? = null, // 推送已过滤订单(可选)
|
||||
val maxMarketEndDate: Long? = null // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -142,9 +152,14 @@ data class CopyTradingDto(
|
||||
// 最大仓位配置
|
||||
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
|
||||
// 关键字过滤配置
|
||||
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
||||
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
|
||||
// 新增配置字段
|
||||
val configName: String? = null, // 配置名(可选)
|
||||
val pushFailedOrders: Boolean = false, // 推送失败订单(默认关闭)
|
||||
val pushFilteredOrders: Boolean = false, // 推送已过滤订单(默认关闭)
|
||||
val maxMarketEndDate: Long? = null, // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
|
||||
val createdAt: Long,
|
||||
val updatedAt: Long
|
||||
)
|
||||
|
||||
@@ -40,6 +40,10 @@ data class BuyOrderInfo(
|
||||
val orderId: String,
|
||||
val leaderTradeId: String,
|
||||
val marketId: String,
|
||||
val marketTitle: String? = null, // 市场名称
|
||||
val marketSlug: String? = null, // 市场 slug(用于显示)
|
||||
val eventSlug: String? = null, // 跳转用的 slug(从 events[0].slug 获取)
|
||||
val marketCategory: String? = null, // 市场分类(sports, crypto 等)
|
||||
val side: String,
|
||||
val quantity: String,
|
||||
val price: String,
|
||||
@@ -57,6 +61,10 @@ data class SellOrderInfo(
|
||||
val orderId: String,
|
||||
val leaderTradeId: String,
|
||||
val marketId: String,
|
||||
val marketTitle: String? = null, // 市场名称
|
||||
val marketSlug: String? = null, // 市场 slug(用于显示)
|
||||
val eventSlug: String? = null, // 跳转用的 slug(从 events[0].slug 获取)
|
||||
val marketCategory: String? = null, // 市场分类(sports, crypto 等)
|
||||
val side: String,
|
||||
val quantity: String,
|
||||
val price: String,
|
||||
@@ -71,6 +79,11 @@ data class SellOrderInfo(
|
||||
data class MatchedOrderInfo(
|
||||
val sellOrderId: String,
|
||||
val buyOrderId: String,
|
||||
val marketId: String? = null, // 市场ID(从买入订单获取)
|
||||
val marketTitle: String? = null, // 市场名称
|
||||
val marketSlug: String? = null, // 市场 slug(用于显示)
|
||||
val eventSlug: String? = null, // 跳转用的 slug(从 events[0].slug 获取)
|
||||
val marketCategory: String? = null, // 市场分类(sports, crypto 等)
|
||||
val matchedQuantity: String,
|
||||
val buyPrice: String,
|
||||
val sellPrice: String,
|
||||
@@ -97,12 +110,60 @@ data class OrderTrackingRequest(
|
||||
val page: Int? = 1,
|
||||
val limit: Int? = 20,
|
||||
val marketId: String? = null,
|
||||
val side: String? = null,
|
||||
val marketTitle: String? = null, // 市场标题关键字筛选
|
||||
val status: String? = null,
|
||||
val sellOrderId: String? = null,
|
||||
val buyOrderId: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 按市场分组的订单查询请求
|
||||
*/
|
||||
data class MarketGroupedOrdersRequest(
|
||||
val copyTradingId: Long,
|
||||
val type: String, // buy, sell, matched
|
||||
val page: Int? = 1,
|
||||
val limit: Int? = 20,
|
||||
val marketId: String? = null,
|
||||
val marketTitle: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 单个市场的订单统计信息
|
||||
*/
|
||||
data class MarketOrderStats(
|
||||
val count: Long,
|
||||
val totalAmount: String, // 总金额
|
||||
val totalPnl: String?, // 总盈亏(买入订单未实现盈亏,此字段为空)
|
||||
val fullyMatched: Boolean, // 是否全部成交
|
||||
val fullyMatchedCount: Long, // 完全成交的订单数
|
||||
val partiallyMatchedCount: Long, // 部分成交的订单数
|
||||
val filledCount: Long // 未成交的订单数
|
||||
)
|
||||
|
||||
/**
|
||||
* 单个市场分组的响应数据
|
||||
*/
|
||||
data class MarketOrderGroup(
|
||||
val marketId: String,
|
||||
val marketTitle: String?,
|
||||
val marketSlug: String?, // 显示用的 slug
|
||||
val eventSlug: String? = null, // 跳转用的 slug(从 events[0].slug 获取)
|
||||
val marketCategory: String?,
|
||||
val stats: MarketOrderStats,
|
||||
val orders: List<Any> // BuyOrderInfo, SellOrderInfo 或 MatchedOrderInfo 的列表
|
||||
)
|
||||
|
||||
/**
|
||||
* 按市场分组的订单列表响应
|
||||
*/
|
||||
data class MarketGroupedOrdersResponse(
|
||||
val list: List<MarketOrderGroup>,
|
||||
val total: Long, // 市场总数
|
||||
val page: Int,
|
||||
val limit: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* 统计查询请求
|
||||
*/
|
||||
|
||||
@@ -23,7 +23,8 @@ data class TemplateCreateRequest(
|
||||
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
|
||||
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
|
||||
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
|
||||
val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价
|
||||
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||
val pushFilteredOrders: Boolean? = null // 推送已过滤订单(默认关闭)
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -50,7 +51,8 @@ data class TemplateUpdateRequest(
|
||||
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
|
||||
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
|
||||
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
|
||||
val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价
|
||||
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||
val pushFilteredOrders: Boolean? = null // 推送已过滤订单(默认关闭)
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -84,7 +86,8 @@ data class TemplateCopyRequest(
|
||||
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
|
||||
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
|
||||
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
|
||||
val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价
|
||||
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||
val pushFilteredOrders: Boolean? = null // 推送已过滤订单(默认关闭)
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -119,6 +122,7 @@ data class TemplateDto(
|
||||
val maxSpread: String?,
|
||||
val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价
|
||||
val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价
|
||||
val pushFilteredOrders: Boolean, // 推送已过滤订单(默认关闭)
|
||||
val createdAt: Long,
|
||||
val updatedAt: Long
|
||||
)
|
||||
|
||||
@@ -25,7 +25,8 @@ data class MarketDto(
|
||||
val liquidityNum: Double?,
|
||||
val bestBid: Double?,
|
||||
val bestAsk: Double?,
|
||||
val lastTradePrice: Double?
|
||||
val lastTradePrice: Double?,
|
||||
val events: List<MarketDto>? = null // 事件列表(从 events[0] 获取 slug)
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -37,9 +38,9 @@ data class OutcomeDto(
|
||||
)
|
||||
|
||||
/**
|
||||
* 事件 DTO
|
||||
* 事件 DTO(用于其他 API 调用)
|
||||
*/
|
||||
data class EventDto(
|
||||
data class EventListDto(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val category: String,
|
||||
@@ -55,7 +56,7 @@ data class SeriesDto(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val category: String,
|
||||
val events: List<EventDto>?,
|
||||
val events: List<EventListDto>?,
|
||||
val createdAt: Long? // 时间戳(毫秒)
|
||||
)
|
||||
|
||||
@@ -70,4 +71,3 @@ data class CommentDto(
|
||||
val createdAt: Long, // 时间戳(毫秒)
|
||||
val user: String?
|
||||
)
|
||||
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* NBA 比赛 DTO
|
||||
*/
|
||||
data class NbaGameDto(
|
||||
val id: Long?,
|
||||
val nbaGameId: String?,
|
||||
val homeTeam: String,
|
||||
val awayTeam: String,
|
||||
val gameDate: LocalDate,
|
||||
val gameTime: Long?,
|
||||
val gameStatus: String,
|
||||
val homeScore: Int,
|
||||
val awayScore: Int,
|
||||
val period: Int,
|
||||
val timeRemaining: String?,
|
||||
val polymarketMarketId: String?
|
||||
)
|
||||
|
||||
/**
|
||||
* NBA 比赛列表响应
|
||||
*/
|
||||
data class NbaGameListResponse(
|
||||
val list: List<NbaGameDto>,
|
||||
val total: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* NBA 比赛列表请求
|
||||
* 前端传递时间戳(毫秒),后端转换为西8区时间
|
||||
*/
|
||||
data class NbaGameListRequest(
|
||||
val startTimestamp: Long? = null, // 开始时间戳(毫秒)
|
||||
val endTimestamp: Long? = null, // 结束时间戳(毫秒)
|
||||
val gameStatus: String? = null
|
||||
)
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
/**
|
||||
* NBA 市场 DTO
|
||||
*/
|
||||
data class NbaMarketDto(
|
||||
val id: String? = null,
|
||||
val question: String? = null,
|
||||
val conditionId: String? = null,
|
||||
val slug: String? = null,
|
||||
val description: String? = null,
|
||||
val category: String? = null,
|
||||
val active: Boolean? = null,
|
||||
val closed: Boolean? = null,
|
||||
val archived: Boolean? = null,
|
||||
val volume: String? = null,
|
||||
val liquidity: String? = null,
|
||||
val endDate: String? = null,
|
||||
val startDate: String? = null,
|
||||
val outcomes: String? = null,
|
||||
val outcomePrices: String? = null,
|
||||
val volumeNum: Double? = null,
|
||||
val liquidityNum: Double? = null,
|
||||
val lastTradePrice: Double? = null,
|
||||
val bestBid: Double? = null,
|
||||
val bestAsk: Double? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* NBA 市场列表响应
|
||||
*/
|
||||
data class NbaMarketListResponse(
|
||||
val list: List<NbaMarketDto>,
|
||||
val total: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* NBA 市场列表请求
|
||||
*/
|
||||
data class NbaMarketListRequest(
|
||||
val active: Boolean? = true,
|
||||
val closed: Boolean? = false,
|
||||
val archived: Boolean? = false
|
||||
)
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
import java.math.BigDecimal
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* NBA 量化策略 DTO
|
||||
*/
|
||||
data class NbaQuantitativeStrategyDto(
|
||||
val id: Long?,
|
||||
val strategyName: String,
|
||||
val strategyDescription: String?,
|
||||
val accountId: Long,
|
||||
val accountName: String?,
|
||||
val enabled: Boolean,
|
||||
val filterTeams: List<String>?,
|
||||
val filterDateFrom: LocalDate?,
|
||||
val filterDateTo: LocalDate?,
|
||||
val filterGameImportance: String?,
|
||||
val minWinProbabilityDiff: BigDecimal,
|
||||
val minWinProbability: BigDecimal?,
|
||||
val maxWinProbability: BigDecimal?,
|
||||
val minTradeValue: BigDecimal,
|
||||
val minRemainingTime: Int?,
|
||||
val maxRemainingTime: Int?,
|
||||
val minScoreDiff: Int?,
|
||||
val maxScoreDiff: Int?,
|
||||
val buyAmountStrategy: String,
|
||||
val fixedBuyAmount: BigDecimal?,
|
||||
val buyRatio: BigDecimal?,
|
||||
val baseBuyAmount: BigDecimal?,
|
||||
val buyTiming: String,
|
||||
val delayBuySeconds: Int,
|
||||
val buyDirection: String,
|
||||
val enableSell: Boolean,
|
||||
val takeProfitThreshold: BigDecimal?,
|
||||
val stopLossThreshold: BigDecimal?,
|
||||
val probabilityReversalThreshold: BigDecimal?,
|
||||
val sellRatio: BigDecimal,
|
||||
val sellTiming: String,
|
||||
val delaySellSeconds: Int,
|
||||
val priceStrategy: String,
|
||||
val fixedPrice: BigDecimal?,
|
||||
val priceOffset: BigDecimal,
|
||||
val maxPosition: BigDecimal,
|
||||
val minPosition: BigDecimal,
|
||||
val maxGamePosition: BigDecimal?,
|
||||
val maxDailyLoss: BigDecimal?,
|
||||
val maxDailyOrders: Int?,
|
||||
val maxDailyProfit: BigDecimal?,
|
||||
val priceTolerance: BigDecimal,
|
||||
val minProbabilityThreshold: BigDecimal?,
|
||||
val maxProbabilityThreshold: BigDecimal?,
|
||||
val baseStrengthWeight: BigDecimal,
|
||||
val recentFormWeight: BigDecimal,
|
||||
val lineupIntegrityWeight: BigDecimal,
|
||||
val starStatusWeight: BigDecimal,
|
||||
val environmentWeight: BigDecimal,
|
||||
val matchupAdvantageWeight: BigDecimal,
|
||||
val scoreDiffWeight: BigDecimal,
|
||||
val momentumWeight: BigDecimal,
|
||||
val dataUpdateFrequency: Int,
|
||||
val analysisFrequency: Int,
|
||||
val pushFailedOrders: Boolean,
|
||||
val pushFrequency: String,
|
||||
val batchPushInterval: Int,
|
||||
val createdAt: Long,
|
||||
val updatedAt: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* NBA 量化策略创建请求
|
||||
*/
|
||||
data class NbaQuantitativeStrategyCreateRequest(
|
||||
val strategyName: String,
|
||||
val strategyDescription: String? = null,
|
||||
val accountId: Long,
|
||||
val enabled: Boolean = true,
|
||||
val filterTeams: List<String>? = null,
|
||||
val filterDateFrom: LocalDate? = null,
|
||||
val filterDateTo: LocalDate? = null,
|
||||
val filterGameImportance: String? = null,
|
||||
val minWinProbabilityDiff: BigDecimal? = null,
|
||||
val minWinProbability: BigDecimal? = null,
|
||||
val maxWinProbability: BigDecimal? = null,
|
||||
val minTradeValue: BigDecimal? = null,
|
||||
val minRemainingTime: Int? = null,
|
||||
val maxRemainingTime: Int? = null,
|
||||
val minScoreDiff: Int? = null,
|
||||
val maxScoreDiff: Int? = null,
|
||||
val buyAmountStrategy: String? = null,
|
||||
val fixedBuyAmount: BigDecimal? = null,
|
||||
val buyRatio: BigDecimal? = null,
|
||||
val baseBuyAmount: BigDecimal? = null,
|
||||
val buyTiming: String? = null,
|
||||
val delayBuySeconds: Int? = null,
|
||||
val buyDirection: String? = null,
|
||||
val enableSell: Boolean? = null,
|
||||
val takeProfitThreshold: BigDecimal? = null,
|
||||
val stopLossThreshold: BigDecimal? = null,
|
||||
val probabilityReversalThreshold: BigDecimal? = null,
|
||||
val sellRatio: BigDecimal? = null,
|
||||
val sellTiming: String? = null,
|
||||
val delaySellSeconds: Int? = null,
|
||||
val priceStrategy: String? = null,
|
||||
val fixedPrice: BigDecimal? = null,
|
||||
val priceOffset: BigDecimal? = null,
|
||||
val maxPosition: BigDecimal? = null,
|
||||
val minPosition: BigDecimal? = null,
|
||||
val maxGamePosition: BigDecimal? = null,
|
||||
val maxDailyLoss: BigDecimal? = null,
|
||||
val maxDailyOrders: Int? = null,
|
||||
val maxDailyProfit: BigDecimal? = null,
|
||||
val priceTolerance: BigDecimal? = null,
|
||||
val minProbabilityThreshold: BigDecimal? = null,
|
||||
val maxProbabilityThreshold: BigDecimal? = null,
|
||||
val baseStrengthWeight: BigDecimal? = null,
|
||||
val recentFormWeight: BigDecimal? = null,
|
||||
val lineupIntegrityWeight: BigDecimal? = null,
|
||||
val starStatusWeight: BigDecimal? = null,
|
||||
val environmentWeight: BigDecimal? = null,
|
||||
val matchupAdvantageWeight: BigDecimal? = null,
|
||||
val scoreDiffWeight: BigDecimal? = null,
|
||||
val momentumWeight: BigDecimal? = null,
|
||||
val dataUpdateFrequency: Int? = null,
|
||||
val analysisFrequency: Int? = null,
|
||||
val pushFailedOrders: Boolean? = null,
|
||||
val pushFrequency: String? = null,
|
||||
val batchPushInterval: Int? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* NBA 量化策略更新请求
|
||||
*/
|
||||
data class NbaQuantitativeStrategyUpdateRequest(
|
||||
val id: Long,
|
||||
val strategyName: String? = null,
|
||||
val strategyDescription: String? = null,
|
||||
val enabled: Boolean? = null,
|
||||
val filterTeams: List<String>? = null,
|
||||
val filterDateFrom: LocalDate? = null,
|
||||
val filterDateTo: LocalDate? = null,
|
||||
val filterGameImportance: String? = null,
|
||||
val minWinProbabilityDiff: BigDecimal? = null,
|
||||
val minWinProbability: BigDecimal? = null,
|
||||
val maxWinProbability: BigDecimal? = null,
|
||||
val minTradeValue: BigDecimal? = null,
|
||||
val minRemainingTime: Int? = null,
|
||||
val maxRemainingTime: Int? = null,
|
||||
val minScoreDiff: Int? = null,
|
||||
val maxScoreDiff: Int? = null,
|
||||
val buyAmountStrategy: String? = null,
|
||||
val fixedBuyAmount: BigDecimal? = null,
|
||||
val buyRatio: BigDecimal? = null,
|
||||
val baseBuyAmount: BigDecimal? = null,
|
||||
val buyTiming: String? = null,
|
||||
val delayBuySeconds: Int? = null,
|
||||
val buyDirection: String? = null,
|
||||
val enableSell: Boolean? = null,
|
||||
val takeProfitThreshold: BigDecimal? = null,
|
||||
val stopLossThreshold: BigDecimal? = null,
|
||||
val probabilityReversalThreshold: BigDecimal? = null,
|
||||
val sellRatio: BigDecimal? = null,
|
||||
val sellTiming: String? = null,
|
||||
val delaySellSeconds: Int? = null,
|
||||
val priceStrategy: String? = null,
|
||||
val fixedPrice: BigDecimal? = null,
|
||||
val priceOffset: BigDecimal? = null,
|
||||
val maxPosition: BigDecimal? = null,
|
||||
val minPosition: BigDecimal? = null,
|
||||
val maxGamePosition: BigDecimal? = null,
|
||||
val maxDailyLoss: BigDecimal? = null,
|
||||
val maxDailyOrders: Int? = null,
|
||||
val maxDailyProfit: BigDecimal? = null,
|
||||
val priceTolerance: BigDecimal? = null,
|
||||
val minProbabilityThreshold: BigDecimal? = null,
|
||||
val maxProbabilityThreshold: BigDecimal? = null,
|
||||
val baseStrengthWeight: BigDecimal? = null,
|
||||
val recentFormWeight: BigDecimal? = null,
|
||||
val lineupIntegrityWeight: BigDecimal? = null,
|
||||
val starStatusWeight: BigDecimal? = null,
|
||||
val environmentWeight: BigDecimal? = null,
|
||||
val matchupAdvantageWeight: BigDecimal? = null,
|
||||
val scoreDiffWeight: BigDecimal? = null,
|
||||
val momentumWeight: BigDecimal? = null,
|
||||
val dataUpdateFrequency: Int? = null,
|
||||
val analysisFrequency: Int? = null,
|
||||
val pushFailedOrders: Boolean? = null,
|
||||
val pushFrequency: String? = null,
|
||||
val batchPushInterval: Int? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* NBA 量化策略列表请求
|
||||
*/
|
||||
data class NbaQuantitativeStrategyListRequest(
|
||||
val accountId: Long? = null,
|
||||
val enabled: Boolean? = null,
|
||||
val strategyName: String? = null,
|
||||
val page: Int? = 1,
|
||||
val limit: Int? = 20
|
||||
)
|
||||
|
||||
/**
|
||||
* NBA 量化策略列表响应
|
||||
*/
|
||||
data class NbaQuantitativeStrategyListResponse(
|
||||
val list: List<NbaQuantitativeStrategyDto>,
|
||||
val total: Long,
|
||||
val page: Int,
|
||||
val limit: Int
|
||||
)
|
||||
|
||||
@@ -8,12 +8,7 @@ import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
* 跟单配置实体(独立配置,不再绑定模板)
|
||||
*/
|
||||
@Entity
|
||||
@Table(
|
||||
name = "copy_trading",
|
||||
uniqueConstraints = [
|
||||
UniqueConstraint(columnNames = ["account_id", "leader_id"])
|
||||
]
|
||||
)
|
||||
@Table(name = "copy_trading")
|
||||
data class CopyTrading(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@@ -32,7 +27,7 @@ data class CopyTrading(
|
||||
@Column(name = "copy_mode", nullable = false, length = 10)
|
||||
val copyMode: String = "RATIO", // "RATIO" 或 "FIXED"
|
||||
|
||||
@Column(name = "copy_ratio", nullable = false, precision = 10, scale = 2)
|
||||
@Column(name = "copy_ratio", nullable = false, precision = 20, scale = 8)
|
||||
val copyRatio: BigDecimal = BigDecimal.ONE, // 仅在 copyMode="RATIO" 时生效
|
||||
|
||||
@Column(name = "fixed_amount", precision = 20, scale = 8)
|
||||
@@ -91,6 +86,13 @@ data class CopyTrading(
|
||||
@Column(name = "max_position_count")
|
||||
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
|
||||
|
||||
// 关键字过滤配置
|
||||
@Column(name = "keyword_filter_mode", nullable = false, length = 20)
|
||||
val keywordFilterMode: String = "DISABLED", // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
||||
|
||||
@Column(name = "keywords", columnDefinition = "JSON")
|
||||
val keywords: String? = null, // 关键字列表(JSON数组),例如:["NBA", "足球", "NBA总决赛"],当keywordFilterMode为DISABLED时为NULL
|
||||
|
||||
// 新增配置字段
|
||||
@Column(name = "config_name", length = 255)
|
||||
val configName: String? = null, // 配置名(可选)
|
||||
@@ -98,6 +100,12 @@ data class CopyTrading(
|
||||
@Column(name = "push_failed_orders", nullable = false)
|
||||
val pushFailedOrders: Boolean = false, // 推送失败订单(默认关闭)
|
||||
|
||||
@Column(name = "push_filtered_orders", nullable = false)
|
||||
val pushFilteredOrders: Boolean = false, // 推送已过滤订单(默认关闭)
|
||||
|
||||
@Column(name = "max_market_end_date")
|
||||
val maxMarketEndDate: Long? = null, // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ data class CopyTradingTemplate(
|
||||
@Column(name = "copy_mode", nullable = false, length = 10)
|
||||
val copyMode: String = "RATIO", // "RATIO" 或 "FIXED"
|
||||
|
||||
@Column(name = "copy_ratio", nullable = false, precision = 10, scale = 2)
|
||||
@Column(name = "copy_ratio", nullable = false, precision = 20, scale = 8)
|
||||
val copyRatio: BigDecimal = BigDecimal.ONE, // 仅在 copyMode="RATIO" 时生效
|
||||
|
||||
@Column(name = "fixed_amount", precision = 20, scale = 8)
|
||||
@@ -72,6 +72,9 @@ data class CopyTradingTemplate(
|
||||
@Column(name = "max_price", precision = 20, scale = 8)
|
||||
val maxPrice: BigDecimal? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||
|
||||
@Column(name = "push_filtered_orders", nullable = false)
|
||||
val pushFilteredOrders: Boolean = false, // 推送已过滤订单(默认关闭)
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.wrbug.polymarketbot.entity
|
||||
|
||||
import jakarta.persistence.*
|
||||
|
||||
/**
|
||||
* 市场信息实体
|
||||
* 用于缓存市场的基本信息(名称、slug等)
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "markets", indexes = [
|
||||
Index(name = "idx_market_id", columnList = "market_id", unique = true)
|
||||
])
|
||||
data class Market(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
@Column(name = "market_id", unique = true, nullable = false, length = 100)
|
||||
val marketId: String, // 市场ID(condition ID)
|
||||
|
||||
@Column(name = "title", nullable = false, length = 500)
|
||||
val title: String, // 市场名称(question)
|
||||
|
||||
@Column(name = "slug", length = 200)
|
||||
val slug: String? = null, // 市场slug(用于显示)
|
||||
|
||||
@Column(name = "event_slug", length = 200)
|
||||
val eventSlug: String? = null, // 跳转用的 slug(从 events[0].slug 获取)
|
||||
|
||||
@Column(name = "category", length = 50)
|
||||
val category: String? = null, // 市场分类
|
||||
|
||||
@Column(name = "icon", length = 500)
|
||||
val icon: String? = null, // 市场图标URL
|
||||
|
||||
@Column(name = "image", length = 500)
|
||||
val image: String? = null, // 市场图片URL
|
||||
|
||||
@Column(name = "description", columnDefinition = "TEXT")
|
||||
val description: String? = null, // 市场描述
|
||||
|
||||
@Column(name = "active", nullable = false)
|
||||
val active: Boolean = true, // 是否活跃
|
||||
|
||||
@Column(name = "closed", nullable = false)
|
||||
val closed: Boolean = false, // 是否已关闭
|
||||
|
||||
@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(),
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
var updatedAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
package com.wrbug.polymarketbot.entity
|
||||
|
||||
import jakarta.persistence.*
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* NBA 比赛实体
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "nba_games")
|
||||
data class NbaGame(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
@Column(name = "nba_game_id", unique = true, length = 100)
|
||||
val nbaGameId: String? = null,
|
||||
|
||||
@Column(name = "home_team", nullable = false, length = 100)
|
||||
val homeTeam: String,
|
||||
|
||||
@Column(name = "away_team", nullable = false, length = 100)
|
||||
val awayTeam: String,
|
||||
|
||||
@Column(name = "game_date", nullable = false)
|
||||
val gameDate: LocalDate,
|
||||
|
||||
@Column(name = "game_time")
|
||||
val gameTime: Long? = null,
|
||||
|
||||
@Column(name = "game_status", length = 50)
|
||||
val gameStatus: String = "scheduled",
|
||||
|
||||
@Column(name = "home_score")
|
||||
val homeScore: Int = 0,
|
||||
|
||||
@Column(name = "away_score")
|
||||
val awayScore: Int = 0,
|
||||
|
||||
@Column(name = "period")
|
||||
val period: Int = 0,
|
||||
|
||||
@Column(name = "time_remaining", length = 50)
|
||||
val timeRemaining: String? = null,
|
||||
|
||||
@Column(name = "polymarket_market_id", length = 100)
|
||||
val polymarketMarketId: String? = null,
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
var updatedAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
package com.wrbug.polymarketbot.entity
|
||||
|
||||
import jakarta.persistence.*
|
||||
|
||||
/**
|
||||
* NBA 市场实体(Polymarket 市场信息)
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "nba_markets")
|
||||
data class NbaMarket(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
@Column(name = "polymarket_market_id", unique = true, nullable = false, length = 100)
|
||||
val polymarketMarketId: String,
|
||||
|
||||
@Column(name = "condition_id", unique = true, nullable = false, length = 100)
|
||||
val conditionId: String,
|
||||
|
||||
@Column(name = "market_slug", length = 255)
|
||||
val marketSlug: String? = null,
|
||||
|
||||
@Column(name = "market_question", columnDefinition = "TEXT")
|
||||
val marketQuestion: String? = null,
|
||||
|
||||
@Column(name = "market_description", columnDefinition = "TEXT")
|
||||
val marketDescription: String? = null,
|
||||
|
||||
@Column(name = "category", length = 50)
|
||||
val category: String = "sports",
|
||||
|
||||
@Column(name = "active")
|
||||
val active: Boolean = true,
|
||||
|
||||
@Column(name = "closed")
|
||||
val closed: Boolean = false,
|
||||
|
||||
@Column(name = "archived")
|
||||
val archived: Boolean = false,
|
||||
|
||||
@Column(name = "volume", length = 50)
|
||||
val volume: String? = null,
|
||||
|
||||
@Column(name = "liquidity", length = 50)
|
||||
val liquidity: String? = null,
|
||||
|
||||
@Column(name = "outcomes", columnDefinition = "TEXT")
|
||||
val outcomes: String? = null,
|
||||
|
||||
@Column(name = "end_date", length = 50)
|
||||
val endDate: String? = null,
|
||||
|
||||
@Column(name = "start_date", length = 50)
|
||||
val startDate: String? = null,
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
var updatedAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
package com.wrbug.polymarketbot.entity
|
||||
|
||||
import jakarta.persistence.*
|
||||
import java.math.BigDecimal
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* NBA 量化策略配置实体
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "nba_quantitative_strategies")
|
||||
data class NbaQuantitativeStrategy(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
@Column(name = "strategy_name", nullable = false, length = 100)
|
||||
val strategyName: String,
|
||||
|
||||
@Column(name = "strategy_description", columnDefinition = "TEXT")
|
||||
val strategyDescription: String? = null,
|
||||
|
||||
@Column(name = "account_id", nullable = false)
|
||||
val accountId: Long,
|
||||
|
||||
@Column(name = "enabled")
|
||||
val enabled: Boolean = true,
|
||||
|
||||
// 比赛筛选参数
|
||||
@Column(name = "filter_teams", columnDefinition = "TEXT")
|
||||
val filterTeams: String? = null,
|
||||
|
||||
@Column(name = "filter_date_from")
|
||||
val filterDateFrom: LocalDate? = null,
|
||||
|
||||
@Column(name = "filter_date_to")
|
||||
val filterDateTo: LocalDate? = null,
|
||||
|
||||
@Column(name = "filter_game_importance", length = 50)
|
||||
val filterGameImportance: String? = null,
|
||||
|
||||
// 触发条件参数
|
||||
@Column(name = "min_win_probability_diff", precision = 5, scale = 4)
|
||||
val minWinProbabilityDiff: BigDecimal = BigDecimal("0.1"),
|
||||
|
||||
@Column(name = "min_win_probability", precision = 5, scale = 4)
|
||||
val minWinProbability: BigDecimal? = null,
|
||||
|
||||
@Column(name = "max_win_probability", precision = 5, scale = 4)
|
||||
val maxWinProbability: BigDecimal? = null,
|
||||
|
||||
@Column(name = "min_trade_value", precision = 5, scale = 4)
|
||||
val minTradeValue: BigDecimal = BigDecimal("0.05"),
|
||||
|
||||
@Column(name = "min_remaining_time")
|
||||
val minRemainingTime: Int? = null,
|
||||
|
||||
@Column(name = "max_remaining_time")
|
||||
val maxRemainingTime: Int? = null,
|
||||
|
||||
@Column(name = "min_score_diff")
|
||||
val minScoreDiff: Int? = null,
|
||||
|
||||
@Column(name = "max_score_diff")
|
||||
val maxScoreDiff: Int? = null,
|
||||
|
||||
// 买入规则参数
|
||||
@Column(name = "buy_amount_strategy", length = 20)
|
||||
val buyAmountStrategy: String = "FIXED",
|
||||
|
||||
@Column(name = "fixed_buy_amount", precision = 20, scale = 8)
|
||||
val fixedBuyAmount: BigDecimal? = null,
|
||||
|
||||
@Column(name = "buy_ratio", precision = 5, scale = 4)
|
||||
val buyRatio: BigDecimal? = null,
|
||||
|
||||
@Column(name = "base_buy_amount", precision = 20, scale = 8)
|
||||
val baseBuyAmount: BigDecimal? = null,
|
||||
|
||||
@Column(name = "buy_timing", length = 20)
|
||||
val buyTiming: String = "IMMEDIATE",
|
||||
|
||||
@Column(name = "delay_buy_seconds")
|
||||
val delayBuySeconds: Int = 0,
|
||||
|
||||
@Column(name = "buy_direction", length = 10)
|
||||
val buyDirection: String = "AUTO",
|
||||
|
||||
// 卖出规则参数
|
||||
@Column(name = "enable_sell")
|
||||
val enableSell: Boolean = true,
|
||||
|
||||
@Column(name = "take_profit_threshold", precision = 5, scale = 4)
|
||||
val takeProfitThreshold: BigDecimal? = null,
|
||||
|
||||
@Column(name = "stop_loss_threshold", precision = 5, scale = 4)
|
||||
val stopLossThreshold: BigDecimal? = null,
|
||||
|
||||
@Column(name = "probability_reversal_threshold", precision = 5, scale = 4)
|
||||
val probabilityReversalThreshold: BigDecimal? = null,
|
||||
|
||||
@Column(name = "sell_ratio", precision = 5, scale = 4)
|
||||
val sellRatio: BigDecimal = BigDecimal("1.0"),
|
||||
|
||||
@Column(name = "sell_timing", length = 20)
|
||||
val sellTiming: String = "IMMEDIATE",
|
||||
|
||||
@Column(name = "delay_sell_seconds")
|
||||
val delaySellSeconds: Int = 0,
|
||||
|
||||
// 价格策略参数
|
||||
@Column(name = "price_strategy", length = 20)
|
||||
val priceStrategy: String = "MARKET",
|
||||
|
||||
@Column(name = "fixed_price", precision = 5, scale = 4)
|
||||
val fixedPrice: BigDecimal? = null,
|
||||
|
||||
@Column(name = "price_offset", precision = 5, scale = 4)
|
||||
val priceOffset: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
// 风险控制参数
|
||||
@Column(name = "max_position", precision = 20, scale = 8)
|
||||
val maxPosition: BigDecimal = BigDecimal("50"),
|
||||
|
||||
@Column(name = "min_position", precision = 20, scale = 8)
|
||||
val minPosition: BigDecimal = BigDecimal("5"),
|
||||
|
||||
@Column(name = "max_game_position", precision = 20, scale = 8)
|
||||
val maxGamePosition: BigDecimal? = null,
|
||||
|
||||
@Column(name = "max_daily_loss", precision = 20, scale = 8)
|
||||
val maxDailyLoss: BigDecimal? = null,
|
||||
|
||||
@Column(name = "max_daily_orders")
|
||||
val maxDailyOrders: Int? = null,
|
||||
|
||||
@Column(name = "max_daily_profit", precision = 20, scale = 8)
|
||||
val maxDailyProfit: BigDecimal? = null,
|
||||
|
||||
@Column(name = "price_tolerance", precision = 5, scale = 4)
|
||||
val priceTolerance: BigDecimal = BigDecimal("0.05"),
|
||||
|
||||
@Column(name = "min_probability_threshold", precision = 5, scale = 4)
|
||||
val minProbabilityThreshold: BigDecimal? = null,
|
||||
|
||||
@Column(name = "max_probability_threshold", precision = 5, scale = 4)
|
||||
val maxProbabilityThreshold: BigDecimal? = null,
|
||||
|
||||
// 算法权重参数
|
||||
@Column(name = "base_strength_weight", precision = 5, scale = 4)
|
||||
val baseStrengthWeight: BigDecimal = BigDecimal("0.3"),
|
||||
|
||||
@Column(name = "recent_form_weight", precision = 5, scale = 4)
|
||||
val recentFormWeight: BigDecimal = BigDecimal("0.25"),
|
||||
|
||||
@Column(name = "lineup_integrity_weight", precision = 5, scale = 4)
|
||||
val lineupIntegrityWeight: BigDecimal = BigDecimal("0.2"),
|
||||
|
||||
@Column(name = "star_status_weight", precision = 5, scale = 4)
|
||||
val starStatusWeight: BigDecimal = BigDecimal("0.15"),
|
||||
|
||||
@Column(name = "environment_weight", precision = 5, scale = 4)
|
||||
val environmentWeight: BigDecimal = BigDecimal("0.1"),
|
||||
|
||||
@Column(name = "matchup_advantage_weight", precision = 5, scale = 4)
|
||||
val matchupAdvantageWeight: BigDecimal = BigDecimal("0.2"),
|
||||
|
||||
@Column(name = "score_diff_weight", precision = 5, scale = 4)
|
||||
val scoreDiffWeight: BigDecimal = BigDecimal("0.3"),
|
||||
|
||||
@Column(name = "momentum_weight", precision = 5, scale = 4)
|
||||
val momentumWeight: BigDecimal = BigDecimal("0.2"),
|
||||
|
||||
// 系统配置参数
|
||||
@Column(name = "data_update_frequency")
|
||||
val dataUpdateFrequency: Int = 30,
|
||||
|
||||
@Column(name = "analysis_frequency")
|
||||
val analysisFrequency: Int = 30,
|
||||
|
||||
@Column(name = "push_failed_orders")
|
||||
val pushFailedOrders: Boolean = false,
|
||||
|
||||
@Column(name = "push_frequency", length = 20)
|
||||
val pushFrequency: String = "REALTIME",
|
||||
|
||||
@Column(name = "batch_push_interval")
|
||||
val batchPushInterval: Int = 1,
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
var updatedAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
package com.wrbug.polymarketbot.entity
|
||||
|
||||
import jakarta.persistence.*
|
||||
import java.math.BigDecimal
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* NBA 策略执行统计实体
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "nba_strategy_statistics")
|
||||
data class NbaStrategyStatistics(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
@Column(name = "strategy_id", nullable = false)
|
||||
val strategyId: Long,
|
||||
|
||||
@Column(name = "stat_date", nullable = false)
|
||||
val statDate: LocalDate,
|
||||
|
||||
@Column(name = "total_signals")
|
||||
val totalSignals: Int = 0,
|
||||
|
||||
@Column(name = "buy_signals")
|
||||
val buySignals: Int = 0,
|
||||
|
||||
@Column(name = "sell_signals")
|
||||
val sellSignals: Int = 0,
|
||||
|
||||
@Column(name = "success_signals")
|
||||
val successSignals: Int = 0,
|
||||
|
||||
@Column(name = "failed_signals")
|
||||
val failedSignals: Int = 0,
|
||||
|
||||
@Column(name = "total_profit", precision = 20, scale = 8)
|
||||
val totalProfit: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
@Column(name = "total_volume", precision = 20, scale = 8)
|
||||
val totalVolume: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
var updatedAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
package com.wrbug.polymarketbot.entity
|
||||
|
||||
import jakarta.persistence.*
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* NBA 交易信号实体
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "nba_trading_signals")
|
||||
data class NbaTradingSignal(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
@Column(name = "strategy_id", nullable = false)
|
||||
val strategyId: Long,
|
||||
|
||||
@Column(name = "game_id")
|
||||
val gameId: Long? = null,
|
||||
|
||||
@Column(name = "market_id")
|
||||
val marketId: Long? = null,
|
||||
|
||||
@Column(name = "signal_type", nullable = false, length = 10)
|
||||
val signalType: String,
|
||||
|
||||
@Column(name = "direction", nullable = false, length = 10)
|
||||
val direction: String,
|
||||
|
||||
@Column(name = "price", nullable = false, precision = 5, scale = 4)
|
||||
val price: BigDecimal,
|
||||
|
||||
@Column(name = "quantity", nullable = false, precision = 20, scale = 8)
|
||||
val quantity: BigDecimal,
|
||||
|
||||
@Column(name = "total_amount", nullable = false, precision = 20, scale = 8)
|
||||
val totalAmount: BigDecimal,
|
||||
|
||||
@Column(name = "reason", columnDefinition = "TEXT")
|
||||
val reason: String? = null,
|
||||
|
||||
@Column(name = "win_probability", precision = 5, scale = 4)
|
||||
val winProbability: BigDecimal? = null,
|
||||
|
||||
@Column(name = "trade_value", precision = 5, scale = 4)
|
||||
val tradeValue: BigDecimal? = null,
|
||||
|
||||
@Column(name = "signal_status", length = 20)
|
||||
val signalStatus: String = "GENERATED",
|
||||
|
||||
@Column(name = "execution_result", columnDefinition = "TEXT")
|
||||
val executionResult: String? = null,
|
||||
|
||||
@Column(name = "error_message", columnDefinition = "TEXT")
|
||||
val errorMessage: String? = null,
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
var updatedAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
package com.wrbug.polymarketbot.enums
|
||||
|
||||
/**
|
||||
* Polymarket 体育项目 Tag ID 枚举
|
||||
* 用于标识不同体育项目在 Polymarket 中的 tag ID
|
||||
*/
|
||||
enum class SportsTagId(val tagId: String, val displayName: String) {
|
||||
/**
|
||||
* 美国职业篮球联赛
|
||||
*/
|
||||
NBA("745", "NBA"),
|
||||
|
||||
/**
|
||||
* 美国职业棒球大联盟
|
||||
*/
|
||||
MLB("100381", "MLB"),
|
||||
|
||||
/**
|
||||
* 美国国家橄榄球联盟
|
||||
*/
|
||||
NFL("450", "NFL"),
|
||||
|
||||
/**
|
||||
* 美国大学橄榄球
|
||||
*/
|
||||
CFB("100351", "CFB"),
|
||||
|
||||
/**
|
||||
* 美国国家冰球联盟
|
||||
*/
|
||||
NHL("899", "NHL"),
|
||||
|
||||
/**
|
||||
* 游戏/电子竞技
|
||||
*/
|
||||
GAMES("100639", "GAMES"),
|
||||
|
||||
/**
|
||||
* 美国大学篮球
|
||||
*/
|
||||
CBB("101178", "CBB");
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* 根据 tag ID 查找枚举
|
||||
*/
|
||||
fun fromTagId(tagId: String): SportsTagId? {
|
||||
return values().find { it.tagId == tagId }
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据显示名称查找枚举
|
||||
*/
|
||||
fun fromDisplayName(displayName: String): SportsTagId? {
|
||||
return values().find { it.displayName.equals(displayName, ignoreCase = true) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有 tag IDs 列表
|
||||
*/
|
||||
fun getAllTagIds(): List<String> {
|
||||
return values().map { it.tagId }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+41
@@ -61,5 +61,46 @@ interface CopyOrderTrackingRepository : JpaRepository<CopyOrderTracking, Long> {
|
||||
*/
|
||||
@Query("SELECT t FROM CopyOrderTracking t WHERE t.createdAt <= :beforeTime")
|
||||
fun findByCreatedAtBefore(beforeTime: Long): List<CopyOrderTracking>
|
||||
|
||||
/**
|
||||
* 查询指定时间之前创建且状态不为指定状态的订单
|
||||
*/
|
||||
fun findByCreatedAtBeforeAndStatusNot(beforeTime: Long, status: String): List<CopyOrderTracking>
|
||||
|
||||
/**
|
||||
* 查询指定跟单配置下的活跃仓位数量
|
||||
* 活跃仓位定义为 remainingQuantity > 0 的不同 (marketId, outcomeIndex) 组合
|
||||
*/
|
||||
@Query("SELECT COUNT(DISTINCT CONCAT(t.marketId, '_', COALESCE(t.outcomeIndex, -1))) FROM CopyOrderTracking t WHERE t.copyTradingId = :copyTradingId AND t.remainingQuantity > 0")
|
||||
fun countActivePositions(copyTradingId: Long): Int
|
||||
|
||||
/**
|
||||
* 检查指定市场是否存在活跃仓位
|
||||
*/
|
||||
fun existsByCopyTradingIdAndMarketIdAndRemainingQuantityGreaterThan(copyTradingId: Long, marketId: String, remainingQuantity: BigDecimal): Boolean
|
||||
|
||||
/**
|
||||
* 计算指定跟单配置和市场下的当前持仓总价值 (成本价计算)
|
||||
*/
|
||||
@Query("SELECT SUM(t.remainingQuantity * t.price) FROM CopyOrderTracking t WHERE t.copyTradingId = :copyTradingId AND t.marketId = :marketId AND t.remainingQuantity > 0")
|
||||
fun sumCurrentPositionValueByMarket(copyTradingId: Long, marketId: String): BigDecimal?
|
||||
|
||||
/**
|
||||
* 查询指定跟单配置下,创建时间超过指定时间点的未匹配订单(FIFO顺序)
|
||||
* 用于避免刚创建的订单被误判为已卖出
|
||||
*
|
||||
* @param copyTradingId 跟单配置ID
|
||||
* @param marketId 市场ID
|
||||
* @param outcomeIndex 结果索引
|
||||
* @param thresholdTime 时间阈值(毫秒时间戳),只查询创建时间小于该值的订单
|
||||
* @return 未匹配订单列表(按创建时间升序排列)
|
||||
*/
|
||||
@Query("SELECT t FROM CopyOrderTracking t WHERE t.copyTradingId = :copyTradingId AND t.marketId = :marketId AND t.outcomeIndex = :outcomeIndex AND t.remainingQuantity > 0 AND t.createdAt < :thresholdTime ORDER BY t.createdAt ASC")
|
||||
fun findUnmatchedBuyOrdersByOutcomeIndexOlderThan(
|
||||
copyTradingId: Long,
|
||||
marketId: String,
|
||||
outcomeIndex: Int,
|
||||
thresholdTime: Long
|
||||
): List<CopyOrderTracking>
|
||||
}
|
||||
|
||||
|
||||
@@ -21,12 +21,12 @@ interface CopyTradingRepository : JpaRepository<CopyTrading, Long> {
|
||||
fun findByLeaderId(leaderId: Long): List<CopyTrading>
|
||||
|
||||
/**
|
||||
* 根据账户ID和Leader ID查找跟单
|
||||
* 根据账户ID和Leader ID查找跟单列表
|
||||
*/
|
||||
fun findByAccountIdAndLeaderId(
|
||||
accountId: Long,
|
||||
leaderId: Long
|
||||
): CopyTrading?
|
||||
): List<CopyTrading>
|
||||
|
||||
/**
|
||||
* 查找所有启用的跟单
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.wrbug.polymarketbot.repository
|
||||
|
||||
import com.wrbug.polymarketbot.entity.Market
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
@Repository
|
||||
interface MarketRepository : JpaRepository<Market, Long> {
|
||||
fun findByMarketId(marketId: String): Market?
|
||||
fun findByMarketIdIn(marketIds: List<String>): List<Market>
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
package com.wrbug.polymarketbot.repository
|
||||
|
||||
import com.wrbug.polymarketbot.entity.NbaGame
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.time.LocalDate
|
||||
|
||||
@Repository
|
||||
interface NbaGameRepository : JpaRepository<NbaGame, Long> {
|
||||
fun findByNbaGameId(nbaGameId: String): NbaGame?
|
||||
fun findByGameDate(gameDate: LocalDate): List<NbaGame>
|
||||
fun findByGameDateBetween(startDate: LocalDate, endDate: LocalDate): List<NbaGame>
|
||||
fun findByGameStatus(gameStatus: String): List<NbaGame>
|
||||
fun findByHomeTeamAndAwayTeamAndGameDate(homeTeam: String, awayTeam: String, gameDate: LocalDate): NbaGame?
|
||||
fun findByPolymarketMarketId(polymarketMarketId: String): NbaGame?
|
||||
|
||||
/**
|
||||
* 查询最新的比赛(按创建时间倒序)
|
||||
*/
|
||||
fun findFirstByOrderByCreatedAtDesc(): NbaGame?
|
||||
|
||||
/**
|
||||
* 根据创建时间查询比赛
|
||||
*/
|
||||
fun findByCreatedAtGreaterThan(createdAt: Long): List<NbaGame>
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
package com.wrbug.polymarketbot.repository
|
||||
|
||||
import com.wrbug.polymarketbot.entity.NbaMarket
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
@Repository
|
||||
interface NbaMarketRepository : JpaRepository<NbaMarket, Long> {
|
||||
fun findByConditionId(conditionId: String): NbaMarket?
|
||||
fun findByPolymarketMarketId(polymarketMarketId: String): NbaMarket?
|
||||
fun findByActiveAndClosed(active: Boolean, closed: Boolean): List<NbaMarket>
|
||||
fun findByCategory(category: String): List<NbaMarket>
|
||||
fun findByActive(active: Boolean): List<NbaMarket>
|
||||
}
|
||||
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
package com.wrbug.polymarketbot.repository
|
||||
|
||||
import com.wrbug.polymarketbot.entity.NbaQuantitativeStrategy
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
@Repository
|
||||
interface NbaQuantitativeStrategyRepository : JpaRepository<NbaQuantitativeStrategy, Long> {
|
||||
fun findByAccountId(accountId: Long): List<NbaQuantitativeStrategy>
|
||||
fun findByAccountIdAndEnabled(accountId: Long, enabled: Boolean): List<NbaQuantitativeStrategy>
|
||||
fun findByEnabled(enabled: Boolean): List<NbaQuantitativeStrategy>
|
||||
fun findByStrategyName(strategyName: String): NbaQuantitativeStrategy?
|
||||
}
|
||||
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
package com.wrbug.polymarketbot.repository
|
||||
|
||||
import com.wrbug.polymarketbot.entity.NbaStrategyStatistics
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.time.LocalDate
|
||||
|
||||
@Repository
|
||||
interface NbaStrategyStatisticsRepository : JpaRepository<NbaStrategyStatistics, Long> {
|
||||
fun findByStrategyId(strategyId: Long): List<NbaStrategyStatistics>
|
||||
fun findByStrategyIdAndStatDate(strategyId: Long, statDate: LocalDate): NbaStrategyStatistics?
|
||||
fun findByStrategyIdAndStatDateBetween(strategyId: Long, startDate: LocalDate, endDate: LocalDate): List<NbaStrategyStatistics>
|
||||
}
|
||||
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
package com.wrbug.polymarketbot.repository
|
||||
|
||||
import com.wrbug.polymarketbot.entity.NbaTradingSignal
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.time.Instant
|
||||
|
||||
@Repository
|
||||
interface NbaTradingSignalRepository : JpaRepository<NbaTradingSignal, Long> {
|
||||
fun findByStrategyId(strategyId: Long): List<NbaTradingSignal>
|
||||
fun findByGameId(gameId: Long): List<NbaTradingSignal>
|
||||
fun findByMarketId(marketId: Long): List<NbaTradingSignal>
|
||||
fun findBySignalType(signalType: String): List<NbaTradingSignal>
|
||||
fun findBySignalStatus(signalStatus: String): List<NbaTradingSignal>
|
||||
fun findByStrategyIdAndSignalType(strategyId: Long, signalType: String): List<NbaTradingSignal>
|
||||
fun findByStrategyIdAndCreatedAtBetween(strategyId: Long, startTime: Long, endTime: Long): List<NbaTradingSignal>
|
||||
}
|
||||
|
||||
+62
-142
@@ -8,8 +8,10 @@ import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import com.wrbug.polymarketbot.util.eq
|
||||
import com.wrbug.polymarketbot.util.JsonUtils
|
||||
import com.wrbug.polymarketbot.util.getEventSlug
|
||||
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
||||
import com.wrbug.polymarketbot.service.common.BlockchainService
|
||||
import com.wrbug.polymarketbot.service.common.MarketService
|
||||
import com.wrbug.polymarketbot.service.common.PolymarketApiKeyService
|
||||
import com.wrbug.polymarketbot.service.copytrading.orders.OrderPushService
|
||||
import com.wrbug.polymarketbot.service.copytrading.orders.OrderSigningService
|
||||
@@ -36,6 +38,7 @@ class AccountService(
|
||||
private val orderPushService: OrderPushService,
|
||||
private val orderSigningService: OrderSigningService,
|
||||
private val cryptoUtils: CryptoUtils,
|
||||
private val marketService: MarketService, // 市场信息服务
|
||||
private val telegramNotificationService: TelegramNotificationService? = null, // 可选,避免循环依赖
|
||||
private val relayClientService: RelayClientService,
|
||||
private val jsonUtils: JsonUtils
|
||||
@@ -202,85 +205,6 @@ class AccountService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新账户的代理地址
|
||||
* 使用最新的代理地址计算逻辑(支持 Magic 和 Safe 两种类型)
|
||||
*/
|
||||
@Transactional
|
||||
fun refreshProxyAddress(accountId: Long): Result<AccountDto> {
|
||||
return try {
|
||||
val account = accountRepository.findById(accountId)
|
||||
.orElse(null) ?: return Result.failure(IllegalArgumentException("账户不存在"))
|
||||
|
||||
// 重新获取代理地址(使用保存的钱包类型)
|
||||
val proxyAddress = runBlocking {
|
||||
val proxyResult = blockchainService.getProxyAddress(account.walletAddress, account.walletType)
|
||||
if (proxyResult.isSuccess) {
|
||||
proxyResult.getOrNull()
|
||||
?: throw IllegalStateException("获取代理地址返回空值")
|
||||
} else {
|
||||
val error = proxyResult.exceptionOrNull()
|
||||
throw IllegalStateException("获取代理地址失败: ${error?.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// 更新账户
|
||||
val updated = account.copy(
|
||||
proxyAddress = proxyAddress,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
val saved = accountRepository.save(updated)
|
||||
|
||||
logger.info("刷新代理地址成功: accountId=${accountId}, oldProxy=${account.proxyAddress}, newProxy=${proxyAddress}")
|
||||
Result.success(toDto(saved))
|
||||
} catch (e: Exception) {
|
||||
logger.error("刷新代理地址失败: accountId=${accountId}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新所有账户的代理地址
|
||||
*/
|
||||
@Transactional
|
||||
fun refreshAllProxyAddresses(): Result<List<AccountDto>> {
|
||||
return try {
|
||||
val accounts = accountRepository.findAll()
|
||||
val updatedAccounts = mutableListOf<AccountDto>()
|
||||
|
||||
accounts.forEach { account ->
|
||||
try {
|
||||
val proxyAddress = runBlocking {
|
||||
val proxyResult = blockchainService.getProxyAddress(account.walletAddress, account.walletType)
|
||||
if (proxyResult.isSuccess) {
|
||||
proxyResult.getOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
if (proxyAddress != null && proxyAddress != account.proxyAddress) {
|
||||
val updated = account.copy(
|
||||
proxyAddress = proxyAddress,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
val saved = accountRepository.save(updated)
|
||||
logger.info("刷新代理地址成功: accountId=${account.id}, oldProxy=${account.proxyAddress}, newProxy=${proxyAddress}")
|
||||
updatedAccounts.add(toDto(saved))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("刷新账户 ${account.id} 代理地址失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("批量刷新代理地址完成: 更新了 ${updatedAccounts.size} 个账户")
|
||||
Result.success(updatedAccounts)
|
||||
} catch (e: Exception) {
|
||||
logger.error("批量刷新代理地址失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除账户
|
||||
*/
|
||||
@@ -307,11 +231,12 @@ class AccountService(
|
||||
|
||||
/**
|
||||
* 查询账户列表
|
||||
* 列表接口只返回基本信息,不查询统计信息(统计信息只在详情接口中查询)
|
||||
*/
|
||||
fun getAccountList(): Result<AccountListResponse> {
|
||||
return try {
|
||||
val accounts = accountRepository.findAllByOrderByCreatedAtAsc()
|
||||
val accountDtos = accounts.map { toDto(it) }
|
||||
val accountDtos = accounts.map { toBasicDto(it) }
|
||||
|
||||
Result.success(
|
||||
AccountListResponse(
|
||||
@@ -432,7 +357,30 @@ class AccountService(
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为 DTO
|
||||
* 转换为基础 DTO(列表使用,不包含统计信息)
|
||||
* 列表接口只返回基本信息,不查询统计信息,以提高性能
|
||||
*/
|
||||
private fun toBasicDto(account: Account): AccountDto {
|
||||
return AccountDto(
|
||||
id = account.id!!,
|
||||
walletAddress = account.walletAddress,
|
||||
proxyAddress = account.proxyAddress,
|
||||
accountName = account.accountName,
|
||||
isEnabled = account.isEnabled,
|
||||
walletType = account.walletType,
|
||||
apiKeyConfigured = account.apiKey != null,
|
||||
apiSecretConfigured = account.apiSecret != null,
|
||||
apiPassphraseConfigured = account.apiPassphrase != null,
|
||||
totalOrders = null,
|
||||
totalPnl = null,
|
||||
activeOrders = null,
|
||||
completedOrders = null,
|
||||
positionCount = null
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为完整 DTO(详情使用,包含交易统计数据)
|
||||
* 包含交易统计数据(总订单数、总盈亏、活跃订单数、已完成订单数、持仓数量)
|
||||
*/
|
||||
private fun toDto(account: Account): AccountDto {
|
||||
@@ -711,7 +659,8 @@ class AccountService(
|
||||
proxyAddress = account.proxyAddress,
|
||||
marketId = pos.conditionId ?: "",
|
||||
marketTitle = pos.title ?: "",
|
||||
marketSlug = pos.slug ?: "",
|
||||
marketSlug = pos.slug ?: "", // 显示用的 slug
|
||||
eventSlug = pos.eventSlug, // 跳转用的 slug(从 events[0].slug 获取)
|
||||
marketIcon = pos.icon, // 市场图标
|
||||
side = pos.outcome ?: "",
|
||||
outcomeIndex = pos.outcomeIndex, // 添加 outcomeIndex
|
||||
@@ -918,6 +867,15 @@ class AccountService(
|
||||
// 7. 解密私钥
|
||||
val decryptedPrivateKey = decryptPrivateKey(account)
|
||||
|
||||
// 获取费率(根据 Polymarket Maker Rebates Program 要求)
|
||||
val feeRateResult = clobService.getFeeRate(tokenId)
|
||||
val feeRateBps = if (feeRateResult.isSuccess) {
|
||||
feeRateResult.getOrNull()?.toString() ?: "0"
|
||||
} else {
|
||||
logger.warn("获取费率失败,使用默认值 0: tokenId=$tokenId, error=${feeRateResult.exceptionOrNull()?.message}")
|
||||
"0"
|
||||
}
|
||||
|
||||
// 11. 创建并签名订单(使用计算后的卖出数量)
|
||||
val signedOrder = try {
|
||||
orderSigningService.createAndSignOrder(
|
||||
@@ -929,7 +887,7 @@ class AccountService(
|
||||
size = sellQuantity.toPlainString(), // 使用计算后的卖出数量
|
||||
signatureType = 2, // Browser Wallet(与正确订单数据一致)
|
||||
nonce = "0",
|
||||
feeRateBps = "0",
|
||||
feeRateBps = feeRateBps, // 使用动态获取的费率
|
||||
expiration = expiration
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
@@ -979,31 +937,20 @@ class AccountService(
|
||||
notificationScope.launch {
|
||||
try {
|
||||
// 获取市场信息(标题和slug)
|
||||
val marketInfo = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val gammaApi = retrofitFactory.createGammaApi()
|
||||
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(request.marketId))
|
||||
if (marketResponse.isSuccessful && marketResponse.body() != null) {
|
||||
marketResponse.body()!!.firstOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取市场信息失败: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val marketTitle = marketInfo?.question ?: request.marketId
|
||||
val marketSlug = marketInfo?.slug
|
||||
|
||||
val market = marketService.getMarket(request.marketId)
|
||||
val marketTitle = market?.title ?: request.marketId
|
||||
val marketSlug = market?.eventSlug // 跳转用的 slug
|
||||
|
||||
// 获取当前语言设置(从 LocaleContextHolder)
|
||||
val locale = try {
|
||||
org.springframework.context.i18n.LocaleContextHolder.getLocale()
|
||||
} catch (e: Exception) {
|
||||
java.util.Locale("zh", "CN") // 默认简体中文
|
||||
}
|
||||
|
||||
|
||||
// 使用当前时间作为订单创建时间
|
||||
val orderTime = System.currentTimeMillis()
|
||||
|
||||
telegramNotificationService?.sendOrderSuccessNotification(
|
||||
orderId = orderId,
|
||||
marketTitle = marketTitle,
|
||||
@@ -1019,7 +966,8 @@ class AccountService(
|
||||
apiSecret = try { cryptoUtils.decrypt(account.apiSecret!!) } catch (e: Exception) { null },
|
||||
apiPassphrase = try { cryptoUtils.decrypt(account.apiPassphrase!!) } catch (e: Exception) { null },
|
||||
walletAddressForApi = account.walletAddress,
|
||||
locale = locale
|
||||
locale = locale,
|
||||
orderTime = orderTime // 使用订单创建时间
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("发送订单成功通知失败: ${e.message}", e)
|
||||
@@ -1047,31 +995,17 @@ class AccountService(
|
||||
notificationScope.launch {
|
||||
try {
|
||||
// 获取市场信息(标题和slug)
|
||||
val marketInfo = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val gammaApi = retrofitFactory.createGammaApi()
|
||||
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(request.marketId))
|
||||
if (marketResponse.isSuccessful && marketResponse.body() != null) {
|
||||
marketResponse.body()!!.firstOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取市场信息失败: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val marketTitle = marketInfo?.question ?: request.marketId
|
||||
val marketSlug = marketInfo?.slug
|
||||
|
||||
val market = marketService.getMarket(request.marketId)
|
||||
val marketTitle = market?.title ?: request.marketId
|
||||
val marketSlug = market?.eventSlug // 跳转用的 slug
|
||||
|
||||
// 获取当前语言设置(从 LocaleContextHolder)
|
||||
val locale = try {
|
||||
org.springframework.context.i18n.LocaleContextHolder.getLocale()
|
||||
} catch (e: Exception) {
|
||||
java.util.Locale("zh", "CN") // 默认简体中文
|
||||
}
|
||||
|
||||
|
||||
telegramNotificationService?.sendOrderFailureNotification(
|
||||
marketTitle = marketTitle,
|
||||
marketId = request.marketId,
|
||||
@@ -1105,34 +1039,20 @@ class AccountService(
|
||||
notificationScope.launch {
|
||||
try {
|
||||
// 获取市场信息(标题和slug)
|
||||
val marketInfo = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val gammaApi = retrofitFactory.createGammaApi()
|
||||
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(request.marketId))
|
||||
if (marketResponse.isSuccessful && marketResponse.body() != null) {
|
||||
marketResponse.body()!!.firstOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取市场信息失败: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val marketTitle = marketInfo?.question ?: request.marketId
|
||||
val marketSlug = marketInfo?.slug
|
||||
|
||||
val market = marketService.getMarket(request.marketId)
|
||||
val marketTitle = market?.title ?: request.marketId
|
||||
val marketSlug = market?.eventSlug // 跳转用的 slug
|
||||
|
||||
// 获取当前语言设置(从 LocaleContextHolder)
|
||||
val locale = try {
|
||||
org.springframework.context.i18n.LocaleContextHolder.getLocale()
|
||||
} catch (e: Exception) {
|
||||
java.util.Locale("zh", "CN") // 默认简体中文
|
||||
}
|
||||
|
||||
|
||||
// 只传递后端返回的 msg,不传递完整堆栈
|
||||
val errorMsg = orderResponse.body()?.errorMsg ?: "创建订单失败"
|
||||
|
||||
|
||||
telegramNotificationService?.sendOrderFailureNotification(
|
||||
marketTitle = marketTitle,
|
||||
marketId = request.marketId,
|
||||
|
||||
+212
-26
@@ -62,6 +62,19 @@ class PositionCheckService(
|
||||
// 记录已发送提示的配置(避免重复推送)
|
||||
private val notifiedConfigs = ConcurrentHashMap<Long, Long>() // accountId/copyTradingId -> lastNotificationTime
|
||||
|
||||
// 待检查的仓位记录(延迟检测机制)
|
||||
// key: "accountId_marketId_outcomeIndex_copyTradingId"
|
||||
// value: PendingPositionCheck(包含订单列表和首次检测时间)
|
||||
private data class PendingPositionCheck(
|
||||
val accountId: Long,
|
||||
val marketId: String,
|
||||
val outcomeIndex: Int,
|
||||
val copyTradingId: Long,
|
||||
val orders: List<CopyOrderTracking>,
|
||||
val firstDetectedTime: Long // 首次检测到仓位不存在的时间
|
||||
)
|
||||
private val pendingPositionChecks = ConcurrentHashMap<String, PendingPositionCheck>()
|
||||
|
||||
// 同步锁,确保订阅任务的启动和停止是线程安全的
|
||||
private val lock = Any()
|
||||
|
||||
@@ -73,6 +86,7 @@ class PositionCheckService(
|
||||
logger.info("PositionCheckService 初始化,订阅仓位轮训事件")
|
||||
startSubscription()
|
||||
startCacheCleanup()
|
||||
startPendingPositionCheckTask()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,6 +146,121 @@ class PositionCheckService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动待检查仓位的定期检查任务
|
||||
* 每30秒检查一次,如果超过3分钟且确实不存在,则标记为已卖出
|
||||
*/
|
||||
private fun startPendingPositionCheckTask() {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
while (isActive) {
|
||||
try {
|
||||
delay(30000) // 每30秒检查一次
|
||||
checkPendingPositions()
|
||||
} catch (e: Exception) {
|
||||
logger.error("检查待检查仓位异常: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查待检查的仓位
|
||||
* 如果超过3分钟且确实不存在,则标记为已卖出
|
||||
* 如果存在,则删除记录
|
||||
*/
|
||||
private suspend fun checkPendingPositions() {
|
||||
if (pendingPositionChecks.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取最新的仓位数据
|
||||
val result = accountService.getAllPositions()
|
||||
if (result.isFailure) {
|
||||
logger.warn("获取仓位数据失败,跳过待检查仓位验证: ${result.exceptionOrNull()?.message}")
|
||||
return
|
||||
}
|
||||
|
||||
val positionListResponse = result.getOrNull() ?: return
|
||||
val currentPositions = positionListResponse.currentPositions
|
||||
|
||||
// 按账户和市场分组当前仓位
|
||||
val positionsByAccountAndMarket = currentPositions.groupBy {
|
||||
"${it.accountId}_${it.marketId}_${it.outcomeIndex ?: 0}"
|
||||
}
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val checkDelay = 180000L // 3分钟 = 180000毫秒
|
||||
val toRemove = mutableListOf<String>()
|
||||
val toMarkAsSold = mutableListOf<PendingPositionCheck>()
|
||||
|
||||
// 遍历所有待检查的仓位
|
||||
for ((key, pendingCheck) in pendingPositionChecks) {
|
||||
// 先过滤出仍然有效的订单(remainingQuantity > 0)
|
||||
val validOrders = pendingCheck.orders.filter { order ->
|
||||
// 重新从数据库查询订单状态,确保数据是最新的
|
||||
val currentOrder = copyOrderTrackingRepository.findById(order.id!!).orElse(null)
|
||||
currentOrder != null && currentOrder.remainingQuantity > BigDecimal.ZERO
|
||||
}
|
||||
|
||||
// 如果没有有效订单了,删除记录
|
||||
if (validOrders.isEmpty()) {
|
||||
toRemove.add(key)
|
||||
logger.info("待检查仓位的订单已全部处理,删除记录: marketId=${pendingCheck.marketId}, outcomeIndex=${pendingCheck.outcomeIndex}, accountId=${pendingCheck.accountId}, copyTradingId=${pendingCheck.copyTradingId}")
|
||||
continue
|
||||
}
|
||||
|
||||
val positionKey = "${pendingCheck.accountId}_${pendingCheck.marketId}_${pendingCheck.outcomeIndex}"
|
||||
val position = positionsByAccountAndMarket[positionKey]?.firstOrNull()
|
||||
|
||||
if (position != null) {
|
||||
// 仓位存在,删除记录
|
||||
toRemove.add(key)
|
||||
logger.info("待检查仓位已恢复,删除记录: marketId=${pendingCheck.marketId}, outcomeIndex=${pendingCheck.outcomeIndex}, accountId=${pendingCheck.accountId}, copyTradingId=${pendingCheck.copyTradingId}, elapsedTime=${now - pendingCheck.firstDetectedTime}ms")
|
||||
} else {
|
||||
// 仓位不存在,检查是否超过3分钟
|
||||
val elapsedTime = now - pendingCheck.firstDetectedTime
|
||||
if (elapsedTime >= checkDelay) {
|
||||
// 超过3分钟且确实不存在,标记为已卖出(使用有效订单)
|
||||
toMarkAsSold.add(pendingCheck.copy(orders = validOrders))
|
||||
toRemove.add(key)
|
||||
logger.info("待检查仓位超过3分钟仍不存在,标记为已卖出: marketId=${pendingCheck.marketId}, outcomeIndex=${pendingCheck.outcomeIndex}, accountId=${pendingCheck.accountId}, copyTradingId=${pendingCheck.copyTradingId}, elapsedTime=${elapsedTime}ms, validOrderCount=${validOrders.size}, originalOrderCount=${pendingCheck.orders.size}")
|
||||
} else {
|
||||
// 未超过3分钟,更新订单列表(移除已处理的订单)
|
||||
if (validOrders.size < pendingCheck.orders.size) {
|
||||
pendingPositionChecks[key] = pendingCheck.copy(orders = validOrders)
|
||||
logger.debug("更新待检查仓位记录,移除已处理的订单: marketId=${pendingCheck.marketId}, outcomeIndex=${pendingCheck.outcomeIndex}, validOrderCount=${validOrders.size}, originalOrderCount=${pendingCheck.orders.size}")
|
||||
}
|
||||
logger.debug("待检查仓位仍不存在,继续等待: marketId=${pendingCheck.marketId}, outcomeIndex=${pendingCheck.outcomeIndex}, accountId=${pendingCheck.accountId}, copyTradingId=${pendingCheck.copyTradingId}, elapsedTime=${elapsedTime}ms, remainingTime=${checkDelay - elapsedTime}ms")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除已恢复或已处理的记录
|
||||
toRemove.forEach { key ->
|
||||
pendingPositionChecks.remove(key)
|
||||
}
|
||||
|
||||
// 标记为已卖出
|
||||
for (pendingCheck in toMarkAsSold) {
|
||||
try {
|
||||
val currentPrice = getCurrentMarketPrice(pendingCheck.marketId, pendingCheck.outcomeIndex)
|
||||
updateOrdersAsSold(
|
||||
pendingCheck.orders,
|
||||
currentPrice,
|
||||
pendingCheck.copyTradingId,
|
||||
pendingCheck.marketId,
|
||||
pendingCheck.outcomeIndex
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("标记待检查仓位为已卖出失败: marketId=${pendingCheck.marketId}, outcomeIndex=${pendingCheck.outcomeIndex}, error=${e.message}", e)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("检查待检查仓位异常: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期的缓存条目(超过2小时的记录)
|
||||
*/
|
||||
@@ -163,8 +292,16 @@ class PositionCheckService(
|
||||
notifiedConfigs.remove(key)
|
||||
}
|
||||
|
||||
if (expiredPositions.isNotEmpty() || expiredProcessed.isNotEmpty() || expiredConfigs.isNotEmpty()) {
|
||||
logger.debug("清理过期缓存: positions=${expiredPositions.size}, processed=${expiredProcessed.size}, configs=${expiredConfigs.size}")
|
||||
// 清理过期的待检查仓位记录(超过1小时的记录,正常情况下应该在3分钟内处理完)
|
||||
val expiredPendingChecks = pendingPositionChecks.entries.filter { (_, check) ->
|
||||
(now - check.firstDetectedTime) > 3600000 // 1小时
|
||||
}
|
||||
expiredPendingChecks.forEach { (key, _) ->
|
||||
pendingPositionChecks.remove(key)
|
||||
}
|
||||
|
||||
if (expiredPositions.isNotEmpty() || expiredProcessed.isNotEmpty() || expiredConfigs.isNotEmpty() || expiredPendingChecks.isNotEmpty()) {
|
||||
logger.debug("清理过期缓存: positions=${expiredPositions.size}, processed=${expiredProcessed.size}, configs=${expiredConfigs.size}, pendingChecks=${expiredPendingChecks.size}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,51 +508,100 @@ class PositionCheckService(
|
||||
val position = positionsByAccountAndMarket[positionKey]?.firstOrNull()
|
||||
|
||||
if (position == null) {
|
||||
// 仓位不存在,检查订单创建时间
|
||||
// 只有当订单创建时间超过2分钟时,才认为仓位被出售了
|
||||
// 这样可以避免刚创建的订单因为API延迟而被误判为已卖出
|
||||
// 仓位不存在,使用延迟检测机制
|
||||
// 先查询创建时间超过2分钟的未匹配订单(SQL层过滤,避免刚创建的订单被误判)
|
||||
val now = System.currentTimeMillis()
|
||||
val ordersToMarkAsSold = orders.filter { order ->
|
||||
val orderAge = now - order.createdAt
|
||||
orderAge > 120000 // 2分钟 = 120000毫秒
|
||||
}
|
||||
|
||||
if (ordersToMarkAsSold.isNotEmpty()) {
|
||||
// 有订单创建时间超过2分钟,认为仓位已被出售
|
||||
val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex)
|
||||
updateOrdersAsSold(ordersToMarkAsSold, currentPrice, copyTrading.id, marketId, outcomeIndex)
|
||||
logger.debug("仓位不存在且订单创建时间超过2分钟,标记为已卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, orderCount=${ordersToMarkAsSold.size}")
|
||||
val thresholdTime = now - 120000 // 2分钟 = 120000毫秒
|
||||
|
||||
val ordersToCheck = copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndexOlderThan(
|
||||
copyTradingId = copyTrading.id!!,
|
||||
marketId = marketId,
|
||||
outcomeIndex = outcomeIndex,
|
||||
thresholdTime = thresholdTime
|
||||
)
|
||||
|
||||
if (ordersToCheck.isNotEmpty()) {
|
||||
// 有订单创建时间超过2分钟,记录到待检查列表
|
||||
val checkKey = "${copyTrading.accountId}_${marketId}_${outcomeIndex}_${copyTrading.id}"
|
||||
|
||||
// 如果已经存在记录,更新订单列表(可能订单状态有变化)
|
||||
val existingCheck = pendingPositionChecks[checkKey]
|
||||
if (existingCheck == null) {
|
||||
// 首次检测到,记录
|
||||
pendingPositionChecks[checkKey] = PendingPositionCheck(
|
||||
accountId = copyTrading.accountId,
|
||||
marketId = marketId,
|
||||
outcomeIndex = outcomeIndex,
|
||||
copyTradingId = copyTrading.id!!,
|
||||
orders = ordersToCheck,
|
||||
firstDetectedTime = now
|
||||
)
|
||||
logger.info("首次检测到仓位不存在,记录待检查: marketId=$marketId, outcomeIndex=$outcomeIndex, accountId=${copyTrading.accountId}, copyTradingId=${copyTrading.id}, orderCount=${ordersToCheck.size}, positionKey=$positionKey")
|
||||
} else {
|
||||
// 已存在记录,更新订单列表(可能订单状态有变化)
|
||||
pendingPositionChecks[checkKey] = existingCheck.copy(orders = ordersToCheck)
|
||||
logger.debug("更新待检查仓位记录: marketId=$marketId, outcomeIndex=$outcomeIndex, accountId=${copyTrading.accountId}, copyTradingId=${copyTrading.id}, orderCount=${ordersToCheck.size}, elapsedTime=${now - existingCheck.firstDetectedTime}ms")
|
||||
}
|
||||
} else {
|
||||
// 订单创建时间不足2分钟,可能是刚创建的订单,暂时不处理
|
||||
logger.debug("仓位不存在但订单创建时间不足2分钟,暂不标记为已卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, orderCount=${orders.size}, oldestOrderAge=${orders.minOfOrNull { now - it.createdAt }?.let { "${it}ms" } ?: "N/A"}")
|
||||
logger.debug("仓位不存在但无符合条件的订单(创建时间不足2分钟),暂不标记为已卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, orderCount=${orders.size}, thresholdTime=$thresholdTime, positionKey=$positionKey")
|
||||
}
|
||||
} else {
|
||||
// 有仓位,先检查是否有对应的待检查记录,如果有则删除(仓位已恢复)
|
||||
val checkKey = "${copyTrading.accountId}_${marketId}_${outcomeIndex}_${copyTrading.id}"
|
||||
val pendingCheck = pendingPositionChecks.remove(checkKey)
|
||||
if (pendingCheck != null) {
|
||||
logger.info("待检查仓位已恢复,删除待检查记录: marketId=$marketId, outcomeIndex=$outcomeIndex, accountId=${copyTrading.accountId}, copyTradingId=${copyTrading.id}, elapsedTime=${System.currentTimeMillis() - pendingCheck.firstDetectedTime}ms")
|
||||
}
|
||||
|
||||
// 有仓位,按订单下单顺序(FIFO)更新状态
|
||||
// 先查询创建时间超过2分钟的未匹配订单(SQL层过滤,避免刚创建的订单被误判)
|
||||
val now = System.currentTimeMillis()
|
||||
val thresholdTime = now - 120000 // 2分钟 = 120000毫秒
|
||||
|
||||
val validOrders = copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndexOlderThan(
|
||||
copyTradingId = copyTrading.id!!,
|
||||
marketId = marketId,
|
||||
outcomeIndex = outcomeIndex,
|
||||
thresholdTime = thresholdTime
|
||||
)
|
||||
|
||||
// 如果没有符合条件的订单,跳过处理
|
||||
if (validOrders.isEmpty()) {
|
||||
logger.debug("仓位存在但无符合条件的订单(创建时间不足2分钟),暂不进行FIFO匹配: marketId=$marketId, outcomeIndex=$outcomeIndex, thresholdTime=$thresholdTime")
|
||||
continue
|
||||
}
|
||||
|
||||
// 计算逻辑:
|
||||
// 1. 总订单数量 = 所有未卖出订单的剩余数量总和
|
||||
// 1. 总订单数量 = 所有符合条件的未卖出订单的剩余数量总和
|
||||
// 2. 已成交数量 = 总订单数量 - 仓位数量(因为还有仓位,说明部分订单已卖出)
|
||||
// 3. 如果已成交数量 = 0,说明订单还没有卖出,不修改订单状态
|
||||
// 4. 如果已成交数量 > 0,按FIFO顺序匹配订单
|
||||
val positionQuantity = position.quantity.toSafeBigDecimal()
|
||||
|
||||
// 计算总订单数量
|
||||
val totalOrderQuantity = orders.fold(BigDecimal.ZERO) { sum, order ->
|
||||
|
||||
// 计算总订单数量(只计算符合条件的订单)
|
||||
val totalOrderQuantity = validOrders.fold(BigDecimal.ZERO) { sum, order ->
|
||||
sum.add(order.remainingQuantity.toSafeBigDecimal())
|
||||
}
|
||||
|
||||
|
||||
// 计算已成交数量
|
||||
val soldQuantity = totalOrderQuantity.subtract(positionQuantity)
|
||||
|
||||
|
||||
// 如果已成交数量 <= 0,说明订单还没有卖出,不修改订单状态
|
||||
if (soldQuantity <= BigDecimal.ZERO) {
|
||||
logger.debug("仓位数量 >= 订单数量总和,订单尚未卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, positionQuantity=$positionQuantity, totalOrderQuantity=$totalOrderQuantity")
|
||||
continue
|
||||
}
|
||||
|
||||
// 如果已成交数量 > 0,按FIFO顺序匹配订单
|
||||
|
||||
// 如果已成交数量 > 0,按FIFO顺序匹配订单(只匹配符合条件的订单)
|
||||
try {
|
||||
val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex)
|
||||
updateOrdersAsSoldByFIFO(orders, soldQuantity, currentPrice,
|
||||
updateOrdersAsSoldByFIFO(validOrders, soldQuantity, currentPrice,
|
||||
copyTrading.id, marketId, outcomeIndex)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("无法获取市场价格,跳过FIFO匹配: marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}")
|
||||
// 无法获取价格时,跳过该市场的处理,等待下次检查时再试
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+107
-20
@@ -715,7 +715,7 @@ class BlockchainService(
|
||||
|
||||
/**
|
||||
* 从链上查询市场条件(Condition)的结算结果
|
||||
* 通过调用 ConditionalTokens 合约的 getCondition 函数获取 payouts
|
||||
* 通过调用 ConditionalTokens 合约的 conditions mapping 和 payoutNumerators mapping
|
||||
*
|
||||
* @param conditionId 市场条件ID(bytes32,必须是 0x 开头的 66 位十六进制字符串)
|
||||
* @return Result<Pair<payoutDenominator, payouts>>
|
||||
@@ -734,44 +734,131 @@ class BlockchainService(
|
||||
|
||||
val rpcApi = polygonRpcApi
|
||||
|
||||
// 构建 getCondition(bytes32) 函数调用
|
||||
// 函数签名: getCondition(bytes32)
|
||||
val functionSelector = EthereumUtils.getFunctionSelector("getCondition(bytes32)")
|
||||
// 1. 调用 getOutcomeSlotCount(bytes32) 获取结果槽位数量
|
||||
// 函数签名: getOutcomeSlotCount(bytes32) returns (uint)
|
||||
val getOutcomeSlotCountSelector = EthereumUtils.getFunctionSelector("getOutcomeSlotCount(bytes32)")
|
||||
val encodedConditionId = EthereumUtils.encodeBytes32(conditionId)
|
||||
val data = functionSelector + encodedConditionId
|
||||
val outcomeSlotCountData = getOutcomeSlotCountSelector + encodedConditionId
|
||||
|
||||
// 构建 JSON-RPC 请求
|
||||
val rpcRequest = JsonRpcRequest(
|
||||
val outcomeSlotCountRequest = JsonRpcRequest(
|
||||
method = "eth_call",
|
||||
params = listOf(
|
||||
mapOf(
|
||||
"to" to conditionalTokensAddress,
|
||||
"data" to data
|
||||
"data" to outcomeSlotCountData
|
||||
),
|
||||
"latest"
|
||||
)
|
||||
)
|
||||
|
||||
// 发送 RPC 请求
|
||||
val response = rpcApi.call(rpcRequest)
|
||||
val outcomeSlotCountResponse = rpcApi.call(outcomeSlotCountRequest)
|
||||
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
return Result.failure(Exception("RPC 请求失败: ${response.code()} ${response.message()}"))
|
||||
if (!outcomeSlotCountResponse.isSuccessful || outcomeSlotCountResponse.body() == null) {
|
||||
return Result.failure(Exception("RPC 请求失败 (getOutcomeSlotCount): ${outcomeSlotCountResponse.code()} ${outcomeSlotCountResponse.message()}"))
|
||||
}
|
||||
|
||||
val rpcResponse = response.body()!!
|
||||
val outcomeSlotCountRpcResponse = outcomeSlotCountResponse.body()!!
|
||||
|
||||
// 检查错误
|
||||
if (rpcResponse.error != null) {
|
||||
return Result.failure(Exception("RPC 错误: ${rpcResponse.error.message}"))
|
||||
if (outcomeSlotCountRpcResponse.error != null) {
|
||||
val errorMsg = "RPC 错误 (code=${outcomeSlotCountRpcResponse.error.code}): ${outcomeSlotCountRpcResponse.error.message}, data=${outcomeSlotCountRpcResponse.error.data}"
|
||||
logger.warn("查询市场条件(getOutcomeSlotCount)出现RPC错误: conditionId=$conditionId, $errorMsg")
|
||||
logger.debug("RPC 请求详情: to=$conditionalTokensAddress, data=$outcomeSlotCountData")
|
||||
return Result.failure(Exception(errorMsg))
|
||||
}
|
||||
|
||||
// 使用 Gson 解析 result(JsonElement)
|
||||
val hexResult = rpcResponse.result?.asString
|
||||
val outcomeSlotCountHex = outcomeSlotCountRpcResponse.result?.asString
|
||||
?: return Result.failure(Exception("RPC 响应格式错误: result 为空"))
|
||||
|
||||
// 解析 ABI 编码的返回结果
|
||||
val (payoutDenominator, payouts) = EthereumUtils.decodeConditionResult(hexResult)
|
||||
val outcomeSlotCount = EthereumUtils.decodeUint256(outcomeSlotCountHex).toInt()
|
||||
|
||||
// 如果 outcomeSlotCount 为 0,说明市场尚未创建或不存在
|
||||
if (outcomeSlotCount <= 0) {
|
||||
logger.debug("市场尚未创建或不存在: conditionId=$conditionId, outcomeSlotCount=$outcomeSlotCount")
|
||||
return Result.success(Pair(BigInteger.ZERO, emptyList()))
|
||||
}
|
||||
|
||||
// 2. 调用 payoutDenominator(bytes32) 获取分母
|
||||
// 函数签名: payoutDenominator(bytes32) returns (uint)
|
||||
val payoutDenominatorSelector = EthereumUtils.getFunctionSelector("payoutDenominator(bytes32)")
|
||||
val payoutDenominatorData = payoutDenominatorSelector + encodedConditionId
|
||||
|
||||
val payoutDenominatorRequest = JsonRpcRequest(
|
||||
method = "eth_call",
|
||||
params = listOf(
|
||||
mapOf(
|
||||
"to" to conditionalTokensAddress,
|
||||
"data" to payoutDenominatorData
|
||||
),
|
||||
"latest"
|
||||
)
|
||||
)
|
||||
|
||||
val payoutDenominatorResponse = rpcApi.call(payoutDenominatorRequest)
|
||||
|
||||
if (!payoutDenominatorResponse.isSuccessful || payoutDenominatorResponse.body() == null) {
|
||||
return Result.failure(Exception("RPC 请求失败 (payoutDenominator): ${payoutDenominatorResponse.code()} ${payoutDenominatorResponse.message()}"))
|
||||
}
|
||||
|
||||
val payoutDenominatorRpcResponse = payoutDenominatorResponse.body()!!
|
||||
|
||||
if (payoutDenominatorRpcResponse.error != null) {
|
||||
val errorMsg = "RPC 错误 (code=${payoutDenominatorRpcResponse.error.code}): ${payoutDenominatorRpcResponse.error.message}, data=${payoutDenominatorRpcResponse.error.data}"
|
||||
logger.warn("查询市场条件(payoutDenominator)出现RPC错误: conditionId=$conditionId, $errorMsg")
|
||||
logger.debug("RPC 请求详情: to=$conditionalTokensAddress, data=$payoutDenominatorData")
|
||||
return Result.failure(Exception(errorMsg))
|
||||
}
|
||||
|
||||
val payoutDenominatorHex = payoutDenominatorRpcResponse.result?.asString
|
||||
?: return Result.failure(Exception("RPC 响应格式错误: result 为空"))
|
||||
|
||||
val payoutDenominator = EthereumUtils.decodeUint256(payoutDenominatorHex)
|
||||
|
||||
// 如果 outcomeSlotCount 为 0,说明市场尚未创建或不存在
|
||||
if (outcomeSlotCount <= 0) {
|
||||
logger.debug("市场尚未创建或不存在: conditionId=$conditionId, outcomeSlotCount=$outcomeSlotCount")
|
||||
return Result.success(Pair(BigInteger.ZERO, emptyList()))
|
||||
}
|
||||
|
||||
// 如果 payoutDenominator 为 0,说明市场尚未结算
|
||||
if (payoutDenominator == BigInteger.ZERO) {
|
||||
logger.debug("市场尚未结算: conditionId=$conditionId, payoutDenominator=$payoutDenominator")
|
||||
return Result.success(Pair(BigInteger.ZERO, emptyList()))
|
||||
}
|
||||
|
||||
// 2. 查询每个 outcome 的 payoutNumerators
|
||||
val payouts = mutableListOf<BigInteger>()
|
||||
for (i in 0 until outcomeSlotCount) {
|
||||
val payoutNumeratorsFunctionSelector = EthereumUtils.getFunctionSelector("payoutNumerators(bytes32,uint256)")
|
||||
val encodedIndex = EthereumUtils.encodeUint256(BigInteger.valueOf(i.toLong()))
|
||||
val payoutNumeratorsData = payoutNumeratorsFunctionSelector + encodedConditionId + encodedIndex
|
||||
|
||||
val payoutRequest = JsonRpcRequest(
|
||||
method = "eth_call",
|
||||
params = listOf(
|
||||
mapOf(
|
||||
"to" to conditionalTokensAddress,
|
||||
"data" to payoutNumeratorsData
|
||||
),
|
||||
"latest"
|
||||
)
|
||||
)
|
||||
|
||||
val payoutResponse = rpcApi.call(payoutRequest)
|
||||
if (!payoutResponse.isSuccessful || payoutResponse.body() == null) {
|
||||
logger.warn("查询 payoutNumerators 失败: index=$i")
|
||||
continue
|
||||
}
|
||||
|
||||
val payoutRpcResponse = payoutResponse.body()!!
|
||||
if (payoutRpcResponse.error != null) {
|
||||
logger.warn("查询 payoutNumerators 错误: index=$i, error=${payoutRpcResponse.error.message}")
|
||||
continue
|
||||
}
|
||||
|
||||
val payoutHex = payoutRpcResponse.result?.asString ?: "0x0"
|
||||
val payout = EthereumUtils.decodeUint256(payoutHex)
|
||||
payouts.add(payout)
|
||||
}
|
||||
|
||||
Result.success(Pair(payoutDenominator, payouts))
|
||||
} catch (e: Exception) {
|
||||
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
package com.wrbug.polymarketbot.service.common
|
||||
|
||||
import com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository
|
||||
import jakarta.annotation.PostConstruct
|
||||
import jakarta.annotation.PreDestroy
|
||||
import kotlinx.coroutines.*
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
/**
|
||||
* 市场信息轮询服务
|
||||
* 定期检查买入订单的市场信息是否缺失,如果缺失则从API获取并更新
|
||||
*/
|
||||
@Service
|
||||
class MarketPollingService(
|
||||
private val copyOrderTrackingRepository: CopyOrderTrackingRepository,
|
||||
private val marketService: MarketService
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(MarketPollingService::class.java)
|
||||
|
||||
@Value("\${market.polling.interval:30000}")
|
||||
private var pollingInterval: Long = 30000 // 轮询间隔(毫秒),默认30秒
|
||||
|
||||
@Value("\${market.polling.batch.size:50}")
|
||||
private var batchSize: Int = 50 // 批量处理大小,每次最多处理50个市场
|
||||
|
||||
// 协程作用域和任务
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
private var pollingJob: Job? = null
|
||||
|
||||
// 同步锁,确保轮询任务的启动和停止是线程安全的
|
||||
private val lock = Any()
|
||||
|
||||
/**
|
||||
* 初始化服务(后端启动时直接启动轮询)
|
||||
*/
|
||||
@PostConstruct
|
||||
fun init() {
|
||||
logger.info("MarketPollingService 初始化,启动市场信息轮询任务,轮询间隔: ${pollingInterval}ms (${pollingInterval / 1000 / 60}分钟)")
|
||||
startPolling()
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理资源
|
||||
*/
|
||||
@PreDestroy
|
||||
fun destroy() {
|
||||
synchronized(lock) {
|
||||
pollingJob?.cancel()
|
||||
pollingJob = null
|
||||
}
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动轮询任务
|
||||
*/
|
||||
private fun startPolling() {
|
||||
synchronized(lock) {
|
||||
// 如果已经有轮询任务在运行,先取消
|
||||
pollingJob?.cancel()
|
||||
|
||||
// 启动新的轮询任务
|
||||
pollingJob = scope.launch(Dispatchers.IO) {
|
||||
// 启动时立即执行一次
|
||||
try {
|
||||
checkAndUpdateMissingMarkets()
|
||||
} catch (e: Exception) {
|
||||
logger.error("初始检查市场信息失败: ${e.message}", e)
|
||||
}
|
||||
|
||||
// 然后按间隔定期执行
|
||||
while (isActive) {
|
||||
try {
|
||||
delay(pollingInterval)
|
||||
checkAndUpdateMissingMarkets()
|
||||
} catch (e: Exception) {
|
||||
logger.error("轮询市场信息失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查并更新缺失的市场信息
|
||||
*/
|
||||
private suspend fun checkAndUpdateMissingMarkets() {
|
||||
try {
|
||||
logger.debug("开始检查缺失的市场信息...")
|
||||
|
||||
// 1. 获取所有买入订单的市场ID(去重)
|
||||
val allOrders = copyOrderTrackingRepository.findAll()
|
||||
val marketIds = allOrders.map { it.marketId }.distinct()
|
||||
|
||||
if (marketIds.isEmpty()) {
|
||||
logger.debug("没有找到任何订单,跳过市场信息检查")
|
||||
return
|
||||
}
|
||||
|
||||
logger.debug("找到 ${marketIds.size} 个不同的市场ID")
|
||||
|
||||
// 2. 检查哪些市场信息在数据库中缺失
|
||||
val existingMarkets = marketService.marketRepository.findByMarketIdIn(marketIds)
|
||||
val existingMarketIds = existingMarkets.map { it.marketId }.toSet()
|
||||
val missingMarketIds = marketIds.filter { it !in existingMarketIds }
|
||||
|
||||
// 过滤掉空字符串和无效的市场ID
|
||||
val validMissingMarketIds = missingMarketIds.filter {
|
||||
it.isNotBlank() && it.startsWith("0x")
|
||||
}
|
||||
|
||||
if (validMissingMarketIds.isEmpty()) {
|
||||
logger.debug("所有市场信息都已存在,无需更新")
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("发现 ${validMissingMarketIds.size} 个缺失的市场信息,开始批量更新...")
|
||||
|
||||
// 3. 批量从API获取缺失的市场信息(分批处理,避免一次性请求过多)
|
||||
val batches = validMissingMarketIds.chunked(batchSize)
|
||||
var successCount = 0
|
||||
var failCount = 0
|
||||
|
||||
for ((index, batch) in batches.withIndex()) {
|
||||
try {
|
||||
logger.debug("处理第 ${index + 1}/${batches.size} 批,包含 ${batch.size} 个市场: ${batch.take(3).joinToString(", ")}${if (batch.size > 3) "..." else ""}")
|
||||
|
||||
// 使用 MarketService 的批量获取方法
|
||||
// 这个方法会尝试从API获取并保存到数据库
|
||||
val markets = marketService.getMarkets(batch)
|
||||
val batchSuccessCount = markets.size
|
||||
val batchFailCount = batch.size - batchSuccessCount
|
||||
|
||||
successCount += batchSuccessCount
|
||||
failCount += batchFailCount
|
||||
|
||||
if (batchFailCount > 0) {
|
||||
logger.warn("第 ${index + 1} 批中有 ${batchFailCount} 个市场信息获取失败")
|
||||
}
|
||||
|
||||
// 避免请求过于频繁,每批之间稍作延迟
|
||||
if (index < batches.size - 1) {
|
||||
delay(1000) // 延迟1秒,避免API限流
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("批量获取市场信息失败: batch=${batch.take(5).joinToString(", ")}..., error=${e.message}", e)
|
||||
failCount += batch.size
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("市场信息更新完成: 成功=${successCount}, 失败=${failCount}, 总计=${validMissingMarketIds.size}")
|
||||
} catch (e: Exception) {
|
||||
logger.error("检查并更新市场信息异常: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发检查(用于测试或手动刷新)
|
||||
*/
|
||||
fun triggerCheck() {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
checkAndUpdateMissingMarkets()
|
||||
} catch (e: Exception) {
|
||||
logger.error("手动触发检查市场信息失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+93
-10
@@ -31,7 +31,8 @@ class MarketPriceService(
|
||||
* 获取当前市场最新价
|
||||
* 优先级:
|
||||
* 1. 链上查询市场结算结果(如果已结算,返回 1.0 或 0.0)
|
||||
* 2. CLOB API 查询订单簿价格(最准确,使用 bestBid)
|
||||
* 2. CLOB API 查询订单簿价格(最准确,优先使用,使用 bestBid)
|
||||
* 3. Gamma Market API 查询市场价格(快速,作为备选)
|
||||
*
|
||||
* 价格会被截位到 4 位小数(向下截断,不四舍五入),用于显示和后续计算
|
||||
*
|
||||
@@ -42,21 +43,35 @@ class MarketPriceService(
|
||||
*/
|
||||
suspend fun getCurrentMarketPrice(marketId: String, outcomeIndex: Int): BigDecimal {
|
||||
// 1. 优先从链上查询市场结算结果
|
||||
val chainPrice = getPriceFromChainCondition(marketId, outcomeIndex)
|
||||
val (chainPrice, hasRpcError) = getPriceFromChainCondition(marketId, outcomeIndex)
|
||||
if (chainPrice != null) {
|
||||
// 截位到 4 位小数(向下截断,不四舍五入)
|
||||
return chainPrice.setScale(4, java.math.RoundingMode.DOWN)
|
||||
}
|
||||
|
||||
// 2. 从 CLOB API 查询订单簿价格(最准确)
|
||||
// 如果链上查询出现 RPC 错误(execution reverted),说明市场可能不存在或尚未创建
|
||||
// 在这种情况下,降级到其他数据源(CLOB API 或 Gamma API),而不是直接抛出异常
|
||||
// 因为 marketId 可能在 API 中存在,但在链上尚未创建
|
||||
if (hasRpcError) {
|
||||
logger.debug("链上查询市场条件出现 RPC 错误(execution reverted),降级到 API 查询: marketId=$marketId, outcomeIndex=$outcomeIndex")
|
||||
}
|
||||
|
||||
// 2. 从 CLOB API 查询订单簿价格(最准确,优先使用)
|
||||
val orderbookPrice = getPriceFromClobOrderbook(marketId, outcomeIndex)
|
||||
if (orderbookPrice != null) {
|
||||
// 截位到 4 位小数(向下截断,不四舍五入)
|
||||
return orderbookPrice.setScale(4, java.math.RoundingMode.DOWN)
|
||||
}
|
||||
|
||||
// 3. 从 Gamma Market API 查询市场价格(作为备选)
|
||||
val marketPrice = getPriceFromGammaMarket(marketId, outcomeIndex)
|
||||
if (marketPrice != null) {
|
||||
// 截位到 4 位小数(向下截断,不四舍五入)
|
||||
return marketPrice.setScale(4, java.math.RoundingMode.DOWN)
|
||||
}
|
||||
|
||||
// 如果所有数据源都失败,抛出异常
|
||||
val errorMsg = "无法获取市场价格: marketId=$marketId, outcomeIndex=$outcomeIndex (链上查询和订单簿查询均失败)"
|
||||
val errorMsg = "无法获取市场价格: marketId=$marketId, outcomeIndex=$outcomeIndex (链上查询、订单簿查询和 Market API 均失败)"
|
||||
logger.error(errorMsg)
|
||||
throw IllegalStateException(errorMsg)
|
||||
}
|
||||
@@ -67,8 +82,10 @@ class MarketPriceService(
|
||||
* - payout > 0(赢了)→ 返回 1.0
|
||||
* - payout == 0(输了)→ 返回 0.0
|
||||
* 如果市场未结算或查询失败,返回 null
|
||||
*
|
||||
* @return Pair<BigDecimal?, Boolean> 第一个值是价格(如果已结算),第二个值表示是否发生了 RPC 错误(execution reverted)
|
||||
*/
|
||||
private suspend fun getPriceFromChainCondition(marketId: String, outcomeIndex: Int): BigDecimal? {
|
||||
private suspend fun getPriceFromChainCondition(marketId: String, outcomeIndex: Int): Pair<BigDecimal?, Boolean> {
|
||||
return try {
|
||||
val chainResult = blockchainService.getCondition(marketId)
|
||||
chainResult.fold(
|
||||
@@ -79,29 +96,95 @@ class MarketPriceService(
|
||||
when {
|
||||
payout > BigInteger.ZERO -> {
|
||||
logger.info("从链上查询到市场已结算,该 outcome 赢了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
|
||||
return BigDecimal.ONE
|
||||
return Pair(BigDecimal.ONE, false)
|
||||
}
|
||||
payout == BigInteger.ZERO -> {
|
||||
logger.info("从链上查询到市场已结算,该 outcome 输了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
|
||||
return BigDecimal.ZERO
|
||||
return Pair(BigDecimal.ZERO, false)
|
||||
}
|
||||
else -> {
|
||||
logger.warn("从链上查询到异常的 payout 值: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
|
||||
null
|
||||
Pair(null, false)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.debug("从链上查询到市场尚未结算: marketId=$marketId, payouts=${payouts.size}")
|
||||
null
|
||||
Pair(null, false)
|
||||
}
|
||||
},
|
||||
onFailure = { e ->
|
||||
// 检查是否是 execution reverted 错误
|
||||
val isRpcError = e.message?.contains("execution reverted", ignoreCase = true) == true
|
||||
if (isRpcError) {
|
||||
logger.warn("链上查询市场条件出现 RPC 错误(execution reverted),可能市场不存在或尚未创建: marketId=$marketId, error=${e.message}")
|
||||
} else {
|
||||
logger.debug("链上查询市场条件失败,降级到 API 查询: marketId=$marketId, error=${e.message}")
|
||||
null
|
||||
}
|
||||
Pair(null, isRpcError)
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
val isRpcError = e.message?.contains("execution reverted", ignoreCase = true) == true
|
||||
if (isRpcError) {
|
||||
logger.warn("链上查询市场条件异常(execution reverted): marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}")
|
||||
} else {
|
||||
logger.debug("链上查询市场条件异常: marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}")
|
||||
}
|
||||
Pair(null, isRpcError)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Gamma Market API 获取价格
|
||||
* 使用 outcomePrices 字段,格式通常为 JSON 字符串 "[\"0.5\", \"0.5\"]"
|
||||
* 如果查询失败或 outcomePrices 为空,返回 null
|
||||
*/
|
||||
private suspend fun getPriceFromGammaMarket(marketId: String, outcomeIndex: Int): BigDecimal? {
|
||||
return try {
|
||||
val gammaApi = retrofitFactory.createGammaApi()
|
||||
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(marketId))
|
||||
|
||||
if (!marketResponse.isSuccessful || marketResponse.body() == null) {
|
||||
logger.debug("Gamma Market API 查询失败: marketId=$marketId, code=${marketResponse.code()}")
|
||||
return null
|
||||
}
|
||||
|
||||
val markets = marketResponse.body()!!
|
||||
if (markets.isEmpty()) {
|
||||
logger.debug("Gamma Market API 未找到市场: marketId=$marketId")
|
||||
return null
|
||||
}
|
||||
|
||||
val market = markets.first()
|
||||
|
||||
// 尝试从 outcomePrices 字段获取价格
|
||||
val outcomePricesStr = market.outcomePrices
|
||||
if (outcomePricesStr.isNullOrBlank()) {
|
||||
logger.debug("Market outcomePrices 为空: marketId=$marketId")
|
||||
return null
|
||||
}
|
||||
|
||||
// 解析 outcomePrices(通常是 JSON 数组字符串)
|
||||
val outcomePrices = try {
|
||||
// 移除首尾的方括号和引号,按逗号分割
|
||||
val cleanStr = outcomePricesStr.trim().removeSurrounding("[", "]")
|
||||
cleanStr.split(",").map {
|
||||
it.trim().removeSurrounding("\"").toSafeBigDecimal()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("解析 outcomePrices 失败: marketId=$marketId, outcomePrices=$outcomePricesStr, error=${e.message}")
|
||||
null
|
||||
}
|
||||
|
||||
if (outcomePrices != null && outcomeIndex < outcomePrices.size) {
|
||||
val price = outcomePrices[outcomeIndex]
|
||||
logger.debug("从 Gamma Market API 获取价格: marketId=$marketId, outcomeIndex=$outcomeIndex, price=$price")
|
||||
return price
|
||||
}
|
||||
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
logger.debug("Gamma Market API 查询异常: marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package com.wrbug.polymarketbot.service.common
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache
|
||||
import com.github.benmanes.caffeine.cache.Caffeine
|
||||
import com.wrbug.polymarketbot.api.MarketResponse
|
||||
import com.wrbug.polymarketbot.api.PolymarketGammaApi
|
||||
import com.wrbug.polymarketbot.entity.Market
|
||||
import com.wrbug.polymarketbot.repository.MarketRepository
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
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
|
||||
|
||||
/**
|
||||
* 市场信息服务
|
||||
* 负责缓存和管理市场信息
|
||||
*/
|
||||
@Service
|
||||
class MarketService(
|
||||
val marketRepository: MarketRepository, // 改为 public,供 MarketPollingService 使用
|
||||
private val retrofitFactory: RetrofitFactory
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(MarketService::class.java)
|
||||
|
||||
// LRU 缓存(避免频繁查询数据库),最多缓存 200 条记录
|
||||
private val marketCache: Cache<String, Market> = Caffeine.newBuilder()
|
||||
.maximumSize(200) // 最多缓存 200 条记录
|
||||
.build()
|
||||
|
||||
/**
|
||||
* 根据市场ID获取市场信息
|
||||
* 优先从缓存获取,如果不存在则从数据库查询,如果数据库也没有则从API获取并保存
|
||||
*/
|
||||
fun getMarket(marketId: String): Market? {
|
||||
// 1. 从缓存获取
|
||||
marketCache.getIfPresent(marketId)?.let { return it }
|
||||
|
||||
// 2. 从数据库查询
|
||||
val market = marketRepository.findByMarketId(marketId)
|
||||
if (market != null) {
|
||||
marketCache.put(marketId, market)
|
||||
return market
|
||||
}
|
||||
|
||||
// 3. 从API获取(异步,不阻塞)
|
||||
runBlocking {
|
||||
try {
|
||||
fetchAndSaveMarket(marketId)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取市场信息失败: marketId=$marketId, error=${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// 再次从数据库查询(API可能已经保存)
|
||||
return marketRepository.findByMarketId(marketId)?.also {
|
||||
marketCache.put(marketId, it)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取市场信息
|
||||
*/
|
||||
fun getMarkets(marketIds: List<String>): Map<String, Market> {
|
||||
val result = mutableMapOf<String, Market>()
|
||||
val missingIds = mutableListOf<String>()
|
||||
|
||||
// 1. 从缓存和数据库获取
|
||||
for (marketId in marketIds) {
|
||||
val market = getMarket(marketId)
|
||||
if (market != null) {
|
||||
result[marketId] = market
|
||||
} else {
|
||||
missingIds.add(marketId)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 批量从API获取缺失的市场信息
|
||||
if (missingIds.isNotEmpty()) {
|
||||
runBlocking {
|
||||
try {
|
||||
fetchAndSaveMarkets(missingIds)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("批量获取市场信息失败: marketIds=$missingIds, error=${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// 再次从数据库查询
|
||||
val savedMarkets = marketRepository.findByMarketIdIn(missingIds)
|
||||
for (market in savedMarkets) {
|
||||
result[market.marketId] = market
|
||||
marketCache.put(market.marketId, market)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 从API获取市场信息并保存到数据库
|
||||
*/
|
||||
private suspend fun fetchAndSaveMarket(marketId: String): Market? {
|
||||
return try {
|
||||
val gammaApi = retrofitFactory.createGammaApi()
|
||||
val response = gammaApi.listMarkets(conditionIds = listOf(marketId))
|
||||
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val markets = response.body()!!
|
||||
if (markets.isNotEmpty()) {
|
||||
val marketResponse = markets.first()
|
||||
saveMarketFromResponse(marketId, marketResponse)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("从API获取市场信息失败: marketId=$marketId, error=${e.message}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量从API获取市场信息并保存到数据库
|
||||
*/
|
||||
private suspend fun fetchAndSaveMarkets(marketIds: List<String>) {
|
||||
if (marketIds.isEmpty()) return
|
||||
|
||||
try {
|
||||
val gammaApi = retrofitFactory.createGammaApi()
|
||||
val response = gammaApi.listMarkets(conditionIds = marketIds)
|
||||
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val markets = response.body()!!
|
||||
val marketMap = markets.associateBy { it.conditionId ?: "" }
|
||||
|
||||
for (marketId in marketIds) {
|
||||
val marketResponse = marketMap[marketId]
|
||||
if (marketResponse != null) {
|
||||
saveMarketFromResponse(marketId, marketResponse)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("批量从API获取市场信息失败: marketIds=$marketIds, error=${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从API响应保存市场信息到数据库
|
||||
*/
|
||||
private fun saveMarketFromResponse(marketId: String, marketResponse: MarketResponse): Market? {
|
||||
return try {
|
||||
val existingMarket = marketRepository.findByMarketId(marketId)
|
||||
|
||||
// 保存原来的 slug(用于显示)
|
||||
val slug = marketResponse.slug
|
||||
// 保存跳转用的 slug(从 events[0].slug 获取)
|
||||
val eventSlug = marketResponse.getEventSlug()
|
||||
|
||||
val market = if (existingMarket != null) {
|
||||
// 更新现有市场信息
|
||||
existingMarket.copy(
|
||||
title = marketResponse.question ?: existingMarket.title,
|
||||
slug = slug ?: existingMarket.slug,
|
||||
eventSlug = eventSlug ?: existingMarket.eventSlug,
|
||||
category = marketResponse.category ?: existingMarket.category,
|
||||
icon = marketResponse.icon ?: existingMarket.icon,
|
||||
image = marketResponse.image ?: existingMarket.image,
|
||||
description = marketResponse.description ?: existingMarket.description,
|
||||
active = marketResponse.active ?: existingMarket.active,
|
||||
closed = marketResponse.closed ?: existingMarket.closed,
|
||||
archived = marketResponse.archived ?: existingMarket.archived,
|
||||
endDate = parseEndDate(marketResponse.endDate),
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
} else {
|
||||
// 创建新市场信息
|
||||
Market(
|
||||
marketId = marketId,
|
||||
title = marketResponse.question ?: marketId,
|
||||
slug = slug,
|
||||
eventSlug = eventSlug,
|
||||
category = marketResponse.category,
|
||||
icon = marketResponse.icon,
|
||||
image = marketResponse.image,
|
||||
description = marketResponse.description,
|
||||
active = marketResponse.active ?: true,
|
||||
closed = marketResponse.closed ?: false,
|
||||
archived = marketResponse.archived ?: false,
|
||||
endDate = parseEndDate(marketResponse.endDate),
|
||||
createdAt = System.currentTimeMillis(),
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
|
||||
val savedMarket = marketRepository.save(market)
|
||||
marketCache.put(marketId, savedMarket)
|
||||
savedMarket
|
||||
} catch (e: Exception) {
|
||||
logger.error("保存市场信息失败: marketId=$marketId, error=${e.message}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除缓存(用于测试或手动刷新)
|
||||
*/
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+32
@@ -401,5 +401,37 @@ class PolymarketClobService(
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取费率
|
||||
* 文档: https://docs.polymarket.com/developers/market-makers/maker-rebates-program#1-fetch-the-fee-rate
|
||||
*
|
||||
* 注意:根据 TypeScript clob-client 源码,API 返回的字段名是 base_fee,而不是文档中的 fee_rate_bps
|
||||
* 参考: clob-client/src/client.ts:312
|
||||
*
|
||||
* @param tokenId Token ID
|
||||
* @return 费率基点(0 表示无费率,1000 表示 10%)
|
||||
*/
|
||||
suspend fun getFeeRate(tokenId: String): Result<Int> {
|
||||
return try {
|
||||
val response = clobApi.getFeeRate(tokenId)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val baseFee = response.body()!!.baseFee
|
||||
logger.debug("获取费率成功: tokenId=$tokenId, baseFee=$baseFee")
|
||||
Result.success(baseFee)
|
||||
} else {
|
||||
val errorBody = try {
|
||||
response.errorBody()?.string()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
logger.error("获取费率失败: tokenId=$tokenId, code=${response.code()}, errorBody=$errorBody")
|
||||
Result.failure(Exception("获取费率失败: ${response.code()} ${response.message()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取费率异常: tokenId=$tokenId, error=${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+150
-20
@@ -6,9 +6,11 @@ import com.wrbug.polymarketbot.util.gt
|
||||
import com.wrbug.polymarketbot.util.lt
|
||||
import com.wrbug.polymarketbot.util.multi
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import com.wrbug.polymarketbot.util.JsonUtils
|
||||
import org.slf4j.LoggerFactory
|
||||
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
||||
import com.wrbug.polymarketbot.service.accounts.AccountService
|
||||
import com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository
|
||||
import org.springframework.stereotype.Service
|
||||
import java.math.BigDecimal
|
||||
|
||||
@@ -18,7 +20,9 @@ import java.math.BigDecimal
|
||||
@Service
|
||||
class CopyTradingFilterService(
|
||||
private val clobService: PolymarketClobService,
|
||||
private val accountService: AccountService
|
||||
private val accountService: AccountService,
|
||||
private val copyOrderTrackingRepository: CopyOrderTrackingRepository,
|
||||
private val jsonUtils: JsonUtils
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CopyTradingFilterService::class.java)
|
||||
@@ -30,6 +34,8 @@ class CopyTradingFilterService(
|
||||
* @param tradePrice Leader 交易价格,用于价格区间检查
|
||||
* @param copyOrderAmount 跟单金额(USDC),用于仓位检查,如果为null则不进行仓位检查
|
||||
* @param marketId 市场ID,用于仓位检查(按市场过滤仓位)
|
||||
* @param marketTitle 市场标题,用于关键字过滤
|
||||
* @param marketEndDate 市场截止时间,用于市场截止时间检查
|
||||
* @return 过滤结果
|
||||
*/
|
||||
suspend fun checkFilters(
|
||||
@@ -37,9 +43,27 @@ class CopyTradingFilterService(
|
||||
tokenId: String,
|
||||
tradePrice: BigDecimal? = null, // Leader 交易价格,用于价格区间检查
|
||||
copyOrderAmount: BigDecimal? = null, // 跟单金额(USDC),用于仓位检查
|
||||
marketId: String? = null // 市场ID,用于仓位检查(按市场过滤仓位)
|
||||
marketId: String? = null, // 市场ID,用于仓位检查(按市场过滤仓位)
|
||||
marketTitle: String? = null, // 市场标题,用于关键字过滤
|
||||
marketEndDate: Long? = null // 市场截止时间,用于市场截止时间检查
|
||||
): FilterResult {
|
||||
// 1. 价格区间检查(如果配置了价格区间)
|
||||
// 1. 关键字过滤检查(如果配置了关键字过滤)
|
||||
if (copyTrading.keywordFilterMode != null && copyTrading.keywordFilterMode != "DISABLED") {
|
||||
val keywordCheck = checkKeywordFilter(copyTrading, marketTitle)
|
||||
if (!keywordCheck.isPassed) {
|
||||
return keywordCheck
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
if (!priceRangeCheck.isPassed) {
|
||||
@@ -47,7 +71,7 @@ class CopyTradingFilterService(
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查是否需要获取订单簿
|
||||
// 3. 检查是否需要获取订单簿
|
||||
// 只有在配置了需要订单簿的过滤条件时才获取
|
||||
val needOrderbook = copyTrading.maxSpread != null || copyTrading.minOrderDepth != null
|
||||
|
||||
@@ -56,7 +80,7 @@ class CopyTradingFilterService(
|
||||
return FilterResult.passed()
|
||||
}
|
||||
|
||||
// 3. 获取订单簿(仅在需要时,只请求一次)
|
||||
// 4. 获取订单簿(仅在需要时,只请求一次)
|
||||
val orderbookResult = clobService.getOrderbookByTokenId(tokenId)
|
||||
if (!orderbookResult.isSuccess) {
|
||||
val error = orderbookResult.exceptionOrNull()
|
||||
@@ -66,7 +90,7 @@ class CopyTradingFilterService(
|
||||
val orderbook = orderbookResult.getOrNull()
|
||||
?: return FilterResult.orderbookEmpty()
|
||||
|
||||
// 4. 买一卖一价差过滤(如果配置了)
|
||||
// 5. 买一卖一价差过滤(如果配置了)
|
||||
if (copyTrading.maxSpread != null) {
|
||||
val spreadCheck = checkSpread(copyTrading, orderbook)
|
||||
if (!spreadCheck.isPassed) {
|
||||
@@ -74,7 +98,7 @@ class CopyTradingFilterService(
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 订单深度过滤(如果配置了,检查所有方向)
|
||||
// 6. 订单深度过滤(如果配置了,检查所有方向)
|
||||
if (copyTrading.minOrderDepth != null) {
|
||||
val depthCheck = checkOrderDepth(copyTrading, orderbook)
|
||||
if (!depthCheck.isPassed) {
|
||||
@@ -82,7 +106,7 @@ class CopyTradingFilterService(
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 仓位检查(如果配置了最大仓位限制且提供了跟单金额和市场ID)
|
||||
// 7. 仓位检查(如果配置了最大仓位限制且提供了跟单金额和市场ID)
|
||||
if (copyOrderAmount != null && marketId != null) {
|
||||
val positionCheck = checkPositionLimits(copyTrading, copyOrderAmount, marketId)
|
||||
if (!positionCheck.isPassed) {
|
||||
@@ -93,6 +117,65 @@ class CopyTradingFilterService(
|
||||
return FilterResult.passed(orderbook)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查关键字过滤
|
||||
* @param copyTrading 跟单配置
|
||||
* @param marketTitle 市场标题
|
||||
* @return 过滤结果
|
||||
*/
|
||||
private fun checkKeywordFilter(
|
||||
copyTrading: CopyTrading,
|
||||
marketTitle: String?
|
||||
): FilterResult {
|
||||
// 如果未启用关键字过滤,直接通过
|
||||
if (copyTrading.keywordFilterMode == null || copyTrading.keywordFilterMode == "DISABLED") {
|
||||
return FilterResult.passed()
|
||||
}
|
||||
|
||||
// 如果没有市场标题,无法进行关键字过滤,为了安全起见,不通过
|
||||
if (marketTitle.isNullOrBlank()) {
|
||||
return FilterResult.keywordFilterFailed("市场标题为空,无法进行关键字过滤")
|
||||
}
|
||||
|
||||
// 解析关键字列表
|
||||
val keywords = jsonUtils.parseStringArray(copyTrading.keywords)
|
||||
if (keywords.isEmpty()) {
|
||||
// 如果关键字列表为空,白名单模式不通过,黑名单模式通过
|
||||
return if (copyTrading.keywordFilterMode == "WHITELIST") {
|
||||
FilterResult.keywordFilterFailed("白名单模式但关键字列表为空")
|
||||
} else {
|
||||
FilterResult.passed()
|
||||
}
|
||||
}
|
||||
|
||||
// 将市场标题转换为小写,用于不区分大小写的匹配
|
||||
val titleLower = marketTitle.lowercase()
|
||||
|
||||
// 检查市场标题是否包含任意关键字
|
||||
val containsKeyword = keywords.any { keyword ->
|
||||
titleLower.contains(keyword.lowercase())
|
||||
}
|
||||
|
||||
// 根据过滤模式决定是否通过
|
||||
return when (copyTrading.keywordFilterMode) {
|
||||
"WHITELIST" -> {
|
||||
if (containsKeyword) {
|
||||
FilterResult.passed()
|
||||
} else {
|
||||
FilterResult.keywordFilterFailed("白名单模式:市场标题不包含任何关键字。市场标题:$marketTitle,关键字列表:${keywords.joinToString(", ")}")
|
||||
}
|
||||
}
|
||||
"BLACKLIST" -> {
|
||||
if (containsKeyword) {
|
||||
FilterResult.keywordFilterFailed("黑名单模式:市场标题包含关键字。市场标题:$marketTitle,匹配的关键字:${keywords.filter { titleLower.contains(it.lowercase()) }.joinToString(", ")}")
|
||||
} else {
|
||||
FilterResult.passed()
|
||||
}
|
||||
}
|
||||
else -> FilterResult.passed()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查价格区间
|
||||
* @param copyTrading 跟单配置
|
||||
@@ -234,32 +317,46 @@ class CopyTradingFilterService(
|
||||
|
||||
// 检查最大仓位金额(如果配置了)
|
||||
if (copyTrading.maxPositionValue != null) {
|
||||
// 计算该市场的当前仓位总价值(累加该市场所有仓位的 currentValue)
|
||||
val currentPositionValue = marketPositions.sumOf { position ->
|
||||
position.currentValue.toSafeBigDecimal()
|
||||
}
|
||||
// 比较数据库成本价(本地订单记录)和外部持仓市值(可能来自其他终端的操作),取最大值
|
||||
val dbValue = copyOrderTrackingRepository.sumCurrentPositionValueByMarket(copyTrading.id!!, marketId) ?: BigDecimal.ZERO
|
||||
val extValue = marketPositions.sumOf { it.currentValue.toSafeBigDecimal() }
|
||||
val currentPositionValue = dbValue.max(extValue)
|
||||
|
||||
// 检查:该市场的当前仓位 + 跟单金额 <= 最大仓位金额
|
||||
val totalValueAfterOrder = currentPositionValue.add(copyOrderAmount)
|
||||
|
||||
if (totalValueAfterOrder.gt(copyTrading.maxPositionValue)) {
|
||||
return FilterResult.maxPositionValueFailed(
|
||||
"超过最大仓位金额限制: 当前该市场仓位=${currentPositionValue} USDC, 跟单金额=${copyOrderAmount} USDC, 总计=${totalValueAfterOrder} USDC > 最大限制=${copyTrading.maxPositionValue} USDC"
|
||||
"超过最大仓位金额限制: 当前该市场仓位(取最大值)=${currentPositionValue} USDC (DB=${dbValue}, Ext=${extValue}), 跟单金额=${copyOrderAmount} USDC, 总计=${totalValueAfterOrder} USDC > 最大限制=${copyTrading.maxPositionValue} USDC"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查最大仓位数量(如果配置了)
|
||||
if (copyTrading.maxPositionCount != null) {
|
||||
// 计算该市场的当前仓位数量(该市场不同方向的仓位算不同仓位)
|
||||
val currentPositionCount = marketPositions.size
|
||||
// 使用数据库中的订单记录计算活跃仓位数量(解决延迟问题)
|
||||
val dbCount = copyOrderTrackingRepository.countActivePositions(copyTrading.id!!)
|
||||
|
||||
// 检查:该市场的当前仓位数量 <= 最大仓位数量
|
||||
// 注意:如果该市场已有仓位,跟单可能会增加新的仓位(不同方向)或增加现有仓位
|
||||
// 为了简化,我们检查当前该市场的仓位数量是否已经达到或超过限制
|
||||
if (currentPositionCount >= copyTrading.maxPositionCount) {
|
||||
// 计算外部持仓中的唯一市场数量(防止遗漏非本项目创建的仓位)
|
||||
val extCount = positions.currentPositions
|
||||
.filter { it.accountId == copyTrading.accountId }
|
||||
.map { it.marketId }
|
||||
.distinct()
|
||||
.size
|
||||
|
||||
val currentPositionCount = maxOf(dbCount, extCount)
|
||||
|
||||
// 检查:如果当前没有该市场的活跃仓位,且总仓位数量已达到限制,则不允许开新仓
|
||||
// 判断当前市场是否已有活跃仓位(数据库或外部持仓)
|
||||
val hasDbPosition = copyOrderTrackingRepository.existsByCopyTradingIdAndMarketIdAndRemainingQuantityGreaterThan(
|
||||
copyTrading.id, marketId, BigDecimal.ZERO
|
||||
)
|
||||
val hasExtPosition = marketPositions.isNotEmpty()
|
||||
val hasCurrentMarketPosition = hasDbPosition || hasExtPosition
|
||||
|
||||
if (!hasCurrentMarketPosition && currentPositionCount >= copyTrading.maxPositionCount) {
|
||||
return FilterResult.maxPositionCountFailed(
|
||||
"超过最大仓位数量限制: 当前该市场仓位数量=${currentPositionCount} >= 最大限制=${copyTrading.maxPositionCount}"
|
||||
"超过最大仓位数量限制: 当前活跃仓位总数(取最大值)=${currentPositionCount} (DB=${dbCount}, Ext=${extCount}) >= 最大限制=${copyTrading.maxPositionCount}"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -271,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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+142
-23
@@ -9,6 +9,9 @@ import com.wrbug.polymarketbot.repository.CopyTradingRepository
|
||||
import com.wrbug.polymarketbot.repository.CopyTradingTemplateRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
import com.wrbug.polymarketbot.service.copytrading.monitor.CopyTradingMonitorService
|
||||
import com.google.gson.Gson
|
||||
import com.wrbug.polymarketbot.util.IllegalBigDecimal
|
||||
import com.wrbug.polymarketbot.util.JsonUtils
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
@@ -24,7 +27,9 @@ class CopyTradingService(
|
||||
private val accountRepository: AccountRepository,
|
||||
private val templateRepository: CopyTradingTemplateRepository,
|
||||
private val leaderRepository: LeaderRepository,
|
||||
private val monitorService: CopyTradingMonitorService
|
||||
private val monitorService: CopyTradingMonitorService,
|
||||
private val jsonUtils: JsonUtils,
|
||||
private val gson: Gson
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CopyTradingService::class.java)
|
||||
@@ -46,16 +51,7 @@ class CopyTradingService(
|
||||
val leader = leaderRepository.findById(request.leaderId).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("Leader 不存在"))
|
||||
|
||||
// 3. 检查是否已存在相同的跟单关系(accountId + leaderId)
|
||||
val existing = copyTradingRepository.findByAccountIdAndLeaderId(
|
||||
request.accountId,
|
||||
request.leaderId
|
||||
)
|
||||
if (existing != null) {
|
||||
return Result.failure(IllegalArgumentException("该跟单关系已存在"))
|
||||
}
|
||||
|
||||
// 4. 验证配置名(强校验:不能为空字符串)
|
||||
// 3. 验证配置名(强校验:不能为空字符串)
|
||||
val configName = request.configName?.trim()
|
||||
if (configName.isNullOrBlank()) {
|
||||
return Result.failure(IllegalArgumentException("配置名不能为空"))
|
||||
@@ -88,7 +84,11 @@ class CopyTradingService(
|
||||
minPrice = request.minPrice?.toSafeBigDecimal() ?: template.minPrice,
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice,
|
||||
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
|
||||
maxPositionCount = request.maxPositionCount
|
||||
maxPositionCount = request.maxPositionCount,
|
||||
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
|
||||
keywords = convertKeywordsToJson(request.keywords),
|
||||
maxMarketEndDate = request.maxMarketEndDate,
|
||||
pushFilteredOrders = request.pushFilteredOrders ?: template.pushFilteredOrders
|
||||
)
|
||||
} else {
|
||||
// 手动输入(所有字段必须提供)
|
||||
@@ -116,7 +116,11 @@ class CopyTradingService(
|
||||
minPrice = request.minPrice?.toSafeBigDecimal(),
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal(),
|
||||
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
|
||||
maxPositionCount = request.maxPositionCount
|
||||
maxPositionCount = request.maxPositionCount,
|
||||
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
|
||||
keywords = convertKeywordsToJson(request.keywords),
|
||||
maxMarketEndDate = request.maxMarketEndDate,
|
||||
pushFilteredOrders = request.pushFilteredOrders ?: false // 手动输入时使用请求中的值,默认为 false
|
||||
)
|
||||
}
|
||||
|
||||
@@ -145,8 +149,12 @@ class CopyTradingService(
|
||||
maxPrice = config.maxPrice,
|
||||
maxPositionValue = config.maxPositionValue,
|
||||
maxPositionCount = config.maxPositionCount,
|
||||
keywordFilterMode = config.keywordFilterMode,
|
||||
keywords = config.keywords,
|
||||
configName = configName,
|
||||
pushFailedOrders = request.pushFailedOrders ?: false
|
||||
pushFailedOrders = request.pushFailedOrders ?: false,
|
||||
maxMarketEndDate = config.maxMarketEndDate,
|
||||
pushFilteredOrders = config.pushFilteredOrders
|
||||
)
|
||||
|
||||
val saved = copyTradingRepository.save(copyTrading)
|
||||
@@ -207,14 +215,88 @@ class CopyTradingService(
|
||||
websocketReconnectInterval = request.websocketReconnectInterval ?: copyTrading.websocketReconnectInterval,
|
||||
websocketMaxRetries = request.websocketMaxRetries ?: copyTrading.websocketMaxRetries,
|
||||
supportSell = request.supportSell ?: copyTrading.supportSell,
|
||||
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: copyTrading.minOrderDepth,
|
||||
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: copyTrading.maxSpread,
|
||||
minPrice = request.minPrice?.toSafeBigDecimal() ?: copyTrading.minPrice,
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: copyTrading.maxPrice,
|
||||
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal() ?: copyTrading.maxPositionValue,
|
||||
maxPositionCount = request.maxPositionCount ?: copyTrading.maxPositionCount,
|
||||
// 处理可选字段:空字符串表示要清空(设置为 null),null 表示不更新,转换失败保留旧值
|
||||
minOrderDepth = if (request.minOrderDepth != null) {
|
||||
if (request.minOrderDepth.isBlank()) {
|
||||
null
|
||||
} else {
|
||||
val converted = request.minOrderDepth.toSafeBigDecimal()
|
||||
if (converted == IllegalBigDecimal) copyTrading.minOrderDepth else converted
|
||||
}
|
||||
} else {
|
||||
copyTrading.minOrderDepth
|
||||
},
|
||||
maxSpread = if (request.maxSpread != null) {
|
||||
if (request.maxSpread.isBlank()) {
|
||||
null
|
||||
} else {
|
||||
val converted = request.maxSpread.toSafeBigDecimal()
|
||||
if (converted == IllegalBigDecimal) copyTrading.maxSpread else converted
|
||||
}
|
||||
} else {
|
||||
copyTrading.maxSpread
|
||||
},
|
||||
minPrice = if (request.minPrice != null) {
|
||||
if (request.minPrice.isBlank()) {
|
||||
null
|
||||
} else {
|
||||
val converted = request.minPrice.toSafeBigDecimal()
|
||||
if (converted == IllegalBigDecimal) copyTrading.minPrice else converted
|
||||
}
|
||||
} else {
|
||||
copyTrading.minPrice
|
||||
},
|
||||
maxPrice = if (request.maxPrice != null) {
|
||||
if (request.maxPrice.isBlank()) {
|
||||
null
|
||||
} else {
|
||||
val converted = request.maxPrice.toSafeBigDecimal()
|
||||
if (converted == IllegalBigDecimal) copyTrading.maxPrice else converted
|
||||
}
|
||||
} else {
|
||||
copyTrading.maxPrice
|
||||
},
|
||||
maxPositionValue = if (request.maxPositionValue != null) {
|
||||
if (request.maxPositionValue.isBlank()) {
|
||||
null
|
||||
} else {
|
||||
val converted = request.maxPositionValue.toSafeBigDecimal()
|
||||
if (converted == IllegalBigDecimal) copyTrading.maxPositionValue else converted
|
||||
}
|
||||
} else {
|
||||
copyTrading.maxPositionValue
|
||||
},
|
||||
// 处理 maxPositionCount:-1 表示要清空(设置为 null),null 表示不更新
|
||||
maxPositionCount = if (request.maxPositionCount != null) {
|
||||
if (request.maxPositionCount == -1) {
|
||||
null
|
||||
} else {
|
||||
request.maxPositionCount
|
||||
}
|
||||
} else {
|
||||
copyTrading.maxPositionCount
|
||||
},
|
||||
keywordFilterMode = request.keywordFilterMode ?: copyTrading.keywordFilterMode,
|
||||
keywords = if (request.keywords != null) {
|
||||
convertKeywordsToJson(request.keywords)
|
||||
} else if (request.keywordFilterMode != null && request.keywordFilterMode == "DISABLED") {
|
||||
null
|
||||
} else {
|
||||
copyTrading.keywords
|
||||
},
|
||||
configName = configName,
|
||||
pushFailedOrders = request.pushFailedOrders ?: copyTrading.pushFailedOrders,
|
||||
pushFilteredOrders = request.pushFilteredOrders ?: copyTrading.pushFilteredOrders,
|
||||
// 处理 maxMarketEndDate:-1 表示要清空(设置为 null),null 表示不更新
|
||||
maxMarketEndDate = if (request.maxMarketEndDate != null) {
|
||||
if (request.maxMarketEndDate == -1L) {
|
||||
null
|
||||
} else {
|
||||
request.maxMarketEndDate
|
||||
}
|
||||
} else {
|
||||
copyTrading.maxMarketEndDate
|
||||
},
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
@@ -264,11 +346,10 @@ class CopyTradingService(
|
||||
return try {
|
||||
val copyTradings = when {
|
||||
request.accountId != null && request.leaderId != null -> {
|
||||
val found = copyTradingRepository.findByAccountIdAndLeaderId(
|
||||
copyTradingRepository.findByAccountIdAndLeaderId(
|
||||
request.accountId,
|
||||
request.leaderId
|
||||
)
|
||||
if (found != null) listOf(found) else emptyList()
|
||||
}
|
||||
request.accountId != null -> {
|
||||
copyTradingRepository.findByAccountId(request.accountId)
|
||||
@@ -424,13 +505,47 @@ class CopyTradingService(
|
||||
maxPrice = copyTrading.maxPrice?.toPlainString(),
|
||||
maxPositionValue = copyTrading.maxPositionValue?.toPlainString(),
|
||||
maxPositionCount = copyTrading.maxPositionCount,
|
||||
keywordFilterMode = copyTrading.keywordFilterMode,
|
||||
keywords = convertJsonToKeywords(copyTrading.keywords),
|
||||
configName = copyTrading.configName,
|
||||
pushFailedOrders = copyTrading.pushFailedOrders,
|
||||
pushFilteredOrders = copyTrading.pushFilteredOrders,
|
||||
maxMarketEndDate = copyTrading.maxMarketEndDate,
|
||||
createdAt = copyTrading.createdAt,
|
||||
updatedAt = copyTrading.updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 将关键字列表转换为 JSON 字符串
|
||||
*/
|
||||
private fun convertKeywordsToJson(keywords: List<String>?): String? {
|
||||
if (keywords == null || keywords.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
return try {
|
||||
gson.toJson(keywords)
|
||||
} catch (e: Exception) {
|
||||
logger.error("转换关键字列表为 JSON 失败", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 JSON 字符串转换为关键字列表
|
||||
*/
|
||||
private fun convertJsonToKeywords(jsonString: String?): List<String>? {
|
||||
if (jsonString.isNullOrBlank()) {
|
||||
return null
|
||||
}
|
||||
return try {
|
||||
jsonUtils.parseStringArray(jsonString)
|
||||
} catch (e: Exception) {
|
||||
logger.error("解析关键字 JSON 失败", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部配置类(用于构建 CopyTrading 实体)
|
||||
*/
|
||||
@@ -454,6 +569,10 @@ class CopyTradingService(
|
||||
val minPrice: BigDecimal?,
|
||||
val maxPrice: BigDecimal?,
|
||||
val maxPositionValue: BigDecimal?,
|
||||
val maxPositionCount: Int?
|
||||
val maxPositionCount: Int?,
|
||||
val keywordFilterMode: String,
|
||||
val keywords: String?, // JSON 字符串
|
||||
val maxMarketEndDate: Long?, // 市场截止时间限制(毫秒时间戳)
|
||||
val pushFilteredOrders: Boolean // 推送已过滤订单(默认关闭)
|
||||
)
|
||||
}
|
||||
|
||||
+17
-1
@@ -21,7 +21,11 @@ enum class FilterStatus {
|
||||
/** 失败:超过最大仓位金额 */
|
||||
FAILED_MAX_POSITION_VALUE,
|
||||
/** 失败:超过最大仓位数量 */
|
||||
FAILED_MAX_POSITION_COUNT
|
||||
FAILED_MAX_POSITION_COUNT,
|
||||
/** 失败:关键字过滤 */
|
||||
FAILED_KEYWORD_FILTER,
|
||||
/** 失败:市场截止时间超出限制 */
|
||||
FAILED_MARKET_END_DATE
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,6 +93,18 @@ data class FilterResult(
|
||||
status = FilterStatus.FAILED_MAX_POSITION_COUNT,
|
||||
reason = reason
|
||||
)
|
||||
|
||||
/** 关键字过滤失败 */
|
||||
fun keywordFilterFailed(reason: String) = FilterResult(
|
||||
status = FilterStatus.FAILED_KEYWORD_FILTER,
|
||||
reason = reason
|
||||
)
|
||||
|
||||
/** 市场截止时间超出限制 */
|
||||
fun marketEndDateFailed(reason: String) = FilterResult(
|
||||
status = FilterStatus.FAILED_MARKET_END_DATE,
|
||||
reason = reason
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+22
-19
@@ -14,7 +14,9 @@ import org.springframework.stereotype.Service
|
||||
/**
|
||||
* 跟单监听服务(主服务)
|
||||
* 管理所有Leader的交易监听
|
||||
* 同时运行链上 WebSocket 监听和轮询监听(并行处理)
|
||||
* 使用双重监听机制:
|
||||
* 1. Activity WebSocket - 低延迟(< 100ms),优先使用
|
||||
* 2. OnChain WebSocket - 高可靠性(~2-3s),作为兜底
|
||||
* 同时监听跟单账户的卖出/赎回事件(通过链上 WebSocket)
|
||||
*/
|
||||
@Service
|
||||
@@ -22,7 +24,7 @@ class CopyTradingMonitorService(
|
||||
private val copyTradingRepository: CopyTradingRepository,
|
||||
private val leaderRepository: LeaderRepository,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val pollingService: CopyTradingPollingService,
|
||||
private val activityWsService: PolymarketActivityWsService,
|
||||
private val onChainWsService: OnChainWsService,
|
||||
private val accountOnChainMonitorService: AccountOnChainMonitorService
|
||||
) {
|
||||
@@ -51,15 +53,17 @@ class CopyTradingMonitorService(
|
||||
@PreDestroy
|
||||
fun destroy() {
|
||||
scope.cancel()
|
||||
// 停止轮询和链上 WS 监听
|
||||
pollingService.stop()
|
||||
// 停止所有监听
|
||||
activityWsService.stop()
|
||||
onChainWsService.stop()
|
||||
accountOnChainMonitorService.stop()
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动监听
|
||||
* 同时启动链上 WebSocket 监听和轮询监听(并行运行)
|
||||
* 启动双重监听机制:
|
||||
* 1. Activity WebSocket - 低延迟(< 100ms),优先使用
|
||||
* 2. OnChain WebSocket - 高可靠性(~2-3s),作为兜底
|
||||
* 同时启动跟单账户的链上 WebSocket 监听(用于检测卖出/赎回事件)
|
||||
*/
|
||||
suspend fun startMonitoring() {
|
||||
@@ -82,14 +86,13 @@ class CopyTradingMonitorService(
|
||||
accountRepository.findById(accountId).orElse(null)
|
||||
}
|
||||
|
||||
// 4. 同时启动链上 WebSocket 监听和轮询监听(并行运行)
|
||||
// 链上 WS 监听 Leader 的交易(实时,秒级延迟)
|
||||
// 4. 启动 Activity WebSocket 监听(优先,低延迟)
|
||||
activityWsService.start(leaders)
|
||||
|
||||
// 5. 启动链上 WebSocket 监听(兜底,高可靠性)
|
||||
onChainWsService.start(leaders)
|
||||
|
||||
// 轮询监听 Leader 的交易(延迟,2秒间隔,作为备份)
|
||||
pollingService.start(leaders)
|
||||
|
||||
// 5. 启动跟单账户的链上 WebSocket 监听(用于检测卖出/赎回事件)
|
||||
// 6. 启动跟单账户的链上 WebSocket 监听(用于检测卖出/赎回事件)
|
||||
accountOnChainMonitorService.start(accounts)
|
||||
}
|
||||
|
||||
@@ -106,9 +109,9 @@ class CopyTradingMonitorService(
|
||||
return
|
||||
}
|
||||
|
||||
// 同时添加到链上 WS 监听和轮询监听(如果不在列表中才添加)
|
||||
// 同时添加到两种监听
|
||||
activityWsService.addLeader(leader)
|
||||
onChainWsService.addLeader(leader)
|
||||
pollingService.addLeader(leader)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,9 +125,9 @@ class CopyTradingMonitorService(
|
||||
return
|
||||
}
|
||||
|
||||
// 没有启用的跟单配置了,移除监听
|
||||
// 没有启用的跟单配置了,同时从两种监听移除
|
||||
activityWsService.removeLeader(leaderId)
|
||||
onChainWsService.removeLeader(leaderId)
|
||||
pollingService.removeLeader(leaderId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,8 +141,8 @@ class CopyTradingMonitorService(
|
||||
|
||||
if (copyTradings.isNotEmpty()) {
|
||||
// 有启用的跟单配置,确保在监听列表中
|
||||
activityWsService.addLeader(leader)
|
||||
onChainWsService.addLeader(leader)
|
||||
pollingService.addLeader(leader)
|
||||
|
||||
// 更新账户监听(添加该配置关联的账户)
|
||||
val accountIds = copyTradings.map { it.accountId }.distinct()
|
||||
@@ -150,9 +153,9 @@ class CopyTradingMonitorService(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 没有启用的跟单配置,移除监听
|
||||
// 没有启用的跟单配置,同时从两种监听移除
|
||||
activityWsService.removeLeader(leaderId)
|
||||
onChainWsService.removeLeader(leaderId)
|
||||
pollingService.removeLeader(leaderId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,8 +184,8 @@ class CopyTradingMonitorService(
|
||||
*/
|
||||
suspend fun restartMonitoring() {
|
||||
// 停止所有监听
|
||||
activityWsService.stop()
|
||||
onChainWsService.stop()
|
||||
pollingService.stop()
|
||||
delay(1000) // 等待1秒
|
||||
startMonitoring()
|
||||
}
|
||||
|
||||
-279
@@ -1,279 +0,0 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.monitor
|
||||
|
||||
import com.wrbug.polymarketbot.api.TradeResponse
|
||||
import com.wrbug.polymarketbot.api.UserActivityResponse
|
||||
import com.wrbug.polymarketbot.entity.Leader
|
||||
import com.wrbug.polymarketbot.repository.CopyTradingTemplateRepository
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import jakarta.annotation.PreDestroy
|
||||
import kotlinx.coroutines.*
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
|
||||
import org.springframework.stereotype.Service
|
||||
import retrofit2.Response
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* 跟单轮询监听服务
|
||||
* 通过定期轮询 Polymarket Data API 的 /activity 接口获取Leader的交易记录
|
||||
* 使用 /activity 接口可以查询用户的链上活动,包括交易
|
||||
*/
|
||||
@Service
|
||||
class CopyTradingPollingService(
|
||||
private val copyOrderTrackingService: CopyOrderTrackingService,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val templateRepository: CopyTradingTemplateRepository
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CopyTradingPollingService::class.java)
|
||||
|
||||
@Value("\${copy.trading.polling.interval:2000}")
|
||||
private var pollingInterval: Long = 2000 // 轮询间隔(毫秒),默认2秒
|
||||
|
||||
@Value("\${copy.trading.polling.enabled:true}")
|
||||
private var pollingEnabled: Boolean = true // 是否启用轮询
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
// 存储需要监听的Leader:leaderId -> Leader
|
||||
private val monitoredLeaders = ConcurrentHashMap<Long, Leader>()
|
||||
|
||||
// 存储每个Leader已缓存的交易ID集合:leaderId -> Set<tradeId>
|
||||
private val cachedTradeIds = ConcurrentHashMap<Long, MutableSet<String>>()
|
||||
|
||||
// 存储每个Leader是否首次轮询:leaderId -> isFirstPoll
|
||||
private val isFirstPoll = ConcurrentHashMap<Long, Boolean>()
|
||||
|
||||
// 轮询任务
|
||||
private var pollingJob: Job? = null
|
||||
|
||||
/**
|
||||
* 启动轮询监听
|
||||
*/
|
||||
fun start(leaders: List<Leader>) {
|
||||
if (!pollingEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
leaders.forEach { leader ->
|
||||
addLeader(leader)
|
||||
}
|
||||
|
||||
// 启动轮询任务
|
||||
startPolling()
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加Leader监听
|
||||
*/
|
||||
fun addLeader(leader: Leader) {
|
||||
if (leader.id == null) {
|
||||
logger.warn("Leader ID为空,跳过: ${leader.leaderAddress}")
|
||||
return
|
||||
}
|
||||
|
||||
val leaderId = leader.id
|
||||
monitoredLeaders[leaderId] = leader
|
||||
// 初始化缓存的交易ID集合
|
||||
cachedTradeIds[leaderId] = mutableSetOf()
|
||||
// 首次轮询标志,用于缓存数据而不处理
|
||||
isFirstPoll[leaderId] = true
|
||||
|
||||
// 如果轮询任务没有运行,启动它
|
||||
startPolling()
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除Leader监听
|
||||
*/
|
||||
fun removeLeader(leaderId: Long) {
|
||||
monitoredLeaders.remove(leaderId)
|
||||
cachedTradeIds.remove(leaderId)
|
||||
isFirstPoll.remove(leaderId)
|
||||
|
||||
// 如果没有需要监听的Leader了,停止轮询任务
|
||||
if (monitoredLeaders.isEmpty()) {
|
||||
stopPolling()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止所有监听
|
||||
*/
|
||||
fun stop() {
|
||||
stopPolling()
|
||||
monitoredLeaders.clear()
|
||||
cachedTradeIds.clear()
|
||||
isFirstPoll.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动轮询任务
|
||||
*/
|
||||
private fun startPolling() {
|
||||
if (pollingJob != null && pollingJob!!.isActive) {
|
||||
return
|
||||
}
|
||||
|
||||
if (monitoredLeaders.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
pollingJob = scope.launch {
|
||||
|
||||
while (isActive) {
|
||||
try {
|
||||
// 轮询所有Leader的交易
|
||||
pollAllLeaders()
|
||||
|
||||
// 等待下一次轮询
|
||||
delay(pollingInterval)
|
||||
} catch (e: Exception) {
|
||||
logger.error("轮询任务异常", e)
|
||||
delay(pollingInterval) // 异常后继续等待
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止轮询任务
|
||||
*/
|
||||
private fun stopPolling() {
|
||||
pollingJob?.cancel()
|
||||
pollingJob = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询所有Leader的交易
|
||||
*/
|
||||
private suspend fun pollAllLeaders() {
|
||||
val leaders = monitoredLeaders.values.toList()
|
||||
|
||||
if (leaders.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
// 并发轮询所有Leader(限制并发数)
|
||||
leaders.chunked(10).forEach { chunk ->
|
||||
chunk.forEach { leader ->
|
||||
try {
|
||||
pollLeaderTrades(leader)
|
||||
} catch (e: Exception) {
|
||||
logger.error("轮询Leader交易失败: leaderId=${leader.id}, address=${leader.leaderAddress}", e)
|
||||
}
|
||||
}
|
||||
// 每个chunk之间稍作延迟,避免API限流
|
||||
delay(100)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询单个Leader的交易
|
||||
* 使用 Polymarket Data API 的 /activity 接口
|
||||
* 通过 diff 分析增量数据,不使用 start 字段
|
||||
*/
|
||||
private suspend fun pollLeaderTrades(leader: Leader) {
|
||||
if (leader.id == null) {
|
||||
return
|
||||
}
|
||||
|
||||
val leaderId = leader.id
|
||||
val leaderAddress = leader.leaderAddress
|
||||
|
||||
try {
|
||||
val firstPoll = isFirstPoll[leaderId] == true
|
||||
val cachedIds = cachedTradeIds[leaderId] ?: mutableSetOf()
|
||||
|
||||
// 创建 Data API 客户端(不需要认证)
|
||||
val dataApi = retrofitFactory.createDataApi()
|
||||
|
||||
// 查询用户活动(只查询交易类型,不使用 start 字段)
|
||||
// 查询最近的数据(limit=100),通过 diff 找出增量
|
||||
val response: Response<List<UserActivityResponse>> = dataApi.getUserActivity(
|
||||
user = leaderAddress,
|
||||
limit = 100, // 每次最多查询100条
|
||||
offset = 0,
|
||||
type = listOf("TRADE"), // 只查询交易类型
|
||||
start = null, // 不使用 start 字段
|
||||
sortBy = "TIMESTAMP",
|
||||
sortDirection = "DESC" // 按时间戳降序,最新的在前
|
||||
)
|
||||
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
logger.warn("获取Leader活动失败: leaderId=$leaderId, address=$leaderAddress, code=${response.code()}, message=${response.message()}")
|
||||
return
|
||||
}
|
||||
|
||||
val activities = response.body()!!
|
||||
|
||||
// 将 UserActivityResponse 转换为 TradeResponse
|
||||
val allTrades = activities.mapNotNull { activity ->
|
||||
// 只处理交易类型
|
||||
if (activity.type != "TRADE" || activity.side == null || activity.price == null || activity.size == null) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
||||
// 转换为 TradeResponse
|
||||
TradeResponse(
|
||||
id = activity.transactionHash ?: "${activity.timestamp}_${activity.conditionId}",
|
||||
market = activity.conditionId,
|
||||
side = activity.side, // BUY 或 SELL
|
||||
price = activity.price.toString(),
|
||||
size = activity.size.toString(),
|
||||
timestamp = activity.timestamp.toString(), // 时间戳(秒)
|
||||
user = activity.proxyWallet,
|
||||
outcomeIndex = activity.outcomeIndex, // 结果索引(0=YES, 1=NO)
|
||||
outcome = activity.outcome // 结果名称
|
||||
)
|
||||
}
|
||||
|
||||
if (firstPoll) {
|
||||
// 首次轮询:缓存所有查询到的交易ID,不处理
|
||||
val tradeIds = allTrades.map { it.id }.toSet()
|
||||
cachedIds.addAll(tradeIds)
|
||||
cachedTradeIds[leaderId] = cachedIds
|
||||
|
||||
// 标记首次轮询完成
|
||||
isFirstPoll[leaderId] = false
|
||||
} else {
|
||||
// 后续轮询:通过 diff 找出新增的交易
|
||||
val newTradeIds = allTrades.map { it.id }.toSet()
|
||||
val incrementalTradeIds = newTradeIds - cachedIds
|
||||
|
||||
if (incrementalTradeIds.isNotEmpty()) {
|
||||
// 找出新增的交易
|
||||
val incrementalTrades = allTrades.filter { it.id in incrementalTradeIds }
|
||||
|
||||
|
||||
// 处理新增的交易
|
||||
incrementalTrades.forEach { trade ->
|
||||
try {
|
||||
// 检查是否已处理(去重由processTrade内部处理)
|
||||
copyOrderTrackingService.processTrade(leaderId, trade, "polling")
|
||||
} catch (e: Exception) {
|
||||
logger.error("处理交易失败: leaderId=$leaderId, tradeId=${trade.id}", e)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新缓存:添加新增的交易ID
|
||||
cachedIds.addAll(incrementalTradeIds)
|
||||
cachedTradeIds[leaderId] = cachedIds
|
||||
|
||||
} else {
|
||||
}
|
||||
|
||||
// 限制缓存大小,避免内存溢出(只保留最近1000条)
|
||||
if (cachedIds.size > 1000) {
|
||||
// 保留最新的1000条(由于查询是按时间戳降序,保留前1000条即可)
|
||||
val sortedTradeIds = allTrades.map { it.id }.take(1000).toSet()
|
||||
cachedTradeIds[leaderId] = sortedTradeIds.toMutableSet()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("轮询Leader交易异常: leaderId=$leaderId, address=$leaderAddress", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-1
@@ -89,6 +89,8 @@ class OnChainWsService(
|
||||
private suspend fun handleLeaderTransaction(leaderId: Long, txHash: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
|
||||
val leader = monitoredLeaders[leaderId] ?: return
|
||||
|
||||
logger.debug("开始处理 Leader 交易: leaderId=$leaderId, txHash=$txHash, leaderAddress=${leader.leaderAddress}")
|
||||
|
||||
try {
|
||||
// 获取交易 receipt
|
||||
val receiptRequest = JsonRpcRequest(
|
||||
@@ -98,11 +100,13 @@ class OnChainWsService(
|
||||
|
||||
val receiptResponse = rpcApi.call(receiptRequest)
|
||||
if (!receiptResponse.isSuccessful || receiptResponse.body() == null) {
|
||||
logger.warn("获取交易 receipt 失败: leaderId=$leaderId, txHash=$txHash, code=${receiptResponse.code()}")
|
||||
return
|
||||
}
|
||||
|
||||
val receiptRpcResponse = receiptResponse.body()!!
|
||||
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null) {
|
||||
logger.warn("交易 receipt 错误: leaderId=$leaderId, txHash=$txHash, error=${receiptRpcResponse.error}")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -118,8 +122,12 @@ class OnChainWsService(
|
||||
}
|
||||
|
||||
// 解析 receipt 中的 Transfer 日志
|
||||
val logs = receiptJson.getAsJsonArray("logs") ?: return
|
||||
val logs = receiptJson.getAsJsonArray("logs") ?: run {
|
||||
logger.warn("交易 receipt 中没有日志: leaderId=$leaderId, txHash=$txHash")
|
||||
return
|
||||
}
|
||||
val (erc20Transfers, erc1155Transfers) = OnChainWsUtils.parseReceiptTransfers(logs)
|
||||
logger.debug("解析交易日志: leaderId=$leaderId, txHash=$txHash, erc20Transfers=${erc20Transfers.size}, erc1155Transfers=${erc1155Transfers.size}")
|
||||
|
||||
// 解析交易信息
|
||||
val trade = OnChainWsUtils.parseTradeFromTransfers(
|
||||
@@ -132,12 +140,15 @@ class OnChainWsService(
|
||||
)
|
||||
|
||||
if (trade != null) {
|
||||
logger.info("成功解析交易: leaderId=$leaderId, txHash=$txHash, side=${trade.side}, market=${trade.market}, size=${trade.size}")
|
||||
// 调用 processTrade 处理交易
|
||||
copyOrderTrackingService.processTrade(
|
||||
leaderId = leaderId,
|
||||
trade = trade,
|
||||
source = "onchain-ws"
|
||||
)
|
||||
} else {
|
||||
logger.warn("无法解析交易(返回 null): leaderId=$leaderId, txHash=$txHash, erc20Transfers=${erc20Transfers.size}, erc1155Transfers=${erc1155Transfers.size}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("处理 Leader 交易失败: leaderId=$leaderId, txHash=$txHash, ${e.message}", e)
|
||||
|
||||
+1
@@ -207,6 +207,7 @@ object OnChainWsUtils {
|
||||
usdcRaw = usdcIn
|
||||
} else {
|
||||
// 无法判断交易方向
|
||||
logger.debug("无法判断交易方向: txHash=$txHash, bestInId=$bestInId, bestInVal=$bestInVal, bestOutId=$bestOutId, bestOutVal=$bestOutVal, usdcOut=$usdcOut, usdcIn=$usdcIn")
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
+412
@@ -0,0 +1,412 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.monitor
|
||||
|
||||
import com.wrbug.polymarketbot.api.TradeResponse
|
||||
import com.wrbug.polymarketbot.dto.ActivityTradeMessage
|
||||
import com.wrbug.polymarketbot.dto.ActivityTradePayload
|
||||
import com.wrbug.polymarketbot.entity.Leader
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
|
||||
import com.wrbug.polymarketbot.util.fromJson
|
||||
import com.wrbug.polymarketbot.websocket.PolymarketWebSocketClient
|
||||
import jakarta.annotation.PreDestroy
|
||||
import kotlinx.coroutines.*
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Service
|
||||
import java.math.BigDecimal
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Polymarket Activity WebSocket 监听服务
|
||||
* 通过订阅全局 activity 交易流,客户端过滤 Leader 地址,实现实时交易检测
|
||||
* 延迟 < 100ms,适合快速跟单场景
|
||||
*/
|
||||
@Service
|
||||
class PolymarketActivityWsService(
|
||||
private val copyOrderTrackingService: CopyOrderTrackingService,
|
||||
private val leaderRepository: LeaderRepository
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(PolymarketActivityWsService::class.java)
|
||||
|
||||
@Value("\${polymarket.websocket.activity.url:wss://ws-live-data.polymarket.com}")
|
||||
private var websocketUrl: String = "wss://ws-live-data.polymarket.com"
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
// 单例 WebSocket 客户端
|
||||
private var wsClient: PolymarketWebSocketClient? = null
|
||||
|
||||
// 要监听的 Leader 地址集合(小写地址 -> leaderId)
|
||||
private val monitoredAddresses = ConcurrentHashMap<String, Long>()
|
||||
|
||||
// 是否已订阅
|
||||
@Volatile
|
||||
private var isSubscribed = false
|
||||
|
||||
/**
|
||||
* 启动监听
|
||||
*/
|
||||
fun start(leaders: List<Leader>) {
|
||||
monitoredAddresses.clear()
|
||||
leaders.forEach { leader ->
|
||||
val leaderId = leader.id
|
||||
if (leaderId != null) {
|
||||
monitoredAddresses[leader.leaderAddress.lowercase()] = leaderId
|
||||
}
|
||||
}
|
||||
|
||||
if (monitoredAddresses.isEmpty()) {
|
||||
logger.info("没有需要监听的 Leader,停止 Activity WebSocket")
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("启动 Activity WebSocket 监听,监控 ${monitoredAddresses.size} 个 Leader 地址")
|
||||
connectAndSubscribe()
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加 Leader
|
||||
*/
|
||||
fun addLeader(leader: Leader) {
|
||||
if (leader.id == null) {
|
||||
logger.warn("Leader ID 为空,跳过: ${leader.leaderAddress}")
|
||||
return
|
||||
}
|
||||
|
||||
val address = leader.leaderAddress.lowercase()
|
||||
val existingLeaderId = monitoredAddresses[address]
|
||||
|
||||
if (existingLeaderId != null && existingLeaderId == leader.id) {
|
||||
logger.debug("Leader 已在监听列表中: ${leader.leaderName} (${address})")
|
||||
return
|
||||
}
|
||||
|
||||
val leaderId = leader.id
|
||||
if (leaderId != null) {
|
||||
monitoredAddresses[address] = leaderId
|
||||
logger.info("添加 Leader 到 Activity WS 监听: ${leader.leaderName} (${address})")
|
||||
|
||||
// 如果 WebSocket 未连接,连接
|
||||
val client = wsClient
|
||||
if (client == null || !client.isConnected()) {
|
||||
connectAndSubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除 Leader
|
||||
*/
|
||||
fun removeLeader(leaderId: Long) {
|
||||
val addressToRemove = monitoredAddresses.entries
|
||||
.find { it.value == leaderId }?.key
|
||||
|
||||
if (addressToRemove != null) {
|
||||
monitoredAddresses.remove(addressToRemove)
|
||||
logger.info("从 Activity WS 监听移除 Leader: leaderId=$leaderId, address=$addressToRemove")
|
||||
}
|
||||
|
||||
// 如果没有 Leader 了,停止监听
|
||||
if (monitoredAddresses.isEmpty()) {
|
||||
logger.info("没有 Leader 需要监听了,停止 Activity WebSocket")
|
||||
stop()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接并订阅
|
||||
*/
|
||||
private fun connectAndSubscribe() {
|
||||
val existingClient = wsClient
|
||||
if (existingClient != null && existingClient.isConnected()) {
|
||||
// 如果已连接但未订阅,发送订阅
|
||||
if (!isSubscribed) {
|
||||
subscribeAllActivity()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("连接 Activity WebSocket: $websocketUrl")
|
||||
|
||||
val newClient = PolymarketWebSocketClient(
|
||||
url = websocketUrl,
|
||||
sessionId = "copy-trading-activity",
|
||||
onMessage = { message -> handleMessage(message) },
|
||||
onOpen = {
|
||||
logger.info("Activity WebSocket 连接成功")
|
||||
subscribeAllActivity()
|
||||
},
|
||||
onReconnect = {
|
||||
logger.info("Activity WebSocket 重连成功,重新订阅")
|
||||
subscribeAllActivity()
|
||||
}
|
||||
)
|
||||
|
||||
wsClient = newClient
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
newClient.connect()
|
||||
} catch (e: Exception) {
|
||||
logger.error("连接 Activity WebSocket 失败", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅全局 activity
|
||||
* 根据 @polymarket/real-time-data-client 的协议格式
|
||||
* 使用 "action": "subscribe" 而不是 "type": "subscribe"
|
||||
*/
|
||||
private fun subscribeAllActivity() {
|
||||
val client = wsClient
|
||||
if (client == null || !client.isConnected()) {
|
||||
logger.warn("WebSocket 未连接,无法订阅")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 根据 real-time-data-client 的协议格式
|
||||
// 订阅消息应包含 "action": "subscribe" 和 "subscriptions" 数组
|
||||
val subscribeMessage = """
|
||||
{
|
||||
"action": "subscribe",
|
||||
"subscriptions": [
|
||||
{
|
||||
"topic": "activity",
|
||||
"type": "trades"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
client.sendMessage(subscribeMessage)
|
||||
isSubscribed = true
|
||||
logger.info("Activity WebSocket 订阅成功(全局交易流)")
|
||||
} catch (e: Exception) {
|
||||
logger.error("订阅 Activity WebSocket 失败", e)
|
||||
isSubscribed = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理消息
|
||||
*/
|
||||
private fun handleMessage(message: String) {
|
||||
try {
|
||||
// 处理 PONG 响应
|
||||
if (message.trim() == "PONG" || message.trim() == "pong") {
|
||||
return
|
||||
}
|
||||
|
||||
// 使用扩展函数解析消息
|
||||
val tradeMessage = message.fromJson<ActivityTradeMessage>() ?: run {
|
||||
// 不是有效的 JSON 或格式不匹配,跳过
|
||||
logger.warn("无法解析为 ActivityTradeMessage,可能不是 activity 消息: ${message.take(200)}")
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否是 activity trade 消息
|
||||
if (tradeMessage.topic != "activity" || tradeMessage.type != "trades") {
|
||||
// 不是我们关心的消息,直接返回
|
||||
return
|
||||
}
|
||||
|
||||
val payload = tradeMessage.payload
|
||||
|
||||
// 提取交易者地址
|
||||
val traderAddress = extractTraderAddress(payload) ?: run {
|
||||
// 没有交易者地址,跳过
|
||||
logger.warn("Activity Trade 消息中没有交易者地址: trader=${payload.trader}, proxyWallet=${payload.proxyWallet}, asset=${payload.asset}")
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否是我们监听的 Leader
|
||||
val normalizedAddress = traderAddress.lowercase()
|
||||
val leaderId = monitoredAddresses[normalizedAddress] ?: run {
|
||||
return
|
||||
}
|
||||
|
||||
// 解析交易数据
|
||||
val trade = parseActivityTrade(payload, leaderId)
|
||||
if (trade != null) {
|
||||
logger.info("✅ 检测到 Leader 交易: leaderId=$leaderId, address=$traderAddress, side=${trade.side}, market=${trade.market}, size=${trade.size}")
|
||||
|
||||
// 异步处理交易(避免阻塞消息处理)
|
||||
scope.launch {
|
||||
try {
|
||||
copyOrderTrackingService.processTrade(
|
||||
leaderId = leaderId,
|
||||
trade = trade,
|
||||
source = "activity-ws"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("处理 Activity WS 交易失败: leaderId=$leaderId, tradeId=${trade.id}", e)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.warn("解析交易数据失败: leaderId=$leaderId, address=$traderAddress, asset=${payload.asset}, side=${payload.side}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("处理 Activity WebSocket 消息失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取交易者地址
|
||||
* 优先检查 trader.address,fallback 到 proxyWallet
|
||||
*/
|
||||
private fun extractTraderAddress(payload: ActivityTradePayload): String? {
|
||||
// 优先检查 trader.address
|
||||
val address = payload.trader?.address
|
||||
?: payload.proxyWallet
|
||||
|
||||
return address
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Activity Trade 为 TradeResponse
|
||||
*/
|
||||
private fun parseActivityTrade(payload: ActivityTradePayload, leaderId: Long): TradeResponse? {
|
||||
return try {
|
||||
// 提取必需字段并验证
|
||||
val asset = payload.asset
|
||||
val conditionId = payload.conditionId
|
||||
val sideRaw = payload.side
|
||||
|
||||
if (asset.isBlank() || conditionId.isBlank() || sideRaw.isBlank()) {
|
||||
logger.warn("Activity Trade 消息缺少必需字段: asset=$asset, conditionId=$conditionId, side=$sideRaw")
|
||||
return null
|
||||
}
|
||||
|
||||
val side = sideRaw.uppercase()
|
||||
|
||||
// 验证 side 必须是 BUY 或 SELL
|
||||
if (side != "BUY" && side != "SELL") {
|
||||
logger.warn("Activity Trade 消息 side 字段无效: side=$side")
|
||||
return null
|
||||
}
|
||||
|
||||
// price 和 size 可能是数字或字符串,统一转换为字符串
|
||||
val price = convertToString(payload.price) ?: run {
|
||||
logger.warn("Activity Trade 消息 price 字段无效: ${payload.price}")
|
||||
return null
|
||||
}
|
||||
|
||||
val size = convertToString(payload.size) ?: run {
|
||||
logger.warn("Activity Trade 消息 size 字段无效: ${payload.size}")
|
||||
return null
|
||||
}
|
||||
|
||||
// 时间戳处理:可能是秒或毫秒,可能是数字或字符串
|
||||
val timestamp = when {
|
||||
payload.timestamp == null -> System.currentTimeMillis().toString()
|
||||
payload.timestamp is Number -> {
|
||||
val ts = (payload.timestamp as Number).toLong()
|
||||
// 如果时间戳小于 1e12(秒级),转换为毫秒
|
||||
if (ts < 1e12) (ts * 1000).toString() else ts.toString()
|
||||
}
|
||||
|
||||
payload.timestamp is String -> {
|
||||
val tsStr = payload.timestamp as String
|
||||
val ts = tsStr.toLongOrNull() ?: System.currentTimeMillis()
|
||||
if (ts < 1e12) (ts * 1000).toString() else tsStr
|
||||
}
|
||||
|
||||
else -> System.currentTimeMillis().toString()
|
||||
}
|
||||
|
||||
// 解析 outcome 和 outcomeIndex
|
||||
// 优先使用消息中的 outcomeIndex,如果没有则从 outcome 字符串解析
|
||||
val outcome = payload.outcome
|
||||
val outcomeIndex = payload.outcomeIndex
|
||||
?: parseOutcomeIndex(outcome)
|
||||
|
||||
// 使用 transactionHash 作为 trade ID,如果没有则生成 fallback ID
|
||||
val tradeId = payload.transactionHash ?: "${leaderId}_${System.currentTimeMillis()}_${asset.take(10)}"
|
||||
|
||||
TradeResponse(
|
||||
id = tradeId,
|
||||
market = conditionId,
|
||||
side = side,
|
||||
price = price,
|
||||
size = size,
|
||||
timestamp = timestamp,
|
||||
user = null, // Activity WS 中不需要
|
||||
outcomeIndex = outcomeIndex,
|
||||
outcome = outcome
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("解析 Activity Trade 失败: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Any 类型的值转换为 String
|
||||
* 支持 Number、String、BigDecimal 等类型
|
||||
*/
|
||||
private fun convertToString(value: Any?): String? {
|
||||
if (value == null) return null
|
||||
|
||||
return when (value) {
|
||||
is String -> value
|
||||
is Number -> {
|
||||
// 如果是数字,转换为 BigDecimal 再转为字符串(保持精度)
|
||||
try {
|
||||
BigDecimal(value.toString()).toPlainString()
|
||||
} catch (e: Exception) {
|
||||
value.toString()
|
||||
}
|
||||
}
|
||||
|
||||
is BigDecimal -> value.toPlainString()
|
||||
else -> value.toString()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 outcome 为 outcomeIndex
|
||||
*/
|
||||
private fun parseOutcomeIndex(outcome: String?): Int? {
|
||||
return when (outcome?.uppercase()) {
|
||||
"YES", "UP", "TRUE" -> 0
|
||||
"NO", "DOWN", "FALSE" -> 1
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止监听
|
||||
*/
|
||||
fun stop() {
|
||||
logger.info("停止 Activity WebSocket 监听")
|
||||
wsClient?.closeConnection()
|
||||
wsClient = null
|
||||
isSubscribed = false
|
||||
monitoredAddresses.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取连接状态
|
||||
*/
|
||||
fun isConnected(): Boolean {
|
||||
return wsClient?.isConnected() ?: false
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取监听的 Leader 数量
|
||||
*/
|
||||
fun getMonitoredCount(): Int {
|
||||
return monitoredAddresses.size
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
fun destroy() {
|
||||
stop()
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
+318
-449
@@ -1,6 +1,7 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.monitor
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonObject
|
||||
import com.wrbug.polymarketbot.api.*
|
||||
import com.wrbug.polymarketbot.service.system.RpcNodeService
|
||||
@@ -19,6 +20,7 @@ import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* 统一的链上 WebSocket 服务
|
||||
@@ -38,27 +40,8 @@ class UnifiedOnChainWsService(
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
// WebSocket 连接(唯一)
|
||||
private var webSocket: WebSocket? = null
|
||||
@Volatile
|
||||
private var isConnected = false
|
||||
|
||||
// 订阅ID计数器(用于请求 ID)
|
||||
private var requestIdCounter = 0
|
||||
|
||||
// 连接任务(确保只有一个连接任务在运行)
|
||||
private var connectionJob: Job? = null
|
||||
|
||||
// 存储所有订阅:subscriptionId -> 订阅信息
|
||||
private val subscriptions = ConcurrentHashMap<String, SubscriptionInfo>()
|
||||
|
||||
// 存储请求 ID 到订阅 ID 的映射:requestId -> subscriptionId
|
||||
// 用于在收到订阅响应时,将 subscription ID 关联到对应的订阅
|
||||
private val requestIdToSubscriptionId = ConcurrentHashMap<Int, String>()
|
||||
|
||||
// 存储 RPC subscriptionId 到订阅 ID 的映射:rpcSubscriptionId -> subscriptionId
|
||||
// 用于在收到日志通知时,知道是哪个订阅
|
||||
private val rpcSubscriptionIdToSubscriptionId = ConcurrentHashMap<String, String>()
|
||||
// 存储所有地址的连接:address -> AddressWsConnection
|
||||
private val addressConnections = ConcurrentHashMap<String, AddressWsConnection>()
|
||||
|
||||
/**
|
||||
* 订阅信息
|
||||
@@ -88,31 +71,24 @@ class UnifiedOnChainWsService(
|
||||
callback: suspend (String, OkHttpClient, EthereumRpcApi) -> Unit
|
||||
): Boolean {
|
||||
try {
|
||||
// 如果已经订阅,先取消
|
||||
if (subscriptions.containsKey(subscriptionId)) {
|
||||
unsubscribe(subscriptionId)
|
||||
val lowerAddress = address.lowercase()
|
||||
|
||||
// 找到或创建该地址的连接
|
||||
val connection = addressConnections.computeIfAbsent(lowerAddress) {
|
||||
AddressWsConnection(it).apply { start() }
|
||||
}
|
||||
|
||||
// 创建订阅信息
|
||||
val subscription = SubscriptionInfo(
|
||||
subscriptionId = subscriptionId,
|
||||
address = address.lowercase(),
|
||||
address = lowerAddress,
|
||||
entityType = entityType,
|
||||
entityId = entityId,
|
||||
callback = callback
|
||||
)
|
||||
|
||||
subscriptions[subscriptionId] = subscription
|
||||
|
||||
// 如果已连接,立即订阅
|
||||
if (isConnected) {
|
||||
scope.launch {
|
||||
subscribeAddress(subscription)
|
||||
}
|
||||
} else {
|
||||
// 如果未连接,启动连接
|
||||
startConnection()
|
||||
}
|
||||
// 添加订阅
|
||||
connection.addSubscription(subscription)
|
||||
|
||||
logger.info("订阅地址监听: subscriptionId=$subscriptionId, address=$address, entityType=$entityType, entityId=$entityId")
|
||||
return true
|
||||
@@ -126,434 +102,37 @@ class UnifiedOnChainWsService(
|
||||
* 取消订阅
|
||||
*/
|
||||
fun unsubscribe(subscriptionId: String) {
|
||||
val subscription = subscriptions.remove(subscriptionId)
|
||||
|
||||
if (subscription != null && isConnected) {
|
||||
// 取消该订阅的所有 RPC 订阅
|
||||
scope.launch {
|
||||
// 查找该订阅的所有 RPC subscriptionId
|
||||
val rpcSubscriptionIds = rpcSubscriptionIdToSubscriptionId.entries
|
||||
.filter { it.value == subscriptionId }
|
||||
.map { it.key }
|
||||
// 遍历所有连接找到含有该订阅的连接
|
||||
for (connection in addressConnections.values) {
|
||||
if (connection.hasSubscription(subscriptionId)) {
|
||||
connection.removeSubscription(subscriptionId)
|
||||
|
||||
for (rpcSubId in rpcSubscriptionIds) {
|
||||
unsubscribeRpc(rpcSubId)
|
||||
rpcSubscriptionIdToSubscriptionId.remove(rpcSubId)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("取消订阅: subscriptionId=$subscriptionId")
|
||||
}
|
||||
|
||||
// 如果没有订阅了,停止连接
|
||||
if (subscriptions.isEmpty()) {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动连接(如果还没有连接)
|
||||
*/
|
||||
private fun startConnection() {
|
||||
// 如果没有订阅,不启动连接
|
||||
if (subscriptions.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果连接任务已经在运行,不重复启动
|
||||
if (connectionJob != null && connectionJob!!.isActive) {
|
||||
return
|
||||
}
|
||||
|
||||
// 启动连接任务
|
||||
connectionJob = scope.launch {
|
||||
startConnectionLoop()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动连接循环
|
||||
*/
|
||||
private suspend fun startConnectionLoop() {
|
||||
while (scope.isActive) {
|
||||
try {
|
||||
// 如果没有订阅,停止连接
|
||||
if (subscriptions.isEmpty()) {
|
||||
logger.info("没有订阅,停止连接")
|
||||
stop()
|
||||
break
|
||||
// 如果该连接没有订阅了,停止并移除
|
||||
if (connection.isSubscriptionsEmpty()) {
|
||||
connection.stop()
|
||||
addressConnections.remove(connection.address)
|
||||
logger.info("连接已无订阅,关闭连接: address=${connection.address}")
|
||||
}
|
||||
|
||||
// 如果已经连接,等待断开
|
||||
if (isConnected && webSocket != null) {
|
||||
waitForDisconnect()
|
||||
continue
|
||||
}
|
||||
|
||||
// 获取可用的 RPC 节点
|
||||
val wsUrl = rpcNodeService.getWsUrl()
|
||||
val httpUrl = rpcNodeService.getHttpUrl()
|
||||
|
||||
if (wsUrl.isBlank() || httpUrl.isBlank()) {
|
||||
logger.warn("没有可用的 RPC 节点,等待重试...")
|
||||
delay(reconnectDelay)
|
||||
continue
|
||||
}
|
||||
|
||||
logger.info("连接链上 WebSocket: $wsUrl (${subscriptions.size} 个订阅)")
|
||||
|
||||
// 创建 HTTP 客户端(用于 RPC 调用)
|
||||
val httpClient = createHttpClient()
|
||||
|
||||
// 创建 RPC API 客户端
|
||||
val rpcApi = retrofitFactory.createEthereumRpcApi(httpUrl)
|
||||
|
||||
// 连接 WebSocket
|
||||
connectWebSocket(wsUrl, httpClient, rpcApi)
|
||||
|
||||
// 等待连接建立
|
||||
waitForConnect()
|
||||
|
||||
// 如果连接成功,订阅所有地址
|
||||
if (isConnected) {
|
||||
logger.info("WebSocket 连接已建立,开始订阅")
|
||||
for (subscription in subscriptions.values) {
|
||||
subscribeAddress(subscription)
|
||||
}
|
||||
|
||||
// 等待连接断开
|
||||
waitForDisconnect()
|
||||
}
|
||||
|
||||
// 连接断开后,如果没有订阅了,不再重连
|
||||
if (subscriptions.isEmpty()) {
|
||||
logger.info("没有订阅,停止重连")
|
||||
break
|
||||
}
|
||||
|
||||
// 等待后重连
|
||||
logger.info("WebSocket 连接断开,等待 ${reconnectDelay}ms 后重连")
|
||||
delay(reconnectDelay)
|
||||
|
||||
} catch (e: Exception) {
|
||||
logger.error("连接异常: ${e.message}", e)
|
||||
delay(reconnectDelay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 HTTP 客户端
|
||||
*/
|
||||
private fun createHttpClient(): OkHttpClient {
|
||||
val proxy = getProxyConfig()
|
||||
val builder = createClient()
|
||||
|
||||
if (proxy != null) {
|
||||
builder.proxy(proxy)
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接 WebSocket
|
||||
*/
|
||||
private fun connectWebSocket(wsUrl: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
|
||||
// 先关闭旧连接
|
||||
webSocket?.close(1000, "重新连接")
|
||||
webSocket = null
|
||||
isConnected = false
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(wsUrl)
|
||||
.build()
|
||||
|
||||
webSocket = httpClient.newWebSocket(request, object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
|
||||
isConnected = true
|
||||
logger.info("链上 WebSocket 连接成功")
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
scope.launch {
|
||||
handleMessage(text, httpClient, rpcApi)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
|
||||
scope.launch {
|
||||
handleMessage(bytes.utf8(), httpClient, rpcApi)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
isConnected = false
|
||||
logger.warn("链上 WebSocket 连接关闭: code=$code, reason=$reason")
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
isConnected = false
|
||||
logger.warn("链上 WebSocket 连接已关闭: code=$code, reason=$reason")
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
|
||||
logger.error("链上 WebSocket 连接失败: ${t.message}", t)
|
||||
isConnected = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待连接建立
|
||||
*/
|
||||
private suspend fun waitForConnect() {
|
||||
var waited = 0L
|
||||
val timeout = 15000L // 15秒超时
|
||||
|
||||
while (!isConnected && waited < timeout) {
|
||||
delay(100)
|
||||
waited += 100
|
||||
}
|
||||
|
||||
if (!isConnected) {
|
||||
logger.warn("WebSocket 连接超时,等待重连")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待连接断开
|
||||
*/
|
||||
private suspend fun waitForDisconnect() {
|
||||
while (isConnected && scope.isActive) {
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅地址(为每个地址订阅 6 个事件)
|
||||
*/
|
||||
private suspend fun subscribeAddress(subscription: SubscriptionInfo) {
|
||||
if (webSocket == null || !isConnected) {
|
||||
return
|
||||
}
|
||||
|
||||
val address = subscription.address
|
||||
val walletTopic = OnChainWsUtils.addressToTopic32(address)
|
||||
val subscriptionId = subscription.subscriptionId
|
||||
|
||||
try {
|
||||
// 订阅 USDC Transfer (from wallet)
|
||||
subscribeLogs(OnChainWsUtils.USDC_CONTRACT, listOf(OnChainWsUtils.ERC20_TRANSFER_TOPIC, walletTopic), subscriptionId)
|
||||
|
||||
// 订阅 USDC Transfer (to wallet)
|
||||
subscribeLogs(OnChainWsUtils.USDC_CONTRACT, listOf(OnChainWsUtils.ERC20_TRANSFER_TOPIC, null, walletTopic), subscriptionId)
|
||||
|
||||
// 订阅 ERC1155 TransferSingle (from wallet)
|
||||
subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_SINGLE_TOPIC, null, walletTopic), subscriptionId)
|
||||
|
||||
// 订阅 ERC1155 TransferSingle (to wallet)
|
||||
subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_SINGLE_TOPIC, null, null, walletTopic), subscriptionId)
|
||||
|
||||
// 订阅 ERC1155 TransferBatch (from wallet)
|
||||
subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_BATCH_TOPIC, null, walletTopic), subscriptionId)
|
||||
|
||||
// 订阅 ERC1155 TransferBatch (to wallet)
|
||||
subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_BATCH_TOPIC, null, null, walletTopic), subscriptionId)
|
||||
|
||||
logger.debug("已订阅地址: subscriptionId=$subscriptionId, address=$address")
|
||||
} catch (e: Exception) {
|
||||
logger.error("订阅地址失败: subscriptionId=$subscriptionId, address=$address, error=${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅日志
|
||||
*/
|
||||
private fun subscribeLogs(address: String, topics: List<String?>, subscriptionId: String) {
|
||||
val ws = webSocket ?: return
|
||||
|
||||
val params = mapOf(
|
||||
"address" to address.lowercase(),
|
||||
"topics" to topics.filterNotNull()
|
||||
)
|
||||
|
||||
val requestId = ++requestIdCounter
|
||||
requestIdToSubscriptionId[requestId] = subscriptionId
|
||||
|
||||
val request = mapOf(
|
||||
"jsonrpc" to "2.0",
|
||||
"id" to requestId,
|
||||
"method" to "eth_subscribe",
|
||||
"params" to listOf("logs", params)
|
||||
)
|
||||
|
||||
val message = gson.toJson(request)
|
||||
ws.send(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消 RPC 订阅
|
||||
*/
|
||||
private fun unsubscribeRpc(rpcSubscriptionId: String) {
|
||||
val ws = webSocket ?: return
|
||||
|
||||
val requestId = ++requestIdCounter
|
||||
val request = mapOf(
|
||||
"jsonrpc" to "2.0",
|
||||
"id" to requestId,
|
||||
"method" to "eth_unsubscribe",
|
||||
"params" to listOf(rpcSubscriptionId)
|
||||
)
|
||||
|
||||
val message = gson.toJson(request)
|
||||
ws.send(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 WebSocket 消息
|
||||
*/
|
||||
private suspend fun handleMessage(text: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
|
||||
try {
|
||||
val message = gson.fromJson(text, JsonObject::class.java)
|
||||
|
||||
// 处理订阅响应
|
||||
if (message.has("result") && message.has("id")) {
|
||||
val requestId = message.get("id")?.asInt
|
||||
val rpcSubscriptionId = message.get("result")?.asString
|
||||
|
||||
if (requestId != null && rpcSubscriptionId != null) {
|
||||
val subscriptionId = requestIdToSubscriptionId.remove(requestId)
|
||||
if (subscriptionId != null) {
|
||||
// 保存 RPC subscriptionId 到订阅的映射
|
||||
rpcSubscriptionIdToSubscriptionId[rpcSubscriptionId] = subscriptionId
|
||||
logger.debug("订阅成功: subscriptionId=$subscriptionId, rpcSubscriptionId=$rpcSubscriptionId")
|
||||
}
|
||||
}
|
||||
logger.info("取消订阅: subscriptionId=$subscriptionId")
|
||||
return
|
||||
}
|
||||
|
||||
// 处理日志通知
|
||||
if (message.has("params")) {
|
||||
val params = message.getAsJsonObject("params")
|
||||
val subscriptionIdParam = params.get("subscription")?.asString
|
||||
val result = params.getAsJsonObject("result")
|
||||
|
||||
if (result != null) {
|
||||
val txHash = result.get("transactionHash")?.asString
|
||||
if (txHash != null && subscriptionIdParam != null) {
|
||||
// 根据 RPC subscriptionId 找到对应的订阅
|
||||
val subscriptionId = rpcSubscriptionIdToSubscriptionId[subscriptionIdParam]
|
||||
if (subscriptionId != null) {
|
||||
// 处理交易,分发给对应的订阅者
|
||||
processTransactionForSubscription(txHash, subscriptionId, httpClient, rpcApi)
|
||||
} else {
|
||||
// 如果没有找到订阅,可能是新订阅还未建立映射,尝试处理所有订阅
|
||||
processTransaction(txHash, httpClient, rpcApi)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("处理 WebSocket 消息失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理交易(为特定订阅)
|
||||
* 直接调用订阅的回调
|
||||
*/
|
||||
private suspend fun processTransactionForSubscription(
|
||||
txHash: String,
|
||||
subscriptionId: String,
|
||||
httpClient: OkHttpClient,
|
||||
rpcApi: EthereumRpcApi
|
||||
) {
|
||||
val subscription = subscriptions[subscriptionId] ?: return
|
||||
|
||||
try {
|
||||
subscription.callback(txHash, httpClient, rpcApi)
|
||||
} catch (e: Exception) {
|
||||
logger.error("调用订阅回调失败: subscriptionId=$subscriptionId, txHash=$txHash, error=${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理交易(为所有订阅,用于兼容)
|
||||
* 解析交易中的 Transfer 事件,分发给所有订阅者
|
||||
*/
|
||||
private suspend fun processTransaction(txHash: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
|
||||
try {
|
||||
// 获取交易 receipt
|
||||
val receiptRequest = JsonRpcRequest(
|
||||
method = "eth_getTransactionReceipt",
|
||||
params = listOf(txHash)
|
||||
)
|
||||
|
||||
val receiptResponse = rpcApi.call(receiptRequest)
|
||||
if (!receiptResponse.isSuccessful || receiptResponse.body() == null) {
|
||||
return
|
||||
}
|
||||
|
||||
val receiptRpcResponse = receiptResponse.body()!!
|
||||
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null) {
|
||||
return
|
||||
}
|
||||
|
||||
// 使用 Gson 解析 receipt JSON
|
||||
val receiptJson = receiptRpcResponse.result.asJsonObject
|
||||
|
||||
// 解析 receipt 中的 Transfer 日志
|
||||
val logs = receiptJson.getAsJsonArray("logs") ?: return
|
||||
val (erc20Transfers, erc1155Transfers) = OnChainWsUtils.parseReceiptTransfers(logs)
|
||||
|
||||
// 为每个订阅检查是否匹配,如果匹配则调用回调
|
||||
for (subscription in subscriptions.values) {
|
||||
val address = subscription.address
|
||||
|
||||
// 检查该地址是否参与了交易(通过检查 Transfer 日志)
|
||||
val isInvolved = erc20Transfers.any {
|
||||
it.from.lowercase() == address || it.to.lowercase() == address
|
||||
} || erc1155Transfers.any {
|
||||
it.from.lowercase() == address || it.to.lowercase() == address
|
||||
}
|
||||
|
||||
if (isInvolved) {
|
||||
// 该地址参与了交易,调用回调
|
||||
try {
|
||||
subscription.callback(txHash, httpClient, rpcApi)
|
||||
} catch (e: Exception) {
|
||||
logger.error("调用订阅回调失败: subscriptionId=${subscription.subscriptionId}, txHash=$txHash, error=${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("处理交易失败: txHash=$txHash, ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止连接
|
||||
* 停止所有服务
|
||||
*/
|
||||
fun stop() {
|
||||
connectionJob?.cancel()
|
||||
connectionJob = null
|
||||
|
||||
// 关闭 WebSocket 连接
|
||||
webSocket?.close(1000, "停止监听")
|
||||
webSocket = null
|
||||
isConnected = false
|
||||
|
||||
// 清空订阅信息
|
||||
subscriptions.clear()
|
||||
requestIdToSubscriptionId.clear()
|
||||
rpcSubscriptionIdToSubscriptionId.clear()
|
||||
for (connection in addressConnections.values) {
|
||||
connection.stop()
|
||||
}
|
||||
addressConnections.clear()
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
fun init() {
|
||||
// 服务启动时不自动连接,等待有订阅时再连接
|
||||
logger.info("统一链上 WebSocket 服务已初始化")
|
||||
logger.info("统一链上 WebSocket 服务已初始化 (独立连接模式)")
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
@@ -561,5 +140,295 @@ class UnifiedOnChainWsService(
|
||||
stop()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个地址的 WebSocket 连接管理
|
||||
*/
|
||||
inner class AddressWsConnection(val address: String) {
|
||||
private var webSocket: WebSocket? = null
|
||||
@Volatile
|
||||
private var isConnected = false
|
||||
|
||||
// 订阅ID计数器(用于请求 ID)
|
||||
private var requestIdCounter = AtomicInteger(0)
|
||||
|
||||
// 连接任务
|
||||
private var connectionJob: Job? = null
|
||||
|
||||
// 该连接下的所有订阅:subscriptionId -> SubscriptionInfo
|
||||
// 理论上一个地址可能被多个业务订阅(如:既是被跟单者又是普通监控),虽然业务上通常只有一个
|
||||
private val subscriptions = ConcurrentHashMap<String, SubscriptionInfo>()
|
||||
|
||||
// 存储请求 ID 到订阅 ID 的映射:requestId -> subscriptionId
|
||||
private val requestIdToSubscriptionId = ConcurrentHashMap<Int, String>()
|
||||
|
||||
// 存储 RPC subscriptionId 到订阅 ID 的映射:rpcSubscriptionId -> subscriptionId
|
||||
private val rpcSubscriptionIdToSubscriptionId = ConcurrentHashMap<String, String>()
|
||||
|
||||
fun start() {
|
||||
if (connectionJob != null && connectionJob!!.isActive) return
|
||||
connectionJob = scope.launch {
|
||||
startConnectionLoop()
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
connectionJob?.cancel()
|
||||
connectionJob = null
|
||||
webSocket?.close(1000, "停止监听")
|
||||
webSocket = null
|
||||
isConnected = false
|
||||
subscriptions.clear()
|
||||
requestIdToSubscriptionId.clear()
|
||||
rpcSubscriptionIdToSubscriptionId.clear()
|
||||
}
|
||||
|
||||
fun addSubscription(subscription: SubscriptionInfo) {
|
||||
// 如果已经存在,先移除旧的
|
||||
removeSubscription(subscription.subscriptionId)
|
||||
subscriptions[subscription.subscriptionId] = subscription
|
||||
|
||||
// 如果已经连接,立即发送链上订阅请求
|
||||
if (isConnected) {
|
||||
scope.launch {
|
||||
subscribeAddressOnChain(subscription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeSubscription(subscriptionId: String) {
|
||||
subscriptions.remove(subscriptionId)
|
||||
// 不需要显式发送 eth_unsubscribe,因为连接是 per-address 的,
|
||||
// 只要只要连接还在,就保持该地址相关的所有 logs 订阅。
|
||||
// 只有当所有 subscription 都移除了,连接才会关闭。
|
||||
}
|
||||
|
||||
fun hasSubscription(subscriptionId: String): Boolean {
|
||||
return subscriptions.containsKey(subscriptionId)
|
||||
}
|
||||
|
||||
fun isSubscriptionsEmpty(): Boolean {
|
||||
return subscriptions.isEmpty()
|
||||
}
|
||||
|
||||
private suspend fun startConnectionLoop() {
|
||||
while (scope.isActive) {
|
||||
try {
|
||||
if (subscriptions.isEmpty()) {
|
||||
// 如果启动循环时还没订阅(不太可能,通常是先 addSubscription 再 start,或者是 start 后 addSubscription)
|
||||
// 或者订阅被清空了,外部应当掉 stop,但这里作为防守
|
||||
delay(1000)
|
||||
continue
|
||||
}
|
||||
|
||||
if (isConnected && webSocket != null) {
|
||||
waitForDisconnect()
|
||||
continue
|
||||
}
|
||||
|
||||
// 获取可用的 RPC 节点
|
||||
val wsUrl = rpcNodeService.getWsUrl()
|
||||
val httpUrl = rpcNodeService.getHttpUrl()
|
||||
|
||||
logger.info("[$address] 连接链上 WebSocket: $wsUrl")
|
||||
|
||||
val httpClient = createHttpClient()
|
||||
val rpcApi = retrofitFactory.createEthereumRpcApi(httpUrl)
|
||||
|
||||
connectWebSocket(wsUrl, httpClient, rpcApi)
|
||||
waitForConnect()
|
||||
|
||||
if (isConnected) {
|
||||
logger.info("[$address] WebSocket 连接已建立,开始注册订阅")
|
||||
// 重新为所有订阅注册链上监听
|
||||
for (subscription in subscriptions.values) {
|
||||
subscribeAddressOnChain(subscription)
|
||||
}
|
||||
waitForDisconnect()
|
||||
}
|
||||
|
||||
logger.info("[$address] WebSocket 连接断开,等待 ${reconnectDelay}ms 后重连")
|
||||
delay(reconnectDelay)
|
||||
|
||||
} catch (e: Exception) {
|
||||
logger.error("[$address] 连接异常: ${e.message}", e)
|
||||
delay(reconnectDelay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun connectWebSocket(wsUrl: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
|
||||
webSocket?.close(1000, "重新连接")
|
||||
webSocket = null
|
||||
isConnected = false
|
||||
|
||||
val request = Request.Builder().url(wsUrl).build()
|
||||
|
||||
webSocket = httpClient.newWebSocket(request, object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
|
||||
isConnected = true
|
||||
logger.info("[$address] 链上 WebSocket 连接成功")
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
scope.launch { handleMessage(text, httpClient, rpcApi) }
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
|
||||
scope.launch { handleMessage(bytes.utf8(), httpClient, rpcApi) }
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
isConnected = false
|
||||
logger.warn("[$address] 链上 WebSocket 连接关闭: code=$code, reason=$reason")
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
isConnected = false
|
||||
logger.warn("[$address] 链上 WebSocket 连接已关闭: code=$code, reason=$reason")
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
|
||||
logger.error("[$address] 链上 WebSocket 连接失败: ${t.message}", t)
|
||||
isConnected = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private suspend fun subscribeAddressOnChain(subscription: SubscriptionInfo) {
|
||||
if (webSocket == null || !isConnected) return
|
||||
|
||||
val walletTopic = OnChainWsUtils.addressToTopic32(address)
|
||||
val subId = subscription.subscriptionId
|
||||
|
||||
try {
|
||||
// 订阅该地址相关的所有事件
|
||||
// USDC Transfer (from/to)
|
||||
subscribeLogs(OnChainWsUtils.USDC_CONTRACT, listOf(OnChainWsUtils.ERC20_TRANSFER_TOPIC, walletTopic), subId)
|
||||
subscribeLogs(OnChainWsUtils.USDC_CONTRACT, listOf(OnChainWsUtils.ERC20_TRANSFER_TOPIC, null, walletTopic), subId)
|
||||
|
||||
// ERC1155 TransferSingle (from/to)
|
||||
subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_SINGLE_TOPIC, null, walletTopic), subId)
|
||||
subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_SINGLE_TOPIC, null, null, walletTopic), subId)
|
||||
|
||||
// ERC1155 TransferBatch (from/to)
|
||||
subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_BATCH_TOPIC, null, walletTopic), subId)
|
||||
subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_BATCH_TOPIC, null, null, walletTopic), subId)
|
||||
|
||||
logger.debug("[$address] 已发送链上订阅请求: subscriptionId=$subId")
|
||||
} catch (e: Exception) {
|
||||
logger.error("[$address] 发送链上订阅请求失败: error=${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun subscribeLogs(contractAddress: String, topics: List<String?>, subscriptionId: String) {
|
||||
val ws = webSocket ?: return
|
||||
|
||||
val topicsArray = gson.toJsonTree(topics).asJsonArray
|
||||
val logParams = JsonObject()
|
||||
logParams.addProperty("address", contractAddress.lowercase())
|
||||
logParams.add("topics", topicsArray)
|
||||
|
||||
val requestId = requestIdCounter.incrementAndGet()
|
||||
requestIdToSubscriptionId[requestId] = subscriptionId
|
||||
|
||||
val request = JsonObject()
|
||||
request.addProperty("jsonrpc", "2.0")
|
||||
request.addProperty("id", requestId)
|
||||
request.addProperty("method", "eth_subscribe")
|
||||
val paramsArray = JsonArray()
|
||||
paramsArray.add("logs")
|
||||
paramsArray.add(logParams)
|
||||
request.add("params", paramsArray)
|
||||
|
||||
ws.send(gson.toJson(request))
|
||||
}
|
||||
|
||||
private suspend fun handleMessage(text: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
|
||||
try {
|
||||
val message = gson.fromJson(text, JsonObject::class.java)
|
||||
|
||||
// 1. 处理订阅响应 (eth_subscribe response)
|
||||
if (message.has("result") && message.has("id")) {
|
||||
val requestId = message.get("id")?.asInt
|
||||
val rpcSubscriptionId = message.get("result")?.asString
|
||||
|
||||
if (requestId != null && rpcSubscriptionId != null) {
|
||||
val subId = requestIdToSubscriptionId.remove(requestId)
|
||||
if (subId != null) {
|
||||
rpcSubscriptionIdToSubscriptionId[rpcSubscriptionId] = subId
|
||||
logger.debug("[$address] 链上订阅成功: mapped connection rpcSubId=$rpcSubscriptionId to localSubId=$subId")
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 处理日志通知 (eth_subscription)
|
||||
val method = message.get("method")?.asString
|
||||
if (method == "eth_subscription") {
|
||||
val params = message.getAsJsonObject("params") ?: return
|
||||
val rpcSubParam = params.get("subscription")?.asString
|
||||
val result = params.getAsJsonObject("result") ?: return
|
||||
val txHash = result.get("transactionHash")?.asString
|
||||
|
||||
if (txHash != null && rpcSubParam != null) {
|
||||
// 找到触发此通知的本地订阅 ID
|
||||
// 因为我们在这个连接里只订阅了 this.address,所以理论上所有通知都跟这个 address 有关
|
||||
// 但我们需要找到对应的 callback
|
||||
val localSubId = rpcSubscriptionIdToSubscriptionId[rpcSubParam]
|
||||
|
||||
if (localSubId != null) {
|
||||
val subscription = subscriptions[localSubId]
|
||||
if (subscription != null) {
|
||||
logger.info("[$address] 收到交易通知: txHash=$txHash, subId=$localSubId")
|
||||
runCatching {
|
||||
subscription.callback(txHash, httpClient, rpcApi)
|
||||
}.onFailure { e ->
|
||||
logger.error("[$address] 回调执行失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 找不到具体是哪个订阅请求触发的(可能是重启后之前的订阅残留?或者映射丢失?)
|
||||
// 在单地址单连接模式下,只要是这个 connection 收到的,肯定是关于这个 address 的
|
||||
// 我们可以尝试通知所有订阅者(通常一个地址只有一个订阅者,除非此地址既是Leader又是User)
|
||||
logger.warn("[$address] 未找到映射的订阅ID: rpcSubId=$rpcSubParam. 广播给所有订阅者.")
|
||||
subscriptions.values.forEach { sub ->
|
||||
runCatching {
|
||||
sub.callback(txHash, httpClient, rpcApi)
|
||||
}.onFailure { e ->
|
||||
logger.error("[$address] 广播回调执行失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("[$address] 处理消息失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun waitForConnect() {
|
||||
var waited = 0L
|
||||
val timeout = 15000L
|
||||
while (!isConnected && waited < timeout) {
|
||||
delay(100)
|
||||
waited += 100
|
||||
}
|
||||
if (!isConnected) logger.warn("[$address] WebSocket 连接超时")
|
||||
}
|
||||
|
||||
private suspend fun waitForDisconnect() {
|
||||
while (isConnected && scope.isActive) {
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createHttpClient(): OkHttpClient {
|
||||
val proxy = getProxyConfig()
|
||||
val builder = createClient()
|
||||
if (proxy != null) builder.proxy(proxy)
|
||||
return builder.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
-41
@@ -1,7 +1,6 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.orders
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.wrbug.polymarketbot.api.MarketResponse
|
||||
import com.wrbug.polymarketbot.dto.OrderDetailDto
|
||||
import com.wrbug.polymarketbot.dto.OrderMessageDto
|
||||
import com.wrbug.polymarketbot.dto.OrderPushMessage
|
||||
@@ -19,6 +18,7 @@ import com.wrbug.polymarketbot.util.CryptoUtils
|
||||
import com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository
|
||||
import com.wrbug.polymarketbot.repository.CopyTradingRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
import com.wrbug.polymarketbot.service.common.MarketService
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
@@ -35,7 +35,8 @@ class OrderPushService(
|
||||
private val cryptoUtils: CryptoUtils,
|
||||
private val copyOrderTrackingRepository: CopyOrderTrackingRepository? = null, // 可选,避免循环依赖
|
||||
private val copyTradingRepository: CopyTradingRepository? = null, // 可选,避免循环依赖
|
||||
private val leaderRepository: LeaderRepository? = null // 可选,避免循环依赖
|
||||
private val leaderRepository: LeaderRepository? = null, // 可选,避免循环依赖
|
||||
private val marketService: MarketService // 市场信息服务
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(OrderPushService::class.java)
|
||||
@@ -422,8 +423,8 @@ class OrderPushService(
|
||||
|
||||
result.fold(
|
||||
onSuccess = { openOrder ->
|
||||
// 获取市场信息(通过 Gamma API)
|
||||
val marketInfo = fetchMarketInfo(conditionId ?: openOrder.market)
|
||||
// 获取市场信息(使用 MarketService,优先从数据库/缓存获取)
|
||||
val market = marketService.getMarket(conditionId ?: openOrder.market)
|
||||
|
||||
// 转换为 DTO
|
||||
// 注意:createdAt 是 unix timestamp (Long),需要转换为字符串
|
||||
@@ -436,9 +437,9 @@ class OrderPushService(
|
||||
filled = openOrder.sizeMatched, // 使用 size_matched
|
||||
status = openOrder.status,
|
||||
createdAt = openOrder.createdAt.toString(), // unix timestamp 转换为字符串
|
||||
marketName = marketInfo?.question,
|
||||
marketSlug = marketInfo?.slug,
|
||||
marketIcon = marketInfo?.icon
|
||||
marketName = market?.title,
|
||||
marketSlug = market?.slug, // 显示用的 slug
|
||||
marketIcon = market?.icon
|
||||
)
|
||||
},
|
||||
onFailure = { e ->
|
||||
@@ -452,40 +453,6 @@ class OrderPushService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取市场信息(通过 Gamma API)
|
||||
* 文档: https://docs.polymarket.com/api-reference/markets/list-markets
|
||||
*
|
||||
* 使用 /markets 接口,通过 condition_ids 查询参数获取市场信息
|
||||
* 订单返回的 market 字段是 16 进制的 condition ID(如 "0x...")
|
||||
*/
|
||||
private suspend fun fetchMarketInfo(conditionId: String): MarketResponse? {
|
||||
return try {
|
||||
// 创建 Gamma API 客户端(公开 API,不需要认证)
|
||||
val gammaApi = retrofitFactory.createGammaApi()
|
||||
|
||||
// 调用 Gamma API 获取市场信息
|
||||
// 使用 /markets 接口,通过 condition_ids 查询参数
|
||||
val response = gammaApi.listMarkets(
|
||||
conditionIds = listOf(conditionId),
|
||||
includeTag = null
|
||||
)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val markets = response.body()!!
|
||||
if (markets.isNotEmpty()) {
|
||||
val market = markets.first()
|
||||
return market
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅账户的订单推送(保留用于向后兼容)
|
||||
*/
|
||||
|
||||
+285
-190
@@ -19,12 +19,14 @@ import com.wrbug.polymarketbot.service.copytrading.configs.CopyTradingFilterServ
|
||||
import com.wrbug.polymarketbot.service.copytrading.configs.FilterStatus
|
||||
import com.wrbug.polymarketbot.service.copytrading.orders.OrderSigningService
|
||||
import com.wrbug.polymarketbot.service.common.BlockchainService
|
||||
import com.wrbug.polymarketbot.service.common.MarketService
|
||||
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
||||
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
|
||||
import com.wrbug.polymarketbot.util.CryptoUtils
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* 订单跟踪服务
|
||||
@@ -47,6 +49,7 @@ open class CopyOrderTrackingService(
|
||||
private val clobService: PolymarketClobService,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val cryptoUtils: CryptoUtils,
|
||||
private val marketService: MarketService, // 市场信息服务
|
||||
private val telegramNotificationService: TelegramNotificationService? = null // 可选,避免循环依赖
|
||||
) {
|
||||
|
||||
@@ -54,16 +57,16 @@ open class CopyOrderTrackingService(
|
||||
|
||||
// 协程作用域(用于异步发送通知)
|
||||
private val notificationScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
|
||||
// 使用 Mutex 保证线程安全(按交易ID锁定)
|
||||
private val tradeMutexMap = ConcurrentHashMap<String, Mutex>()
|
||||
|
||||
|
||||
// 订单创建重试配置
|
||||
companion object {
|
||||
private const val MAX_RETRY_ATTEMPTS = 2 // 最多重试次数(首次 + 1次重试)
|
||||
private const val RETRY_DELAY_MS = 3000L // 重试前等待时间(毫秒,3秒)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取或创建 Mutex(按交易ID)
|
||||
*/
|
||||
@@ -121,85 +124,86 @@ open class CopyOrderTrackingService(
|
||||
suspend fun processTrade(leaderId: Long, trade: TradeResponse, source: String): Result<Unit> {
|
||||
// 获取该交易的 Mutex(按交易ID锁定,不同交易可以并行处理)
|
||||
val mutex = getMutex(leaderId, trade.id)
|
||||
|
||||
logger.debug("processTrade: ${trade.id}, $source")
|
||||
return mutex.withLock {
|
||||
try {
|
||||
// 1. 检查是否已处理(去重,包括失败状态)
|
||||
val existingProcessed = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
|
||||
// 1. 检查是否已处理(去重,包括失败状态)
|
||||
val existingProcessed = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
|
||||
|
||||
if (existingProcessed != null) {
|
||||
if (existingProcessed.status == "FAILED") {
|
||||
if (existingProcessed != null) {
|
||||
logger.debug("processTrade: 重复 ${trade.id}, $source")
|
||||
if (existingProcessed.status == "FAILED") {
|
||||
return@withLock Result.success(Unit)
|
||||
}
|
||||
return@withLock Result.success(Unit)
|
||||
}
|
||||
|
||||
// 2. 处理交易逻辑
|
||||
val result = when (trade.side.uppercase()) {
|
||||
"BUY" -> processBuyTrade(leaderId, trade)
|
||||
"SELL" -> processSellTrade(leaderId, trade)
|
||||
else -> {
|
||||
logger.warn("未知的交易方向: ${trade.side}")
|
||||
Result.failure(IllegalArgumentException("未知的交易方向: ${trade.side}"))
|
||||
}
|
||||
}
|
||||
|
||||
if (result.isFailure) {
|
||||
logger.error(
|
||||
"处理交易失败: leaderId=$leaderId, tradeId=${trade.id}, side=${trade.side}",
|
||||
result.exceptionOrNull()
|
||||
)
|
||||
return@withLock result
|
||||
}
|
||||
|
||||
// 3. 标记为已处理(成功状态)
|
||||
// 由于使用了 Mutex,这里理论上不会出现并发冲突,但保留异常处理作为兜底
|
||||
try {
|
||||
val processed = ProcessedTrade(
|
||||
leaderId = leaderId,
|
||||
leaderTradeId = trade.id,
|
||||
tradeType = trade.side.uppercase(),
|
||||
source = source,
|
||||
status = "SUCCESS",
|
||||
processedAt = System.currentTimeMillis()
|
||||
)
|
||||
processedTradeRepository.save(processed)
|
||||
} catch (e: Exception) {
|
||||
// 检查是否是唯一键冲突异常(理论上不会发生,但保留作为兜底)
|
||||
if (isUniqueConstraintViolation(e)) {
|
||||
val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
|
||||
if (existing != null) {
|
||||
if (existing.status == "FAILED") {
|
||||
logger.debug("交易已标记为失败,跳过处理: leaderId=$leaderId, tradeId=${trade.id}")
|
||||
return@withLock Result.success(Unit)
|
||||
}
|
||||
logger.debug("交易已处理(并发检测): leaderId=$leaderId, tradeId=${trade.id}, status=${existing.status}")
|
||||
return@withLock Result.success(Unit)
|
||||
} else {
|
||||
// 如果检查不到,可能是事务隔离级别问题,等待一下再查询
|
||||
delay(100)
|
||||
val existingAfterDelay =
|
||||
processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
|
||||
if (existingAfterDelay != null) {
|
||||
logger.debug("延迟查询到记录(并发检测): leaderId=$leaderId, tradeId=${trade.id}, status=${existingAfterDelay.status}")
|
||||
return@withLock Result.success(Unit)
|
||||
}
|
||||
logger.warn(
|
||||
"保存ProcessedTrade时发生唯一约束冲突,但查询不到记录: leaderId=$leaderId, tradeId=${trade.id}",
|
||||
e
|
||||
)
|
||||
return@withLock Result.success(Unit)
|
||||
}
|
||||
} else {
|
||||
// 其他类型的异常,重新抛出
|
||||
throw e
|
||||
return@withLock Result.success(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
logger.error("处理交易异常: leaderId=$leaderId, tradeId=${trade.id}", e)
|
||||
Result.failure(e)
|
||||
// 2. 处理交易逻辑
|
||||
val result = when (trade.side.uppercase()) {
|
||||
"BUY" -> processBuyTrade(leaderId, trade)
|
||||
"SELL" -> processSellTrade(leaderId, trade)
|
||||
else -> {
|
||||
logger.warn("未知的交易方向: ${trade.side}")
|
||||
Result.failure(IllegalArgumentException("未知的交易方向: ${trade.side}"))
|
||||
}
|
||||
}
|
||||
|
||||
if (result.isFailure) {
|
||||
logger.error(
|
||||
"处理交易失败: leaderId=$leaderId, tradeId=${trade.id}, side=${trade.side}",
|
||||
result.exceptionOrNull()
|
||||
)
|
||||
return@withLock result
|
||||
}
|
||||
|
||||
// 3. 标记为已处理(成功状态)
|
||||
// 由于使用了 Mutex,这里理论上不会出现并发冲突,但保留异常处理作为兜底
|
||||
try {
|
||||
val processed = ProcessedTrade(
|
||||
leaderId = leaderId,
|
||||
leaderTradeId = trade.id,
|
||||
tradeType = trade.side.uppercase(),
|
||||
source = source,
|
||||
status = "SUCCESS",
|
||||
processedAt = System.currentTimeMillis()
|
||||
)
|
||||
processedTradeRepository.save(processed)
|
||||
} catch (e: Exception) {
|
||||
// 检查是否是唯一键冲突异常(理论上不会发生,但保留作为兜底)
|
||||
if (isUniqueConstraintViolation(e)) {
|
||||
val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
|
||||
if (existing != null) {
|
||||
if (existing.status == "FAILED") {
|
||||
logger.debug("交易已标记为失败,跳过处理: leaderId=$leaderId, tradeId=${trade.id}")
|
||||
return@withLock Result.success(Unit)
|
||||
}
|
||||
logger.debug("交易已处理(并发检测): leaderId=$leaderId, tradeId=${trade.id}, status=${existing.status}")
|
||||
return@withLock Result.success(Unit)
|
||||
} else {
|
||||
// 如果检查不到,可能是事务隔离级别问题,等待一下再查询
|
||||
delay(100)
|
||||
val existingAfterDelay =
|
||||
processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
|
||||
if (existingAfterDelay != null) {
|
||||
logger.debug("延迟查询到记录(并发检测): leaderId=$leaderId, tradeId=${trade.id}, status=${existingAfterDelay.status}")
|
||||
return@withLock Result.success(Unit)
|
||||
}
|
||||
logger.warn(
|
||||
"保存ProcessedTrade时发生唯一约束冲突,但查询不到记录: leaderId=$leaderId, tradeId=${trade.id}",
|
||||
e
|
||||
)
|
||||
return@withLock Result.success(Unit)
|
||||
}
|
||||
} else {
|
||||
// 其他类型的异常,重新抛出
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
logger.error("处理交易异常: leaderId=$leaderId, tradeId=${trade.id}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -253,26 +257,46 @@ open class CopyOrderTrackingService(
|
||||
// 先计算跟单金额(用于仓位检查)
|
||||
// 注意:这里先计算金额,即使后续被过滤也会记录
|
||||
val tradePrice = trade.price.toSafeBigDecimal()
|
||||
val buyQuantity = try {
|
||||
var buyQuantity = try {
|
||||
calculateBuyQuantity(trade, copyTrading)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("计算买入数量失败: ${e.message}", e)
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
// 计算跟单金额(USDC)= 买入数量 × 价格
|
||||
val copyOrderAmount = buyQuantity.multi(tradePrice)
|
||||
|
||||
// 如果启用了关键字过滤或市场截止时间过滤,需要先获取市场信息
|
||||
var marketTitle: String? = null
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤条件检查(在计算订单参数之前)
|
||||
// 传入 Leader 交易价格,用于价格区间检查
|
||||
// 传入跟单金额和市场ID,用于仓位检查(按市场检查仓位)
|
||||
// 传入市场标题,用于关键字过滤
|
||||
// 传入市场截止时间,用于市场截止时间检查
|
||||
// 订单簿只请求一次,返回给后续逻辑使用
|
||||
val filterResult = filterService.checkFilters(
|
||||
copyTrading,
|
||||
tokenId,
|
||||
copyTrading,
|
||||
tokenId,
|
||||
tradePrice = tradePrice,
|
||||
copyOrderAmount = copyOrderAmount,
|
||||
marketId = trade.market
|
||||
marketId = trade.market,
|
||||
marketTitle = marketTitle,
|
||||
marketEndDate = marketEndDate
|
||||
)
|
||||
val orderbook = filterResult.orderbook // 获取订单簿(如果需要)
|
||||
if (!filterResult.isPassed) {
|
||||
@@ -282,23 +306,9 @@ open class CopyOrderTrackingService(
|
||||
notificationScope.launch {
|
||||
try {
|
||||
// 获取市场信息(标题和slug)
|
||||
val marketInfo = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val gammaApi = retrofitFactory.createGammaApi()
|
||||
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(trade.market))
|
||||
if (marketResponse.isSuccessful && marketResponse.body() != null) {
|
||||
marketResponse.body()!!.firstOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取市场信息失败: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val marketTitle = marketInfo?.question ?: trade.market
|
||||
val marketSlug = marketInfo?.slug
|
||||
val market = marketService.getMarket(trade.market)
|
||||
val marketTitle = market?.title ?: trade.market
|
||||
val marketSlug = market?.slug // 显示用的 slug
|
||||
|
||||
// 从过滤结果中提取 filterType
|
||||
val filterType = extractFilterType(filterResult.status, filterResult.reason)
|
||||
@@ -337,27 +347,29 @@ open class CopyOrderTrackingService(
|
||||
logger.error("保存被过滤订单失败: ${e.message}", e)
|
||||
}
|
||||
|
||||
// 发送 Telegram 通知
|
||||
val locale = try {
|
||||
org.springframework.context.i18n.LocaleContextHolder.getLocale()
|
||||
} catch (e: Exception) {
|
||||
java.util.Locale("zh", "CN") // 默认简体中文
|
||||
}
|
||||
// 发送 Telegram 通知(仅在 pushFilteredOrders 为 true 时发送)
|
||||
if (copyTrading.pushFilteredOrders) {
|
||||
val locale = try {
|
||||
org.springframework.context.i18n.LocaleContextHolder.getLocale()
|
||||
} catch (e: Exception) {
|
||||
java.util.Locale("zh", "CN") // 默认简体中文
|
||||
}
|
||||
|
||||
telegramNotificationService?.sendOrderFilteredNotification(
|
||||
marketTitle = marketTitle,
|
||||
marketId = trade.market,
|
||||
marketSlug = marketSlug,
|
||||
side = "BUY",
|
||||
outcome = trade.outcome,
|
||||
price = trade.price,
|
||||
size = trade.size,
|
||||
filterReason = filterResult.reason,
|
||||
filterType = filterType,
|
||||
accountName = account.accountName,
|
||||
walletAddress = account.walletAddress,
|
||||
locale = locale
|
||||
)
|
||||
telegramNotificationService?.sendOrderFilteredNotification(
|
||||
marketTitle = marketTitle,
|
||||
marketId = trade.market,
|
||||
marketSlug = marketSlug,
|
||||
side = "BUY",
|
||||
outcome = trade.outcome,
|
||||
price = trade.price,
|
||||
size = trade.size,
|
||||
filterReason = filterResult.reason,
|
||||
filterType = filterType,
|
||||
accountName = account.accountName,
|
||||
walletAddress = account.walletAddress,
|
||||
locale = locale
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("处理被过滤订单通知失败: ${e.message}", e)
|
||||
}
|
||||
@@ -369,22 +381,72 @@ open class CopyOrderTrackingService(
|
||||
// 买入数量已在过滤检查前计算,这里直接使用
|
||||
// 如果数量为0或负数,跳过
|
||||
if (buyQuantity.lte(BigDecimal.ZERO)) {
|
||||
logger.warn("计算出的买入数量为0或负数,跳过: copyTradingId=${copyTrading.id}, tradeId=${trade.id}")
|
||||
logger.warn("计算得到的买入数量为0,跳过跟单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}")
|
||||
continue
|
||||
}
|
||||
|
||||
if (buyQuantity.lt(BigDecimal.ONE)) {
|
||||
logger.warn("计算得到的买入数量小于1,自动调整为1 (Polymarket 最小下单数量): copyTradingId=${copyTrading.id}, tradeId=${trade.id}, originalQuantity=$buyQuantity")
|
||||
buyQuantity = BigDecimal.ONE
|
||||
}
|
||||
// 验证订单数量限制(仅比例模式)
|
||||
var finalBuyQuantity = buyQuantity
|
||||
if (copyTrading.copyMode == "RATIO") {
|
||||
val orderAmount = buyQuantity.multi(trade.price.toSafeBigDecimal())
|
||||
if (orderAmount.lt(copyTrading.minOrderSize)) {
|
||||
logger.warn("订单金额低于最小限制,跳过: copyTradingId=${copyTrading.id}, amount=$orderAmount, min=${copyTrading.minOrderSize}")
|
||||
continue
|
||||
val tradePrice = trade.price.toSafeBigDecimal()
|
||||
val rawOrderAmount = buyQuantity.multi(tradePrice)
|
||||
|
||||
// 对按比例计算的金额进行向上取整处理(确保满足最小限制)
|
||||
// 向上取整到 2 位小数(USDC 精度)
|
||||
val roundedOrderAmount = rawOrderAmount.setScale(2, java.math.RoundingMode.CEILING)
|
||||
|
||||
// 如果原始金额或向上取整后的金额小于最小限制,调整 buyQuantity 以满足最小限制
|
||||
// 这样可以避免精度问题导致订单被错误地跳过
|
||||
if (roundedOrderAmount.lt(copyTrading.minOrderSize)) {
|
||||
logger.debug("订单金额(向上取整后)低于最小限制,调整数量以满足最小限制: copyTradingId=${copyTrading.id}, rawAmount=$rawOrderAmount, roundedAmount=$roundedOrderAmount, min=${copyTrading.minOrderSize}")
|
||||
// 计算满足最小限制所需的数量(向上取整)
|
||||
val minQuantity =
|
||||
copyTrading.minOrderSize.div(tradePrice, 8, java.math.RoundingMode.CEILING)
|
||||
if (minQuantity.lte(BigDecimal.ZERO)) {
|
||||
logger.warn("计算出的最小数量为0或负数,跳过: copyTradingId=${copyTrading.id}")
|
||||
continue
|
||||
}
|
||||
// 使用调整后的数量
|
||||
finalBuyQuantity = minQuantity
|
||||
logger.debug(
|
||||
"已调整数量以满足最小限制: copyTradingId=${copyTrading.id}, originalQuantity=$buyQuantity, adjustedQuantity=$finalBuyQuantity, adjustedAmount=${
|
||||
finalBuyQuantity.multi(
|
||||
tradePrice
|
||||
)
|
||||
}"
|
||||
)
|
||||
} else if (rawOrderAmount.lt(copyTrading.minOrderSize)) {
|
||||
// 原始金额小于最小限制,但向上取整后满足,调整数量以满足最小限制
|
||||
logger.debug("订单金额(精度处理后)低于最小限制,调整数量以满足最小限制: copyTradingId=${copyTrading.id}, rawAmount=$rawOrderAmount, min=${copyTrading.minOrderSize}")
|
||||
// 计算满足最小限制所需的数量(向上取整)
|
||||
val minQuantity =
|
||||
copyTrading.minOrderSize.div(tradePrice, 8, java.math.RoundingMode.CEILING)
|
||||
if (minQuantity.lte(BigDecimal.ZERO)) {
|
||||
logger.warn("计算出的最小数量为0或负数,跳过: copyTradingId=${copyTrading.id}")
|
||||
continue
|
||||
}
|
||||
// 使用调整后的数量
|
||||
finalBuyQuantity = minQuantity
|
||||
logger.debug(
|
||||
"已调整数量以满足最小限制: copyTradingId=${copyTrading.id}, originalQuantity=$buyQuantity, adjustedQuantity=$finalBuyQuantity, adjustedAmount=${
|
||||
finalBuyQuantity.multi(
|
||||
tradePrice
|
||||
)
|
||||
}"
|
||||
)
|
||||
}
|
||||
if (orderAmount.gt(copyTrading.maxOrderSize)) {
|
||||
logger.warn("订单金额超过最大限制,调整数量: copyTradingId=${copyTrading.id}, amount=$orderAmount, max=${copyTrading.maxOrderSize}")
|
||||
|
||||
// 检查最大限制(使用调整后的数量)
|
||||
val finalOrderAmount = finalBuyQuantity.multi(tradePrice)
|
||||
if (finalOrderAmount.gt(copyTrading.maxOrderSize)) {
|
||||
logger.warn("订单金额超过最大限制,调整数量: copyTradingId=${copyTrading.id}, amount=$finalOrderAmount, max=${copyTrading.maxOrderSize}")
|
||||
// 调整数量到最大值
|
||||
val adjustedQuantity = copyTrading.maxOrderSize.div(trade.price.toSafeBigDecimal())
|
||||
val adjustedQuantity =
|
||||
copyTrading.maxOrderSize.div(tradePrice, 8, java.math.RoundingMode.DOWN)
|
||||
if (adjustedQuantity.lte(BigDecimal.ZERO)) {
|
||||
logger.warn("调整后的数量为0或负数,跳过: copyTradingId=${copyTrading.id}")
|
||||
continue
|
||||
@@ -409,7 +471,7 @@ open class CopyOrderTrackingService(
|
||||
|
||||
// 计算价格(应用价格容忍度)
|
||||
val buyPrice = calculateAdjustedPrice(trade.price.toSafeBigDecimal(), copyTrading, isBuy = true)
|
||||
|
||||
logger.debug("计算价格结果:$buyPrice")
|
||||
// 在创建订单前,检查订单簿中是否有可匹配的订单(避免 FAK 订单失败)
|
||||
// 如果过滤检查时已经获取了订单簿,直接使用;否则重新获取
|
||||
val orderbookForCheck = orderbook ?: run {
|
||||
@@ -420,21 +482,21 @@ open class CopyOrderTrackingService(
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 检查是否有可匹配的卖单(asks)
|
||||
if (orderbookForCheck != null) {
|
||||
val bestAsk = orderbookForCheck.asks
|
||||
.mapNotNull { it.price.toSafeBigDecimal() }
|
||||
.minOrNull()
|
||||
|
||||
|
||||
if (bestAsk == null) {
|
||||
logger.warn("订单簿中没有卖单,跳过创建订单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}")
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
// 如果调整后的买入价格低于最佳卖单价格,无法匹配
|
||||
if (buyPrice.lt(bestAsk)) {
|
||||
logger.warn("调整后的买入价格 ($buyPrice) 低于最佳卖单价格 ($bestAsk),无法匹配,跳过创建订单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}, leaderPrice=${trade.price}, tolerance=${copyTrading.priceTolerance}")
|
||||
logger.info("调整后的买入价格 ($buyPrice) 低于最佳卖单价格 ($bestAsk),无法匹配,跳过创建订单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}, leaderPrice=${trade.price}, tolerance=${copyTrading.priceTolerance}")
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -464,6 +526,17 @@ open class CopyOrderTrackingService(
|
||||
// 解密私钥
|
||||
val decryptedPrivateKey = decryptPrivateKey(account)
|
||||
|
||||
// 获取费率(根据 Polymarket Maker Rebates Program 要求)
|
||||
val feeRateResult = clobService.getFeeRate(tokenId)
|
||||
val feeRateBps = if (feeRateResult.isSuccess) {
|
||||
feeRateResult.getOrNull()?.toString() ?: "0"
|
||||
} else {
|
||||
logger.warn("获取费率失败,使用默认值 0: tokenId=$tokenId, error=${feeRateResult.exceptionOrNull()?.message}")
|
||||
"0"
|
||||
}
|
||||
|
||||
logger.info("准备创建买入订单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}, leaderPrice=${trade.price}, tolerance=${copyTrading.priceTolerance}, calculatedPrice=$buyPrice, quantity=$finalBuyQuantity, baseFee=$feeRateBps")
|
||||
|
||||
// 调用API创建订单(带重试机制)
|
||||
// 重试策略:最多重试 MAX_RETRY_ATTEMPTS 次,每次重试前等待 RETRY_DELAY_MS 毫秒
|
||||
// 每次重试都会重新生成salt并重新签名,确保签名唯一性
|
||||
@@ -477,37 +550,24 @@ open class CopyOrderTrackingService(
|
||||
size = finalBuyQuantity.toString(),
|
||||
owner = account.apiKey,
|
||||
copyTradingId = copyTrading.id!!,
|
||||
tradeId = trade.id
|
||||
tradeId = trade.id,
|
||||
feeRateBps = feeRateBps
|
||||
)
|
||||
|
||||
// 处理订单创建失败
|
||||
if (createOrderResult.isFailure) {
|
||||
// 提取错误信息(只保留 code 和 errorBody)
|
||||
val exception = createOrderResult.exceptionOrNull()
|
||||
logger.error("创建买入订单失败: copyTradingId=${copyTrading.id}, tradeId=${trade.id}, leaderPrice=${trade.price}, myPrice=$buyPrice, error=${exception?.message}")
|
||||
|
||||
// 发送订单失败通知(异步,不阻塞,仅在 pushFailedOrders 为 true 时发送)
|
||||
if (copyTrading.pushFailedOrders) {
|
||||
notificationScope.launch {
|
||||
try {
|
||||
// 获取市场信息(标题和slug)
|
||||
val marketInfo = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val gammaApi = retrofitFactory.createGammaApi()
|
||||
val marketResponse =
|
||||
gammaApi.listMarkets(conditionIds = listOf(trade.market))
|
||||
if (marketResponse.isSuccessful && marketResponse.body() != null) {
|
||||
marketResponse.body()!!.firstOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取市场信息失败: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val marketTitle = marketInfo?.question ?: trade.market
|
||||
val marketSlug = marketInfo?.slug
|
||||
val market = marketService.getMarket(trade.market)
|
||||
val marketTitle = market?.title ?: trade.market
|
||||
val marketSlug = market?.eventSlug // 跳转用的 slug
|
||||
|
||||
// 获取当前语言设置(从 LocaleContextHolder)
|
||||
val locale = try {
|
||||
@@ -539,7 +599,7 @@ open class CopyOrderTrackingService(
|
||||
}
|
||||
|
||||
val realOrderId = createOrderResult.getOrNull() ?: continue
|
||||
|
||||
|
||||
// 验证 orderId 格式(必须以 0x 开头的 16 进制)
|
||||
if (!isValidOrderId(realOrderId)) {
|
||||
logger.warn("买入订单ID格式无效,跳过保存: orderId=$realOrderId")
|
||||
@@ -625,8 +685,8 @@ open class CopyOrderTrackingService(
|
||||
private fun calculateBuyQuantity(trade: TradeResponse, copyTrading: CopyTrading): BigDecimal {
|
||||
return when (copyTrading.copyMode) {
|
||||
"RATIO" -> {
|
||||
// 比例模式:Leader 数量 × 比例
|
||||
trade.size.toSafeBigDecimal().multi(copyTrading.copyRatio)
|
||||
// 比例模式:Leader 数量 × (比例 / 100)
|
||||
trade.size.toSafeBigDecimal().multi(copyTrading.copyRatio.div(100))
|
||||
}
|
||||
|
||||
"FIXED" -> {
|
||||
@@ -658,7 +718,7 @@ open class CopyOrderTrackingService(
|
||||
val leader = leaderRepository.findById(copyTrading.leaderId).orElse(null)
|
||||
?: run {
|
||||
logger.warn("Leader 不存在,使用默认比例: leaderId=${copyTrading.leaderId}")
|
||||
return leaderSellQuantity.multi(copyTrading.copyRatio)
|
||||
return leaderSellQuantity.multi(copyTrading.copyRatio.div(100))
|
||||
}
|
||||
|
||||
// 创建不需要认证的 CLOB API 客户端(用于查询公开的交易数据)
|
||||
@@ -677,7 +737,7 @@ open class CopyOrderTrackingService(
|
||||
for (order in unmatchedOrders) {
|
||||
val copyQty = order.quantity.toSafeBigDecimal()
|
||||
var leaderQty: BigDecimal? = null
|
||||
|
||||
|
||||
// 优先使用存储的 leaderBuyQuantity
|
||||
if (order.leaderBuyQuantity != null) {
|
||||
leaderQty = order.leaderBuyQuantity.toSafeBigDecimal()
|
||||
@@ -688,7 +748,7 @@ open class CopyOrderTrackingService(
|
||||
logger.debug("Leader 买入数量未存储,尝试查询 API: leaderBuyTradeId=${order.leaderBuyTradeId}, copyOrderId=${order.buyOrderId}")
|
||||
try {
|
||||
val tradesResponse = clobApi.getTrades(id = order.leaderBuyTradeId)
|
||||
|
||||
|
||||
if (tradesResponse.isSuccessful && tradesResponse.body() != null) {
|
||||
val tradesData = tradesResponse.body()!!.data
|
||||
if (tradesData.isNotEmpty()) {
|
||||
@@ -714,7 +774,7 @@ open class CopyOrderTrackingService(
|
||||
failCount++
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 如果成功获取到 Leader 买入数量,累加
|
||||
if (leaderQty != null && leaderQty.gt(BigDecimal.ZERO)) {
|
||||
totalCopyQuantity = totalCopyQuantity.add(copyQty)
|
||||
@@ -723,23 +783,23 @@ open class CopyOrderTrackingService(
|
||||
logger.warn("无法获取 Leader 买入数量,跳过该订单: copyOrderId=${order.buyOrderId}, leaderBuyTradeId=${order.leaderBuyTradeId}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
logger.info("固定金额模式计算结果汇总: copyTradingId=${copyTrading.id}, successCount=$successCount, failCount=$failCount, totalCopyQuantity=$totalCopyQuantity, totalLeaderQuantity=$totalLeaderQuantity")
|
||||
|
||||
// 如果无法计算总比例(查询失败),使用默认比例
|
||||
if (totalLeaderQuantity.lte(BigDecimal.ZERO)) {
|
||||
logger.warn("无法计算总比例(Leader 买入数量为 0),使用默认比例: copyTradingId=${copyTrading.id}")
|
||||
return leaderSellQuantity.multi(copyTrading.copyRatio)
|
||||
return leaderSellQuantity.multi(copyTrading.copyRatio.div(100))
|
||||
}
|
||||
|
||||
// 计算实际比例:跟单买入数量 / Leader 买入数量
|
||||
val actualRatio = totalCopyQuantity.div(totalLeaderQuantity)
|
||||
|
||||
|
||||
// 计算需要卖出的数量:Leader 卖出数量 × 实际比例
|
||||
val needMatch = leaderSellQuantity.multi(actualRatio)
|
||||
|
||||
|
||||
logger.debug("固定金额模式卖出数量计算: copyTradingId=${copyTrading.id}, leaderSellQuantity=$leaderSellQuantity, totalCopyQuantity=$totalCopyQuantity, totalLeaderQuantity=$totalLeaderQuantity, actualRatio=$actualRatio, needMatch=$needMatch")
|
||||
|
||||
|
||||
return needMatch
|
||||
}
|
||||
|
||||
@@ -803,16 +863,26 @@ open class CopyOrderTrackingService(
|
||||
copyTrading = copyTrading
|
||||
)
|
||||
}
|
||||
|
||||
"RATIO" -> {
|
||||
// 比例模式:直接使用配置的 copyRatio
|
||||
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio)
|
||||
// 比例模式:直接使用配置的 copyRatio (需要除以100)
|
||||
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio.div(100))
|
||||
}
|
||||
|
||||
else -> {
|
||||
logger.warn("不支持的 copyMode: ${copyTrading.copyMode},使用默认比例模式")
|
||||
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio)
|
||||
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio.div(100))
|
||||
}
|
||||
}
|
||||
|
||||
// 如果需要卖出的数量小于1(但大于0),自动调整为1(Polymarket 最小下单数量)
|
||||
// 注意:如果实际持有数量不足1,后续的 totalMatched 检查会拦截
|
||||
var finalNeedMatch = needMatch
|
||||
if (finalNeedMatch.gt(BigDecimal.ZERO) && finalNeedMatch.lt(BigDecimal.ONE)) {
|
||||
logger.warn("计算得到的卖出数量小于1,自动调整为1: copyTradingId=${copyTrading.id}, original=$needMatch")
|
||||
finalNeedMatch = BigDecimal.ONE
|
||||
}
|
||||
|
||||
// 4. 获取tokenId(直接使用outcomeIndex,支持多元市场)
|
||||
val tokenIdResult = blockchainService.getTokenId(leaderSellTrade.market, leaderSellTrade.outcomeIndex)
|
||||
if (tokenIdResult.isFailure) {
|
||||
@@ -836,7 +906,7 @@ open class CopyOrderTrackingService(
|
||||
// 6. 按FIFO顺序匹配,计算实际可以卖出的数量
|
||||
// 使用计算出的实际卖出价格(而不是 Leader 价格)来创建匹配明细
|
||||
var totalMatched = BigDecimal.ZERO
|
||||
var remaining = needMatch
|
||||
var remaining = finalNeedMatch
|
||||
val matchDetails = mutableListOf<SellMatchDetail>()
|
||||
|
||||
for (order in unmatchedOrders) {
|
||||
@@ -873,6 +943,11 @@ open class CopyOrderTrackingService(
|
||||
return
|
||||
}
|
||||
|
||||
if (totalMatched.lt(BigDecimal.ONE)) {
|
||||
logger.warn("卖出数量小于1,跳过卖出 (Polymarket 最小下单数量为 1): copyTradingId=${copyTrading.id}, tradeId=${leaderSellTrade.id}, quantity=$totalMatched")
|
||||
return
|
||||
}
|
||||
|
||||
// 7. 解密 API 凭证
|
||||
val apiSecret = try {
|
||||
decryptApiSecret(account)
|
||||
@@ -889,7 +964,16 @@ open class CopyOrderTrackingService(
|
||||
|
||||
// 8. 解密私钥(在方法开始时解密一次,后续复用)
|
||||
val decryptedPrivateKey = decryptPrivateKey(account)
|
||||
|
||||
|
||||
// 获取费率(根据 Polymarket Maker Rebates Program 要求)
|
||||
val feeRateResult = clobService.getFeeRate(tokenId)
|
||||
val feeRateBps = if (feeRateResult.isSuccess) {
|
||||
feeRateResult.getOrNull()?.toString() ?: "0"
|
||||
} else {
|
||||
logger.warn("获取费率失败,使用默认值 0: tokenId=$tokenId, error=${feeRateResult.exceptionOrNull()?.message}")
|
||||
"0"
|
||||
}
|
||||
|
||||
// 9. 创建并签名卖出订单
|
||||
val signedOrder = try {
|
||||
orderSigningService.createAndSignOrder(
|
||||
@@ -901,7 +985,7 @@ open class CopyOrderTrackingService(
|
||||
size = totalMatched.toString(),
|
||||
signatureType = 2, // Browser Wallet
|
||||
nonce = "0",
|
||||
feeRateBps = "0",
|
||||
feeRateBps = feeRateBps, // 使用动态获取的费率
|
||||
expiration = "0"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
@@ -939,7 +1023,8 @@ open class CopyOrderTrackingService(
|
||||
size = totalMatched.toString(),
|
||||
owner = account.apiKey,
|
||||
copyTradingId = copyTrading.id,
|
||||
tradeId = leaderSellTrade.id
|
||||
tradeId = leaderSellTrade.id,
|
||||
feeRateBps = feeRateBps
|
||||
)
|
||||
|
||||
if (createOrderResult.isFailure) {
|
||||
@@ -959,7 +1044,7 @@ open class CopyOrderTrackingService(
|
||||
} else {
|
||||
logger.debug("卖出订单ID为0x开头,等待定时任务更新价格: orderId=$realSellOrderId")
|
||||
}
|
||||
|
||||
|
||||
// 使用下单价格,等待定时任务更新实际成交价
|
||||
val actualSellPrice = sellPrice
|
||||
|
||||
@@ -1008,19 +1093,19 @@ open class CopyOrderTrackingService(
|
||||
val savedDetail = detail.copy(matchRecordId = savedRecord.id!!)
|
||||
sellMatchDetailRepository.save(savedDetail)
|
||||
}
|
||||
|
||||
|
||||
logger.info("卖出订单已保存,等待轮询任务获取实际数据后发送通知: orderId=$realSellOrderId, copyTradingId=${copyTrading.id}")
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建订单(带重试机制)
|
||||
*
|
||||
*
|
||||
* 重试策略:
|
||||
* - 最多重试 MAX_RETRY_ATTEMPTS 次(首次尝试 + 重试)
|
||||
* - 每次重试前等待 RETRY_DELAY_MS 毫秒
|
||||
* - 每次重试都重新生成salt并重新签名,确保签名唯一性
|
||||
*
|
||||
*
|
||||
* @param clobApi CLOB API 客户端
|
||||
* @param privateKey 私钥(用于签名)
|
||||
* @param makerAddress 代理钱包地址
|
||||
@@ -1031,6 +1116,7 @@ open class CopyOrderTrackingService(
|
||||
* @param owner API Key(用于owner字段)
|
||||
* @param copyTradingId 跟单配置ID(用于日志)
|
||||
* @param tradeId Leader 交易ID(用于日志)
|
||||
* @param feeRateBps 费率基点(从API动态获取)
|
||||
* @return 成功返回订单ID,失败返回异常
|
||||
*/
|
||||
private suspend fun createOrderWithRetry(
|
||||
@@ -1043,7 +1129,8 @@ open class CopyOrderTrackingService(
|
||||
size: String,
|
||||
owner: String,
|
||||
copyTradingId: Long,
|
||||
tradeId: String
|
||||
tradeId: String,
|
||||
feeRateBps: String
|
||||
): Result<String> {
|
||||
var lastError: Exception? = null
|
||||
|
||||
@@ -1060,7 +1147,7 @@ open class CopyOrderTrackingService(
|
||||
size = size,
|
||||
signatureType = 2, // Browser Wallet
|
||||
nonce = "0",
|
||||
feeRateBps = "0",
|
||||
feeRateBps = feeRateBps, // 使用动态获取的费率
|
||||
expiration = "0"
|
||||
)
|
||||
|
||||
@@ -1086,10 +1173,10 @@ open class CopyOrderTrackingService(
|
||||
}
|
||||
val errorMsg = "code=${orderResponse.code()}, errorBody=${errorBody ?: "null"}"
|
||||
lastError = Exception(errorMsg)
|
||||
|
||||
|
||||
// 记录错误日志
|
||||
logger.error("创建订单失败 (尝试 $attempt/$MAX_RETRY_ATTEMPTS): copyTradingId=$copyTradingId, tradeId=$tradeId, $errorMsg")
|
||||
|
||||
|
||||
// 如果不是最后一次尝试,等待后重试
|
||||
if (attempt < MAX_RETRY_ATTEMPTS) {
|
||||
delay(RETRY_DELAY_MS)
|
||||
@@ -1103,10 +1190,10 @@ open class CopyOrderTrackingService(
|
||||
if (!response.success || response.orderId == null) {
|
||||
val errorMsg = "errorMsg=${response.errorMsg}"
|
||||
lastError = Exception(errorMsg)
|
||||
|
||||
|
||||
// 记录错误日志
|
||||
logger.error("创建订单失败 (尝试 $attempt/$MAX_RETRY_ATTEMPTS): copyTradingId=$copyTradingId, tradeId=$tradeId, $errorMsg")
|
||||
|
||||
|
||||
// 如果不是最后一次尝试,等待后重试
|
||||
if (attempt < MAX_RETRY_ATTEMPTS) {
|
||||
delay(RETRY_DELAY_MS)
|
||||
@@ -1118,14 +1205,17 @@ open class CopyOrderTrackingService(
|
||||
// 创建订单成功
|
||||
logger.info("创建订单成功: copyTradingId=$copyTradingId, tradeId=$tradeId, orderId=${response.orderId}, attempt=$attempt")
|
||||
return Result.success(response.orderId)
|
||||
|
||||
|
||||
} catch (e: Exception) {
|
||||
val errorMsg = "error=${e.message}"
|
||||
lastError = Exception(errorMsg, e)
|
||||
|
||||
|
||||
// 记录错误日志(包含堆栈)
|
||||
logger.error("创建订单异常 (尝试 $attempt/$MAX_RETRY_ATTEMPTS): copyTradingId=$copyTradingId, tradeId=$tradeId, $errorMsg", e)
|
||||
|
||||
logger.error(
|
||||
"创建订单异常 (尝试 $attempt/$MAX_RETRY_ATTEMPTS): copyTradingId=$copyTradingId, tradeId=$tradeId, $errorMsg",
|
||||
e
|
||||
)
|
||||
|
||||
// 如果不是最后一次尝试,等待后重试
|
||||
if (attempt < MAX_RETRY_ATTEMPTS) {
|
||||
delay(RETRY_DELAY_MS)
|
||||
@@ -1137,7 +1227,10 @@ open class CopyOrderTrackingService(
|
||||
|
||||
// 所有重试都失败
|
||||
val finalError = lastError ?: Exception("error=未知错误")
|
||||
logger.error("创建订单失败(所有重试都失败): copyTradingId=$copyTradingId, tradeId=$tradeId, side=$side, price=$price, size=$size", finalError)
|
||||
logger.error(
|
||||
"创建订单失败(所有重试都失败): copyTradingId=$copyTradingId, tradeId=$tradeId, side=$side, price=$price, size=$size",
|
||||
finalError
|
||||
)
|
||||
return Result.failure(finalError)
|
||||
}
|
||||
|
||||
@@ -1272,7 +1365,7 @@ open class CopyOrderTrackingService(
|
||||
|
||||
// 计算价格调整范围(百分比)
|
||||
val tolerancePercent = tolerance.div(100)
|
||||
val adjustment = originalPrice.multi(tolerancePercent)
|
||||
val adjustment = originalPrice.multi(tolerancePercent).max(0.01.toSafeBigDecimal())
|
||||
|
||||
return if (isBuy) {
|
||||
// 买入:可以稍微加价以确保成交(在原价格基础上加容忍度)
|
||||
@@ -1319,13 +1412,15 @@ open class CopyOrderTrackingService(
|
||||
FilterStatus.FAILED_ORDER_DEPTH -> "ORDER_DEPTH"
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证订单ID格式
|
||||
* 订单ID必须以 0x 开头,且是有效的 16 进制字符串
|
||||
*
|
||||
*
|
||||
* @param orderId 订单ID
|
||||
* @return 如果格式有效返回 true,否则返回 false
|
||||
*/
|
||||
@@ -1341,11 +1436,11 @@ open class CopyOrderTrackingService(
|
||||
// 检查是否只包含 0-9, a-f, A-F
|
||||
return hexPart.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取订单的实际成交价
|
||||
* 通过查询订单详情和关联的交易记录,计算加权平均成交价
|
||||
*
|
||||
*
|
||||
* @param orderId 订单ID
|
||||
* @param clobApi CLOB API 客户端(已认证)
|
||||
* @param fallbackPrice 如果查询失败,使用此价格作为默认值
|
||||
@@ -1364,14 +1459,14 @@ open class CopyOrderTrackingService(
|
||||
logger.warn("查询订单详情失败: orderId=$orderId, code=${orderResponse.code()}, errorBody=$errorBody")
|
||||
return fallbackPrice
|
||||
}
|
||||
|
||||
|
||||
val order = orderResponse.body()
|
||||
if (order == null) {
|
||||
// 响应体为空,可能是订单不存在或已过期
|
||||
logger.warn("查询订单详情失败: 响应体为空, orderId=$orderId, code=${orderResponse.code()}")
|
||||
return fallbackPrice
|
||||
}
|
||||
|
||||
|
||||
// 2. 如果订单未成交,使用下单价格
|
||||
if (order.status != "FILLED" && order.sizeMatched.toSafeBigDecimal() <= BigDecimal.ZERO) {
|
||||
logger.debug("订单未成交,使用下单价格: orderId=$orderId, status=${order.status}")
|
||||
@@ -1412,7 +1507,7 @@ open class CopyOrderTrackingService(
|
||||
for (trade in trades) {
|
||||
val tradePrice = trade.price.toSafeBigDecimal()
|
||||
val tradeSize = trade.size.toSafeBigDecimal()
|
||||
|
||||
|
||||
if (tradeSize > BigDecimal.ZERO) {
|
||||
totalAmount = totalAmount.add(tradePrice.multiply(tradeSize))
|
||||
totalSize = totalSize.add(tradeSize)
|
||||
|
||||
+331
-212
@@ -31,8 +31,7 @@ class CopyTradingStatisticsService(
|
||||
private val sellMatchDetailRepository: SellMatchDetailRepository,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val leaderRepository: LeaderRepository,
|
||||
private val accountService: AccountService,
|
||||
private val blockchainService: BlockchainService
|
||||
private val marketService: com.wrbug.polymarketbot.service.common.MarketService
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CopyTradingStatisticsService::class.java)
|
||||
@@ -62,19 +61,12 @@ class CopyTradingStatisticsService(
|
||||
// 6. 计算统计信息
|
||||
val statistics = calculateStatistics(buyOrders, sellRecords, matchDetails)
|
||||
|
||||
// 7. 获取链上实际持仓(用于准确计算未实现盈亏,考虑手动卖出的情况)
|
||||
val actualPositions = getActualPositions(account)
|
||||
// 7. 不再计算未实现盈亏和持仓价值(优化性能)
|
||||
// 未实现盈亏计算需要查询链上持仓和市场价格,性能开销大
|
||||
val unrealizedPnl = "0"
|
||||
val positionValue = "0"
|
||||
|
||||
// 8. 获取当前市场价格(用于计算未实现盈亏)
|
||||
val currentPrice = getCurrentMarketPrice(buyOrders)
|
||||
|
||||
// 9. 计算未实现盈亏(使用链上实际持仓,而不是 remainingQuantity)
|
||||
val unrealizedPnl = calculateUnrealizedPnl(buyOrders, currentPrice, actualPositions)
|
||||
|
||||
// 10. 计算持仓价值(使用链上实际持仓和当前价格)
|
||||
val positionValue = calculatePositionValue(buyOrders, currentPrice, actualPositions)
|
||||
|
||||
// 11. 构建响应
|
||||
// 8. 构建响应(总盈亏 = 已实现盈亏)
|
||||
val response = CopyTradingStatisticsResponse(
|
||||
copyTradingId = copyTradingId,
|
||||
accountId = copyTrading.accountId,
|
||||
@@ -93,8 +85,8 @@ class CopyTradingStatisticsService(
|
||||
currentPositionValue = positionValue,
|
||||
totalRealizedPnl = statistics.totalRealizedPnl,
|
||||
totalUnrealizedPnl = unrealizedPnl,
|
||||
totalPnl = (statistics.totalRealizedPnl.toSafeBigDecimal().add(unrealizedPnl.toSafeBigDecimal())).toString(),
|
||||
totalPnlPercent = calculatePnlPercent(statistics.totalBuyAmount, statistics.totalRealizedPnl, unrealizedPnl)
|
||||
totalPnl = statistics.totalRealizedPnl,
|
||||
totalPnlPercent = calculatePnlPercentOnlyRealized(statistics.totalBuyAmount, statistics.totalRealizedPnl)
|
||||
)
|
||||
|
||||
Result.success(response)
|
||||
@@ -142,12 +134,21 @@ class CopyTradingStatisticsService(
|
||||
private fun getBuyOrderList(request: OrderTrackingRequest): Pair<List<BuyOrderInfo>, Long> {
|
||||
var orders = copyOrderTrackingRepository.findByCopyTradingId(request.copyTradingId)
|
||||
|
||||
// 批量获取市场信息(用于筛选)
|
||||
val allMarketIds = orders.map { it.marketId }.distinct()
|
||||
val markets = marketService.getMarkets(allMarketIds)
|
||||
|
||||
// 筛选
|
||||
if (!request.marketId.isNullOrBlank()) {
|
||||
orders = orders.filter { it.marketId == request.marketId }
|
||||
// marketId 支持模糊匹配
|
||||
orders = orders.filter { it.marketId.contains(request.marketId!!, ignoreCase = true) }
|
||||
}
|
||||
if (!request.side.isNullOrBlank()) {
|
||||
orders = orders.filter { it.side == request.side }
|
||||
if (!request.marketTitle.isNullOrBlank()) {
|
||||
// marketTitle 关键字筛选
|
||||
orders = orders.filter { order ->
|
||||
val market = markets[order.marketId]
|
||||
market?.title?.contains(request.marketTitle!!, ignoreCase = true) == true
|
||||
}
|
||||
}
|
||||
if (!request.status.isNullOrBlank()) {
|
||||
orders = orders.filter { it.status == request.status }
|
||||
@@ -168,10 +169,15 @@ class CopyTradingStatisticsService(
|
||||
// 转换为DTO
|
||||
val list = pagedOrders.map { order ->
|
||||
val amount = order.quantity.toSafeBigDecimal().multi(order.price)
|
||||
val market = markets[order.marketId]
|
||||
BuyOrderInfo(
|
||||
orderId = order.buyOrderId,
|
||||
leaderTradeId = order.leaderBuyTradeId,
|
||||
marketId = order.marketId,
|
||||
marketTitle = market?.title,
|
||||
marketSlug = market?.slug, // 显示用的 slug
|
||||
eventSlug = market?.eventSlug, // 跳转用的 slug(从数据库读取)
|
||||
marketCategory = market?.category,
|
||||
side = order.side,
|
||||
quantity = order.quantity.toString(),
|
||||
price = order.price.toString(),
|
||||
@@ -192,12 +198,21 @@ class CopyTradingStatisticsService(
|
||||
private fun getSellOrderList(request: OrderTrackingRequest): Pair<List<SellOrderInfo>, Long> {
|
||||
var records = sellMatchRecordRepository.findByCopyTradingId(request.copyTradingId)
|
||||
|
||||
// 批量获取市场信息(用于筛选)
|
||||
val allMarketIds = records.map { it.marketId }.distinct()
|
||||
val markets = marketService.getMarkets(allMarketIds)
|
||||
|
||||
// 筛选
|
||||
if (!request.marketId.isNullOrBlank()) {
|
||||
records = records.filter { it.marketId == request.marketId }
|
||||
// marketId 支持模糊匹配
|
||||
records = records.filter { it.marketId.contains(request.marketId!!, ignoreCase = true) }
|
||||
}
|
||||
if (!request.side.isNullOrBlank()) {
|
||||
records = records.filter { it.side == request.side }
|
||||
if (!request.marketTitle.isNullOrBlank()) {
|
||||
// marketTitle 关键字筛选
|
||||
records = records.filter { record ->
|
||||
val market = markets[record.marketId]
|
||||
market?.title?.contains(request.marketTitle!!, ignoreCase = true) == true
|
||||
}
|
||||
}
|
||||
|
||||
val total = records.size.toLong()
|
||||
@@ -215,10 +230,15 @@ class CopyTradingStatisticsService(
|
||||
// 转换为DTO
|
||||
val list = pagedRecords.map { record ->
|
||||
val amount = record.totalMatchedQuantity.toSafeBigDecimal().multi(record.sellPrice)
|
||||
val market = markets[record.marketId]
|
||||
SellOrderInfo(
|
||||
orderId = record.sellOrderId,
|
||||
leaderTradeId = record.leaderSellTradeId,
|
||||
marketId = record.marketId,
|
||||
marketTitle = market?.title,
|
||||
marketSlug = market?.slug, // 显示用的 slug
|
||||
eventSlug = market?.eventSlug, // 跳转用的 slug(从数据库读取)
|
||||
marketCategory = market?.category,
|
||||
side = record.side,
|
||||
quantity = record.totalMatchedQuantity.toString(),
|
||||
price = record.sellPrice.toString(),
|
||||
@@ -237,6 +257,14 @@ class CopyTradingStatisticsService(
|
||||
private fun getMatchedOrderList(request: OrderTrackingRequest): Pair<List<MatchedOrderInfo>, Long> {
|
||||
val matchDetails = sellMatchDetailRepository.findByCopyTradingId(request.copyTradingId)
|
||||
|
||||
// 获取所有相关的卖出记录(用于筛选)
|
||||
val matchRecordIds = matchDetails.map { it.matchRecordId }.distinct()
|
||||
val matchRecords = matchRecordIds.mapNotNull { id ->
|
||||
sellMatchRecordRepository.findById(id).orElse(null)
|
||||
}
|
||||
val marketIds = matchRecords.map { it.marketId }.distinct()
|
||||
val markets = marketService.getMarkets(marketIds)
|
||||
|
||||
// 筛选
|
||||
var filtered = matchDetails
|
||||
if (!request.sellOrderId.isNullOrBlank()) {
|
||||
@@ -250,6 +278,21 @@ class CopyTradingStatisticsService(
|
||||
if (!request.buyOrderId.isNullOrBlank()) {
|
||||
filtered = filtered.filter { it.buyOrderId == request.buyOrderId }
|
||||
}
|
||||
if (!request.marketId.isNullOrBlank()) {
|
||||
// marketId 支持模糊匹配
|
||||
filtered = filtered.filter { detail ->
|
||||
val matchRecord = matchRecords.find { it.id == detail.matchRecordId }
|
||||
matchRecord?.marketId?.contains(request.marketId!!, ignoreCase = true) == true
|
||||
}
|
||||
}
|
||||
if (!request.marketTitle.isNullOrBlank()) {
|
||||
// marketTitle 关键字筛选
|
||||
filtered = filtered.filter { detail ->
|
||||
val matchRecord = matchRecords.find { it.id == detail.matchRecordId }
|
||||
val market = matchRecord?.let { markets[it.marketId] }
|
||||
market?.title?.contains(request.marketTitle!!, ignoreCase = true) == true
|
||||
}
|
||||
}
|
||||
|
||||
val total = filtered.size.toLong()
|
||||
|
||||
@@ -263,11 +306,26 @@ class CopyTradingStatisticsService(
|
||||
val end = minOf(start + limit, filtered.size)
|
||||
val pagedDetails = if (start < filtered.size) filtered.subList(start, end) else emptyList()
|
||||
|
||||
// 获取匹配记录以获取市场ID
|
||||
val pagedMatchRecordIds = pagedDetails.map { it.matchRecordId }.distinct()
|
||||
val pagedMatchRecords = pagedMatchRecordIds.mapNotNull { id ->
|
||||
sellMatchRecordRepository.findById(id).orElse(null)
|
||||
}
|
||||
val pagedMarketIds = pagedMatchRecords.map { it.marketId }.distinct()
|
||||
val pagedMarkets = marketService.getMarkets(pagedMarketIds)
|
||||
|
||||
// 转换为DTO
|
||||
val list = pagedDetails.map { detail ->
|
||||
val matchRecord = pagedMatchRecords.find { it.id == detail.matchRecordId }
|
||||
val market = matchRecord?.let { pagedMarkets[it.marketId] }
|
||||
MatchedOrderInfo(
|
||||
sellOrderId = sellMatchRecordRepository.findById(detail.matchRecordId).orElse(null)?.sellOrderId ?: "",
|
||||
sellOrderId = matchRecord?.sellOrderId ?: "",
|
||||
buyOrderId = detail.buyOrderId,
|
||||
marketId = matchRecord?.marketId,
|
||||
marketTitle = market?.title,
|
||||
marketSlug = market?.slug, // 显示用的 slug
|
||||
eventSlug = market?.eventSlug, // 跳转用的 slug(从数据库读取)
|
||||
marketCategory = market?.category,
|
||||
matchedQuantity = detail.matchedQuantity.toString(),
|
||||
buyPrice = detail.buyPrice.toString(),
|
||||
sellPrice = detail.sellPrice.toString(),
|
||||
@@ -324,200 +382,16 @@ class CopyTradingStatisticsService(
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前市场价格
|
||||
* 按 (marketId, outcomeIndex) 组合获取价格,支持多元市场
|
||||
* 计算盈亏百分比(仅基于已实现盈亏)
|
||||
*/
|
||||
private suspend fun getCurrentMarketPrice(buyOrders: List<CopyOrderTracking>): Map<String, String> {
|
||||
val prices = mutableMapOf<String, String>()
|
||||
|
||||
// 获取所有不同的 (marketId, outcomeIndex) 组合
|
||||
val marketOutcomePairs = buyOrders
|
||||
.filter { it.outcomeIndex != null }
|
||||
.map { Pair(it.marketId, it.outcomeIndex!!) }
|
||||
.distinct()
|
||||
|
||||
for ((marketId, outcomeIndex) in marketOutcomePairs) {
|
||||
try {
|
||||
// 传递 outcomeIndex 参数,确保获取对应 outcome 的价格
|
||||
val result = accountService.getMarketPrice(marketId, outcomeIndex)
|
||||
result.onSuccess { response ->
|
||||
// 使用当前价格
|
||||
val price = response.currentPrice
|
||||
if (price.isNotBlank() && price != "0") {
|
||||
// 使用 "marketId:outcomeIndex" 作为 key
|
||||
val key = "$marketId:$outcomeIndex"
|
||||
prices[key] = price
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取市场价格失败: marketId=$marketId, outcomeIndex=$outcomeIndex", e)
|
||||
}
|
||||
}
|
||||
|
||||
return prices
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取链上实际持仓
|
||||
* 按 (marketId, outcomeIndex) 组合返回实际持仓数量
|
||||
*/
|
||||
private suspend fun getActualPositions(account: 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>,
|
||||
actualPositions: Map<String, BigDecimal>
|
||||
): String {
|
||||
var totalUnrealizedPnl = BigDecimal.ZERO
|
||||
|
||||
// 按市场聚合订单,计算加权平均买入价格
|
||||
val marketAggregates = mutableMapOf<String, Pair<BigDecimal, BigDecimal>>() // key -> (总持仓, 总成本)
|
||||
|
||||
for (order in buyOrders) {
|
||||
// 如果没有 outcomeIndex,跳过(无法确定价格和持仓)
|
||||
if (order.outcomeIndex == null) {
|
||||
logger.warn("订单缺少 outcomeIndex,跳过未实现盈亏计算: orderId=${order.buyOrderId}, marketId=${order.marketId}")
|
||||
continue
|
||||
}
|
||||
|
||||
// 使用 "marketId:outcomeIndex" 作为 key
|
||||
val key = "${order.marketId}:${order.outcomeIndex}"
|
||||
|
||||
// 获取订单的持仓数量(使用 remainingQuantity,因为这是该订单的持仓)
|
||||
val orderQty = order.remainingQuantity.toSafeBigDecimal()
|
||||
|
||||
// 如果订单持仓 <= 0,跳过
|
||||
if (orderQty.lte(BigDecimal.ZERO)) continue
|
||||
|
||||
val buyPrice = order.price.toSafeBigDecimal()
|
||||
val orderCost = orderQty.multi(buyPrice)
|
||||
|
||||
// 聚合同一市场的订单
|
||||
val existing = marketAggregates[key]
|
||||
if (existing != null) {
|
||||
val totalQty = existing.first.add(orderQty)
|
||||
val totalCost = existing.second.add(orderCost)
|
||||
marketAggregates[key] = Pair(totalQty, totalCost)
|
||||
} else {
|
||||
marketAggregates[key] = Pair(orderQty, orderCost)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算每个市场的未实现盈亏
|
||||
for ((key, aggregate) in marketAggregates) {
|
||||
val (totalQty, totalCost) = aggregate
|
||||
|
||||
// 获取链上实际持仓数量(如果存在),否则使用聚合的持仓数量
|
||||
val actualQty = actualPositions[key] ?: totalQty
|
||||
|
||||
// 如果实际持仓 <= 0,说明已全部卖出(包括手动卖出),跳过未实现盈亏计算
|
||||
if (actualQty.lte(BigDecimal.ZERO)) continue
|
||||
|
||||
// 获取当前市场价格
|
||||
val currentPrice = currentPrices[key]?.toSafeBigDecimal()
|
||||
?: continue // 如果没有当前价格,跳过
|
||||
|
||||
// 计算加权平均买入价格
|
||||
val avgBuyPrice = if (totalQty.gt(BigDecimal.ZERO)) {
|
||||
totalCost.div(totalQty)
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
// 使用实际持仓数量和加权平均买入价格计算未实现盈亏
|
||||
val unrealizedPnl = currentPrice.subtract(avgBuyPrice).multi(actualQty)
|
||||
totalUnrealizedPnl = totalUnrealizedPnl.add(unrealizedPnl)
|
||||
}
|
||||
|
||||
return totalUnrealizedPnl.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算持仓价值
|
||||
* 使用链上实际持仓数量和当前市场价格计算
|
||||
* 按市场聚合,避免重复计算
|
||||
*/
|
||||
private fun calculatePositionValue(
|
||||
buyOrders: List<CopyOrderTracking>,
|
||||
currentPrices: Map<String, String>,
|
||||
actualPositions: Map<String, BigDecimal>
|
||||
): String {
|
||||
var totalPositionValue = BigDecimal.ZERO
|
||||
|
||||
// 按市场聚合,获取所有不同的市场
|
||||
val marketKeys = buyOrders
|
||||
.filter { it.outcomeIndex != null }
|
||||
.map { "${it.marketId}:${it.outcomeIndex}" }
|
||||
.distinct()
|
||||
|
||||
for (key in marketKeys) {
|
||||
// 获取链上实际持仓数量(如果存在)
|
||||
val actualQty = actualPositions[key]
|
||||
|
||||
// 如果没有链上持仓,计算该市场的总持仓(所有订单的 remainingQuantity 之和)
|
||||
val totalQty = actualQty ?: buyOrders
|
||||
.filter { it.outcomeIndex != null && "${it.marketId}:${it.outcomeIndex}" == key }
|
||||
.sumOf { it.remainingQuantity.toSafeBigDecimal() }
|
||||
|
||||
// 如果持仓 <= 0,跳过
|
||||
if (totalQty.lte(BigDecimal.ZERO)) continue
|
||||
|
||||
// 获取当前市场价格
|
||||
val currentPrice = currentPrices[key]?.toSafeBigDecimal()
|
||||
?: continue // 如果没有当前价格,跳过
|
||||
|
||||
// 计算持仓价值:持仓数量 × 当前价格
|
||||
val positionValue = totalQty.multi(currentPrice)
|
||||
totalPositionValue = totalPositionValue.add(positionValue)
|
||||
}
|
||||
|
||||
return totalPositionValue.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算盈亏百分比
|
||||
*/
|
||||
private fun calculatePnlPercent(
|
||||
private fun calculatePnlPercentOnlyRealized(
|
||||
totalBuyAmount: String,
|
||||
totalRealizedPnl: String,
|
||||
totalUnrealizedPnl: String
|
||||
totalRealizedPnl: String
|
||||
): String {
|
||||
val buyAmount = totalBuyAmount.toSafeBigDecimal()
|
||||
if (buyAmount.lte(BigDecimal.ZERO)) return "0"
|
||||
|
||||
val totalPnl = totalRealizedPnl.toSafeBigDecimal().add(totalUnrealizedPnl.toSafeBigDecimal())
|
||||
val percent = totalPnl.div(buyAmount).multi(100)
|
||||
val percent = totalRealizedPnl.toSafeBigDecimal().div(buyAmount).multi(100)
|
||||
|
||||
return percent.setScale(2, RoundingMode.HALF_UP).toString()
|
||||
}
|
||||
@@ -696,5 +570,250 @@ class CopyTradingStatisticsService(
|
||||
val currentPositionQuantity: String,
|
||||
val totalRealizedPnl: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 获取按市场分组的买入订单列表
|
||||
*/
|
||||
fun getBuyOrderListGroupedByMarket(request: MarketGroupedOrdersRequest): Result<MarketGroupedOrdersResponse> {
|
||||
return try {
|
||||
// 1. 验证跟单关系
|
||||
copyTradingRepository.findById(request.copyTradingId).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("跟单关系不存在: ${request.copyTradingId}"))
|
||||
|
||||
// 2. 获取所有买入订单
|
||||
var orders = copyOrderTrackingRepository.findByCopyTradingId(request.copyTradingId)
|
||||
|
||||
// 3. 批量获取市场信息(用于筛选)
|
||||
val allMarketIds = orders.map { it.marketId }.distinct()
|
||||
val markets = marketService.getMarkets(allMarketIds)
|
||||
|
||||
// 4. 筛选
|
||||
if (!request.marketId.isNullOrBlank()) {
|
||||
// marketId 支持模糊匹配
|
||||
orders = orders.filter { it.marketId.contains(request.marketId!!, ignoreCase = true) }
|
||||
}
|
||||
if (!request.marketTitle.isNullOrBlank()) {
|
||||
// marketTitle 关键字筛选
|
||||
orders = orders.filter { order ->
|
||||
val market = markets[order.marketId]
|
||||
market?.title?.contains(request.marketTitle!!, ignoreCase = true) == true
|
||||
}
|
||||
}
|
||||
// 5. 按市场ID分组
|
||||
val groups = mutableMapOf<String, MutableList<CopyOrderTracking>>()
|
||||
orders.forEach { order ->
|
||||
val marketId = order.marketId
|
||||
if (!groups.containsKey(marketId)) {
|
||||
groups[marketId] = mutableListOf()
|
||||
}
|
||||
groups[marketId]!!.add(order)
|
||||
}
|
||||
|
||||
// 4. 转换为分组数据并计算统计信息
|
||||
val marketIds = groups.keys.toList()
|
||||
|
||||
val list = marketIds.map { marketId ->
|
||||
val marketOrders = groups[marketId] ?: mutableListOf()
|
||||
|
||||
// 计算统计信息
|
||||
val count = marketOrders.size.toLong()
|
||||
val totalAmount = marketOrders.sumOf { order ->
|
||||
order.quantity.toSafeBigDecimal().multi(order.price)
|
||||
}
|
||||
|
||||
// 计算订单状态统计
|
||||
val fullyMatchedCount = marketOrders.count { it.status == "fully_matched" }
|
||||
val partiallyMatchedCount = marketOrders.count { it.status == "partially_matched" }
|
||||
val filledCount = marketOrders.count { it.status == "filled" }
|
||||
val fullyMatched = fullyMatchedCount == marketOrders.size
|
||||
|
||||
val stats = MarketOrderStats(
|
||||
count = count,
|
||||
totalAmount = totalAmount.toString(),
|
||||
totalPnl = null, // 买入订单没有已实现盈亏
|
||||
fullyMatched = fullyMatched,
|
||||
fullyMatchedCount = fullyMatchedCount.toLong(),
|
||||
partiallyMatchedCount = partiallyMatchedCount.toLong(),
|
||||
filledCount = filledCount.toLong()
|
||||
)
|
||||
|
||||
// 排序(按创建时间倒序)
|
||||
marketOrders.sortByDescending { it.createdAt }
|
||||
|
||||
// 转换为 DTO
|
||||
val orderDtos = marketOrders.map { order ->
|
||||
val amount = order.quantity.toSafeBigDecimal().multi(order.price)
|
||||
val market = markets[order.marketId]
|
||||
BuyOrderInfo(
|
||||
orderId = order.buyOrderId,
|
||||
leaderTradeId = order.leaderBuyTradeId,
|
||||
marketId = order.marketId,
|
||||
marketTitle = market?.title,
|
||||
marketSlug = market?.slug, // 显示用的 slug
|
||||
eventSlug = market?.eventSlug, // 跳转用的 slug(从数据库读取)
|
||||
marketCategory = market?.category,
|
||||
side = order.side,
|
||||
quantity = order.quantity.toString(),
|
||||
price = order.price.toString(),
|
||||
amount = amount.toString(),
|
||||
matchedQuantity = order.matchedQuantity.toString(),
|
||||
remainingQuantity = order.remainingQuantity.toString(),
|
||||
status = order.status,
|
||||
createdAt = order.createdAt
|
||||
)
|
||||
}
|
||||
|
||||
MarketOrderGroup(
|
||||
marketId = marketId,
|
||||
marketTitle = markets[marketId]?.title,
|
||||
marketSlug = markets[marketId]?.slug, // 显示用的 slug
|
||||
eventSlug = markets[marketId]?.eventSlug, // 跳转用的 slug(从数据库读取)
|
||||
marketCategory = markets[marketId]?.category,
|
||||
stats = stats,
|
||||
orders = orderDtos as List<Any>
|
||||
)
|
||||
}.sortedByDescending { it.stats.count }
|
||||
|
||||
// 5. 分页
|
||||
val page = (request.page ?: 1)
|
||||
val limit = request.limit ?: 20
|
||||
val total = list.size.toLong()
|
||||
|
||||
val start = (page - 1) * limit
|
||||
val end = minOf(start + limit, list.size)
|
||||
val pagedList = if (start < list.size) list.subList(start, end) else emptyList()
|
||||
|
||||
val response = MarketGroupedOrdersResponse(
|
||||
list = pagedList,
|
||||
total = total,
|
||||
page = page,
|
||||
limit = limit
|
||||
)
|
||||
|
||||
Result.success(response)
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取按市场分组的买入订单列表失败: copyTradingId=${request.copyTradingId}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取按市场分组的卖出订单列表
|
||||
*/
|
||||
fun getSellOrderListGroupedByMarket(request: MarketGroupedOrdersRequest): Result<MarketGroupedOrdersResponse> {
|
||||
return try {
|
||||
// 1. 验证跟单关系
|
||||
copyTradingRepository.findById(request.copyTradingId).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("跟单关系不存在: ${request.copyTradingId}"))
|
||||
|
||||
// 2. 获取所有卖出记录
|
||||
var sellRecords = sellMatchRecordRepository.findByCopyTradingId(request.copyTradingId)
|
||||
|
||||
// 3. 批量获取市场信息(用于筛选)
|
||||
val allMarketIds = sellRecords.map { it.marketId }.distinct()
|
||||
val markets = marketService.getMarkets(allMarketIds)
|
||||
|
||||
// 4. 筛选
|
||||
if (!request.marketId.isNullOrBlank()) {
|
||||
// marketId 支持模糊匹配
|
||||
sellRecords = sellRecords.filter { it.marketId.contains(request.marketId!!, ignoreCase = true) }
|
||||
}
|
||||
if (!request.marketTitle.isNullOrBlank()) {
|
||||
// marketTitle 关键字筛选
|
||||
sellRecords = sellRecords.filter { record ->
|
||||
val market = markets[record.marketId]
|
||||
market?.title?.contains(request.marketTitle!!, ignoreCase = true) == true
|
||||
}
|
||||
}
|
||||
// 5. 按市场ID分组
|
||||
val groups = mutableMapOf<String, MutableList<SellMatchRecord>>()
|
||||
sellRecords.forEach { record ->
|
||||
val marketId = record.marketId
|
||||
if (!groups.containsKey(marketId)) {
|
||||
groups[marketId] = mutableListOf()
|
||||
}
|
||||
groups[marketId]!!.add(record)
|
||||
}
|
||||
|
||||
// 4. 转换为分组数据并计算统计信息
|
||||
val marketIds = groups.keys.toList()
|
||||
|
||||
val list = marketIds.map { marketId ->
|
||||
val marketRecords = groups[marketId] ?: mutableListOf()
|
||||
|
||||
// 计算统计信息
|
||||
val count = marketRecords.size.toLong()
|
||||
val totalAmount = marketRecords.sumOf { record ->
|
||||
record.totalMatchedQuantity.toSafeBigDecimal().multi(record.sellPrice)
|
||||
}
|
||||
val totalPnl = marketRecords.sumOf { it.totalRealizedPnl.toSafeBigDecimal() }
|
||||
|
||||
val stats = MarketOrderStats(
|
||||
count = count,
|
||||
totalAmount = totalAmount.toString(),
|
||||
totalPnl = totalPnl.toString(),
|
||||
fullyMatched = true, // 卖出订单都是已成交的
|
||||
fullyMatchedCount = count, // 所有订单都是已成交的
|
||||
partiallyMatchedCount = 0L,
|
||||
filledCount = 0L
|
||||
)
|
||||
|
||||
// 排序(按创建时间倒序)
|
||||
marketRecords.sortByDescending { it.createdAt }
|
||||
|
||||
// 转换为 DTO
|
||||
val orderDtos = marketRecords.map { record ->
|
||||
val amount = record.totalMatchedQuantity.toSafeBigDecimal().multi(record.sellPrice)
|
||||
val market = markets[record.marketId]
|
||||
SellOrderInfo(
|
||||
orderId = record.sellOrderId,
|
||||
leaderTradeId = record.leaderSellTradeId,
|
||||
marketId = record.marketId,
|
||||
marketTitle = market?.title,
|
||||
marketSlug = market?.slug, // 显示用的 slug
|
||||
eventSlug = market?.eventSlug, // 跳转用的 slug(从数据库读取)
|
||||
marketCategory = market?.category,
|
||||
side = record.side,
|
||||
quantity = record.totalMatchedQuantity.toString(),
|
||||
price = record.sellPrice.toString(),
|
||||
amount = amount.toString(),
|
||||
realizedPnl = record.totalRealizedPnl.toString(),
|
||||
createdAt = record.createdAt
|
||||
)
|
||||
}
|
||||
|
||||
MarketOrderGroup(
|
||||
marketId = marketId,
|
||||
marketTitle = markets[marketId]?.title,
|
||||
marketSlug = markets[marketId]?.slug, // 显示用的 slug
|
||||
eventSlug = markets[marketId]?.eventSlug, // 跳转用的 slug(从数据库读取)
|
||||
marketCategory = markets[marketId]?.category,
|
||||
stats = stats,
|
||||
orders = orderDtos as List<Any>
|
||||
)
|
||||
}.sortedByDescending { it.stats.count }
|
||||
|
||||
// 5. 分页
|
||||
val page = (request.page ?: 1)
|
||||
val limit = request.limit ?: 20
|
||||
val total = list.size.toLong()
|
||||
|
||||
val start = (page - 1) * limit
|
||||
val end = minOf(start + limit, list.size)
|
||||
val pagedList = if (start < list.size) list.subList(start, end) else emptyList()
|
||||
|
||||
val response = MarketGroupedOrdersResponse(
|
||||
list = pagedList,
|
||||
total = total,
|
||||
page = page,
|
||||
limit = limit
|
||||
)
|
||||
|
||||
Result.success(response)
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取按市场分组的卖出订单列表失败: copyTradingId=${request.copyTradingId}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
+111
-88
@@ -3,6 +3,7 @@ package com.wrbug.polymarketbot.service.copytrading.statistics
|
||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||
import com.wrbug.polymarketbot.entity.*
|
||||
import com.wrbug.polymarketbot.repository.*
|
||||
import com.wrbug.polymarketbot.service.common.MarketService
|
||||
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.CryptoUtils
|
||||
@@ -33,6 +34,7 @@ class OrderStatusUpdateService(
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val cryptoUtils: CryptoUtils,
|
||||
private val trackingService: CopyOrderTrackingService,
|
||||
private val marketService: MarketService, // 市场信息服务
|
||||
private val telegramNotificationService: TelegramNotificationService?
|
||||
) {
|
||||
|
||||
@@ -143,8 +145,13 @@ class OrderStatusUpdateService(
|
||||
// 计算30秒前的时间戳
|
||||
val thirtySecondsAgo = System.currentTimeMillis() - 30000
|
||||
|
||||
// 查询30秒前创建的订单
|
||||
val ordersToCheck = copyOrderTrackingRepository.findByCreatedAtBefore(thirtySecondsAgo)
|
||||
// 查询30秒前创建的订单,并过滤掉已经完全匹配的订单
|
||||
// 已经完全匹配的订单(status = "fully_matched")不需要再检查
|
||||
// 使用数据库查询过滤,避免加载过多数据
|
||||
val ordersToCheck = copyOrderTrackingRepository.findByCreatedAtBeforeAndStatusNot(
|
||||
thirtySecondsAgo,
|
||||
"fully_matched"
|
||||
)
|
||||
|
||||
if (ordersToCheck.isEmpty()) {
|
||||
return
|
||||
@@ -337,7 +344,8 @@ class OrderStatusUpdateService(
|
||||
copyTrading = copyTrading,
|
||||
clobApi = clobApi,
|
||||
apiSecret = apiSecret,
|
||||
apiPassphrase = apiPassphrase
|
||||
apiPassphrase = apiPassphrase,
|
||||
orderCreatedAt = record.createdAt
|
||||
)
|
||||
} else {
|
||||
logger.debug("自动生成的订单,跳过发送通知: orderId=${record.sellOrderId}")
|
||||
@@ -422,19 +430,7 @@ class OrderStatusUpdateService(
|
||||
totalRealizedPnl = totalRealizedPnl.add(updatedRealizedPnl)
|
||||
}
|
||||
|
||||
// 发送通知(使用实际价格)
|
||||
sendSellOrderNotification(
|
||||
record = record,
|
||||
actualPrice = actualSellPrice.toString(),
|
||||
actualSize = record.totalMatchedQuantity.toString(),
|
||||
account = account,
|
||||
copyTrading = copyTrading,
|
||||
clobApi = clobApi,
|
||||
apiSecret = apiSecret,
|
||||
apiPassphrase = apiPassphrase
|
||||
)
|
||||
|
||||
// 更新卖出记录
|
||||
// 先更新卖出记录,标记 priceUpdated = true(在发送通知之前更新)
|
||||
// 注意:SellMatchRecord 的字段都是 val,需要创建新对象
|
||||
val updatedRecord = SellMatchRecord(
|
||||
id = record.id,
|
||||
@@ -452,20 +448,31 @@ class OrderStatusUpdateService(
|
||||
)
|
||||
sellMatchRecordRepository.save(updatedRecord)
|
||||
|
||||
logger.info("更新卖出订单价格成功并已发送通知: orderId=${record.sellOrderId}, 原价格=${record.sellPrice}, 新价格=$actualSellPrice")
|
||||
} else {
|
||||
// 价格相同,但已经查询过,发送通知并标记为已处理
|
||||
sendSellOrderNotification(
|
||||
record = record,
|
||||
actualPrice = actualSellPrice.toString(),
|
||||
actualSize = record.totalMatchedQuantity.toString(),
|
||||
account = account,
|
||||
copyTrading = copyTrading,
|
||||
clobApi = clobApi,
|
||||
apiSecret = apiSecret,
|
||||
apiPassphrase = apiPassphrase
|
||||
)
|
||||
logger.info("更新卖出订单价格成功: orderId=${record.sellOrderId}, 原价格=${record.sellPrice}, 新价格=$actualSellPrice")
|
||||
|
||||
// 重新从数据库查询最新的记录状态,检查是否已被其他任务标记为已处理
|
||||
val latestRecord = sellMatchRecordRepository.findById(record.id!!).orElse(null)
|
||||
if (latestRecord == null) {
|
||||
logger.warn("卖出记录已被删除,跳过发送通知: orderId=${record.sellOrderId}, recordId=${record.id}")
|
||||
} else if (latestRecord.priceUpdated) {
|
||||
logger.debug("卖出记录已被标记为已处理,跳过重复发送通知: orderId=${record.sellOrderId}, recordId=${record.id}")
|
||||
} else {
|
||||
// 发送通知(使用实际价格)
|
||||
sendSellOrderNotification(
|
||||
record = updatedRecord,
|
||||
actualPrice = actualSellPrice.toString(),
|
||||
actualSize = record.totalMatchedQuantity.toString(),
|
||||
account = account,
|
||||
copyTrading = copyTrading,
|
||||
clobApi = clobApi,
|
||||
apiSecret = apiSecret,
|
||||
apiPassphrase = apiPassphrase,
|
||||
orderCreatedAt = record.createdAt
|
||||
)
|
||||
logger.info("卖出订单通知已发送: orderId=${record.sellOrderId}")
|
||||
}
|
||||
} else {
|
||||
// 价格相同,但已经查询过,先标记为已处理再发送通知
|
||||
val updatedRecord = SellMatchRecord(
|
||||
id = record.id,
|
||||
copyTradingId = record.copyTradingId,
|
||||
@@ -481,7 +488,30 @@ class OrderStatusUpdateService(
|
||||
createdAt = record.createdAt
|
||||
)
|
||||
sellMatchRecordRepository.save(updatedRecord)
|
||||
logger.debug("卖出订单价格无需更新但已发送通知: orderId=${record.sellOrderId}, price=$actualSellPrice")
|
||||
|
||||
logger.debug("卖出订单价格无需更新: orderId=${record.sellOrderId}, price=$actualSellPrice")
|
||||
|
||||
// 重新从数据库查询最新的记录状态,检查是否已被其他任务标记为已处理
|
||||
val latestRecord = sellMatchRecordRepository.findById(record.id!!).orElse(null)
|
||||
if (latestRecord == null) {
|
||||
logger.warn("卖出记录已被删除,跳过发送通知: orderId=${record.sellOrderId}, recordId=${record.id}")
|
||||
} else if (latestRecord.priceUpdated) {
|
||||
logger.debug("卖出记录已被标记为已处理,跳过重复发送通知: orderId=${record.sellOrderId}, recordId=${record.id}")
|
||||
} else {
|
||||
// 发送通知
|
||||
sendSellOrderNotification(
|
||||
record = updatedRecord,
|
||||
actualPrice = actualSellPrice.toString(),
|
||||
actualSize = record.totalMatchedQuantity.toString(),
|
||||
account = account,
|
||||
copyTrading = copyTrading,
|
||||
clobApi = clobApi,
|
||||
apiSecret = apiSecret,
|
||||
apiPassphrase = apiPassphrase,
|
||||
orderCreatedAt = record.createdAt
|
||||
)
|
||||
logger.info("卖出订单通知已发送: orderId=${record.sellOrderId}")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("更新卖出订单价格失败: orderId=${record.sellOrderId}, error=${e.message}", e)
|
||||
@@ -514,7 +544,7 @@ class OrderStatusUpdateService(
|
||||
// 验证 orderId 格式(必须以 0x 开头的 16 进制)
|
||||
if (!isValidOrderId(order.buyOrderId)) {
|
||||
logger.warn("买入订单ID格式无效,直接标记为已发送通知: orderId=${order.buyOrderId}")
|
||||
// 对于非 0x 开头的订单ID,直接标记为已发送,使用临时数据发送通知
|
||||
// 对于非 0x 开头的订单ID,先标记为已发送,再使用临时数据发送通知
|
||||
val updatedOrder = CopyOrderTracking(
|
||||
id = order.id,
|
||||
copyTradingId = order.copyTradingId,
|
||||
@@ -535,7 +565,16 @@ class OrderStatusUpdateService(
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
copyOrderTrackingRepository.save(updatedOrder)
|
||||
sendBuyOrderNotification(updatedOrder, useTemporaryData = true)
|
||||
|
||||
// 重新从数据库查询最新的订单状态,检查是否已被其他任务标记为已发送通知
|
||||
val latestOrder = copyOrderTrackingRepository.findById(order.id!!).orElse(null)
|
||||
if (latestOrder == null) {
|
||||
logger.warn("订单已被删除,跳过发送通知: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
|
||||
} else if (latestOrder.notificationSent) {
|
||||
logger.debug("订单已被标记为已发送通知,跳过重复发送: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
|
||||
} else {
|
||||
sendBuyOrderNotification(updatedOrder, useTemporaryData = true, orderCreatedAt = order.createdAt)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -623,7 +662,8 @@ class OrderStatusUpdateService(
|
||||
// 更新订单数据(如果实际数据与临时数据不同)
|
||||
val needUpdate = actualPrice != order.price || actualSize != order.quantity
|
||||
|
||||
// 创建更新后的订单对象
|
||||
// 先保存更新后的订单,标记 notificationSent = true
|
||||
// 这样可以防止其他并发任务重复发送通知
|
||||
val updatedOrder = CopyOrderTracking(
|
||||
id = order.id,
|
||||
copyTradingId = order.copyTradingId,
|
||||
@@ -644,7 +684,7 @@ class OrderStatusUpdateService(
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
// 保存更新后的订单
|
||||
// 保存更新后的订单(在发送通知之前保存)
|
||||
copyOrderTrackingRepository.save(updatedOrder)
|
||||
|
||||
if (needUpdate) {
|
||||
@@ -653,18 +693,27 @@ class OrderStatusUpdateService(
|
||||
logger.debug("买入订单数据无需更新: orderId=${order.buyOrderId}")
|
||||
}
|
||||
|
||||
// 发送通知(使用实际数据)
|
||||
sendBuyOrderNotification(
|
||||
order = updatedOrder,
|
||||
actualPrice = actualPrice.toString(),
|
||||
actualSize = actualSize.toString(),
|
||||
actualOutcome = actualOutcome,
|
||||
account = account,
|
||||
copyTrading = copyTrading,
|
||||
clobApi = clobApi,
|
||||
apiSecret = apiSecret,
|
||||
apiPassphrase = apiPassphrase
|
||||
)
|
||||
// 重新从数据库查询最新的订单状态,检查是否已被其他任务标记为已发送通知
|
||||
val latestOrder = copyOrderTrackingRepository.findById(order.id!!).orElse(null)
|
||||
if (latestOrder == null) {
|
||||
logger.warn("订单已被删除,跳过发送通知: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
|
||||
} else if (latestOrder.notificationSent) {
|
||||
logger.debug("订单已被标记为已发送通知,跳过重复发送: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
|
||||
} else {
|
||||
// 发送通知(使用实际数据)
|
||||
sendBuyOrderNotification(
|
||||
order = updatedOrder,
|
||||
actualPrice = actualPrice.toString(),
|
||||
actualSize = actualSize.toString(),
|
||||
actualOutcome = actualOutcome,
|
||||
account = account,
|
||||
copyTrading = copyTrading,
|
||||
clobApi = clobApi,
|
||||
apiSecret = apiSecret,
|
||||
apiPassphrase = apiPassphrase,
|
||||
orderCreatedAt = order.createdAt
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("更新买入订单失败: orderId=${order.buyOrderId}, error=${e.message}", e)
|
||||
// 继续处理下一条记录
|
||||
@@ -688,7 +737,8 @@ class OrderStatusUpdateService(
|
||||
copyTrading: CopyTrading? = null,
|
||||
clobApi: PolymarketClobApi? = null,
|
||||
apiSecret: String? = null,
|
||||
apiPassphrase: String? = null
|
||||
apiPassphrase: String? = null,
|
||||
orderCreatedAt: Long? = null // 订单创建时间(毫秒时间戳)
|
||||
) {
|
||||
if (telegramNotificationService == null) {
|
||||
return
|
||||
@@ -707,25 +757,10 @@ class OrderStatusUpdateService(
|
||||
logger.warn("账户不存在,跳过发送通知: accountId=${order.accountId}")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// 获取市场信息
|
||||
val marketInfo = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val gammaApi = retrofitFactory.createGammaApi()
|
||||
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(order.marketId))
|
||||
if (marketResponse.isSuccessful && marketResponse.body() != null) {
|
||||
marketResponse.body()!!.firstOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取市场信息失败: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val marketTitle = marketInfo?.question ?: order.marketId
|
||||
val marketSlug = marketInfo?.slug
|
||||
val market = marketService.getMarket(order.marketId)
|
||||
val marketTitle = market?.title ?: order.marketId
|
||||
|
||||
// 获取 Leader 和跟单配置信息
|
||||
val leader = leaderRepository.findById(order.leaderId).orElse(null)
|
||||
@@ -756,7 +791,7 @@ class OrderStatusUpdateService(
|
||||
orderId = order.buyOrderId,
|
||||
marketTitle = marketTitle,
|
||||
marketId = order.marketId,
|
||||
marketSlug = marketSlug,
|
||||
marketSlug = market?.eventSlug, // 跳转用的 slug
|
||||
side = "BUY",
|
||||
price = actualPrice ?: order.price.toString(), // 使用实际价格或临时价格
|
||||
size = actualSize ?: order.quantity.toString(), // 使用实际数量或临时数量
|
||||
@@ -770,7 +805,8 @@ class OrderStatusUpdateService(
|
||||
walletAddressForApi = finalAccount.walletAddress,
|
||||
locale = locale,
|
||||
leaderName = leaderName,
|
||||
configName = configName
|
||||
configName = configName,
|
||||
orderTime = orderCreatedAt // 使用订单创建时间
|
||||
)
|
||||
|
||||
logger.info("买入订单通知已发送: orderId=${order.buyOrderId}, copyTradingId=${order.copyTradingId}")
|
||||
@@ -793,7 +829,8 @@ class OrderStatusUpdateService(
|
||||
copyTrading: CopyTrading? = null,
|
||||
clobApi: PolymarketClobApi? = null,
|
||||
apiSecret: String? = null,
|
||||
apiPassphrase: String? = null
|
||||
apiPassphrase: String? = null,
|
||||
orderCreatedAt: Long? = null // 订单创建时间(毫秒时间戳)
|
||||
) {
|
||||
if (telegramNotificationService == null) {
|
||||
return
|
||||
@@ -812,25 +849,10 @@ class OrderStatusUpdateService(
|
||||
logger.warn("账户不存在,跳过发送通知: accountId=${finalCopyTrading.accountId}")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// 获取市场信息
|
||||
val marketInfo = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val gammaApi = retrofitFactory.createGammaApi()
|
||||
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(record.marketId))
|
||||
if (marketResponse.isSuccessful && marketResponse.body() != null) {
|
||||
marketResponse.body()!!.firstOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取市场信息失败: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val marketTitle = marketInfo?.question ?: record.marketId
|
||||
val marketSlug = marketInfo?.slug
|
||||
val market = marketService.getMarket(record.marketId)
|
||||
val marketTitle = market?.title ?: record.marketId
|
||||
|
||||
// 获取 Leader 和跟单配置信息
|
||||
val leader = leaderRepository.findById(finalCopyTrading.leaderId).orElse(null)
|
||||
@@ -861,7 +883,7 @@ class OrderStatusUpdateService(
|
||||
orderId = record.sellOrderId,
|
||||
marketTitle = marketTitle,
|
||||
marketId = record.marketId,
|
||||
marketSlug = marketSlug,
|
||||
marketSlug = market?.eventSlug, // 跳转用的 slug
|
||||
side = "SELL",
|
||||
price = actualPrice ?: record.sellPrice.toString(), // 使用实际价格或临时价格
|
||||
size = actualSize ?: record.totalMatchedQuantity.toString(), // 使用实际数量或临时数量
|
||||
@@ -875,7 +897,8 @@ class OrderStatusUpdateService(
|
||||
walletAddressForApi = finalAccount.walletAddress,
|
||||
locale = locale,
|
||||
leaderName = leaderName,
|
||||
configName = configName
|
||||
configName = configName,
|
||||
orderTime = orderCreatedAt // 使用订单创建时间
|
||||
)
|
||||
|
||||
logger.info("卖出订单通知已发送: orderId=${record.sellOrderId}, copyTradingId=${record.copyTradingId}")
|
||||
|
||||
+6
-2
@@ -62,7 +62,8 @@ class CopyTradingTemplateService(
|
||||
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal(),
|
||||
maxSpread = request.maxSpread?.toSafeBigDecimal(),
|
||||
minPrice = request.minPrice?.toSafeBigDecimal(),
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal()
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal(),
|
||||
pushFilteredOrders = request.pushFilteredOrders ?: false
|
||||
)
|
||||
|
||||
val saved = templateRepository.save(template)
|
||||
@@ -121,6 +122,7 @@ class CopyTradingTemplateService(
|
||||
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: template.maxSpread,
|
||||
minPrice = request.minPrice?.toSafeBigDecimal() ?: template.minPrice,
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice,
|
||||
pushFilteredOrders = request.pushFilteredOrders ?: template.pushFilteredOrders,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
@@ -186,7 +188,8 @@ class CopyTradingTemplateService(
|
||||
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: sourceTemplate.minOrderDepth,
|
||||
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: sourceTemplate.maxSpread,
|
||||
minPrice = request.minPrice?.toSafeBigDecimal() ?: sourceTemplate.minPrice,
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: sourceTemplate.maxPrice
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: sourceTemplate.maxPrice,
|
||||
pushFilteredOrders = request.pushFilteredOrders ?: sourceTemplate.pushFilteredOrders
|
||||
)
|
||||
|
||||
val saved = templateRepository.save(newTemplate)
|
||||
@@ -260,6 +263,7 @@ class CopyTradingTemplateService(
|
||||
maxSpread = template.maxSpread?.toPlainString(),
|
||||
minPrice = template.minPrice?.toPlainString(),
|
||||
maxPrice = template.maxPrice?.toPlainString(),
|
||||
pushFilteredOrders = template.pushFilteredOrders,
|
||||
createdAt = template.createdAt,
|
||||
updatedAt = template.updatedAt
|
||||
)
|
||||
|
||||
@@ -1,579 +0,0 @@
|
||||
package com.wrbug.polymarketbot.service.nba
|
||||
|
||||
import com.wrbug.polymarketbot.api.PolymarketGammaApi
|
||||
import com.wrbug.polymarketbot.dto.NbaGameDto
|
||||
import com.wrbug.polymarketbot.dto.NbaGameListRequest
|
||||
import com.wrbug.polymarketbot.dto.NbaGameListResponse
|
||||
import com.wrbug.polymarketbot.entity.NbaGame
|
||||
import com.wrbug.polymarketbot.enums.SportsTagId
|
||||
import com.wrbug.polymarketbot.repository.NbaGameRepository
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
/**
|
||||
* NBA 比赛服务
|
||||
* 从数据库和 Polymarket API 获取比赛数据
|
||||
* 优先从数据库获取,如果数据不足则增量拉取 API 数据
|
||||
*/
|
||||
@Service
|
||||
class NbaGameService(
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val nbaGameRepository: NbaGameRepository
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(NbaGameService::class.java)
|
||||
|
||||
|
||||
/**
|
||||
* 获取 NBA 比赛列表
|
||||
* 优先从数据库获取,如果数据不足则增量拉取 API 数据
|
||||
* 前端传递时间戳,后端转换为西8区时间用于过滤
|
||||
*/
|
||||
suspend fun getNbaGames(request: NbaGameListRequest): Result<NbaGameListResponse> {
|
||||
return try {
|
||||
// 将时间戳转换为西8区(PST/PDT)的日期范围
|
||||
val pstZone = ZoneId.of("America/Los_Angeles")
|
||||
|
||||
val startTimestamp = request.startTimestamp ?: ZonedDateTime.now(pstZone).toInstant().toEpochMilli()
|
||||
val endTimestamp = request.endTimestamp ?: ZonedDateTime.now(pstZone).plusDays(7).toInstant().toEpochMilli()
|
||||
|
||||
val startDate = Instant.ofEpochMilli(startTimestamp).atZone(pstZone).toLocalDate()
|
||||
val endDate = Instant.ofEpochMilli(endTimestamp).atZone(pstZone).toLocalDate()
|
||||
|
||||
// 1. 先从数据库获取数据
|
||||
val dbGames = nbaGameRepository.findByGameDateBetween(startDate, endDate)
|
||||
logger.info("从数据库获取到 ${dbGames.size} 个比赛(日期范围:$startDate 到 $endDate)")
|
||||
|
||||
// 2. 检查是否需要增量拉取
|
||||
val needFetch = shouldFetchFromApi(dbGames)
|
||||
|
||||
if (needFetch) {
|
||||
logger.info("数据库数据不足,开始增量拉取 API 数据")
|
||||
|
||||
// 3. 获取数据库最新的 createdAt,用于增量拉取
|
||||
val latestGame = nbaGameRepository.findFirstByOrderByCreatedAtDesc()
|
||||
val incrementalStartDateMin = latestGame?.createdAt?.let {
|
||||
// 将数据库的 createdAt(时间戳)转换为 UTC ISO 8601 格式
|
||||
Instant.ofEpochMilli(it)
|
||||
.atZone(java.time.ZoneOffset.UTC)
|
||||
.format(DateTimeFormatter.ISO_INSTANT)
|
||||
} ?: run {
|
||||
// 如果没有数据库数据,使用一周前的时间
|
||||
Instant.now()
|
||||
.minusSeconds(7 * 24 * 60 * 60)
|
||||
.atZone(java.time.ZoneOffset.UTC)
|
||||
.format(DateTimeFormatter.ISO_INSTANT)
|
||||
}
|
||||
|
||||
logger.info("使用增量拉取起始时间: $incrementalStartDateMin")
|
||||
|
||||
// 4. 增量拉取 API 数据
|
||||
val apiGames = fetchGamesFromApi(startDate, endDate, incrementalStartDateMin)
|
||||
|
||||
// 5. 保存新数据到数据库
|
||||
if (apiGames.isNotEmpty()) {
|
||||
saveGamesToDatabase(apiGames)
|
||||
}
|
||||
|
||||
// 6. 合并数据库数据和 API 数据
|
||||
val allGames = (dbGames + apiGames.map { dtoToEntity(it) }).distinctBy {
|
||||
"${it.homeTeam}_${it.awayTeam}_${it.gameDate}"
|
||||
}
|
||||
|
||||
// 转换为 DTO
|
||||
val gameDtos = allGames.map { entityToDto(it) }
|
||||
|
||||
// 根据状态过滤
|
||||
val filteredGames = if (request.gameStatus != null) {
|
||||
gameDtos.filter { it.gameStatus == request.gameStatus }
|
||||
} else {
|
||||
gameDtos
|
||||
}
|
||||
|
||||
Result.success(
|
||||
NbaGameListResponse(
|
||||
list = filteredGames,
|
||||
total = filteredGames.size.toLong()
|
||||
)
|
||||
)
|
||||
} else {
|
||||
// 数据库数据充足,直接返回
|
||||
val gameDtos = dbGames.map { entityToDto(it) }
|
||||
|
||||
// 根据状态过滤
|
||||
val filteredGames = if (request.gameStatus != null) {
|
||||
gameDtos.filter { it.gameStatus == request.gameStatus }
|
||||
} else {
|
||||
gameDtos
|
||||
}
|
||||
|
||||
Result.success(
|
||||
NbaGameListResponse(
|
||||
list = filteredGames,
|
||||
total = filteredGames.size.toLong()
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取 NBA 比赛列表失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否需要从 API 拉取数据
|
||||
* 逻辑:
|
||||
* 1. 如果数据库没有数据,需要拉取
|
||||
* 2. 如果数据库最新数据的 gameTime 在未来 3 天内(0-3 天),不需要拉取
|
||||
* 3. 如果数据库最新数据的 gameTime 超过 3 天(>3 天),不需要拉取(数据太远)
|
||||
* 4. 如果数据库最新数据的 gameTime 已经过去(<0),需要拉取(数据过期)
|
||||
*/
|
||||
private fun shouldFetchFromApi(dbGames: List<NbaGame>): Boolean {
|
||||
if (dbGames.isEmpty()) {
|
||||
logger.info("数据库没有数据,需要从 API 拉取")
|
||||
return true
|
||||
}
|
||||
|
||||
// 检查最新数据的 gameTime(未来最远的比赛)
|
||||
val latestGame = dbGames.maxByOrNull { it.gameTime ?: 0L }
|
||||
if (latestGame?.gameTime == null) {
|
||||
logger.info("数据库最新数据没有 gameTime,需要从 API 拉取")
|
||||
return true
|
||||
}
|
||||
|
||||
// 计算最新数据的 gameTime 距离现在的时间(以天为单位)
|
||||
val now = Instant.now().toEpochMilli()
|
||||
val gameTime = latestGame.gameTime
|
||||
val daysDiff = (gameTime - now) / (24 * 60 * 60 * 1000)
|
||||
|
||||
// 如果数据已经过去(daysDiff < 0),需要拉取
|
||||
if (daysDiff < 0) {
|
||||
logger.info("数据库最新数据的 gameTime 已经过去(${daysDiff} 天前),需要从 API 拉取")
|
||||
return true
|
||||
}
|
||||
|
||||
// 如果数据在未来 3 天内(0 <= daysDiff <= 3),不需要拉取
|
||||
if (daysDiff >= 0 && daysDiff <= 3) {
|
||||
logger.info("数据库数据充足(最新数据 ${daysDiff} 天后,在未来 3 天内),无需从 API 拉取")
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果数据超过 3 天(daysDiff > 3),不需要拉取(数据太远)
|
||||
logger.info("数据库最新数据的 gameTime 超过 3 天(${daysDiff} 天后),数据太远,不需要从 API 拉取")
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 API 拉取比赛数据
|
||||
*/
|
||||
private suspend fun fetchGamesFromApi(
|
||||
startDate: LocalDate,
|
||||
endDate: LocalDate,
|
||||
startDateMin: String
|
||||
): List<NbaGameDto> {
|
||||
// 从 Polymarket API 获取 NBA 市场(分页拉取)
|
||||
val gammaApi = retrofitFactory.createGammaApi()
|
||||
val nbaTagId = SportsTagId.NBA.tagId
|
||||
|
||||
// 计算未来3天的时间点(UTC)
|
||||
val threeDaysLater = Instant.now()
|
||||
.plusSeconds(3 * 24 * 60 * 60) // 加上3天(秒数)
|
||||
|
||||
val allMarkets = mutableListOf<com.wrbug.polymarketbot.api.MarketResponse>()
|
||||
var hasMore = true
|
||||
var pageCount = 0
|
||||
var currentStartDateMin = startDateMin
|
||||
|
||||
while (hasMore) {
|
||||
pageCount++
|
||||
logger.debug("分页拉取第 $pageCount 页,start_date_min: $currentStartDateMin")
|
||||
|
||||
val response = gammaApi.listMarkets(
|
||||
conditionIds = null,
|
||||
includeTag = true,
|
||||
tags = listOf(nbaTagId),
|
||||
active = true, // 只获取活跃的市场
|
||||
closed = false,
|
||||
archived = false,
|
||||
limit = 500, // 使用 500 作为 limit
|
||||
startDateMin = currentStartDateMin,
|
||||
sportsMarketTypes = listOf("moneyline") // 直接通过 API 筛选 moneyline 类型
|
||||
)
|
||||
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
logger.error("获取 NBA 市场失败: ${response.code()} ${response.message()}")
|
||||
break
|
||||
}
|
||||
|
||||
val markets = response.body()!!
|
||||
logger.info("第 $pageCount 页获取到 ${markets.size} 个市场")
|
||||
|
||||
if (markets.isEmpty()) {
|
||||
// 没有更多数据了
|
||||
hasMore = false
|
||||
break
|
||||
}
|
||||
|
||||
// 先记录最后一项的 createdAt(用于下一次分页)
|
||||
val lastMarket = markets.last()
|
||||
val lastCreatedAt = lastMarket.createdAt
|
||||
|
||||
if (lastCreatedAt == null) {
|
||||
// 如果最后一个元素没有 createdAt,停止分页
|
||||
hasMore = false
|
||||
logger.warn("数组最后一个元素缺少 createdAt,停止分页")
|
||||
break
|
||||
}
|
||||
|
||||
// 移除非 NBA 项(根据 resolutionSource 判断)
|
||||
val nbaMarkets = markets.filter { market ->
|
||||
!market.resolutionSource.isNullOrBlank() &&
|
||||
market.resolutionSource!!.lowercase().contains("nba")
|
||||
}
|
||||
logger.info("第 $pageCount 页过滤后剩余 ${nbaMarkets.size} 个 NBA 市场")
|
||||
|
||||
// 添加到总列表(只添加 NBA 市场)
|
||||
allMarkets.addAll(nbaMarkets)
|
||||
|
||||
// 从后往前遍历,找到第一个有 gameStartTime 字段的数据(在 NBA 市场中查找)
|
||||
var foundGameStartTime: String? = null
|
||||
for (i in nbaMarkets.size - 1 downTo 0) {
|
||||
val market = nbaMarkets[i]
|
||||
if (!market.gameStartTime.isNullOrBlank()) {
|
||||
foundGameStartTime = market.gameStartTime
|
||||
logger.debug("从后往前找到第 ${i + 1} 个有 gameStartTime 的 NBA 市场: $foundGameStartTime")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (foundGameStartTime == null) {
|
||||
// 如果整页都没有 gameStartTime,使用 createdAt 继续分页
|
||||
currentStartDateMin = lastCreatedAt
|
||||
logger.debug("本页没有找到 gameStartTime,使用最后一个元素的 createdAt 继续分页")
|
||||
continue
|
||||
}
|
||||
|
||||
// 解析 gameStartTime(格式:2025-12-13 00:00:00+00)
|
||||
val gameStartDate = try {
|
||||
// 尝试解析格式 "2025-12-13 00:00:00+00"
|
||||
val dateTimeStr = foundGameStartTime.replace(" ", "T")
|
||||
// 如果时区是 +00,转换为 Z
|
||||
val normalizedStr = if (dateTimeStr.endsWith("+00")) {
|
||||
dateTimeStr.replace("+00", "Z")
|
||||
} else if (dateTimeStr.endsWith("-00")) {
|
||||
dateTimeStr.replace("-00", "Z")
|
||||
} else {
|
||||
dateTimeStr
|
||||
}
|
||||
val instant = Instant.parse(normalizedStr)
|
||||
// 转换为日期(以天为单位,不考虑时间)
|
||||
instant.atZone(java.time.ZoneOffset.UTC).toLocalDate()
|
||||
} catch (e: Exception) {
|
||||
logger.warn("解析 gameStartTime 失败: $foundGameStartTime, error: ${e.message}")
|
||||
null
|
||||
}
|
||||
|
||||
if (gameStartDate == null) {
|
||||
// 无法解析 gameStartTime,使用 createdAt 继续分页
|
||||
currentStartDateMin = lastCreatedAt
|
||||
logger.debug("无法解析 gameStartTime,使用最后一个元素的 createdAt 继续分页")
|
||||
continue
|
||||
}
|
||||
|
||||
// 计算未来 3 天的日期(以天为单位,不考虑时间)
|
||||
val threeDaysLaterDate = Instant.now()
|
||||
.plusSeconds(3 * 24 * 60 * 60) // 加上3天(秒数)
|
||||
.atZone(java.time.ZoneOffset.UTC)
|
||||
.toLocalDate()
|
||||
|
||||
// 判断 gameStartDate 是否在未来 3 天以内(包括第 3 天)
|
||||
val daysBetween = java.time.temporal.ChronoUnit.DAYS.between(
|
||||
Instant.now().atZone(java.time.ZoneOffset.UTC).toLocalDate(),
|
||||
gameStartDate
|
||||
)
|
||||
|
||||
if (daysBetween <= 3 && daysBetween >= 0) {
|
||||
// 如果在 3 天内(包括第 3 天),使用数组最后一个元素的 createdAt 继续分页
|
||||
currentStartDateMin = lastCreatedAt
|
||||
logger.info("找到的 gameStartTime ($foundGameStartTime, 日期: $gameStartDate) 在未来 ${daysBetween} 天内,继续分页")
|
||||
} else {
|
||||
// 如果不在 3 天内,停止分页
|
||||
hasMore = false
|
||||
logger.info("找到的 gameStartTime ($foundGameStartTime, 日期: $gameStartDate) 不在未来 3 天内(相差 ${daysBetween} 天),停止分页")
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("分页拉取完成,共获取 ${allMarkets.size} 个 NBA moneyline 市场(${pageCount} 页)")
|
||||
|
||||
// 注意:allMarkets 已经通过 API 的 sports_market_types 参数过滤了 moneyline 类型
|
||||
// 并且已经过滤了非 NBA 项(根据 resolutionSource),这里直接使用即可
|
||||
|
||||
// 将市场转换为比赛数据
|
||||
val games = allMarkets.mapNotNull { market ->
|
||||
convertMarketToGame(market, startDate, endDate)
|
||||
}
|
||||
|
||||
// 去重:相同的主队、客队和日期只保留一个
|
||||
val uniqueGames = games.groupBy { "${it.homeTeam}_${it.awayTeam}_${it.gameDate}" }
|
||||
.map { it.value.first() }
|
||||
|
||||
return uniqueGames
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存比赛数据到数据库
|
||||
*/
|
||||
@Transactional
|
||||
private fun saveGamesToDatabase(games: List<NbaGameDto>) {
|
||||
if (games.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
var savedCount = 0
|
||||
var updatedCount = 0
|
||||
|
||||
games.forEach { dto ->
|
||||
try {
|
||||
// 尝试根据 nbaGameId 或 polymarketMarketId 查找现有记录
|
||||
val existing = dto.nbaGameId?.let {
|
||||
nbaGameRepository.findByNbaGameId(it)
|
||||
} ?: dto.polymarketMarketId?.let {
|
||||
nbaGameRepository.findByPolymarketMarketId(it)
|
||||
}
|
||||
|
||||
if (existing != null) {
|
||||
// 更新现有记录(data class 的 copy 方法)
|
||||
val updated = NbaGame(
|
||||
id = existing.id,
|
||||
nbaGameId = existing.nbaGameId,
|
||||
homeTeam = dto.homeTeam,
|
||||
awayTeam = dto.awayTeam,
|
||||
gameDate = dto.gameDate,
|
||||
gameTime = dto.gameTime,
|
||||
gameStatus = dto.gameStatus,
|
||||
homeScore = dto.homeScore,
|
||||
awayScore = dto.awayScore,
|
||||
period = dto.period,
|
||||
timeRemaining = dto.timeRemaining,
|
||||
polymarketMarketId = dto.polymarketMarketId,
|
||||
createdAt = existing.createdAt,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
nbaGameRepository.save(updated)
|
||||
updatedCount++
|
||||
} else {
|
||||
// 创建新记录
|
||||
val entity = dtoToEntity(dto)
|
||||
nbaGameRepository.save(entity)
|
||||
savedCount++
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("保存比赛数据失败: ${dto.nbaGameId}, error: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("保存比赛数据完成:新增 $savedCount 条,更新 $updatedCount 条")
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO 转实体
|
||||
*/
|
||||
private fun dtoToEntity(dto: NbaGameDto): NbaGame {
|
||||
return NbaGame(
|
||||
id = null,
|
||||
nbaGameId = dto.nbaGameId,
|
||||
homeTeam = dto.homeTeam,
|
||||
awayTeam = dto.awayTeam,
|
||||
gameDate = dto.gameDate,
|
||||
gameTime = dto.gameTime,
|
||||
gameStatus = dto.gameStatus,
|
||||
homeScore = dto.homeScore,
|
||||
awayScore = dto.awayScore,
|
||||
period = dto.period,
|
||||
timeRemaining = dto.timeRemaining,
|
||||
polymarketMarketId = dto.polymarketMarketId,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 实体转 DTO
|
||||
*/
|
||||
private fun entityToDto(entity: NbaGame): NbaGameDto {
|
||||
return NbaGameDto(
|
||||
id = entity.id,
|
||||
nbaGameId = entity.nbaGameId,
|
||||
homeTeam = entity.homeTeam,
|
||||
awayTeam = entity.awayTeam,
|
||||
gameDate = entity.gameDate,
|
||||
gameTime = entity.gameTime,
|
||||
gameStatus = entity.gameStatus,
|
||||
homeScore = entity.homeScore,
|
||||
awayScore = entity.awayScore,
|
||||
period = entity.period,
|
||||
timeRemaining = entity.timeRemaining,
|
||||
polymarketMarketId = entity.polymarketMarketId
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 7 天内的所有球队(去重)
|
||||
*/
|
||||
suspend fun getTeamsInNext7Days(): Result<List<String>> {
|
||||
return try {
|
||||
// 使用当前西8区时间计算7天范围
|
||||
val pstZone = ZoneId.of("America/Los_Angeles")
|
||||
val now = ZonedDateTime.now(pstZone)
|
||||
val startTimestamp = now.toInstant().toEpochMilli()
|
||||
val endTimestamp = now.plusDays(7).toInstant().toEpochMilli()
|
||||
|
||||
val gamesResult = getNbaGames(
|
||||
NbaGameListRequest(
|
||||
startTimestamp = startTimestamp,
|
||||
endTimestamp = endTimestamp
|
||||
)
|
||||
)
|
||||
|
||||
gamesResult.fold(
|
||||
onSuccess = { response ->
|
||||
val teams = mutableSetOf<String>()
|
||||
response.list.forEach { game ->
|
||||
teams.add(game.homeTeam)
|
||||
teams.add(game.awayTeam)
|
||||
}
|
||||
Result.success(teams.sorted())
|
||||
},
|
||||
onFailure = { exception -> Result.failure(exception) }
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取球队列表失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Polymarket 市场转换为比赛数据
|
||||
*/
|
||||
private fun convertMarketToGame(
|
||||
market: com.wrbug.polymarketbot.api.MarketResponse,
|
||||
startDate: LocalDate,
|
||||
endDate: LocalDate
|
||||
): NbaGameDto? {
|
||||
if (market.question.isNullOrBlank()) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 解析市场名称,提取球队和日期信息
|
||||
val parsed = NbaMarketNameParser.parse(market.question)
|
||||
|
||||
if (parsed.homeTeam == null || parsed.awayTeam == null) {
|
||||
// 无法解析出两个球队,跳过
|
||||
return null
|
||||
}
|
||||
|
||||
// 确定比赛日期
|
||||
val gameDate = parsed.gameDate ?: run {
|
||||
// 如果没有解析出日期,尝试从 startDate 或 endDate 中提取
|
||||
parseDateFromMarketDates(market.startDate, market.endDate) ?: return null
|
||||
}
|
||||
|
||||
// 检查日期是否在请求范围内
|
||||
if (gameDate.isBefore(startDate) || gameDate.isAfter(endDate)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 解析比赛时间(从 endDate 或 startDate 中提取,转换为西8区时间戳)
|
||||
val gameTime = parseGameTimeFromMarket(market.startDate, market.endDate, gameDate)
|
||||
|
||||
// 确定比赛状态
|
||||
val gameStatus = when {
|
||||
market.closed == true -> "finished"
|
||||
market.archived == true -> "finished"
|
||||
market.active == true -> "scheduled"
|
||||
else -> "scheduled"
|
||||
}
|
||||
|
||||
return NbaGameDto(
|
||||
id = null,
|
||||
nbaGameId = market.conditionId ?: market.id, // 使用 conditionId 或 id 作为 gameId
|
||||
homeTeam = parsed.homeTeam,
|
||||
awayTeam = parsed.awayTeam,
|
||||
gameDate = gameDate,
|
||||
gameTime = gameTime, // 西8区时间戳(毫秒)
|
||||
gameStatus = gameStatus,
|
||||
homeScore = 0, // Polymarket 不提供比分
|
||||
awayScore = 0,
|
||||
period = 0,
|
||||
timeRemaining = null,
|
||||
polymarketMarketId = market.id
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从市场的 startDate 或 endDate 中解析日期
|
||||
*/
|
||||
private fun parseDateFromMarketDates(startDate: String?, endDate: String?): LocalDate? {
|
||||
val dateStr = endDate ?: startDate ?: return null
|
||||
|
||||
return try {
|
||||
// 尝试解析 ISO 8601 格式
|
||||
if (dateStr.contains("T")) {
|
||||
val instant = Instant.parse(dateStr)
|
||||
val pstZone = ZoneId.of("America/Los_Angeles")
|
||||
instant.atZone(pstZone).toLocalDate()
|
||||
} else {
|
||||
// 尝试解析日期字符串
|
||||
LocalDate.parse(dateStr, DateTimeFormatter.ISO_DATE)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.debug("解析市场日期失败: $dateStr, error: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从市场的日期时间中解析比赛时间,转换为西8区时间戳
|
||||
*/
|
||||
private fun parseGameTimeFromMarket(
|
||||
startDate: String?,
|
||||
endDate: String?,
|
||||
gameDate: LocalDate
|
||||
): Long? {
|
||||
val dateTimeStr = endDate ?: startDate ?: return null
|
||||
|
||||
return try {
|
||||
val pstZone = ZoneId.of("America/Los_Angeles")
|
||||
|
||||
// 尝试解析 ISO 8601 格式
|
||||
val instant = if (dateTimeStr.contains("T")) {
|
||||
Instant.parse(dateTimeStr)
|
||||
} else {
|
||||
// 如果没有时间部分,使用默认时间(晚上8点)
|
||||
val defaultTime = gameDate.atTime(20, 0)
|
||||
defaultTime.atZone(pstZone).toInstant()
|
||||
}
|
||||
|
||||
// 转换为西8区时间戳
|
||||
instant.atZone(pstZone).toInstant().toEpochMilli()
|
||||
} catch (e: Exception) {
|
||||
logger.debug("解析比赛时间失败: $dateTimeStr, error: ${e.message}")
|
||||
// 解析失败时,使用默认时间(晚上8点 PST)
|
||||
try {
|
||||
val defaultTime = gameDate.atTime(20, 0)
|
||||
val pstZone = ZoneId.of("America/Los_Angeles")
|
||||
defaultTime.atZone(pstZone).toInstant().toEpochMilli()
|
||||
} catch (e2: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,314 +0,0 @@
|
||||
package com.wrbug.polymarketbot.service.nba
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.regex.Pattern
|
||||
|
||||
/**
|
||||
* NBA 市场名称解析器
|
||||
* 从 Polymarket 市场名称中提取球队和日期信息
|
||||
*/
|
||||
object NbaMarketNameParser {
|
||||
private val logger = LoggerFactory.getLogger(NbaMarketNameParser::class.java)
|
||||
|
||||
// NBA 球队名称映射(支持多种格式)
|
||||
private val teamNameMapping = mapOf(
|
||||
// 完整名称
|
||||
"atlanta hawks" to "Atlanta Hawks",
|
||||
"boston celtics" to "Boston Celtics",
|
||||
"brooklyn nets" to "Brooklyn Nets",
|
||||
"charlotte hornets" to "Charlotte Hornets",
|
||||
"chicago bulls" to "Chicago Bulls",
|
||||
"cleveland cavaliers" to "Cleveland Cavaliers",
|
||||
"dallas mavericks" to "Dallas Mavericks",
|
||||
"denver nuggets" to "Denver Nuggets",
|
||||
"detroit pistons" to "Detroit Pistons",
|
||||
"golden state warriors" to "Golden State Warriors",
|
||||
"houston rockets" to "Houston Rockets",
|
||||
"indiana pacers" to "Indiana Pacers",
|
||||
"la clippers" to "LA Clippers",
|
||||
"los angeles lakers" to "Los Angeles Lakers",
|
||||
"memphis grizzlies" to "Memphis Grizzlies",
|
||||
"miami heat" to "Miami Heat",
|
||||
"milwaukee bucks" to "Milwaukee Bucks",
|
||||
"minnesota timberwolves" to "Minnesota Timberwolves",
|
||||
"new orleans pelicans" to "New Orleans Pelicans",
|
||||
"new york knicks" to "New York Knicks",
|
||||
"oklahoma city thunder" to "Oklahoma City Thunder",
|
||||
"orlando magic" to "Orlando Magic",
|
||||
"philadelphia 76ers" to "Philadelphia 76ers",
|
||||
"phoenix suns" to "Phoenix Suns",
|
||||
"portland trail blazers" to "Portland Trail Blazers",
|
||||
"sacramento kings" to "Sacramento Kings",
|
||||
"san antonio spurs" to "San Antonio Spurs",
|
||||
"toronto raptors" to "Toronto Raptors",
|
||||
"utah jazz" to "Utah Jazz",
|
||||
"washington wizards" to "Washington Wizards",
|
||||
// 常见缩写和别名
|
||||
"hawks" to "Atlanta Hawks",
|
||||
"celtics" to "Boston Celtics",
|
||||
"nets" to "Brooklyn Nets",
|
||||
"hornets" to "Charlotte Hornets",
|
||||
"bulls" to "Chicago Bulls",
|
||||
"cavaliers" to "Cleveland Cavaliers",
|
||||
"cavs" to "Cleveland Cavaliers",
|
||||
"mavericks" to "Dallas Mavericks",
|
||||
"mavs" to "Dallas Mavericks",
|
||||
"nuggets" to "Denver Nuggets",
|
||||
"pistons" to "Detroit Pistons",
|
||||
"warriors" to "Golden State Warriors",
|
||||
"rockets" to "Houston Rockets",
|
||||
"pacers" to "Indiana Pacers",
|
||||
"clippers" to "LA Clippers",
|
||||
"lakers" to "Los Angeles Lakers",
|
||||
"grizzlies" to "Memphis Grizzlies",
|
||||
"heat" to "Miami Heat",
|
||||
"bucks" to "Milwaukee Bucks",
|
||||
"timberwolves" to "Minnesota Timberwolves",
|
||||
"wolves" to "Minnesota Timberwolves",
|
||||
"pelicans" to "New Orleans Pelicans",
|
||||
"knicks" to "New York Knicks",
|
||||
"thunder" to "Oklahoma City Thunder",
|
||||
"magic" to "Orlando Magic",
|
||||
"76ers" to "Philadelphia 76ers",
|
||||
"sixers" to "Philadelphia 76ers",
|
||||
"suns" to "Phoenix Suns",
|
||||
"trail blazers" to "Portland Trail Blazers",
|
||||
"blazers" to "Portland Trail Blazers",
|
||||
"kings" to "Sacramento Kings",
|
||||
"spurs" to "San Antonio Spurs",
|
||||
"raptors" to "Toronto Raptors",
|
||||
"jazz" to "Utah Jazz",
|
||||
"wizards" to "Washington Wizards",
|
||||
"wiz" to "Washington Wizards"
|
||||
)
|
||||
|
||||
/**
|
||||
* 解析结果
|
||||
*/
|
||||
data class ParsedMarketInfo(
|
||||
val homeTeam: String?,
|
||||
val awayTeam: String?,
|
||||
val gameDate: LocalDate?,
|
||||
val confidence: Double // 置信度 0.0-1.0
|
||||
)
|
||||
|
||||
/**
|
||||
* 解析市场名称
|
||||
* @param marketName 市场名称
|
||||
* @return 解析结果
|
||||
*/
|
||||
fun parse(marketName: String?): ParsedMarketInfo {
|
||||
if (marketName.isNullOrBlank()) {
|
||||
return ParsedMarketInfo(null, null, null, 0.0)
|
||||
}
|
||||
|
||||
val normalized = marketName.lowercase()
|
||||
var homeTeam: String? = null
|
||||
var awayTeam: String? = null
|
||||
var gameDate: LocalDate? = null
|
||||
var confidence = 0.0
|
||||
|
||||
// 尝试提取球队名称
|
||||
val teams = extractTeams(normalized)
|
||||
if (teams.size >= 2) {
|
||||
// 通常第一个是客队,第二个是主队
|
||||
awayTeam = teams[0]
|
||||
homeTeam = teams[1]
|
||||
confidence += 0.5
|
||||
} else if (teams.size == 1) {
|
||||
// 只有一个球队,无法确定主客场
|
||||
awayTeam = teams[0]
|
||||
confidence += 0.2
|
||||
}
|
||||
|
||||
// 尝试提取日期
|
||||
val date = extractDate(normalized)
|
||||
if (date != null) {
|
||||
gameDate = date
|
||||
confidence += 0.3
|
||||
}
|
||||
|
||||
return ParsedMarketInfo(homeTeam, awayTeam, gameDate, confidence.coerceAtMost(1.0))
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取球队名称
|
||||
*/
|
||||
private fun extractTeams(text: String): List<String> {
|
||||
val teams = mutableListOf<String>()
|
||||
|
||||
// 常见的球队名称模式
|
||||
val patterns = listOf(
|
||||
// "Team1 vs Team2" 或 "Team1 @ Team2"
|
||||
Pattern.compile("(\\w+(?:\\s+\\w+)*?)\\s+(?:vs|@|v\\.?|versus)\\s+(\\w+(?:\\s+\\w+)*?)", Pattern.CASE_INSENSITIVE),
|
||||
// "Will Team1 beat Team2"
|
||||
Pattern.compile("will\\s+(\\w+(?:\\s+\\w+)*?)\\s+beat\\s+(\\w+(?:\\s+\\w+)*?)", Pattern.CASE_INSENSITIVE),
|
||||
// "Team1 win" 或 "Team1 wins"
|
||||
Pattern.compile("(\\w+(?:\\s+\\w+)*?)\\s+win", Pattern.CASE_INSENSITIVE)
|
||||
)
|
||||
|
||||
for (pattern in patterns) {
|
||||
val matcher = pattern.matcher(text)
|
||||
if (matcher.find()) {
|
||||
val team1 = normalizeTeamName(matcher.group(1) ?: "")
|
||||
val team2 = if (matcher.groupCount() >= 2) {
|
||||
normalizeTeamName(matcher.group(2) ?: "")
|
||||
} else null
|
||||
|
||||
if (team1 != null) {
|
||||
teams.add(team1)
|
||||
}
|
||||
if (team2 != null) {
|
||||
teams.add(team2)
|
||||
}
|
||||
|
||||
if (teams.size >= 2) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果模式匹配失败,尝试直接查找球队名称
|
||||
if (teams.isEmpty()) {
|
||||
for ((key, value) in teamNameMapping) {
|
||||
if (text.contains(key, ignoreCase = true)) {
|
||||
if (!teams.contains(value)) {
|
||||
teams.add(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return teams.distinct()
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化球队名称
|
||||
*/
|
||||
private fun normalizeTeamName(name: String): String? {
|
||||
val normalized = name.trim().lowercase()
|
||||
return teamNameMapping[normalized] ?: teamNameMapping.entries.firstOrNull {
|
||||
normalized.contains(it.key, ignoreCase = true)
|
||||
}?.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取日期
|
||||
*/
|
||||
private fun extractDate(text: String): LocalDate? {
|
||||
// 尝试多种日期格式
|
||||
try {
|
||||
// 格式1: "Dec 15, 2024" 或 "December 15, 2024"
|
||||
val pattern1 = Pattern.compile("(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\\s+(\\d{1,2}),?\\s+(\\d{4})", Pattern.CASE_INSENSITIVE)
|
||||
val matcher1 = pattern1.matcher(text)
|
||||
if (matcher1.find()) {
|
||||
val monthStr = matcher1.group(1)?.lowercase() ?: return null
|
||||
val day = matcher1.group(2)?.toIntOrNull() ?: return null
|
||||
val year = matcher1.group(3)?.toIntOrNull() ?: return null
|
||||
|
||||
val monthMap = mapOf(
|
||||
"jan" to 1, "january" to 1,
|
||||
"feb" to 2, "february" to 2,
|
||||
"mar" to 3, "march" to 3,
|
||||
"apr" to 4, "april" to 4,
|
||||
"may" to 5,
|
||||
"jun" to 6, "june" to 6,
|
||||
"jul" to 7, "july" to 7,
|
||||
"aug" to 8, "august" to 8,
|
||||
"sep" to 9, "september" to 9,
|
||||
"oct" to 10, "october" to 10,
|
||||
"nov" to 11, "november" to 11,
|
||||
"dec" to 12, "december" to 12
|
||||
)
|
||||
|
||||
val month = monthMap.entries.firstOrNull { monthStr.startsWith(it.key) }?.value
|
||||
if (month != null) {
|
||||
return try {
|
||||
LocalDate.of(year, month, day)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 格式2: "2024-12-15"
|
||||
val pattern2 = Pattern.compile("(\\d{4})[-/](\\d{1,2})[-/](\\d{1,2})")
|
||||
val matcher2 = pattern2.matcher(text)
|
||||
if (matcher2.find()) {
|
||||
val year = matcher2.group(1)?.toIntOrNull() ?: return null
|
||||
val month = matcher2.group(2)?.toIntOrNull() ?: return null
|
||||
val day = matcher2.group(3)?.toIntOrNull() ?: return null
|
||||
return try {
|
||||
LocalDate.of(year, month, day)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// 格式3: "12/15/2024" 或 "12/15/24"
|
||||
val pattern3 = Pattern.compile("(\\d{1,2})/(\\d{1,2})/(\\d{2,4})")
|
||||
val matcher3 = pattern3.matcher(text)
|
||||
if (matcher3.find()) {
|
||||
val month = matcher3.group(1)?.toIntOrNull() ?: return null
|
||||
val day = matcher3.group(2)?.toIntOrNull() ?: return null
|
||||
val yearStr = matcher3.group(3) ?: return null
|
||||
val year = if (yearStr.length == 2) {
|
||||
// 两位年份,假设是 2000-2099
|
||||
val y = yearStr.toIntOrNull() ?: return null
|
||||
if (y < 50) 2000 + y else 1900 + y
|
||||
} else {
|
||||
yearStr.toIntOrNull() ?: return null
|
||||
}
|
||||
return try {
|
||||
LocalDate.of(year, month, day)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.debug("解析日期失败: ${e.message}")
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 匹配比赛和市场
|
||||
* @param homeTeam 主队名称
|
||||
* @param awayTeam 客队名称
|
||||
* @param gameDate 比赛日期
|
||||
* @param parsedMarket 解析的市场信息
|
||||
* @return 是否匹配
|
||||
*/
|
||||
fun matchGame(
|
||||
homeTeam: String,
|
||||
awayTeam: String,
|
||||
gameDate: LocalDate,
|
||||
parsedMarket: ParsedMarketInfo
|
||||
): Boolean {
|
||||
// 检查日期是否匹配(允许1天误差)
|
||||
val dateMatch = parsedMarket.gameDate?.let { marketDate ->
|
||||
val daysDiff = kotlin.math.abs(java.time.temporal.ChronoUnit.DAYS.between(gameDate, marketDate))
|
||||
daysDiff <= 1
|
||||
} ?: false
|
||||
|
||||
if (!dateMatch && parsedMarket.gameDate != null) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查球队是否匹配
|
||||
val homeMatch = parsedMarket.homeTeam?.let {
|
||||
normalizeTeamName(it)?.equals(normalizeTeamName(homeTeam), ignoreCase = true)
|
||||
} ?: false
|
||||
|
||||
val awayMatch = parsedMarket.awayTeam?.let {
|
||||
normalizeTeamName(it)?.equals(normalizeTeamName(awayTeam), ignoreCase = true)
|
||||
} ?: false
|
||||
|
||||
// 如果两个球队都匹配,或者至少一个匹配且日期匹配
|
||||
return (homeMatch && awayMatch) || ((homeMatch || awayMatch) && dateMatch)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
package com.wrbug.polymarketbot.service.nba
|
||||
|
||||
import com.wrbug.polymarketbot.api.MarketResponse
|
||||
import com.wrbug.polymarketbot.api.PolymarketGammaApi
|
||||
import com.wrbug.polymarketbot.dto.NbaMarketDto
|
||||
import com.wrbug.polymarketbot.dto.NbaMarketListRequest
|
||||
import com.wrbug.polymarketbot.dto.NbaMarketListResponse
|
||||
import com.wrbug.polymarketbot.enums.SportsTagId
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
/**
|
||||
* NBA 市场服务
|
||||
* 用于从 Polymarket 获取 NBA 相关的市场信息
|
||||
*/
|
||||
@Service
|
||||
class NbaMarketService(
|
||||
private val retrofitFactory: RetrofitFactory
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(NbaMarketService::class.java)
|
||||
|
||||
/**
|
||||
* 获取 NBA 的 tag ID 列表
|
||||
* 直接使用枚举中定义的已知 tag ID,无需调用 API
|
||||
*/
|
||||
suspend fun getNbaTagIds(): Result<List<String>> {
|
||||
// 直接使用枚举中定义的 NBA tag ID
|
||||
val nbaTagId = SportsTagId.NBA.tagId
|
||||
logger.debug("使用枚举中的 NBA tag ID: $nbaTagId")
|
||||
return Result.success(listOf(nbaTagId))
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 NBA 市场列表
|
||||
* 使用 NBA 的 tag IDs 过滤市场
|
||||
*
|
||||
* @param request 请求参数
|
||||
* @return NBA 市场列表响应
|
||||
*/
|
||||
suspend fun getNbaMarkets(request: NbaMarketListRequest): Result<NbaMarketListResponse> {
|
||||
return try {
|
||||
// 先获取 NBA 的 tag IDs
|
||||
val tagIdsResult = getNbaTagIds()
|
||||
if (tagIdsResult.isFailure) {
|
||||
return Result.failure(tagIdsResult.exceptionOrNull() ?: Exception("无法获取 NBA tag IDs"))
|
||||
}
|
||||
|
||||
val tagIds = tagIdsResult.getOrNull() ?: return Result.failure(IllegalStateException("NBA tag IDs 为空"))
|
||||
|
||||
if (tagIds.isEmpty()) {
|
||||
logger.warn("NBA tag IDs 为空,无法过滤市场")
|
||||
return Result.success(NbaMarketListResponse(
|
||||
list = emptyList(),
|
||||
total = 0L
|
||||
))
|
||||
}
|
||||
|
||||
// 调用 /markets 接口,使用 tag IDs 过滤
|
||||
val gammaApi = retrofitFactory.createGammaApi()
|
||||
val response = gammaApi.listMarkets(
|
||||
conditionIds = null,
|
||||
includeTag = true,
|
||||
tags = tagIds,
|
||||
active = request.active,
|
||||
closed = request.closed,
|
||||
archived = request.archived
|
||||
)
|
||||
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val markets = response.body()!!
|
||||
logger.info("获取到 ${markets.size} 个 NBA 市场")
|
||||
|
||||
// 转换为 DTO
|
||||
val marketDtos = markets.map { market ->
|
||||
NbaMarketDto(
|
||||
id = market.id,
|
||||
question = market.question,
|
||||
conditionId = market.conditionId,
|
||||
slug = market.slug,
|
||||
description = market.description,
|
||||
category = market.category,
|
||||
active = market.active,
|
||||
closed = market.closed,
|
||||
archived = market.archived,
|
||||
volume = market.volume,
|
||||
liquidity = market.liquidity,
|
||||
endDate = market.endDate,
|
||||
startDate = market.startDate,
|
||||
outcomes = market.outcomes,
|
||||
outcomePrices = market.outcomePrices,
|
||||
volumeNum = market.volumeNum,
|
||||
liquidityNum = market.liquidityNum,
|
||||
lastTradePrice = market.lastTradePrice,
|
||||
bestBid = market.bestBid,
|
||||
bestAsk = market.bestAsk
|
||||
)
|
||||
}
|
||||
|
||||
Result.success(NbaMarketListResponse(
|
||||
list = marketDtos,
|
||||
total = marketDtos.size.toLong()
|
||||
))
|
||||
} else {
|
||||
logger.error("获取 NBA 市场失败: ${response.code()} ${response.message()}")
|
||||
val errorBody = response.errorBody()?.string()
|
||||
logger.error("错误响应体: $errorBody")
|
||||
Result.failure(Exception("获取 NBA 市场失败: ${response.code()} ${response.message()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取 NBA 市场异常: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 NBA 市场中提取球队列表
|
||||
* 解析市场名称,提取所有唯一的球队名称
|
||||
*
|
||||
* @param active 是否只从活跃市场提取(默认 true)
|
||||
* @return 球队名称列表(去重、排序)
|
||||
*/
|
||||
suspend fun getTeamsFromMarkets(active: Boolean = true): Result<List<String>> {
|
||||
return try {
|
||||
// 获取 NBA 市场列表
|
||||
val marketsResult = getNbaMarkets(
|
||||
NbaMarketListRequest(
|
||||
active = active,
|
||||
closed = false,
|
||||
archived = false
|
||||
)
|
||||
)
|
||||
|
||||
if (marketsResult.isFailure) {
|
||||
return Result.failure(marketsResult.exceptionOrNull() ?: Exception("无法获取 NBA 市场"))
|
||||
}
|
||||
|
||||
val markets = marketsResult.getOrNull()?.list ?: return Result.success(emptyList())
|
||||
|
||||
// 使用市场名称解析器提取球队
|
||||
val teams = mutableSetOf<String>()
|
||||
|
||||
markets.forEach { market ->
|
||||
if (!market.question.isNullOrBlank()) {
|
||||
val parsed = NbaMarketNameParser.parse(market.question)
|
||||
if (parsed != null) {
|
||||
// 提取主队和客队
|
||||
parsed.homeTeam?.let { teams.add(it) }
|
||||
parsed.awayTeam?.let { teams.add(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 排序并返回
|
||||
val sortedTeams = teams.sorted()
|
||||
logger.info("从 ${markets.size} 个市场中提取到 ${sortedTeams.size} 个球队")
|
||||
Result.success(sortedTeams)
|
||||
} catch (e: Exception) {
|
||||
logger.error("从市场提取球队列表失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
-346
@@ -1,346 +0,0 @@
|
||||
package com.wrbug.polymarketbot.service.nba
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.entity.NbaQuantitativeStrategy
|
||||
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||
import com.wrbug.polymarketbot.repository.NbaQuantitativeStrategyRepository
|
||||
import com.wrbug.polymarketbot.util.JsonUtils
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.data.domain.Sort
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* NBA 量化策略服务
|
||||
*/
|
||||
@Service
|
||||
class NbaQuantitativeStrategyService(
|
||||
private val strategyRepository: NbaQuantitativeStrategyRepository,
|
||||
private val accountRepository: AccountRepository
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(NbaQuantitativeStrategyService::class.java)
|
||||
|
||||
/**
|
||||
* 创建策略
|
||||
*/
|
||||
@Transactional
|
||||
suspend fun createStrategy(request: NbaQuantitativeStrategyCreateRequest): Result<NbaQuantitativeStrategyDto> {
|
||||
return try {
|
||||
// 验证账户是否存在
|
||||
val account = accountRepository.findById(request.accountId).orElse(null)
|
||||
if (account == null) {
|
||||
return Result.failure(IllegalArgumentException("账户不存在"))
|
||||
}
|
||||
|
||||
// 验证策略名称是否重复
|
||||
val existing = strategyRepository.findByStrategyName(request.strategyName)
|
||||
if (existing != null) {
|
||||
return Result.failure(IllegalArgumentException("策略名称已存在"))
|
||||
}
|
||||
|
||||
// 创建策略实体
|
||||
val strategy = NbaQuantitativeStrategy(
|
||||
strategyName = request.strategyName,
|
||||
strategyDescription = request.strategyDescription,
|
||||
accountId = request.accountId,
|
||||
enabled = request.enabled,
|
||||
filterTeams = request.filterTeams?.let { JsonUtils.toJson(it) },
|
||||
filterDateFrom = request.filterDateFrom,
|
||||
filterDateTo = request.filterDateTo,
|
||||
filterGameImportance = request.filterGameImportance,
|
||||
minWinProbabilityDiff = request.minWinProbabilityDiff ?: BigDecimal("0.1"),
|
||||
minWinProbability = request.minWinProbability,
|
||||
maxWinProbability = request.maxWinProbability,
|
||||
minTradeValue = request.minTradeValue ?: BigDecimal("0.05"),
|
||||
minRemainingTime = request.minRemainingTime,
|
||||
maxRemainingTime = request.maxRemainingTime,
|
||||
minScoreDiff = request.minScoreDiff,
|
||||
maxScoreDiff = request.maxScoreDiff,
|
||||
buyAmountStrategy = request.buyAmountStrategy ?: "FIXED",
|
||||
fixedBuyAmount = request.fixedBuyAmount,
|
||||
buyRatio = request.buyRatio,
|
||||
baseBuyAmount = request.baseBuyAmount,
|
||||
buyTiming = request.buyTiming ?: "IMMEDIATE",
|
||||
delayBuySeconds = request.delayBuySeconds ?: 0,
|
||||
buyDirection = request.buyDirection ?: "AUTO",
|
||||
enableSell = request.enableSell ?: true,
|
||||
takeProfitThreshold = request.takeProfitThreshold,
|
||||
stopLossThreshold = request.stopLossThreshold,
|
||||
probabilityReversalThreshold = request.probabilityReversalThreshold,
|
||||
sellRatio = request.sellRatio ?: BigDecimal("1.0"),
|
||||
sellTiming = request.sellTiming ?: "IMMEDIATE",
|
||||
delaySellSeconds = request.delaySellSeconds ?: 0,
|
||||
priceStrategy = request.priceStrategy ?: "MARKET",
|
||||
fixedPrice = request.fixedPrice,
|
||||
priceOffset = request.priceOffset ?: BigDecimal.ZERO,
|
||||
maxPosition = request.maxPosition ?: BigDecimal("50"),
|
||||
minPosition = request.minPosition ?: BigDecimal("5"),
|
||||
maxGamePosition = request.maxGamePosition,
|
||||
maxDailyLoss = request.maxDailyLoss,
|
||||
maxDailyOrders = request.maxDailyOrders,
|
||||
maxDailyProfit = request.maxDailyProfit,
|
||||
priceTolerance = request.priceTolerance ?: BigDecimal("0.05"),
|
||||
minProbabilityThreshold = request.minProbabilityThreshold,
|
||||
maxProbabilityThreshold = request.maxProbabilityThreshold,
|
||||
baseStrengthWeight = request.baseStrengthWeight ?: BigDecimal("0.3"),
|
||||
recentFormWeight = request.recentFormWeight ?: BigDecimal("0.25"),
|
||||
lineupIntegrityWeight = request.lineupIntegrityWeight ?: BigDecimal("0.2"),
|
||||
starStatusWeight = request.starStatusWeight ?: BigDecimal("0.15"),
|
||||
environmentWeight = request.environmentWeight ?: BigDecimal("0.1"),
|
||||
matchupAdvantageWeight = request.matchupAdvantageWeight ?: BigDecimal("0.2"),
|
||||
scoreDiffWeight = request.scoreDiffWeight ?: BigDecimal("0.3"),
|
||||
momentumWeight = request.momentumWeight ?: BigDecimal("0.2"),
|
||||
dataUpdateFrequency = request.dataUpdateFrequency ?: 30,
|
||||
analysisFrequency = request.analysisFrequency ?: 30,
|
||||
pushFailedOrders = request.pushFailedOrders ?: false,
|
||||
pushFrequency = request.pushFrequency ?: "REALTIME",
|
||||
batchPushInterval = request.batchPushInterval ?: 1
|
||||
)
|
||||
|
||||
val saved = strategyRepository.save(strategy)
|
||||
Result.success(toDto(saved))
|
||||
} catch (e: Exception) {
|
||||
logger.error("创建策略失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新策略
|
||||
*/
|
||||
@Transactional
|
||||
suspend fun updateStrategy(request: NbaQuantitativeStrategyUpdateRequest): Result<NbaQuantitativeStrategyDto> {
|
||||
return try {
|
||||
val strategy = strategyRepository.findById(request.id).orElse(null)
|
||||
if (strategy == null) {
|
||||
return Result.failure(IllegalArgumentException("策略不存在"))
|
||||
}
|
||||
|
||||
// 更新字段(只更新提供的字段)
|
||||
val updated = strategy.copy(
|
||||
strategyName = request.strategyName ?: strategy.strategyName,
|
||||
strategyDescription = request.strategyDescription ?: strategy.strategyDescription,
|
||||
enabled = request.enabled ?: strategy.enabled,
|
||||
filterTeams = request.filterTeams?.let { JsonUtils.toJson(it) } ?: strategy.filterTeams,
|
||||
filterDateFrom = request.filterDateFrom ?: strategy.filterDateFrom,
|
||||
filterDateTo = request.filterDateTo ?: strategy.filterDateTo,
|
||||
filterGameImportance = request.filterGameImportance ?: strategy.filterGameImportance,
|
||||
minWinProbabilityDiff = request.minWinProbabilityDiff ?: strategy.minWinProbabilityDiff,
|
||||
minWinProbability = request.minWinProbability ?: strategy.minWinProbability,
|
||||
maxWinProbability = request.maxWinProbability ?: strategy.maxWinProbability,
|
||||
minTradeValue = request.minTradeValue ?: strategy.minTradeValue,
|
||||
minRemainingTime = request.minRemainingTime ?: strategy.minRemainingTime,
|
||||
maxRemainingTime = request.maxRemainingTime ?: strategy.maxRemainingTime,
|
||||
minScoreDiff = request.minScoreDiff ?: strategy.minScoreDiff,
|
||||
maxScoreDiff = request.maxScoreDiff ?: strategy.maxScoreDiff,
|
||||
buyAmountStrategy = request.buyAmountStrategy ?: strategy.buyAmountStrategy,
|
||||
fixedBuyAmount = request.fixedBuyAmount ?: strategy.fixedBuyAmount,
|
||||
buyRatio = request.buyRatio ?: strategy.buyRatio,
|
||||
baseBuyAmount = request.baseBuyAmount ?: strategy.baseBuyAmount,
|
||||
buyTiming = request.buyTiming ?: strategy.buyTiming,
|
||||
delayBuySeconds = request.delayBuySeconds ?: strategy.delayBuySeconds,
|
||||
buyDirection = request.buyDirection ?: strategy.buyDirection,
|
||||
enableSell = request.enableSell ?: strategy.enableSell,
|
||||
takeProfitThreshold = request.takeProfitThreshold ?: strategy.takeProfitThreshold,
|
||||
stopLossThreshold = request.stopLossThreshold ?: strategy.stopLossThreshold,
|
||||
probabilityReversalThreshold = request.probabilityReversalThreshold ?: strategy.probabilityReversalThreshold,
|
||||
sellRatio = request.sellRatio ?: strategy.sellRatio,
|
||||
sellTiming = request.sellTiming ?: strategy.sellTiming,
|
||||
delaySellSeconds = request.delaySellSeconds ?: strategy.delaySellSeconds,
|
||||
priceStrategy = request.priceStrategy ?: strategy.priceStrategy,
|
||||
fixedPrice = request.fixedPrice ?: strategy.fixedPrice,
|
||||
priceOffset = request.priceOffset ?: strategy.priceOffset,
|
||||
maxPosition = request.maxPosition ?: strategy.maxPosition,
|
||||
minPosition = request.minPosition ?: strategy.minPosition,
|
||||
maxGamePosition = request.maxGamePosition ?: strategy.maxGamePosition,
|
||||
maxDailyLoss = request.maxDailyLoss ?: strategy.maxDailyLoss,
|
||||
maxDailyOrders = request.maxDailyOrders ?: strategy.maxDailyOrders,
|
||||
maxDailyProfit = request.maxDailyProfit ?: strategy.maxDailyProfit,
|
||||
priceTolerance = request.priceTolerance ?: strategy.priceTolerance,
|
||||
minProbabilityThreshold = request.minProbabilityThreshold ?: strategy.minProbabilityThreshold,
|
||||
maxProbabilityThreshold = request.maxProbabilityThreshold ?: strategy.maxProbabilityThreshold,
|
||||
baseStrengthWeight = request.baseStrengthWeight ?: strategy.baseStrengthWeight,
|
||||
recentFormWeight = request.recentFormWeight ?: strategy.recentFormWeight,
|
||||
lineupIntegrityWeight = request.lineupIntegrityWeight ?: strategy.lineupIntegrityWeight,
|
||||
starStatusWeight = request.starStatusWeight ?: strategy.starStatusWeight,
|
||||
environmentWeight = request.environmentWeight ?: strategy.environmentWeight,
|
||||
matchupAdvantageWeight = request.matchupAdvantageWeight ?: strategy.matchupAdvantageWeight,
|
||||
scoreDiffWeight = request.scoreDiffWeight ?: strategy.scoreDiffWeight,
|
||||
momentumWeight = request.momentumWeight ?: strategy.momentumWeight,
|
||||
dataUpdateFrequency = request.dataUpdateFrequency ?: strategy.dataUpdateFrequency,
|
||||
analysisFrequency = request.analysisFrequency ?: strategy.analysisFrequency,
|
||||
pushFailedOrders = request.pushFailedOrders ?: strategy.pushFailedOrders,
|
||||
pushFrequency = request.pushFrequency ?: strategy.pushFrequency,
|
||||
batchPushInterval = request.batchPushInterval ?: strategy.batchPushInterval,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
val saved = strategyRepository.save(updated)
|
||||
Result.success(toDto(saved))
|
||||
} catch (e: Exception) {
|
||||
logger.error("更新策略失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取策略列表
|
||||
*/
|
||||
suspend fun getStrategyList(request: NbaQuantitativeStrategyListRequest): Result<NbaQuantitativeStrategyListResponse> {
|
||||
return try {
|
||||
val page = request.page ?: 1
|
||||
val limit = request.limit ?: 20
|
||||
val pageable = PageRequest.of(page - 1, limit, Sort.by(Sort.Direction.DESC, "createdAt"))
|
||||
|
||||
val strategies = when {
|
||||
request.accountId != null && request.enabled != null -> {
|
||||
strategyRepository.findByAccountIdAndEnabled(request.accountId, request.enabled)
|
||||
}
|
||||
request.accountId != null -> {
|
||||
strategyRepository.findByAccountId(request.accountId)
|
||||
}
|
||||
request.enabled != null -> {
|
||||
strategyRepository.findByEnabled(request.enabled)
|
||||
}
|
||||
else -> {
|
||||
strategyRepository.findAll(pageable).content
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤策略名称(如果提供)
|
||||
val filtered = if (request.strategyName != null) {
|
||||
strategies.filter { it.strategyName.contains(request.strategyName, ignoreCase = true) }
|
||||
} else {
|
||||
strategies
|
||||
}
|
||||
|
||||
val total = filtered.size.toLong()
|
||||
val dtoList = filtered.map { toDto(it) }
|
||||
|
||||
Result.success(
|
||||
NbaQuantitativeStrategyListResponse(
|
||||
list = dtoList,
|
||||
total = total,
|
||||
page = page,
|
||||
limit = limit
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取策略列表失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取策略详情
|
||||
*/
|
||||
suspend fun getStrategyDetail(id: Long): Result<NbaQuantitativeStrategyDto> {
|
||||
return try {
|
||||
val strategy = strategyRepository.findById(id).orElse(null)
|
||||
if (strategy == null) {
|
||||
return Result.failure(IllegalArgumentException("策略不存在"))
|
||||
}
|
||||
Result.success(toDto(strategy))
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取策略详情失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除策略
|
||||
*/
|
||||
@Transactional
|
||||
suspend fun deleteStrategy(id: Long): Result<Unit> {
|
||||
return try {
|
||||
val strategy = strategyRepository.findById(id).orElse(null)
|
||||
if (strategy == null) {
|
||||
return Result.failure(IllegalArgumentException("策略不存在"))
|
||||
}
|
||||
strategyRepository.delete(strategy)
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
logger.error("删除策略失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取启用的策略列表
|
||||
*/
|
||||
suspend fun getEnabledStrategies(): List<NbaQuantitativeStrategy> {
|
||||
return strategyRepository.findByEnabled(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为 DTO
|
||||
*/
|
||||
private fun toDto(strategy: NbaQuantitativeStrategy): NbaQuantitativeStrategyDto {
|
||||
val account = accountRepository.findById(strategy.accountId).orElse(null)
|
||||
return NbaQuantitativeStrategyDto(
|
||||
id = strategy.id,
|
||||
strategyName = strategy.strategyName,
|
||||
strategyDescription = strategy.strategyDescription,
|
||||
accountId = strategy.accountId,
|
||||
accountName = account?.accountName,
|
||||
enabled = strategy.enabled,
|
||||
filterTeams = strategy.filterTeams?.let { JsonUtils.parseStringList(it) },
|
||||
filterDateFrom = strategy.filterDateFrom,
|
||||
filterDateTo = strategy.filterDateTo,
|
||||
filterGameImportance = strategy.filterGameImportance,
|
||||
minWinProbabilityDiff = strategy.minWinProbabilityDiff,
|
||||
minWinProbability = strategy.minWinProbability,
|
||||
maxWinProbability = strategy.maxWinProbability,
|
||||
minTradeValue = strategy.minTradeValue,
|
||||
minRemainingTime = strategy.minRemainingTime,
|
||||
maxRemainingTime = strategy.maxRemainingTime,
|
||||
minScoreDiff = strategy.minScoreDiff,
|
||||
maxScoreDiff = strategy.maxScoreDiff,
|
||||
buyAmountStrategy = strategy.buyAmountStrategy,
|
||||
fixedBuyAmount = strategy.fixedBuyAmount,
|
||||
buyRatio = strategy.buyRatio,
|
||||
baseBuyAmount = strategy.baseBuyAmount,
|
||||
buyTiming = strategy.buyTiming,
|
||||
delayBuySeconds = strategy.delayBuySeconds,
|
||||
buyDirection = strategy.buyDirection,
|
||||
enableSell = strategy.enableSell,
|
||||
takeProfitThreshold = strategy.takeProfitThreshold,
|
||||
stopLossThreshold = strategy.stopLossThreshold,
|
||||
probabilityReversalThreshold = strategy.probabilityReversalThreshold,
|
||||
sellRatio = strategy.sellRatio,
|
||||
sellTiming = strategy.sellTiming,
|
||||
delaySellSeconds = strategy.delaySellSeconds,
|
||||
priceStrategy = strategy.priceStrategy,
|
||||
fixedPrice = strategy.fixedPrice,
|
||||
priceOffset = strategy.priceOffset,
|
||||
maxPosition = strategy.maxPosition,
|
||||
minPosition = strategy.minPosition,
|
||||
maxGamePosition = strategy.maxGamePosition,
|
||||
maxDailyLoss = strategy.maxDailyLoss,
|
||||
maxDailyOrders = strategy.maxDailyOrders,
|
||||
maxDailyProfit = strategy.maxDailyProfit,
|
||||
priceTolerance = strategy.priceTolerance,
|
||||
minProbabilityThreshold = strategy.minProbabilityThreshold,
|
||||
maxProbabilityThreshold = strategy.maxProbabilityThreshold,
|
||||
baseStrengthWeight = strategy.baseStrengthWeight,
|
||||
recentFormWeight = strategy.recentFormWeight,
|
||||
lineupIntegrityWeight = strategy.lineupIntegrityWeight,
|
||||
starStatusWeight = strategy.starStatusWeight,
|
||||
environmentWeight = strategy.environmentWeight,
|
||||
matchupAdvantageWeight = strategy.matchupAdvantageWeight,
|
||||
scoreDiffWeight = strategy.scoreDiffWeight,
|
||||
momentumWeight = strategy.momentumWeight,
|
||||
dataUpdateFrequency = strategy.dataUpdateFrequency,
|
||||
analysisFrequency = strategy.analysisFrequency,
|
||||
pushFailedOrders = strategy.pushFailedOrders,
|
||||
pushFrequency = strategy.pushFrequency,
|
||||
batchPushInterval = strategy.batchPushInterval,
|
||||
createdAt = strategy.createdAt,
|
||||
updatedAt = strategy.updatedAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+61
-6
@@ -59,6 +59,26 @@ class TelegramNotificationService(
|
||||
* @param walletAddressForApi 钱包地址(可选,用于查询订单详情)
|
||||
* @param locale 语言设置(可选,如果提供则使用,否则使用 LocaleContextHolder 获取)
|
||||
*/
|
||||
// 已发送通知的订单ID缓存(key: orderId, value: timestamp)
|
||||
private val sentOrderIds = java.util.concurrent.ConcurrentHashMap<String, Long>()
|
||||
|
||||
/**
|
||||
* 发送订单成功通知
|
||||
* @param orderId 订单ID(用于查询订单详情获取实际价格和数量)
|
||||
* @param marketTitle 市场标题
|
||||
* @param marketId 市场ID(conditionId),用于生成链接
|
||||
* @param marketSlug 市场slug,用于生成链接
|
||||
* @param side 订单方向(BUY/SELL),用于多语言显示
|
||||
* @param accountName 账户名称
|
||||
* @param walletAddress 钱包地址
|
||||
* @param clobApi CLOB API 客户端(可选,如果提供则查询订单详情获取实际价格和数量)
|
||||
* @param apiKey API Key(可选,用于查询订单详情)
|
||||
* @param apiSecret API Secret(可选,用于查询订单详情)
|
||||
* @param apiPassphrase API Passphrase(可选,用于查询订单详情)
|
||||
* @param walletAddressForApi 钱包地址(可选,用于查询订单详情)
|
||||
* @param locale 语言设置(可选,如果提供则使用,否则使用 LocaleContextHolder 获取)
|
||||
* @param orderTime 订单时间(可选,如果提供则使用订单创建时间,否则使用当前通知时间)
|
||||
*/
|
||||
suspend fun sendOrderSuccessNotification(
|
||||
orderId: String?,
|
||||
marketTitle: String,
|
||||
@@ -77,8 +97,29 @@ class TelegramNotificationService(
|
||||
walletAddressForApi: String? = null,
|
||||
locale: java.util.Locale? = null,
|
||||
leaderName: String? = null, // Leader 名称(备注)
|
||||
configName: String? = null // 跟单配置名
|
||||
configName: String? = null, // 跟单配置名
|
||||
orderTime: Long? = null // 订单创建时间(毫秒时间戳),用于通知中的时间显示
|
||||
) {
|
||||
// 1. 如果提供了 orderId,检查是否已发送过通知(去重)
|
||||
if (orderId != null) {
|
||||
val lastSentTime = sentOrderIds[orderId]
|
||||
if (lastSentTime != null) {
|
||||
// 如果5分钟内已发送过,跳过
|
||||
if (System.currentTimeMillis() - lastSentTime < 5 * 60 * 1000) {
|
||||
logger.info("订单通知已发送过(5分钟内),跳过: orderId=$orderId")
|
||||
return
|
||||
}
|
||||
}
|
||||
// 记录发送时间
|
||||
sentOrderIds[orderId] = System.currentTimeMillis()
|
||||
|
||||
// 简单的清理逻辑:如果缓存过大,清理过期的
|
||||
if (sentOrderIds.size > 1000) {
|
||||
val expiryTime = System.currentTimeMillis() - 5 * 60 * 1000
|
||||
sentOrderIds.entries.removeIf { it.value < expiryTime }
|
||||
}
|
||||
}
|
||||
|
||||
// 获取语言设置(优先使用传入的 locale,否则从 LocaleContextHolder 获取)
|
||||
val currentLocale = locale ?: try {
|
||||
LocaleContextHolder.getLocale()
|
||||
@@ -150,7 +191,8 @@ class TelegramNotificationService(
|
||||
walletAddress = walletAddress,
|
||||
locale = currentLocale,
|
||||
leaderName = leaderName,
|
||||
configName = configName
|
||||
configName = configName,
|
||||
orderTime = orderTime
|
||||
)
|
||||
sendMessage(message)
|
||||
}
|
||||
@@ -660,7 +702,8 @@ class TelegramNotificationService(
|
||||
walletAddress: String?,
|
||||
locale: java.util.Locale,
|
||||
leaderName: String? = null, // Leader 名称(备注)
|
||||
configName: String? = null // 跟单配置名
|
||||
configName: String? = null, // 跟单配置名
|
||||
orderTime: Long? = null // 订单创建时间(毫秒时间戳)
|
||||
): String {
|
||||
|
||||
// 获取多语言文本
|
||||
@@ -686,6 +729,13 @@ class TelegramNotificationService(
|
||||
else -> side
|
||||
}
|
||||
|
||||
// 获取图标
|
||||
val icon = when (side.uppercase()) {
|
||||
"BUY" -> "🚀"
|
||||
"SELL" -> "💰"
|
||||
else -> "📣"
|
||||
}
|
||||
|
||||
// 构建账户信息(格式:账户名(钱包地址))
|
||||
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
|
||||
|
||||
@@ -703,7 +753,12 @@ class TelegramNotificationService(
|
||||
""
|
||||
}
|
||||
|
||||
val time = DateUtils.formatDateTime()
|
||||
// 使用订单时间(如果提供),否则使用当前通知时间
|
||||
val time = if (orderTime != null) {
|
||||
DateUtils.formatDateTime(orderTime)
|
||||
} else {
|
||||
DateUtils.formatDateTime()
|
||||
}
|
||||
|
||||
// 转义 HTML 特殊字符
|
||||
val escapedMarketTitle = marketTitle.replace("<", "<").replace(">", ">")
|
||||
@@ -761,7 +816,7 @@ class TelegramNotificationService(
|
||||
val priceDisplay = formatPrice(price)
|
||||
val sizeDisplay = formatQuantity(size)
|
||||
|
||||
return """✅ <b>$orderCreatedSuccess</b>
|
||||
return """$icon <b>$orderCreatedSuccess</b>
|
||||
|
||||
📊 <b>$orderInfo:</b>
|
||||
• $orderIdLabel: <code>${orderId ?: unknown}</code>
|
||||
@@ -989,7 +1044,7 @@ class TelegramNotificationService(
|
||||
" • ${position.marketId.substring(0, 8)}... (${position.side}): $quantityDisplay shares = $valueDisplay USDC"
|
||||
}
|
||||
|
||||
return """✅ <b>$redeemSuccess</b>
|
||||
return """💸 <b>$redeemSuccess</b>
|
||||
|
||||
📊 <b>$redeemInfo:</b>
|
||||
• $accountLabel: $escapedAccountInfo
|
||||
|
||||
@@ -1,58 +1,154 @@
|
||||
package com.wrbug.polymarketbot.util
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import jakarta.annotation.PostConstruct
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
/**
|
||||
* JSON 工具类
|
||||
* 用于解析 JSON 字符串
|
||||
* 用于初始化全局 Gson 实例,供扩展函数使用
|
||||
*
|
||||
* 使用 Spring 注入的 Gson Bean(已在 GsonConfig 中配置为 lenient 模式)
|
||||
* 初始化后设置全局 gson 实例,供扩展函数使用
|
||||
*/
|
||||
@Component
|
||||
class JsonUtils(
|
||||
private val gson: Gson
|
||||
private val injectedGson: Gson
|
||||
) {
|
||||
|
||||
|
||||
@PostConstruct
|
||||
fun init() {
|
||||
// 设置全局 Gson 实例,供扩展函数使用
|
||||
_gson = injectedGson
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 JSON 字符串数组
|
||||
* @param jsonString JSON 字符串,如 "[\"Yes\", \"No\"]"
|
||||
* @return 字符串列表,如果解析失败返回空列表
|
||||
*/
|
||||
fun parseStringArray(jsonString: String?): List<String> {
|
||||
if (jsonString.isNullOrBlank()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
return try {
|
||||
val listType = object : TypeToken<List<String>>() {}.type
|
||||
gson.fromJson<List<String>>(jsonString, listType) ?: emptyList()
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
return jsonString?.parseStringArray() ?: emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 JSON 字符串列表(parseStringArray 的别名)
|
||||
*/
|
||||
fun parseStringList(jsonString: String?): List<String> {
|
||||
return parseStringArray(jsonString)
|
||||
}
|
||||
|
||||
/**
|
||||
* 将对象转换为 JSON 字符串
|
||||
* @param obj 要转换的对象
|
||||
* @return JSON 字符串
|
||||
*/
|
||||
fun toJson(obj: Any?): String? {
|
||||
if (obj == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return try {
|
||||
gson.toJson(obj)
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局 Gson 实例(用于扩展函数)
|
||||
* 在 JsonUtils 初始化时设置
|
||||
*/
|
||||
@Volatile
|
||||
private var _gson: Gson? = null
|
||||
|
||||
/**
|
||||
* 获取全局 Gson 实例
|
||||
* 如果尚未初始化,创建默认实例
|
||||
*/
|
||||
val gson: Gson
|
||||
get() = _gson ?: GsonBuilder().setLenient().create().also { _gson = it }
|
||||
|
||||
// ============================================================================
|
||||
// 扩展函数:JSON 序列化和反序列化
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 将对象转换为 JSON 字符串
|
||||
*
|
||||
* @return JSON 字符串,如果对象为 null 则返回空字符串
|
||||
*
|
||||
* @example
|
||||
* ```kotlin
|
||||
* val obj = MyDataClass(name = "test", value = 123)
|
||||
* val json = obj.toJson() // {"name":"test","value":123}
|
||||
* ```
|
||||
*/
|
||||
fun Any?.toJson(): String {
|
||||
return if (this == null) {
|
||||
""
|
||||
} else {
|
||||
try {
|
||||
gson.toJson(this)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 JSON 字符串解析为对象(支持泛型)
|
||||
*
|
||||
* @return 解析后的对象,如果解析失败返回 null
|
||||
*
|
||||
* @example
|
||||
* ```kotlin
|
||||
* val json = "{\"name\":\"test\",\"value\":123}"
|
||||
* val obj = json.fromJson<MyDataClass>() // MyDataClass(name="test", value=123)
|
||||
*
|
||||
* // 对于泛型类型,自动使用 TypeToken
|
||||
* val json = "[\"a\", \"b\", \"c\"]"
|
||||
* val list = json.fromJson<List<String>>() // ["a", "b", "c"]
|
||||
* ```
|
||||
*/
|
||||
inline fun <reified T> String?.fromJson(): T? {
|
||||
if (this.isNullOrBlank()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return try {
|
||||
val typeToken = object : TypeToken<T>() {}
|
||||
gson.fromJson(this, typeToken.type)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 JsonElement 解析为对象(支持泛型)
|
||||
*
|
||||
* @return 解析后的对象,如果解析失败返回 null
|
||||
*
|
||||
* @example
|
||||
* ```kotlin
|
||||
* val jsonElement: JsonElement = ...
|
||||
* val obj = jsonElement.fromJson<MyDataClass>()
|
||||
* ```
|
||||
*/
|
||||
inline fun <reified T> JsonElement?.fromJson(): T? {
|
||||
if (this == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return try {
|
||||
val typeToken = object : TypeToken<T>() {}
|
||||
gson.fromJson(this, typeToken.type)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 JSON 字符串数组
|
||||
*
|
||||
* @return 字符串列表,如果解析失败返回空列表
|
||||
*
|
||||
* @example
|
||||
* ```kotlin
|
||||
* val json = "[\"Yes\", \"No\"]"
|
||||
* val list = json.parseStringArray() // ["Yes", "No"]
|
||||
* ```
|
||||
*/
|
||||
fun String?.parseStringArray(): List<String> {
|
||||
if (this.isNullOrBlank()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
return try {
|
||||
val listType = object : TypeToken<List<String>>() {}.type
|
||||
gson.fromJson<List<String>>(this, listType) ?: emptyList()
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.wrbug.polymarketbot.util
|
||||
|
||||
import com.wrbug.polymarketbot.api.MarketResponse
|
||||
|
||||
/**
|
||||
* MarketResponse 扩展函数
|
||||
* 从 events[0].slug 获取 slug,用于网页跳转
|
||||
*/
|
||||
fun MarketResponse?.getEventSlug(): String? {
|
||||
return this?.events?.firstOrNull()?.slug ?: this?.slug
|
||||
}
|
||||
|
||||
/**
|
||||
* MarketResponse 扩展函数
|
||||
* 获取显示用的 slug(使用原来的 slug 字段)
|
||||
*/
|
||||
fun MarketResponse?.getDisplaySlug(): String? {
|
||||
return this?.slug
|
||||
}
|
||||
|
||||
@@ -26,21 +26,23 @@ fun BigDecimal.multi(value: Any): BigDecimal {
|
||||
return BigDecimal.ZERO
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* BigDecimal除法扩展函数
|
||||
* BigDecimal除法扩展函数(带精度和舍入模式)
|
||||
* 安全地将BigDecimal与任意数值类型相除
|
||||
* @param value 除数,支持BigDecimal、BigInteger类型或可转换为BigDecimal的字符串
|
||||
* @return 除法结果,精度为18位小数,使用四舍五入模式,如果转换失败返回IllegalBigDecimal
|
||||
* @param scale 精度(小数位数)
|
||||
* @param roundingMode 舍入模式
|
||||
* @return 除法结果,如果转换失败返回IllegalBigDecimal
|
||||
*/
|
||||
fun BigDecimal.div(value: Any): BigDecimal {
|
||||
fun BigDecimal.div(value: Any, scale: Int = 18, roundingMode: RoundingMode = RoundingMode.HALF_UP): BigDecimal {
|
||||
kotlin.runCatching {
|
||||
if (value is BigDecimal) {
|
||||
return divide(value, 18, RoundingMode.HALF_UP).stripTrailingZeros()
|
||||
val divisor = when (value) {
|
||||
is BigDecimal -> value
|
||||
is BigInteger -> value.toBigDecimal()
|
||||
else -> BigDecimal(value.toString())
|
||||
}
|
||||
if (value is BigInteger) {
|
||||
return divide(value.toSafeBigDecimal(), 18, RoundingMode.HALF_UP).stripTrailingZeros()
|
||||
}
|
||||
return divide(BigDecimal(value.toString()), 18, RoundingMode.HALF_UP).stripTrailingZeros()
|
||||
return divide(divisor, scale, roundingMode)
|
||||
}
|
||||
return IllegalBigDecimal
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
package com.wrbug.polymarketbot.util
|
||||
|
||||
import com.wrbug.polymarketbot.api.NbaStatsApi
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
/**
|
||||
* NBA API 验证工具
|
||||
* 用于验证 API 调用是否正确
|
||||
*/
|
||||
object NbaApiValidator {
|
||||
private val logger = LoggerFactory.getLogger(NbaApiValidator::class.java)
|
||||
|
||||
/**
|
||||
* 验证 API 调用
|
||||
*/
|
||||
suspend fun validateApi(nbaStatsApi: NbaStatsApi): Boolean {
|
||||
return try {
|
||||
val today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))
|
||||
logger.info("验证 NBA Stats API,日期: $today")
|
||||
|
||||
val response = nbaStatsApi.getScoreboard(gameDate = today)
|
||||
|
||||
logger.info("API 响应状态码: ${response.code()}")
|
||||
logger.info("API 响应消息: ${response.message()}")
|
||||
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val scoreboard = response.body()!!
|
||||
logger.info("ResultSets 数量: ${scoreboard.resultSets.size}")
|
||||
|
||||
scoreboard.resultSets.forEachIndexed { index, resultSet ->
|
||||
logger.info("ResultSet[$index]: name=${resultSet.name}, headers=${resultSet.headers.size}, rows=${resultSet.rowSet.size}")
|
||||
if (resultSet.headers.isNotEmpty()) {
|
||||
logger.info(" Headers: ${resultSet.headers.take(10)}")
|
||||
}
|
||||
if (resultSet.rowSet.isNotEmpty()) {
|
||||
val firstRow = resultSet.rowSet.first()
|
||||
logger.info(" First row size: ${firstRow.size}")
|
||||
logger.info(" First row (first 5): ${firstRow.take(5)}")
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否有 GameHeader 和 LineScore
|
||||
val hasGameHeader = scoreboard.resultSets.any { it.name == "GameHeader" }
|
||||
val hasLineScore = scoreboard.resultSets.any { it.name == "LineScore" }
|
||||
|
||||
logger.info("包含 GameHeader: $hasGameHeader")
|
||||
logger.info("包含 LineScore: $hasLineScore")
|
||||
|
||||
hasGameHeader && hasLineScore
|
||||
} else {
|
||||
logger.error("API 调用失败")
|
||||
val errorBody = response.errorBody()?.string()
|
||||
logger.error("错误响应体: $errorBody")
|
||||
false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("验证 API 异常: ${e.message}", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import com.google.gson.Gson
|
||||
import com.wrbug.polymarketbot.api.BuilderRelayerApi
|
||||
import com.wrbug.polymarketbot.api.EthereumRpcApi
|
||||
import com.wrbug.polymarketbot.api.GitHubApi
|
||||
import com.wrbug.polymarketbot.api.NbaStatsApi
|
||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||
import com.wrbug.polymarketbot.api.PolymarketDataApi
|
||||
import com.wrbug.polymarketbot.api.PolymarketGammaApi
|
||||
@@ -416,44 +415,6 @@ class RpcUrlReplaceInterceptor(
|
||||
|
||||
return chain.proceed(newRequest)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 NBA Stats API 客户端
|
||||
* NBA Stats API 是公开 API,但需要设置正确的请求头
|
||||
* @return NbaStatsApi 客户端
|
||||
*/
|
||||
fun createNbaStatsApi(): NbaStatsApi {
|
||||
val baseUrl = "https://stats.nba.com/stats/"
|
||||
|
||||
// 添加拦截器,设置 NBA Stats API 需要的请求头
|
||||
val nbaStatsInterceptor = object : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request().newBuilder()
|
||||
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
.header("Referer", "https://www.nba.com/")
|
||||
.header("Accept", "application/json")
|
||||
.header("Accept-Language", "en-US,en;q=0.9")
|
||||
.header("Origin", "https://www.nba.com")
|
||||
.build()
|
||||
return chain.proceed(request)
|
||||
}
|
||||
}
|
||||
|
||||
val okHttpClient = createClient()
|
||||
.addInterceptor(nbaStatsInterceptor)
|
||||
.build()
|
||||
|
||||
val gson = GsonBuilder()
|
||||
.setLenient()
|
||||
.create()
|
||||
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(baseUrl)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.build()
|
||||
.create(NbaStatsApi::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+3
-2
@@ -139,7 +139,8 @@ class PolymarketWebSocketClient(
|
||||
|
||||
/**
|
||||
* 启动 PING 保活机制
|
||||
* 根据官方文档,每 10 秒发送一次 "PING"
|
||||
* 根据 @polymarket/real-time-data-client,发送小写 "ping"
|
||||
* 每 10 秒发送一次
|
||||
*/
|
||||
private fun startPing() {
|
||||
stopPing() // 先停止之前的 PING 任务
|
||||
@@ -149,7 +150,7 @@ class PolymarketWebSocketClient(
|
||||
delay(10000) // 10 秒
|
||||
if (isConnected) {
|
||||
try {
|
||||
sendMessage("PING")
|
||||
sendMessage("ping") // 使用小写,与官方客户端保持一致
|
||||
} catch (e: Exception) {
|
||||
logger.warn("发送 PING 失败: $sessionId, ${e.message}")
|
||||
break
|
||||
|
||||
@@ -29,14 +29,16 @@ server.port=${SERVER_PORT:8000}
|
||||
# 默认使用 dev 环境,可通过 --spring.profiles.active=prod 切换
|
||||
spring.profiles.active=${SPRING_PROFILES_ACTIVE:dev}
|
||||
|
||||
# 日志配置
|
||||
logging.level.root=INFO
|
||||
logging.level.com.wrbug.polyhermes=DEBUG
|
||||
# 日志配置(支持通过环境变量配置)
|
||||
logging.level.root=${LOG_LEVEL_ROOT:WARN}
|
||||
logging.level.com.wrbug.polymarketbot=${LOG_LEVEL_APP:INFO}
|
||||
logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} - %msg%n
|
||||
|
||||
# Polymarket API 配置
|
||||
polymarket.clob.base-url=https://clob.polymarket.com
|
||||
polymarket.rtds.ws-url=wss://ws-subscriptions-clob.polymarket.com
|
||||
polymarket.websocket.url=wss://ws-live-data.polymarket.com
|
||||
polymarket.websocket.activity.url=${POLYMARKET_WEBSOCKET_ACTIVITY_URL:wss://ws-live-data.polymarket.com}
|
||||
polymarket.data-api.base-url=https://data-api.polymarket.com
|
||||
polymarket.gamma.base-url=https://gamma-api.polymarket.com
|
||||
|
||||
@@ -52,6 +54,10 @@ copy.trading.polling.interval=${COPY_TRADING_POLLING_INTERVAL:2000}
|
||||
# 是否启用轮询,默认true
|
||||
copy.trading.polling.enabled=${COPY_TRADING_POLLING_ENABLED:true}
|
||||
|
||||
# 跟单监听配置
|
||||
# 链上 WebSocket 重连延迟(毫秒),默认3秒
|
||||
copy.trading.onchain.ws.reconnect.delay=${COPY_TRADING_ONCHAIN_WS_RECONNECT_DELAY:3000}
|
||||
|
||||
# WebSocket 配置
|
||||
websocket.heartbeat-timeout=${WEBSOCKET_HEARTBEAT_TIMEOUT:60000}
|
||||
|
||||
|
||||
-184
@@ -1,184 +0,0 @@
|
||||
-- NBA 量化交易系统数据库表
|
||||
|
||||
-- 1. NBA 市场表(Polymarket 市场信息)
|
||||
CREATE TABLE IF NOT EXISTS nba_markets (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
polymarket_market_id VARCHAR(100) UNIQUE NOT NULL COMMENT 'Polymarket 市场 ID',
|
||||
condition_id VARCHAR(100) UNIQUE NOT NULL COMMENT 'Condition ID',
|
||||
market_slug VARCHAR(255) COMMENT '市场 slug',
|
||||
market_question TEXT COMMENT '市场名称/问题',
|
||||
market_description TEXT COMMENT '市场描述',
|
||||
category VARCHAR(50) DEFAULT 'sports' COMMENT '分类',
|
||||
active BOOLEAN DEFAULT true COMMENT '是否活跃',
|
||||
closed BOOLEAN DEFAULT false COMMENT '是否已关闭',
|
||||
archived BOOLEAN DEFAULT false COMMENT '是否已归档',
|
||||
volume VARCHAR(50) COMMENT '交易量',
|
||||
liquidity VARCHAR(50) COMMENT '流动性',
|
||||
outcomes TEXT COMMENT '结果选项(JSON)',
|
||||
end_date VARCHAR(50) COMMENT '结束日期',
|
||||
start_date VARCHAR(50) COMMENT '开始日期',
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
INDEX idx_condition_id (condition_id),
|
||||
INDEX idx_active (active),
|
||||
INDEX idx_closed (closed),
|
||||
INDEX idx_category (category)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='NBA市场表(Polymarket市场)';
|
||||
|
||||
-- 2. NBA 比赛表(NBA 比赛信息)
|
||||
CREATE TABLE IF NOT EXISTS nba_games (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
nba_game_id VARCHAR(100) UNIQUE COMMENT 'NBA 比赛 ID(来自 NBA API)',
|
||||
home_team VARCHAR(100) NOT NULL COMMENT '主队名称',
|
||||
away_team VARCHAR(100) NOT NULL COMMENT '客队名称',
|
||||
game_date DATE NOT NULL COMMENT '比赛日期',
|
||||
game_time BIGINT COMMENT '比赛时间(时间戳,毫秒)',
|
||||
game_status VARCHAR(50) DEFAULT 'scheduled' COMMENT '比赛状态:scheduled/active/finished',
|
||||
home_score INT DEFAULT 0 COMMENT '主队得分',
|
||||
away_score INT DEFAULT 0 COMMENT '客队得分',
|
||||
period INT DEFAULT 0 COMMENT '当前节次',
|
||||
time_remaining VARCHAR(50) COMMENT '剩余时间',
|
||||
polymarket_market_id VARCHAR(100) COMMENT '关联的 Polymarket 市场 ID',
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
INDEX idx_game_date (game_date),
|
||||
INDEX idx_game_status (game_status),
|
||||
INDEX idx_home_team (home_team),
|
||||
INDEX idx_away_team (away_team),
|
||||
INDEX idx_polymarket_market_id (polymarket_market_id),
|
||||
FOREIGN KEY (polymarket_market_id) REFERENCES nba_markets(polymarket_market_id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='NBA比赛表';
|
||||
|
||||
-- 3. 量化策略配置表
|
||||
CREATE TABLE IF NOT EXISTS nba_quantitative_strategies (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
strategy_name VARCHAR(100) NOT NULL COMMENT '策略名称',
|
||||
strategy_description TEXT COMMENT '策略描述',
|
||||
account_id BIGINT NOT NULL COMMENT '关联账户 ID',
|
||||
enabled BOOLEAN DEFAULT true COMMENT '是否启用',
|
||||
|
||||
-- 比赛筛选参数
|
||||
filter_teams TEXT COMMENT '关注的球队列表(JSON)',
|
||||
filter_date_from DATE COMMENT '日期范围开始',
|
||||
filter_date_to DATE COMMENT '日期范围结束',
|
||||
filter_game_importance VARCHAR(50) COMMENT '比赛重要性:all/regular/playoff/key',
|
||||
|
||||
-- 触发条件参数
|
||||
min_win_probability_diff DECIMAL(5, 4) DEFAULT 0.1000 COMMENT '最小获胜概率差异',
|
||||
min_win_probability DECIMAL(5, 4) COMMENT '最小获胜概率',
|
||||
max_win_probability DECIMAL(5, 4) COMMENT '最大获胜概率',
|
||||
min_trade_value DECIMAL(5, 4) DEFAULT 0.0500 COMMENT '最小交易价值',
|
||||
min_remaining_time INT COMMENT '最小剩余时间(分钟)',
|
||||
max_remaining_time INT COMMENT '最大剩余时间(分钟)',
|
||||
min_score_diff INT COMMENT '最小分差',
|
||||
max_score_diff INT COMMENT '最大分差',
|
||||
|
||||
-- 买入规则参数
|
||||
buy_amount_strategy VARCHAR(20) DEFAULT 'FIXED' COMMENT '买入金额策略:FIXED/RATIO/DYNAMIC',
|
||||
fixed_buy_amount DECIMAL(20, 8) COMMENT '固定买入金额(USDC)',
|
||||
buy_ratio DECIMAL(5, 4) COMMENT '买入比例(0-1)',
|
||||
base_buy_amount DECIMAL(20, 8) COMMENT '基础买入金额(USDC)',
|
||||
buy_timing VARCHAR(20) DEFAULT 'IMMEDIATE' COMMENT '买入时机:IMMEDIATE/DELAYED',
|
||||
delay_buy_seconds INT DEFAULT 0 COMMENT '延迟买入时间(秒)',
|
||||
buy_direction VARCHAR(10) DEFAULT 'AUTO' COMMENT '买入方向:AUTO/YES/NO',
|
||||
|
||||
-- 卖出规则参数
|
||||
enable_sell BOOLEAN DEFAULT true COMMENT '是否启用卖出',
|
||||
take_profit_threshold DECIMAL(5, 4) COMMENT '止盈阈值(0-1)',
|
||||
stop_loss_threshold DECIMAL(5, 4) COMMENT '止损阈值(-1-0)',
|
||||
probability_reversal_threshold DECIMAL(5, 4) COMMENT '概率反转阈值(0-1)',
|
||||
sell_ratio DECIMAL(5, 4) DEFAULT 1.0000 COMMENT '卖出比例(0-1)',
|
||||
sell_timing VARCHAR(20) DEFAULT 'IMMEDIATE' COMMENT '卖出时机:IMMEDIATE/DELAYED',
|
||||
delay_sell_seconds INT DEFAULT 0 COMMENT '延迟卖出时间(秒)',
|
||||
|
||||
-- 价格策略参数
|
||||
price_strategy VARCHAR(20) DEFAULT 'MARKET' COMMENT '价格策略:FIXED/MARKET/DYNAMIC',
|
||||
fixed_price DECIMAL(5, 4) COMMENT '固定价格(0-1)',
|
||||
price_offset DECIMAL(5, 4) DEFAULT 0.0000 COMMENT '价格偏移(-0.1-0.1)',
|
||||
|
||||
-- 风险控制参数
|
||||
max_position DECIMAL(20, 8) DEFAULT 50.00000000 COMMENT '最大持仓(USDC)',
|
||||
min_position DECIMAL(20, 8) DEFAULT 5.00000000 COMMENT '最小持仓(USDC)',
|
||||
max_game_position DECIMAL(20, 8) COMMENT '单场比赛最大持仓(USDC)',
|
||||
max_daily_loss DECIMAL(20, 8) COMMENT '每日亏损限制(USDC)',
|
||||
max_daily_orders INT COMMENT '每日订单限制',
|
||||
max_daily_profit DECIMAL(20, 8) COMMENT '每日盈利目标(USDC)',
|
||||
price_tolerance DECIMAL(5, 4) DEFAULT 0.0500 COMMENT '价格容忍度(0-1)',
|
||||
min_probability_threshold DECIMAL(5, 4) COMMENT '最小概率阈值(0.5-1.0)',
|
||||
max_probability_threshold DECIMAL(5, 4) COMMENT '最大概率阈值(0.0-0.5)',
|
||||
|
||||
-- 算法权重参数(高级)
|
||||
base_strength_weight DECIMAL(5, 4) DEFAULT 0.3000 COMMENT '基础实力权重',
|
||||
recent_form_weight DECIMAL(5, 4) DEFAULT 0.2500 COMMENT '近期状态权重',
|
||||
lineup_integrity_weight DECIMAL(5, 4) DEFAULT 0.2000 COMMENT '阵容完整度权重',
|
||||
star_status_weight DECIMAL(5, 4) DEFAULT 0.1500 COMMENT '球星状态权重',
|
||||
environment_weight DECIMAL(5, 4) DEFAULT 0.1000 COMMENT '环境因素权重',
|
||||
matchup_advantage_weight DECIMAL(5, 4) DEFAULT 0.2000 COMMENT '对位优势权重',
|
||||
score_diff_weight DECIMAL(5, 4) DEFAULT 0.3000 COMMENT '分差调整权重',
|
||||
momentum_weight DECIMAL(5, 4) DEFAULT 0.2000 COMMENT '势头调整权重',
|
||||
|
||||
-- 系统配置参数
|
||||
data_update_frequency INT DEFAULT 30 COMMENT '数据更新频率(秒)',
|
||||
analysis_frequency INT DEFAULT 30 COMMENT '分析频率(秒)',
|
||||
push_failed_orders BOOLEAN DEFAULT false COMMENT '是否推送失败订单',
|
||||
push_frequency VARCHAR(20) DEFAULT 'REALTIME' COMMENT '推送频率:REALTIME/BATCH',
|
||||
batch_push_interval INT DEFAULT 1 COMMENT '批量推送间隔(秒)',
|
||||
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
INDEX idx_account_id (account_id),
|
||||
INDEX idx_enabled (enabled),
|
||||
INDEX idx_strategy_name (strategy_name),
|
||||
FOREIGN KEY (account_id) REFERENCES wallet_accounts(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='NBA量化策略配置表';
|
||||
|
||||
-- 4. 交易信号表
|
||||
CREATE TABLE IF NOT EXISTS nba_trading_signals (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
strategy_id BIGINT NOT NULL COMMENT '策略 ID',
|
||||
game_id BIGINT COMMENT '比赛 ID',
|
||||
market_id BIGINT COMMENT '市场 ID',
|
||||
signal_type VARCHAR(10) NOT NULL COMMENT '信号类型:BUY/SELL',
|
||||
direction VARCHAR(10) NOT NULL COMMENT '方向:YES/NO',
|
||||
price DECIMAL(5, 4) NOT NULL COMMENT '价格(0-1)',
|
||||
quantity DECIMAL(20, 8) NOT NULL COMMENT '数量',
|
||||
total_amount DECIMAL(20, 8) NOT NULL COMMENT '总金额(USDC)',
|
||||
reason TEXT COMMENT '触发原因',
|
||||
win_probability DECIMAL(5, 4) COMMENT '获胜概率',
|
||||
trade_value DECIMAL(5, 4) COMMENT '交易价值',
|
||||
signal_status VARCHAR(20) DEFAULT 'GENERATED' COMMENT '信号状态:GENERATED/EXECUTING/SUCCESS/FAILED',
|
||||
execution_result TEXT COMMENT '执行结果',
|
||||
error_message TEXT COMMENT '错误信息',
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
INDEX idx_strategy_id (strategy_id),
|
||||
INDEX idx_game_id (game_id),
|
||||
INDEX idx_market_id (market_id),
|
||||
INDEX idx_signal_type (signal_type),
|
||||
INDEX idx_signal_status (signal_status),
|
||||
INDEX idx_created_at (created_at),
|
||||
FOREIGN KEY (strategy_id) REFERENCES nba_quantitative_strategies(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (game_id) REFERENCES nba_games(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (market_id) REFERENCES nba_markets(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='NBA交易信号表';
|
||||
|
||||
-- 5. 策略执行统计表
|
||||
CREATE TABLE IF NOT EXISTS nba_strategy_statistics (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
strategy_id BIGINT NOT NULL COMMENT '策略 ID',
|
||||
stat_date DATE NOT NULL COMMENT '统计日期',
|
||||
total_signals INT DEFAULT 0 COMMENT '总信号数',
|
||||
buy_signals INT DEFAULT 0 COMMENT '买入信号数',
|
||||
sell_signals INT DEFAULT 0 COMMENT '卖出信号数',
|
||||
success_signals INT DEFAULT 0 COMMENT '成功信号数',
|
||||
failed_signals INT DEFAULT 0 COMMENT '失败信号数',
|
||||
total_profit DECIMAL(20, 8) DEFAULT 0.00000000 COMMENT '总盈亏(USDC)',
|
||||
total_volume DECIMAL(20, 8) DEFAULT 0.00000000 COMMENT '总交易量(USDC)',
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
UNIQUE KEY uk_strategy_date (strategy_id, stat_date),
|
||||
INDEX idx_strategy_id (strategy_id),
|
||||
INDEX idx_stat_date (stat_date),
|
||||
FOREIGN KEY (strategy_id) REFERENCES nba_quantitative_strategies(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='NBA策略执行统计表';
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
-- 移除 NBA 市场相关的外键约束
|
||||
-- 由于不再在数据库中存储市场信息,需要移除这些外键约束
|
||||
|
||||
-- 1. 移除 nba_games 表的外键约束
|
||||
SET @fk_name = (SELECT CONSTRAINT_NAME
|
||||
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'nba_games'
|
||||
AND COLUMN_NAME = 'polymarket_market_id'
|
||||
AND REFERENCED_TABLE_NAME = 'nba_markets'
|
||||
LIMIT 1);
|
||||
|
||||
SET @sql = IF(@fk_name IS NOT NULL,
|
||||
CONCAT('ALTER TABLE nba_games DROP FOREIGN KEY ', @fk_name),
|
||||
'SELECT "Foreign key constraint not found"');
|
||||
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 2. 移除 nba_trading_signals 表的外键约束(如果存在)
|
||||
SET @fk_name = (SELECT CONSTRAINT_NAME
|
||||
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'nba_trading_signals'
|
||||
AND COLUMN_NAME = 'market_id'
|
||||
AND REFERENCED_TABLE_NAME = 'nba_markets'
|
||||
LIMIT 1);
|
||||
|
||||
SET @sql = IF(@fk_name IS NOT NULL,
|
||||
CONCAT('ALTER TABLE nba_trading_signals DROP FOREIGN KEY ', @fk_name),
|
||||
'SELECT "Foreign key constraint not found"');
|
||||
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- ============================================
|
||||
-- V18: 增加 copy_ratio 字段的精度,支持更小的小数值
|
||||
-- ============================================
|
||||
|
||||
-- 修改 copy_trading 表的 copy_ratio 字段精度
|
||||
-- 从 DECIMAL(10, 2) 改为 DECIMAL(20, 8),支持更小的跟单比例值(如 0.001)
|
||||
ALTER TABLE copy_trading
|
||||
MODIFY COLUMN copy_ratio DECIMAL(20, 8) NOT NULL DEFAULT 1.00000000 COMMENT '跟单比例(仅在copyMode=RATIO时生效)';
|
||||
|
||||
-- 修改 copy_trading_templates 表的 copy_ratio 字段精度
|
||||
-- 从 DECIMAL(10, 2) 改为 DECIMAL(20, 8),支持更小的跟单比例值(如 0.001)
|
||||
ALTER TABLE copy_trading_templates
|
||||
MODIFY COLUMN copy_ratio DECIMAL(20, 8) NOT NULL DEFAULT 1.00000000 COMMENT '跟单比例(仅在copyMode=RATIO时生效)';
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- ============================================
|
||||
-- V19: 创建市场信息表
|
||||
-- ============================================
|
||||
|
||||
-- 创建 markets 表
|
||||
CREATE TABLE IF NOT EXISTS markets (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
market_id VARCHAR(100) NOT NULL UNIQUE COMMENT '市场ID(condition ID)',
|
||||
title VARCHAR(500) NOT NULL COMMENT '市场名称(question)',
|
||||
slug VARCHAR(200) COMMENT '市场slug',
|
||||
category VARCHAR(50) COMMENT '市场分类',
|
||||
icon VARCHAR(500) COMMENT '市场图标URL',
|
||||
image VARCHAR(500) COMMENT '市场图片URL',
|
||||
description TEXT COMMENT '市场描述',
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否活跃',
|
||||
closed BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否已关闭',
|
||||
archived BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否已归档',
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间(时间戳,毫秒)',
|
||||
updated_at BIGINT NOT NULL COMMENT '更新时间(时间戳,毫秒)',
|
||||
INDEX idx_market_id (market_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='市场信息表';
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- ============================================
|
||||
-- V20: 添加关键字过滤字段
|
||||
-- 在 copy_trading 表中添加关键字过滤配置
|
||||
-- 支持白名单和黑名单两种模式
|
||||
-- ============================================
|
||||
|
||||
-- 添加关键字过滤模式字段
|
||||
-- DISABLED: 不启用关键字过滤(默认)
|
||||
-- WHITELIST: 白名单模式,只跟单包含关键字的市场
|
||||
-- BLACKLIST: 黑名单模式,不跟单包含关键字的市场
|
||||
ALTER TABLE copy_trading
|
||||
ADD COLUMN keyword_filter_mode VARCHAR(20) NOT NULL DEFAULT 'DISABLED' COMMENT '关键字过滤模式(DISABLED/WHITELIST/BLACKLIST)' AFTER max_position_count;
|
||||
|
||||
-- 添加关键字列表字段(JSON 数组)
|
||||
-- 当 keyword_filter_mode 为 DISABLED 时,此字段为 NULL
|
||||
-- 当 keyword_filter_mode 为 WHITELIST 或 BLACKLIST 时,此字段存储关键字数组,例如:["NBA", "足球", "NBA总决赛"]
|
||||
ALTER TABLE copy_trading
|
||||
ADD COLUMN keywords JSON NULL COMMENT '关键字列表(JSON数组),仅在WHITELIST或BLACKLIST模式下使用' AFTER keyword_filter_mode;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
-- ============================================
|
||||
-- V21: 添加 event_slug 字段到 markets 表
|
||||
-- 用于存储跳转用的 slug(从 events[0].slug 获取)
|
||||
-- ============================================
|
||||
|
||||
ALTER TABLE markets
|
||||
ADD COLUMN event_slug VARCHAR(200) NULL COMMENT '跳转用的 slug(从 events[0].slug 获取,用于构建 URL)' AFTER slug;
|
||||
|
||||
+20
@@ -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;
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
-- ============================================
|
||||
-- V23: 移除跟单关系的唯一约束
|
||||
-- 允许同一用户创建多个相同 leader 的跟单配置
|
||||
-- ============================================
|
||||
|
||||
-- 删除唯一约束 uk_account_leader
|
||||
SET @uk_name = (SELECT CONSTRAINT_NAME FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'copy_trading'
|
||||
AND CONSTRAINT_TYPE = 'UNIQUE'
|
||||
AND CONSTRAINT_NAME = 'uk_account_leader'
|
||||
LIMIT 1);
|
||||
SET @sql = IF(@uk_name IS NOT NULL,
|
||||
CONCAT('ALTER TABLE copy_trading DROP INDEX ', @uk_name),
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
-- ============================================
|
||||
-- V24: 添加推送已过滤订单字段到模板表和跟单配置表
|
||||
-- 用于配置是否推送被过滤的订单通知,默认关闭
|
||||
-- ============================================
|
||||
|
||||
-- 添加推送已过滤订单字段到模板表
|
||||
ALTER TABLE copy_trading_templates
|
||||
ADD COLUMN push_filtered_orders BOOLEAN NOT NULL DEFAULT FALSE COMMENT '推送已过滤订单(默认关闭)';
|
||||
|
||||
-- 添加推送已过滤订单字段到跟单配置表
|
||||
ALTER TABLE copy_trading
|
||||
ADD COLUMN push_filtered_orders BOOLEAN NOT NULL DEFAULT FALSE COMMENT '推送已过滤订单(默认关闭)';
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
package com.wrbug.polymarketbot.service.nba
|
||||
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
|
||||
/**
|
||||
* NBA 比赛服务测试
|
||||
* 用于验证 API 调用是否正确
|
||||
*/
|
||||
@SpringBootTest
|
||||
class NbaGameServiceTest {
|
||||
|
||||
@Autowired
|
||||
private lateinit var retrofitFactory: RetrofitFactory
|
||||
|
||||
@Test
|
||||
fun testNbaStatsApi() {
|
||||
runBlocking {
|
||||
try {
|
||||
val nbaStatsApi = retrofitFactory.createNbaStatsApi()
|
||||
|
||||
// 测试获取今天的比赛
|
||||
val today = java.time.LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd"))
|
||||
println("测试日期: $today")
|
||||
|
||||
val response = nbaStatsApi.getScoreboard(gameDate = today)
|
||||
|
||||
println("响应状态码: ${response.code()}")
|
||||
println("响应消息: ${response.message()}")
|
||||
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val scoreboard = response.body()!!
|
||||
println("ResultSets 数量: ${scoreboard.resultSets.size}")
|
||||
|
||||
scoreboard.resultSets.forEachIndexed { index, resultSet ->
|
||||
println("ResultSet[$index]: name=${resultSet.name}, headers=${resultSet.headers.size}, rows=${resultSet.rowSet.size}")
|
||||
if (resultSet.headers.isNotEmpty()) {
|
||||
println(" Headers: ${resultSet.headers.take(5)}...")
|
||||
}
|
||||
if (resultSet.rowSet.isNotEmpty()) {
|
||||
println(" First row size: ${resultSet.rowSet.first().size}")
|
||||
println(" First row: ${resultSet.rowSet.first().take(5)}...")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println("API 调用失败")
|
||||
println("错误响应体: ${response.errorBody()?.string()}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("测试异常: ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,11 @@ JWT_SECRET=${JWT_SECRET}
|
||||
|
||||
# 管理员密码重置密钥(已自动生成随机值,生产环境建议修改)
|
||||
ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_KEY}
|
||||
|
||||
# 日志级别配置(可选,默认值:root=WARN, app=INFO)
|
||||
# 可选值:TRACE, DEBUG, INFO, WARN, ERROR, OFF
|
||||
# LOG_LEVEL_ROOT=WARN
|
||||
# LOG_LEVEL_APP=INFO
|
||||
EOF
|
||||
info ".env 文件已创建,已自动生成随机密码和密钥"
|
||||
warn "生产环境建议修改以下参数:"
|
||||
|
||||
@@ -58,3 +58,20 @@ ADMIN_RESET_PASSWORD_KEY=change-me-in-production
|
||||
# 生成方式 / Generate: openssl rand -hex 32
|
||||
CRYPTO_SECRET_KEY=change-me-in-production
|
||||
|
||||
# ============================================
|
||||
# 日志级别配置 / Logging Level Configuration
|
||||
# ============================================
|
||||
# 可选配置,如果不设置则使用默认值 / Optional configuration, uses default values if not set
|
||||
#
|
||||
# Root 日志级别,控制所有第三方库的日志输出
|
||||
# Root logging level, controls log output for all third-party libraries
|
||||
# 默认值 / Default: WARN
|
||||
# 可选值 / Options: TRACE, DEBUG, INFO, WARN, ERROR, OFF
|
||||
# LOG_LEVEL_ROOT=WARN
|
||||
#
|
||||
# 应用日志级别,控制应用代码的日志输出
|
||||
# Application logging level, controls log output for application code
|
||||
# 默认值 / Default: INFO
|
||||
# 可选值 / Options: TRACE, DEBUG, INFO, WARN, ERROR, OFF
|
||||
# LOG_LEVEL_APP=INFO
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ services:
|
||||
ports:
|
||||
- "${SERVER_PORT:-80}:80"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Shanghai}
|
||||
- SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE:-prod}
|
||||
- DB_URL=${DB_URL:-jdbc:mysql://mysql:3306/polyhermes?useSSL=false&serverTimezone=UTC&characterEncoding=utf8&allowPublicKeyRetrieval=true}
|
||||
- DB_USERNAME=${DB_USERNAME:-root}
|
||||
@@ -29,6 +30,12 @@ services:
|
||||
# 生成随机密钥:openssl rand -hex 32 (ADMIN_RESET_PASSWORD_KEY) 或 openssl rand -hex 64 (JWT_SECRET)
|
||||
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
||||
- ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY:-change-me-in-production}
|
||||
# 日志级别配置(可选,默认值:root=WARN, app=INFO)
|
||||
# 可选值:TRACE, DEBUG, INFO, WARN, ERROR, OFF
|
||||
- LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT:-WARN}
|
||||
- LOG_LEVEL_APP=${LOG_LEVEL_APP:-INFO}
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
@@ -42,12 +49,14 @@ services:
|
||||
ports:
|
||||
- "${MYSQL_PORT:-3307}:3306"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Shanghai}
|
||||
- MYSQL_ROOT_PASSWORD=${DB_PASSWORD:-rootpassword}
|
||||
- MYSQL_DATABASE=polyhermes
|
||||
- MYSQL_CHARACTER_SET_SERVER=utf8mb4
|
||||
- MYSQL_COLLATION_SERVER=utf8mb4_unicode_ci
|
||||
volumes:
|
||||
- mysql-data:/var/lib/mysql
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_PASSWORD:-rootpassword}"]
|
||||
interval: 10s
|
||||
|
||||
@@ -17,6 +17,7 @@ services:
|
||||
ports:
|
||||
- "${SERVER_PORT:-80}:80"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Shanghai}
|
||||
- SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE:-prod}
|
||||
- DB_URL=${DB_URL:-jdbc:mysql://mysql:3306/polyhermes?useSSL=false&serverTimezone=UTC&characterEncoding=utf8&allowPublicKeyRetrieval=true}
|
||||
- DB_USERNAME=${DB_USERNAME:-root}
|
||||
@@ -27,6 +28,12 @@ services:
|
||||
# 生成随机密钥:openssl rand -hex 32 (ADMIN_RESET_PASSWORD_KEY) 或 openssl rand -hex 64 (JWT_SECRET)
|
||||
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
||||
- ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY:-change-me-in-production}
|
||||
# 日志级别配置(可选,默认值:root=WARN, app=INFO)
|
||||
# 可选值:TRACE, DEBUG, INFO, WARN, ERROR, OFF
|
||||
- LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT:-WARN}
|
||||
- LOG_LEVEL_APP=${LOG_LEVEL_APP:-INFO}
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
@@ -40,12 +47,14 @@ services:
|
||||
ports:
|
||||
- "${MYSQL_PORT:-3307}:3306"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Shanghai}
|
||||
- MYSQL_ROOT_PASSWORD=${DB_PASSWORD:-rootpassword}
|
||||
- MYSQL_DATABASE=polyhermes
|
||||
- MYSQL_CHARACTER_SET_SERVER=utf8mb4
|
||||
- MYSQL_COLLATION_SERVER=utf8mb4_unicode_ci
|
||||
volumes:
|
||||
- mysql-data:/var/lib/mysql
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_PASSWORD:-rootpassword}"]
|
||||
interval: 10s
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
# 跟单双重监听方案(OnChain + Poly Activity WS)
|
||||
|
||||
## 1. 方案概述
|
||||
|
||||
### 1.1 目标
|
||||
实现 **OnChain WebSocket + Polymarket Activity WebSocket** 双重监听机制,提高跟单系统的实时性和可靠性。
|
||||
|
||||
### 1.2 架构图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ CopyTradingMonitorService │
|
||||
│ (主监听协调服务) │
|
||||
└────────────┬──────────────────────────────────┬──────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌────────────────────────┐ ┌──────────────────────────────┐
|
||||
│ PolymarketActivity │ │ OnChainWsService │
|
||||
│ WsService (新增) │ │ (已存在) │
|
||||
│ │ │ │
|
||||
│ - 订阅全局 activity │ │ - 订阅 Leader 地址的链上事件 │
|
||||
│ - 客户端地址过滤 │ │ - 监听 Transfer 事件 │
|
||||
│ - 延迟 < 100ms │ │ - 延迟 ~2-3s (出块时间) │
|
||||
│ - 数据来源:Poly WS │ │ - 数据来源:链上 RPC │
|
||||
└────────────┬───────────┘ └──────────────┬───────────────┘
|
||||
│ │
|
||||
└──────────────┬──────────────────────┘
|
||||
▼
|
||||
┌───────────────────────┐
|
||||
│ CopyOrderTrackingService│
|
||||
│ (统一处理服务) │
|
||||
│ │
|
||||
│ - 去重(leaderId+ │
|
||||
│ tradeId) │
|
||||
│ - 处理买入/卖出 │
|
||||
│ - 记录处理状态 │
|
||||
└────────────────────────┘
|
||||
```
|
||||
|
||||
### 1.3 优势对比
|
||||
|
||||
| 特性 | Activity WS | OnChain WS | 双重监听 |
|
||||
|------|-------------|------------|----------|
|
||||
| **延迟** | < 100ms | ~2-3s | < 100ms (优先) |
|
||||
| **可靠性** | 中等(依赖 Poly 服务) | 高(链上数据) | 高(双重保障) |
|
||||
| **数据完整性** | 完整(包含 market、price、size) | 需要解析 Transfer | 完整 |
|
||||
| **兜底机制** | ❌ | ✅ | ✅ |
|
||||
| **去重** | 需要 | 需要 | 统一去重 |
|
||||
|
||||
## 2. 技术方案
|
||||
|
||||
### 2.1 新增服务:PolymarketActivityWsService
|
||||
|
||||
**职责**:
|
||||
- 订阅 Polymarket Activity WebSocket(全局交易流)
|
||||
- 根据 Leader 地址列表进行客户端过滤
|
||||
- 将交易事件转换为 `TradeResponse` 格式
|
||||
- 调用 `CopyOrderTrackingService.processTrade()` 处理
|
||||
|
||||
**实现要点**:
|
||||
1. 使用单个 WebSocket 连接订阅全局 activity(不传 filters)
|
||||
2. 客户端维护 Leader 地址 Set,实时过滤交易
|
||||
3. 支持动态添加/移除 Leader(无需重连)
|
||||
4. 自动重连机制
|
||||
|
||||
### 2.2 修改 CopyTradingMonitorService
|
||||
|
||||
**职责**:
|
||||
- 协调两种监听服务
|
||||
- 统一管理 Leader 的添加/移除
|
||||
- 启动时同时启动两种监听
|
||||
|
||||
### 2.3 去重机制
|
||||
|
||||
**现有机制**(CopyOrderTrackingService):
|
||||
- 使用 `leaderId + trade.id` 作为唯一键
|
||||
- 基于数据库表 `processed_trade` 去重
|
||||
- 使用 Mutex 保证线程安全
|
||||
|
||||
**双重监听场景**:
|
||||
- Activity WS 和 OnChain WS 可能同时检测到同一笔交易
|
||||
- 去重机制确保只处理一次(第一个到达的)
|
||||
- 通过 `source` 字段区分数据源("activity-ws" / "onchain-ws")
|
||||
|
||||
## 3. 实现细节
|
||||
|
||||
### 3.1 PolymarketActivityWsService 设计
|
||||
|
||||
```kotlin
|
||||
@Service
|
||||
class PolymarketActivityWsService(
|
||||
private val copyOrderTrackingService: CopyOrderTrackingService,
|
||||
private val leaderRepository: LeaderRepository
|
||||
) {
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
// 单例 WebSocket 客户端
|
||||
private var wsClient: PolymarketWebSocketClient? = null
|
||||
|
||||
// 要监听的 Leader 地址集合(小写)
|
||||
private val monitoredAddresses = ConcurrentHashMap<String, Long>() // address -> leaderId
|
||||
|
||||
/**
|
||||
* 启动监听
|
||||
*/
|
||||
fun start(leaders: List<Leader>) {
|
||||
monitoredAddresses.clear()
|
||||
leaders.forEach { leader ->
|
||||
if (leader.id != null) {
|
||||
monitoredAddresses[leader.leaderAddress.lowercase()] = leader.id!!
|
||||
}
|
||||
}
|
||||
|
||||
if (monitoredAddresses.isEmpty()) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
connectAndSubscribe()
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加 Leader
|
||||
*/
|
||||
fun addLeader(leader: Leader) {
|
||||
if (leader.id == null) return
|
||||
monitoredAddresses[leader.leaderAddress.lowercase()] = leader.id!!
|
||||
|
||||
// 如果 WebSocket 未连接,连接
|
||||
if (wsClient == null) {
|
||||
connectAndSubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除 Leader
|
||||
*/
|
||||
fun removeLeader(leaderId: Long) {
|
||||
val addressToRemove = monitoredAddresses.entries
|
||||
.find { it.value == leaderId }?.key
|
||||
addressToRemove?.let { monitoredAddresses.remove(it) }
|
||||
|
||||
// 如果没有 Leader 了,停止监听
|
||||
if (monitoredAddresses.isEmpty()) {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接并订阅
|
||||
*/
|
||||
private fun connectAndSubscribe() {
|
||||
if (wsClient != null && wsClient!!.isConnected()) {
|
||||
return
|
||||
}
|
||||
|
||||
wsClient = PolymarketWebSocketClient(
|
||||
url = "wss://ws-live-data.polymarket.com",
|
||||
sessionId = "copy-trading-activity",
|
||||
onMessage = { message -> handleMessage(message) },
|
||||
onOpen = {
|
||||
subscribeAllActivity()
|
||||
},
|
||||
onReconnect = {
|
||||
subscribeAllActivity()
|
||||
}
|
||||
)
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
wsClient!!.connect()
|
||||
} catch (e: Exception) {
|
||||
logger.error("连接 Activity WebSocket 失败", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅全局 activity
|
||||
*/
|
||||
private fun subscribeAllActivity() {
|
||||
val message = """
|
||||
{
|
||||
"type": "subscribe",
|
||||
"channel": "activity",
|
||||
"topic": "trades"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
wsClient?.sendMessage(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理消息
|
||||
*/
|
||||
private fun handleMessage(message: String) {
|
||||
try {
|
||||
if (message.trim() == "PONG") return
|
||||
|
||||
val json = JsonParser.parseString(message).asJsonObject
|
||||
|
||||
// 检查是否是 trade 事件
|
||||
val topic = json.get("topic")?.asString
|
||||
val type = json.get("type")?.asString
|
||||
if (topic != "activity" || type != "trades") {
|
||||
return
|
||||
}
|
||||
|
||||
val payload = json.getAsJsonObject("payload") ?: return
|
||||
|
||||
// 提取交易者地址
|
||||
val traderObj = payload.getAsJsonObject("trader")
|
||||
val traderAddress = traderObj?.get("address")?.asString?.lowercase()
|
||||
?: payload.get("proxyWallet")?.asString?.lowercase()
|
||||
?: return
|
||||
|
||||
// 检查是否是我们要监听的 Leader
|
||||
val leaderId = monitoredAddresses[traderAddress] ?: return
|
||||
|
||||
// 解析交易数据
|
||||
val trade = parseActivityTrade(payload, leaderId)
|
||||
if (trade != null) {
|
||||
scope.launch {
|
||||
copyOrderTrackingService.processTrade(
|
||||
leaderId = leaderId,
|
||||
trade = trade,
|
||||
source = "activity-ws"
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("处理 Activity WebSocket 消息失败", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Activity Trade 为 TradeResponse
|
||||
*/
|
||||
private fun parseActivityTrade(json: JsonObject, leaderId: Long): TradeResponse? {
|
||||
return try {
|
||||
TradeResponse(
|
||||
id = json.get("transactionHash")?.asString
|
||||
?: "${leaderId}_${System.currentTimeMillis()}", // fallback
|
||||
market = json.get("conditionId")?.asString ?: return null,
|
||||
side = json.get("side")?.asString?.uppercase() ?: return null,
|
||||
price = json.get("price")?.asString ?: return null,
|
||||
size = json.get("size")?.asString ?: return null,
|
||||
timestamp = (json.get("timestamp")?.asLong ?: System.currentTimeMillis()).toString(),
|
||||
user = null, // Activity WS 中不需要
|
||||
outcomeIndex = parseOutcomeIndex(json.get("outcome")?.asString),
|
||||
outcome = json.get("outcome")?.asString
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("解析 Activity Trade 失败", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseOutcomeIndex(outcome: String?): Int? {
|
||||
return when (outcome?.uppercase()) {
|
||||
"YES", "UP", "TRUE" -> 0
|
||||
"NO", "DOWN", "FALSE" -> 1
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
wsClient?.closeConnection()
|
||||
wsClient = null
|
||||
monitoredAddresses.clear()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 修改 CopyTradingMonitorService
|
||||
|
||||
```kotlin
|
||||
@Service
|
||||
class CopyTradingMonitorService(
|
||||
private val copyTradingRepository: CopyTradingRepository,
|
||||
private val leaderRepository: LeaderRepository,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val onChainWsService: OnChainWsService,
|
||||
private val accountOnChainMonitorService: AccountOnChainMonitorService,
|
||||
private val activityWsService: PolymarketActivityWsService // 新增
|
||||
) {
|
||||
|
||||
suspend fun startMonitoring() {
|
||||
val enabledCopyTradings = copyTradingRepository.findByEnabledTrue()
|
||||
if (enabledCopyTradings.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val leaderIds = enabledCopyTradings.map { it.leaderId }.distinct()
|
||||
val leaders = leaderIds.mapNotNull { leaderId ->
|
||||
leaderRepository.findById(leaderId).orElse(null)
|
||||
}
|
||||
|
||||
val accountIds = enabledCopyTradings.map { it.accountId }.distinct()
|
||||
val accounts = accountIds.mapNotNull { accountId ->
|
||||
accountRepository.findById(accountId).orElse(null)
|
||||
}
|
||||
|
||||
// 1. 启动 Activity WebSocket 监听(优先,低延迟)
|
||||
activityWsService.start(leaders)
|
||||
|
||||
// 2. 启动链上 WebSocket 监听(兜底,高可靠性)
|
||||
onChainWsService.start(leaders)
|
||||
|
||||
// 3. 启动账户监听
|
||||
accountOnChainMonitorService.start(accounts)
|
||||
}
|
||||
|
||||
suspend fun addLeaderMonitoring(leaderId: Long) {
|
||||
val leader = leaderRepository.findById(leaderId).orElse(null) ?: return
|
||||
|
||||
val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId)
|
||||
if (copyTradings.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
// 同时添加到两种监听
|
||||
activityWsService.addLeader(leader)
|
||||
onChainWsService.addLeader(leader)
|
||||
}
|
||||
|
||||
suspend fun removeLeaderMonitoring(leaderId: Long) {
|
||||
val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId)
|
||||
if (copyTradings.isNotEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
// 同时从两种监听移除
|
||||
activityWsService.removeLeader(leaderId)
|
||||
onChainWsService.removeLeader(leaderId)
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
fun destroy() {
|
||||
scope.cancel()
|
||||
activityWsService.stop()
|
||||
onChainWsService.stop()
|
||||
accountOnChainMonitorService.stop()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 WebSocket 客户端复用
|
||||
|
||||
**检查现有 PolymarketWebSocketClient**:
|
||||
- 如果已存在,检查是否支持 Activity 订阅
|
||||
- 如果不存在,需要创建或引入依赖
|
||||
|
||||
**依赖选项**:
|
||||
1. **使用现有实现**:检查 `PolymarketWebSocketClient` 是否支持 Activity
|
||||
2. **引入 poly-sdk 的方式**:参考 poly-sdk 的 `RealTimeDataClient`
|
||||
3. **自行实现**:基于 OkHttp WebSocket 或 Java-WebSocket
|
||||
|
||||
## 4. 数据流
|
||||
|
||||
### 4.1 Activity WS 数据流
|
||||
|
||||
```
|
||||
Polymarket 服务器
|
||||
↓ (WebSocket推送 activity trade)
|
||||
PolymarketActivityWsService.handleMessage()
|
||||
↓ (提取 trader.address)
|
||||
客户端过滤(monitoredAddresses.contains(traderAddress))
|
||||
↓ (匹配到 Leader)
|
||||
parseActivityTrade() → TradeResponse
|
||||
↓
|
||||
CopyOrderTrackingService.processTrade(
|
||||
leaderId, trade, source="activity-ws"
|
||||
)
|
||||
↓ (去重检查)
|
||||
如果未处理 → 执行跟单逻辑
|
||||
如果已处理 → 跳过(可能是 OnChain WS 先到达)
|
||||
```
|
||||
|
||||
### 4.2 OnChain WS 数据流(保持不变)
|
||||
|
||||
```
|
||||
链上 RPC WebSocket
|
||||
↓ (推送 Transfer 事件)
|
||||
UnifiedOnChainWsService
|
||||
↓
|
||||
OnChainWsService.handleLeaderTransaction()
|
||||
↓ (解析 Transfer → Trade)
|
||||
CopyOrderTrackingService.processTrade(
|
||||
leaderId, trade, source="onchain-ws"
|
||||
)
|
||||
↓ (去重检查)
|
||||
如果未处理 → 执行跟单逻辑
|
||||
如果已处理 → 跳过(可能是 Activity WS 先到达)
|
||||
```
|
||||
|
||||
## 5. 配置项
|
||||
|
||||
### 5.1 application.properties
|
||||
|
||||
```properties
|
||||
# Polymarket WebSocket
|
||||
polymarket.websocket.url=wss://ws-live-data.polymarket.com
|
||||
|
||||
# 监听策略
|
||||
copy.trading.monitor.strategy=dual
|
||||
# dual: 双重监听(Activity WS + OnChain WS)
|
||||
# activity-only: 仅 Activity WS
|
||||
# onchain-only: 仅 OnChain WS
|
||||
|
||||
# Activity WS 配置
|
||||
copy.trading.activity.ws.enabled=true
|
||||
copy.trading.activity.ws.reconnect.delay=3000
|
||||
|
||||
# OnChain WS 配置(已存在)
|
||||
copy.trading.onchain.ws.reconnect.delay=3000
|
||||
```
|
||||
|
||||
## 6. 监控和日志
|
||||
|
||||
### 6.1 关键指标
|
||||
|
||||
- **Activity WS 连接状态**
|
||||
- **OnChain WS 连接状态**
|
||||
- **每种源的交易检测数量**
|
||||
- **去重次数(重复检测)**
|
||||
- **处理延迟(Activity WS vs OnChain WS)**
|
||||
|
||||
### 6.2 日志示例
|
||||
|
||||
```
|
||||
[INFO] Activity WS 连接成功
|
||||
[INFO] Activity WS 订阅全局 activity 成功
|
||||
[INFO] 检测到交易: leaderId=1, address=0x1234..., source=activity-ws, latency=85ms
|
||||
[INFO] 检测到交易: leaderId=1, address=0x1234..., source=onchain-ws, latency=2150ms
|
||||
[WARN] 交易已处理(去重): leaderId=1, tradeId=xxx, firstSource=activity-ws, duplicateSource=onchain-ws
|
||||
```
|
||||
|
||||
## 7. 实施步骤
|
||||
|
||||
### Phase 1: 基础实现
|
||||
1. ✅ 创建 `PolymarketActivityWsService`
|
||||
2. ✅ 实现 Activity WebSocket 连接和订阅
|
||||
3. ✅ 实现客户端地址过滤
|
||||
4. ✅ 实现交易解析和转换
|
||||
|
||||
### Phase 2: 集成
|
||||
5. ✅ 修改 `CopyTradingMonitorService` 集成 Activity WS
|
||||
6. ✅ 测试双重监听和去重机制
|
||||
7. ✅ 添加配置项支持
|
||||
|
||||
### Phase 3: 优化
|
||||
8. ✅ 添加监控指标
|
||||
9. ✅ 性能优化(连接池、消息批处理等)
|
||||
10. ✅ 错误处理和降级策略
|
||||
|
||||
### Phase 4: 验证
|
||||
11. ✅ 端到端测试
|
||||
12. ✅ 压力测试
|
||||
13. ✅ 生产环境灰度验证
|
||||
|
||||
## 8. 风险与缓解
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|----------|
|
||||
| Activity WS 服务不稳定 | 高 | OnChain WS 作为兜底 |
|
||||
| 消息重复处理 | 中 | 数据库去重 + Mutex 锁 |
|
||||
| 连接断开频繁 | 中 | 自动重连 + 指数退避 |
|
||||
| 地址过滤性能 | 低 | 使用 ConcurrentHashMap,O(1) 查询 |
|
||||
|
||||
## 9. 未来优化
|
||||
|
||||
1. **消息批处理**:将多个交易合并处理,减少数据库写入
|
||||
2. **智能切换**:根据 Activity WS 稳定性自动降级到 OnChain
|
||||
3. **延迟监控**:实时监控两种源的延迟,选择最优源
|
||||
4. **缓存优化**:缓存 Leader 地址列表,减少查询
|
||||
|
||||
@@ -1,999 +0,0 @@
|
||||
# NBA 量化交易系统技术方案
|
||||
|
||||
## 一、概述
|
||||
|
||||
### 1.1 产品定位
|
||||
|
||||
本系统是一个基于 NBA 比赛数据的量化交易系统,用户在前端配置量化策略参数,系统后台实时获取 NBA 比赛数据,根据配置的策略参数进行量化分析,自动生成买入/卖出信号,并通过 WebSocket 实时推送给前端展示。
|
||||
|
||||
### 1.2 产品流程
|
||||
|
||||
```
|
||||
用户在前端配置策略参数
|
||||
↓
|
||||
后台保存配置并启动量化任务
|
||||
↓
|
||||
后台实时获取 NBA 比赛数据
|
||||
↓
|
||||
后台执行量化分析逻辑
|
||||
↓
|
||||
生成买入/卖出信号
|
||||
↓
|
||||
通过 WebSocket 推送给前端
|
||||
↓
|
||||
前端展示交易信号和结果
|
||||
```
|
||||
|
||||
### 1.3 核心功能
|
||||
|
||||
1. **策略配置管理**:用户在前端配置量化策略参数(如触发条件、买入卖出规则、风险控制参数等)
|
||||
2. **数据获取**:后台实时获取 NBA 比赛数据、球队统计、球员统计等
|
||||
3. **量化分析**:根据配置的策略参数和实时数据,执行量化分析逻辑
|
||||
4. **信号生成**:生成买入/卖出信号,包含市场、方向、价格、数量等信息
|
||||
5. **实时推送**:通过 WebSocket 将交易信号实时推送给前端
|
||||
6. **结果展示**:前端展示交易信号、执行结果、统计数据等
|
||||
|
||||
### 1.4 技术栈
|
||||
|
||||
- **后端框架**: Spring Boot 3.2.0 + Kotlin
|
||||
- **HTTP 客户端**: Retrofit 2.9.0 + OkHttp 4.12.0
|
||||
- **数据存储**: MySQL 8.2.0(结构化数据)+ Redis(缓存)
|
||||
- **爬虫框架**: Jsoup(用于 Basketball Reference)
|
||||
- **任务调度**: Spring Scheduler(定时任务)
|
||||
- **WebSocket**: Spring WebSocket(实时推送)
|
||||
|
||||
---
|
||||
|
||||
## 二、系统架构设计
|
||||
|
||||
### 2.1 整体架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 前端层 (Frontend Layer) │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ 策略配置页面 │ │ 交易信号展示 │ │ 结果统计页面 │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│ HTTP API / WebSocket
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 应用层 (Application Layer) │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ 策略配置API │ │ 量化任务API │ │ WebSocket │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 服务层 (Service Layer) │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ 策略配置服务 │ │ 量化分析服务 │ │ 数据推送服务 │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ NBA数据服务 │ │ 信号生成服务 │ │ 任务调度服务 │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 数据层 (Data Layer) │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ NBA API │ │ 数据存储 │ │ 缓存服务 │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.2 模块划分
|
||||
|
||||
#### 2.2.1 前端模块
|
||||
|
||||
- **策略配置模块**:用户配置量化策略参数
|
||||
- **交易信号展示模块**:实时展示买入/卖出信号
|
||||
- **结果统计模块**:展示交易结果和统计数据
|
||||
|
||||
#### 2.2.2 后端模块
|
||||
|
||||
- **策略配置管理模块**:管理用户配置的策略参数
|
||||
- **NBA 数据获取模块**:从 NBA Stats API 和 Basketball Reference 获取数据
|
||||
- **量化分析模块**:执行量化分析逻辑,生成交易信号
|
||||
- **信号推送模块**:通过 WebSocket 推送交易信号
|
||||
- **任务调度模块**:管理量化任务的启动、停止、调度
|
||||
|
||||
---
|
||||
|
||||
## 三、产品功能设计
|
||||
|
||||
### 3.1 策略配置功能
|
||||
|
||||
#### 3.1.1 配置参数
|
||||
|
||||
用户在前端可以配置以下参数:
|
||||
|
||||
**基础配置**:
|
||||
- 策略名称:用户自定义策略名称
|
||||
- 启用状态:是否启用该策略
|
||||
- 关联账户:选择用于交易的账户
|
||||
|
||||
**触发条件配置**:
|
||||
- 比赛筛选:选择关注的比赛(可按球队、日期、重要性等筛选)
|
||||
- 数据指标:选择用于分析的指标(如分差、剩余时间、球队实力等)
|
||||
- 触发阈值:设置触发买入/卖出的阈值条件
|
||||
|
||||
**交易规则配置**:
|
||||
- 买入规则:配置买入条件、买入金额、买入时机等
|
||||
- 卖出规则:配置卖出条件、卖出金额、卖出时机等
|
||||
- 价格策略:配置价格计算方式(如固定价格、动态价格等)
|
||||
|
||||
**风险控制配置**:
|
||||
- 最大持仓:单次最大买入金额
|
||||
- 最小持仓:单次最小买入金额
|
||||
- 每日亏损限制:每日最大亏损金额
|
||||
- 每日订单限制:每日最大订单数量
|
||||
- 价格容忍度:允许的价格偏差范围
|
||||
|
||||
**高级配置**:
|
||||
- 数据更新频率:NBA 数据更新频率(如 30 秒、1 分钟等)
|
||||
- 分析频率:量化分析执行频率
|
||||
- 推送设置:是否推送失败订单、推送频率等
|
||||
|
||||
#### 3.1.2 配置管理
|
||||
|
||||
- **创建策略**:用户在前端创建新的量化策略配置
|
||||
- **编辑策略**:用户可以修改已有策略的配置参数
|
||||
- **删除策略**:用户可以删除不需要的策略
|
||||
- **启用/禁用策略**:用户可以启用或禁用策略,禁用后停止执行量化分析
|
||||
- **策略列表**:展示所有策略配置,支持搜索、筛选、排序
|
||||
|
||||
### 3.2 量化分析功能
|
||||
|
||||
#### 3.2.1 数据获取
|
||||
|
||||
系统后台实时获取以下数据:
|
||||
|
||||
**比赛数据**:
|
||||
- 实时比分和分差
|
||||
- 比赛状态(未开始/进行中/已结束)
|
||||
- 当前节次和剩余时间
|
||||
- Play-by-Play 数据(每个回合的详细记录)
|
||||
|
||||
**统计数据**:
|
||||
- 球队统计数据(进攻效率、防守效率、净效率值等)
|
||||
- 球员统计数据(得分、篮板、助攻、真实命中率等)
|
||||
- 高级统计数据(PER、BPM、VORP、DRPM 等)
|
||||
|
||||
**历史数据**:
|
||||
- 历史比赛数据
|
||||
- 历史对战记录
|
||||
- 历史统计数据
|
||||
|
||||
#### 3.2.2 量化分析逻辑
|
||||
|
||||
系统根据用户配置的策略参数和实时获取的 NBA 数据,执行量化分析:
|
||||
|
||||
**核心思想**:
|
||||
- 不是简单的条件判断(如"主队落后买入")
|
||||
- 而是综合分析两支队伍的实力、状态、对位关系等因素
|
||||
- 计算每支队伍的综合评分和获胜概率
|
||||
- 评估交易价值和风险
|
||||
- 选择最优的交易方向和时机
|
||||
|
||||
**数据预处理**:
|
||||
- 数据清洗和验证
|
||||
- 数据标准化和归一化
|
||||
- 特征工程(提取关键特征)
|
||||
|
||||
**综合评分计算**:
|
||||
- 计算主队和客队的基础实力评分(净效率值、攻防效率、节奏等)
|
||||
- 计算近期状态评分(近期胜率、净效率值变化、势头等)
|
||||
- 计算阵容完整度评分(基于缺失球员的VORP)
|
||||
- 计算球星状态评分(PER、TS%、健康状况等)
|
||||
- 计算环境因素评分(主客场、休息天数、背靠背等)
|
||||
- 综合计算主队和客队的综合评分
|
||||
|
||||
**对位分析**:
|
||||
- 分析球星对位优势(防守限制效果、历史对位数据)
|
||||
- 分析阵容克制关系(内线、外线、快攻优势)
|
||||
- 计算对位综合评分
|
||||
|
||||
**实时状态分析**(比赛进行中):
|
||||
- 分析当前分差和剩余时间
|
||||
- 分析势头动量(过去N回合的净胜分)
|
||||
- 实时调整综合评分
|
||||
|
||||
**获胜概率计算**:
|
||||
- 基于综合评分计算基础获胜概率
|
||||
- 根据对位优势调整概率
|
||||
- 根据实时状态调整概率(比赛进行中)
|
||||
- 最终得到主队和客队的获胜概率
|
||||
|
||||
**交易价值计算**:
|
||||
- 计算预期收益(基于获胜概率和价格)
|
||||
- 计算风险调整收益(考虑不确定性、价格波动、流动性)
|
||||
- 计算交易价值评分
|
||||
|
||||
**策略执行**:
|
||||
- 根据配置的触发条件(最小获胜概率差异、最小交易价值等),判断是否满足买入/卖出条件
|
||||
- 根据配置的交易规则,计算买入/卖出价格和数量
|
||||
- 根据配置的风险控制参数,验证交易是否合规
|
||||
|
||||
**信号生成**:
|
||||
- 生成买入信号:包含市场 ID、方向(YES/NO,由系统自动判断)、价格、数量、原因等
|
||||
- 生成卖出信号:包含市场 ID、方向、价格、数量、原因等
|
||||
- 信号验证:验证信号的有效性和合规性
|
||||
|
||||
### 3.3 信号推送功能
|
||||
|
||||
#### 3.3.1 推送方式
|
||||
|
||||
系统通过 WebSocket 实时推送交易信号给前端:
|
||||
|
||||
**推送内容**:
|
||||
- 买入信号:市场信息、方向、价格、数量、触发原因、时间戳等
|
||||
- 卖出信号:市场信息、方向、价格、数量、触发原因、时间戳等
|
||||
- 信号状态:信号生成、信号执行中、信号执行成功、信号执行失败等
|
||||
|
||||
**推送频率**:
|
||||
- 实时推送:信号生成后立即推送
|
||||
- 批量推送:多个信号可以批量推送(减少网络开销)
|
||||
|
||||
#### 3.3.2 订阅管理
|
||||
|
||||
前端通过 WebSocket 订阅交易信号:
|
||||
|
||||
**订阅方式**:
|
||||
- 订阅所有策略的信号
|
||||
- 订阅特定策略的信号
|
||||
- 订阅特定市场的信号
|
||||
|
||||
**订阅管理**:
|
||||
- 前端可以动态订阅/取消订阅
|
||||
- 支持多个前端客户端同时订阅
|
||||
- 连接断开后自动重连和恢复订阅
|
||||
|
||||
### 3.4 结果展示功能
|
||||
|
||||
#### 3.4.1 实时信号展示
|
||||
|
||||
前端实时展示交易信号:
|
||||
|
||||
**信号列表**:
|
||||
- 展示所有生成的交易信号
|
||||
- 支持按策略、市场、时间等筛选
|
||||
- 支持按时间、价格等排序
|
||||
|
||||
**信号详情**:
|
||||
- 展示信号的详细信息(市场、方向、价格、数量、原因等)
|
||||
- 展示信号的执行状态和结果
|
||||
- 展示信号的历史记录
|
||||
|
||||
#### 3.4.2 统计展示
|
||||
|
||||
前端展示交易统计:
|
||||
|
||||
**策略统计**:
|
||||
- 每个策略的信号数量
|
||||
- 每个策略的成功率
|
||||
- 每个策略的盈亏情况
|
||||
|
||||
**总体统计**:
|
||||
- 总信号数量
|
||||
- 总成功率
|
||||
- 总盈亏情况
|
||||
- 每日/每周/每月统计
|
||||
|
||||
---
|
||||
|
||||
## 四、数据源集成方案
|
||||
|
||||
### 4.1 NBA Stats API 集成
|
||||
|
||||
#### 4.1.1 API 基础信息
|
||||
|
||||
- **Base URL**: `https://stats.nba.com/stats/`
|
||||
- **认证**: 无需认证,但需要设置正确的请求头
|
||||
- **请求限制**: 建议 < 10 请求/秒
|
||||
- **数据格式**: JSON
|
||||
|
||||
#### 4.1.2 请求头配置
|
||||
|
||||
需要设置以下请求头:
|
||||
- User-Agent: 浏览器标识
|
||||
- Referer: 来源页面
|
||||
- Accept: 接受 JSON 格式
|
||||
- Accept-Language: 语言设置
|
||||
|
||||
#### 4.1.3 主要接口
|
||||
|
||||
**比赛数据接口**:
|
||||
- 赛程和比分接口:获取指定日期的所有比赛
|
||||
- 比赛统计接口:获取比赛的详细统计数据
|
||||
- Play-by-Play 接口:获取比赛的回合数据
|
||||
|
||||
**统计数据接口**:
|
||||
- 球队统计面板接口:获取球队的统计数据
|
||||
- 球队关键时刻数据接口:获取球队关键时刻的表现
|
||||
- 球员统计面板接口:获取球员的统计数据
|
||||
- 球员单场数据接口:获取球员的单场数据
|
||||
|
||||
#### 4.1.4 数据获取策略
|
||||
|
||||
**实时数据获取**:
|
||||
- 比赛进行时:每 30 秒轮询一次比赛数据
|
||||
- 比赛未开始:每 5 分钟轮询一次(检查比赛状态)
|
||||
- 比赛结束:每 10 分钟轮询一次(确保数据完整)
|
||||
|
||||
**统计数据获取**:
|
||||
- 每天更新一次球队和球员统计数据
|
||||
- 比赛结束后立即更新相关统计数据
|
||||
|
||||
### 4.2 Basketball Reference 爬虫集成
|
||||
|
||||
#### 4.2.1 数据来源
|
||||
|
||||
- **Base URL**: `https://www.basketball-reference.com`
|
||||
- **数据格式**: HTML 页面
|
||||
- **优势**: 提供高级统计数据(PER、BPM、VORP、DRPM 等)
|
||||
|
||||
#### 4.2.2 爬取策略
|
||||
|
||||
**请求频率控制**:
|
||||
- 请求间隔:至少 1 秒(避免反爬虫)
|
||||
- 请求队列:使用队列管理请求,控制并发数
|
||||
|
||||
**数据爬取**:
|
||||
- 球员高级统计数据:从球员页面爬取 PER、BPM、VORP、DRPM 等
|
||||
- 球队高级统计数据:从球队页面爬取相关数据
|
||||
- 阵容统计数据:从阵容页面爬取阵容组合数据
|
||||
|
||||
**数据缓存**:
|
||||
- 爬取的数据存储到数据库,避免重复请求
|
||||
- 高级统计数据更新频率较低,可以缓存较长时间
|
||||
|
||||
### 4.3 数据存储方案
|
||||
|
||||
#### 4.3.1 数据库设计
|
||||
|
||||
**策略配置表**:
|
||||
- 存储用户配置的策略参数
|
||||
- 包含策略名称、配置参数、启用状态等字段
|
||||
|
||||
**比赛数据表**:
|
||||
- 存储比赛基本信息(比赛 ID、日期、主客场、比分等)
|
||||
- 存储比赛状态(未开始/进行中/已结束)
|
||||
- 存储当前节次和剩余时间
|
||||
|
||||
**统计数据表**:
|
||||
- 球队统计表:存储球队的统计数据
|
||||
- 球员统计表:存储球员的统计数据
|
||||
- Play-by-Play 表:存储比赛的回合数据
|
||||
|
||||
**交易信号表**:
|
||||
- 存储生成的交易信号
|
||||
- 包含市场信息、方向、价格、数量、状态等字段
|
||||
- 包含信号的执行结果和统计信息
|
||||
|
||||
#### 4.3.2 缓存策略
|
||||
|
||||
**Redis 缓存**:
|
||||
- 比赛数据缓存:缓存 5 分钟(实时数据)
|
||||
- 统计数据缓存:缓存 1 小时(统计数据更新较慢)
|
||||
- 高级统计缓存:缓存 24 小时(Basketball Reference 更新较慢)
|
||||
|
||||
**缓存更新**:
|
||||
- 数据更新时同步更新缓存
|
||||
- 缓存过期后自动从数据库或 API 重新获取
|
||||
|
||||
---
|
||||
|
||||
## 五、实时性保证策略
|
||||
|
||||
### 5.1 数据实时性要求
|
||||
|
||||
不同数据类型的实时性要求:
|
||||
|
||||
| 数据类型 | 实时性要求 | 更新频率 | 延迟容忍度 |
|
||||
|---------|-----------|---------|-----------|
|
||||
| **比赛比分** | 极高 | 30秒 | < 1分钟 |
|
||||
| **Play-by-Play** | 极高 | 30秒 | < 1分钟 |
|
||||
| **比赛状态** | 高 | 30秒 | < 2分钟 |
|
||||
| **统计数据** | 中 | 5分钟 | < 10分钟 |
|
||||
| **历史数据** | 低 | 每天 | 无要求 |
|
||||
|
||||
### 5.2 实时性保证方案
|
||||
|
||||
#### 5.2.1 多层级数据更新策略
|
||||
|
||||
**第一层:数据源层**
|
||||
- HTTP 轮询:从 NBA Stats API 轮询获取数据
|
||||
- 第三方 WebSocket API:如果可用,使用 WebSocket 实时接收数据
|
||||
- 数据缓存:使用 Redis 缓存数据,减少 API 调用
|
||||
|
||||
**第二层:数据处理层**
|
||||
- 数据变化检测:只处理变化的数据,减少不必要的处理
|
||||
- 数据验证:验证数据的有效性和完整性
|
||||
- 增量更新:只更新变化的数据,提高效率
|
||||
|
||||
**第三层:数据推送层**
|
||||
- WebSocket 推送:通过 WebSocket 实时推送数据给前端
|
||||
- 订阅管理:管理前端的订阅,只推送订阅的数据
|
||||
- 批量推送:多个数据可以批量推送,减少网络开销
|
||||
|
||||
**第四层:前端展示层**
|
||||
- 实时更新:前端实时接收和展示数据
|
||||
- 降级轮询:WebSocket 失败时降级到 HTTP 轮询
|
||||
- 数据缓存:前端缓存数据,减少重复请求
|
||||
|
||||
#### 5.2.2 数据变化检测机制
|
||||
|
||||
**数据快照比较**:
|
||||
- 保存上次的数据快照
|
||||
- 比较新数据和快照,只处理变化的数据
|
||||
- 更新快照,用于下次比较
|
||||
|
||||
**增量更新**:
|
||||
- 只保存和推送新增或变化的数据
|
||||
- 减少数据库写入和网络传输
|
||||
- 提高系统性能
|
||||
|
||||
#### 5.2.3 动态轮询频率调整
|
||||
|
||||
根据比赛状态动态调整轮询频率:
|
||||
|
||||
- **比赛未开始**:5 分钟轮询一次
|
||||
- **比赛进行中**:30 秒轮询一次
|
||||
- **最后 5 分钟**:15 秒轮询一次(关键时刻)
|
||||
- **加时赛**:20 秒轮询一次
|
||||
- **比赛结束**:10 分钟轮询一次(确保数据完整)
|
||||
|
||||
#### 5.2.4 WebSocket 连接管理
|
||||
|
||||
**连接保活**:
|
||||
- 定期发送心跳消息,保持连接活跃
|
||||
- 检测连接状态,及时处理断开情况
|
||||
|
||||
**自动重连**:
|
||||
- 连接断开后自动重连
|
||||
- 使用指数退避策略,避免频繁重连
|
||||
- 重连后恢复订阅,确保数据不丢失
|
||||
|
||||
**连接监控**:
|
||||
- 监控连接健康状态
|
||||
- 记录连接统计信息
|
||||
- 异常情况告警
|
||||
|
||||
#### 5.2.5 前端降级策略
|
||||
|
||||
**WebSocket 失败降级**:
|
||||
- WebSocket 连接失败时,自动降级到 HTTP 轮询
|
||||
- 前端定期轮询获取数据,确保数据不中断
|
||||
- WebSocket 恢复后,自动切换回 WebSocket
|
||||
|
||||
**数据缓存**:
|
||||
- 前端缓存最近的数据
|
||||
- 网络中断时,使用缓存数据展示
|
||||
- 网络恢复后,同步最新数据
|
||||
|
||||
### 5.3 实时性监控
|
||||
|
||||
#### 5.3.1 数据延迟监控
|
||||
|
||||
**延迟统计**:
|
||||
- 记录数据从获取到推送的延迟时间
|
||||
- 统计平均延迟、最大延迟等指标
|
||||
- 延迟超过阈值时触发告警
|
||||
|
||||
**性能指标**:
|
||||
- API 调用响应时间
|
||||
- 数据处理时间
|
||||
- WebSocket 推送延迟
|
||||
|
||||
#### 5.3.2 告警机制
|
||||
|
||||
**告警条件**:
|
||||
- 数据延迟超过阈值(如 1 分钟)
|
||||
- API 调用失败率超过阈值
|
||||
- WebSocket 连接断开
|
||||
- 数据更新异常
|
||||
|
||||
**告警方式**:
|
||||
- 日志记录
|
||||
- 系统通知
|
||||
- 邮件/短信通知(可选)
|
||||
|
||||
---
|
||||
|
||||
## 六、量化分析逻辑设计
|
||||
|
||||
### 6.1 分析流程
|
||||
|
||||
#### 6.1.1 数据准备
|
||||
|
||||
**数据获取**:
|
||||
- 获取实时比赛数据(比分、分差、剩余时间等)
|
||||
- 获取统计数据(球队统计、球员统计等)
|
||||
- 获取历史数据(历史比赛、历史统计等)
|
||||
|
||||
**数据预处理**:
|
||||
- 数据清洗:去除异常值和缺失值
|
||||
- 数据验证:验证数据的有效性和完整性
|
||||
- 数据标准化:将数据标准化为统一的格式
|
||||
|
||||
#### 6.1.2 特征提取
|
||||
|
||||
**基础特征**:
|
||||
- 当前分差
|
||||
- 剩余时间
|
||||
- 当前节次
|
||||
- 比赛状态
|
||||
|
||||
**统计特征**:
|
||||
- 球队实力指标(净效率值、攻防效率等)
|
||||
- 球员表现指标(PER、TS%、USG% 等)
|
||||
- 历史对战数据
|
||||
|
||||
**衍生特征**:
|
||||
- 分差/剩余时间比
|
||||
- 球队实力差
|
||||
- 势头动量(过去 N 回合的净胜分)
|
||||
- 球星爆发因子(本场表现 vs 赛季平均)
|
||||
|
||||
#### 6.1.3 策略执行
|
||||
|
||||
**综合评分和对位分析**:
|
||||
- 计算主队和客队的综合评分(基础实力、近期状态、阵容完整度、球星状态、环境因素)
|
||||
- 分析对位优势(球星对位、阵容克制)
|
||||
- 计算实时状态调整(分差、势头动量,仅比赛进行中)
|
||||
|
||||
**获胜概率和交易价值计算**:
|
||||
- 基于综合评分计算基础获胜概率
|
||||
- 根据对位优势和实时状态调整概率
|
||||
- 计算预期收益和风险调整收益
|
||||
- 计算交易价值评分
|
||||
|
||||
**触发条件判断**:
|
||||
- 判断获胜概率差异是否达到阈值(如主队获胜概率 > 0.55 或 < 0.45)
|
||||
- 判断交易价值评分是否达到阈值(如 > 0.05)
|
||||
- 判断其他触发条件(剩余时间、价格等)
|
||||
|
||||
**交易规则计算**:
|
||||
- 根据获胜概率确定买入方向(YES 或 NO)
|
||||
- 根据配置的交易规则,计算买入/卖出价格(固定价格/市场价格/动态价格)
|
||||
- 根据配置的交易规则,计算买入/卖出数量(固定金额/按比例/动态计算)
|
||||
- 根据配置的风险控制参数,验证交易是否合规
|
||||
|
||||
**信号生成**:
|
||||
- 生成买入信号:包含市场 ID、方向(由系统自动判断)、价格、数量、原因(包含综合评分、获胜概率、交易价值等)等
|
||||
- 生成卖出信号:包含市场 ID、方向、价格、数量、原因等
|
||||
- 信号验证:验证信号的有效性和合规性
|
||||
|
||||
### 6.2 策略配置映射
|
||||
|
||||
#### 6.2.1 配置参数到分析逻辑的映射
|
||||
|
||||
**触发条件配置**:
|
||||
- 比赛筛选 → 数据过滤逻辑
|
||||
- 数据指标 → 特征提取逻辑
|
||||
- 触发阈值 → 条件判断逻辑
|
||||
|
||||
**交易规则配置**:
|
||||
- 买入规则 → 买入信号生成逻辑
|
||||
- 卖出规则 → 卖出信号生成逻辑
|
||||
- 价格策略 → 价格计算逻辑
|
||||
|
||||
**风险控制配置**:
|
||||
- 最大/最小持仓 → 数量限制逻辑
|
||||
- 每日亏损限制 → 风险检查逻辑
|
||||
- 每日订单限制 → 订单计数逻辑
|
||||
- 价格容忍度 → 价格验证逻辑
|
||||
|
||||
### 6.3 信号生成规则
|
||||
|
||||
#### 6.3.1 买入信号生成
|
||||
|
||||
**触发条件**:
|
||||
- 满足配置的买入触发条件
|
||||
- 通过风险控制检查
|
||||
- 市场状态正常(可交易)
|
||||
|
||||
**信号内容**:
|
||||
- 市场 ID:目标市场的唯一标识
|
||||
- 方向:YES 或 NO
|
||||
- 价格:买入价格(根据价格策略计算)
|
||||
- 数量:买入数量(根据交易规则计算)
|
||||
- 原因:触发买入的原因(如"分差达到阈值")
|
||||
- 时间戳:信号生成时间
|
||||
|
||||
#### 6.3.2 卖出信号生成
|
||||
|
||||
**触发条件**:
|
||||
- 满足配置的卖出触发条件
|
||||
- 通过风险控制检查
|
||||
- 市场状态正常(可交易)
|
||||
|
||||
**信号内容**:
|
||||
- 市场 ID:目标市场的唯一标识
|
||||
- 方向:YES 或 NO
|
||||
- 价格:卖出价格(根据价格策略计算)
|
||||
- 数量:卖出数量(根据交易规则计算)
|
||||
- 原因:触发卖出的原因(如"达到止盈条件")
|
||||
- 时间戳:信号生成时间
|
||||
|
||||
---
|
||||
|
||||
## 七、WebSocket 推送方案
|
||||
|
||||
### 7.1 推送架构
|
||||
|
||||
#### 7.1.1 推送流程
|
||||
|
||||
```
|
||||
量化分析服务生成交易信号
|
||||
↓
|
||||
信号推送服务接收信号
|
||||
↓
|
||||
检查订阅关系(哪些前端订阅了该策略)
|
||||
↓
|
||||
通过 WebSocket 推送给订阅的前端
|
||||
↓
|
||||
前端接收并展示信号
|
||||
```
|
||||
|
||||
#### 7.1.2 订阅管理
|
||||
|
||||
**订阅方式**:
|
||||
- 前端通过 WebSocket 发送订阅消息
|
||||
- 支持订阅所有策略的信号
|
||||
- 支持订阅特定策略的信号
|
||||
- 支持订阅特定市场的信号
|
||||
|
||||
**订阅存储**:
|
||||
- 后端维护订阅关系(策略 ID → 前端会话列表)
|
||||
- 支持多个前端同时订阅同一策略
|
||||
- 支持前端动态订阅/取消订阅
|
||||
|
||||
**订阅恢复**:
|
||||
- 前端连接断开后,自动清理订阅关系
|
||||
- 前端重连后,可以重新订阅
|
||||
|
||||
### 7.2 推送消息格式
|
||||
|
||||
#### 7.2.1 买入信号消息
|
||||
|
||||
包含以下字段:
|
||||
- 消息类型:买入信号
|
||||
- 策略 ID:生成信号的策略标识
|
||||
- 市场 ID:目标市场标识
|
||||
- 方向:YES 或 NO
|
||||
- 价格:买入价格
|
||||
- 数量:买入数量
|
||||
- 原因:触发原因
|
||||
- 时间戳:信号生成时间
|
||||
|
||||
#### 7.2.2 卖出信号消息
|
||||
|
||||
包含以下字段:
|
||||
- 消息类型:卖出信号
|
||||
- 策略 ID:生成信号的策略标识
|
||||
- 市场 ID:目标市场标识
|
||||
- 方向:YES 或 NO
|
||||
- 价格:卖出价格
|
||||
- 数量:卖出数量
|
||||
- 原因:触发原因
|
||||
- 时间戳:信号生成时间
|
||||
|
||||
#### 7.2.3 信号状态更新消息
|
||||
|
||||
包含以下字段:
|
||||
- 消息类型:状态更新
|
||||
- 信号 ID:信号的唯一标识
|
||||
- 状态:执行中/执行成功/执行失败
|
||||
- 结果:执行结果详情
|
||||
- 时间戳:状态更新时间
|
||||
|
||||
### 7.3 推送优化
|
||||
|
||||
#### 7.3.1 批量推送
|
||||
|
||||
**批量策略**:
|
||||
- 多个信号可以批量推送,减少网络开销
|
||||
- 批量大小可配置(如每批 10 个信号)
|
||||
- 批量推送间隔可配置(如每 1 秒推送一次)
|
||||
|
||||
**批量格式**:
|
||||
- 单个消息包含多个信号
|
||||
- 前端解析后分别处理每个信号
|
||||
|
||||
#### 7.3.2 推送频率控制
|
||||
|
||||
**频率限制**:
|
||||
- 每个策略的信号推送频率可配置
|
||||
- 避免过于频繁的推送,影响前端性能
|
||||
- 支持优先级:重要信号立即推送,普通信号批量推送
|
||||
|
||||
#### 7.3.3 推送失败处理
|
||||
|
||||
**失败重试**:
|
||||
- 推送失败时,记录失败信息
|
||||
- 支持重试机制(如重试 3 次)
|
||||
- 重试失败后,记录到数据库,后续可以查询
|
||||
|
||||
**降级策略**:
|
||||
- WebSocket 推送失败时,可以降级到 HTTP 轮询
|
||||
- 前端定期轮询获取信号,确保不丢失
|
||||
|
||||
---
|
||||
|
||||
## 八、错误处理和容错机制
|
||||
|
||||
### 8.1 数据获取错误处理
|
||||
|
||||
#### 8.1.1 API 调用失败
|
||||
|
||||
**重试策略**:
|
||||
- API 调用失败时,自动重试(如重试 3 次)
|
||||
- 使用指数退避策略,避免频繁重试
|
||||
- 重试失败后,记录错误日志
|
||||
|
||||
**降级策略**:
|
||||
- API 调用失败时,使用缓存数据
|
||||
- 缓存数据过期时,使用数据库数据
|
||||
- 所有数据源都失败时,记录错误并告警
|
||||
|
||||
#### 8.1.2 数据解析错误
|
||||
|
||||
**错误处理**:
|
||||
- 数据格式异常时,记录错误日志
|
||||
- 跳过异常数据,继续处理其他数据
|
||||
- 数据缺失时,使用默认值或历史数据
|
||||
|
||||
### 8.2 量化分析错误处理
|
||||
|
||||
#### 8.2.1 分析逻辑错误
|
||||
|
||||
**错误捕获**:
|
||||
- 分析逻辑执行时,捕获所有异常
|
||||
- 记录错误日志,包含错误详情和上下文
|
||||
- 错误不影响其他策略的执行
|
||||
|
||||
**错误恢复**:
|
||||
- 错误发生后,跳过本次分析
|
||||
- 等待下次分析周期,重新执行
|
||||
- 连续错误时,触发告警
|
||||
|
||||
#### 8.2.2 信号生成错误
|
||||
|
||||
**错误处理**:
|
||||
- 信号生成失败时,记录错误日志
|
||||
- 不生成无效信号,避免误导用户
|
||||
- 错误原因推送给前端(可选)
|
||||
|
||||
### 8.3 推送错误处理
|
||||
|
||||
#### 8.3.1 WebSocket 推送失败
|
||||
|
||||
**失败处理**:
|
||||
- 推送失败时,记录失败信息
|
||||
- 支持重试机制(如重试 3 次)
|
||||
- 重试失败后,记录到数据库
|
||||
|
||||
**降级策略**:
|
||||
- WebSocket 推送失败时,可以降级到 HTTP 轮询
|
||||
- 前端定期轮询获取信号,确保不丢失
|
||||
|
||||
#### 8.3.2 前端连接断开
|
||||
|
||||
**连接管理**:
|
||||
- 检测前端连接断开,清理订阅关系
|
||||
- 前端重连后,可以重新订阅
|
||||
- 连接断开期间,信号暂存到数据库
|
||||
|
||||
---
|
||||
|
||||
## 九、性能优化方案
|
||||
|
||||
### 9.1 数据获取优化
|
||||
|
||||
#### 9.1.1 请求频率控制
|
||||
|
||||
**频率限制**:
|
||||
- 控制 API 请求频率,避免触发速率限制
|
||||
- 使用请求队列,管理并发请求
|
||||
- 请求失败时,使用指数退避策略
|
||||
|
||||
#### 9.1.2 数据缓存
|
||||
|
||||
**缓存策略**:
|
||||
- 实时数据缓存 5 分钟
|
||||
- 统计数据缓存 1 小时
|
||||
- 高级统计缓存 24 小时
|
||||
|
||||
**缓存更新**:
|
||||
- 数据更新时同步更新缓存
|
||||
- 缓存过期后自动刷新
|
||||
|
||||
### 9.2 量化分析优化
|
||||
|
||||
#### 9.2.1 增量分析
|
||||
|
||||
**变化检测**:
|
||||
- 只分析变化的数据,减少不必要的计算
|
||||
- 使用数据快照比较,检测数据变化
|
||||
- 只处理变化的数据,提高分析效率
|
||||
|
||||
#### 9.2.2 并行分析
|
||||
|
||||
**并行策略**:
|
||||
- 多个策略可以并行分析
|
||||
- 使用线程池管理分析任务
|
||||
- 避免资源竞争,确保分析准确性
|
||||
|
||||
### 9.3 推送优化
|
||||
|
||||
#### 9.3.1 批量推送
|
||||
|
||||
**批量策略**:
|
||||
- 多个信号批量推送,减少网络开销
|
||||
- 批量大小和间隔可配置
|
||||
- 重要信号可以立即推送
|
||||
|
||||
#### 9.3.2 推送频率控制
|
||||
|
||||
**频率限制**:
|
||||
- 控制推送频率,避免前端性能问题
|
||||
- 支持优先级:重要信号立即推送
|
||||
- 普通信号批量推送
|
||||
|
||||
---
|
||||
|
||||
## 十、监控和日志
|
||||
|
||||
### 10.1 系统监控
|
||||
|
||||
#### 10.1.1 性能监控
|
||||
|
||||
**监控指标**:
|
||||
- API 调用响应时间
|
||||
- 数据处理时间
|
||||
- WebSocket 推送延迟
|
||||
- 系统资源使用率(CPU、内存等)
|
||||
|
||||
**监控方式**:
|
||||
- 定期记录性能指标
|
||||
- 性能指标超过阈值时触发告警
|
||||
- 提供性能统计报表
|
||||
|
||||
#### 10.1.2 业务监控
|
||||
|
||||
**监控指标**:
|
||||
- 策略执行次数
|
||||
- 信号生成数量
|
||||
- 信号推送成功率
|
||||
- 数据更新延迟
|
||||
|
||||
**监控方式**:
|
||||
- 记录业务指标到数据库
|
||||
- 提供业务统计报表
|
||||
- 异常情况触发告警
|
||||
|
||||
### 10.2 日志管理
|
||||
|
||||
#### 10.2.1 日志级别
|
||||
|
||||
**日志分类**:
|
||||
- 错误日志:记录系统错误和异常
|
||||
- 警告日志:记录警告信息
|
||||
- 信息日志:记录关键操作信息
|
||||
- 调试日志:记录详细调试信息
|
||||
|
||||
#### 10.2.2 日志内容
|
||||
|
||||
**关键操作日志**:
|
||||
- 策略配置变更
|
||||
- 量化分析执行
|
||||
- 信号生成和推送
|
||||
- API 调用和错误
|
||||
|
||||
**日志格式**:
|
||||
- 统一日志格式,便于解析和分析
|
||||
- 包含时间戳、日志级别、模块、消息等字段
|
||||
- 支持结构化日志(JSON 格式)
|
||||
|
||||
---
|
||||
|
||||
## 十一、实施步骤
|
||||
|
||||
### 11.1 第一阶段:基础功能开发(2-3 周)
|
||||
|
||||
**任务清单**:
|
||||
- 完成策略配置管理功能(前端配置页面 + 后端 API)
|
||||
- 完成 NBA 数据获取功能(API 集成 + 数据存储)
|
||||
- 完成基础量化分析功能(简单策略逻辑)
|
||||
- 完成信号推送功能(WebSocket 推送)
|
||||
|
||||
### 11.2 第二阶段:量化分析完善(2-3 周)
|
||||
|
||||
**任务清单**:
|
||||
- 完善量化分析逻辑(支持复杂策略)
|
||||
- 完善特征提取和数据处理
|
||||
- 完善信号生成和验证
|
||||
- 完善错误处理和容错机制
|
||||
|
||||
### 11.3 第三阶段:实时性优化(1-2 周)
|
||||
|
||||
**任务清单**:
|
||||
- 优化数据获取频率和策略
|
||||
- 优化 WebSocket 推送机制
|
||||
- 实现数据变化检测和增量更新
|
||||
- 实现前端降级策略
|
||||
|
||||
### 11.4 第四阶段:性能优化和监控(1-2 周)
|
||||
|
||||
**任务清单**:
|
||||
- 性能优化(缓存、并行处理等)
|
||||
- 系统监控和日志完善
|
||||
- 压力测试和性能调优
|
||||
- 文档完善
|
||||
|
||||
---
|
||||
|
||||
## 十二、注意事项
|
||||
|
||||
### 12.1 API 限制
|
||||
|
||||
- **NBA Stats API**: 建议请求频率 < 10 请求/秒
|
||||
- **Basketball Reference**: 建议请求频率 < 1 请求/秒
|
||||
- 需要设置正确的请求头,避免被拒绝
|
||||
|
||||
### 12.2 数据质量
|
||||
|
||||
- API 返回的数据格式可能不一致,需要做好异常处理
|
||||
- Basketball Reference 的 HTML 结构可能变化,需要定期检查
|
||||
- 需要验证数据的合理性(如得分不超过 200 分)
|
||||
|
||||
### 12.3 法律合规
|
||||
|
||||
- 遵守 NBA Stats API 的使用条款
|
||||
- 遵守 Basketball Reference 的爬虫协议(robots.txt)
|
||||
- 不要过度请求,避免对服务器造成负担
|
||||
|
||||
### 12.4 风险提示
|
||||
|
||||
- 量化交易存在风险,需要做好风险控制
|
||||
- 信号仅供参考,不构成投资建议
|
||||
- 需要明确告知用户风险和使用条款
|
||||
|
||||
---
|
||||
|
||||
## 十三、扩展建议
|
||||
|
||||
### 13.1 功能扩展
|
||||
|
||||
**策略模板**:
|
||||
- 提供预定义的策略模板
|
||||
- 用户可以基于模板快速创建策略
|
||||
- 支持策略模板的导入和导出
|
||||
|
||||
**回测功能**:
|
||||
- 支持历史数据回测
|
||||
- 评估策略的历史表现
|
||||
- 优化策略参数
|
||||
|
||||
**多账户支持**:
|
||||
- 支持多个交易账户
|
||||
- 每个账户可以配置不同的策略
|
||||
- 统一管理和监控
|
||||
|
||||
### 13.2 技术扩展
|
||||
|
||||
**机器学习集成**:
|
||||
- 使用机器学习模型优化策略
|
||||
- 自动学习和优化参数
|
||||
- 提高信号准确性
|
||||
|
||||
**多数据源整合**:
|
||||
- 整合多个数据源(如 ESPN、Sportradar 等)
|
||||
- 提高数据完整性和准确性
|
||||
- 降低对单一数据源的依赖
|
||||
|
||||
**分布式部署**:
|
||||
- 支持分布式部署,提高系统性能
|
||||
- 支持负载均衡和容错
|
||||
- 支持水平扩展
|
||||
@@ -1,158 +0,0 @@
|
||||
# NBA Stats API 验证检查清单
|
||||
|
||||
## 一、API 接口定义检查
|
||||
|
||||
### 1.1 接口路径
|
||||
- ✅ **路径**: `/Scoreboard`
|
||||
- ✅ **Base URL**: `https://stats.nba.com/stats/`
|
||||
- ✅ **完整 URL**: `https://stats.nba.com/stats/Scoreboard`
|
||||
|
||||
### 1.2 请求参数
|
||||
- ✅ **GameDate**: 日期格式 `YYYY-MM-DD`(如 `2024-12-15`)
|
||||
- ✅ **LeagueID**: 联盟ID,默认 `"00"` (NBA)
|
||||
- ✅ **DayOffset**: 日期偏移,默认 `0`
|
||||
|
||||
**注意**: NBA Stats API 的参数名称是**大小写敏感**的:
|
||||
- ✅ `GameDate` (正确)
|
||||
- ❌ `gameDate` (错误)
|
||||
- ✅ `LeagueID` (正确)
|
||||
- ❌ `leagueId` (错误)
|
||||
|
||||
### 1.3 请求头设置
|
||||
- ✅ **User-Agent**: `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36`
|
||||
- ✅ **Referer**: `https://www.nba.com/`
|
||||
- ✅ **Accept**: `application/json`
|
||||
- ✅ **Accept-Language**: `en-US,en;q=0.9`
|
||||
- ✅ **Origin**: `https://www.nba.com`
|
||||
|
||||
## 二、响应格式检查
|
||||
|
||||
### 2.1 响应结构
|
||||
NBA Stats API 返回的 JSON 结构:
|
||||
```json
|
||||
{
|
||||
"resultSets": [
|
||||
{
|
||||
"name": "GameHeader",
|
||||
"headers": ["GAME_DATE_EST", "GAME_SEQUENCE", "GAME_ID", ...],
|
||||
"rowSet": [[...], [...]]
|
||||
},
|
||||
{
|
||||
"name": "LineScore",
|
||||
"headers": ["GAME_DATE_EST", "GAME_SEQUENCE", "GAME_ID", "TEAM_ID", ...],
|
||||
"rowSet": [[...], [...]]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 ResultSet 名称
|
||||
- ✅ **GameHeader**: 比赛基本信息
|
||||
- ✅ **LineScore**: 比分信息(每支球队一行)
|
||||
|
||||
### 2.3 GameHeader 字段顺序
|
||||
根据注释,字段顺序应该是:
|
||||
```
|
||||
[0] GAME_DATE_EST
|
||||
[1] GAME_SEQUENCE
|
||||
[2] GAME_ID
|
||||
[3] GAME_STATUS_ID
|
||||
[4] GAME_STATUS_TEXT
|
||||
[5] GAMECODE
|
||||
[6] HOME_TEAM_ID
|
||||
[7] VISITOR_TEAM_ID
|
||||
[8] SEASON
|
||||
[9] LIVE_PERIOD
|
||||
[10] LIVE_PC_TIME
|
||||
[11] NATL_TV_BROADCASTER_ABBREV
|
||||
[12] LIVE_PERIOD_TIME_BCAST
|
||||
[13] WH_STATUS
|
||||
```
|
||||
|
||||
### 2.4 LineScore 字段顺序
|
||||
根据注释,字段顺序应该是:
|
||||
```
|
||||
[0] GAME_DATE_EST
|
||||
[1] GAME_SEQUENCE
|
||||
[2] GAME_ID
|
||||
[3] TEAM_ID
|
||||
[4] TEAM_ABBREVIATION
|
||||
[5] TEAM_NAME
|
||||
[6] PTS_QTR1
|
||||
[7] PTS_QTR2
|
||||
[8] PTS_QTR3
|
||||
[9] PTS_QTR4
|
||||
[10] PTS_OT1
|
||||
[11] PTS_OT2
|
||||
[12] PTS_OT3
|
||||
[13] PTS_OT4
|
||||
[14] PTS
|
||||
[15] FG_PCT
|
||||
[16] FT_PCT
|
||||
[17] FG3_PCT
|
||||
[18] AST
|
||||
[19] REB
|
||||
[20] TOV
|
||||
```
|
||||
|
||||
## 三、代码实现检查
|
||||
|
||||
### 3.1 接口定义 ✅
|
||||
```kotlin
|
||||
@GET("Scoreboard")
|
||||
suspend fun getScoreboard(
|
||||
@Query("GameDate") gameDate: String? = null,
|
||||
@Query("LeagueID") leagueId: String = "00",
|
||||
@Query("DayOffset") dayOffset: Int = 0
|
||||
): Response<ScoreboardResponse>
|
||||
```
|
||||
- ✅ 参数名称大小写正确
|
||||
- ✅ 参数类型正确
|
||||
|
||||
### 3.2 请求头设置 ✅
|
||||
- ✅ 已设置所有必需的请求头
|
||||
- ✅ User-Agent 格式正确
|
||||
|
||||
### 3.3 响应解析 ⚠️
|
||||
**潜在问题**:
|
||||
1. **ResultSet 查找方式**: 使用 `firstOrNull { it.name == "GameHeader" }` 可能不够准确
|
||||
- 建议:使用索引 `resultSets[0]` 或 `resultSets.getOrNull(0)`
|
||||
- 或者:先检查 `resultSets.size >= 2`
|
||||
|
||||
2. **字段索引**: 当前代码假设字段顺序固定
|
||||
- 建议:根据 `headers` 数组动态查找字段索引,而不是硬编码索引
|
||||
|
||||
3. **错误处理**: 当前有基本的错误处理,但可以更详细
|
||||
|
||||
## 四、建议的改进
|
||||
|
||||
### 4.1 使用 headers 动态查找字段
|
||||
```kotlin
|
||||
// 根据 headers 查找字段索引,而不是硬编码
|
||||
val gameDateIndex = headers.indexOf("GAME_DATE_EST")
|
||||
val gameIdIndex = headers.indexOf("GAME_ID")
|
||||
// ...
|
||||
```
|
||||
|
||||
### 4.2 增强错误处理
|
||||
- 记录完整的响应内容(用于调试)
|
||||
- 验证 headers 数量是否匹配
|
||||
- 验证 rowSet 数据是否完整
|
||||
|
||||
### 4.3 添加响应验证
|
||||
- 验证 resultSets 数量
|
||||
- 验证每个 resultSet 的 name
|
||||
- 验证 headers 和 rowSet 的对应关系
|
||||
|
||||
## 五、测试建议
|
||||
|
||||
1. **单元测试**: 测试 API 调用和响应解析
|
||||
2. **集成测试**: 测试完整的获取流程
|
||||
3. **错误场景测试**: 测试 API 失败、数据不完整等情况
|
||||
|
||||
## 六、已知问题
|
||||
|
||||
1. **API 限制**: NBA Stats API 可能需要特定的请求头,否则可能返回 403 或空数据
|
||||
2. **数据格式**: 响应格式可能因日期而异(有比赛 vs 无比赛)
|
||||
3. **时区问题**: `GAME_DATE_EST` 是 EST 时区,需要注意时区转换
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
# NBA 量化交易系统后端实现总结
|
||||
|
||||
## 已完成部分
|
||||
|
||||
### 1. 数据库设计 ✅
|
||||
|
||||
**迁移文件**: `V11__create_nba_quantitative_trading_tables.sql`
|
||||
|
||||
创建了以下表:
|
||||
- `nba_markets`: NBA 市场表(Polymarket 市场信息)
|
||||
- `nba_games`: NBA 比赛表
|
||||
- `nba_quantitative_strategies`: 量化策略配置表
|
||||
- `nba_trading_signals`: 交易信号表
|
||||
- `nba_strategy_statistics`: 策略执行统计表
|
||||
|
||||
### 2. 实体类 ✅
|
||||
|
||||
已创建以下实体类:
|
||||
- `NbaMarket.kt`: NBA 市场实体
|
||||
- `NbaGame.kt`: NBA 比赛实体
|
||||
- `NbaQuantitativeStrategy.kt`: 量化策略配置实体
|
||||
- `NbaTradingSignal.kt`: 交易信号实体
|
||||
- `NbaStrategyStatistics.kt`: 策略统计实体
|
||||
|
||||
### 3. Repository 层 ✅
|
||||
|
||||
已创建以下 Repository:
|
||||
- `NbaMarketRepository.kt`
|
||||
- `NbaGameRepository.kt`
|
||||
- `NbaQuantitativeStrategyRepository.kt`
|
||||
- `NbaTradingSignalRepository.kt`
|
||||
- `NbaStrategyStatisticsRepository.kt`
|
||||
|
||||
### 4. DTO 层 ✅
|
||||
|
||||
已创建:
|
||||
- `NbaQuantitativeStrategyDto.kt`: 包含创建、更新、列表请求和响应 DTO
|
||||
|
||||
### 5. Service 层(部分完成)✅
|
||||
|
||||
已创建:
|
||||
- `NbaQuantitativeStrategyService.kt`: 策略管理服务
|
||||
- 创建策略
|
||||
- 更新策略
|
||||
- 获取策略列表
|
||||
- 获取策略详情
|
||||
- 删除策略
|
||||
- 获取启用的策略列表
|
||||
|
||||
### 6. Controller 层(部分完成)✅
|
||||
|
||||
已创建:
|
||||
- `NbaQuantitativeStrategyController.kt`: 策略管理 API
|
||||
- `POST /api/nba/strategies/create`: 创建策略
|
||||
- `POST /api/nba/strategies/update`: 更新策略
|
||||
- `POST /api/nba/strategies/list`: 获取策略列表
|
||||
- `POST /api/nba/strategies/detail`: 获取策略详情
|
||||
- `POST /api/nba/strategies/delete`: 删除策略
|
||||
|
||||
---
|
||||
|
||||
## 待完成部分
|
||||
|
||||
### 1. NBA 市场数据获取服务 ⏳
|
||||
|
||||
**需要创建**:
|
||||
- `NbaMarketService.kt`:
|
||||
- 从 Polymarket API 获取市场列表
|
||||
- 同步市场数据到数据库
|
||||
- 根据条件查询市场
|
||||
- 匹配 NBA 比赛和市场
|
||||
|
||||
**参考文档**: `docs/zh/polymarket-nba-markets-fetching-solution.md`
|
||||
|
||||
### 2. NBA 比赛数据获取服务 ⏳
|
||||
|
||||
**需要创建**:
|
||||
- `NbaGameService.kt`:
|
||||
- 从 NBA API 获取比赛数据
|
||||
- 同步比赛数据到数据库
|
||||
- 实时更新比赛状态
|
||||
- 匹配比赛和市场
|
||||
|
||||
**需要集成**: NBA Stats API 或第三方 NBA 数据 API
|
||||
|
||||
### 3. 量化分析服务 ⏳
|
||||
|
||||
**需要创建**:
|
||||
- `NbaQuantitativeAnalysisService.kt`:
|
||||
- 综合评分计算
|
||||
- 对位分析
|
||||
- 实时状态分析
|
||||
- 获胜概率计算
|
||||
- 交易价值计算
|
||||
|
||||
**参考文档**: `docs/zh/nba-quantitative-strategy-algorithm.md`
|
||||
|
||||
### 4. 交易信号生成服务 ⏳
|
||||
|
||||
**需要创建**:
|
||||
- `NbaTradingSignalService.kt`:
|
||||
- 生成买入信号
|
||||
- 生成卖出信号
|
||||
- 风险控制检查
|
||||
- 信号验证
|
||||
|
||||
### 5. WebSocket 推送服务 ⏳
|
||||
|
||||
**需要创建**:
|
||||
- `NbaTradingSignalPushService.kt`:
|
||||
- WebSocket 连接管理
|
||||
- 信号推送
|
||||
- 订阅管理
|
||||
|
||||
**参考**: 现有的 `OrderPushService.kt`
|
||||
|
||||
### 6. 定时任务和同步服务 ⏳
|
||||
|
||||
**需要创建**:
|
||||
- `NbaMarketSyncScheduler.kt`: 同步 NBA 市场数据
|
||||
- `NbaGameSyncScheduler.kt`: 同步 NBA 比赛数据
|
||||
- `NbaQuantitativeAnalysisScheduler.kt`: 定时执行量化分析
|
||||
|
||||
### 7. 其他 Controller ⏳
|
||||
|
||||
**需要创建**:
|
||||
- `NbaMarketController.kt`: 市场数据 API
|
||||
- `NbaGameController.kt`: 比赛数据 API
|
||||
- `NbaTradingSignalController.kt`: 交易信号 API
|
||||
- `NbaStatisticsController.kt`: 统计 API
|
||||
|
||||
### 8. 其他 DTO ⏳
|
||||
|
||||
**需要创建**:
|
||||
- `NbaMarketDto.kt`
|
||||
- `NbaGameDto.kt`
|
||||
- `NbaTradingSignalDto.kt`
|
||||
- `NbaStatisticsDto.kt`
|
||||
|
||||
---
|
||||
|
||||
## 实现优先级
|
||||
|
||||
### 第一阶段(核心功能)
|
||||
1. ✅ 数据库设计和实体类
|
||||
2. ✅ 策略管理服务(CRUD)
|
||||
3. ⏳ NBA 市场数据获取服务
|
||||
4. ⏳ NBA 比赛数据获取服务
|
||||
|
||||
### 第二阶段(量化分析)
|
||||
5. ⏳ 量化分析服务
|
||||
6. ⏳ 交易信号生成服务
|
||||
7. ⏳ 风险控制逻辑
|
||||
|
||||
### 第三阶段(实时推送)
|
||||
8. ⏳ WebSocket 推送服务
|
||||
9. ⏳ 定时任务和同步服务
|
||||
|
||||
### 第四阶段(完善功能)
|
||||
10. ⏳ 统计服务
|
||||
11. ⏳ 其他 API 接口
|
||||
12. ⏳ 错误处理和日志
|
||||
|
||||
---
|
||||
|
||||
## 技术要点
|
||||
|
||||
### 1. 数据同步策略
|
||||
- 市场数据:每天全量同步,每小时增量同步
|
||||
- 比赛数据:实时更新(比赛进行中)
|
||||
- 分析频率:根据策略配置(默认 30 秒)
|
||||
|
||||
### 2. 量化分析算法
|
||||
- 综合评分计算(基础实力、近期状态、阵容完整度等)
|
||||
- 对位分析(球星对位、阵容克制)
|
||||
- 实时状态分析(分差、势头)
|
||||
- 获胜概率计算
|
||||
- 交易价值计算
|
||||
|
||||
### 3. 风险控制
|
||||
- 持仓限制检查
|
||||
- 每日限制检查
|
||||
- 价格容忍度检查
|
||||
- 概率置信度检查
|
||||
|
||||
### 4. WebSocket 推送
|
||||
- 实时推送交易信号
|
||||
- 支持订阅/取消订阅
|
||||
- 连接管理和重连机制
|
||||
|
||||
---
|
||||
|
||||
## 下一步工作
|
||||
|
||||
1. **实现 NBA 市场数据获取服务**
|
||||
- 参考 `polymarket-nba-markets-fetching-solution.md`
|
||||
- 实现批量查询和过滤逻辑
|
||||
|
||||
2. **实现 NBA 比赛数据获取服务**
|
||||
- 集成 NBA Stats API
|
||||
- 实现比赛数据同步和更新
|
||||
|
||||
3. **实现量化分析服务**
|
||||
- 参考 `nba-quantitative-strategy-algorithm.md`
|
||||
- 实现综合评分、对位分析、概率计算等核心算法
|
||||
|
||||
4. **实现交易信号生成服务**
|
||||
- 集成量化分析结果
|
||||
- 实现买入/卖出信号生成逻辑
|
||||
|
||||
5. **实现 WebSocket 推送服务**
|
||||
- 参考现有的 `OrderPushService`
|
||||
- 实现信号推送和订阅管理
|
||||
|
||||
---
|
||||
|
||||
**文档结束**
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
# NBA 比赛数据来源对比
|
||||
|
||||
## 一、数据来源对比
|
||||
|
||||
### 1.1 NBA Stats API
|
||||
|
||||
**优势**:
|
||||
- ✅ 官方数据源,数据准确可靠
|
||||
- ✅ 包含完整的比赛数据(实时比分、状态、统计等)
|
||||
- ✅ 实时更新,数据最新
|
||||
- ✅ 免费使用
|
||||
- ✅ 支持历史数据查询
|
||||
|
||||
**劣势**:
|
||||
- ❌ 不包含 Polymarket 市场信息
|
||||
- ❌ 需要额外的匹配逻辑来关联 Polymarket 市场
|
||||
|
||||
**适用场景**:
|
||||
- 获取比赛基本信息(球队、日期、时间)
|
||||
- 获取实时比分和比赛状态
|
||||
- 获取比赛统计数据
|
||||
|
||||
### 1.2 Polymarket API
|
||||
|
||||
**优势**:
|
||||
- ✅ 直接获取交易市场信息
|
||||
- ✅ 包含市场 ID、价格、流动性等交易相关数据
|
||||
- ✅ 市场名称可能包含比赛信息
|
||||
|
||||
**劣势**:
|
||||
- ❌ 不包含实时比分和比赛状态
|
||||
- ❌ 无法直接搜索或筛选 NBA 市场(需要知道 condition_ids)
|
||||
- ❌ 市场名称格式不统一,解析困难
|
||||
- ❌ 数据不完整(缺少比赛详细信息)
|
||||
|
||||
**适用场景**:
|
||||
- 获取可交易的市场列表
|
||||
- 获取市场价格和流动性信息
|
||||
- 匹配比赛和交易市场
|
||||
|
||||
## 二、推荐方案
|
||||
|
||||
### 2.1 混合方案(推荐)
|
||||
|
||||
**策略**:结合两种数据源,发挥各自优势
|
||||
|
||||
**实现方式**:
|
||||
1. **主要数据源:NBA Stats API**
|
||||
- 获取完整的比赛数据(球队、日期、时间、比分、状态等)
|
||||
- 实时更新比赛状态
|
||||
|
||||
2. **补充数据源:Polymarket API**
|
||||
- 获取市场信息(市场 ID、价格、流动性等)
|
||||
- 通过球队名称和日期匹配比赛和市场
|
||||
|
||||
3. **数据匹配**
|
||||
- 使用球队名称和比赛日期匹配
|
||||
- 建立比赛和市场的关联关系
|
||||
|
||||
### 2.2 数据流程
|
||||
|
||||
```
|
||||
1. 从 NBA Stats API 获取比赛列表
|
||||
↓
|
||||
2. 从 Polymarket API 获取市场列表(如果知道 condition_ids)
|
||||
↓
|
||||
3. 解析市场名称,提取球队和日期信息
|
||||
↓
|
||||
4. 匹配比赛和市场(通过球队名称和日期)
|
||||
↓
|
||||
5. 合并数据,返回完整的比赛和市场信息
|
||||
```
|
||||
|
||||
## 三、当前实现
|
||||
|
||||
当前系统使用 **NBA Stats API** 作为主要数据源,原因:
|
||||
- 数据完整可靠
|
||||
- 实时更新
|
||||
- 免费使用
|
||||
- 无需预先知道 condition_ids
|
||||
|
||||
如果需要 Polymarket 市场信息,可以:
|
||||
1. 在获取比赛数据后,通过球队名称和日期匹配 Polymarket 市场
|
||||
2. 或者单独维护一个 Polymarket 市场列表,定期同步
|
||||
|
||||
## 四、建议
|
||||
|
||||
**对于获取比赛数据**:
|
||||
- ✅ 使用 NBA Stats API(当前实现)
|
||||
- ✅ 实时、准确、完整
|
||||
|
||||
**对于获取市场信息**:
|
||||
- ✅ 使用 Polymarket API(需要知道 condition_ids)
|
||||
- ✅ 或者从市场名称解析(如果格式统一)
|
||||
|
||||
**最佳实践**:
|
||||
- 主要使用 NBA Stats API 获取比赛数据
|
||||
- 使用 Polymarket API 获取市场信息并匹配
|
||||
- 建立比赛和市场的关联关系
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
# NBA 比赛数据获取实现
|
||||
|
||||
## 一、概述
|
||||
|
||||
本系统从 NBA Stats API 实时获取 NBA 比赛数据:
|
||||
- **数据源**:NBA Stats API(官方数据源,实时、准确、完整)
|
||||
- **不依赖数据库**:所有数据从 API 实时获取
|
||||
|
||||
## 二、实现架构
|
||||
|
||||
### 2.1 数据流程
|
||||
|
||||
```
|
||||
1. 从 NBA Stats API 获取比赛列表(实时)
|
||||
↓
|
||||
2. 解析 API 响应,转换为 DTO
|
||||
↓
|
||||
3. 返回比赛数据给前端
|
||||
```
|
||||
|
||||
### 2.2 关于 Polymarket 市场匹配
|
||||
|
||||
**当前实现**:
|
||||
- 不进行市场匹配(因为 Polymarket API 限制)
|
||||
- 比赛数据中的 `polymarketMarketId` 字段为 `null`
|
||||
|
||||
**未来扩展**:
|
||||
- 如果将来有办法获取 condition_ids,可以从 Polymarket API 实时获取市场信息
|
||||
- 已保留 `NbaMarketNameParser` 工具类,可用于市场名称解析
|
||||
|
||||
### 2.2 核心组件
|
||||
|
||||
#### 1. NbaGameService
|
||||
- **功能**:从 NBA Stats API 实时获取比赛数据
|
||||
- **数据源**:NBA Stats API(官方数据源)
|
||||
- **特点**:
|
||||
- 实时获取,不依赖数据库
|
||||
- 支持日期范围查询
|
||||
- 支持按状态过滤
|
||||
|
||||
#### 2. NbaMarketNameParser(保留,供将来使用)
|
||||
- **功能**:解析 Polymarket 市场名称,提取球队和日期信息
|
||||
- **支持格式**:
|
||||
- "Team1 vs Team2"
|
||||
- "Team1 @ Team2"
|
||||
- "Will Team1 beat Team2"
|
||||
- "Team1 win"
|
||||
- **日期格式**:
|
||||
- "Dec 15, 2024"
|
||||
- "2024-12-15"
|
||||
- "12/15/2024"
|
||||
- **说明**:当前未使用,保留以备将来扩展
|
||||
|
||||
## 三、实现细节
|
||||
|
||||
### 3.1 比赛数据获取
|
||||
|
||||
**获取流程**:
|
||||
1. 根据日期范围,每天调用一次 NBA Stats API
|
||||
2. 解析 Scoreboard 响应,提取比赛信息
|
||||
3. 组合 GameHeader 和 LineScore 数据
|
||||
4. 转换为 NbaGameDto 返回
|
||||
|
||||
**数据字段**:
|
||||
- 比赛基本信息(球队、日期、时间)
|
||||
- 实时比分和状态
|
||||
- 比赛节次和剩余时间
|
||||
- 球队统计信息
|
||||
|
||||
### 3.2 关于市场匹配(未来扩展)
|
||||
|
||||
**当前状态**:
|
||||
- 不进行市场匹配
|
||||
- `polymarketMarketId` 字段为 `null`
|
||||
|
||||
**未来扩展方案**:
|
||||
如果将来需要匹配市场,可以考虑:
|
||||
1. 从其他来源获取 condition_ids(如爬取、手动维护等)
|
||||
2. 使用 Polymarket API 实时查询这些市场
|
||||
3. 使用 `NbaMarketNameParser` 解析市场名称并匹配
|
||||
|
||||
### 3.3 数据返回
|
||||
|
||||
**NbaGameDto** 包含:
|
||||
- 比赛基本信息(从 NBA Stats API)
|
||||
- `polymarketMarketId`:当前为 `null`(未来可扩展)
|
||||
|
||||
## 四、使用方式
|
||||
|
||||
### 4.1 API 调用
|
||||
|
||||
```kotlin
|
||||
// 获取比赛列表(自动匹配市场)
|
||||
val request = NbaGameListRequest(
|
||||
startDate = "2024-12-15",
|
||||
endDate = "2024-12-22"
|
||||
)
|
||||
val result = nbaGameService.getNbaGames(request)
|
||||
```
|
||||
|
||||
### 4.2 返回数据
|
||||
|
||||
```kotlin
|
||||
data class NbaGameDto(
|
||||
val nbaGameId: String?,
|
||||
val homeTeam: String,
|
||||
val awayTeam: String,
|
||||
val gameDate: LocalDate,
|
||||
val gameStatus: String,
|
||||
val homeScore: Int,
|
||||
val awayScore: Int,
|
||||
val polymarketMarketId: String? // 匹配的市场 ID
|
||||
)
|
||||
```
|
||||
|
||||
## 五、优势
|
||||
|
||||
1. **数据完整**:从 NBA Stats API 获取完整的比赛数据
|
||||
2. **实时更新**:比赛数据实时获取,不依赖数据库
|
||||
3. **简单高效**:直接调用 API,无需维护数据库
|
||||
4. **官方数据源**:数据准确可靠
|
||||
|
||||
## 六、注意事项
|
||||
|
||||
1. **API 限制**:
|
||||
- NBA Stats API 需要设置正确的请求头
|
||||
- 建议控制请求频率(< 10 请求/秒)
|
||||
|
||||
2. **市场匹配**:
|
||||
- 当前不进行市场匹配
|
||||
- 如果需要匹配,需要解决 Polymarket API 的限制(需要知道 condition_ids)
|
||||
|
||||
3. **性能考虑**:
|
||||
- 日期范围查询会多次调用 API(每天一次)
|
||||
- 建议合理设置日期范围,避免查询过长的时间段
|
||||
|
||||
## 七、后续优化
|
||||
|
||||
1. **缓存机制**:
|
||||
- 缓存 API 响应,减少重复请求
|
||||
- 设置合理的缓存时间(如 30 秒)
|
||||
|
||||
2. **错误处理**:
|
||||
- 实现重试机制(指数退避)
|
||||
- 处理 API 临时不可用的情况
|
||||
|
||||
3. **市场匹配(可选)**:
|
||||
- 如果将来有办法获取 condition_ids,可以实现市场匹配
|
||||
- 使用 `NbaMarketNameParser` 进行市场名称解析
|
||||
|
||||
@@ -1,834 +0,0 @@
|
||||
# NBA 量化交易策略算法文档
|
||||
|
||||
## 一、算法概述
|
||||
|
||||
### 1.1 算法目标
|
||||
|
||||
本算法基于 NBA 比赛数据,通过量化分析两支队伍的实力、状态、对位关系等因素,计算出哪支队伍具有更高的获胜概率和交易价值,从而生成买入/卖出交易信号。
|
||||
|
||||
### 1.2 算法流程
|
||||
|
||||
```
|
||||
获取比赛数据(主队 vs 客队)
|
||||
↓
|
||||
数据预处理和特征提取
|
||||
↓
|
||||
计算两队综合评分
|
||||
↓
|
||||
计算获胜概率和预期收益
|
||||
↓
|
||||
生成交易信号(买入/卖出)
|
||||
↓
|
||||
风险控制检查
|
||||
↓
|
||||
输出交易信号
|
||||
```
|
||||
|
||||
### 1.3 核心思想
|
||||
|
||||
**不是简单的"主队落后买入",而是**:
|
||||
- 综合分析两支队伍的各项指标
|
||||
- 计算每支队伍的相对优势和劣势
|
||||
- 评估交易价值和风险
|
||||
- 选择最优的交易方向和时机
|
||||
|
||||
---
|
||||
|
||||
## 二、数据预处理
|
||||
|
||||
### 2.1 基础数据获取
|
||||
|
||||
**比赛基本信息**:
|
||||
- 比赛 ID、日期、主客场
|
||||
- 当前比分、分差、节次、剩余时间
|
||||
- 比赛状态(未开始/进行中/已结束)
|
||||
|
||||
**球队基础数据**:
|
||||
- 主队和客队的基本信息
|
||||
- 当前赛季的统计数据
|
||||
- 近期表现数据(近 5 场/10 场)
|
||||
|
||||
**球员数据**:
|
||||
- 核心球员的统计数据
|
||||
- 球员健康状况和轮休情况
|
||||
- 球员近期表现
|
||||
|
||||
### 2.2 数据清洗
|
||||
|
||||
**异常值处理**:
|
||||
- 检查数据合理性(如得分不超过 200 分)
|
||||
- 处理缺失值(使用历史平均值或默认值)
|
||||
- 处理异常数据(如负分差、超时时间等)
|
||||
|
||||
**数据标准化**:
|
||||
- 将不同量纲的数据标准化到统一范围
|
||||
- 使用 Z-score 或 Min-Max 归一化
|
||||
- 确保数据在 [0, 1] 或 [-1, 1] 范围内
|
||||
|
||||
### 2.3 特征工程
|
||||
|
||||
**基础特征**:
|
||||
- 当前分差(主队得分 - 客队得分)
|
||||
- 剩余时间(分钟)
|
||||
- 当前节次(1-4,加时)
|
||||
- 比赛状态
|
||||
|
||||
**统计特征**:
|
||||
- 球队净效率值(进攻效率 - 防守效率)
|
||||
- 球队比赛节奏(Pace)
|
||||
- 球队关键时刻表现(Clutch 数据)
|
||||
- 球队主客场表现差异
|
||||
|
||||
**动态特征**:
|
||||
- 势头动量(过去 N 回合的净胜分)
|
||||
- 手感热度(近期命中率)
|
||||
- 阵容对位优势
|
||||
- 球星爆发因子
|
||||
|
||||
---
|
||||
|
||||
## 三、综合评分算法
|
||||
|
||||
### 3.1 球队实力评分
|
||||
|
||||
#### 3.1.1 基础实力评分
|
||||
|
||||
**计算公式**:
|
||||
```
|
||||
基础实力评分 = 净效率值评分 × W1 + 攻防效率评分 × W2 + 节奏评分 × W3
|
||||
```
|
||||
|
||||
**评分组成**:
|
||||
- **净效率值评分**:基于球队赛季平均净效率值,归一化到 [0, 1]
|
||||
- 评分 = (净效率值 - 联盟最低值) / (联盟最高值 - 联盟最低值)
|
||||
- **攻防效率评分**:综合考虑进攻效率和防守效率
|
||||
- 进攻效率评分 = (进攻效率排名 - 1) / (总球队数 - 1),排名越靠前评分越高
|
||||
- 防守效率评分 = (防守效率排名 - 1) / (总球队数 - 1),排名越靠前评分越高
|
||||
- 攻防效率评分 = (进攻效率评分 + 防守效率评分) / 2
|
||||
- **节奏评分**:基于比赛节奏(Pace)
|
||||
- 节奏评分 = (Pace - 联盟最低值) / (联盟最高值 - 联盟最低值)
|
||||
|
||||
**权重设置**:
|
||||
- W1 = 0.5(净效率值权重最高)
|
||||
- W2 = 0.3(攻防效率权重)
|
||||
- W3 = 0.2(节奏权重)
|
||||
|
||||
#### 3.1.2 近期状态评分
|
||||
|
||||
**计算公式**:
|
||||
```
|
||||
近期状态评分 = 近期胜率评分 × W1 + 近期净效率值变化 × W2 + 势头评分 × W3
|
||||
```
|
||||
|
||||
**评分组成**:
|
||||
- **近期胜率评分**:近 5 场或 10 场胜率
|
||||
- 评分 = 近期胜率(0-1)
|
||||
- **近期净效率值变化**:近期净效率值与赛季平均的差值
|
||||
- 变化 = (近期净效率值 - 赛季平均净效率值) / 赛季平均净效率值
|
||||
- 评分 = (变化 + 1) / 2(归一化到 [0, 1])
|
||||
- **势头评分**:基于连胜/连败场次
|
||||
- 连胜:评分 = min(连胜场次 / 5, 1)
|
||||
- 连败:评分 = max(1 - 连败场次 / 5, 0)
|
||||
|
||||
**权重设置**:
|
||||
- W1 = 0.4(胜率权重)
|
||||
- W2 = 0.4(净效率值变化权重)
|
||||
- W3 = 0.2(势头权重)
|
||||
|
||||
#### 3.1.3 阵容完整度评分
|
||||
|
||||
**计算公式**:
|
||||
```
|
||||
阵容完整度评分 = 1 - (缺失球员总VORP / 球队总VORP)
|
||||
```
|
||||
|
||||
**评分说明**:
|
||||
- 如果所有核心球员都在,评分为 1.0
|
||||
- 如果有核心球员缺席,根据缺失球员的 VORP 值降低评分
|
||||
- 缺失球员越多、重要性越高,评分越低
|
||||
|
||||
#### 3.1.4 球星状态评分
|
||||
|
||||
**计算公式**:
|
||||
```
|
||||
球星状态评分 = 核心球星PER评分 × W1 + 核心球星TS%评分 × W2 + 核心球星健康评分 × W3
|
||||
```
|
||||
|
||||
**评分组成**:
|
||||
- **核心球星 PER 评分**:基于近期 PER 与赛季平均的对比
|
||||
- 评分 = (近期PER - 赛季平均PER) / 赛季平均PER + 1,归一化到 [0, 1]
|
||||
- **核心球星 TS% 评分**:基于近期真实命中率
|
||||
- 评分 = (近期TS% - 赛季平均TS%) / 赛季平均TS% + 1,归一化到 [0, 1]
|
||||
- **核心球星健康评分**:
|
||||
- 健康:1.0
|
||||
- 出战成疑:0.7
|
||||
- 缺席:0.3
|
||||
|
||||
**权重设置**:
|
||||
- W1 = 0.4(PER 权重)
|
||||
- W2 = 0.4(TS% 权重)
|
||||
- W3 = 0.2(健康权重)
|
||||
|
||||
#### 3.1.5 环境因素评分
|
||||
|
||||
**计算公式**:
|
||||
```
|
||||
环境因素评分 = 主客场优势评分 × W1 + 休息天数评分 × W2 + 背靠背影响评分 × W3
|
||||
```
|
||||
|
||||
**评分组成**:
|
||||
- **主客场优势评分**:
|
||||
- 主场:1.0
|
||||
- 客场:基于客场胜率,评分 = 客场胜率 / 主场胜率
|
||||
- **休息天数评分**:
|
||||
- 休息 1 天:0.9
|
||||
- 休息 2 天:1.0(最优)
|
||||
- 休息 3 天以上:0.95
|
||||
- **背靠背影响评分**:
|
||||
- 非背靠背:1.0
|
||||
- 背靠背第二场:0.85
|
||||
|
||||
**权重设置**:
|
||||
- W1 = 0.5(主客场权重最高)
|
||||
- W2 = 0.3(休息天数权重)
|
||||
- W3 = 0.2(背靠背权重)
|
||||
|
||||
### 3.2 综合评分计算
|
||||
|
||||
**主队综合评分**:
|
||||
```
|
||||
主队综合评分 = 基础实力评分 × 0.3
|
||||
+ 近期状态评分 × 0.25
|
||||
+ 阵容完整度评分 × 0.2
|
||||
+ 球星状态评分 × 0.15
|
||||
+ 环境因素评分 × 0.1
|
||||
```
|
||||
|
||||
**客队综合评分**:
|
||||
```
|
||||
客队综合评分 = 基础实力评分 × 0.3
|
||||
+ 近期状态评分 × 0.25
|
||||
+ 阵容完整度评分 × 0.2
|
||||
+ 球星状态评分 × 0.15
|
||||
+ 环境因素评分 × 0.1
|
||||
```
|
||||
|
||||
**相对优势评分**:
|
||||
```
|
||||
主队相对优势 = 主队综合评分 - 客队综合评分
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、对位分析算法
|
||||
|
||||
### 4.1 球星对位分析
|
||||
|
||||
**对位优势评分**:
|
||||
```
|
||||
对位优势评分 = 防守限制效果评分 × W1 + 历史对位数据评分 × W2
|
||||
```
|
||||
|
||||
**评分组成**:
|
||||
- **防守限制效果评分**:基于防守球员的 DRPM 和进攻球员的 ORPM
|
||||
- 如果防守球员 DRPM 高,且进攻球员 ORPM 高,则限制效果好
|
||||
- 评分 = (防守球员DRPM - 联盟平均DRPM) / (联盟最高DRPM - 联盟平均DRPM)
|
||||
- **历史对位数据评分**:基于历史对位时的表现
|
||||
- 对位效率差 = (对位时进攻球员TS% - 赛季平均TS%)
|
||||
- 评分 = 1 - (对位效率差 / 最大可能效率差),归一化到 [0, 1]
|
||||
|
||||
**权重设置**:
|
||||
- W1 = 0.6(防守限制效果权重)
|
||||
- W2 = 0.4(历史对位数据权重)
|
||||
|
||||
### 4.2 阵容克制分析
|
||||
|
||||
**阵容克制评分**:
|
||||
```
|
||||
阵容克制评分 = 内线优势评分 × W1 + 外线优势评分 × W2 + 快攻优势评分 × W3
|
||||
```
|
||||
|
||||
**评分组成**:
|
||||
- **内线优势评分**:
|
||||
- 如果主队内线得分占比高,且客队内线防守效率低,则主队有优势
|
||||
- 评分 = (主队内线得分占比 - 客队内线防守效率排名归一化) / 2
|
||||
- **外线优势评分**:
|
||||
- 如果主队三分出手占比高,且客队三分防守效率低,则主队有优势
|
||||
- 评分 = (主队三分出手占比 - 客队三分防守效率排名归一化) / 2
|
||||
- **快攻优势评分**:
|
||||
- 如果主队快攻得分占比高,且客队快攻防守效率低,则主队有优势
|
||||
- 评分 = (主队快攻得分占比 - 客队快攻防守效率排名归一化) / 2
|
||||
|
||||
**权重设置**:
|
||||
- W1 = 0.4(内线权重)
|
||||
- W2 = 0.4(外线权重)
|
||||
- W3 = 0.2(快攻权重)
|
||||
|
||||
### 4.3 对位综合评分
|
||||
|
||||
**主队对位优势**:
|
||||
```
|
||||
主队对位优势 = 主队球星对位优势 - 客队球星对位优势
|
||||
+ 主队阵容克制优势 - 客队阵容克制优势
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、实时状态分析算法
|
||||
|
||||
### 5.1 比赛进行中的实时分析
|
||||
|
||||
#### 5.1.1 当前分差分析
|
||||
|
||||
**分差优势评分**:
|
||||
```
|
||||
分差优势评分 = (当前分差 / 最大可能分差) × 时间调整系数
|
||||
```
|
||||
|
||||
**时间调整系数**:
|
||||
- 比赛早期(第1-2节):系数 = 0.3(分差影响较小)
|
||||
- 比赛中期(第3节):系数 = 0.6(分差影响中等)
|
||||
- 比赛后期(第4节):系数 = 1.0(分差影响最大)
|
||||
- 最后5分钟:系数 = 1.2(分差影响极大)
|
||||
|
||||
#### 5.1.2 势头动量分析
|
||||
|
||||
**势头动量评分**:
|
||||
```
|
||||
势头动量 = (过去N回合净胜分) / (N × 平均单回合得分)
|
||||
```
|
||||
|
||||
**评分说明**:
|
||||
- 如果主队过去 10 回合净胜 +8 分,则主队势头强劲
|
||||
- 势头动量 > 0.3:势头强劲
|
||||
- 势头动量 < -0.3:势头疲软
|
||||
|
||||
#### 5.1.3 实时综合评分调整
|
||||
|
||||
**实时调整公式**:
|
||||
```
|
||||
实时调整评分 = 基础综合评分
|
||||
+ 分差优势评分 × 0.3
|
||||
+ 势头动量评分 × 0.2
|
||||
- 时间衰减系数 × 0.1
|
||||
```
|
||||
|
||||
**时间衰减系数**:
|
||||
- 比赛越接近结束,基础评分的影响越小,实时状态的影响越大
|
||||
- 时间衰减系数 = (剩余时间 / 总时间) × 0.5
|
||||
|
||||
### 5.2 比赛未开始时的预测分析
|
||||
|
||||
**预测综合评分**:
|
||||
```
|
||||
预测综合评分 = 基础综合评分
|
||||
+ 对位优势评分 × 0.2
|
||||
+ 环境因素评分 × 0.1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、获胜概率计算
|
||||
|
||||
### 6.1 基础获胜概率
|
||||
|
||||
**基于综合评分的概率**:
|
||||
```
|
||||
基础获胜概率 = 1 / (1 + exp(-k × (主队综合评分 - 客队综合评分)))
|
||||
```
|
||||
|
||||
**参数说明**:
|
||||
- k 为调整系数,通常取 5-10
|
||||
- 如果主队综合评分 > 客队综合评分,则主队获胜概率 > 0.5
|
||||
- 如果主队综合评分 = 客队综合评分,则主队获胜概率 = 0.5
|
||||
|
||||
### 6.2 对位调整概率
|
||||
|
||||
**对位调整**:
|
||||
```
|
||||
调整后概率 = 基础获胜概率 + 对位优势调整值
|
||||
```
|
||||
|
||||
**对位优势调整值**:
|
||||
- 如果主队对位优势 > 0.1,则调整值 = +0.05
|
||||
- 如果主队对位优势 < -0.1,则调整值 = -0.05
|
||||
- 调整值范围:[-0.1, +0.1]
|
||||
|
||||
### 6.3 实时状态调整概率
|
||||
|
||||
**实时调整**(仅比赛进行中):
|
||||
```
|
||||
最终概率 = 调整后概率
|
||||
+ 分差调整值 × 0.3
|
||||
+ 势头调整值 × 0.2
|
||||
```
|
||||
|
||||
**分差调整值**:
|
||||
- 如果主队领先,且剩余时间充足,则调整值 = +0.05
|
||||
- 如果主队落后,且剩余时间不足,则调整值 = -0.05
|
||||
|
||||
**势头调整值**:
|
||||
- 如果主队势头强劲,则调整值 = +0.03
|
||||
- 如果主队势头疲软,则调整值 = -0.03
|
||||
|
||||
### 6.4 概率归一化
|
||||
|
||||
**确保概率在 [0, 1] 范围内**:
|
||||
```
|
||||
最终概率 = max(0, min(1, 最终概率))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、交易价值计算
|
||||
|
||||
### 7.1 预期收益计算
|
||||
|
||||
**预期收益公式**:
|
||||
```
|
||||
预期收益 = (获胜概率 × 获胜收益) - ((1 - 获胜概率) × 失败损失)
|
||||
```
|
||||
|
||||
**收益和损失**:
|
||||
- 如果买入主队获胜(YES),价格为 P
|
||||
- 获胜收益 = (1 - P) × 投入金额
|
||||
- 失败损失 = P × 投入金额
|
||||
- 如果买入主队失败(NO),价格为 P
|
||||
- 获胜收益 = P × 投入金额
|
||||
- 失败损失 = (1 - P) × 投入金额
|
||||
|
||||
### 7.2 风险调整收益
|
||||
|
||||
**风险调整公式**:
|
||||
```
|
||||
风险调整收益 = 预期收益 × (1 - 风险系数)
|
||||
```
|
||||
|
||||
**风险系数计算**:
|
||||
```
|
||||
风险系数 = 概率不确定性 × 0.5 + 价格波动风险 × 0.3 + 流动性风险 × 0.2
|
||||
```
|
||||
|
||||
**风险组成**:
|
||||
- **概率不确定性**:如果获胜概率接近 0.5,则不确定性高
|
||||
- 不确定性 = 1 - |获胜概率 - 0.5| × 2
|
||||
- **价格波动风险**:基于市场价格的波动性
|
||||
- 如果价格波动大,则风险高
|
||||
- **流动性风险**:基于市场的流动性
|
||||
- 如果市场流动性低,则风险高
|
||||
|
||||
### 7.3 交易价值评分
|
||||
|
||||
**交易价值评分**:
|
||||
```
|
||||
交易价值评分 = 风险调整收益 / 投入金额
|
||||
```
|
||||
|
||||
**评分说明**:
|
||||
- 交易价值评分 > 0.1:高价值交易,强烈推荐
|
||||
- 交易价值评分 > 0.05:中等价值交易,推荐
|
||||
- 交易价值评分 > 0:低价值交易,可考虑
|
||||
- 交易价值评分 <= 0:无价值交易,不推荐
|
||||
|
||||
---
|
||||
|
||||
## 八、交易信号生成算法
|
||||
|
||||
### 8.1 信号生成条件
|
||||
|
||||
**买入信号生成条件**:
|
||||
1. **获胜概率条件**:
|
||||
- 主队获胜概率 > 阈值(如 0.55)或 < 阈值(如 0.45)
|
||||
- 如果主队获胜概率 > 0.55,买入主队获胜(YES)
|
||||
- 如果主队获胜概率 < 0.45,买入主队失败(NO)
|
||||
|
||||
2. **交易价值条件**:
|
||||
- 交易价值评分 > 用户配置的最小交易价值(如 0.05)
|
||||
|
||||
3. **价格条件**:
|
||||
- 当前市场价格在合理范围内
|
||||
- 价格偏差在用户配置的容忍度内
|
||||
|
||||
4. **时间条件**:
|
||||
- 比赛未结束
|
||||
- 剩余时间充足(如果用户配置了最小剩余时间)
|
||||
|
||||
### 8.2 信号方向确定
|
||||
|
||||
**方向判断逻辑**:
|
||||
```
|
||||
如果 主队获胜概率 > 0.55:
|
||||
方向 = YES(买入主队获胜)
|
||||
目标价格 = 市场价格(如果合理)
|
||||
|
||||
如果 主队获胜概率 < 0.45:
|
||||
方向 = NO(买入主队失败)
|
||||
目标价格 = 1 - 市场价格(如果合理)
|
||||
|
||||
如果 0.45 <= 主队获胜概率 <= 0.55:
|
||||
不生成信号(概率太接近,不确定性高)
|
||||
```
|
||||
|
||||
### 8.3 信号价格计算
|
||||
|
||||
**价格计算策略**:
|
||||
|
||||
**策略一:固定价格**(用户配置)
|
||||
```
|
||||
信号价格 = 用户配置的固定价格
|
||||
```
|
||||
|
||||
**策略二:市场价格**(默认)
|
||||
```
|
||||
信号价格 = 当前市场价格
|
||||
```
|
||||
|
||||
**策略三:动态价格**(基于概率)
|
||||
```
|
||||
如果 方向 = YES:
|
||||
信号价格 = 主队获胜概率 × (1 + 价格偏移)
|
||||
|
||||
如果 方向 = NO:
|
||||
信号价格 = (1 - 主队获胜概率) × (1 + 价格偏移)
|
||||
```
|
||||
|
||||
**价格偏移**:
|
||||
- 价格偏移 = 用户配置的价格偏移百分比(如 ±5%)
|
||||
- 用于调整价格,提高交易成功率
|
||||
|
||||
### 8.4 信号数量计算
|
||||
|
||||
**数量计算策略**:
|
||||
|
||||
**策略一:固定金额**(用户配置)
|
||||
```
|
||||
信号数量 = 用户配置的固定金额 / 信号价格
|
||||
```
|
||||
|
||||
**策略二:按比例**(用户配置)
|
||||
```
|
||||
信号数量 = 账户余额 × 用户配置的比例 / 信号价格
|
||||
```
|
||||
|
||||
**策略三:动态计算**(基于交易价值)
|
||||
```
|
||||
基础数量 = 用户配置的基础金额 / 信号价格
|
||||
调整系数 = min(交易价值评分 / 0.1, 2.0) // 最多放大2倍
|
||||
信号数量 = 基础数量 × 调整系数
|
||||
```
|
||||
|
||||
### 8.5 信号原因生成
|
||||
|
||||
**触发原因说明**:
|
||||
```
|
||||
触发原因 = "主队综合评分: {主队评分}, 客队综合评分: {客队评分}, "
|
||||
+ "获胜概率: {获胜概率}, 交易价值: {交易价值评分}, "
|
||||
+ "主要优势: {主要优势项}"
|
||||
```
|
||||
|
||||
**主要优势项识别**:
|
||||
- 如果基础实力评分差异最大,则主要优势 = "基础实力"
|
||||
- 如果近期状态评分差异最大,则主要优势 = "近期状态"
|
||||
- 如果对位优势明显,则主要优势 = "对位优势"
|
||||
- 如果实时状态优势明显,则主要优势 = "实时状态"
|
||||
|
||||
---
|
||||
|
||||
## 九、卖出信号生成算法
|
||||
|
||||
### 9.1 卖出条件
|
||||
|
||||
**卖出信号生成条件**:
|
||||
1. **止盈条件**:
|
||||
- 当前持仓的预期收益达到用户配置的止盈阈值(如 20%)
|
||||
- 或市场价格达到用户配置的目标价格
|
||||
|
||||
2. **止损条件**:
|
||||
- 当前持仓的预期亏损达到用户配置的止损阈值(如 -10%)
|
||||
- 或市场价格跌破用户配置的止损价格
|
||||
|
||||
3. **概率反转条件**:
|
||||
- 获胜概率发生反转(如从 0.6 降到 0.4)
|
||||
- 反转幅度超过用户配置的阈值(如 0.15)
|
||||
|
||||
4. **时间条件**:
|
||||
- 比赛接近结束(剩余时间 < 用户配置的最小剩余时间)
|
||||
- 或比赛已结束
|
||||
|
||||
### 9.2 卖出价格计算
|
||||
|
||||
**卖出价格**:
|
||||
```
|
||||
卖出价格 = 当前市场价格
|
||||
```
|
||||
|
||||
**价格调整**(可选):
|
||||
```
|
||||
如果 止盈卖出:
|
||||
卖出价格 = min(当前市场价格, 目标价格)
|
||||
|
||||
如果 止损卖出:
|
||||
卖出价格 = max(当前市场价格, 止损价格)
|
||||
```
|
||||
|
||||
### 9.3 卖出数量计算
|
||||
|
||||
**卖出数量**:
|
||||
```
|
||||
如果 全部卖出:
|
||||
卖出数量 = 当前持仓数量
|
||||
|
||||
如果 部分卖出:
|
||||
卖出数量 = 当前持仓数量 × 用户配置的卖出比例
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十、风险控制算法
|
||||
|
||||
### 10.1 持仓限制检查
|
||||
|
||||
**最大持仓检查**:
|
||||
```
|
||||
如果 信号数量 × 信号价格 > 用户配置的最大持仓:
|
||||
信号数量 = 用户配置的最大持仓 / 信号价格
|
||||
记录警告:超过最大持仓限制,已调整数量
|
||||
```
|
||||
|
||||
**最小持仓检查**:
|
||||
```
|
||||
如果 信号数量 × 信号价格 < 用户配置的最小持仓:
|
||||
不生成信号
|
||||
记录原因:金额低于最小持仓限制
|
||||
```
|
||||
|
||||
### 10.2 每日限制检查
|
||||
|
||||
**每日亏损限制**:
|
||||
```
|
||||
今日已亏损 = 今日所有交易的累计亏损
|
||||
|
||||
如果 今日已亏损 + 预期最大亏损 > 用户配置的每日亏损限制:
|
||||
不生成信号
|
||||
记录原因:超过每日亏损限制
|
||||
```
|
||||
|
||||
**每日订单限制**:
|
||||
```
|
||||
今日订单数 = 今日已生成的信号数量
|
||||
|
||||
如果 今日订单数 >= 用户配置的每日订单限制:
|
||||
不生成信号
|
||||
记录原因:超过每日订单限制
|
||||
```
|
||||
|
||||
### 10.3 价格容忍度检查
|
||||
|
||||
**价格偏差检查**:
|
||||
```
|
||||
价格偏差 = |信号价格 - 当前市场价格| / 当前市场价格
|
||||
|
||||
如果 价格偏差 > 用户配置的价格容忍度:
|
||||
不生成信号
|
||||
记录原因:价格偏差超过容忍度
|
||||
```
|
||||
|
||||
### 10.4 概率置信度检查
|
||||
|
||||
**概率置信度**:
|
||||
```
|
||||
如果 0.45 <= 获胜概率 <= 0.55:
|
||||
不生成信号
|
||||
记录原因:获胜概率太接近,不确定性高
|
||||
```
|
||||
|
||||
**最小概率阈值**(可选):
|
||||
```
|
||||
如果 获胜概率 < 用户配置的最小概率阈值:
|
||||
不生成信号
|
||||
记录原因:获胜概率低于最小阈值
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十一、算法参数配置
|
||||
|
||||
### 11.1 用户可配置参数
|
||||
|
||||
**触发条件参数**:
|
||||
- 最小获胜概率差异:主队和客队获胜概率的最小差异(如 0.1)
|
||||
- 最小交易价值:交易价值评分的最小值(如 0.05)
|
||||
- 最小剩余时间:生成信号时的最小剩余时间(如 5 分钟)
|
||||
|
||||
**交易规则参数**:
|
||||
- 买入金额策略:固定金额/按比例/动态计算
|
||||
- 买入金额:固定金额或比例值
|
||||
- 价格策略:固定价格/市场价格/动态价格
|
||||
- 价格偏移:价格偏移百分比(如 ±5%)
|
||||
|
||||
**风险控制参数**:
|
||||
- 最大持仓:单次最大买入金额
|
||||
- 最小持仓:单次最小买入金额
|
||||
- 每日亏损限制:每日最大亏损金额
|
||||
- 每日订单限制:每日最大订单数量
|
||||
- 价格容忍度:允许的价格偏差百分比
|
||||
|
||||
**卖出规则参数**:
|
||||
- 止盈阈值:预期收益达到多少时卖出(如 20%)
|
||||
- 止损阈值:预期亏损达到多少时卖出(如 -10%)
|
||||
- 概率反转阈值:概率反转多少时卖出(如 0.15)
|
||||
- 卖出比例:部分卖出时的比例(如 50%)
|
||||
|
||||
### 11.2 系统默认参数
|
||||
|
||||
**评分权重**(可根据历史数据优化):
|
||||
- 基础实力权重:0.3
|
||||
- 近期状态权重:0.25
|
||||
- 阵容完整度权重:0.2
|
||||
- 球星状态权重:0.15
|
||||
- 环境因素权重:0.1
|
||||
|
||||
**概率计算参数**:
|
||||
- k 值(逻辑回归系数):7.5
|
||||
- 对位调整范围:[-0.1, +0.1]
|
||||
- 实时调整权重:分差 0.3,势头 0.2
|
||||
|
||||
**风险系数权重**:
|
||||
- 概率不确定性权重:0.5
|
||||
- 价格波动风险权重:0.3
|
||||
- 流动性风险权重:0.2
|
||||
|
||||
---
|
||||
|
||||
## 十二、算法优化建议
|
||||
|
||||
### 12.1 参数优化
|
||||
|
||||
**历史回测优化**:
|
||||
- 使用历史数据回测不同参数组合
|
||||
- 找到最优的参数配置
|
||||
- 定期重新优化参数
|
||||
|
||||
**A/B 测试**:
|
||||
- 同时运行多组参数配置
|
||||
- 对比不同参数的效果
|
||||
- 选择最优参数组合
|
||||
|
||||
### 12.2 模型优化
|
||||
|
||||
**机器学习集成**:
|
||||
- 使用机器学习模型预测获胜概率
|
||||
- 使用梯度提升决策树(XGBoost/LightGBM)
|
||||
- 定期重新训练模型
|
||||
|
||||
**特征工程优化**:
|
||||
- 添加更多特征(如历史对战数据、伤病信息等)
|
||||
- 使用特征选择技术筛选重要特征
|
||||
- 使用特征交互提高预测准确性
|
||||
|
||||
### 12.3 实时优化
|
||||
|
||||
**动态权重调整**:
|
||||
- 根据比赛阶段动态调整权重
|
||||
- 比赛早期:基础实力权重高
|
||||
- 比赛后期:实时状态权重高
|
||||
|
||||
**自适应阈值**:
|
||||
- 根据市场情况动态调整阈值
|
||||
- 如果市场波动大,提高概率阈值
|
||||
- 如果市场稳定,降低概率阈值
|
||||
|
||||
---
|
||||
|
||||
## 十三、算法输出
|
||||
|
||||
### 13.1 信号输出格式
|
||||
|
||||
**买入信号**:
|
||||
```
|
||||
{
|
||||
"signalType": "BUY",
|
||||
"strategyId": "策略ID",
|
||||
"gameId": "比赛ID",
|
||||
"marketId": "市场ID",
|
||||
"direction": "YES" | "NO",
|
||||
"price": 0.65,
|
||||
"quantity": 10.0,
|
||||
"totalAmount": 6.5,
|
||||
"reason": "主队综合评分: 0.72, 客队综合评分: 0.58, 获胜概率: 0.62, 交易价值: 0.08, 主要优势: 基础实力",
|
||||
"winProbability": 0.62,
|
||||
"tradeValue": 0.08,
|
||||
"timestamp": 1234567890
|
||||
}
|
||||
```
|
||||
|
||||
**卖出信号**:
|
||||
```
|
||||
{
|
||||
"signalType": "SELL",
|
||||
"strategyId": "策略ID",
|
||||
"gameId": "比赛ID",
|
||||
"marketId": "市场ID",
|
||||
"direction": "YES" | "NO",
|
||||
"price": 0.75,
|
||||
"quantity": 10.0,
|
||||
"totalAmount": 7.5,
|
||||
"reason": "止盈卖出,预期收益: 15%",
|
||||
"profit": 1.0,
|
||||
"profitRate": 0.15,
|
||||
"timestamp": 1234567890
|
||||
}
|
||||
```
|
||||
|
||||
### 13.2 算法执行日志
|
||||
|
||||
**执行日志格式**:
|
||||
```
|
||||
{
|
||||
"timestamp": 1234567890,
|
||||
"strategyId": "策略ID",
|
||||
"gameId": "比赛ID",
|
||||
"step": "数据获取" | "特征提取" | "评分计算" | "信号生成",
|
||||
"status": "SUCCESS" | "FAILED" | "SKIPPED",
|
||||
"message": "执行信息",
|
||||
"data": {} // 相关数据
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十四、算法验证
|
||||
|
||||
### 14.1 回测验证
|
||||
|
||||
**回测流程**:
|
||||
1. 使用历史比赛数据
|
||||
2. 模拟算法执行过程
|
||||
3. 计算信号生成情况
|
||||
4. 计算交易结果和盈亏
|
||||
5. 评估算法效果
|
||||
|
||||
**评估指标**:
|
||||
- 信号生成数量
|
||||
- 信号准确率(实际获胜 vs 预测获胜)
|
||||
- 总盈亏金额
|
||||
- 盈亏比率
|
||||
- 最大回撤
|
||||
|
||||
### 14.2 实时验证
|
||||
|
||||
**实时监控**:
|
||||
- 监控算法执行情况
|
||||
- 记录信号生成和交易结果
|
||||
- 对比预测和实际结果
|
||||
- 评估算法实时表现
|
||||
|
||||
**持续优化**:
|
||||
- 根据实时表现调整参数
|
||||
- 优化算法逻辑
|
||||
- 提高预测准确性
|
||||
|
||||
---
|
||||
|
||||
**文档结束**
|
||||
|
||||
@@ -1,732 +0,0 @@
|
||||
# NBA 量化交易系统产品需求文档
|
||||
|
||||
## 文档信息
|
||||
|
||||
- **文档版本**: v1.0
|
||||
- **创建日期**: 2024-12
|
||||
- **文档类型**: 产品需求文档 (PRD)
|
||||
- **目标用户**: 产品经理、开发团队、测试团队
|
||||
|
||||
---
|
||||
|
||||
## 一、产品概述
|
||||
|
||||
### 1.1 产品定位
|
||||
|
||||
NBA 量化交易系统是一个基于 NBA 比赛数据的智能量化交易平台,帮助用户通过配置量化策略参数,自动分析 NBA 比赛数据,生成买入/卖出交易信号,实现自动化交易决策。
|
||||
|
||||
### 1.2 产品目标
|
||||
|
||||
**核心目标**:
|
||||
- 提供简单易用的策略配置界面,降低量化交易门槛
|
||||
- 实时获取和分析 NBA 比赛数据,提供及时的交易信号
|
||||
- 支持多种量化策略,满足不同用户需求
|
||||
- 提供完善的风险控制机制,保障交易安全
|
||||
|
||||
**用户价值**:
|
||||
- **自动化决策**:无需人工盯盘,系统自动分析并生成交易信号
|
||||
- **数据驱动**:基于专业的 NBA 数据分析,提高决策准确性
|
||||
- **风险可控**:完善的风险控制参数,降低交易风险
|
||||
- **实时响应**:实时数据更新和信号推送,不错过交易机会
|
||||
|
||||
### 1.3 目标用户
|
||||
|
||||
**主要用户群体**:
|
||||
1. **量化交易爱好者**:对 NBA 和量化交易都有兴趣的用户
|
||||
2. **NBA 数据分析师**:希望将数据分析转化为交易信号的用户
|
||||
3. **自动化交易用户**:希望通过自动化系统进行交易的用户
|
||||
|
||||
**用户特征**:
|
||||
- 对 NBA 比赛有一定了解
|
||||
- 对量化交易有基本认知
|
||||
- 希望通过数据驱动的方式进行交易决策
|
||||
- 需要自动化工具提高交易效率
|
||||
|
||||
---
|
||||
|
||||
## 二、产品功能需求
|
||||
|
||||
### 2.1 策略配置管理
|
||||
|
||||
#### 2.1.1 策略创建
|
||||
|
||||
**功能描述**:
|
||||
用户可以在前端创建新的量化策略,配置策略的各项参数。
|
||||
|
||||
**功能点**:
|
||||
- **策略基本信息**:
|
||||
- 策略名称:用户自定义策略名称(必填,1-50 字符)
|
||||
- 策略描述:策略的简要说明(可选,最多 200 字符)
|
||||
- 关联账户:选择用于交易的账户(必填)
|
||||
- 启用状态:创建时默认启用,可后续修改
|
||||
|
||||
- **触发条件配置**:
|
||||
- 比赛筛选:
|
||||
- 按球队筛选:选择关注的球队(可选,不选择则分析所有比赛)
|
||||
- 按日期筛选:选择关注的日期范围
|
||||
- 按重要性筛选:选择比赛重要性(常规赛/季后赛/关键战)
|
||||
- 数据指标选择:
|
||||
- 基础指标:分差、剩余时间、当前节次、比赛状态
|
||||
- 统计指标:球队净效率值、球员 PER、真实命中率等
|
||||
- 高级指标:势头动量、球星爆发因子、阵容对位系数等
|
||||
- 说明:系统会综合分析两支队伍的各项指标,计算综合评分和获胜概率
|
||||
- 触发阈值设置:
|
||||
- 最小获胜概率差异:主队和客队获胜概率的最小差异(如 0.1 表示至少 10% 的差异)
|
||||
- 最小交易价值:交易价值评分的最小值(如 0.05)
|
||||
- 最小剩余时间:生成信号时的最小剩余时间(如 5 分钟,可选)
|
||||
- 说明:只有当系统计算出某支队伍获胜概率明显高于对手,且交易价值达到阈值时,才会生成买入信号
|
||||
|
||||
- **交易规则配置**:
|
||||
- 买入规则:
|
||||
- 买入条件:系统自动分析两支队伍,当某支队伍获胜概率明显高于对手且交易价值达到阈值时触发买入
|
||||
- 买入金额:固定金额或按比例计算或动态计算(基于交易价值)
|
||||
- 买入时机:立即买入或延迟买入
|
||||
- 买入方向:由系统自动判断(YES 或 NO,基于获胜概率高的队伍)
|
||||
- 说明:系统会综合分析两支队伍,选择获胜概率更高、交易价值更大的方向进行买入
|
||||
- 卖出规则:
|
||||
- 卖出条件:满足止盈、止损或概率反转条件时触发卖出
|
||||
- 卖出金额:全部卖出或部分卖出
|
||||
- 卖出时机:立即卖出或延迟卖出
|
||||
- 价格策略:
|
||||
- 固定价格:使用固定价格
|
||||
- 市场价格:使用当前市场价格
|
||||
- 动态价格:根据获胜概率和市场情况动态计算价格
|
||||
- 价格偏移:允许的价格偏差范围(用于调整价格,提高交易成功率)
|
||||
|
||||
- **风险控制配置**:
|
||||
- 持仓限制:
|
||||
- 最大持仓:单次最大买入金额(必填,>= 1 USDC)
|
||||
- 最小持仓:单次最小买入金额(必填,>= 1 USDC)
|
||||
- 每日限制:
|
||||
- 每日亏损限制:每日最大亏损金额(可选)
|
||||
- 每日订单限制:每日最大订单数量(可选)
|
||||
- 价格容忍度:
|
||||
- 价格偏差范围:允许的价格偏差百分比(可选,0-100%)
|
||||
|
||||
- **高级配置**:
|
||||
- 数据更新频率:NBA 数据更新频率(30 秒/1 分钟/5 分钟)
|
||||
- 分析频率:量化分析执行频率(30 秒/1 分钟/5 分钟)
|
||||
- 推送设置:
|
||||
- 是否推送失败订单:默认关闭
|
||||
- 推送频率:实时推送/批量推送
|
||||
|
||||
**交互流程**:
|
||||
1. 用户点击"创建策略"按钮
|
||||
2. 进入策略配置页面
|
||||
3. 填写策略基本信息
|
||||
4. 配置触发条件(可添加多个条件)
|
||||
5. 配置交易规则
|
||||
6. 配置风险控制参数
|
||||
7. 配置高级选项
|
||||
8. 点击"保存"按钮,系统验证配置
|
||||
9. 保存成功,返回策略列表
|
||||
|
||||
**验证规则**:
|
||||
- 策略名称不能为空,不能重复
|
||||
- 必须选择关联账户
|
||||
- 必须配置至少一个触发条件
|
||||
- 必须配置买入或卖出规则
|
||||
- 最大持仓必须 >= 最小持仓
|
||||
- 价格容忍度必须在 0-100% 范围内
|
||||
|
||||
#### 2.1.2 策略编辑
|
||||
|
||||
**功能描述**:
|
||||
用户可以编辑已有策略的配置参数。
|
||||
|
||||
**功能点**:
|
||||
- 支持修改策略的所有配置参数
|
||||
- 支持启用/禁用策略
|
||||
- 修改后立即生效(如果策略正在运行,会重新加载配置)
|
||||
|
||||
**交互流程**:
|
||||
1. 用户在策略列表中点击"编辑"按钮
|
||||
2. 进入策略编辑页面(与创建页面类似)
|
||||
3. 修改配置参数
|
||||
4. 点击"保存"按钮
|
||||
5. 系统验证并保存配置
|
||||
6. 返回策略列表
|
||||
|
||||
#### 2.1.3 策略删除
|
||||
|
||||
**功能描述**:
|
||||
用户可以删除不需要的策略。
|
||||
|
||||
**功能点**:
|
||||
- 删除前需要确认(防止误删)
|
||||
- 删除策略时,会停止该策略的所有量化任务
|
||||
- 删除后,该策略的历史信号记录会保留(用于统计分析)
|
||||
|
||||
**交互流程**:
|
||||
1. 用户在策略列表中点击"删除"按钮
|
||||
2. 弹出确认对话框
|
||||
3. 用户确认删除
|
||||
4. 系统删除策略并停止相关任务
|
||||
5. 返回策略列表
|
||||
|
||||
#### 2.1.4 策略列表
|
||||
|
||||
**功能描述**:
|
||||
展示所有策略配置,支持搜索、筛选、排序。
|
||||
|
||||
**功能点**:
|
||||
- **列表展示**:
|
||||
- 策略名称
|
||||
- 关联账户
|
||||
- 启用状态(启用/禁用)
|
||||
- 创建时间
|
||||
- 最后更新时间
|
||||
- 操作按钮(编辑/删除/启用/禁用)
|
||||
|
||||
- **搜索功能**:
|
||||
- 按策略名称搜索
|
||||
- 按账户名称搜索
|
||||
|
||||
- **筛选功能**:
|
||||
- 按启用状态筛选(全部/启用/禁用)
|
||||
- 按账户筛选
|
||||
|
||||
- **排序功能**:
|
||||
- 按创建时间排序(最新/最旧)
|
||||
- 按更新时间排序(最新/最旧)
|
||||
- 按策略名称排序(A-Z/Z-A)
|
||||
|
||||
- **分页功能**:
|
||||
- 每页显示 20 条记录
|
||||
- 支持翻页
|
||||
|
||||
**交互流程**:
|
||||
1. 用户进入策略列表页面
|
||||
2. 系统加载策略列表
|
||||
3. 用户可以搜索、筛选、排序
|
||||
4. 用户可以点击操作按钮进行编辑、删除、启用/禁用
|
||||
|
||||
### 2.2 交易信号展示
|
||||
|
||||
#### 2.2.1 实时信号列表
|
||||
|
||||
**功能描述**:
|
||||
实时展示系统生成的交易信号。
|
||||
|
||||
**功能点**:
|
||||
- **信号展示**:
|
||||
- 信号类型(买入/卖出)
|
||||
- 策略名称
|
||||
- 市场信息(市场 ID、市场标题)
|
||||
- 方向(YES/NO)
|
||||
- 价格
|
||||
- 数量
|
||||
- 触发原因
|
||||
- 生成时间
|
||||
- 信号状态(已生成/执行中/执行成功/执行失败)
|
||||
|
||||
- **实时更新**:
|
||||
- 通过 WebSocket 实时接收新信号
|
||||
- 新信号自动添加到列表顶部
|
||||
- 信号状态更新时实时刷新
|
||||
|
||||
- **筛选功能**:
|
||||
- 按信号类型筛选(全部/买入/卖出)
|
||||
- 按策略筛选
|
||||
- 按状态筛选(全部/已生成/执行中/成功/失败)
|
||||
- 按时间范围筛选
|
||||
|
||||
- **排序功能**:
|
||||
- 按生成时间排序(最新/最旧)
|
||||
- 按价格排序(高到低/低到高)
|
||||
|
||||
- **分页功能**:
|
||||
- 每页显示 50 条记录
|
||||
- 支持翻页
|
||||
|
||||
**交互流程**:
|
||||
1. 用户进入交易信号页面
|
||||
2. 系统建立 WebSocket 连接
|
||||
3. 系统订阅交易信号推送
|
||||
4. 实时接收并展示信号
|
||||
5. 用户可以筛选、排序、查看详情
|
||||
|
||||
#### 2.2.2 信号详情
|
||||
|
||||
**功能描述**:
|
||||
展示信号的详细信息。
|
||||
|
||||
**功能点**:
|
||||
- **基本信息**:
|
||||
- 信号 ID
|
||||
- 信号类型(买入/卖出)
|
||||
- 策略名称
|
||||
- 生成时间
|
||||
|
||||
- **市场信息**:
|
||||
- 市场 ID
|
||||
- 市场标题
|
||||
- 市场描述
|
||||
- 市场分类
|
||||
|
||||
- **交易信息**:
|
||||
- 方向(YES/NO)
|
||||
- 价格
|
||||
- 数量
|
||||
- 总金额
|
||||
|
||||
- **触发信息**:
|
||||
- 触发原因(详细说明)
|
||||
- 触发条件(展示满足的条件)
|
||||
- 触发时的比赛数据(分差、剩余时间等)
|
||||
|
||||
- **执行信息**:
|
||||
- 执行状态
|
||||
- 执行结果
|
||||
- 执行时间
|
||||
- 错误信息(如果执行失败)
|
||||
|
||||
**交互流程**:
|
||||
1. 用户在信号列表中点击某个信号
|
||||
2. 弹出信号详情对话框
|
||||
3. 展示信号的详细信息
|
||||
4. 用户可以关闭对话框
|
||||
|
||||
### 2.3 结果统计
|
||||
|
||||
#### 2.3.1 策略统计
|
||||
|
||||
**功能描述**:
|
||||
展示每个策略的统计信息。
|
||||
|
||||
**功能点**:
|
||||
- **统计指标**:
|
||||
- 信号总数:该策略生成的信号总数
|
||||
- 买入信号数:买入信号数量
|
||||
- 卖出信号数:卖出信号数量
|
||||
- 成功率:执行成功的信号占比
|
||||
- 总盈亏:该策略的总盈亏金额
|
||||
- 平均盈亏:平均每个信号的盈亏金额
|
||||
|
||||
- **时间维度**:
|
||||
- 今日统计
|
||||
- 本周统计
|
||||
- 本月统计
|
||||
- 全部统计
|
||||
|
||||
- **可视化展示**:
|
||||
- 使用图表展示统计趋势
|
||||
- 使用饼图展示信号类型分布
|
||||
- 使用柱状图展示每日信号数量
|
||||
|
||||
**交互流程**:
|
||||
1. 用户进入策略统计页面
|
||||
2. 系统加载策略列表和统计信息
|
||||
3. 用户选择某个策略查看详细统计
|
||||
4. 用户可以切换时间维度查看不同时期的统计
|
||||
|
||||
#### 2.3.2 总体统计
|
||||
|
||||
**功能描述**:
|
||||
展示所有策略的总体统计信息。
|
||||
|
||||
**功能点**:
|
||||
- **总体指标**:
|
||||
- 总策略数:启用的策略数量
|
||||
- 总信号数:所有策略生成的信号总数
|
||||
- 总成功率:所有信号的平均成功率
|
||||
- 总盈亏:所有策略的总盈亏金额
|
||||
- 平均盈亏:平均每个信号的盈亏金额
|
||||
|
||||
- **趋势分析**:
|
||||
- 信号数量趋势(按日/周/月)
|
||||
- 成功率趋势(按日/周/月)
|
||||
- 盈亏趋势(按日/周/月)
|
||||
|
||||
- **排行榜**:
|
||||
- 信号数量排行榜(按策略)
|
||||
- 成功率排行榜(按策略)
|
||||
- 盈亏排行榜(按策略)
|
||||
|
||||
**交互流程**:
|
||||
1. 用户进入总体统计页面
|
||||
2. 系统加载总体统计信息
|
||||
3. 用户可以查看趋势图表和排行榜
|
||||
4. 用户可以切换时间维度查看不同时期的统计
|
||||
|
||||
### 2.4 NBA 数据展示(可选功能)
|
||||
|
||||
#### 2.4.1 比赛数据展示
|
||||
|
||||
**功能描述**:
|
||||
展示 NBA 比赛的实时数据。
|
||||
|
||||
**功能点**:
|
||||
- **比赛列表**:
|
||||
- 展示今日/本周/本月的比赛
|
||||
- 显示比赛状态(未开始/进行中/已结束)
|
||||
- 显示比分和分差
|
||||
|
||||
- **比赛详情**:
|
||||
- 比赛基本信息(日期、主客场、比分等)
|
||||
- 实时比分和分差
|
||||
- 当前节次和剩余时间
|
||||
- Play-by-Play 数据
|
||||
|
||||
**交互流程**:
|
||||
1. 用户进入 NBA 数据页面
|
||||
2. 系统加载比赛列表
|
||||
3. 用户可以查看比赛详情
|
||||
4. 实时更新比赛数据(如果比赛正在进行)
|
||||
|
||||
---
|
||||
|
||||
## 三、用户场景
|
||||
|
||||
### 3.1 场景一:创建量化策略
|
||||
|
||||
**用户**:量化交易爱好者
|
||||
|
||||
**场景描述**:
|
||||
用户希望创建一个量化策略,系统自动分析比赛的两支队伍,根据综合评分和获胜概率,选择可盈利的队伍进行买入。
|
||||
|
||||
**操作流程**:
|
||||
1. 用户登录系统,进入策略配置页面
|
||||
2. 点击"创建策略"按钮
|
||||
3. 填写策略名称:"综合实力分析策略"
|
||||
4. 选择关联账户
|
||||
5. 配置触发条件:
|
||||
- 比赛筛选:选择关注的比赛(可按球队、日期筛选)
|
||||
- 数据指标选择:选择用于分析的指标
|
||||
- 基础指标:分差、剩余时间、当前节次
|
||||
- 统计指标:球队净效率值、球员PER、真实命中率
|
||||
- 高级指标:势头动量、球星爆发因子、阵容对位系数
|
||||
- 触发阈值设置:
|
||||
- 最小获胜概率差异:0.1(主队和客队获胜概率差异至少 10%)
|
||||
- 最小交易价值:0.05(交易价值评分至少 0.05)
|
||||
6. 配置买入规则:
|
||||
- 买入条件:当系统计算出某支队伍获胜概率明显高于对手,且交易价值评分达到阈值时触发买入
|
||||
- 买入金额:固定金额 10 USDC
|
||||
- 买入方向:由系统自动判断(YES 或 NO,基于获胜概率)
|
||||
- 价格策略:动态价格(基于获胜概率和市场情况)
|
||||
7. 配置风险控制:
|
||||
- 最大持仓:50 USDC
|
||||
- 最小持仓:5 USDC
|
||||
- 每日亏损限制:100 USDC
|
||||
8. 点击"保存"按钮
|
||||
9. 系统验证配置并保存
|
||||
10. 策略创建成功,自动启用
|
||||
|
||||
**预期结果**:
|
||||
- 策略创建成功并启用
|
||||
- 系统开始监控 NBA 比赛数据
|
||||
- 当满足条件时,自动生成买入信号
|
||||
|
||||
### 3.2 场景二:查看交易信号
|
||||
|
||||
**用户**:量化交易爱好者
|
||||
|
||||
**场景描述**:
|
||||
用户希望实时查看系统生成的交易信号,了解策略的执行情况。
|
||||
|
||||
**操作流程**:
|
||||
1. 用户登录系统,进入交易信号页面
|
||||
2. 系统自动建立 WebSocket 连接
|
||||
3. 系统订阅交易信号推送
|
||||
4. 实时接收并展示新信号
|
||||
5. 用户可以查看信号详情:
|
||||
- 点击某个信号,查看详细信息
|
||||
- 查看触发原因和触发时的比赛数据
|
||||
- 查看执行状态和结果
|
||||
6. 用户可以筛选信号:
|
||||
- 按策略筛选:只查看特定策略的信号
|
||||
- 按类型筛选:只查看买入或卖出信号
|
||||
- 按状态筛选:只查看成功或失败的信号
|
||||
|
||||
**预期结果**:
|
||||
- 实时接收并展示交易信号
|
||||
- 可以查看信号的详细信息
|
||||
- 可以按需筛选和排序信号
|
||||
|
||||
### 3.3 场景三:查看策略统计
|
||||
|
||||
**用户**:量化交易爱好者
|
||||
|
||||
**场景描述**:
|
||||
用户希望查看策略的执行效果,了解策略的盈亏情况。
|
||||
|
||||
**操作流程**:
|
||||
1. 用户登录系统,进入策略统计页面
|
||||
2. 系统加载所有策略的统计信息
|
||||
3. 用户选择某个策略查看详细统计:
|
||||
- 查看信号总数、成功率、总盈亏等指标
|
||||
- 查看信号数量趋势图
|
||||
- 查看盈亏趋势图
|
||||
4. 用户可以切换时间维度:
|
||||
- 查看今日统计
|
||||
- 查看本周统计
|
||||
- 查看本月统计
|
||||
- 查看全部统计
|
||||
5. 用户可以查看总体统计:
|
||||
- 查看所有策略的总体指标
|
||||
- 查看趋势分析图表
|
||||
- 查看排行榜
|
||||
|
||||
**预期结果**:
|
||||
- 清晰展示策略的执行效果
|
||||
- 提供多维度的时间统计
|
||||
- 帮助用户优化策略配置
|
||||
|
||||
---
|
||||
|
||||
## 四、界面设计说明
|
||||
|
||||
### 4.1 策略配置页面
|
||||
|
||||
#### 4.1.1 页面布局
|
||||
|
||||
**整体布局**:
|
||||
- 顶部:页面标题"创建策略"或"编辑策略"
|
||||
- 左侧:配置导航(基本信息、触发条件、交易规则、风险控制、高级配置)
|
||||
- 中间:配置表单区域
|
||||
- 底部:操作按钮(保存、取消)
|
||||
|
||||
**配置表单**:
|
||||
- 使用分步骤表单,引导用户逐步配置
|
||||
- 每个步骤有清晰的说明和示例
|
||||
- 必填项用红色星号标记
|
||||
- 提供实时验证和错误提示
|
||||
|
||||
#### 4.1.2 关键组件
|
||||
|
||||
**触发条件配置组件**:
|
||||
- 条件列表:展示已添加的触发条件
|
||||
- 添加条件按钮:点击后弹出条件配置对话框
|
||||
- 条件编辑:支持编辑和删除已有条件
|
||||
- 条件组合:支持设置条件之间的逻辑关系(AND/OR)
|
||||
|
||||
**交易规则配置组件**:
|
||||
- 买入规则配置区域
|
||||
- 卖出规则配置区域
|
||||
- 价格策略选择(固定价格/动态价格)
|
||||
- 价格偏移设置
|
||||
|
||||
**风险控制配置组件**:
|
||||
- 持仓限制输入框
|
||||
- 每日限制输入框
|
||||
- 价格容忍度滑块
|
||||
|
||||
### 4.2 交易信号页面
|
||||
|
||||
#### 4.2.1 页面布局
|
||||
|
||||
**整体布局**:
|
||||
- 顶部:筛选和搜索栏
|
||||
- 中间:信号列表(表格形式)
|
||||
- 底部:分页组件
|
||||
|
||||
**信号列表表格**:
|
||||
- 列:信号类型、策略名称、市场信息、方向、价格、数量、触发原因、生成时间、状态、操作
|
||||
- 支持列排序
|
||||
- 支持行点击查看详情
|
||||
|
||||
#### 4.2.2 关键组件
|
||||
|
||||
**实时信号提示**:
|
||||
- 新信号到达时,顶部显示提示消息
|
||||
- 信号列表自动滚动到顶部
|
||||
- 新信号高亮显示(3 秒后恢复正常)
|
||||
|
||||
**信号状态标签**:
|
||||
- 已生成:蓝色标签
|
||||
- 执行中:黄色标签
|
||||
- 执行成功:绿色标签
|
||||
- 执行失败:红色标签
|
||||
|
||||
**信号详情对话框**:
|
||||
- 模态对话框形式
|
||||
- 展示信号的完整信息
|
||||
- 支持关闭和复制信息
|
||||
|
||||
### 4.3 统计页面
|
||||
|
||||
#### 4.3.1 页面布局
|
||||
|
||||
**整体布局**:
|
||||
- 顶部:时间维度选择(今日/本周/本月/全部)
|
||||
- 左侧:策略列表(如果查看策略统计)
|
||||
- 中间:统计图表和数据表格
|
||||
- 右侧:关键指标卡片
|
||||
|
||||
**统计图表**:
|
||||
- 使用 ECharts 或类似图表库
|
||||
- 支持交互(缩放、筛选等)
|
||||
- 支持导出图片
|
||||
|
||||
#### 4.3.2 关键组件
|
||||
|
||||
**指标卡片**:
|
||||
- 大数字显示关键指标
|
||||
- 支持对比(与上期对比)
|
||||
- 支持趋势箭头(上升/下降)
|
||||
|
||||
**趋势图表**:
|
||||
- 折线图:展示趋势变化
|
||||
- 柱状图:展示数量对比
|
||||
- 饼图:展示分布情况
|
||||
|
||||
---
|
||||
|
||||
## 五、非功能性需求
|
||||
|
||||
### 5.1 性能需求
|
||||
|
||||
**响应时间**:
|
||||
- 页面加载时间:< 2 秒
|
||||
- API 响应时间:< 1 秒
|
||||
- WebSocket 推送延迟:< 100 毫秒
|
||||
|
||||
**并发性能**:
|
||||
- 支持至少 100 个并发用户
|
||||
- 支持至少 1000 个策略同时运行
|
||||
- 支持至少 10000 个信号/天的处理能力
|
||||
|
||||
### 5.2 可用性需求
|
||||
|
||||
**系统可用性**:
|
||||
- 系统可用性:>= 99.5%
|
||||
- 故障恢复时间:< 5 分钟
|
||||
|
||||
**数据实时性**:
|
||||
- NBA 数据更新延迟:< 1 分钟
|
||||
- 交易信号推送延迟:< 5 秒
|
||||
|
||||
### 5.3 安全性需求
|
||||
|
||||
**数据安全**:
|
||||
- 用户配置数据加密存储
|
||||
- API 接口需要身份认证
|
||||
- WebSocket 连接需要身份认证
|
||||
|
||||
**风险控制**:
|
||||
- 所有交易信号需要经过风险控制检查
|
||||
- 异常情况自动告警
|
||||
- 支持紧急停止策略
|
||||
|
||||
### 5.4 兼容性需求
|
||||
|
||||
**浏览器兼容**:
|
||||
- Chrome(最新 2 个版本)
|
||||
- Firefox(最新 2 个版本)
|
||||
- Safari(最新 2 个版本)
|
||||
- Edge(最新 2 个版本)
|
||||
|
||||
**设备兼容**:
|
||||
- 桌面端:1920x1080 及以上分辨率
|
||||
- 平板端:768x1024 及以上分辨率
|
||||
- 移动端:375x667 及以上分辨率(响应式设计)
|
||||
|
||||
---
|
||||
|
||||
## 六、产品特性
|
||||
|
||||
### 6.1 核心特性
|
||||
|
||||
1. **简单易用**:
|
||||
- 直观的配置界面,降低使用门槛
|
||||
- 清晰的步骤引导,帮助用户快速上手
|
||||
- 丰富的帮助文档和示例
|
||||
|
||||
2. **实时响应**:
|
||||
- 实时获取 NBA 比赛数据
|
||||
- 实时执行量化分析
|
||||
- 实时推送交易信号
|
||||
|
||||
3. **数据驱动**:
|
||||
- 基于专业的 NBA 数据分析
|
||||
- 支持多种数据指标和特征
|
||||
- 提供数据可视化展示
|
||||
|
||||
4. **风险可控**:
|
||||
- 完善的风险控制参数
|
||||
- 自动风险检查机制
|
||||
- 异常情况告警
|
||||
|
||||
### 6.2 扩展特性
|
||||
|
||||
1. **策略模板**:
|
||||
- 提供预定义的策略模板
|
||||
- 用户可以基于模板快速创建策略
|
||||
- 支持模板的导入和导出
|
||||
|
||||
2. **回测功能**:
|
||||
- 支持历史数据回测
|
||||
- 评估策略的历史表现
|
||||
- 优化策略参数
|
||||
|
||||
3. **多账户支持**:
|
||||
- 支持多个交易账户
|
||||
- 每个账户可以配置不同的策略
|
||||
- 统一管理和监控
|
||||
|
||||
---
|
||||
|
||||
## 七、产品路线图
|
||||
|
||||
### 7.1 第一阶段:MVP 版本(2-3 个月)
|
||||
|
||||
**核心功能**:
|
||||
- 策略配置管理(创建、编辑、删除、列表)
|
||||
- 基础量化分析(简单策略逻辑)
|
||||
- 交易信号生成和推送
|
||||
- 基础统计展示
|
||||
|
||||
**目标**:
|
||||
- 验证产品可行性
|
||||
- 收集用户反馈
|
||||
- 优化核心功能
|
||||
|
||||
### 7.2 第二阶段:功能完善(2-3 个月)
|
||||
|
||||
**新增功能**:
|
||||
- 完善量化分析逻辑(支持复杂策略)
|
||||
- 完善特征提取和数据处理
|
||||
- 完善统计和可视化
|
||||
- 策略模板功能
|
||||
|
||||
**目标**:
|
||||
- 提升产品功能完整性
|
||||
- 提升用户体验
|
||||
- 扩大用户群体
|
||||
|
||||
### 7.3 第三阶段:高级功能(3-4 个月)
|
||||
|
||||
**新增功能**:
|
||||
- 回测功能
|
||||
- 机器学习集成
|
||||
- 多账户支持
|
||||
- 高级统计分析
|
||||
|
||||
**目标**:
|
||||
- 提升产品竞争力
|
||||
- 满足高级用户需求
|
||||
- 建立产品壁垒
|
||||
|
||||
---
|
||||
|
||||
## 八、附录
|
||||
|
||||
### 8.1 术语表
|
||||
|
||||
- **量化策略**:基于数据分析和算法模型的交易策略
|
||||
- **交易信号**:系统生成的买入或卖出建议
|
||||
- **触发条件**:满足哪些条件时生成交易信号
|
||||
- **风险控制**:限制交易风险的各种参数和机制
|
||||
- **WebSocket**:一种实时通信协议,用于推送数据
|
||||
|
||||
### 8.2 参考文档
|
||||
|
||||
- NBA 比赛分析框架文档(NBA.md)
|
||||
- 技术方案文档(nba-api-integration-technical-solution.md)
|
||||
- API 接口文档(待补充)
|
||||
- 用户手册(待补充)
|
||||
|
||||
---
|
||||
|
||||
**文档结束**
|
||||
|
||||
@@ -1,567 +0,0 @@
|
||||
# NBA 量化策略前端可配置参数清单
|
||||
|
||||
## 一、参数分类
|
||||
|
||||
前端可配置参数分为以下几类:
|
||||
1. **策略基本信息**:策略名称、描述等
|
||||
2. **比赛筛选参数**:选择关注的比赛
|
||||
3. **触发条件参数**:控制何时生成交易信号
|
||||
4. **交易规则参数**:控制如何执行交易
|
||||
5. **风险控制参数**:限制交易风险
|
||||
6. **算法权重参数**(高级):调整算法内部权重
|
||||
7. **系统配置参数**:数据更新频率、推送设置等
|
||||
|
||||
---
|
||||
|
||||
## 二、详细参数列表
|
||||
|
||||
### 2.1 策略基本信息(必填)
|
||||
|
||||
| 参数名称 | 类型 | 必填 | 默认值 | 说明 | UI 组件 |
|
||||
|---------|------|------|--------|------|---------|
|
||||
| **策略名称** | String | ✅ | - | 用户自定义策略名称,1-50 字符 | Input |
|
||||
| **策略描述** | String | ❌ | - | 策略的简要说明,最多 200 字符 | TextArea |
|
||||
| **关联账户** | Long | ✅ | - | 选择用于交易的账户 | Select |
|
||||
| **启用状态** | Boolean | ❌ | true | 是否启用该策略 | Switch |
|
||||
|
||||
---
|
||||
|
||||
### 2.2 比赛筛选参数(可选)
|
||||
|
||||
| 参数名称 | 类型 | 必填 | 默认值 | 说明 | UI 组件 |
|
||||
|---------|------|------|--------|------|---------|
|
||||
| **关注球队** | List\<String\> | ❌ | [] | 选择关注的球队(可选,不选择则分析所有比赛) | MultiSelect |
|
||||
| **日期范围** | DateRange | ❌ | 今日 | 选择关注的日期范围 | DateRangePicker |
|
||||
| **比赛重要性** | Enum | ❌ | 全部 | 选择比赛重要性(全部/常规赛/季后赛/关键战) | Select |
|
||||
| **主客场筛选** | Enum | ❌ | 全部 | 筛选主队或客队(全部/仅主队/仅客队) | Select |
|
||||
|
||||
---
|
||||
|
||||
### 2.3 触发条件参数(必填)
|
||||
|
||||
#### 2.3.1 概率阈值参数
|
||||
|
||||
| 参数名称 | 类型 | 必填 | 默认值 | 取值范围 | 说明 | UI 组件 |
|
||||
|---------|------|------|--------|----------|------|---------|
|
||||
| **最小获胜概率差异** | Decimal | ✅ | 0.1 | 0.05 - 0.5 | 主队和客队获胜概率的最小差异(如 0.1 表示至少 10% 的差异) | InputNumber (0-1, 步长 0.01) |
|
||||
| **最小获胜概率** | Decimal | ❌ | 0.55 | 0.5 - 1.0 | 生成买入信号时的最小获胜概率(可选,不设置则不限制) | InputNumber (0-1, 步长 0.01) |
|
||||
| **最大获胜概率** | Decimal | ❌ | - | 0.0 - 0.5 | 生成买入信号时的最大获胜概率(可选,用于反向策略) | InputNumber (0-1, 步长 0.01) |
|
||||
|
||||
#### 2.3.2 交易价值参数
|
||||
|
||||
| 参数名称 | 类型 | 必填 | 默认值 | 取值范围 | 说明 | UI 组件 |
|
||||
|---------|------|------|--------|----------|------|---------|
|
||||
| **最小交易价值** | Decimal | ✅ | 0.05 | 0.0 - 1.0 | 交易价值评分的最小值,只有达到此值才会生成信号 | InputNumber (0-1, 步长 0.01) |
|
||||
|
||||
#### 2.3.3 时间条件参数
|
||||
|
||||
| 参数名称 | 类型 | 必填 | 默认值 | 取值范围 | 说明 | UI 组件 |
|
||||
|---------|------|------|--------|----------|------|---------|
|
||||
| **最小剩余时间** | Integer | ❌ | 5 | 0 - 48 | 生成信号时的最小剩余时间(分钟),0 表示不限制 | InputNumber (0-48) |
|
||||
| **最大剩余时间** | Integer | ❌ | - | 0 - 48 | 生成信号时的最大剩余时间(分钟),可选 | InputNumber (0-48) |
|
||||
| **比赛阶段限制** | Enum | ❌ | 全部 | 全部/仅比赛前/仅比赛中/仅比赛后 | 限制在哪个阶段生成信号 | Select |
|
||||
|
||||
#### 2.3.4 分差条件参数(可选)
|
||||
|
||||
| 参数名称 | 类型 | 必填 | 默认值 | 取值范围 | 说明 | UI 组件 |
|
||||
|---------|------|------|--------|----------|------|---------|
|
||||
| **最小分差** | Integer | ❌ | - | -50 - 50 | 生成信号时的最小分差(主队得分 - 客队得分),可选 | InputNumber |
|
||||
| **最大分差** | Integer | ❌ | - | -50 - 50 | 生成信号时的最大分差,可选 | InputNumber |
|
||||
| **分差范围** | Enum | ❌ | 全部 | 全部/小分差(<5分)/中分差(5-15分)/大分差(>15分) | 限制分差范围 | Select |
|
||||
|
||||
---
|
||||
|
||||
### 2.4 交易规则参数
|
||||
|
||||
#### 2.4.1 买入规则参数
|
||||
|
||||
| 参数名称 | 类型 | 必填 | 默认值 | 取值范围 | 说明 | UI 组件 |
|
||||
|---------|------|------|--------|----------|------|---------|
|
||||
| **买入金额策略** | Enum | ✅ | FIXED | FIXED/RATIO/DYNAMIC | 买入金额计算方式 | Select |
|
||||
| **固定买入金额** | Decimal | 条件必填 | 10 | > 0 | 固定金额策略时的买入金额(USDC) | InputNumber |
|
||||
| **买入比例** | Decimal | 条件必填 | 0.1 | 0.01 - 1.0 | 按比例策略时的买入比例(账户余额的百分比) | InputNumber (0-1, 步长 0.01) |
|
||||
| **基础买入金额** | Decimal | 条件必填 | 10 | > 0 | 动态计算策略时的基础金额(USDC) | InputNumber |
|
||||
| **买入时机** | Enum | ✅ | IMMEDIATE | IMMEDIATE/DELAYED | 立即买入或延迟买入 | Select |
|
||||
| **延迟买入时间** | Integer | 条件必填 | 0 | 0 - 300 | 延迟买入的秒数(仅在延迟买入时生效) | InputNumber (0-300) |
|
||||
| **买入方向** | Enum | ✅ | AUTO | AUTO/YES/NO | 买入方向:AUTO=系统自动判断,YES/NO=固定方向 | Select |
|
||||
| **说明** | - | - | - | - | 系统会自动分析两支队伍,选择获胜概率更高、交易价值更大的方向 | Info |
|
||||
|
||||
#### 2.4.2 卖出规则参数
|
||||
|
||||
| 参数名称 | 类型 | 必填 | 默认值 | 取值范围 | 说明 | UI 组件 |
|
||||
|---------|------|------|--------|----------|------|---------|
|
||||
| **启用卖出** | Boolean | ✅ | true | - | 是否启用卖出功能 | Switch |
|
||||
| **止盈阈值** | Decimal | ❌ | 0.2 | 0.0 - 1.0 | 预期收益达到多少时卖出(如 0.2 表示 20%) | InputNumber (0-1, 步长 0.01) |
|
||||
| **止损阈值** | Decimal | ❌ | -0.1 | -1.0 - 0.0 | 预期亏损达到多少时卖出(如 -0.1 表示 -10%) | InputNumber (-1-0, 步长 0.01) |
|
||||
| **概率反转阈值** | Decimal | ❌ | 0.15 | 0.0 - 1.0 | 获胜概率反转多少时卖出(如 0.15 表示 15%) | InputNumber (0-1, 步长 0.01) |
|
||||
| **卖出比例** | Decimal | ✅ | 1.0 | 0.1 - 1.0 | 卖出时的比例(1.0 表示全部卖出) | InputNumber (0-1, 步长 0.1) |
|
||||
| **卖出时机** | Enum | ✅ | IMMEDIATE | IMMEDIATE/DELAYED | 立即卖出或延迟卖出 | Select |
|
||||
| **延迟卖出时间** | Integer | 条件必填 | 0 | 0 - 300 | 延迟卖出的秒数(仅在延迟卖出时生效) | InputNumber (0-300) |
|
||||
|
||||
#### 2.4.3 价格策略参数
|
||||
|
||||
| 参数名称 | 类型 | 必填 | 默认值 | 取值范围 | 说明 | UI 组件 |
|
||||
|---------|------|------|--------|----------|------|---------|
|
||||
| **价格策略** | Enum | ✅ | MARKET | FIXED/MARKET/DYNAMIC | 价格计算方式 | Select |
|
||||
| **固定价格** | Decimal | 条件必填 | - | 0.01 - 0.99 | 固定价格策略时的价格(仅在固定价格时生效) | InputNumber (0-1, 步长 0.01) |
|
||||
| **价格偏移** | Decimal | ❌ | 0.0 | -0.1 - 0.1 | 价格偏移百分比(用于调整价格,提高交易成功率,如 0.05 表示 +5%) | InputNumber (-0.1-0.1, 步长 0.01) |
|
||||
|
||||
---
|
||||
|
||||
### 2.5 风险控制参数
|
||||
|
||||
#### 2.5.1 持仓限制参数
|
||||
|
||||
| 参数名称 | 类型 | 必填 | 默认值 | 取值范围 | 说明 | UI 组件 |
|
||||
|---------|------|------|--------|----------|------|---------|
|
||||
| **最大持仓** | Decimal | ✅ | 50 | >= 1 | 单次最大买入金额(USDC) | InputNumber (>= 1) |
|
||||
| **最小持仓** | Decimal | ✅ | 5 | >= 1 | 单次最小买入金额(USDC) | InputNumber (>= 1) |
|
||||
| **单场比赛最大持仓** | Decimal | ❌ | - | >= 1 | 单场比赛的最大持仓金额(USDC),可选 | InputNumber (>= 1) |
|
||||
|
||||
#### 2.5.2 每日限制参数
|
||||
|
||||
| 参数名称 | 类型 | 必填 | 默认值 | 取值范围 | 说明 | UI 组件 |
|
||||
|---------|------|------|--------|----------|------|---------|
|
||||
| **每日亏损限制** | Decimal | ❌ | 100 | > 0 | 每日最大亏损金额(USDC),可选 | InputNumber (> 0) |
|
||||
| **每日订单限制** | Integer | ❌ | 20 | > 0 | 每日最大订单数量,可选 | InputNumber (> 0) |
|
||||
| **每日盈利目标** | Decimal | ❌ | - | > 0 | 每日盈利目标(USDC),达到后停止交易,可选 | InputNumber (> 0) |
|
||||
|
||||
#### 2.5.3 价格容忍度参数
|
||||
|
||||
| 参数名称 | 类型 | 必填 | 默认值 | 取值范围 | 说明 | UI 组件 |
|
||||
|---------|------|------|--------|----------|------|---------|
|
||||
| **价格容忍度** | Decimal | ❌ | 0.05 | 0.0 - 1.0 | 允许的价格偏差百分比(如 0.05 表示 5%) | InputNumber (0-1, 步长 0.01) |
|
||||
|
||||
#### 2.5.4 概率置信度参数
|
||||
|
||||
| 参数名称 | 类型 | 必填 | 默认值 | 取值范围 | 说明 | UI 组件 |
|
||||
|---------|------|------|--------|----------|------|---------|
|
||||
| **最小概率阈值** | Decimal | ❌ | - | 0.5 - 1.0 | 最小获胜概率阈值(可选,不设置则不限制) | InputNumber (0.5-1.0, 步长 0.01) |
|
||||
| **最大概率阈值** | Decimal | ❌ | - | 0.0 - 0.5 | 最大获胜概率阈值(可选,用于反向策略) | InputNumber (0-0.5, 步长 0.01) |
|
||||
|
||||
---
|
||||
|
||||
### 2.6 算法权重参数(高级,可选)
|
||||
|
||||
#### 2.6.1 综合评分权重参数
|
||||
|
||||
| 参数名称 | 类型 | 必填 | 默认值 | 取值范围 | 说明 | UI 组件 |
|
||||
|---------|------|------|--------|----------|------|---------|
|
||||
| **基础实力权重** | Decimal | ❌ | 0.3 | 0.0 - 1.0 | 基础实力评分在综合评分中的权重 | InputNumber (0-1, 步长 0.05) |
|
||||
| **近期状态权重** | Decimal | ❌ | 0.25 | 0.0 - 1.0 | 近期状态评分在综合评分中的权重 | InputNumber (0-1, 步长 0.05) |
|
||||
| **阵容完整度权重** | Decimal | ❌ | 0.2 | 0.0 - 1.0 | 阵容完整度评分在综合评分中的权重 | InputNumber (0-1, 步长 0.05) |
|
||||
| **球星状态权重** | Decimal | ❌ | 0.15 | 0.0 - 1.0 | 球星状态评分在综合评分中的权重 | InputNumber (0-1, 步长 0.05) |
|
||||
| **环境因素权重** | Decimal | ❌ | 0.1 | 0.0 - 1.0 | 环境因素评分在综合评分中的权重 | InputNumber (0-1, 步长 0.05) |
|
||||
| **说明** | - | - | - | - | 所有权重之和应该等于 1.0,系统会自动归一化 | Info |
|
||||
|
||||
#### 2.6.2 对位分析权重参数
|
||||
|
||||
| 参数名称 | 类型 | 必填 | 默认值 | 取值范围 | 说明 | UI 组件 |
|
||||
|---------|------|------|--------|----------|------|---------|
|
||||
| **对位优势权重** | Decimal | ❌ | 0.2 | 0.0 - 1.0 | 对位优势在获胜概率调整中的权重 | InputNumber (0-1, 步长 0.05) |
|
||||
|
||||
#### 2.6.3 实时状态权重参数(仅比赛进行中)
|
||||
|
||||
| 参数名称 | 类型 | 必填 | 默认值 | 取值范围 | 说明 | UI 组件 |
|
||||
|---------|------|------|--------|----------|------|---------|
|
||||
| **分差调整权重** | Decimal | ❌ | 0.3 | 0.0 - 1.0 | 分差调整在实时概率调整中的权重 | InputNumber (0-1, 步长 0.05) |
|
||||
| **势头调整权重** | Decimal | ❌ | 0.2 | 0.0 - 1.0 | 势头调整在实时概率调整中的权重 | InputNumber (0-1, 步长 0.05) |
|
||||
|
||||
---
|
||||
|
||||
### 2.7 系统配置参数
|
||||
|
||||
#### 2.7.1 数据更新参数
|
||||
|
||||
| 参数名称 | 类型 | 必填 | 默认值 | 取值范围 | 说明 | UI 组件 |
|
||||
|---------|------|------|--------|----------|------|---------|
|
||||
| **数据更新频率** | Integer | ✅ | 30 | 10/30/60/300 | NBA 数据更新频率(秒) | Select |
|
||||
| **分析频率** | Integer | ✅ | 30 | 10/30/60/300 | 量化分析执行频率(秒) | Select |
|
||||
|
||||
#### 2.7.2 推送设置参数
|
||||
|
||||
| 参数名称 | 类型 | 必填 | 默认值 | 取值范围 | 说明 | UI 组件 |
|
||||
|---------|------|------|--------|----------|------|---------|
|
||||
| **推送失败订单** | Boolean | ❌ | false | - | 是否推送失败订单 | Switch |
|
||||
| **推送频率** | Enum | ❌ | REALTIME | REALTIME/BATCH | 实时推送或批量推送 | Select |
|
||||
| **批量推送间隔** | Integer | 条件必填 | 1 | 1 - 60 | 批量推送的时间间隔(秒,仅在批量推送时生效) | InputNumber (1-60) |
|
||||
|
||||
---
|
||||
|
||||
## 三、参数分组和页面布局建议
|
||||
|
||||
### 3.1 参数分组
|
||||
|
||||
**基础配置组**(第一页):
|
||||
- 策略基本信息
|
||||
- 比赛筛选参数
|
||||
|
||||
**触发条件组**(第二页):
|
||||
- 概率阈值参数
|
||||
- 交易价值参数
|
||||
- 时间条件参数
|
||||
- 分差条件参数
|
||||
|
||||
**交易规则组**(第三页):
|
||||
- 买入规则参数
|
||||
- 卖出规则参数
|
||||
- 价格策略参数
|
||||
|
||||
**风险控制组**(第四页):
|
||||
- 持仓限制参数
|
||||
- 每日限制参数
|
||||
- 价格容忍度参数
|
||||
- 概率置信度参数
|
||||
|
||||
**高级配置组**(第五页,可折叠):
|
||||
- 算法权重参数
|
||||
- 系统配置参数
|
||||
|
||||
### 3.2 UI 组件建议
|
||||
|
||||
**表单布局**:
|
||||
- 使用分步骤表单(Stepper),引导用户逐步配置
|
||||
- 每个步骤有清晰的标题和说明
|
||||
- 必填项用红色星号标记
|
||||
- 提供实时验证和错误提示
|
||||
|
||||
**参数展示**:
|
||||
- 使用折叠面板(Collapse)组织相关参数
|
||||
- 高级参数默认折叠,用户可展开查看
|
||||
- 提供参数说明和示例
|
||||
- 提供参数推荐值(基于历史数据)
|
||||
|
||||
**交互优化**:
|
||||
- 条件显示:某些参数只在特定条件下显示(如固定价格只在价格策略=固定时显示)
|
||||
- 联动验证:相关参数之间进行联动验证(如最大持仓 >= 最小持仓)
|
||||
- 实时预览:显示参数配置后的预期效果(如预期信号数量、预期交易频率等)
|
||||
|
||||
---
|
||||
|
||||
## 四、参数验证规则
|
||||
|
||||
### 4.1 必填参数验证
|
||||
|
||||
- 策略名称:不能为空,1-50 字符,不能重复
|
||||
- 关联账户:必须选择
|
||||
- 最小获胜概率差异:必须 > 0
|
||||
- 最小交易价值:必须 >= 0
|
||||
- 最大持仓:必须 >= 1
|
||||
- 最小持仓:必须 >= 1
|
||||
- 数据更新频率:必须选择
|
||||
- 分析频率:必须选择
|
||||
|
||||
### 4.2 数值范围验证
|
||||
|
||||
- 概率相关参数:必须在 [0, 1] 范围内
|
||||
- 金额相关参数:必须 > 0
|
||||
- 时间相关参数:必须在合理范围内(如 0-48 分钟)
|
||||
- 权重相关参数:必须在 [0, 1] 范围内,且总和应该接近 1.0
|
||||
|
||||
### 4.3 逻辑关系验证
|
||||
|
||||
- 最大持仓 >= 最小持仓
|
||||
- 最大剩余时间 >= 最小剩余时间(如果都设置了)
|
||||
- 最大分差 >= 最小分差(如果都设置了)
|
||||
- 最大概率阈值 <= 最小概率阈值(如果都设置了)
|
||||
- 权重总和应该在 0.9 - 1.1 范围内(系统会自动归一化)
|
||||
|
||||
### 4.4 条件依赖验证
|
||||
|
||||
- 如果买入金额策略 = FIXED,则固定买入金额必填
|
||||
- 如果买入金额策略 = RATIO,则买入比例必填
|
||||
- 如果买入金额策略 = DYNAMIC,则基础买入金额必填
|
||||
- 如果价格策略 = FIXED,则固定价格必填
|
||||
- 如果买入时机 = DELAYED,则延迟买入时间必填
|
||||
- 如果卖出时机 = DELAYED,则延迟卖出时间必填
|
||||
- 如果推送频率 = BATCH,则批量推送间隔必填
|
||||
|
||||
---
|
||||
|
||||
## 五、参数默认值建议
|
||||
|
||||
### 5.1 新手推荐配置
|
||||
|
||||
**保守策略**(适合新手):
|
||||
- 最小获胜概率差异:0.15(更严格的条件)
|
||||
- 最小交易价值:0.08(更高的价值要求)
|
||||
- 最大持仓:20 USDC(较小的持仓)
|
||||
- 最小持仓:5 USDC
|
||||
- 每日亏损限制:50 USDC
|
||||
- 价格容忍度:0.03(3%,较严格)
|
||||
|
||||
**稳健策略**(推荐):
|
||||
- 最小获胜概率差异:0.1(标准条件)
|
||||
- 最小交易价值:0.05(标准价值)
|
||||
- 最大持仓:50 USDC
|
||||
- 最小持仓:5 USDC
|
||||
- 每日亏损限制:100 USDC
|
||||
- 价格容忍度:0.05(5%)
|
||||
|
||||
**激进策略**(适合有经验的用户):
|
||||
- 最小获胜概率差异:0.05(较宽松的条件)
|
||||
- 最小交易价值:0.03(较低的价值要求)
|
||||
- 最大持仓:100 USDC(较大的持仓)
|
||||
- 最小持仓:10 USDC
|
||||
- 每日亏损限制:200 USDC
|
||||
- 价格容忍度:0.1(10%,较宽松)
|
||||
|
||||
### 5.2 参数模板
|
||||
|
||||
系统可以提供预定义的参数模板:
|
||||
- **保守模板**:使用保守策略的默认值
|
||||
- **稳健模板**:使用稳健策略的默认值
|
||||
- **激进模板**:使用激进策略的默认值
|
||||
- **自定义模板**:用户保存的自定义配置
|
||||
|
||||
### 5.3 基于历史数据的推荐值
|
||||
|
||||
#### 5.3.1 推荐值功能概述
|
||||
|
||||
系统可以根据历史数据分析和回测结果,为每个可配置参数提供智能推荐值,帮助用户快速配置策略,提高策略效果。
|
||||
|
||||
#### 5.3.2 推荐值计算方式
|
||||
|
||||
**方式一:基于历史回测的推荐值**
|
||||
|
||||
**计算流程**:
|
||||
1. 使用历史比赛数据(如过去 1-3 个赛季)
|
||||
2. 对不同的参数组合进行回测
|
||||
3. 评估每个参数组合的效果(准确率、盈亏、风险等)
|
||||
4. 选择最优的参数组合作为推荐值
|
||||
|
||||
**评估指标**:
|
||||
- 信号准确率:预测获胜 vs 实际获胜的比例
|
||||
- 总盈亏:累计盈亏金额
|
||||
- 盈亏比率:盈利信号的平均盈亏 / 亏损信号的平均盈亏
|
||||
- 最大回撤:最大连续亏损
|
||||
- 夏普比率:风险调整后的收益
|
||||
|
||||
**推荐值选择**:
|
||||
- 选择综合评分最高的参数组合
|
||||
- 综合评分 = 准确率 × 0.3 + 盈亏比率 × 0.3 + 夏普比率 × 0.2 + (1 - 最大回撤率) × 0.2
|
||||
|
||||
**方式二:基于用户策略的推荐值**
|
||||
|
||||
**计算方式**:
|
||||
- 分析用户已有策略的参数配置
|
||||
- 统计表现最好的策略的参数分布
|
||||
- 计算参数的平均值或中位数作为推荐值
|
||||
|
||||
**适用场景**:
|
||||
- 新用户:使用所有用户的最佳策略参数
|
||||
- 老用户:使用该用户自己的最佳策略参数
|
||||
|
||||
**方式三:基于市场环境的推荐值**
|
||||
|
||||
**动态调整**:
|
||||
- 根据当前市场环境(波动性、流动性等)动态调整推荐值
|
||||
- 市场波动大时:提高概率阈值,降低持仓
|
||||
- 市场稳定时:降低概率阈值,提高持仓
|
||||
|
||||
**环境因素**:
|
||||
- 市场波动性:基于近期价格的波动率
|
||||
- 市场流动性:基于订单深度和交易量
|
||||
- 比赛重要性:季后赛 vs 常规赛
|
||||
|
||||
#### 5.3.3 推荐值展示方式
|
||||
|
||||
**在参数输入框中的展示**:
|
||||
- 在参数输入框旁边显示推荐值按钮(如"使用推荐值"图标)
|
||||
- 点击后自动填充推荐值
|
||||
- 推荐值用不同颜色标识(如蓝色高亮)
|
||||
- 显示推荐值的来源标签(如"基于历史回测"、"基于您的策略"、"基于市场环境")
|
||||
- 显示推荐值的置信度(如"高置信度"、"中置信度"、"低置信度")
|
||||
|
||||
**推荐值详细说明**:
|
||||
- 点击推荐值按钮后,弹出推荐值说明卡片
|
||||
- 显示推荐值的计算依据
|
||||
- 显示使用该推荐值的预期效果(如"预期准确率 65%"、"预期盈亏比 1.5")
|
||||
- 显示推荐值的适用场景(如"适合保守策略"、"适合激进策略")
|
||||
- 显示推荐值的验证数据(如"基于 500 场历史比赛验证")
|
||||
|
||||
**参数对比展示**:
|
||||
- 在参数配置页面底部显示"推荐值对比"面板
|
||||
- 显示当前配置值 vs 推荐值的对比
|
||||
- 显示预期效果对比(如准确率、盈亏等)
|
||||
- 提供"应用所有推荐值"按钮,一键应用所有推荐值
|
||||
- 提供"应用部分推荐值"选项,让用户选择应用哪些推荐值
|
||||
|
||||
#### 5.3.4 推荐值更新机制
|
||||
|
||||
**更新频率**:
|
||||
- 每周更新一次:基于最新的历史数据
|
||||
- 每月深度更新:重新进行完整的回测分析
|
||||
- 实时更新:根据市场环境变化实时调整(仅市场环境相关参数)
|
||||
|
||||
**触发更新**:
|
||||
- 新赛季开始:使用新赛季的数据重新计算
|
||||
- 市场环境变化:检测到市场环境显著变化时更新
|
||||
- 用户请求:用户手动触发更新
|
||||
|
||||
**更新通知**:
|
||||
- 在策略配置页面显示"推荐值已更新"提示
|
||||
- 显示更新内容(哪些参数有变化)
|
||||
- 提供"查看更新"按钮,查看详细的更新说明
|
||||
- 提供"应用更新"按钮,一键应用更新的推荐值
|
||||
|
||||
#### 5.3.5 推荐值个性化
|
||||
|
||||
**基于用户偏好的推荐**:
|
||||
|
||||
**偏好设置**:
|
||||
- 风险偏好:保守/稳健/激进
|
||||
- 交易频率偏好:低频/中频/高频
|
||||
- 持仓偏好:小持仓/中持仓/大持仓
|
||||
|
||||
**个性化推荐**:
|
||||
- 根据用户偏好筛选推荐值
|
||||
- 优先推荐符合用户偏好的参数组合
|
||||
- 提供多个推荐选项(保守推荐、稳健推荐、激进推荐)
|
||||
|
||||
**基于策略类型的推荐**:
|
||||
|
||||
**策略类型**:
|
||||
- 保守策略:高概率阈值、小持仓、严格风险控制
|
||||
- 稳健策略:中等概率阈值、中等持仓、标准风险控制
|
||||
- 激进策略:低概率阈值、大持仓、宽松风险控制
|
||||
|
||||
**类型推荐**:
|
||||
- 用户选择策略类型后,自动推荐该类型的参数值
|
||||
- 提供策略类型模板,一键应用
|
||||
|
||||
#### 5.3.6 推荐值验证和反馈
|
||||
|
||||
**推荐值验证**:
|
||||
|
||||
**验证方式**:
|
||||
- 使用历史数据验证推荐值的有效性
|
||||
- 计算推荐值的回测准确率
|
||||
- 评估推荐值的风险水平
|
||||
|
||||
**验证结果展示**:
|
||||
- 显示推荐值的验证状态(如"已验证"、"待验证")
|
||||
- 显示验证数据(如"基于 500 场历史比赛验证")
|
||||
- 显示验证指标(如准确率、盈亏比等)
|
||||
- 显示验证时间(如"2024-12-01 验证")
|
||||
|
||||
**用户反馈机制**:
|
||||
|
||||
**反馈收集**:
|
||||
- 用户使用推荐值后,可以反馈效果
|
||||
- 收集用户对推荐值的满意度(1-5 星)
|
||||
- 收集用户的实际使用效果数据(准确率、盈亏等)
|
||||
|
||||
**反馈应用**:
|
||||
- 根据用户反馈调整推荐算法
|
||||
- 优化推荐值的准确性
|
||||
- 提高推荐值的适用性
|
||||
|
||||
#### 5.3.7 推荐值功能实现
|
||||
|
||||
**前端实现**:
|
||||
|
||||
**UI 组件**:
|
||||
- 推荐值按钮:在参数输入框旁边显示推荐值图标按钮
|
||||
- 推荐值卡片:点击后显示推荐值的详细说明
|
||||
- 推荐值对比面板:显示当前值 vs 推荐值的对比
|
||||
- 推荐值应用按钮:一键应用所有推荐值
|
||||
|
||||
**交互流程**:
|
||||
1. 用户进入策略配置页面
|
||||
2. 系统异步加载推荐值(不阻塞页面加载)
|
||||
3. 在参数输入框旁边显示推荐值按钮(如果该参数有推荐值)
|
||||
4. 用户点击推荐值按钮,查看推荐值详情
|
||||
5. 用户可以选择应用推荐值或手动调整
|
||||
6. 用户可以在底部查看所有推荐值的对比
|
||||
|
||||
**后端实现**:
|
||||
|
||||
**推荐值计算服务**:
|
||||
- 历史回测服务:执行历史数据回测
|
||||
- 推荐值计算服务:计算推荐值
|
||||
- 推荐值缓存服务:缓存推荐值,提高响应速度
|
||||
|
||||
**API 接口**:
|
||||
- 获取推荐值接口:根据参数类型和用户偏好返回推荐值
|
||||
- 更新推荐值接口:触发推荐值更新
|
||||
- 推荐值验证接口:验证推荐值的有效性
|
||||
- 用户反馈接口:收集用户反馈
|
||||
|
||||
**数据存储**:
|
||||
|
||||
**推荐值存储**:
|
||||
- 存储推荐值计算结果
|
||||
- 存储推荐值的计算依据和验证结果
|
||||
- 存储用户反馈数据
|
||||
|
||||
**缓存策略**:
|
||||
- 推荐值缓存 24 小时(每天更新一次)
|
||||
- 市场环境变化时立即更新缓存
|
||||
- 用户请求时优先使用缓存,异步更新
|
||||
|
||||
#### 5.3.8 推荐值参数列表
|
||||
|
||||
以下参数支持推荐值功能:
|
||||
|
||||
**触发条件参数**:
|
||||
- 最小获胜概率差异:基于历史回测的最优值
|
||||
- 最小交易价值:基于历史回测的最优值
|
||||
- 最小剩余时间:基于历史回测的最优值
|
||||
|
||||
**交易规则参数**:
|
||||
- 买入金额策略:基于用户策略的推荐
|
||||
- 固定买入金额:基于用户策略的推荐
|
||||
- 买入比例:基于用户策略的推荐
|
||||
- 价格策略:基于市场环境的推荐
|
||||
- 价格偏移:基于历史回测的最优值
|
||||
|
||||
**风险控制参数**:
|
||||
- 最大持仓:基于用户偏好和账户余额的推荐
|
||||
- 最小持仓:基于用户偏好和账户余额的推荐
|
||||
- 每日亏损限制:基于用户账户余额的推荐
|
||||
- 每日订单限制:基于历史数据的推荐
|
||||
- 价格容忍度:基于市场环境的推荐
|
||||
|
||||
**卖出规则参数**:
|
||||
- 止盈阈值:基于历史回测的最优值
|
||||
- 止损阈值:基于历史回测的最优值
|
||||
- 概率反转阈值:基于历史回测的最优值
|
||||
|
||||
**算法权重参数**(高级):
|
||||
- 综合评分权重:基于历史回测的最优值
|
||||
- 对位分析权重:基于历史回测的最优值
|
||||
- 实时状态权重:基于历史回测的最优值
|
||||
|
||||
---
|
||||
|
||||
## 六、参数说明和帮助
|
||||
|
||||
### 6.1 参数说明
|
||||
|
||||
每个参数都应该有清晰的说明:
|
||||
- **参数名称**:简洁明了的名称
|
||||
- **参数说明**:详细说明参数的作用和影响
|
||||
- **取值范围**:明确的范围和单位
|
||||
- **推荐值**:基于历史数据的推荐值
|
||||
- **示例**:具体的使用示例
|
||||
|
||||
### 6.2 帮助文档
|
||||
|
||||
提供以下帮助内容:
|
||||
- **参数说明文档**:详细的参数说明文档
|
||||
- **策略配置指南**:如何配置策略的指南
|
||||
- **常见问题**:常见配置问题和解答
|
||||
- **最佳实践**:配置策略的最佳实践
|
||||
|
||||
### 6.3 实时提示
|
||||
|
||||
在配置过程中提供实时提示:
|
||||
- **参数影响提示**:说明参数调整对策略的影响
|
||||
- **风险提示**:高风险配置的警告提示
|
||||
- **优化建议**:基于当前配置的优化建议
|
||||
|
||||
---
|
||||
|
||||
**文档结束**
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
# Polymarket Activity WebSocket API 格式
|
||||
|
||||
## 1. 连接信息
|
||||
|
||||
### 1.1 WebSocket URL
|
||||
```
|
||||
wss://ws-live-data.polymarket.com
|
||||
```
|
||||
|
||||
这是 Polymarket 官方 RTDS (Real-Time Data Stream) 的 WebSocket 端点。
|
||||
|
||||
### 1.2 协议说明
|
||||
- 使用 **官方 `@polymarket/real-time-data-client`** 的协议格式
|
||||
- 消息格式为 JSON
|
||||
- 支持自动重连
|
||||
- 需要 PING/PONG 保活(每 10 秒发送 PING)
|
||||
|
||||
## 2. 订阅消息格式
|
||||
|
||||
### 2.1 订阅全局 Activity(所有交易)
|
||||
|
||||
**订阅消息**:
|
||||
```json
|
||||
{
|
||||
"action": "subscribe",
|
||||
"subscriptions": [
|
||||
{
|
||||
"topic": "activity",
|
||||
"type": "trades"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**关键点**:
|
||||
- `action`: `"subscribe"` - 订阅动作(根据 `@polymarket/real-time-data-client` 协议)
|
||||
- `topic`: `"activity"` - 活动数据频道
|
||||
- `type`: `"trades"` - 交易类型(在 subscriptions 数组内)
|
||||
- **不传 `filters` 字段** - 订阅所有市场的交易(空对象 `{}` 会被拒绝)
|
||||
|
||||
### 2.2 订阅特定市场的 Activity(可选)
|
||||
|
||||
如果需要过滤特定市场:
|
||||
```json
|
||||
{
|
||||
"action": "subscribe",
|
||||
"subscriptions": [
|
||||
{
|
||||
"topic": "activity",
|
||||
"type": "trades",
|
||||
"filters": "{\"market_slug\":\"trump-win-2024\"}"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
或者过滤特定事件:
|
||||
```json
|
||||
{
|
||||
"action": "subscribe",
|
||||
"subscriptions": [
|
||||
{
|
||||
"topic": "activity",
|
||||
"type": "trades",
|
||||
"filters": "{\"event_slug\":\"presidential-election-2024\"}"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**注意**:
|
||||
- `filters` 是 JSON 字符串(不是对象)
|
||||
- 使用 `snake_case`(`market_slug`, `event_slug`)
|
||||
- 对于 Copy Trading,我们**不传 filters**,订阅全局然后客户端过滤
|
||||
|
||||
### 2.3 取消订阅
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "unsubscribe",
|
||||
"subscriptions": [
|
||||
{
|
||||
"topic": "activity",
|
||||
"type": "trades"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 3. 接收消息格式
|
||||
|
||||
### 3.1 Trade 消息结构
|
||||
|
||||
当有交易发生时,服务器会推送如下格式的消息:
|
||||
|
||||
```json
|
||||
{
|
||||
"topic": "activity",
|
||||
"type": "trades",
|
||||
"timestamp": 1704067200000,
|
||||
"payload": {
|
||||
"asset": "47632033502843656213...", // Token ID (用于下单)
|
||||
"conditionId": "0xb82c6573...", // Market condition ID
|
||||
"eventSlug": "aus-mct-per-2025-12-28", // 事件 slug
|
||||
"slug": "aus-mct-per-draw", // 市场 slug
|
||||
"outcome": "No", // 结果方向 (Yes/No)
|
||||
"side": "BUY", // 交易方向 (BUY/SELL)
|
||||
"size": 15.72, // 交易数量 (shares)
|
||||
"price": 0.87, // 交易价格
|
||||
"timestamp": 1766913243, // Unix 时间戳 (秒)
|
||||
"transactionHash": "0x921936dfc9...", // 交易哈希
|
||||
"trader": { // 交易者信息对象
|
||||
"name": "gabagool22", // 交易者用户名(可选)
|
||||
"address": "0x6031B6eed1C97e..." // 交易者钱包地址 ⭐ 关键字段!
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**重要字段说明**:
|
||||
- `payload.trader.address` - **交易者钱包地址**(用于过滤 Leader)
|
||||
- `payload.trader.name` - 交易者用户名(可选)
|
||||
- `payload.asset` - Token ID(用于下单)
|
||||
- `payload.conditionId` - Market condition ID
|
||||
- `payload.side` - BUY 或 SELL
|
||||
- `payload.size` - 交易数量
|
||||
- `payload.price` - 交易价格
|
||||
- `payload.timestamp` - Unix 时间戳(秒)
|
||||
|
||||
### 3.2 注意:字段命名可能不同
|
||||
|
||||
根据实测(poly-sdk 文档),有些情况下字段可能在顶层:
|
||||
```json
|
||||
{
|
||||
"topic": "activity",
|
||||
"type": "trades",
|
||||
"payload": {
|
||||
"asset": "...",
|
||||
"conditionId": "...",
|
||||
"side": "BUY",
|
||||
"price": 0.87,
|
||||
"size": 15.72,
|
||||
"transactionHash": "...",
|
||||
"name": "gabagool22", // 可能在顶层
|
||||
"proxyWallet": "0x6031..." // 可能在顶层(而不是 trader.address)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**建议处理方式**:同时检查 `payload.trader?.address` 和 `payload.proxyWallet`
|
||||
|
||||
## 4. PING/PONG 保活
|
||||
|
||||
### 4.1 发送 PING
|
||||
|
||||
每 10 秒发送一次:
|
||||
```json
|
||||
"PING"
|
||||
```
|
||||
|
||||
或:
|
||||
```json
|
||||
{
|
||||
"type": "ping"
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 接收 PONG
|
||||
|
||||
服务器会回复:
|
||||
```json
|
||||
"PONG"
|
||||
```
|
||||
|
||||
或:
|
||||
```json
|
||||
{
|
||||
"type": "pong"
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Kotlin 实现示例
|
||||
|
||||
### 5.1 订阅消息
|
||||
|
||||
```kotlin
|
||||
fun subscribeAllActivity() {
|
||||
val subscribeMessage = """
|
||||
{
|
||||
"type": "subscribe",
|
||||
"subscriptions": [
|
||||
{
|
||||
"topic": "activity",
|
||||
"type": "trades"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
wsClient.sendMessage(subscribeMessage)
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 解析交易消息
|
||||
|
||||
```kotlin
|
||||
private fun parseActivityTrade(json: JsonObject): ActivityTrade? {
|
||||
val payload = json.getAsJsonObject("payload") ?: return null
|
||||
|
||||
// 提取交易者地址(优先 trader.address,fallback 到 proxyWallet)
|
||||
val traderObj = payload.getAsJsonObject("trader")
|
||||
val traderAddress = traderObj?.get("address")?.asString
|
||||
?: payload.get("proxyWallet")?.asString
|
||||
?: return null
|
||||
|
||||
val traderName = traderObj?.get("name")?.asString
|
||||
?: payload.get("name")?.asString
|
||||
|
||||
return ActivityTrade(
|
||||
asset = payload.get("asset")?.asString ?: return null,
|
||||
conditionId = payload.get("conditionId")?.asString ?: return null,
|
||||
eventSlug = payload.get("eventSlug")?.asString,
|
||||
marketSlug = payload.get("slug")?.asString,
|
||||
outcome = payload.get("outcome")?.asString,
|
||||
side = payload.get("side")?.asString?.uppercase() ?: return null,
|
||||
size = payload.get("size")?.asDouble ?: return null,
|
||||
price = payload.get("price")?.asDouble ?: return null,
|
||||
timestamp = payload.get("timestamp")?.asLong
|
||||
?.let { if (it < 1e12) it * 1000 else it } // 秒转毫秒
|
||||
?: System.currentTimeMillis(),
|
||||
transactionHash = payload.get("transactionHash")?.asString,
|
||||
traderAddress = traderAddress.lowercase(),
|
||||
traderName = traderName
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 与现有实现的对比
|
||||
|
||||
### 6.1 User Channel(当前使用)
|
||||
|
||||
**URL**: `wss://ws-subscriptions-clob.polymarket.com/ws/user`
|
||||
**订阅格式**:
|
||||
```json
|
||||
{
|
||||
"type": "subscribe",
|
||||
"channel": "user",
|
||||
"user": "0x1234..."
|
||||
}
|
||||
```
|
||||
|
||||
**限制**:
|
||||
- ❌ 只能订阅**自己的**交易(需要 API 认证)
|
||||
- ❌ 无法监听别人的交易
|
||||
|
||||
### 6.2 Activity Channel(新方案)
|
||||
|
||||
**URL**: `wss://ws-live-data.polymarket.com`
|
||||
**订阅格式**:
|
||||
```json
|
||||
{
|
||||
"type": "subscribe",
|
||||
"subscriptions": [
|
||||
{
|
||||
"topic": "activity",
|
||||
"type": "trades"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**优势**:
|
||||
- ✅ 可以监听**所有**交易的全局流
|
||||
- ✅ 包含交易者地址,可以客户端过滤
|
||||
- ✅ 不需要认证
|
||||
- ✅ 延迟低(< 100ms)
|
||||
|
||||
## 7. 完整的消息处理流程
|
||||
|
||||
### 7.1 连接和订阅
|
||||
|
||||
```kotlin
|
||||
// 1. 连接到 WebSocket
|
||||
val client = PolymarketWebSocketClient(
|
||||
url = "wss://ws-live-data.polymarket.com",
|
||||
sessionId = "copy-trading-activity",
|
||||
onMessage = { message -> handleMessage(message) },
|
||||
onOpen = {
|
||||
subscribeAllActivity()
|
||||
},
|
||||
onReconnect = {
|
||||
subscribeAllActivity()
|
||||
}
|
||||
)
|
||||
|
||||
client.connect()
|
||||
|
||||
// 2. 发送订阅消息
|
||||
fun subscribeAllActivity() {
|
||||
val message = """
|
||||
{
|
||||
"type": "subscribe",
|
||||
"subscriptions": [
|
||||
{
|
||||
"topic": "activity",
|
||||
"type": "trades"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
client.sendMessage(message)
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 处理消息
|
||||
|
||||
```kotlin
|
||||
private fun handleMessage(message: String) {
|
||||
if (message.trim() == "PONG") return
|
||||
|
||||
val json = JsonParser.parseString(message).asJsonObject
|
||||
|
||||
// 检查是否是 activity trade 消息
|
||||
val topic = json.get("topic")?.asString
|
||||
val type = json.get("type")?.asString
|
||||
|
||||
if (topic == "activity" && type == "trades") {
|
||||
val payload = json.getAsJsonObject("payload") ?: return
|
||||
|
||||
// 提取交易者地址
|
||||
val traderAddress = extractTraderAddress(payload) ?: return
|
||||
|
||||
// 检查是否是我们监听的 Leader
|
||||
val leaderId = monitoredAddresses[traderAddress.lowercase()] ?: return
|
||||
|
||||
// 解析交易
|
||||
val trade = parseActivityTrade(payload) ?: return
|
||||
|
||||
// 处理交易
|
||||
processTrade(leaderId, trade)
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractTraderAddress(payload: JsonObject): String? {
|
||||
// 优先检查 trader.address
|
||||
val traderObj = payload.getAsJsonObject("trader")
|
||||
val address = traderObj?.get("address")?.asString
|
||||
?: payload.get("proxyWallet")?.asString
|
||||
|
||||
return address?.lowercase()
|
||||
}
|
||||
```
|
||||
|
||||
## 8. 错误处理
|
||||
|
||||
### 8.1 连接错误
|
||||
|
||||
- 连接失败时自动重连(指数退避:3s → 6s → 12s → 最大 60s)
|
||||
- 重连后重新发送订阅消息
|
||||
|
||||
### 8.2 订阅错误
|
||||
|
||||
如果订阅失败,服务器可能返回:
|
||||
```json
|
||||
{
|
||||
"type": "error",
|
||||
"message": "Invalid subscription"
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 消息解析错误
|
||||
|
||||
- 如果字段缺失,记录警告日志并跳过
|
||||
- 如果地址格式错误,跳过该消息
|
||||
- 如果解析失败,记录错误但不中断连接
|
||||
|
||||
## 9. 性能考虑
|
||||
|
||||
### 9.1 消息频率
|
||||
|
||||
- Activity WebSocket 会推送**所有市场**的所有交易
|
||||
- 高频市场可能每秒收到数百条消息
|
||||
- 需要高效的客户端过滤(使用 `Set` 或 `Map`,O(1) 查找)
|
||||
|
||||
### 9.2 内存管理
|
||||
|
||||
- 维护 Leader 地址 Set(通常 < 100 个地址)
|
||||
- 消息处理使用协程异步处理,避免阻塞
|
||||
- 定期清理不再监听的地址
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user