Initial commit: Polymarket copy trading bot
- Backend: Spring Boot + Kotlin implementation - Account management with private key import - Leader management - Copy trading configuration - Order synchronization - Balance and position queries via Polymarket API - Ethereum RPC integration for USDC balance - Proxy address calculation - Frontend: React + TypeScript - Account management UI - Mobile responsive design - Account import with private key/mnemonic support - Balance display and account details modal - Database: MySQL with Flyway migrations - API Integration: Polymarket CLOB API, Data API, Ethereum RPC
This commit is contained in:
@@ -0,0 +1,506 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
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 管理等)
|
||||
|
||||
## 项目范围
|
||||
|
||||
### 平台支持
|
||||
- **仅支持**: Polymarket 平台
|
||||
- **不支持**: 其他预测市场平台(如 Kalshi 等)
|
||||
|
||||
### 分类支持
|
||||
- **仅支持**:
|
||||
- `sports`: 体育相关市场
|
||||
- `crypto`: 加密货币相关市场
|
||||
- **不支持**: 其他分类(如 politics、entertainment 等)
|
||||
|
||||
### 分类验证
|
||||
- 所有涉及分类的接口、实体、服务必须验证分类参数
|
||||
- 分类参数只能是 `sports` 或 `crypto`
|
||||
- 无效分类应返回明确的错误提示
|
||||
|
||||
## 包名规范
|
||||
- **包名**: `com.wrbug.polymarketbot`
|
||||
- 所有代码必须使用此包名
|
||||
|
||||
## 实体类规范
|
||||
|
||||
### ID字段规范
|
||||
- **必须**使用 `Long? = null` 作为 `@GeneratedValue` 的 id 字段
|
||||
- **禁止**使用 `Long = 0` 或其他默认值
|
||||
|
||||
```kotlin
|
||||
// ✅ 正确
|
||||
@Entity
|
||||
@Table(name = "example_table")
|
||||
data class ExampleEntity(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
// ...
|
||||
)
|
||||
|
||||
// ❌ 错误
|
||||
@Entity
|
||||
data class ExampleEntity(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long = 0, // 禁止使用
|
||||
// ...
|
||||
)
|
||||
```
|
||||
|
||||
## 配置文件规范
|
||||
|
||||
### 配置文件格式
|
||||
- **必须**使用 `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`
|
||||
|
||||
## 代码规范
|
||||
|
||||
### Controller规范
|
||||
- Controller 方法**禁止**使用 `suspend`
|
||||
- 如需调用 suspend 方法,使用 `runBlocking`(最小化使用)
|
||||
- 只作用于 suspend 方法调用
|
||||
|
||||
```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` 方法
|
||||
- 使用 `@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`
|
||||
- 使用 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
|
||||
}
|
||||
```
|
||||
|
||||
### 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,
|
||||
"data": {},
|
||||
"msg": ""
|
||||
}
|
||||
```
|
||||
|
||||
- **响应字段说明**:
|
||||
- `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`: 认证/权限错误
|
||||
- `3001-3999`: 资源不存在
|
||||
- `4001-4999`: 业务逻辑错误
|
||||
- `5001-5999`: 服务器内部错误
|
||||
|
||||
详细错误码定义参见需求文档
|
||||
|
||||
## 禁止事项
|
||||
|
||||
### 代码质量
|
||||
- ❌ 禁止使用 `!!` 除非有明确原因
|
||||
- ❌ 禁止忽略异常
|
||||
- ❌ 禁止硬编码配置值
|
||||
- ❌ 禁止提交敏感信息到Git
|
||||
- ❌ Controller 方法禁止使用 `suspend`
|
||||
- ❌ 实体类ID禁止使用 `Long = 0`
|
||||
- ❌ **禁止直接返回 mock 数据或硬编码的假数据**
|
||||
- ❌ **禁止在 API 调用失败时返回 mock 数据作为默认值**
|
||||
- ❌ **所有返回的数据必须来自真实的 API 调用或数据库查询**
|
||||
|
||||
### 配置
|
||||
- ❌ 禁止使用 `application.yml`
|
||||
- ❌ 禁止在代码中硬编码配置值
|
||||
|
||||
### 类型
|
||||
- ❌ 禁止使用 `Double` 进行数值计算
|
||||
- ❌ 禁止使用 `LocalDateTime` 存储时间
|
||||
- ❌ 禁止实体类ID使用非空默认值
|
||||
|
||||
### API接口
|
||||
- ❌ 禁止使用GET、PUT、DELETE等方法(统一使用POST)
|
||||
- ❌ 禁止返回不符合统一格式的响应
|
||||
- ❌ 禁止在响应中直接返回Map类型(使用data class)
|
||||
@@ -0,0 +1,258 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
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' }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
- **框架**: React + TypeScript
|
||||
- **UI库**: Ant Design 或 Material-UI(推荐 Ant Design Mobile 用于移动端)
|
||||
- **HTTP客户端**: axios
|
||||
- **状态管理**: Zustand 或 Redux
|
||||
- **响应式设计**: 必须支持移动端和桌面端
|
||||
|
||||
## 移动端适配要求
|
||||
|
||||
### 响应式设计
|
||||
- **必须支持移动端和桌面端**
|
||||
- 使用响应式布局(Responsive Design)
|
||||
- 移动端优先(Mobile First)设计原则
|
||||
- 支持触摸操作和手势
|
||||
|
||||
### 断点设置
|
||||
- **移动端**: < 768px
|
||||
- **平板**: 768px - 1024px
|
||||
- **桌面端**: > 1024px
|
||||
|
||||
### UI 组件适配
|
||||
- 使用 Ant Design 的响应式组件
|
||||
- 移动端使用 Ant Design Mobile(如果使用 Ant Design)
|
||||
- 表格使用虚拟滚动或分页(移动端性能优化)
|
||||
- 表单使用移动端友好的输入组件
|
||||
|
||||
### 布局适配
|
||||
- 导航栏:移动端使用抽屉菜单,桌面端使用顶部导航
|
||||
- 列表:移动端使用卡片布局,桌面端使用表格布局
|
||||
- 按钮:移动端按钮尺寸不小于 44x44px(触摸友好)
|
||||
- 间距:移动端使用更大的间距,提高可点击区域
|
||||
|
||||
### 性能优化
|
||||
- 图片懒加载
|
||||
- 代码分割(Code Splitting)
|
||||
- 移动端减少动画效果
|
||||
- 使用 CSS 媒体查询优化样式
|
||||
|
||||
## 代码规范
|
||||
|
||||
### 组件规范
|
||||
- 使用函数式组件
|
||||
- 使用 TypeScript 类型定义
|
||||
- 组件文件使用 PascalCase 命名
|
||||
|
||||
```typescript
|
||||
// ✅ 正确
|
||||
interface MarketProps {
|
||||
marketId: string;
|
||||
platform: string;
|
||||
}
|
||||
|
||||
export const MarketCard: React.FC<MarketProps> = ({ marketId, platform }) => {
|
||||
// ...
|
||||
};
|
||||
|
||||
// ❌ 错误
|
||||
export const marketCard = (props: any) => { // 禁止使用any
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
### API调用规范
|
||||
- 使用 axios 进行 HTTP 请求
|
||||
- 统一错误处理
|
||||
- 使用 TypeScript 定义响应类型
|
||||
|
||||
```typescript
|
||||
// API服务
|
||||
import axios from 'axios';
|
||||
|
||||
interface Market {
|
||||
id: string;
|
||||
marketId: string;
|
||||
platform: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
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 管理全局状态
|
||||
- 本地状态使用 `useState`
|
||||
- 复杂状态使用 `useReducer`
|
||||
|
||||
```typescript
|
||||
// Zustand Store示例
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface MarketStore {
|
||||
markets: Market[];
|
||||
setMarkets: (markets: Market[]) => void;
|
||||
}
|
||||
|
||||
export const useMarketStore = create<MarketStore>((set) => ({
|
||||
markets: [],
|
||||
setMarkets: (markets) => set({ markets }),
|
||||
}));
|
||||
```
|
||||
|
||||
## 移动端适配示例
|
||||
|
||||
### 响应式布局
|
||||
```typescript
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
|
||||
const MyComponent: React.FC = () => {
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 });
|
||||
|
||||
return (
|
||||
<div className={isMobile ? 'mobile-layout' : 'desktop-layout'}>
|
||||
{isMobile ? <MobileView /> : <DesktopView />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 移动端导航
|
||||
```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';
|
||||
|
||||
const ResponsiveTable: React.FC = () => {
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 });
|
||||
|
||||
return (
|
||||
<Table
|
||||
dataSource={data}
|
||||
columns={columns}
|
||||
scroll={{ x: isMobile ? 600 : 'auto' }}
|
||||
pagination={{
|
||||
pageSize: isMobile ? 10 : 20,
|
||||
showSizeChanger: !isMobile
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## 禁止事项
|
||||
|
||||
### 代码质量
|
||||
- ❌ 禁止使用 `any` 类型
|
||||
- ❌ 禁止忽略错误处理
|
||||
- ❌ 禁止硬编码API地址
|
||||
- ❌ 禁止在组件中直接使用 `fetch`
|
||||
- ❌ 禁止忽略移动端适配
|
||||
|
||||
### 类型安全
|
||||
- ❌ 禁止使用 `any`
|
||||
- ❌ 禁止忽略 TypeScript 类型检查
|
||||
- ❌ 禁止使用 `@ts-ignore` 除非有明确原因
|
||||
|
||||
### 移动端适配
|
||||
- ❌ 禁止固定宽度布局
|
||||
- ❌ 禁止使用过小的触摸目标(< 44x44px)
|
||||
- ❌ 禁止忽略移动端性能优化
|
||||
- ❌ 禁止使用桌面端专用的交互方式(如 hover)
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
# IDE
|
||||
.idea/
|
||||
*.iml
|
||||
*.iws
|
||||
*.ipr
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Gradle
|
||||
backend/.gradle/
|
||||
backend/build/
|
||||
backend/bin/
|
||||
backend/out/
|
||||
backend/*.log
|
||||
backend/gradle-app.setting
|
||||
backend/.gradle
|
||||
backend/gradle-wrapper.jar
|
||||
|
||||
# Kotlin
|
||||
*.kt.bak
|
||||
*.class
|
||||
|
||||
# Java
|
||||
*.jar
|
||||
*.war
|
||||
*.ear
|
||||
*.class
|
||||
hs_err_pid*
|
||||
|
||||
# Node.js
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-store/
|
||||
|
||||
# Frontend build
|
||||
frontend/dist/
|
||||
frontend/.vite/
|
||||
frontend/.cache/
|
||||
frontend/.temp/
|
||||
frontend/.tmp/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
*.env
|
||||
|
||||
# Application properties (may contain sensitive data)
|
||||
backend/src/main/resources/application-local.properties
|
||||
backend/src/main/resources/application-dev.properties
|
||||
backend/src/main/resources/application-prod.properties
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
.cache/
|
||||
|
||||
# Coverage
|
||||
coverage/
|
||||
*.lcov
|
||||
.nyc_output/
|
||||
|
||||
# Test
|
||||
test-results/
|
||||
*.test.log
|
||||
|
||||
# Misc
|
||||
*.bak
|
||||
*.backup
|
||||
*.old
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
# 后端实现总结
|
||||
|
||||
## 已完成的工作
|
||||
|
||||
### 1. 项目基础结构 ✅
|
||||
|
||||
- ✅ 创建了完整的后端项目目录结构
|
||||
- ✅ 配置了 `application.properties` 配置文件
|
||||
- ✅ 创建了 Spring Boot 主应用类
|
||||
- ✅ 配置了 Gradle 构建文件
|
||||
|
||||
### 2. 工具类 ✅
|
||||
|
||||
从 `/Users/wrbug/hype-quant/quant/src/main/kotlin/com/hypequant/util` 拷贝并适配了以下工具类:
|
||||
|
||||
- ✅ `SafeConvertExt.kt` - 安全类型转换扩展函数
|
||||
- ✅ `MathExt.kt` - BigDecimal 数学运算扩展函数
|
||||
- ✅ `SystemExt.kt` - 系统环境变量工具
|
||||
- ✅ `OkHttpExt.kt` - OkHttp 客户端扩展函数
|
||||
- ✅ `CategoryValidator.kt` - 分类验证工具类(新增)
|
||||
|
||||
### 3. Polymarket API 封装 ✅
|
||||
|
||||
#### 3.1 CLOB API ✅
|
||||
|
||||
- ✅ 定义了 `PolymarketClobApi` 接口
|
||||
- ✅ 实现了 `PolymarketClobService` 服务封装
|
||||
- ✅ 支持的功能:
|
||||
- 获取订单簿
|
||||
- 获取价格信息
|
||||
- 获取中间价
|
||||
- 创建订单
|
||||
- 获取活跃订单
|
||||
- 取消订单
|
||||
- 获取交易记录
|
||||
|
||||
#### 3.2 Gamma API ✅
|
||||
|
||||
- ✅ 定义了 `PolymarketGammaApi` 接口
|
||||
- ✅ 实现了 `PolymarketGammaService` 服务封装
|
||||
- ✅ 支持的功能:
|
||||
- 获取市场列表(带分类验证)
|
||||
- 获取市场详情
|
||||
- 搜索市场(带分类验证)
|
||||
- 获取事件列表(带分类验证)
|
||||
- 获取体育市场
|
||||
- 获取加密货币市场
|
||||
|
||||
### 4. WebSocket 转发服务 ✅
|
||||
|
||||
- ✅ 实现了 `PolymarketWebSocketHandler` - WebSocket 处理器
|
||||
- ✅ 实现了 `PolymarketWebSocketClient` - Polymarket RTDS 客户端
|
||||
- ✅ 配置了 `WebSocketConfig` - WebSocket 配置
|
||||
- ✅ 支持双向消息转发:
|
||||
- 前端 → 后端 → Polymarket RTDS
|
||||
- Polymarket RTDS → 后端 → 前端
|
||||
|
||||
### 5. 统一响应格式 ✅
|
||||
|
||||
- ✅ 创建了 `ApiResponse` 统一响应格式
|
||||
- ✅ 提供了便捷的响应创建方法:
|
||||
- `success()` - 成功响应
|
||||
- `error()` - 错误响应
|
||||
- `paramError()` - 参数错误
|
||||
- `authError()` - 认证错误
|
||||
- `notFound()` - 资源不存在
|
||||
- `businessError()` - 业务逻辑错误
|
||||
- `serverError()` - 服务器错误
|
||||
|
||||
### 6. Controller 实现 ✅
|
||||
|
||||
- ✅ 实现了 `MarketController` - 市场相关接口
|
||||
- ✅ 提供的接口:
|
||||
- `POST /api/markets/list` - 获取市场列表
|
||||
- `POST /api/markets/detail` - 获取市场详情
|
||||
- `POST /api/markets/search` - 搜索市场
|
||||
- `POST /api/markets/sports` - 获取体育市场
|
||||
- `POST /api/markets/crypto` - 获取加密货币市场
|
||||
|
||||
### 7. 配置类 ✅
|
||||
|
||||
- ✅ `RetrofitConfig` - Retrofit 和 API 客户端配置
|
||||
- ✅ `WebSocketConfig` - WebSocket 配置
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
backend/
|
||||
├── src/main/kotlin/com/wrbug/polymarketbot/
|
||||
│ ├── api/ # API 接口定义
|
||||
│ │ ├── PolymarketClobApi.kt
|
||||
│ │ └── PolymarketGammaApi.kt
|
||||
│ ├── config/ # 配置类
|
||||
│ │ ├── RetrofitConfig.kt
|
||||
│ │ └── WebSocketConfig.kt
|
||||
│ ├── controller/ # 控制器
|
||||
│ │ └── MarketController.kt
|
||||
│ ├── dto/ # 数据传输对象
|
||||
│ │ └── ApiResponse.kt
|
||||
│ ├── service/ # 服务层
|
||||
│ │ ├── PolymarketClobService.kt
|
||||
│ │ └── PolymarketGammaService.kt
|
||||
│ ├── util/ # 工具类
|
||||
│ │ ├── SafeConvertExt.kt
|
||||
│ │ ├── MathExt.kt
|
||||
│ │ ├── SystemExt.kt
|
||||
│ │ ├── OkHttpExt.kt
|
||||
│ │ └── CategoryValidator.kt
|
||||
│ ├── websocket/ # WebSocket 处理
|
||||
│ │ ├── PolymarketWebSocketHandler.kt
|
||||
│ │ └── PolymarketWebSocketClient.kt
|
||||
│ └── PolymarketBotApplication.kt # 主应用类
|
||||
├── src/main/resources/
|
||||
│ └── application.properties # 配置文件
|
||||
├── build.gradle.kts # Gradle 构建配置
|
||||
└── settings.gradle.kts # Gradle 设置
|
||||
```
|
||||
|
||||
## 关键特性
|
||||
|
||||
### 1. 分类验证
|
||||
|
||||
所有涉及分类的接口都会验证分类参数,仅支持 `sports` 和 `crypto`:
|
||||
|
||||
```kotlin
|
||||
// 自动验证分类
|
||||
CategoryValidator.validate(category)
|
||||
```
|
||||
|
||||
### 2. 统一错误处理
|
||||
|
||||
所有接口统一返回 `ApiResponse` 格式,包含:
|
||||
- `code`: 错误码
|
||||
- `data`: 响应数据
|
||||
- `msg`: 错误消息
|
||||
|
||||
### 3. WebSocket 转发
|
||||
|
||||
前端通过 `ws://localhost:8000/ws/polymarket` 连接,后端自动转发到 Polymarket RTDS。
|
||||
|
||||
### 4. 异步支持
|
||||
|
||||
所有 API 调用使用 Kotlin Coroutines 的 `suspend` 函数,Controller 层使用 `runBlocking` 调用。
|
||||
|
||||
## 依赖说明
|
||||
|
||||
主要依赖:
|
||||
- Spring Boot 3.2.0
|
||||
- Kotlin 1.9.20
|
||||
- Retrofit 2.9.0
|
||||
- OkHttp 4.12.0
|
||||
- Java-WebSocket 1.5.4
|
||||
- MySQL Connector 8.2.0
|
||||
|
||||
## 配置说明
|
||||
|
||||
### 环境变量
|
||||
|
||||
- `DB_USERNAME`: 数据库用户名
|
||||
- `DB_PASSWORD`: 数据库密码
|
||||
- `SERVER_PORT`: 服务器端口(默认 8000)
|
||||
- `POLYMARKET_API_KEY`: Polymarket API Key(可选)
|
||||
|
||||
### application.properties
|
||||
|
||||
```properties
|
||||
# Polymarket API 配置
|
||||
polymarket.clob.base-url=https://clob.polymarket.com
|
||||
polymarket.gamma.base-url=https://gamma-api.polymarket.com
|
||||
polymarket.rtds.ws-url=wss://ws-live-data.polymarket.com
|
||||
polymarket.api-key=${POLYMARKET_API_KEY:}
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 1. 获取市场列表
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/markets/list \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"category": "sports",
|
||||
"active": true,
|
||||
"limit": 20
|
||||
}'
|
||||
```
|
||||
|
||||
### 2. WebSocket 连接
|
||||
|
||||
```javascript
|
||||
const ws = new WebSocket('ws://localhost:8000/ws/polymarket');
|
||||
|
||||
ws.onopen = () => {
|
||||
// 订阅市场数据
|
||||
ws.send(JSON.stringify({
|
||||
type: 'subscribe',
|
||||
channel: 'market',
|
||||
market: 'market_id'
|
||||
}));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
console.log('收到消息:', event.data);
|
||||
};
|
||||
```
|
||||
|
||||
## 下一步工作
|
||||
|
||||
1. **数据库实体和 Repository**
|
||||
- 创建 Market、Order、Trade 等实体类
|
||||
- 实现对应的 Repository 接口
|
||||
- 创建 Flyway 迁移脚本
|
||||
|
||||
2. **数据同步服务**
|
||||
- 实现市场数据同步任务
|
||||
- 实现价格更新任务
|
||||
- 实现订单状态同步
|
||||
|
||||
3. **订单管理 Controller**
|
||||
- 实现订单创建接口
|
||||
- 实现订单查询接口
|
||||
- 实现订单取消接口
|
||||
|
||||
4. **测试**
|
||||
- 单元测试
|
||||
- 集成测试
|
||||
- API 测试
|
||||
|
||||
5. **文档**
|
||||
- API 文档
|
||||
- 部署文档
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **分类限制**: 严格限制只支持 `sports` 和 `crypto` 两个分类
|
||||
2. **WebSocket**: 生产环境需要配置正确的 CORS 策略
|
||||
3. **API Key**: 某些接口需要配置 Polymarket API Key
|
||||
4. **错误处理**: 所有异常都会被捕获并返回统一格式的错误响应
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# Polymarket Bot Backend
|
||||
|
||||
Polymarket 预测市场机器人后端服务
|
||||
|
||||
## 功能特性
|
||||
|
||||
- ✅ 封装 Polymarket CLOB API(订单操作、市场数据、交易数据)
|
||||
- ✅ 封装 Polymarket Gamma API(市场、事件、系列查询)
|
||||
- ✅ WebSocket 转发服务(转发前端 WebSocket 连接到 Polymarket RTDS)
|
||||
- ✅ 统一 API 响应格式
|
||||
- ✅ 分类验证(仅支持 sports 和 crypto)
|
||||
- ✅ 工具类支持(安全转换、数学运算、HTTP 客户端等)
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Spring Boot 3.2.0
|
||||
- Kotlin 1.9.20
|
||||
- Retrofit 2.9.0
|
||||
- OkHttp 4.12.0
|
||||
- Java-WebSocket 1.5.4
|
||||
- MySQL 8.2.0
|
||||
- Flyway(数据库迁移)
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
backend/
|
||||
├── src/
|
||||
│ ├── main/
|
||||
│ │ ├── kotlin/com/wrbug/polymarketbot/
|
||||
│ │ │ ├── api/ # API 接口定义
|
||||
│ │ │ ├── config/ # 配置类
|
||||
│ │ │ ├── controller/ # 控制器
|
||||
│ │ │ ├── dto/ # 数据传输对象
|
||||
│ │ │ ├── service/ # 服务层
|
||||
│ │ │ ├── util/ # 工具类
|
||||
│ │ │ └── websocket/ # WebSocket 处理
|
||||
│ │ └── resources/
|
||||
│ │ ├── application.properties
|
||||
│ │ └── db/migration/ # Flyway 迁移脚本
|
||||
│ └── test/
|
||||
└── build.gradle.kts
|
||||
```
|
||||
|
||||
## 配置说明
|
||||
|
||||
### 环境变量
|
||||
|
||||
- `DB_USERNAME`: 数据库用户名(默认: root)
|
||||
- `DB_PASSWORD`: 数据库密码(默认: password)
|
||||
- `SERVER_PORT`: 服务器端口(默认: 8000)
|
||||
- `CRYPTO_SECRET_KEY`: 加密密钥(用于加密存储私钥和 API Key,建议设置)
|
||||
|
||||
### application.properties
|
||||
|
||||
主要配置项:
|
||||
- 数据库连接配置
|
||||
- Polymarket API 地址配置
|
||||
- WebSocket 地址配置
|
||||
|
||||
## API 接口
|
||||
|
||||
### 市场相关
|
||||
|
||||
- `POST /api/markets/list` - 获取市场列表
|
||||
- `POST /api/markets/detail` - 获取市场详情
|
||||
- `POST /api/markets/search` - 搜索市场
|
||||
- `POST /api/markets/sports` - 获取体育市场
|
||||
- `POST /api/markets/crypto` - 获取加密货币市场
|
||||
|
||||
### WebSocket
|
||||
|
||||
- `WS /ws/polymarket` - WebSocket 连接端点(转发到 Polymarket RTDS)
|
||||
|
||||
## 响应格式
|
||||
|
||||
所有接口统一返回以下格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {},
|
||||
"msg": ""
|
||||
}
|
||||
```
|
||||
|
||||
- `code`: 0 表示成功,非 0 表示失败
|
||||
- `data`: 响应数据
|
||||
- `msg`: 响应消息
|
||||
|
||||
## 错误码
|
||||
|
||||
- `0`: 成功
|
||||
- `1001-1999`: 参数错误
|
||||
- `2001-2999`: 认证/权限错误
|
||||
- `3001-3999`: 资源不存在
|
||||
- `4001-4999`: 业务逻辑错误
|
||||
- `5001-5999`: 服务器内部错误
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
# 构建项目
|
||||
./gradlew build
|
||||
|
||||
# 运行应用
|
||||
./gradlew bootRun
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 仅支持 Polymarket 平台
|
||||
2. 仅支持 `sports` 和 `crypto` 两个分类
|
||||
3. WebSocket 转发需要配置正确的 Polymarket RTDS 地址
|
||||
4. 生产环境需要配置正确的 CORS 策略
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||
|
||||
plugins {
|
||||
id("org.springframework.boot") version "3.2.0"
|
||||
id("io.spring.dependency-management") version "1.1.4"
|
||||
kotlin("jvm") version "1.9.20"
|
||||
kotlin("plugin.spring") version "1.9.20"
|
||||
kotlin("plugin.jpa") version "1.9.20"
|
||||
}
|
||||
|
||||
group = "com.wrbug"
|
||||
version = "1.0.0"
|
||||
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Spring Boot
|
||||
implementation("org.springframework.boot:spring-boot-starter-web")
|
||||
implementation("org.springframework.boot:spring-boot-starter-websocket")
|
||||
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
|
||||
implementation("org.springframework.boot:spring-boot-starter-validation")
|
||||
|
||||
// Kotlin
|
||||
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
||||
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
|
||||
|
||||
// Retrofit
|
||||
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
|
||||
|
||||
// OkHttp
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
|
||||
// WebSocket Client
|
||||
implementation("org.java-websocket:Java-WebSocket:1.5.4")
|
||||
|
||||
// Database
|
||||
implementation("com.mysql:mysql-connector-j:8.2.0")
|
||||
implementation("org.flywaydb:flyway-core")
|
||||
implementation("org.flywaydb:flyway-mysql")
|
||||
|
||||
// Jackson
|
||||
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
|
||||
implementation("com.fasterxml.jackson.core:jackson-databind")
|
||||
|
||||
// Keccak-256 for Ethereum function selector
|
||||
implementation("org.bouncycastle:bcprov-jdk18on:1.78.1")
|
||||
|
||||
// Logging
|
||||
implementation("org.slf4j:slf4j-api")
|
||||
|
||||
// Test
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test")
|
||||
}
|
||||
|
||||
tasks.withType<KotlinCompile> {
|
||||
kotlinOptions {
|
||||
freeCompilerArgs += "-Xjsr305=strict"
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<Test> {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
Vendored
+94
@@ -0,0 +1,94 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,2 @@
|
||||
rootProject.name = "polymarket-bot-backend"
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.wrbug.polymarketbot
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.runApplication
|
||||
|
||||
@SpringBootApplication
|
||||
class PolymarketBotApplication
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
runApplication<PolymarketBotApplication>(*args)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.wrbug.polymarketbot.api
|
||||
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
|
||||
/**
|
||||
* Ethereum RPC API 接口定义
|
||||
* 用于调用 Ethereum JSON-RPC 接口
|
||||
*/
|
||||
interface EthereumRpcApi {
|
||||
|
||||
/**
|
||||
* 调用 Ethereum JSON-RPC 方法
|
||||
*/
|
||||
@POST("/")
|
||||
suspend fun call(@Body request: JsonRpcRequest): Response<JsonRpcResponse>
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-RPC 请求
|
||||
*/
|
||||
data class JsonRpcRequest(
|
||||
val jsonrpc: String = "2.0",
|
||||
val method: String,
|
||||
val params: List<Any>,
|
||||
val id: Int = 1
|
||||
)
|
||||
|
||||
/**
|
||||
* JSON-RPC 响应
|
||||
*/
|
||||
data class JsonRpcResponse(
|
||||
val jsonrpc: String? = null,
|
||||
val result: String? = null,
|
||||
val error: JsonRpcError? = null,
|
||||
val id: Int? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* JSON-RPC 错误
|
||||
*/
|
||||
data class JsonRpcError(
|
||||
val code: Int,
|
||||
val message: String,
|
||||
val data: Any? = null
|
||||
)
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
package com.wrbug.polymarketbot.api
|
||||
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.*
|
||||
|
||||
/**
|
||||
* Polymarket CLOB API 接口定义
|
||||
* 用于程序化地管理市场订单
|
||||
*/
|
||||
interface PolymarketClobApi {
|
||||
|
||||
/**
|
||||
* 获取订单簿
|
||||
*/
|
||||
@GET("/book")
|
||||
suspend fun getOrderbook(
|
||||
@Query("market") market: String
|
||||
): Response<OrderbookResponse>
|
||||
|
||||
/**
|
||||
* 获取价格信息
|
||||
*/
|
||||
@GET("/price")
|
||||
suspend fun getPrice(
|
||||
@Query("market") market: String
|
||||
): Response<PriceResponse>
|
||||
|
||||
/**
|
||||
* 获取中间价
|
||||
*/
|
||||
@GET("/midpoint")
|
||||
suspend fun getMidpoint(
|
||||
@Query("market") market: String
|
||||
): Response<MidpointResponse>
|
||||
|
||||
/**
|
||||
* 获取价差
|
||||
*/
|
||||
@GET("/spreads")
|
||||
suspend fun getSpreads(
|
||||
@Query("market") market: String
|
||||
): Response<SpreadsResponse>
|
||||
|
||||
/**
|
||||
* 创建单个订单
|
||||
*/
|
||||
@POST("/orders")
|
||||
suspend fun createOrder(
|
||||
@Body request: CreateOrderRequest
|
||||
): Response<OrderResponse>
|
||||
|
||||
/**
|
||||
* 批量创建订单
|
||||
*/
|
||||
@POST("/orders/batch")
|
||||
suspend fun createOrdersBatch(
|
||||
@Body request: CreateOrdersBatchRequest
|
||||
): Response<List<OrderResponse>>
|
||||
|
||||
/**
|
||||
* 获取订单信息
|
||||
*/
|
||||
@GET("/orders/{orderId}")
|
||||
suspend fun getOrder(
|
||||
@Path("orderId") orderId: String
|
||||
): Response<OrderResponse>
|
||||
|
||||
/**
|
||||
* 获取活跃订单
|
||||
* 端点: /data/orders
|
||||
* 注意:Polymarket CLOB API 使用 GET 方法,参数通过 query params 传递
|
||||
* 虽然项目规范要求使用 POST,但这是外部 API,必须遵循 API 的实际要求
|
||||
*/
|
||||
@GET("/data/orders")
|
||||
suspend fun getActiveOrders(
|
||||
@Query("id") id: String? = null,
|
||||
@Query("market") market: String? = null,
|
||||
@Query("asset_id") asset_id: String? = null,
|
||||
@Query("next_cursor") next_cursor: String? = null
|
||||
): Response<GetActiveOrdersResponse>
|
||||
|
||||
/**
|
||||
* 取消订单
|
||||
*/
|
||||
@DELETE("/orders/{orderId}")
|
||||
suspend fun cancelOrder(
|
||||
@Path("orderId") orderId: String
|
||||
): Response<CancelOrderResponse>
|
||||
|
||||
/**
|
||||
* 批量取消订单
|
||||
*/
|
||||
@DELETE("/orders/batch")
|
||||
suspend fun cancelOrdersBatch(
|
||||
@Body request: CancelOrdersBatchRequest
|
||||
): Response<CancelOrdersBatchResponse>
|
||||
|
||||
/**
|
||||
* 获取交易记录
|
||||
* 端点: /data/trades
|
||||
* 注意:Polymarket CLOB API 使用 GET 方法,参数通过 query params 传递
|
||||
*/
|
||||
@GET("/data/trades")
|
||||
suspend fun getTrades(
|
||||
@Query("id") id: String? = null,
|
||||
@Query("maker_address") maker_address: String? = null,
|
||||
@Query("market") market: String? = null,
|
||||
@Query("asset_id") asset_id: String? = null,
|
||||
@Query("before") before: String? = null,
|
||||
@Query("after") after: String? = null,
|
||||
@Query("next_cursor") next_cursor: String? = null
|
||||
): Response<GetTradesResponse>
|
||||
}
|
||||
|
||||
// 请求和响应数据类
|
||||
data class CreateOrderRequest(
|
||||
val market: String,
|
||||
val side: String, // "BUY" or "SELL"
|
||||
val price: String,
|
||||
val size: String,
|
||||
val type: String = "LIMIT",
|
||||
val expiration: Long? = null
|
||||
)
|
||||
|
||||
data class CreateOrdersBatchRequest(
|
||||
val orders: List<CreateOrderRequest>
|
||||
)
|
||||
|
||||
data class CancelOrdersBatchRequest(
|
||||
val orderIds: List<String>
|
||||
)
|
||||
|
||||
data class OrderbookResponse(
|
||||
val bids: List<OrderbookEntry>,
|
||||
val asks: List<OrderbookEntry>
|
||||
)
|
||||
|
||||
data class OrderbookEntry(
|
||||
val price: String,
|
||||
val size: String
|
||||
)
|
||||
|
||||
data class PriceResponse(
|
||||
val market: String,
|
||||
val lastPrice: String?,
|
||||
val bestBid: String?,
|
||||
val bestAsk: String?
|
||||
)
|
||||
|
||||
data class MidpointResponse(
|
||||
val market: String,
|
||||
val midpoint: String
|
||||
)
|
||||
|
||||
data class SpreadsResponse(
|
||||
val market: String,
|
||||
val spread: String
|
||||
)
|
||||
|
||||
data class OrderResponse(
|
||||
val id: String,
|
||||
val market: String,
|
||||
val side: String,
|
||||
val price: String,
|
||||
val size: String,
|
||||
val filled: String,
|
||||
val status: String,
|
||||
val createdAt: String // ISO 8601 格式字符串
|
||||
)
|
||||
|
||||
data class CancelOrderResponse(
|
||||
val orderId: String,
|
||||
val status: String
|
||||
)
|
||||
|
||||
data class CancelOrdersBatchResponse(
|
||||
val cancelled: List<String>,
|
||||
val failed: List<String>
|
||||
)
|
||||
|
||||
data class TradeResponse(
|
||||
val id: String,
|
||||
val market: String,
|
||||
val side: String,
|
||||
val price: String,
|
||||
val size: String,
|
||||
val timestamp: String, // ISO 8601 格式字符串
|
||||
val user: String?
|
||||
)
|
||||
|
||||
/**
|
||||
* 获取活跃订单响应
|
||||
* 注意:参数通过 @Query 传递,不需要单独的 Request 类
|
||||
*/
|
||||
data class GetActiveOrdersResponse(
|
||||
val data: List<OrderResponse>,
|
||||
val next_cursor: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 获取交易记录响应
|
||||
*/
|
||||
data class GetTradesResponse(
|
||||
val data: List<TradeResponse>,
|
||||
val next_cursor: String? = null
|
||||
)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.wrbug.polymarketbot.api
|
||||
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* Polymarket Data API 接口定义
|
||||
* 用于查询仓位信息
|
||||
* Base URL: https://data-api.polymarket.com
|
||||
*/
|
||||
interface PolymarketDataApi {
|
||||
|
||||
/**
|
||||
* 获取用户当前仓位
|
||||
* 文档: https://docs.polymarket.com/api-reference/core/get-current-positions-for-a-user
|
||||
*/
|
||||
@GET("/positions")
|
||||
suspend fun getPositions(
|
||||
@Query("user") user: String,
|
||||
@Query("market") market: String? = null,
|
||||
@Query("eventId") eventId: String? = null,
|
||||
@Query("sizeThreshold") sizeThreshold: Double? = null,
|
||||
@Query("redeemable") redeemable: Boolean? = null,
|
||||
@Query("mergeable") mergeable: Boolean? = null,
|
||||
@Query("limit") limit: Int? = null,
|
||||
@Query("offset") offset: Int? = null,
|
||||
@Query("sortBy") sortBy: String? = null,
|
||||
@Query("sortDirection") sortDirection: String? = null,
|
||||
@Query("title") title: String? = null
|
||||
): Response<List<PositionResponse>>
|
||||
}
|
||||
|
||||
/**
|
||||
* 仓位响应(根据 Polymarket Data API 文档)
|
||||
*/
|
||||
data class PositionResponse(
|
||||
val proxyWallet: String,
|
||||
val asset: String? = null,
|
||||
val conditionId: String? = null,
|
||||
val size: Double? = null,
|
||||
val avgPrice: Double? = null,
|
||||
val initialValue: Double? = null,
|
||||
val currentValue: Double? = null,
|
||||
val cashPnl: Double? = null,
|
||||
val percentPnl: Double? = null,
|
||||
val totalBought: Double? = null,
|
||||
val realizedPnl: Double? = null,
|
||||
val percentRealizedPnl: Double? = null,
|
||||
val curPrice: Double? = null,
|
||||
val redeemable: Boolean? = null,
|
||||
val mergeable: Boolean? = null,
|
||||
val title: String? = null,
|
||||
val slug: String? = null,
|
||||
val icon: String? = null,
|
||||
val eventSlug: String? = null,
|
||||
val outcome: String? = null,
|
||||
val outcomeIndex: Int? = null,
|
||||
val oppositeOutcome: String? = null,
|
||||
val oppositeAsset: String? = null,
|
||||
val endDate: String? = null,
|
||||
val negativeRisk: Boolean? = null
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.wrbug.polymarketbot.config
|
||||
|
||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
|
||||
/**
|
||||
* Retrofit 配置类
|
||||
* 用于创建 Polymarket CLOB API 客户端(跟单系统需要)
|
||||
*
|
||||
* 注意:
|
||||
* - 查询类接口(如 /book, /price, /trades)不需要认证
|
||||
* - 操作类接口(如 /orders)需要认证,应使用账户级别的 API Key
|
||||
* - 账户 API Key 在调用时动态设置,不在此处配置
|
||||
*/
|
||||
@Configuration
|
||||
class RetrofitConfig {
|
||||
|
||||
@Value("\${polymarket.clob.base-url}")
|
||||
private lateinit var clobBaseUrl: String
|
||||
|
||||
/**
|
||||
* 创建 CLOB API 客户端
|
||||
* 用于跟单系统的订单操作和交易查询
|
||||
*
|
||||
* 注意:此客户端不包含全局认证拦截器
|
||||
* 需要认证的请求应在调用时使用账户级别的 API Key 动态设置认证头
|
||||
*/
|
||||
@Bean
|
||||
fun polymarketClobApi(): PolymarketClobApi {
|
||||
val okHttpClient = createClient().build()
|
||||
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(clobBaseUrl)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
.create(PolymarketClobApi::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.wrbug.polymarketbot.config
|
||||
|
||||
import com.wrbug.polymarketbot.websocket.PolymarketWebSocketHandler
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.web.socket.config.annotation.EnableWebSocket
|
||||
import org.springframework.web.socket.config.annotation.WebSocketConfigurer
|
||||
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry
|
||||
|
||||
/**
|
||||
* WebSocket 配置类
|
||||
* 用于配置 WebSocket 端点
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSocket
|
||||
class WebSocketConfig(
|
||||
private val polymarketWebSocketHandler: PolymarketWebSocketHandler
|
||||
) : WebSocketConfigurer {
|
||||
|
||||
override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) {
|
||||
registry.addHandler(polymarketWebSocketHandler, "/ws/polymarket")
|
||||
.setAllowedOrigins("*") // 生产环境应该配置具体的域名
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package com.wrbug.polymarketbot.controller
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.service.AccountService
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.*
|
||||
|
||||
/**
|
||||
* 账户管理控制器
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/copy-trading/accounts")
|
||||
class AccountController(
|
||||
private val accountService: AccountService
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(AccountController::class.java)
|
||||
|
||||
/**
|
||||
* 通过私钥导入账户
|
||||
*/
|
||||
@PostMapping("/import")
|
||||
fun importAccount(@RequestBody request: AccountImportRequest): ResponseEntity<ApiResponse<AccountDto>> {
|
||||
return try {
|
||||
// 参数验证
|
||||
if (request.privateKey.isBlank()) {
|
||||
return ResponseEntity.ok(ApiResponse.paramError("私钥不能为空"))
|
||||
}
|
||||
if (request.walletAddress.isBlank()) {
|
||||
return ResponseEntity.ok(ApiResponse.paramError("钱包地址不能为空"))
|
||||
}
|
||||
|
||||
val result = accountService.importAccount(request)
|
||||
result.fold(
|
||||
onSuccess = { account ->
|
||||
logger.info("成功导入账户: ${account.id}")
|
||||
ResponseEntity.ok(ApiResponse.success(account))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("导入账户失败: ${e.message}", e)
|
||||
when (e) {
|
||||
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误"))
|
||||
else -> ResponseEntity.ok(ApiResponse.serverError("导入账户失败: ${e.message}"))
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("导入账户异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.serverError("导入账户失败: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新账户信息
|
||||
*/
|
||||
@PostMapping("/update")
|
||||
fun updateAccount(@RequestBody request: AccountUpdateRequest): ResponseEntity<ApiResponse<AccountDto>> {
|
||||
return try {
|
||||
val result = accountService.updateAccount(request)
|
||||
result.fold(
|
||||
onSuccess = { account ->
|
||||
logger.info("成功更新账户: ${account.id}")
|
||||
ResponseEntity.ok(ApiResponse.success(account))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("更新账户失败: ${e.message}", e)
|
||||
when (e) {
|
||||
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误"))
|
||||
else -> ResponseEntity.ok(ApiResponse.serverError("更新账户失败: ${e.message}"))
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("更新账户异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.serverError("更新账户失败: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除账户
|
||||
*/
|
||||
@PostMapping("/delete")
|
||||
fun deleteAccount(@RequestBody request: AccountDeleteRequest): ResponseEntity<ApiResponse<Unit>> {
|
||||
return try {
|
||||
val result = accountService.deleteAccount(request.accountId)
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
logger.info("成功删除账户: ${request.accountId}")
|
||||
ResponseEntity.ok(ApiResponse.success(Unit))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("删除账户失败: ${e.message}", e)
|
||||
when (e) {
|
||||
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误"))
|
||||
is IllegalStateException -> ResponseEntity.ok(ApiResponse.businessError(e.message ?: "业务逻辑错误"))
|
||||
else -> ResponseEntity.ok(ApiResponse.serverError("删除账户失败: ${e.message}"))
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("删除账户异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.serverError("删除账户失败: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询账户列表
|
||||
*/
|
||||
@PostMapping("/list")
|
||||
fun getAccountList(): ResponseEntity<ApiResponse<AccountListResponse>> {
|
||||
return try {
|
||||
val result = accountService.getAccountList()
|
||||
result.fold(
|
||||
onSuccess = { response ->
|
||||
logger.info("成功查询账户列表: ${response.total} 个账户")
|
||||
ResponseEntity.ok(ApiResponse.success(response))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("查询账户列表失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.serverError("查询账户列表失败: ${e.message}"))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询账户列表异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.serverError("查询账户列表失败: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询账户详情
|
||||
*/
|
||||
@PostMapping("/detail")
|
||||
fun getAccountDetail(@RequestBody request: AccountDetailRequest): ResponseEntity<ApiResponse<AccountDto>> {
|
||||
return try {
|
||||
val result = accountService.getAccountDetail(request.accountId)
|
||||
result.fold(
|
||||
onSuccess = { account ->
|
||||
logger.info("成功查询账户详情: ${account.id}")
|
||||
ResponseEntity.ok(ApiResponse.success(account))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("查询账户详情失败: ${e.message}", e)
|
||||
when (e) {
|
||||
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误"))
|
||||
else -> ResponseEntity.ok(ApiResponse.serverError("查询账户详情失败: ${e.message}"))
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询账户详情异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.serverError("查询账户详情失败: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询账户余额
|
||||
*/
|
||||
@PostMapping("/balance")
|
||||
fun getAccountBalance(@RequestBody request: AccountBalanceRequest): ResponseEntity<ApiResponse<AccountBalanceResponse>> {
|
||||
return try {
|
||||
val result = accountService.getAccountBalance(request.accountId)
|
||||
result.fold(
|
||||
onSuccess = { balance ->
|
||||
logger.info("成功查询账户余额")
|
||||
ResponseEntity.ok(ApiResponse.success(balance))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("查询账户余额失败: ${e.message}", e)
|
||||
when (e) {
|
||||
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误"))
|
||||
else -> ResponseEntity.ok(ApiResponse.serverError("查询账户余额失败: ${e.message}"))
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询账户余额异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.serverError("查询账户余额失败: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置默认账户
|
||||
*/
|
||||
@PostMapping("/set-default")
|
||||
fun setDefaultAccount(@RequestBody request: SetDefaultAccountRequest): ResponseEntity<ApiResponse<Unit>> {
|
||||
return try {
|
||||
val result = accountService.setDefaultAccount(request.accountId)
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
logger.info("成功设置默认账户: ${request.accountId}")
|
||||
ResponseEntity.ok(ApiResponse.success(Unit))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("设置默认账户失败: ${e.message}", e)
|
||||
when (e) {
|
||||
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误"))
|
||||
else -> ResponseEntity.ok(ApiResponse.serverError("设置默认账户失败: ${e.message}"))
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("设置默认账户异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.serverError("设置默认账户失败: ${e.message}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
/**
|
||||
* 账户导入请求
|
||||
*/
|
||||
data class AccountImportRequest(
|
||||
val privateKey: String, // 私钥(前端加密后传输)
|
||||
val walletAddress: String, // 钱包地址(前端从私钥推导,用于验证)
|
||||
val accountName: String? = null,
|
||||
val apiKey: String? = null, // Polymarket API Key(可选)
|
||||
val apiSecret: String? = null, // Polymarket API Secret(可选)
|
||||
val apiPassphrase: String? = null, // Polymarket API Passphrase(可选)
|
||||
val isDefault: Boolean = false
|
||||
)
|
||||
|
||||
/**
|
||||
* 账户更新请求
|
||||
*/
|
||||
data class AccountUpdateRequest(
|
||||
val accountId: Long,
|
||||
val accountName: String? = null,
|
||||
val apiKey: String? = null,
|
||||
val apiSecret: String? = null,
|
||||
val apiPassphrase: String? = null,
|
||||
val isDefault: Boolean? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 账户删除请求
|
||||
*/
|
||||
data class AccountDeleteRequest(
|
||||
val accountId: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* 账户详情请求
|
||||
*/
|
||||
data class AccountDetailRequest(
|
||||
val accountId: Long? = null // 不提供则返回默认账户
|
||||
)
|
||||
|
||||
/**
|
||||
* 账户余额请求
|
||||
*/
|
||||
data class AccountBalanceRequest(
|
||||
val accountId: Long? = null // 不提供则查询默认账户
|
||||
)
|
||||
|
||||
/**
|
||||
* 设置默认账户请求
|
||||
*/
|
||||
data class SetDefaultAccountRequest(
|
||||
val accountId: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* 账户信息响应
|
||||
*/
|
||||
data class AccountDto(
|
||||
val id: Long,
|
||||
val walletAddress: String,
|
||||
val accountName: String?,
|
||||
val isDefault: Boolean,
|
||||
val apiKeyConfigured: Boolean, // API Key 是否已配置(不返回实际 Key)
|
||||
val apiSecretConfigured: Boolean, // API Secret 是否已配置
|
||||
val apiPassphraseConfigured: Boolean, // API Passphrase 是否已配置
|
||||
val balance: String? = null, // 账户余额(可选)
|
||||
val totalOrders: Long? = null, // 总订单数(可选)
|
||||
val totalPnl: String? = null // 总盈亏(可选)
|
||||
)
|
||||
|
||||
/**
|
||||
* 账户列表响应
|
||||
*/
|
||||
data class AccountListResponse(
|
||||
val list: List<AccountDto>,
|
||||
val total: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* 账户余额响应
|
||||
*/
|
||||
data class AccountBalanceResponse(
|
||||
val availableBalance: String, // 可用余额(RPC 查询的 USDC 余额)
|
||||
val positionBalance: String, // 仓位余额(持仓总价值)
|
||||
val totalBalance: String, // 总余额 = 可用余额 + 仓位余额
|
||||
val positions: List<PositionDto> = emptyList()
|
||||
)
|
||||
|
||||
/**
|
||||
* 持仓信息
|
||||
*/
|
||||
data class PositionDto(
|
||||
val marketId: String,
|
||||
val side: String, // YES 或 NO
|
||||
val quantity: String,
|
||||
val avgPrice: String,
|
||||
val currentValue: String,
|
||||
val pnl: String? = null
|
||||
)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
/**
|
||||
* 统一API响应格式
|
||||
* @param code 响应码,0表示成功,非0表示失败
|
||||
* @param data 响应数据,可以是任意类型(对象、数组、字符串、数字等)
|
||||
* @param msg 响应消息,成功时通常为空,失败时包含错误提示
|
||||
*/
|
||||
data class ApiResponse<T>(
|
||||
val code: Int,
|
||||
val data: T?,
|
||||
val msg: String
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* 创建成功响应
|
||||
*/
|
||||
fun <T> success(data: T?): ApiResponse<T> {
|
||||
return ApiResponse(code = 0, data = data, msg = "")
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建失败响应
|
||||
*/
|
||||
fun <T> error(code: Int, msg: String): ApiResponse<T> {
|
||||
return ApiResponse(code = code, data = null, msg = msg)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建参数错误响应
|
||||
*/
|
||||
fun <T> paramError(msg: String): ApiResponse<T> {
|
||||
return ApiResponse(code = 1001, data = null, msg = msg)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建认证错误响应
|
||||
*/
|
||||
fun <T> authError(msg: String): ApiResponse<T> {
|
||||
return ApiResponse(code = 2001, data = null, msg = msg)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建资源不存在响应
|
||||
*/
|
||||
fun <T> notFound(msg: String): ApiResponse<T> {
|
||||
return ApiResponse(code = 3001, data = null, msg = msg)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建业务逻辑错误响应
|
||||
*/
|
||||
fun <T> businessError(msg: String): ApiResponse<T> {
|
||||
return ApiResponse(code = 4001, data = null, msg = msg)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建服务器内部错误响应
|
||||
*/
|
||||
fun <T> serverError(msg: String): ApiResponse<T> {
|
||||
return ApiResponse(code = 5001, data = null, msg = msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
/**
|
||||
* 市场 DTO(用于返回给前端)
|
||||
*/
|
||||
data class MarketDto(
|
||||
val id: String,
|
||||
val question: String,
|
||||
val slug: String,
|
||||
val category: String,
|
||||
val active: Boolean,
|
||||
val volume: String?,
|
||||
val liquidity: String?,
|
||||
val endDate: Long?, // 时间戳(毫秒)
|
||||
val createdAt: Long?, // 时间戳(毫秒)
|
||||
val updatedAt: Long?, // 时间戳(毫秒)
|
||||
val outcomes: List<OutcomeDto>?,
|
||||
val conditionId: String?,
|
||||
val description: String?,
|
||||
val image: String?,
|
||||
val icon: String?,
|
||||
val closed: Boolean?,
|
||||
val archived: Boolean?,
|
||||
val volumeNum: Double?,
|
||||
val liquidityNum: Double?,
|
||||
val bestBid: Double?,
|
||||
val bestAsk: Double?,
|
||||
val lastTradePrice: Double?
|
||||
)
|
||||
|
||||
/**
|
||||
* 结果 DTO
|
||||
*/
|
||||
data class OutcomeDto(
|
||||
val name: String, // 结果名称,如 "Yes" 或 "No"
|
||||
val price: String // 价格
|
||||
)
|
||||
|
||||
/**
|
||||
* 事件 DTO
|
||||
*/
|
||||
data class EventDto(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val category: String,
|
||||
val active: Boolean,
|
||||
val markets: List<MarketDto>?,
|
||||
val createdAt: Long? // 时间戳(毫秒)
|
||||
)
|
||||
|
||||
/**
|
||||
* 系列 DTO
|
||||
*/
|
||||
data class SeriesDto(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val category: String,
|
||||
val events: List<EventDto>?,
|
||||
val createdAt: Long? // 时间戳(毫秒)
|
||||
)
|
||||
|
||||
/**
|
||||
* 评论 DTO
|
||||
*/
|
||||
data class CommentDto(
|
||||
val id: String,
|
||||
val market: String,
|
||||
val content: String,
|
||||
val parent: String?,
|
||||
val createdAt: Long, // 时间戳(毫秒)
|
||||
val user: String?
|
||||
)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
/**
|
||||
* 订单 DTO(用于返回给前端)
|
||||
*/
|
||||
data class OrderDto(
|
||||
val id: String,
|
||||
val market: String,
|
||||
val side: String,
|
||||
val price: String,
|
||||
val size: String,
|
||||
val filled: String,
|
||||
val status: String,
|
||||
val createdAt: Long // 时间戳(毫秒)
|
||||
)
|
||||
|
||||
/**
|
||||
* 交易 DTO
|
||||
*/
|
||||
data class TradeDto(
|
||||
val id: String,
|
||||
val market: String,
|
||||
val side: String,
|
||||
val price: String,
|
||||
val size: String,
|
||||
val timestamp: Long, // 时间戳(毫秒)
|
||||
val user: String?
|
||||
)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.wrbug.polymarketbot.entity
|
||||
|
||||
import jakarta.persistence.*
|
||||
|
||||
/**
|
||||
* 账户信息实体
|
||||
* 用于存储跟单者的账户信息(支持多账户)
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "copy_trading_accounts")
|
||||
data class Account(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
@Column(name = "private_key", nullable = false, length = 500)
|
||||
val privateKey: String, // 私钥(加密存储)
|
||||
|
||||
@Column(name = "wallet_address", unique = true, nullable = false, length = 42)
|
||||
val walletAddress: String, // 钱包地址(从私钥推导)
|
||||
|
||||
@Column(name = "proxy_address", nullable = false, length = 42)
|
||||
val proxyAddress: String, // Polymarket 代理钱包地址(从合约获取,必须)
|
||||
|
||||
@Column(name = "api_key", length = 500)
|
||||
val apiKey: String? = null, // Polymarket API Key(可选,加密存储)
|
||||
|
||||
@Column(name = "api_secret", length = 500)
|
||||
val apiSecret: String? = null, // Polymarket API Secret(可选,加密存储)
|
||||
|
||||
@Column(name = "api_passphrase", length = 500)
|
||||
val apiPassphrase: String? = null, // Polymarket API Passphrase(可选,加密存储)
|
||||
|
||||
@Column(name = "account_name", length = 100)
|
||||
val accountName: String? = null,
|
||||
|
||||
@Column(name = "is_default", nullable = false)
|
||||
val isDefault: Boolean = false, // 是否默认账户
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
var updatedAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.wrbug.polymarketbot.repository
|
||||
|
||||
import com.wrbug.polymarketbot.entity.Account
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
/**
|
||||
* 账户 Repository
|
||||
*/
|
||||
@Repository
|
||||
interface AccountRepository : JpaRepository<Account, Long> {
|
||||
|
||||
/**
|
||||
* 根据钱包地址查找账户
|
||||
*/
|
||||
fun findByWalletAddress(walletAddress: String): Account?
|
||||
|
||||
/**
|
||||
* 查找默认账户
|
||||
*/
|
||||
fun findByIsDefaultTrue(): Account?
|
||||
|
||||
/**
|
||||
* 查找所有账户,按创建时间排序
|
||||
*/
|
||||
fun findAllByOrderByCreatedAtAsc(): List<Account>
|
||||
|
||||
/**
|
||||
* 检查钱包地址是否存在
|
||||
*/
|
||||
fun existsByWalletAddress(walletAddress: String): Boolean
|
||||
}
|
||||
|
||||
@@ -0,0 +1,536 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.entity.Account
|
||||
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||
import com.wrbug.polymarketbot.util.CryptoUtils
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* 账户管理服务
|
||||
*/
|
||||
@Service
|
||||
class AccountService(
|
||||
private val accountRepository: AccountRepository,
|
||||
private val cryptoUtils: CryptoUtils,
|
||||
private val clobService: PolymarketClobService,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val blockchainService: BlockchainService
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(AccountService::class.java)
|
||||
|
||||
/**
|
||||
* 通过私钥导入账户
|
||||
*/
|
||||
@Transactional
|
||||
fun importAccount(request: AccountImportRequest): Result<AccountDto> {
|
||||
return try {
|
||||
// 1. 验证钱包地址格式
|
||||
if (!isValidWalletAddress(request.walletAddress)) {
|
||||
return Result.failure(IllegalArgumentException("无效的钱包地址格式"))
|
||||
}
|
||||
|
||||
// 2. 检查地址是否已存在
|
||||
if (accountRepository.existsByWalletAddress(request.walletAddress)) {
|
||||
return Result.failure(IllegalArgumentException("该钱包地址已存在"))
|
||||
}
|
||||
|
||||
// 3. 验证私钥和地址的对应关系
|
||||
// 注意:前端已经验证了私钥和地址的对应关系,这里只做格式验证
|
||||
// 如果需要更严格的验证,可以使用以太坊库(如 web3j)进行验证
|
||||
if (!isValidPrivateKey(request.privateKey)) {
|
||||
return Result.failure(IllegalArgumentException("无效的私钥格式"))
|
||||
}
|
||||
|
||||
// 4. 加密私钥和 API 凭证
|
||||
val encryptedPrivateKey = cryptoUtils.encrypt(request.privateKey)
|
||||
val encryptedApiKey = request.apiKey?.let { cryptoUtils.encrypt(it) }
|
||||
val encryptedApiSecret = request.apiSecret?.let { cryptoUtils.encrypt(it) }
|
||||
val encryptedApiPassphrase = request.apiPassphrase?.let { cryptoUtils.encrypt(it) }
|
||||
|
||||
// 5. 如果设置为默认账户,取消其他账户的默认状态
|
||||
if (request.isDefault) {
|
||||
accountRepository.findByIsDefaultTrue()?.let { defaultAccount ->
|
||||
val updated = defaultAccount.copy(isDefault = false, updatedAt = System.currentTimeMillis())
|
||||
accountRepository.save(updated)
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 获取代理地址(必须成功,否则导入失败)
|
||||
val proxyAddress = runBlocking {
|
||||
val proxyResult = blockchainService.getProxyAddress(request.walletAddress)
|
||||
if (proxyResult.isSuccess) {
|
||||
val address = proxyResult.getOrNull()
|
||||
if (address != null) {
|
||||
logger.info("成功获取代理地址: ${request.walletAddress} -> $address")
|
||||
address
|
||||
} else {
|
||||
logger.error("获取代理地址返回空值")
|
||||
throw IllegalStateException("获取代理地址失败:返回值为空")
|
||||
}
|
||||
} else {
|
||||
val error = proxyResult.exceptionOrNull()
|
||||
logger.error("获取代理地址失败: ${error?.message}")
|
||||
throw IllegalStateException("获取代理地址失败: ${error?.message}。请确保已配置 Ethereum RPC URL 且 RPC 节点可用")
|
||||
}
|
||||
}
|
||||
|
||||
// 7. 创建账户
|
||||
val account = Account(
|
||||
privateKey = encryptedPrivateKey,
|
||||
walletAddress = request.walletAddress,
|
||||
proxyAddress = proxyAddress,
|
||||
apiKey = encryptedApiKey,
|
||||
apiSecret = encryptedApiSecret,
|
||||
apiPassphrase = encryptedApiPassphrase,
|
||||
accountName = request.accountName,
|
||||
isDefault = request.isDefault,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
val saved = accountRepository.save(account)
|
||||
logger.info("成功导入账户: ${saved.id}, ${saved.walletAddress}, 代理地址: ${saved.proxyAddress}")
|
||||
|
||||
Result.success(toDto(saved))
|
||||
} catch (e: Exception) {
|
||||
logger.error("导入账户失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新账户信息
|
||||
*/
|
||||
@Transactional
|
||||
fun updateAccount(request: AccountUpdateRequest): Result<AccountDto> {
|
||||
return try {
|
||||
val account = accountRepository.findById(request.accountId)
|
||||
.orElse(null) ?: return Result.failure(IllegalArgumentException("账户不存在"))
|
||||
|
||||
// 更新账户名称
|
||||
val updatedAccountName = request.accountName ?: account.accountName
|
||||
|
||||
// 更新 API 凭证
|
||||
val updatedApiKey = if (request.apiKey != null) {
|
||||
cryptoUtils.encrypt(request.apiKey)
|
||||
} else {
|
||||
account.apiKey
|
||||
}
|
||||
val updatedApiSecret = if (request.apiSecret != null) {
|
||||
cryptoUtils.encrypt(request.apiSecret)
|
||||
} else {
|
||||
account.apiSecret
|
||||
}
|
||||
val updatedApiPassphrase = if (request.apiPassphrase != null) {
|
||||
cryptoUtils.encrypt(request.apiPassphrase)
|
||||
} else {
|
||||
account.apiPassphrase
|
||||
}
|
||||
|
||||
// 如果设置为默认账户,取消其他账户的默认状态
|
||||
val updatedIsDefault = request.isDefault ?: account.isDefault
|
||||
if (updatedIsDefault && !account.isDefault) {
|
||||
accountRepository.findByIsDefaultTrue()?.let { defaultAccount ->
|
||||
val updated = defaultAccount.copy(isDefault = false, updatedAt = System.currentTimeMillis())
|
||||
accountRepository.save(updated)
|
||||
}
|
||||
}
|
||||
|
||||
val updated = account.copy(
|
||||
accountName = updatedAccountName,
|
||||
apiKey = updatedApiKey,
|
||||
apiSecret = updatedApiSecret,
|
||||
apiPassphrase = updatedApiPassphrase,
|
||||
isDefault = updatedIsDefault,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
val saved = accountRepository.save(updated)
|
||||
logger.info("成功更新账户: ${saved.id}")
|
||||
|
||||
Result.success(toDto(saved))
|
||||
} catch (e: Exception) {
|
||||
logger.error("更新账户失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除账户
|
||||
*/
|
||||
@Transactional
|
||||
fun deleteAccount(accountId: Long): Result<Unit> {
|
||||
return try {
|
||||
val account = accountRepository.findById(accountId)
|
||||
.orElse(null) ?: return Result.failure(IllegalArgumentException("账户不存在"))
|
||||
|
||||
// 注意:不再检查活跃订单,允许用户删除有活跃订单的账户
|
||||
// 前端会显示确认提示框,由用户决定是否删除
|
||||
|
||||
// 如果删除的是默认账户,需要先设置其他账户为默认
|
||||
if (account.isDefault) {
|
||||
val otherAccounts = accountRepository.findAllByOrderByCreatedAtAsc()
|
||||
.filter { it.id != accountId }
|
||||
|
||||
if (otherAccounts.isNotEmpty()) {
|
||||
val newDefault = otherAccounts.first().copy(
|
||||
isDefault = true,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
accountRepository.save(newDefault)
|
||||
} else {
|
||||
return Result.failure(IllegalStateException("不能删除最后一个账户"))
|
||||
}
|
||||
}
|
||||
|
||||
accountRepository.delete(account)
|
||||
logger.info("成功删除账户: $accountId")
|
||||
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
logger.error("删除账户失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询账户列表
|
||||
*/
|
||||
fun getAccountList(): Result<AccountListResponse> {
|
||||
return try {
|
||||
val accounts = accountRepository.findAllByOrderByCreatedAtAsc()
|
||||
val accountDtos = accounts.map { toDto(it) }
|
||||
|
||||
Result.success(AccountListResponse(
|
||||
list = accountDtos,
|
||||
total = accountDtos.size.toLong()
|
||||
))
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询账户列表失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询账户详情
|
||||
*/
|
||||
fun getAccountDetail(accountId: Long?): Result<AccountDto> {
|
||||
return try {
|
||||
val account = if (accountId != null) {
|
||||
accountRepository.findById(accountId).orElse(null)
|
||||
} else {
|
||||
accountRepository.findByIsDefaultTrue()
|
||||
}
|
||||
|
||||
account ?: return Result.failure(IllegalArgumentException("账户不存在"))
|
||||
|
||||
Result.success(toDto(account))
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询账户详情失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询账户余额
|
||||
* 通过链上 RPC 查询 USDC 余额,并通过 Subgraph API 查询持仓信息
|
||||
*/
|
||||
fun getAccountBalance(accountId: Long?): Result<AccountBalanceResponse> {
|
||||
return try {
|
||||
val account = if (accountId != null) {
|
||||
accountRepository.findById(accountId).orElse(null)
|
||||
} else {
|
||||
accountRepository.findByIsDefaultTrue()
|
||||
}
|
||||
|
||||
account ?: return Result.failure(IllegalArgumentException("账户不存在"))
|
||||
|
||||
// 检查代理地址是否存在
|
||||
if (account.proxyAddress.isBlank()) {
|
||||
logger.error("账户 ${account.id} 的代理地址为空,无法查询余额")
|
||||
return Result.failure(IllegalStateException("账户代理地址不存在,无法查询余额。请重新导入账户以获取代理地址"))
|
||||
}
|
||||
|
||||
// 查询 USDC 余额和持仓信息
|
||||
val balanceResult = runBlocking {
|
||||
try {
|
||||
// 先查询持仓信息(用于计算仓位余额和返回持仓列表)
|
||||
// 使用代理地址查询持仓(Polymarket 使用代理地址存储持仓)
|
||||
val positionsResult = blockchainService.getPositions(account.proxyAddress)
|
||||
val positions = if (positionsResult.isSuccess) {
|
||||
positionsResult.getOrNull()?.map { pos ->
|
||||
PositionDto(
|
||||
marketId = pos.conditionId ?: "",
|
||||
side = pos.outcome ?: "",
|
||||
quantity = pos.size?.toString() ?: "0",
|
||||
avgPrice = pos.avgPrice?.toString() ?: "0",
|
||||
currentValue = pos.currentValue?.toString() ?: "0",
|
||||
pnl = pos.cashPnl?.toString()
|
||||
)
|
||||
} ?: emptyList()
|
||||
} else {
|
||||
logger.warn("持仓信息查询失败: ${positionsResult.exceptionOrNull()?.message}")
|
||||
emptyList()
|
||||
}
|
||||
|
||||
// 计算仓位余额(持仓总价值)
|
||||
val positionBalance = positions.sumOf {
|
||||
it.currentValue.toSafeBigDecimal()
|
||||
}
|
||||
|
||||
// 查询可用余额(通过 RPC 查询 USDC 余额)
|
||||
// 必须使用代理地址查询
|
||||
val availableBalanceResult = blockchainService.getUsdcBalance(
|
||||
walletAddress = account.walletAddress,
|
||||
proxyAddress = account.proxyAddress
|
||||
)
|
||||
val availableBalance = if (availableBalanceResult.isSuccess) {
|
||||
availableBalanceResult.getOrNull() ?: throw Exception("USDC 余额查询返回空值")
|
||||
} else {
|
||||
// 如果 RPC 查询失败,返回错误(不返回 mock 数据)
|
||||
val error = availableBalanceResult.exceptionOrNull()
|
||||
logger.error("USDC 可用余额 RPC 查询失败: ${error?.message}")
|
||||
throw Exception("USDC 可用余额查询失败: ${error?.message}。请确保已配置 Ethereum RPC URL")
|
||||
}
|
||||
|
||||
// 计算总余额 = 可用余额 + 仓位余额
|
||||
val totalBalance = availableBalance.toSafeBigDecimal().add(positionBalance)
|
||||
|
||||
AccountBalanceResponse(
|
||||
availableBalance = availableBalance,
|
||||
positionBalance = positionBalance.toPlainString(),
|
||||
totalBalance = totalBalance.toPlainString(),
|
||||
positions = positions
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询余额失败: ${e.message}", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
Result.success(balanceResult)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询账户余额失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置默认账户
|
||||
*/
|
||||
@Transactional
|
||||
fun setDefaultAccount(accountId: Long): Result<Unit> {
|
||||
return try {
|
||||
val account = accountRepository.findById(accountId)
|
||||
.orElse(null) ?: return Result.failure(IllegalArgumentException("账户不存在"))
|
||||
|
||||
// 取消其他账户的默认状态
|
||||
accountRepository.findByIsDefaultTrue()?.let { defaultAccount ->
|
||||
if (defaultAccount.id != account.id) {
|
||||
val updated = defaultAccount.copy(isDefault = false, updatedAt = System.currentTimeMillis())
|
||||
accountRepository.save(updated)
|
||||
}
|
||||
}
|
||||
|
||||
// 设置当前账户为默认
|
||||
val updated = account.copy(isDefault = true, updatedAt = System.currentTimeMillis())
|
||||
accountRepository.save(updated)
|
||||
|
||||
logger.info("成功设置默认账户: $accountId")
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
logger.error("设置默认账户失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为 DTO
|
||||
* 包含交易统计数据(总订单数和总盈亏)
|
||||
*/
|
||||
private fun toDto(account: Account): AccountDto {
|
||||
return runBlocking {
|
||||
val statistics = getAccountStatistics(account)
|
||||
AccountDto(
|
||||
id = account.id!!,
|
||||
walletAddress = account.walletAddress,
|
||||
accountName = account.accountName,
|
||||
isDefault = account.isDefault,
|
||||
apiKeyConfigured = account.apiKey != null,
|
||||
apiSecretConfigured = account.apiSecret != null,
|
||||
apiPassphraseConfigured = account.apiPassphrase != null,
|
||||
totalOrders = statistics.totalOrders,
|
||||
totalPnl = statistics.totalPnl
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账户交易统计数据
|
||||
*/
|
||||
private suspend fun getAccountStatistics(account: Account): AccountStatistics {
|
||||
return try {
|
||||
// 如果账户没有配置 API 凭证,无法查询统计数据
|
||||
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
|
||||
return AccountStatistics(totalOrders = null, totalPnl = null)
|
||||
}
|
||||
|
||||
// 解密 API 凭证
|
||||
val apiKey = cryptoUtils.decrypt(account.apiKey)
|
||||
val apiSecret = cryptoUtils.decrypt(account.apiSecret)
|
||||
val apiPassphrase = cryptoUtils.decrypt(account.apiPassphrase)
|
||||
|
||||
// 创建带认证的 API 客户端
|
||||
val clobApi = retrofitFactory.createClobApi(apiKey, apiSecret, apiPassphrase)
|
||||
|
||||
// 1. 查询交易记录数量(总订单数)
|
||||
val tradesResult = runBlocking {
|
||||
try {
|
||||
// 使用代理地址查询交易记录
|
||||
val response = clobApi.getTrades(
|
||||
maker_address = account.proxyAddress,
|
||||
next_cursor = null
|
||||
)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val tradesResponse = response.body()!!
|
||||
// 统计所有交易(需要分页查询所有)
|
||||
var totalTrades = tradesResponse.data.size
|
||||
var nextCursor = tradesResponse.next_cursor
|
||||
|
||||
// 分页查询所有交易
|
||||
while (nextCursor != null && nextCursor.isNotEmpty()) {
|
||||
val nextResponse = clobApi.getTrades(
|
||||
maker_address = account.proxyAddress,
|
||||
next_cursor = nextCursor
|
||||
)
|
||||
if (nextResponse.isSuccessful && nextResponse.body() != null) {
|
||||
val nextTradesResponse = nextResponse.body()!!
|
||||
totalTrades += nextTradesResponse.data.size
|
||||
nextCursor = nextTradesResponse.next_cursor
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
Result.success(totalTrades.toLong())
|
||||
} else {
|
||||
Result.failure(Exception("查询交易记录失败: ${response.code()} ${response.message()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("查询交易记录失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 查询仓位信息计算总盈亏(已实现盈亏)
|
||||
val totalPnlResult = runBlocking {
|
||||
try {
|
||||
val positionsResult = blockchainService.getPositions(account.proxyAddress)
|
||||
if (positionsResult.isSuccess) {
|
||||
val positions = positionsResult.getOrNull() ?: emptyList()
|
||||
// 汇总所有仓位的已实现盈亏
|
||||
val totalRealizedPnl = positions.sumOf { pos ->
|
||||
pos.realizedPnl?.toSafeBigDecimal() ?: BigDecimal.ZERO
|
||||
}
|
||||
Result.success(totalRealizedPnl.toPlainString())
|
||||
} else {
|
||||
Result.failure(Exception("查询仓位信息失败"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("查询仓位盈亏失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
AccountStatistics(
|
||||
totalOrders = tradesResult.getOrNull(),
|
||||
totalPnl = totalPnlResult.getOrNull()
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取账户统计数据失败: ${e.message}", e)
|
||||
AccountStatistics(totalOrders = null, totalPnl = null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 账户统计数据
|
||||
*/
|
||||
private data class AccountStatistics(
|
||||
val totalOrders: Long?,
|
||||
val totalPnl: String?
|
||||
)
|
||||
|
||||
/**
|
||||
* 验证钱包地址格式
|
||||
*/
|
||||
private fun isValidWalletAddress(address: String): Boolean {
|
||||
// 以太坊地址格式:0x 开头,42 位字符
|
||||
return address.startsWith("0x") && address.length == 42 && address.matches(Regex("^0x[0-9a-fA-F]{40}$"))
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证私钥格式
|
||||
*/
|
||||
private fun isValidPrivateKey(privateKey: String): Boolean {
|
||||
// 私钥格式:64 位十六进制字符(可选 0x 前缀)
|
||||
val cleanKey = if (privateKey.startsWith("0x")) privateKey.substring(2) else privateKey
|
||||
return cleanKey.length == 64 && cleanKey.matches(Regex("^[0-9a-fA-F]{64}$"))
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查账户是否有活跃订单
|
||||
* 使用账户的 API Key 查询该账户的活跃订单
|
||||
*/
|
||||
private suspend fun hasActiveOrders(account: Account): Boolean {
|
||||
return try {
|
||||
// 如果账户没有配置 API 凭证,无法查询活跃订单,允许删除
|
||||
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
|
||||
logger.debug("账户 ${account.id} 未配置 API 凭证,无法查询活跃订单,允许删除")
|
||||
return false
|
||||
}
|
||||
|
||||
// 解密 API 凭证(前面已检查不为 null)
|
||||
val apiKey = cryptoUtils.decrypt(account.apiKey)
|
||||
val apiSecret = cryptoUtils.decrypt(account.apiSecret)
|
||||
val apiPassphrase = cryptoUtils.decrypt(account.apiPassphrase)
|
||||
|
||||
// 创建带认证的 API 客户端
|
||||
val clobApi = retrofitFactory.createClobApi(apiKey, apiSecret, apiPassphrase)
|
||||
|
||||
// 查询活跃订单(只查询第一条,用于判断是否有订单)
|
||||
// 使用 next_cursor 参数进行分页,这里只查询第一页
|
||||
val response = clobApi.getActiveOrders(
|
||||
id = null,
|
||||
market = null,
|
||||
asset_id = null,
|
||||
next_cursor = null // null 表示从第一页开始
|
||||
)
|
||||
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val ordersResponse = response.body()!!
|
||||
val hasOrders = ordersResponse.data.isNotEmpty()
|
||||
logger.debug("账户 ${account.id} 活跃订单检查结果: $hasOrders (订单数: ${ordersResponse.data.size})")
|
||||
hasOrders
|
||||
} else {
|
||||
// 如果查询失败(可能是认证失败或网络问题),记录警告但允许删除
|
||||
// 因为无法确定是否有活跃订单,不应该阻止删除操作
|
||||
logger.warn("查询活跃订单失败: ${response.code()} ${response.message()},允许删除账户")
|
||||
false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// 如果查询异常(网络问题、API 错误等),记录警告但允许删除
|
||||
// 因为无法确定是否有活跃订单,不应该阻止删除操作
|
||||
logger.warn("检查活跃订单异常: ${e.message},允许删除账户", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
|
||||
import com.wrbug.polymarketbot.api.EthereumRpcApi
|
||||
import com.wrbug.polymarketbot.api.JsonRpcRequest
|
||||
import com.wrbug.polymarketbot.api.JsonRpcResponse
|
||||
import com.wrbug.polymarketbot.api.PolymarketDataApi
|
||||
import com.wrbug.polymarketbot.api.PositionResponse
|
||||
import com.wrbug.polymarketbot.util.EthereumUtils
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Service
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
|
||||
/**
|
||||
* 区块链查询服务
|
||||
* 用于查询链上余额和持仓信息
|
||||
*/
|
||||
@Service
|
||||
class BlockchainService(
|
||||
@Value("\${polymarket.data-api.base-url:https://data-api.polymarket.com}")
|
||||
private val dataApiBaseUrl: String,
|
||||
@Value("\${ethereum.rpc.url:}")
|
||||
private val ethereumRpcUrl: String,
|
||||
private val retrofitFactory: RetrofitFactory
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(BlockchainService::class.java)
|
||||
|
||||
// USDC 合约地址(Polygon 主网,Polymarket 使用 Polygon)
|
||||
private val usdcContractAddress = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
|
||||
|
||||
// Polymarket 代理工厂合约地址(Polygon 主网)
|
||||
// 合约地址: 0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b
|
||||
private val proxyFactoryContractAddress = "0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b"
|
||||
|
||||
// 获取代理地址的函数签名
|
||||
// 根据 Polygonscan 的 F4 方法,函数签名为: computeProxyAddress(address)
|
||||
private val computeProxyAddressFunctionSignature = "computeProxyAddress(address)"
|
||||
|
||||
private val dataApi: PolymarketDataApi by lazy {
|
||||
val baseUrl = if (dataApiBaseUrl.endsWith("/")) {
|
||||
dataApiBaseUrl.dropLast(1)
|
||||
} else {
|
||||
dataApiBaseUrl
|
||||
}
|
||||
val okHttpClient = createClient()
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.build()
|
||||
Retrofit.Builder()
|
||||
.baseUrl("$baseUrl/")
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
.create(PolymarketDataApi::class.java)
|
||||
}
|
||||
|
||||
private val ethereumRpcApi: EthereumRpcApi? by lazy {
|
||||
if (ethereumRpcUrl.isBlank()) {
|
||||
null
|
||||
} else {
|
||||
retrofitFactory.createEthereumRpcApi(ethereumRpcUrl)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Polymarket 代理钱包地址
|
||||
* 通过 RPC 调用代理工厂合约获取用户的代理钱包地址
|
||||
* @param walletAddress 用户的钱包地址
|
||||
* @return 代理钱包地址
|
||||
*/
|
||||
suspend fun getProxyAddress(walletAddress: String): Result<String> {
|
||||
return try {
|
||||
// 如果未配置 RPC URL,返回错误
|
||||
if (ethereumRpcUrl.isBlank()) {
|
||||
logger.warn("未配置 Ethereum RPC URL,无法获取代理地址")
|
||||
return Result.failure(IllegalStateException("未配置 Ethereum RPC URL,无法获取代理地址。请在配置文件中设置 ethereum.rpc.url 环境变量"))
|
||||
}
|
||||
|
||||
val rpcApi = ethereumRpcApi ?: throw IllegalStateException("Ethereum RPC URL 未配置")
|
||||
|
||||
// 计算函数选择器
|
||||
val functionSelector = EthereumUtils.getFunctionSelector(computeProxyAddressFunctionSignature)
|
||||
// 编码地址参数
|
||||
val encodedAddress = EthereumUtils.encodeAddress(walletAddress)
|
||||
// 构建调用数据
|
||||
val data = functionSelector + encodedAddress
|
||||
|
||||
// 构建 JSON-RPC 请求
|
||||
val rpcRequest = JsonRpcRequest(
|
||||
method = "eth_call",
|
||||
params = listOf(
|
||||
mapOf(
|
||||
"to" to proxyFactoryContractAddress,
|
||||
"data" to data
|
||||
),
|
||||
"latest"
|
||||
)
|
||||
)
|
||||
|
||||
// 发送 RPC 请求
|
||||
val response = rpcApi.call(rpcRequest)
|
||||
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
throw Exception("RPC 请求失败: ${response.code()} ${response.message()}")
|
||||
}
|
||||
|
||||
val rpcResponse = response.body()!!
|
||||
|
||||
// 检查错误
|
||||
if (rpcResponse.error != null) {
|
||||
throw Exception("RPC 错误: ${rpcResponse.error.message}")
|
||||
}
|
||||
|
||||
val hexResult = rpcResponse.result ?: throw Exception("RPC 响应格式错误: result 为空")
|
||||
|
||||
// 解析代理地址
|
||||
val proxyAddress = EthereumUtils.decodeAddress(hexResult)
|
||||
|
||||
logger.debug("获取代理地址成功: 原始地址=$walletAddress, 代理地址=$proxyAddress")
|
||||
Result.success(proxyAddress)
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取代理地址失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询账户 USDC 余额
|
||||
* 通过 Ethereum RPC 查询 ERC-20 代币余额
|
||||
* @param walletAddress 钱包地址(用于日志记录)
|
||||
* @param proxyAddress 代理地址(必须提供)
|
||||
* 如果 RPC 未配置或代理地址为空,返回失败(不返回 mock 数据)
|
||||
*/
|
||||
suspend fun getUsdcBalance(walletAddress: String, proxyAddress: String): Result<String> {
|
||||
return try {
|
||||
// 如果未配置 RPC URL,返回错误
|
||||
if (ethereumRpcUrl.isBlank()) {
|
||||
logger.warn("未配置 Ethereum RPC URL,无法查询 USDC 余额")
|
||||
return Result.failure(IllegalStateException("未配置 Ethereum RPC URL,无法查询 USDC 余额。请在配置文件中设置 ethereum.rpc.url 环境变量"))
|
||||
}
|
||||
|
||||
// 检查代理地址是否为空
|
||||
if (proxyAddress.isBlank()) {
|
||||
logger.error("代理地址为空,无法查询余额")
|
||||
return Result.failure(IllegalArgumentException("代理地址不能为空"))
|
||||
}
|
||||
|
||||
logger.debug("使用代理地址查询余额: $proxyAddress (原始地址: $walletAddress)")
|
||||
|
||||
// 使用 RPC 查询 USDC 余额(使用代理地址)
|
||||
val balance = queryUsdcBalanceViaRpc(proxyAddress)
|
||||
Result.success(balance)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询 USDC 余额失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 RPC 查询 USDC 余额
|
||||
*/
|
||||
private suspend fun queryUsdcBalanceViaRpc(walletAddress: String): String {
|
||||
val rpcApi = ethereumRpcApi ?: throw IllegalStateException("Ethereum RPC URL 未配置")
|
||||
|
||||
// 构建 ERC-20 balanceOf 函数调用
|
||||
// function signature: balanceOf(address) -> bytes4(0x70a08231)
|
||||
// 参数编码: address (32 bytes, padded)
|
||||
val functionSelector = "0x70a08231" // balanceOf(address)
|
||||
val paddedAddress = walletAddress.removePrefix("0x").lowercase().padStart(64, '0')
|
||||
val data = functionSelector + paddedAddress
|
||||
|
||||
// 构建 JSON-RPC 请求
|
||||
val rpcRequest = JsonRpcRequest(
|
||||
method = "eth_call",
|
||||
params = listOf(
|
||||
mapOf(
|
||||
"to" to usdcContractAddress,
|
||||
"data" to data
|
||||
),
|
||||
"latest"
|
||||
)
|
||||
)
|
||||
|
||||
// 发送 RPC 请求(使用 Retrofit)
|
||||
val response = rpcApi.call(rpcRequest)
|
||||
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
throw Exception("RPC 请求失败: ${response.code()} ${response.message()}")
|
||||
}
|
||||
|
||||
val rpcResponse = response.body()!!
|
||||
|
||||
// 检查错误
|
||||
if (rpcResponse.error != null) {
|
||||
throw Exception("RPC 错误: ${rpcResponse.error.message}")
|
||||
}
|
||||
|
||||
val hexBalance = rpcResponse.result ?: throw Exception("RPC 响应格式错误: result 为空")
|
||||
|
||||
// 将十六进制转换为 BigDecimal(USDC 有 6 位小数)
|
||||
val balanceWei = BigInteger(hexBalance.removePrefix("0x"), 16)
|
||||
val balance = BigDecimal(balanceWei).divide(BigDecimal("1000000")) // USDC 有 6 位小数
|
||||
|
||||
return balance.toPlainString()
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询账户持仓信息
|
||||
* 通过 Polymarket Data API 查询
|
||||
* 文档: https://docs.polymarket.com/api-reference/core/get-current-positions-for-a-user
|
||||
*/
|
||||
suspend fun getPositions(proxyWalletAddress: String): Result<List<PositionResponse>> {
|
||||
return try {
|
||||
// 使用代理钱包地址查询仓位
|
||||
val response = dataApi.getPositions(
|
||||
user = proxyWalletAddress,
|
||||
limit = 500, // 最大限制
|
||||
offset = 0
|
||||
)
|
||||
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val positions = response.body()!!
|
||||
logger.debug("查询到 ${positions.size} 个仓位")
|
||||
Result.success(positions)
|
||||
} else {
|
||||
val errorMsg = "Data API 请求失败: ${response.code()} ${response.message()}"
|
||||
logger.error(errorMsg)
|
||||
Result.failure(Exception(errorMsg))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询持仓信息失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package com.wrbug.polymarketbot.service
|
||||
|
||||
import com.wrbug.polymarketbot.api.*
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
/**
|
||||
* Polymarket CLOB API 服务封装
|
||||
* 提供订单操作、市场数据、交易数据等功能
|
||||
*/
|
||||
@Service
|
||||
class PolymarketClobService(
|
||||
private val clobApi: PolymarketClobApi
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(PolymarketClobService::class.java)
|
||||
|
||||
/**
|
||||
* 获取订单簿
|
||||
*/
|
||||
suspend fun getOrderbook(market: String): Result<OrderbookResponse> {
|
||||
return try {
|
||||
val response = clobApi.getOrderbook(market)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
Result.success(response.body()!!)
|
||||
} else {
|
||||
Result.failure(Exception("获取订单簿失败: ${response.code()} ${response.message()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取订单簿异常: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取价格信息
|
||||
*/
|
||||
suspend fun getPrice(market: String): Result<PriceResponse> {
|
||||
return try {
|
||||
val response = clobApi.getPrice(market)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
Result.success(response.body()!!)
|
||||
} else {
|
||||
Result.failure(Exception("获取价格失败: ${response.code()} ${response.message()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取价格异常: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取中间价
|
||||
*/
|
||||
suspend fun getMidpoint(market: String): Result<MidpointResponse> {
|
||||
return try {
|
||||
val response = clobApi.getMidpoint(market)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
Result.success(response.body()!!)
|
||||
} else {
|
||||
Result.failure(Exception("获取中间价失败: ${response.code()} ${response.message()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取中间价异常: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建订单
|
||||
*/
|
||||
suspend fun createOrder(request: CreateOrderRequest): Result<OrderResponse> {
|
||||
return try {
|
||||
val response = clobApi.createOrder(request)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
Result.success(response.body()!!)
|
||||
} else {
|
||||
Result.failure(Exception("创建订单失败: ${response.code()} ${response.message()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("创建订单异常: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取活跃订单
|
||||
*/
|
||||
suspend fun getActiveOrders(
|
||||
id: String? = null,
|
||||
market: String? = null,
|
||||
asset_id: String? = null,
|
||||
next_cursor: String? = null
|
||||
): Result<List<OrderResponse>> {
|
||||
return try {
|
||||
val response = clobApi.getActiveOrders(
|
||||
id = id,
|
||||
market = market,
|
||||
asset_id = asset_id,
|
||||
next_cursor = next_cursor
|
||||
)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val ordersResponse = response.body()!!
|
||||
Result.success(ordersResponse.data)
|
||||
} else {
|
||||
Result.failure(Exception("获取活跃订单失败: ${response.code()} ${response.message()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取活跃订单异常: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消订单
|
||||
*/
|
||||
suspend fun cancelOrder(orderId: String): Result<CancelOrderResponse> {
|
||||
return try {
|
||||
val response = clobApi.cancelOrder(orderId)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
Result.success(response.body()!!)
|
||||
} else {
|
||||
Result.failure(Exception("取消订单失败: ${response.code()} ${response.message()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("取消订单异常: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取交易记录
|
||||
*/
|
||||
suspend fun getTrades(
|
||||
id: String? = null,
|
||||
maker_address: String? = null,
|
||||
market: String? = null,
|
||||
asset_id: String? = null,
|
||||
before: String? = null,
|
||||
after: String? = null,
|
||||
next_cursor: String? = null
|
||||
): Result<List<TradeResponse>> {
|
||||
return try {
|
||||
val response = clobApi.getTrades(
|
||||
id = id,
|
||||
maker_address = maker_address,
|
||||
market = market,
|
||||
asset_id = asset_id,
|
||||
before = before,
|
||||
after = after,
|
||||
next_cursor = next_cursor
|
||||
)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val tradesResponse = response.body()!!
|
||||
Result.success(tradesResponse.data)
|
||||
} else {
|
||||
Result.failure(Exception("获取交易记录失败: ${response.code()} ${response.message()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取交易记录异常: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.wrbug.polymarketbot.util
|
||||
|
||||
/**
|
||||
* 分类验证工具类
|
||||
* 用于验证分类参数是否符合项目要求(仅支持 sports 和 crypto)
|
||||
*/
|
||||
object CategoryValidator {
|
||||
|
||||
/**
|
||||
* 支持的分类列表
|
||||
*/
|
||||
private val SUPPORTED_CATEGORIES = setOf("sports", "crypto")
|
||||
|
||||
/**
|
||||
* 分类名称映射(将 Polymarket API 返回的分类名称映射到标准分类)
|
||||
*/
|
||||
private val CATEGORY_MAPPING = mapOf(
|
||||
"sports" to "sports",
|
||||
"crypto" to "crypto",
|
||||
"cryptocurrency" to "crypto",
|
||||
"cryptocurrencies" to "crypto"
|
||||
)
|
||||
|
||||
/**
|
||||
* 验证分类是否有效(支持精确匹配和关键字匹配)
|
||||
* @param category 分类名称
|
||||
* @return 是否有效
|
||||
*/
|
||||
fun isValid(category: String?): Boolean {
|
||||
if (category == null) {
|
||||
return false
|
||||
}
|
||||
|
||||
val categoryLower = category.lowercase()
|
||||
|
||||
// 精确匹配
|
||||
if (categoryLower in SUPPORTED_CATEGORIES) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 映射匹配
|
||||
if (categoryLower in CATEGORY_MAPPING.keys) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 关键字匹配
|
||||
if (categoryLower.contains("sport")) {
|
||||
return true
|
||||
}
|
||||
if (categoryLower.contains("crypto")) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化分类名称
|
||||
* @param category 原始分类名称
|
||||
* @return 标准化后的分类名称(sports 或 crypto)
|
||||
*/
|
||||
fun normalizeCategory(category: String?): String? {
|
||||
if (category == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
val categoryLower = category.lowercase()
|
||||
|
||||
// 映射匹配
|
||||
CATEGORY_MAPPING[categoryLower]?.let {
|
||||
return it
|
||||
}
|
||||
|
||||
// 关键字匹配
|
||||
if (categoryLower.contains("sport")) {
|
||||
return "sports"
|
||||
}
|
||||
if (categoryLower.contains("crypto")) {
|
||||
return "crypto"
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证分类,如果无效则抛出异常
|
||||
* @param category 分类名称
|
||||
* @throws IllegalArgumentException 如果分类无效
|
||||
*/
|
||||
fun validate(category: String?) {
|
||||
if (!isValid(category)) {
|
||||
throw IllegalArgumentException("不支持的分类: $category,仅支持: ${SUPPORTED_CATEGORIES.joinToString(", ")}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有支持的分类
|
||||
* @return 支持的分类列表
|
||||
*/
|
||||
fun getSupportedCategories(): Set<String> {
|
||||
return SUPPORTED_CATEGORIES
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.wrbug.polymarketbot.util
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Component
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.*
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/**
|
||||
* 加密工具类
|
||||
* 用于加密存储私钥和 API Key
|
||||
*/
|
||||
@Component
|
||||
class CryptoUtils {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CryptoUtils::class.java)
|
||||
|
||||
@Value("\${crypto.secret.key:}")
|
||||
private var secretKey: String = ""
|
||||
|
||||
private val algorithm = "AES"
|
||||
private val transformation = "AES"
|
||||
|
||||
/**
|
||||
* 获取密钥字节数组
|
||||
* 使用 SHA-256 哈希从任意长度的密钥生成固定 32 字节的密钥(AES-256)
|
||||
*/
|
||||
private fun getKeyBytes(): ByteArray {
|
||||
val rawKey = if (secretKey.isEmpty()) {
|
||||
logger.warn("未配置加密密钥,使用默认密钥(仅用于开发环境)")
|
||||
"default-secret-key-32-bytes-long!!"
|
||||
} else {
|
||||
secretKey
|
||||
}
|
||||
|
||||
// 将原始密钥转换为字节数组
|
||||
val keyBytes = rawKey.toByteArray(StandardCharsets.UTF_8)
|
||||
|
||||
// 使用 SHA-256 哈希生成固定 32 字节的密钥(AES-256)
|
||||
val messageDigest = java.security.MessageDigest.getInstance("SHA-256")
|
||||
return messageDigest.digest(keyBytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密字符串
|
||||
*/
|
||||
fun encrypt(plainText: String): String {
|
||||
return try {
|
||||
val keyBytes = getKeyBytes()
|
||||
val key = SecretKeySpec(keyBytes, algorithm)
|
||||
val cipher = Cipher.getInstance(transformation)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key)
|
||||
val encrypted = cipher.doFinal(plainText.toByteArray(StandardCharsets.UTF_8))
|
||||
Base64.getEncoder().encodeToString(encrypted)
|
||||
} catch (e: Exception) {
|
||||
logger.error("加密失败", e)
|
||||
throw RuntimeException("加密失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密字符串
|
||||
*/
|
||||
fun decrypt(encryptedText: String): String {
|
||||
return try {
|
||||
val keyBytes = getKeyBytes()
|
||||
val key = SecretKeySpec(keyBytes, algorithm)
|
||||
val cipher = Cipher.getInstance(transformation)
|
||||
cipher.init(Cipher.DECRYPT_MODE, key)
|
||||
val decrypted = cipher.doFinal(Base64.getDecoder().decode(encryptedText))
|
||||
String(decrypted, StandardCharsets.UTF_8)
|
||||
} catch (e: Exception) {
|
||||
logger.error("解密失败", e)
|
||||
throw RuntimeException("解密失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.wrbug.polymarketbot.util
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.DateTimeParseException
|
||||
|
||||
/**
|
||||
* 日期工具类
|
||||
* 用于处理日期字符串和时间戳之间的转换
|
||||
*/
|
||||
object DateUtils {
|
||||
|
||||
/**
|
||||
* ISO 8601 日期时间格式化器
|
||||
*/
|
||||
private val isoFormatter = DateTimeFormatter.ISO_DATE_TIME
|
||||
|
||||
/**
|
||||
* 将 ISO 8601 格式的日期字符串转换为时间戳(毫秒)
|
||||
* @param dateString ISO 8601 格式的日期字符串,如 "2020-11-04T00:00:00Z"
|
||||
* @return 时间戳(毫秒),如果转换失败返回 null
|
||||
*/
|
||||
fun parseToTimestamp(dateString: String?): Long? {
|
||||
if (dateString.isNullOrBlank()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return try {
|
||||
// 尝试解析 ISO 8601 格式
|
||||
val instant = Instant.parse(dateString)
|
||||
instant.toEpochMilli()
|
||||
} catch (e: DateTimeParseException) {
|
||||
// 如果解析失败,尝试其他格式
|
||||
try {
|
||||
// 尝试使用 ISO_DATE_TIME 格式化器
|
||||
val dateTime = java.time.ZonedDateTime.parse(dateString, isoFormatter)
|
||||
dateTime.toInstant().toEpochMilli()
|
||||
} catch (e2: Exception) {
|
||||
// 所有解析都失败,返回 null
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将时间戳(毫秒)转换为 ISO 8601 格式的日期字符串
|
||||
* @param timestamp 时间戳(毫秒)
|
||||
* @return ISO 8601 格式的日期字符串
|
||||
*/
|
||||
fun formatFromTimestamp(timestamp: Long?): String? {
|
||||
if (timestamp == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return try {
|
||||
val instant = Instant.ofEpochMilli(timestamp)
|
||||
instant.toString()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.wrbug.polymarketbot.util
|
||||
|
||||
import org.bouncycastle.crypto.digests.KeccakDigest
|
||||
import java.math.BigInteger
|
||||
|
||||
/**
|
||||
* Ethereum 工具类
|
||||
* 用于计算函数签名、编码参数等
|
||||
*/
|
||||
object EthereumUtils {
|
||||
|
||||
/**
|
||||
* 计算函数选择器(前4个字节)
|
||||
* @param functionSignature 函数签名,例如 "computeProxyAddress(address)"
|
||||
* @return 函数选择器,例如 "0x12345678"
|
||||
*/
|
||||
fun getFunctionSelector(functionSignature: String): String {
|
||||
val hash = keccak256(functionSignature.toByteArray())
|
||||
return "0x" + hash.substring(0, 8)
|
||||
}
|
||||
|
||||
/**
|
||||
* 编码地址参数(32字节,左对齐)
|
||||
* @param address 地址,例如 "0x1234..."
|
||||
* @return 编码后的地址,64个十六进制字符
|
||||
*/
|
||||
fun encodeAddress(address: String): String {
|
||||
val cleanAddress = address.removePrefix("0x").lowercase()
|
||||
return cleanAddress.padStart(64, '0')
|
||||
}
|
||||
|
||||
/**
|
||||
* 从合约调用结果中解析地址
|
||||
* @param hexResult 十六进制结果
|
||||
* @return 地址字符串
|
||||
*/
|
||||
fun decodeAddress(hexResult: String): String {
|
||||
val cleanHex = hexResult.removePrefix("0x")
|
||||
// 地址是最后20字节(40个十六进制字符)
|
||||
val addressHex = cleanHex.takeLast(40)
|
||||
return "0x$addressHex"
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算 Keccak-256 哈希(Ethereum 标准)
|
||||
* 使用 BouncyCastle 库实现真正的 Keccak-256
|
||||
*/
|
||||
private fun keccak256(data: ByteArray): String {
|
||||
val digest = KeccakDigest(256)
|
||||
digest.update(data, 0, data.size)
|
||||
val hash = ByteArray(digest.digestSize)
|
||||
digest.doFinal(hash, 0)
|
||||
return hash.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.wrbug.polymarketbot.util
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
|
||||
/**
|
||||
* JSON 工具类
|
||||
* 用于解析 JSON 字符串
|
||||
*/
|
||||
object JsonUtils {
|
||||
|
||||
private val gson = Gson()
|
||||
|
||||
/**
|
||||
* 解析 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.wrbug.polymarketbot.util
|
||||
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
import java.math.RoundingMode
|
||||
|
||||
/**
|
||||
* BigDecimal乘法扩展函数
|
||||
* 安全地将BigDecimal与任意数值类型相乘
|
||||
* @param value 乘数,支持BigDecimal、BigInteger、Number类型或可转换为BigDecimal的字符串
|
||||
* @return 乘法结果,如果转换失败返回BigDecimal.ZERO
|
||||
*/
|
||||
fun BigDecimal.multi(value: Any): BigDecimal {
|
||||
kotlin.runCatching {
|
||||
if (value is BigDecimal) {
|
||||
return multiply(value)
|
||||
}
|
||||
if (value is BigInteger) {
|
||||
return multiply(value.toBigDecimal())
|
||||
}
|
||||
if (value is Number) {
|
||||
return multiply(value.toSafeBigDecimal())
|
||||
}
|
||||
return multiply(BigDecimal(value.toString()))
|
||||
}
|
||||
return BigDecimal.ZERO
|
||||
}
|
||||
|
||||
/**
|
||||
* BigDecimal除法扩展函数
|
||||
* 安全地将BigDecimal与任意数值类型相除
|
||||
* @param value 除数,支持BigDecimal、BigInteger类型或可转换为BigDecimal的字符串
|
||||
* @return 除法结果,精度为18位小数,使用四舍五入模式,如果转换失败返回IllegalBigDecimal
|
||||
*/
|
||||
fun BigDecimal.div(value: Any): BigDecimal {
|
||||
kotlin.runCatching {
|
||||
if (value is BigDecimal) {
|
||||
return divide(value, 18, RoundingMode.HALF_UP).stripTrailingZeros()
|
||||
}
|
||||
if (value is BigInteger) {
|
||||
return divide(value.toSafeBigDecimal(), 18, RoundingMode.HALF_UP).stripTrailingZeros()
|
||||
}
|
||||
return divide(BigDecimal(value.toString()), 18, RoundingMode.HALF_UP).stripTrailingZeros()
|
||||
}
|
||||
return IllegalBigDecimal
|
||||
}
|
||||
|
||||
/**
|
||||
* BigInteger乘法扩展函数
|
||||
* 将BigInteger转换为BigDecimal后与任意数值类型相乘
|
||||
* @param value 乘数,支持任意可转换为BigDecimal的类型
|
||||
* @return 乘法结果,如果转换失败返回IllegalBigDecimal
|
||||
*/
|
||||
fun BigInteger.multi(value: Any): BigDecimal {
|
||||
val v = this.toBigDecimal()
|
||||
return runCatching {
|
||||
v.multi(value)
|
||||
}.getOrDefault(IllegalBigDecimal)
|
||||
}
|
||||
|
||||
/**
|
||||
* 大于比较扩展函数
|
||||
* 安全地比较两个任意类型的数值大小
|
||||
* @param target 比较目标值
|
||||
* @return 如果当前值大于目标值返回true,否则返回false(null值返回false)
|
||||
*/
|
||||
fun Any?.gt(target: Any?): Boolean {
|
||||
if (this == null || target == null) {
|
||||
return false
|
||||
}
|
||||
return this.toSafeBigDecimal() > target.toSafeBigDecimal()
|
||||
}
|
||||
|
||||
/**
|
||||
* 大于等于比较扩展函数
|
||||
* 安全地比较两个任意类型的数值大小
|
||||
* @param target 比较目标值
|
||||
* @return 如果当前值大于等于目标值返回true,否则返回false(null值返回false)
|
||||
*/
|
||||
fun Any?.gte(target: Any?): Boolean {
|
||||
if (this == null || target == null) {
|
||||
return false
|
||||
}
|
||||
return this.toSafeBigDecimal() >= target.toSafeBigDecimal()
|
||||
}
|
||||
|
||||
/**
|
||||
* 小于比较扩展函数
|
||||
* 安全地比较两个任意类型的数值大小
|
||||
* @param target 比较目标值
|
||||
* @return 如果当前值小于目标值返回true,否则返回false(null值返回false)
|
||||
*/
|
||||
fun Any?.lt(target: Any?): Boolean {
|
||||
if (this == null || target == null) {
|
||||
return false
|
||||
}
|
||||
return this.toSafeBigDecimal() < target.toSafeBigDecimal()
|
||||
}
|
||||
|
||||
/**
|
||||
* 小于等于比较扩展函数
|
||||
* 安全地比较两个任意类型的数值大小
|
||||
* @param target 比较目标值
|
||||
* @return 如果当前值小于等于目标值返回true,否则返回false(null值返回false)
|
||||
*/
|
||||
fun Any?.lte(target: Any?): Boolean {
|
||||
if (this == null || target == null) {
|
||||
return false
|
||||
}
|
||||
return this.toSafeBigDecimal() <= target.toSafeBigDecimal()
|
||||
}
|
||||
|
||||
/**
|
||||
* 等于比较扩展函数
|
||||
* 安全地比较两个任意类型的数值是否相等
|
||||
* @param target 比较目标值
|
||||
* @return 如果当前值等于目标值返回true,否则返回false(null值返回false)
|
||||
*/
|
||||
fun Any?.eq(target: Any?): Boolean {
|
||||
if (this == null || target == null) {
|
||||
return false
|
||||
}
|
||||
return this.toSafeBigDecimal() == target.toSafeBigDecimal()
|
||||
}
|
||||
|
||||
/**
|
||||
* 不等于比较扩展函数
|
||||
* 安全地比较两个任意类型的数值是否不相等
|
||||
* @param target 比较目标值
|
||||
* @return 如果当前值不等于目标值返回true,否则返回false(null值返回false)
|
||||
*/
|
||||
fun Any?.neq(target: Any?): Boolean {
|
||||
if (this == null || target == null) {
|
||||
return false
|
||||
}
|
||||
return this.toSafeBigDecimal() != target.toSafeBigDecimal()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.wrbug.polymarketbot.util
|
||||
|
||||
import okhttp3.Credentials
|
||||
import okhttp3.OkHttpClient
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.security.SecureRandom
|
||||
import java.security.cert.CertificateException
|
||||
import java.security.cert.X509Certificate
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.net.ssl.*
|
||||
|
||||
/**
|
||||
* 创建OkHttpClient客户端
|
||||
* @return OkHttpClient.Builder
|
||||
*/
|
||||
fun createClient() = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.httpProxy("127.0.0.1", 8888)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
|
||||
/**
|
||||
* 为OkHttpClient添加HTTP代理支持
|
||||
* @param hostname 代理服务器地址
|
||||
* @param port 代理服务器端口
|
||||
* @param user 代理用户名(可选)
|
||||
* @param password 代理密码(可选)
|
||||
* @return OkHttpClient.Builder
|
||||
*/
|
||||
fun OkHttpClient.Builder.httpProxy(
|
||||
hostname: String, port: Int, user: String = "", password: String = ""
|
||||
): OkHttpClient.Builder {
|
||||
if (getEnv("ENABLE_PROXY") != "1") {
|
||||
return this
|
||||
}
|
||||
return apply {
|
||||
proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress(hostname, port)))
|
||||
createSSLSocketFactory()
|
||||
if (user.isNotEmpty() && password.isNotEmpty()) {
|
||||
proxyAuthenticator { _, res ->
|
||||
val credential: String = Credentials.basic(user, password)
|
||||
res.request.newBuilder().header("Proxy-Authorization", credential).build()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为OkHttpClient创建信任所有证书的SSL工厂
|
||||
* @return OkHttpClient.Builder
|
||||
*/
|
||||
fun OkHttpClient.Builder.createSSLSocketFactory(): OkHttpClient.Builder {
|
||||
runCatching {
|
||||
val sc: SSLContext = SSLContext.getInstance("TLS")
|
||||
sc.init(null, arrayOf<TrustManager>(TrustAllManager()), SecureRandom())
|
||||
this.sslSocketFactory(sc.socketFactory, TrustAllManager())
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* 信任所有证书的TrustManager
|
||||
*/
|
||||
class TrustAllManager : X509TrustManager {
|
||||
@Throws(CertificateException::class)
|
||||
override fun checkClientTrusted(chain: Array<X509Certificate?>?, authType: String?) {
|
||||
}
|
||||
|
||||
@Throws(CertificateException::class)
|
||||
override fun checkServerTrusted(chain: Array<X509Certificate?>?, authType: String?) {
|
||||
}
|
||||
|
||||
override fun getAcceptedIssuers() = arrayOfNulls<X509Certificate>(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 信任所有主机名的HostnameVerifier
|
||||
*/
|
||||
class TrustAllHostnameVerifier : HostnameVerifier {
|
||||
override fun verify(hostname: String?, session: SSLSession?): Boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.wrbug.polymarketbot.util
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okio.Buffer
|
||||
import java.io.IOException
|
||||
import java.time.Instant
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
import java.util.Base64
|
||||
|
||||
/**
|
||||
* Polymarket API 认证拦截器
|
||||
* 实现 L2 认证(使用 API Key、Secret、Passphrase)
|
||||
*
|
||||
* 认证方式:
|
||||
* 1. 生成时间戳(秒)
|
||||
* 2. 使用 Secret 对 (timestamp + method + path + body) 进行 HMAC-SHA256 签名
|
||||
* 3. 在请求头中添加:
|
||||
* - X-API-KEY: API Key
|
||||
* - X-API-SIGN: Base64 编码的签名
|
||||
* - X-API-TIMESTAMP: 时间戳
|
||||
* - X-API-PASSPHRASE: Passphrase
|
||||
*/
|
||||
class PolymarketAuthInterceptor(
|
||||
private val apiKey: String,
|
||||
private val apiSecret: String,
|
||||
private val apiPassphrase: String
|
||||
) : Interceptor {
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
|
||||
// 生成时间戳(秒)
|
||||
val timestamp = Instant.now().epochSecond.toString()
|
||||
|
||||
// 构建签名字符串: timestamp + method + path + body
|
||||
val method = originalRequest.method
|
||||
val path = originalRequest.url.encodedPath + if (originalRequest.url.query != null) "?${originalRequest.url.query}" else ""
|
||||
|
||||
// 读取请求体(如果存在)
|
||||
val body = originalRequest.body?.let { requestBody ->
|
||||
val buffer = Buffer()
|
||||
requestBody.writeTo(buffer)
|
||||
buffer.readUtf8()
|
||||
} ?: ""
|
||||
|
||||
val signString = "$timestamp$method$path$body"
|
||||
|
||||
// 使用 HMAC-SHA256 生成签名
|
||||
val signature = generateSignature(signString, apiSecret)
|
||||
|
||||
// 构建新的请求,添加认证头
|
||||
val newRequest = originalRequest.newBuilder()
|
||||
.header("X-API-KEY", apiKey)
|
||||
.header("X-API-SIGN", signature)
|
||||
.header("X-API-TIMESTAMP", timestamp)
|
||||
.header("X-API-PASSPHRASE", apiPassphrase)
|
||||
.build()
|
||||
|
||||
return chain.proceed(newRequest)
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 HMAC-SHA256 生成签名
|
||||
*/
|
||||
private fun generateSignature(message: String, secret: String): String {
|
||||
val mac = Mac.getInstance("HmacSHA256")
|
||||
val secretKeySpec = SecretKeySpec(secret.toByteArray(), "HmacSHA256")
|
||||
mac.init(secretKeySpec)
|
||||
val hash = mac.doFinal(message.toByteArray())
|
||||
return Base64.getEncoder().encodeToString(hash)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.wrbug.polymarketbot.util
|
||||
|
||||
import com.wrbug.polymarketbot.api.EthereumRpcApi
|
||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Component
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
|
||||
/**
|
||||
* Retrofit 客户端工厂
|
||||
* 用于创建带认证的 Polymarket CLOB API 客户端和 Ethereum RPC API 客户端
|
||||
*/
|
||||
@Component
|
||||
class RetrofitFactory(
|
||||
@Value("\${polymarket.clob.base-url}")
|
||||
private val clobBaseUrl: String
|
||||
) {
|
||||
|
||||
/**
|
||||
* 创建带认证的 Polymarket CLOB API 客户端
|
||||
* @param apiKey API Key
|
||||
* @param apiSecret API Secret
|
||||
* @param apiPassphrase API Passphrase
|
||||
* @return PolymarketClobApi 客户端
|
||||
*/
|
||||
fun createClobApi(
|
||||
apiKey: String,
|
||||
apiSecret: String,
|
||||
apiPassphrase: String
|
||||
): PolymarketClobApi {
|
||||
val authInterceptor = PolymarketAuthInterceptor(apiKey, apiSecret, apiPassphrase)
|
||||
|
||||
val okHttpClient = createClient()
|
||||
.addInterceptor(authInterceptor)
|
||||
.build()
|
||||
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(clobBaseUrl)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
.create(PolymarketClobApi::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Ethereum RPC API 客户端
|
||||
* @param rpcUrl RPC 节点 URL
|
||||
* @return EthereumRpcApi 客户端
|
||||
*/
|
||||
fun createEthereumRpcApi(rpcUrl: String): EthereumRpcApi {
|
||||
val okHttpClient = createClient().build()
|
||||
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(rpcUrl)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
.create(EthereumRpcApi::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.wrbug.polymarketbot.util
|
||||
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
|
||||
/**
|
||||
* 非法的BigDecimal常量,用于表示转换失败的情况
|
||||
*/
|
||||
val IllegalBigDecimal = BigDecimal("0")
|
||||
|
||||
/**
|
||||
* 非法的BigInteger常量,用于表示转换失败的情况
|
||||
*/
|
||||
val IllegalBigInteger = BigInteger("0")
|
||||
|
||||
/**
|
||||
* 安全转换为BigDecimal的扩展函数
|
||||
* 将任意类型安全地转换为BigDecimal,转换失败时返回IllegalBigDecimal
|
||||
* @return 转换后的BigDecimal值,失败时返回IllegalBigDecimal
|
||||
*/
|
||||
fun Any?.toSafeBigDecimal(): BigDecimal {
|
||||
return try {
|
||||
if (this is BigDecimal) {
|
||||
return this
|
||||
}
|
||||
if (this is BigInteger) {
|
||||
return this.toBigDecimal()
|
||||
}
|
||||
if (this is Number) {
|
||||
return BigDecimal.valueOf(this.toDouble())
|
||||
}
|
||||
BigDecimal(this.toString())
|
||||
} catch (t: Throwable) {
|
||||
IllegalBigDecimal
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全转换为BigInteger的扩展函数
|
||||
* 将字符串安全地转换为BigInteger,转换失败时返回IllegalBigInteger
|
||||
* @return 转换后的BigInteger值,失败时返回IllegalBigInteger
|
||||
*/
|
||||
fun String?.toSafeBigInteger(): BigInteger {
|
||||
return try {
|
||||
BigInteger(this.orEmpty())
|
||||
} catch (t: Throwable) {
|
||||
IllegalBigInteger
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全转换为Long的扩展函数
|
||||
* 将字符串安全地转换为Long,转换失败时返回0
|
||||
* @return 转换后的Long值,失败时返回0
|
||||
*/
|
||||
fun String?.toSafeLong(): Long {
|
||||
return try {
|
||||
this?.toLong() ?: 0
|
||||
} catch (t: Throwable) {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全转换为Int的扩展函数
|
||||
* 将任意类型安全地转换为Int,转换失败时返回0
|
||||
* @return 转换后的Int值,失败时返回0
|
||||
*/
|
||||
fun Any?.toSafeInt(): Int {
|
||||
return try {
|
||||
if (this is Number) {
|
||||
this.toInt()
|
||||
} else {
|
||||
this?.toString().toSafeBigDecimal().toInt()
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全转换为Double的扩展函数
|
||||
* 将任意类型安全地转换为Double,转换失败时返回0.0
|
||||
* @return 转换后的Double值,失败时返回0.0
|
||||
*/
|
||||
fun Any?.toSafeDouble(): Double {
|
||||
return try {
|
||||
when (this) {
|
||||
is Number -> this.toDouble()
|
||||
is Boolean -> if (this) 1.0 else 0.0
|
||||
else -> this?.toString().toSafeBigDecimal().toDouble()
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.wrbug.polymarketbot.util
|
||||
|
||||
/**
|
||||
* 获取环境变量的扩展函数
|
||||
* @param name 环境变量名称
|
||||
* @return 环境变量值,不存在时返回空字符串
|
||||
*/
|
||||
fun getEnv(name: String) = System.getenv(name).orEmpty()
|
||||
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package com.wrbug.polymarketbot.websocket
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import org.java_websocket.client.WebSocketClient
|
||||
import org.java_websocket.handshake.ServerHandshake
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.net.URI
|
||||
|
||||
/**
|
||||
* Polymarket WebSocket 客户端
|
||||
* 用于连接到 Polymarket RTDS
|
||||
*/
|
||||
class PolymarketWebSocketClient(
|
||||
serverUri: URI,
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val sessionId: String,
|
||||
private val onMessage: (String) -> Unit
|
||||
) : WebSocketClient(serverUri) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(PolymarketWebSocketClient::class.java)
|
||||
|
||||
override fun onOpen(handshakedata: ServerHandshake?) {
|
||||
logger.info("已成功连接到 Polymarket RTDS: $sessionId")
|
||||
}
|
||||
|
||||
override fun onMessage(message: String?) {
|
||||
if (message != null) {
|
||||
logger.debug("收到 Polymarket 消息: $sessionId, $message")
|
||||
onMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClose(code: Int, reason: String?, remote: Boolean) {
|
||||
logger.info("Polymarket 连接关闭: $sessionId, code: $code, reason: $reason, remote: $remote")
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭连接
|
||||
*/
|
||||
fun closeConnection() {
|
||||
if (isOpen) {
|
||||
try {
|
||||
closeBlocking()
|
||||
} catch (e: Exception) {
|
||||
logger.error("关闭连接失败: $sessionId, ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(ex: Exception?) {
|
||||
logger.error("Polymarket WebSocket 错误: $sessionId, ${ex?.message}", ex)
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息到 Polymarket
|
||||
*/
|
||||
fun sendMessage(message: String) {
|
||||
if (isOpen) {
|
||||
try {
|
||||
send(message)
|
||||
} catch (e: Exception) {
|
||||
logger.error("发送消息失败: $sessionId, ${e.message}", e)
|
||||
throw e
|
||||
}
|
||||
} else {
|
||||
logger.warn("WebSocket 未连接,无法发送消息: $sessionId")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查连接状态
|
||||
*/
|
||||
fun isConnected(): Boolean {
|
||||
return isOpen
|
||||
}
|
||||
}
|
||||
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
package com.wrbug.polymarketbot.websocket
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.socket.*
|
||||
import java.net.URI
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Polymarket WebSocket 处理器
|
||||
* 转发前端 WebSocket 连接到 Polymarket RTDS
|
||||
*/
|
||||
@Component
|
||||
class PolymarketWebSocketHandler(
|
||||
private val objectMapper: ObjectMapper
|
||||
) : WebSocketHandler {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(PolymarketWebSocketHandler::class.java)
|
||||
|
||||
@Value("\${polymarket.rtds.ws-url}")
|
||||
private lateinit var polymarketWsUrl: String
|
||||
|
||||
// 存储客户端会话和对应的 Polymarket 连接的映射
|
||||
private val clientSessions = ConcurrentHashMap<String, WebSocketSession>()
|
||||
private val polymarketConnections = ConcurrentHashMap<String, PolymarketWebSocketClient>()
|
||||
|
||||
override fun afterConnectionEstablished(session: WebSocketSession) {
|
||||
logger.info("客户端连接建立: ${session.id}")
|
||||
clientSessions[session.id] = session
|
||||
|
||||
try {
|
||||
// 创建到 Polymarket 的 WebSocket 连接
|
||||
val polymarketClient = PolymarketWebSocketClient(
|
||||
URI(polymarketWsUrl),
|
||||
objectMapper,
|
||||
session.id
|
||||
) { message ->
|
||||
// 当收到 Polymarket 消息时,转发给客户端
|
||||
forwardToClient(session.id, message)
|
||||
}
|
||||
|
||||
polymarketConnections[session.id] = polymarketClient
|
||||
|
||||
// 异步连接,不阻塞
|
||||
try {
|
||||
polymarketClient.connect()
|
||||
logger.info("正在连接到 Polymarket RTDS: ${session.id}")
|
||||
} catch (e: Exception) {
|
||||
logger.error("启动 Polymarket 连接失败: ${e.message}", e)
|
||||
// 连接失败时清理资源
|
||||
cleanup(session.id)
|
||||
try {
|
||||
session.close(CloseStatus.SERVER_ERROR.withReason("无法连接到 Polymarket"))
|
||||
} catch (ex: Exception) {
|
||||
logger.error("关闭客户端连接失败: ${ex.message}", ex)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("创建 Polymarket 客户端失败: ${e.message}", e)
|
||||
try {
|
||||
session.close(CloseStatus.SERVER_ERROR.withReason("无法创建连接"))
|
||||
} catch (ex: Exception) {
|
||||
logger.error("关闭客户端连接失败: ${ex.message}", ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleMessage(session: WebSocketSession, message: WebSocketMessage<*>) {
|
||||
logger.debug("收到客户端消息: ${session.id}, ${message.payload}")
|
||||
|
||||
val polymarketClient = polymarketConnections[session.id]
|
||||
if (polymarketClient != null) {
|
||||
if (polymarketClient.isConnected()) {
|
||||
// 将客户端消息转发给 Polymarket
|
||||
try {
|
||||
polymarketClient.sendMessage(message.payload.toString())
|
||||
} catch (e: Exception) {
|
||||
logger.error("转发消息到 Polymarket 失败: ${e.message}", e)
|
||||
}
|
||||
} else {
|
||||
logger.warn("Polymarket 连接未就绪,消息将被丢弃: ${session.id}")
|
||||
}
|
||||
} else {
|
||||
logger.warn("Polymarket 连接不存在: ${session.id}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleTransportError(session: WebSocketSession, exception: Throwable) {
|
||||
logger.error("WebSocket 传输错误: ${session.id}, ${exception.message}", exception)
|
||||
cleanup(session.id)
|
||||
}
|
||||
|
||||
override fun afterConnectionClosed(session: WebSocketSession, closeStatus: CloseStatus) {
|
||||
logger.info("客户端连接关闭: ${session.id}, 状态: $closeStatus")
|
||||
cleanup(session.id)
|
||||
}
|
||||
|
||||
override fun supportsPartialMessages(): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 转发消息给客户端
|
||||
*/
|
||||
private fun forwardToClient(sessionId: String, message: String) {
|
||||
val session = clientSessions[sessionId]
|
||||
if (session != null && session.isOpen) {
|
||||
try {
|
||||
session.sendMessage(TextMessage(message))
|
||||
} catch (e: Exception) {
|
||||
logger.error("转发消息给客户端失败: ${sessionId}, ${e.message}", e)
|
||||
}
|
||||
} else {
|
||||
logger.warn("客户端会话不存在或已关闭: $sessionId")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理资源
|
||||
*/
|
||||
private fun cleanup(sessionId: String) {
|
||||
try {
|
||||
// 关闭 Polymarket 连接
|
||||
val polymarketClient = polymarketConnections.remove(sessionId)
|
||||
if (polymarketClient != null) {
|
||||
try {
|
||||
if (polymarketClient.isConnected()) {
|
||||
polymarketClient.closeConnection()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("关闭 Polymarket 连接失败: ${sessionId}, ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
// 移除客户端会话
|
||||
clientSessions.remove(sessionId)
|
||||
logger.debug("已清理资源: $sessionId")
|
||||
} catch (e: Exception) {
|
||||
logger.error("清理资源时发生错误: ${sessionId}, ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# 应用配置
|
||||
spring.application.name=polymarket-bot-backend
|
||||
|
||||
# 数据源配置
|
||||
spring.datasource.url=jdbc:mysql://localhost:3306/polymarket_bot?useSSL=false&serverTimezone=UTC&characterEncoding=utf8&allowPublicKeyRetrieval=true
|
||||
spring.datasource.username=${DB_USERNAME:root}
|
||||
spring.datasource.password=${DB_PASSWORD:11111111}
|
||||
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
|
||||
|
||||
# Polymarket API 配置
|
||||
polymarket.clob.base-url=https://clob.polymarket.com
|
||||
polymarket.rtds.ws-url=wss://ws-live-data.polymarket.com
|
||||
polymarket.data-api.base-url=https://data-api.polymarket.com
|
||||
|
||||
# Ethereum RPC 配置(用于查询链上余额)
|
||||
# 可选:如果未配置,将无法查询 USDC 余额,但仍可通过 Subgraph API 查询持仓
|
||||
# 示例:https://polygon-rpc.com 或 https://polygon-mainnet.infura.io/v3/YOUR_PROJECT_ID
|
||||
ethereum.rpc.url=${ETHEREUM_RPC_URL:https://polygon-rpc.com}
|
||||
|
||||
# 加密配置
|
||||
crypto.secret.key=${CRYPTO_SECRET_KEY:wrbug123}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- 创建账户表
|
||||
CREATE TABLE IF NOT EXISTS copy_trading_accounts (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
private_key VARCHAR(500) NOT NULL COMMENT '私钥(加密存储)',
|
||||
wallet_address VARCHAR(42) NOT NULL UNIQUE COMMENT '钱包地址(从私钥推导)',
|
||||
api_key VARCHAR(500) NULL COMMENT 'Polymarket API Key(可选,加密存储)',
|
||||
account_name VARCHAR(100) NULL COMMENT '账户名称',
|
||||
is_default BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否默认账户',
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)',
|
||||
updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)',
|
||||
INDEX idx_wallet_address (wallet_address),
|
||||
INDEX idx_is_default (is_default)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='跟单系统账户表';
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
-- 添加 API Secret 和 Passphrase 字段
|
||||
ALTER TABLE copy_trading_accounts
|
||||
ADD COLUMN api_secret VARCHAR(500) NULL COMMENT 'Polymarket API Secret(可选,加密存储)' AFTER api_key,
|
||||
ADD COLUMN api_passphrase VARCHAR(500) NULL COMMENT 'Polymarket API Passphrase(可选,加密存储)' AFTER api_secret;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- 添加代理地址字段到账户表
|
||||
ALTER TABLE copy_trading_accounts
|
||||
ADD COLUMN proxy_address VARCHAR(42) NOT NULL COMMENT 'Polymarket 代理钱包地址(从合约获取,必须)' AFTER wallet_address;
|
||||
|
||||
Submodule
+1
Submodule clob-client added at 96e25998b8
@@ -0,0 +1,550 @@
|
||||
# 跟单买入和卖出实现方案
|
||||
|
||||
## 1. 核心思路
|
||||
|
||||
### 1.1 基本原理
|
||||
- **买入跟单**:当 Leader 执行 `BUY` 交易时,系统自动创建 `BUY` 订单
|
||||
- **卖出跟单**:当 Leader 执行 `SELL` 交易时,系统自动创建 `SELL` 订单
|
||||
- **方向复制**:直接复制 Leader 交易的 `side` 字段(BUY 或 SELL)
|
||||
|
||||
### 1.2 数据来源
|
||||
- **方式1(优先)**:WebSocket 推送(RTDS API)
|
||||
- 实时接收 Leader 的交易推送
|
||||
- WebSocket URL: `wss://ws-live-data.polymarket.com`
|
||||
- 订阅用户交易频道,实时获取交易数据
|
||||
- **方式2(备选)**:轮询 CLOB API
|
||||
- 通过 CLOB API `/trades?user={leaderAddress}` 获取 Leader 的交易记录
|
||||
- 定期轮询(默认每 5 秒)
|
||||
|
||||
**交易数据包含**:
|
||||
- `side`: "BUY" 或 "SELL"(直接复制)
|
||||
- `market`: 市场地址(直接复制)
|
||||
- `price`: 交易价格(可调整)
|
||||
- `size`: 交易数量(按比例或固定金额计算)
|
||||
|
||||
## 2. 实现流程
|
||||
|
||||
### 2.1 监控 Leader 交易
|
||||
|
||||
#### 2.1.1 WebSocket 推送模式(优先)
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* WebSocket 推送监控服务
|
||||
*/
|
||||
@Service
|
||||
class CopyTradingWebSocketService(
|
||||
private val leaderRepository: LeaderRepository,
|
||||
private val configRepository: CopyTradingConfigRepository
|
||||
) {
|
||||
private var webSocketClient: WebSocketClient? = null
|
||||
private val subscribedLeaders = mutableSetOf<String>()
|
||||
|
||||
/**
|
||||
* 初始化 WebSocket 连接
|
||||
*/
|
||||
@PostConstruct
|
||||
fun initWebSocket() {
|
||||
val config = configRepository.findFirstByOrderByIdAsc() ?: getDefaultConfig()
|
||||
|
||||
if (config.useWebSocket) {
|
||||
connectWebSocket()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接 WebSocket
|
||||
*/
|
||||
private fun connectWebSocket() {
|
||||
try {
|
||||
val wsUrl = "wss://ws-live-data.polymarket.com"
|
||||
webSocketClient = WebSocketClient(wsUrl)
|
||||
|
||||
webSocketClient?.onMessage { message ->
|
||||
handleWebSocketMessage(message)
|
||||
}
|
||||
|
||||
webSocketClient?.onError { error ->
|
||||
logger.error("WebSocket 连接错误", error)
|
||||
// 降级到轮询模式
|
||||
fallbackToPolling()
|
||||
}
|
||||
|
||||
webSocketClient?.onClose {
|
||||
logger.warn("WebSocket 连接关闭,尝试重连")
|
||||
reconnectWebSocket()
|
||||
}
|
||||
|
||||
// 订阅所有启用的 Leader
|
||||
subscribeAllLeaders()
|
||||
|
||||
} catch (e: Exception) {
|
||||
logger.error("WebSocket 连接失败,降级到轮询模式", e)
|
||||
fallbackToPolling()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅所有启用的 Leader
|
||||
*/
|
||||
private fun subscribeAllLeaders() {
|
||||
val enabledLeaders = leaderRepository.findByEnabledTrue()
|
||||
enabledLeaders.forEach { leader ->
|
||||
subscribeLeader(leader.leaderAddress)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅单个 Leader
|
||||
*/
|
||||
fun subscribeLeader(leaderAddress: String) {
|
||||
val subscribeMessage = jsonObjectOf(
|
||||
"type" to "subscribe",
|
||||
"channel" to "user",
|
||||
"user" to leaderAddress
|
||||
)
|
||||
webSocketClient?.send(subscribeMessage.toString())
|
||||
subscribedLeaders.add(leaderAddress)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 WebSocket 消息
|
||||
*/
|
||||
private fun handleWebSocketMessage(message: String) {
|
||||
try {
|
||||
val json = JSONObject(message)
|
||||
val channel = json.optString("channel")
|
||||
val eventType = json.optString("event")
|
||||
|
||||
if (channel == "user" && eventType == "trade") {
|
||||
val trade = parseTradeMessage(json)
|
||||
// 触发跟单逻辑
|
||||
processTradeFromWebSocket(trade)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("处理 WebSocket 消息失败", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重连 WebSocket
|
||||
*/
|
||||
private fun reconnectWebSocket() {
|
||||
val config = configRepository.findFirstByOrderByIdAsc() ?: getDefaultConfig()
|
||||
var retryCount = 0
|
||||
|
||||
while (retryCount < config.websocketMaxRetries) {
|
||||
try {
|
||||
Thread.sleep(config.websocketReconnectInterval.toLong())
|
||||
connectWebSocket()
|
||||
return
|
||||
} catch (e: Exception) {
|
||||
retryCount++
|
||||
logger.warn("WebSocket 重连失败 (${retryCount}/${config.websocketMaxRetries})", e)
|
||||
}
|
||||
}
|
||||
|
||||
// 重连失败,降级到轮询
|
||||
logger.error("WebSocket 重连失败,降级到轮询模式")
|
||||
fallbackToPolling()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.1.2 轮询模式(备选)
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* 轮询监控服务(WebSocket 不可用时的备选方案)
|
||||
*/
|
||||
@Service
|
||||
class CopyTradingPollingService(
|
||||
private val clobService: PolymarketClobService,
|
||||
private val leaderRepository: LeaderRepository,
|
||||
private val configRepository: CopyTradingConfigRepository
|
||||
) {
|
||||
|
||||
/**
|
||||
* 定期轮询所有启用的 Leader 的交易
|
||||
*/
|
||||
@Scheduled(fixedDelayString = "\${copy.trading.poll.interval:5000}")
|
||||
suspend fun monitorLeaders() {
|
||||
val config = configRepository.findFirstByOrderByIdAsc() ?: getDefaultConfig()
|
||||
|
||||
// 如果配置了使用 WebSocket 且 WebSocket 可用,则不轮询
|
||||
if (config.useWebSocket && isWebSocketAvailable()) {
|
||||
return
|
||||
}
|
||||
|
||||
val enabledLeaders = leaderRepository.findByEnabledTrue()
|
||||
|
||||
enabledLeaders.forEach { leader ->
|
||||
try {
|
||||
processLeaderTrades(leader)
|
||||
} catch (e: Exception) {
|
||||
logger.error("处理 Leader ${leader.id} 交易失败", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理单个 Leader 的交易
|
||||
*/
|
||||
private suspend fun processLeaderTrades(leader: Leader) {
|
||||
// 1. 获取 Leader 的最新交易记录
|
||||
val tradesResult = clobService.getTrades(
|
||||
market = null,
|
||||
user = leader.leaderAddress,
|
||||
limit = 50,
|
||||
offset = 0
|
||||
)
|
||||
|
||||
tradesResult.fold(
|
||||
onSuccess = { trades ->
|
||||
trades.forEach { trade ->
|
||||
// 2. 检查是否已处理过(去重)
|
||||
if (!isProcessed(leader.id, trade.id)) {
|
||||
// 3. 处理交易(买入或卖出)
|
||||
processTrade(leader, trade)
|
||||
// 4. 标记为已处理
|
||||
markAsProcessed(leader.id, trade.id)
|
||||
}
|
||||
}
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("获取 Leader ${leader.id} 交易失败", e)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 处理交易(买入/卖出)
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* 处理单笔交易,自动识别买入或卖出
|
||||
*/
|
||||
private suspend fun processTrade(leader: Leader, trade: TradeResponse) {
|
||||
// 1. 验证分类筛选
|
||||
if (leader.category != null) {
|
||||
val marketCategory = getMarketCategory(trade.market)
|
||||
if (marketCategory != leader.category) {
|
||||
logger.debug("跳过交易:分类不匹配 ${trade.market}")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 验证风险控制
|
||||
if (!checkRiskControl(leader)) {
|
||||
logger.warn("风险控制限制,跳过跟单 Leader ${leader.id}")
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 确定使用的账户
|
||||
val account = getAccountForLeader(leader)
|
||||
if (account == null) {
|
||||
logger.error("无法获取账户,跳过跟单 Leader ${leader.id}")
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 计算跟单订单参数
|
||||
val orderParams = calculateOrderParams(leader, trade)
|
||||
|
||||
// 5. 创建跟单订单(买入或卖出)
|
||||
createCopyOrder(leader, account, trade, orderParams)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算跟单订单参数
|
||||
*/
|
||||
private fun calculateOrderParams(leader: Leader, trade: TradeResponse): OrderParams {
|
||||
// 获取配置
|
||||
val globalConfig = configRepository.findFirstByOrderByIdAsc() ?: getDefaultConfig()
|
||||
|
||||
// 计算订单大小
|
||||
val leaderSize = trade.size.toSafeBigDecimal()
|
||||
val copyRatio = leader.copyRatio ?: globalConfig.copyRatio
|
||||
var orderSize = leaderSize.multiply(copyRatio)
|
||||
|
||||
// 应用限制
|
||||
val maxSize = leader.maxOrderSize ?: globalConfig.maxOrderSize
|
||||
val minSize = leader.minOrderSize ?: globalConfig.minOrderSize
|
||||
orderSize = orderSize.coerceIn(minSize, maxSize)
|
||||
|
||||
// 计算价格(默认使用 Leader 的价格)
|
||||
val price = trade.price.toSafeBigDecimal()
|
||||
// TODO: 可以根据价格容忍度调整价格
|
||||
|
||||
return OrderParams(
|
||||
market = trade.market,
|
||||
side = trade.side, // 直接复制 BUY 或 SELL
|
||||
price = price,
|
||||
size = orderSize
|
||||
)
|
||||
}
|
||||
|
||||
data class OrderParams(
|
||||
val market: String,
|
||||
val side: String, // "BUY" 或 "SELL"
|
||||
val price: BigDecimal,
|
||||
val size: BigDecimal
|
||||
)
|
||||
```
|
||||
|
||||
### 2.3 创建跟单订单
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* 创建跟单订单(买入或卖出)
|
||||
*/
|
||||
private suspend fun createCopyOrder(
|
||||
leader: Leader,
|
||||
account: Account,
|
||||
trade: TradeResponse,
|
||||
params: OrderParams
|
||||
) {
|
||||
try {
|
||||
// 1. 创建订单请求
|
||||
val orderRequest = CreateOrderRequest(
|
||||
market = params.market,
|
||||
side = params.side, // "BUY" 或 "SELL"
|
||||
price = params.price.toPlainString(),
|
||||
size = params.size.toPlainString(),
|
||||
type = "LIMIT"
|
||||
)
|
||||
|
||||
// 2. 使用账户的 API Key 创建订单
|
||||
val apiKey = account.apiKey ?: throw IllegalStateException("账户 ${account.id} 未配置 API Key")
|
||||
val clobApi = createClobApiWithApiKey(apiKey)
|
||||
|
||||
val orderResult = clobApi.createOrder(orderRequest)
|
||||
|
||||
orderResult.fold(
|
||||
onSuccess = { orderResponse ->
|
||||
// 3. 保存跟单记录
|
||||
val copyOrder = CopyOrder(
|
||||
accountId = account.id!!,
|
||||
leaderId = leader.id!!,
|
||||
leaderAddress = leader.leaderAddress,
|
||||
leaderTradeId = trade.id,
|
||||
marketId = params.market,
|
||||
category = getMarketCategory(params.market),
|
||||
side = params.side, // 保存买入或卖出方向
|
||||
price = params.price,
|
||||
size = params.size,
|
||||
copyRatio = leader.copyRatio ?: BigDecimal.ONE,
|
||||
orderId = orderResponse.id,
|
||||
status = "created"
|
||||
)
|
||||
copyOrderRepository.save(copyOrder)
|
||||
|
||||
logger.info("成功创建跟单订单: Leader=${leader.id}, Side=${params.side}, Market=${params.market}")
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("创建跟单订单失败: Leader=${leader.id}, Side=${params.side}", e)
|
||||
|
||||
// 记录失败的跟单订单
|
||||
val copyOrder = CopyOrder(
|
||||
accountId = account.id!!,
|
||||
leaderId = leader.id!!,
|
||||
leaderAddress = leader.leaderAddress,
|
||||
leaderTradeId = trade.id,
|
||||
marketId = params.market,
|
||||
category = getMarketCategory(params.market),
|
||||
side = params.side,
|
||||
price = params.price,
|
||||
size = params.size,
|
||||
copyRatio = leader.copyRatio ?: BigDecimal.ONE,
|
||||
status = "failed"
|
||||
)
|
||||
copyOrderRepository.save(copyOrder)
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("创建跟单订单异常: Leader=${leader.id}, Side=${params.side}", e)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 3. 关键实现细节
|
||||
|
||||
### 3.1 买入和卖出的区别
|
||||
|
||||
**买入跟单(BUY)**:
|
||||
- Leader 执行 `BUY` 交易 → 系统创建 `BUY` 订单
|
||||
- 订单参数:`side = "BUY"`
|
||||
- 表示买入该市场的 YES 或 NO 代币
|
||||
|
||||
**卖出跟单(SELL)**:
|
||||
- Leader 执行 `SELL` 交易 → 系统创建 `SELL` 订单
|
||||
- 订单参数:`side = "SELL"`
|
||||
- 表示卖出持有的代币
|
||||
|
||||
**实现上无区别**:
|
||||
- 买入和卖出的处理逻辑完全相同
|
||||
- 只是 `side` 字段的值不同("BUY" 或 "SELL")
|
||||
- 都通过 `createOrder` API 创建订单
|
||||
|
||||
### 3.2 去重机制
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* 检查交易是否已处理
|
||||
*/
|
||||
private suspend fun isProcessed(leaderId: Long, tradeId: String): Boolean {
|
||||
return processedTradeRepository.existsByLeaderIdAndTradeId(leaderId, tradeId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记交易为已处理
|
||||
*/
|
||||
private suspend fun markAsProcessed(leaderId: Long, tradeId: String) {
|
||||
val processed = ProcessedTrade(
|
||||
leaderId = leaderId,
|
||||
tradeId = tradeId,
|
||||
processedAt = System.currentTimeMillis()
|
||||
)
|
||||
processedTradeRepository.save(processed)
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 账户选择
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* 获取 Leader 使用的账户
|
||||
*/
|
||||
private suspend fun getAccountForLeader(leader: Leader): Account? {
|
||||
return if (leader.accountId != null) {
|
||||
// 使用 Leader 指定的账户
|
||||
accountRepository.findById(leader.accountId)
|
||||
} else {
|
||||
// 使用默认账户
|
||||
accountRepository.findByIsDefaultTrue()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 风险控制检查
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* 检查风险控制限制
|
||||
*/
|
||||
private suspend fun checkRiskControl(leader: Leader): Boolean {
|
||||
val config = configRepository.findFirstByOrderByIdAsc() ?: getDefaultConfig()
|
||||
|
||||
// 检查每日亏损限制
|
||||
val todayLoss = getTodayLoss(leader.accountId ?: getDefaultAccountId())
|
||||
if (todayLoss >= config.maxDailyLoss) {
|
||||
logger.warn("达到每日亏损限制: $todayLoss >= ${config.maxDailyLoss}")
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查每日订单数限制
|
||||
val todayOrderCount = getTodayOrderCount(leader.accountId ?: getDefaultAccountId())
|
||||
if (todayOrderCount >= config.maxDailyOrders) {
|
||||
logger.warn("达到每日订单数限制: $todayOrderCount >= ${config.maxDailyOrders}")
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 数据模型
|
||||
|
||||
### 4.1 ProcessedTrade(已处理交易)
|
||||
|
||||
```kotlin
|
||||
@Entity
|
||||
@Table(name = "copy_trading_processed_trades")
|
||||
data class ProcessedTrade(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
@Column(name = "leader_id", nullable = false)
|
||||
val leaderId: Long,
|
||||
|
||||
@Column(name = "trade_id", nullable = false, length = 100)
|
||||
val tradeId: String,
|
||||
|
||||
@Column(name = "processed_at", nullable = false)
|
||||
val processedAt: Long = System.currentTimeMillis(),
|
||||
|
||||
@UniqueConstraint(columnNames = ["leader_id", "trade_id"])
|
||||
)
|
||||
```
|
||||
|
||||
## 5. 完整示例
|
||||
|
||||
### 5.1 买入跟单示例
|
||||
|
||||
```
|
||||
1. Leader 执行买入交易:
|
||||
- Trade: { id: "trade_123", market: "0x...", side: "BUY", price: "0.5", size: "100" }
|
||||
|
||||
2. 系统检测到交易:
|
||||
- 验证分类、风险控制
|
||||
- 计算跟单参数:size = 100 × 1.0 = 100
|
||||
|
||||
3. 创建跟单订单:
|
||||
- CreateOrderRequest: { market: "0x...", side: "BUY", price: "0.5", size: "100" }
|
||||
- 调用 CLOB API 创建订单
|
||||
|
||||
4. 保存记录:
|
||||
- CopyOrder: { side: "BUY", ... }
|
||||
```
|
||||
|
||||
### 5.2 卖出跟单示例
|
||||
|
||||
```
|
||||
1. Leader 执行卖出交易:
|
||||
- Trade: { id: "trade_456", market: "0x...", side: "SELL", price: "0.6", size: "50" }
|
||||
|
||||
2. 系统检测到交易:
|
||||
- 验证分类、风险控制
|
||||
- 计算跟单参数:size = 50 × 1.0 = 50
|
||||
|
||||
3. 创建跟单订单:
|
||||
- CreateOrderRequest: { market: "0x...", side: "SELL", price: "0.6", size: "50" }
|
||||
- 调用 CLOB API 创建订单
|
||||
|
||||
4. 保存记录:
|
||||
- CopyOrder: { side: "SELL", ... }
|
||||
```
|
||||
|
||||
## 6. 注意事项
|
||||
|
||||
### 6.1 交易 vs 订单
|
||||
- **交易(Trade)**:已成交的记录,包含 `side` 字段
|
||||
- **订单(Order)**:挂单,可能未成交
|
||||
- 跟单系统基于**交易记录**触发,创建**订单**
|
||||
|
||||
### 6.2 价格和数量
|
||||
- **价格**:默认使用 Leader 的交易价格,可配置价格容忍度
|
||||
- **数量**:按跟单比例计算,应用最大/最小限制
|
||||
|
||||
### 6.3 错误处理
|
||||
- API 调用失败时记录失败状态
|
||||
- 网络异常时重试机制
|
||||
- 记录详细日志便于排查
|
||||
|
||||
### 6.4 性能优化
|
||||
- 批量查询多个 Leader 的交易
|
||||
- 使用缓存减少重复查询
|
||||
- 异步处理订单创建
|
||||
|
||||
## 7. 总结
|
||||
|
||||
**买入和卖出的实现完全相同**:
|
||||
- 都通过监控 Leader 的交易记录触发
|
||||
- 都通过 `side` 字段区分("BUY" 或 "SELL")
|
||||
- 都调用相同的 `createOrder` API
|
||||
- 区别仅在于 `side` 参数的值
|
||||
|
||||
**核心流程**:
|
||||
1. 轮询 Leader 交易 → 2. 识别买入/卖出 → 3. 计算参数 → 4. 创建订单 → 5. 保存记录
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# Polymarket 跟单系统前端
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **框架**: React 18 + TypeScript
|
||||
- **构建工具**: Vite
|
||||
- **UI 库**: Ant Design + Ant Design Mobile
|
||||
- **HTTP 客户端**: axios
|
||||
- **状态管理**: Zustand
|
||||
- **路由**: React Router
|
||||
- **以太坊库**: ethers.js
|
||||
|
||||
## 功能特性
|
||||
|
||||
- ✅ 账户管理(通过私钥导入,支持多账户)
|
||||
- ✅ Leader 管理(被跟单者管理)
|
||||
- ✅ 跟单配置管理
|
||||
- ✅ 订单管理
|
||||
- ✅ 统计信息
|
||||
- ✅ 移动端适配
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 启动开发服务器
|
||||
npm run dev
|
||||
|
||||
# 构建生产版本
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── components/ # 公共组件
|
||||
│ │ └── Layout.tsx # 布局组件(支持移动端)
|
||||
│ ├── pages/ # 页面组件
|
||||
│ │ ├── AccountList.tsx
|
||||
│ │ ├── AccountImport.tsx
|
||||
│ │ ├── LeaderList.tsx
|
||||
│ │ ├── LeaderAdd.tsx
|
||||
│ │ ├── ConfigPage.tsx
|
||||
│ │ ├── OrderList.tsx
|
||||
│ │ └── Statistics.tsx
|
||||
│ ├── services/ # API 服务
|
||||
│ │ └── api.ts
|
||||
│ ├── store/ # 状态管理
|
||||
│ │ └── accountStore.ts
|
||||
│ ├── types/ # TypeScript 类型定义
|
||||
│ │ └── index.ts
|
||||
│ ├── utils/ # 工具函数
|
||||
│ │ └── ethers.ts
|
||||
│ ├── styles/ # 样式文件
|
||||
│ │ └── index.css
|
||||
│ ├── App.tsx # 根组件
|
||||
│ └── main.tsx # 入口文件
|
||||
├── index.html
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
└── vite.config.ts
|
||||
```
|
||||
|
||||
## 移动端适配
|
||||
|
||||
- 使用 `react-responsive` 检测设备类型
|
||||
- 移动端使用 Ant Design Mobile 组件
|
||||
- 响应式布局(移动端 < 768px,桌面端 >= 768px)
|
||||
- 触摸友好的按钮尺寸(>= 44x44px)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>Polymarket 跟单系统</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Generated
+4917
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "polymarket-bot-frontend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 3000 --host 0.0.0.0",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"axios": "^1.6.2",
|
||||
"zustand": "^4.4.7",
|
||||
"antd": "^5.12.0",
|
||||
"antd-mobile": "^5.34.0",
|
||||
"ethers": "^6.9.0",
|
||||
"react-responsive": "^9.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||
"@typescript-eslint/parser": "^6.14.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"eslint": "^8.55.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||
import { ConfigProvider } from 'antd'
|
||||
import zhCN from 'antd/locale/zh_CN'
|
||||
import Layout from './components/Layout'
|
||||
import AccountList from './pages/AccountList'
|
||||
import AccountImport from './pages/AccountImport'
|
||||
import AccountDetail from './pages/AccountDetail'
|
||||
import LeaderList from './pages/LeaderList'
|
||||
import LeaderAdd from './pages/LeaderAdd'
|
||||
import ConfigPage from './pages/ConfigPage'
|
||||
import OrderList from './pages/OrderList'
|
||||
import Statistics from './pages/Statistics'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<BrowserRouter>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<AccountList />} />
|
||||
<Route path="/accounts" element={<AccountList />} />
|
||||
<Route path="/accounts/import" element={<AccountImport />} />
|
||||
<Route path="/accounts/detail" element={<AccountDetail />} />
|
||||
<Route path="/leaders" element={<LeaderList />} />
|
||||
<Route path="/leaders/add" element={<LeaderAdd />} />
|
||||
<Route path="/config" element={<ConfigPage />} />
|
||||
<Route path="/orders" element={<OrderList />} />
|
||||
<Route path="/statistics" element={<Statistics />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</BrowserRouter>
|
||||
</ConfigProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { Layout as AntLayout, Menu, Drawer, Button } from 'antd'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import {
|
||||
WalletOutlined,
|
||||
UserOutlined,
|
||||
SettingOutlined,
|
||||
UnorderedListOutlined,
|
||||
BarChartOutlined,
|
||||
MenuOutlined
|
||||
} from '@ant-design/icons'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
const { Header, Content, Sider } = AntLayout
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
key: '/accounts',
|
||||
icon: <WalletOutlined />,
|
||||
label: '账户管理'
|
||||
},
|
||||
{
|
||||
key: '/leaders',
|
||||
icon: <UserOutlined />,
|
||||
label: 'Leader 管理'
|
||||
},
|
||||
{
|
||||
key: '/config',
|
||||
icon: <SettingOutlined />,
|
||||
label: '跟单配置'
|
||||
},
|
||||
{
|
||||
key: '/orders',
|
||||
icon: <UnorderedListOutlined />,
|
||||
label: '订单管理'
|
||||
},
|
||||
{
|
||||
key: '/statistics',
|
||||
icon: <BarChartOutlined />,
|
||||
label: '统计信息'
|
||||
}
|
||||
]
|
||||
|
||||
const handleMenuClick = (key: string) => {
|
||||
navigate(key)
|
||||
if (isMobile) {
|
||||
setMobileMenuOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
// 移动端布局
|
||||
return (
|
||||
<AntLayout style={{ minHeight: '100vh' }}>
|
||||
<Header style={{
|
||||
background: '#001529',
|
||||
padding: '0 16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<div style={{ color: '#fff', fontSize: '18px', fontWeight: 'bold' }}>
|
||||
Polymarket 跟单
|
||||
</div>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<MenuOutlined />}
|
||||
style={{ color: '#fff' }}
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
/>
|
||||
</Header>
|
||||
<Content style={{
|
||||
padding: '12px 8px',
|
||||
background: '#f0f2f5',
|
||||
minHeight: 'calc(100vh - 64px)'
|
||||
}}>
|
||||
{children}
|
||||
</Content>
|
||||
<Drawer
|
||||
title="导航菜单"
|
||||
placement="left"
|
||||
onClose={() => setMobileMenuOpen(false)}
|
||||
open={mobileMenuOpen}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[location.pathname]}
|
||||
items={menuItems}
|
||||
onClick={({ key }) => handleMenuClick(key)}
|
||||
style={{ border: 'none' }}
|
||||
/>
|
||||
</Drawer>
|
||||
</AntLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// 桌面端布局
|
||||
return (
|
||||
<AntLayout style={{ minHeight: '100vh' }}>
|
||||
<Sider width={200} style={{ background: '#001529' }}>
|
||||
<div style={{
|
||||
height: '64px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#fff',
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
Polymarket 跟单
|
||||
</div>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[location.pathname]}
|
||||
items={menuItems}
|
||||
onClick={({ key }) => handleMenuClick(key)}
|
||||
style={{ height: 'calc(100vh - 64px)', borderRight: 0 }}
|
||||
/>
|
||||
</Sider>
|
||||
<AntLayout>
|
||||
<Content style={{ padding: '24px', background: '#f0f2f5', minHeight: '100vh' }}>
|
||||
{children}
|
||||
</Content>
|
||||
</AntLayout>
|
||||
</AntLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default Layout
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './styles/index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { Card, Descriptions, Button, Space, Tag, Spin, message, Typography, Divider } from 'antd'
|
||||
import { ArrowLeftOutlined, ReloadOutlined, EditOutlined } from '@ant-design/icons'
|
||||
import { useAccountStore } from '../store/accountStore'
|
||||
import type { Account } from '../types'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
|
||||
const { Title } = Typography
|
||||
|
||||
const AccountDetail: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const accountId = searchParams.get('id')
|
||||
|
||||
const { fetchAccountDetail, fetchAccountBalance } = useAccountStore()
|
||||
const [account, setAccount] = useState<Account | null>(null)
|
||||
const [balance, setBalance] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [balanceLoading, setBalanceLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (accountId) {
|
||||
loadAccountDetail()
|
||||
loadBalance()
|
||||
} else {
|
||||
message.error('账户ID不能为空')
|
||||
navigate('/accounts')
|
||||
}
|
||||
}, [accountId])
|
||||
|
||||
const loadAccountDetail = async () => {
|
||||
if (!accountId) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const accountData = await fetchAccountDetail(Number(accountId))
|
||||
setAccount(accountData)
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '获取账户详情失败')
|
||||
navigate('/accounts')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadBalance = async () => {
|
||||
if (!accountId) return
|
||||
|
||||
setBalanceLoading(true)
|
||||
try {
|
||||
const balanceData = await fetchAccountBalance(Number(accountId))
|
||||
setBalance(balanceData.balance || null)
|
||||
} catch (error: any) {
|
||||
console.error('获取余额失败:', error)
|
||||
// 余额查询失败不显示错误,只显示 "-"
|
||||
setBalance(null)
|
||||
} finally {
|
||||
setBalanceLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '50px' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: isMobile ? '0' : undefined,
|
||||
margin: isMobile ? '0 -8px' : undefined
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: isMobile ? '12px' : '16px',
|
||||
flexWrap: 'wrap',
|
||||
gap: '12px',
|
||||
padding: isMobile ? '0 8px' : '0'
|
||||
}}>
|
||||
<Space wrap>
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/accounts')}
|
||||
size={isMobile ? 'middle' : 'large'}
|
||||
>
|
||||
返回
|
||||
</Button>
|
||||
<Title level={isMobile ? 4 : 2} style={{ margin: 0, fontSize: isMobile ? '16px' : undefined }}>
|
||||
{account.accountName || `账户 ${account.id}`}
|
||||
</Title>
|
||||
</Space>
|
||||
<Space wrap style={{ width: isMobile ? '100%' : 'auto' }}>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={loadBalance}
|
||||
loading={balanceLoading}
|
||||
size={isMobile ? 'middle' : 'large'}
|
||||
block={isMobile}
|
||||
style={isMobile ? { minHeight: '44px' } : undefined}
|
||||
>
|
||||
刷新余额
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => navigate(`/accounts/edit?id=${account.id}`)}
|
||||
size={isMobile ? 'middle' : 'large'}
|
||||
block={isMobile}
|
||||
style={isMobile ? { minHeight: '44px' } : undefined}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card style={{
|
||||
margin: isMobile ? '0 -8px' : '0',
|
||||
borderRadius: isMobile ? '0' : undefined
|
||||
}}>
|
||||
<Descriptions
|
||||
column={isMobile ? 1 : 2}
|
||||
bordered
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
style={{ fontSize: isMobile ? '14px' : undefined }}
|
||||
>
|
||||
<Descriptions.Item label="账户ID">
|
||||
{account.id}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="账户名称">
|
||||
{account.accountName || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="钱包地址" span={isMobile ? 1 : 2}>
|
||||
<span style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: isMobile ? '11px' : '14px',
|
||||
wordBreak: 'break-all',
|
||||
lineHeight: '1.4',
|
||||
display: 'block'
|
||||
}}>
|
||||
{account.walletAddress}
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="默认账户">
|
||||
<Tag color={account.isDefault ? 'gold' : 'default'}>
|
||||
{account.isDefault ? '是' : '否'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="账户余额">
|
||||
{balanceLoading ? (
|
||||
<Spin size="small" />
|
||||
) : balance ? (
|
||||
<span style={{ fontWeight: 'bold', color: '#1890ff' }}>
|
||||
{balance} USDC
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: '#999' }}>-</span>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Card
|
||||
title="API 凭证配置"
|
||||
style={{
|
||||
marginTop: isMobile ? '12px' : '16px',
|
||||
margin: isMobile ? '0 -8px' : '0',
|
||||
borderRadius: isMobile ? '0' : undefined
|
||||
}}
|
||||
>
|
||||
<Descriptions
|
||||
column={isMobile ? 1 : 2}
|
||||
bordered
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
style={{ fontSize: isMobile ? '14px' : undefined }}
|
||||
>
|
||||
<Descriptions.Item label="API Key">
|
||||
<Tag color={account.apiKeyConfigured ? 'success' : 'default'}>
|
||||
{account.apiKeyConfigured ? '已配置' : '未配置'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="API Secret">
|
||||
<Tag color={account.apiSecretConfigured ? 'success' : 'default'}>
|
||||
{account.apiSecretConfigured ? '已配置' : '未配置'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="API Passphrase">
|
||||
<Tag color={account.apiPassphraseConfigured ? 'success' : 'default'}>
|
||||
{account.apiPassphraseConfigured ? '已配置' : '未配置'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="配置状态">
|
||||
{account.apiKeyConfigured && account.apiSecretConfigured && account.apiPassphraseConfigured ? (
|
||||
<Tag color="success">完整配置</Tag>
|
||||
) : (
|
||||
<Tag color="warning">部分配置</Tag>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
{account.totalOrders !== undefined || account.totalPnl !== undefined ? (
|
||||
<>
|
||||
<Divider style={{ margin: isMobile ? '12px 0' : '16px 0' }} />
|
||||
<Card
|
||||
title="交易统计"
|
||||
style={{
|
||||
marginTop: isMobile ? '12px' : '16px',
|
||||
margin: isMobile ? '0 -8px' : '0',
|
||||
borderRadius: isMobile ? '0' : undefined
|
||||
}}
|
||||
>
|
||||
<Descriptions
|
||||
column={isMobile ? 1 : 2}
|
||||
bordered
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
style={{ fontSize: isMobile ? '14px' : undefined }}
|
||||
>
|
||||
{account.totalOrders !== undefined && (
|
||||
<Descriptions.Item label="总订单数">
|
||||
{account.totalOrders}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
{account.totalPnl !== undefined && (
|
||||
<Descriptions.Item label="总盈亏">
|
||||
<span style={{
|
||||
fontWeight: 'bold',
|
||||
color: account.totalPnl.startsWith('-') ? '#ff4d4f' : '#52c41a'
|
||||
}}>
|
||||
{account.totalPnl} USDC
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccountDetail
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Card, Form, Input, Button, message, Typography, Radio, Space, Alert, Checkbox } from 'antd'
|
||||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
||||
import { useAccountStore } from '../store/accountStore'
|
||||
import {
|
||||
getAddressFromPrivateKey,
|
||||
getAddressFromMnemonic,
|
||||
getPrivateKeyFromMnemonic,
|
||||
isValidWalletAddress,
|
||||
isValidPrivateKey,
|
||||
isValidMnemonic
|
||||
} from '../utils/ethers'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
type ImportType = 'privateKey' | 'mnemonic'
|
||||
|
||||
const AccountImport: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const { importAccount, loading } = useAccountStore()
|
||||
const [form] = Form.useForm()
|
||||
const [importType, setImportType] = useState<ImportType>('privateKey')
|
||||
const [derivedAddress, setDerivedAddress] = useState<string>('')
|
||||
const [addressError, setAddressError] = useState<string>('')
|
||||
|
||||
// 当私钥输入时,自动推导地址
|
||||
const handlePrivateKeyChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const privateKey = e.target.value.trim()
|
||||
if (!privateKey) {
|
||||
setDerivedAddress('')
|
||||
setAddressError('')
|
||||
return
|
||||
}
|
||||
|
||||
// 验证私钥格式
|
||||
if (!isValidPrivateKey(privateKey)) {
|
||||
setAddressError('私钥格式不正确(应为64位十六进制字符串)')
|
||||
setDerivedAddress('')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const address = getAddressFromPrivateKey(privateKey)
|
||||
setDerivedAddress(address)
|
||||
setAddressError('')
|
||||
|
||||
// 自动填充钱包地址字段
|
||||
form.setFieldsValue({ walletAddress: address })
|
||||
} catch (error: any) {
|
||||
setAddressError(error.message || '无法从私钥推导地址')
|
||||
setDerivedAddress('')
|
||||
}
|
||||
}
|
||||
|
||||
// 当助记词输入时,自动推导地址
|
||||
const handleMnemonicChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const mnemonic = e.target.value.trim()
|
||||
if (!mnemonic) {
|
||||
setDerivedAddress('')
|
||||
setAddressError('')
|
||||
return
|
||||
}
|
||||
|
||||
// 验证助记词格式
|
||||
if (!isValidMnemonic(mnemonic)) {
|
||||
setAddressError('助记词格式不正确(应为12或24个单词,用空格分隔)')
|
||||
setDerivedAddress('')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const address = getAddressFromMnemonic(mnemonic, 0)
|
||||
setDerivedAddress(address)
|
||||
setAddressError('')
|
||||
|
||||
// 自动填充钱包地址字段
|
||||
form.setFieldsValue({ walletAddress: address })
|
||||
} catch (error: any) {
|
||||
setAddressError(error.message || '无法从助记词推导地址')
|
||||
setDerivedAddress('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
try {
|
||||
let privateKey: string
|
||||
let walletAddress: string
|
||||
|
||||
if (importType === 'privateKey') {
|
||||
// 私钥模式
|
||||
privateKey = values.privateKey
|
||||
walletAddress = values.walletAddress
|
||||
|
||||
// 验证推导的地址和输入的地址是否一致
|
||||
if (derivedAddress && walletAddress !== derivedAddress) {
|
||||
message.error('钱包地址与私钥不匹配')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 助记词模式
|
||||
if (!values.mnemonic) {
|
||||
message.error('请输入助记词')
|
||||
return
|
||||
}
|
||||
|
||||
// 从助记词导出私钥和地址
|
||||
privateKey = getPrivateKeyFromMnemonic(values.mnemonic, 0)
|
||||
const derivedAddressFromMnemonic = getAddressFromMnemonic(values.mnemonic, 0)
|
||||
|
||||
// 如果用户手动输入了地址,验证是否与推导的地址一致
|
||||
if (values.walletAddress) {
|
||||
if (values.walletAddress !== derivedAddressFromMnemonic) {
|
||||
// 地址不匹配,使用推导的地址(因为私钥是从助记词导出的,必须使用对应的地址)
|
||||
message.warning(`输入的地址与助记词推导的地址不一致。推导的地址: ${derivedAddressFromMnemonic},将使用推导的地址`)
|
||||
walletAddress = derivedAddressFromMnemonic
|
||||
} else {
|
||||
// 地址匹配,使用用户输入的地址
|
||||
walletAddress = values.walletAddress
|
||||
}
|
||||
} else {
|
||||
// 如果用户没有输入地址,使用推导的地址
|
||||
walletAddress = derivedAddressFromMnemonic
|
||||
}
|
||||
}
|
||||
|
||||
// 验证钱包地址格式
|
||||
if (!isValidWalletAddress(walletAddress)) {
|
||||
message.error('钱包地址格式不正确')
|
||||
return
|
||||
}
|
||||
|
||||
await importAccount({
|
||||
privateKey: privateKey,
|
||||
walletAddress: walletAddress,
|
||||
accountName: values.accountName,
|
||||
apiKey: values.apiKey,
|
||||
apiSecret: values.apiSecret,
|
||||
apiPassphrase: values.apiPassphrase,
|
||||
isDefault: values.isDefault || false
|
||||
})
|
||||
|
||||
message.success('导入账户成功')
|
||||
navigate('/accounts')
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '导入账户失败')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/accounts')}
|
||||
style={{ marginBottom: '16px' }}
|
||||
>
|
||||
返回
|
||||
</Button>
|
||||
<Title level={2} style={{ margin: 0 }}>导入账户</Title>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Alert
|
||||
message="安全提示"
|
||||
description="私钥将加密存储在后端,请确保网络连接安全。建议使用 HTTPS 连接。"
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: '24px' }}
|
||||
/>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
size={isMobile ? 'middle' : 'large'}
|
||||
>
|
||||
<Form.Item label="导入方式">
|
||||
<Radio.Group
|
||||
value={importType}
|
||||
onChange={(e) => {
|
||||
setImportType(e.target.value)
|
||||
setDerivedAddress('')
|
||||
setAddressError('')
|
||||
form.setFieldsValue({ walletAddress: '' })
|
||||
}}
|
||||
>
|
||||
<Radio value="privateKey">私钥</Radio>
|
||||
<Radio value="mnemonic">助记词</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
{importType === 'privateKey' ? (
|
||||
<>
|
||||
<Form.Item
|
||||
label="私钥"
|
||||
name="privateKey"
|
||||
rules={[
|
||||
{ required: true, message: '请输入私钥' },
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (!value) return Promise.resolve()
|
||||
if (!isValidPrivateKey(value)) {
|
||||
return Promise.reject(new Error('私钥格式不正确(应为64位十六进制字符串)'))
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
]}
|
||||
help={addressError || (derivedAddress ? `推导地址: ${derivedAddress}` : '')}
|
||||
validateStatus={addressError ? 'error' : derivedAddress ? 'success' : ''}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
placeholder="请输入私钥(64位十六进制字符串,可选0x前缀)"
|
||||
onChange={handlePrivateKeyChange}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="钱包地址"
|
||||
name="walletAddress"
|
||||
rules={[
|
||||
{ required: true, message: '请输入钱包地址' },
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (!value) return Promise.resolve()
|
||||
if (!isValidWalletAddress(value)) {
|
||||
return Promise.reject(new Error('钱包地址格式不正确'))
|
||||
}
|
||||
if (derivedAddress && value !== derivedAddress) {
|
||||
return Promise.reject(new Error('钱包地址与私钥不匹配'))
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder="钱包地址(将从私钥自动推导)"
|
||||
readOnly={!!derivedAddress}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Form.Item
|
||||
label="助记词"
|
||||
name="mnemonic"
|
||||
rules={[
|
||||
{ required: true, message: '请输入助记词' },
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (!value) return Promise.resolve()
|
||||
if (!isValidMnemonic(value)) {
|
||||
return Promise.reject(new Error('助记词格式不正确(应为12或24个单词,用空格分隔)'))
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
]}
|
||||
help={addressError || (derivedAddress ? `推导地址: ${derivedAddress}` : '')}
|
||||
validateStatus={addressError ? 'error' : derivedAddress ? 'success' : ''}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
placeholder="请输入12或24个单词的助记词(用空格分隔)"
|
||||
onChange={handleMnemonicChange}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="钱包地址"
|
||||
name="walletAddress"
|
||||
rules={[
|
||||
{ required: true, message: '请输入钱包地址' },
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (!value) return Promise.resolve()
|
||||
if (!isValidWalletAddress(value)) {
|
||||
return Promise.reject(new Error('钱包地址格式不正确'))
|
||||
}
|
||||
if (derivedAddress && value !== derivedAddress) {
|
||||
return Promise.reject(new Error('钱包地址与助记词不匹配'))
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder="钱包地址(将从助记词自动推导)"
|
||||
readOnly={!!derivedAddress}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
label="账户名称"
|
||||
name="accountName"
|
||||
>
|
||||
<Input placeholder="可选,用于标识账户" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="API Key"
|
||||
name="apiKey"
|
||||
help="Polymarket API Key(可选,用于 L2 API 认证)"
|
||||
>
|
||||
<Input.Password placeholder="可选,Polymarket API Key" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="API Secret"
|
||||
name="apiSecret"
|
||||
help="Polymarket API Secret(可选,用于 HMAC 签名)"
|
||||
>
|
||||
<Input.Password placeholder="可选,Polymarket API Secret" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="API Passphrase"
|
||||
name="apiPassphrase"
|
||||
help="Polymarket API Passphrase(可选,用于加密/解密密钥)"
|
||||
>
|
||||
<Input.Password placeholder="可选,Polymarket API Passphrase" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="isDefault"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Checkbox>设为默认账户</Checkbox>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
size={isMobile ? 'middle' : 'large'}
|
||||
>
|
||||
导入账户
|
||||
</Button>
|
||||
<Button onClick={() => navigate('/accounts')}>
|
||||
取消
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccountImport
|
||||
|
||||
@@ -0,0 +1,575 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Card, Table, Button, Space, Tag, Popconfirm, message, Typography, Spin, Modal, Descriptions, Divider } from 'antd'
|
||||
import { PlusOutlined, StarOutlined, StarFilled, ReloadOutlined } from '@ant-design/icons'
|
||||
import { useAccountStore } from '../store/accountStore'
|
||||
import type { Account } from '../types'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
|
||||
const { Title } = Typography
|
||||
|
||||
const AccountList: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const { accounts, loading, fetchAccounts, deleteAccount, setDefaultAccount, fetchAccountBalance, fetchAccountDetail } = useAccountStore()
|
||||
const [balanceMap, setBalanceMap] = useState<Record<number, { total: string; available: string; position: string }>>({})
|
||||
const [balanceLoading, setBalanceLoading] = useState<Record<number, boolean>>({})
|
||||
const [detailModalVisible, setDetailModalVisible] = useState(false)
|
||||
const [detailAccount, setDetailAccount] = useState<Account | null>(null)
|
||||
const [detailBalance, setDetailBalance] = useState<{ total: string; available: string; position: string; positions: any[] } | null>(null)
|
||||
const [detailBalanceLoading, setDetailBalanceLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchAccounts()
|
||||
}, [fetchAccounts])
|
||||
|
||||
// 加载所有账户的余额
|
||||
useEffect(() => {
|
||||
const loadBalances = async () => {
|
||||
for (const account of accounts) {
|
||||
if (!balanceMap[account.id] && !balanceLoading[account.id]) {
|
||||
setBalanceLoading(prev => ({ ...prev, [account.id]: true }))
|
||||
try {
|
||||
const balanceData = await fetchAccountBalance(account.id)
|
||||
setBalanceMap(prev => ({
|
||||
...prev,
|
||||
[account.id]: {
|
||||
total: balanceData.totalBalance || '0',
|
||||
available: balanceData.availableBalance || '0',
|
||||
position: balanceData.positionBalance || '0'
|
||||
}
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error(`获取账户 ${account.id} 余额失败:`, error)
|
||||
setBalanceMap(prev => ({
|
||||
...prev,
|
||||
[account.id]: { total: '-', available: '-', position: '-' }
|
||||
}))
|
||||
} finally {
|
||||
setBalanceLoading(prev => ({ ...prev, [account.id]: false }))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (accounts.length > 0) {
|
||||
loadBalances()
|
||||
}
|
||||
}, [accounts])
|
||||
|
||||
const handleDelete = async (account: Account) => {
|
||||
try {
|
||||
await deleteAccount(account.id)
|
||||
message.success('删除账户成功')
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '删除账户失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSetDefault = async (account: Account) => {
|
||||
try {
|
||||
await setDefaultAccount(account.id)
|
||||
message.success('设置默认账户成功')
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '设置默认账户失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleShowDetail = async (account: Account) => {
|
||||
try {
|
||||
setDetailModalVisible(true)
|
||||
setDetailAccount(account)
|
||||
setDetailBalance(null)
|
||||
setDetailBalanceLoading(false)
|
||||
|
||||
// 加载详情和余额
|
||||
try {
|
||||
const accountDetail = await fetchAccountDetail(account.id)
|
||||
setDetailAccount(accountDetail)
|
||||
|
||||
// 加载余额
|
||||
setDetailBalanceLoading(true)
|
||||
try {
|
||||
const balanceData = await fetchAccountBalance(account.id)
|
||||
setDetailBalance({
|
||||
total: balanceData.totalBalance || '0',
|
||||
available: balanceData.availableBalance || '0',
|
||||
position: balanceData.positionBalance || '0',
|
||||
positions: balanceData.positions || []
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('获取余额失败:', error)
|
||||
setDetailBalance(null)
|
||||
} finally {
|
||||
setDetailBalanceLoading(false)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('获取账户详情失败:', error)
|
||||
message.error(error.message || '获取账户详情失败')
|
||||
setDetailModalVisible(false)
|
||||
setDetailAccount(null)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('打开详情失败:', error)
|
||||
message.error('打开详情失败')
|
||||
setDetailModalVisible(false)
|
||||
setDetailAccount(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefreshDetailBalance = async () => {
|
||||
if (!detailAccount) return
|
||||
|
||||
setDetailBalanceLoading(true)
|
||||
try {
|
||||
const balanceData = await fetchAccountBalance(detailAccount.id)
|
||||
setDetailBalance({
|
||||
total: balanceData.totalBalance || '0',
|
||||
available: balanceData.availableBalance || '0',
|
||||
position: balanceData.positionBalance || '0',
|
||||
positions: balanceData.positions || []
|
||||
})
|
||||
message.success('余额刷新成功')
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '刷新余额失败')
|
||||
} finally {
|
||||
setDetailBalanceLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '账户名称',
|
||||
dataIndex: 'accountName',
|
||||
key: 'accountName',
|
||||
render: (text: string, record: Account) => text || `账户 ${record.id}`
|
||||
},
|
||||
{
|
||||
title: '钱包地址',
|
||||
dataIndex: 'walletAddress',
|
||||
key: 'walletAddress',
|
||||
render: (address: string) => (
|
||||
<span style={{ fontFamily: 'monospace' }}>{address}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '默认账户',
|
||||
dataIndex: 'isDefault',
|
||||
key: 'isDefault',
|
||||
render: (isDefault: boolean, record: Account) => (
|
||||
<Button
|
||||
type="text"
|
||||
icon={isDefault ? <StarFilled style={{ color: '#faad14' }} /> : <StarOutlined />}
|
||||
onClick={() => !isDefault && handleSetDefault(record)}
|
||||
disabled={isDefault}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'API 凭证',
|
||||
key: 'apiCredentials',
|
||||
render: (_: any, record: Account) => {
|
||||
const allConfigured = record.apiKeyConfigured && record.apiSecretConfigured && record.apiPassphraseConfigured
|
||||
const partialConfigured = record.apiKeyConfigured || record.apiSecretConfigured || record.apiPassphraseConfigured
|
||||
return (
|
||||
<Tag color={allConfigured ? 'success' : partialConfigured ? 'warning' : 'default'}>
|
||||
{allConfigured ? '完整配置' : partialConfigured ? '部分配置' : '未配置'}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '余额',
|
||||
dataIndex: 'balance',
|
||||
key: 'balance',
|
||||
render: (_: any, record: Account) => {
|
||||
if (balanceLoading[record.id]) {
|
||||
return <Spin size="small" />
|
||||
}
|
||||
const balanceObj = balanceMap[record.id]
|
||||
const balance = balanceObj?.total || record.balance || '-'
|
||||
return balance && balance !== '-' && typeof balance === 'string' ? `${balance} USDC` : '-'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: any, record: Account) => (
|
||||
<Space size="small">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => handleShowDetail(record)}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定要删除这个账户吗?"
|
||||
description={
|
||||
record.apiKeyConfigured
|
||||
? "删除账户前,请确保已取消所有活跃订单。删除后无法恢复,请谨慎操作!"
|
||||
: "删除后无法恢复,请谨慎操作!"
|
||||
}
|
||||
onConfirm={() => handleDelete(record)}
|
||||
okText="确定删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
const mobileColumns = [
|
||||
{
|
||||
title: '账户信息',
|
||||
key: 'info',
|
||||
render: (_: any, record: Account) => {
|
||||
const allConfigured = record.apiKeyConfigured && record.apiSecretConfigured && record.apiPassphraseConfigured
|
||||
const partialConfigured = record.apiKeyConfigured || record.apiSecretConfigured || record.apiPassphraseConfigured
|
||||
|
||||
return (
|
||||
<div style={{ padding: '8px 0' }}>
|
||||
<div style={{
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
fontSize: '16px'
|
||||
}}>
|
||||
{record.accountName || `账户 ${record.id}`}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: '#666',
|
||||
marginBottom: '8px',
|
||||
wordBreak: 'break-all',
|
||||
fontFamily: 'monospace',
|
||||
lineHeight: '1.4'
|
||||
}}>
|
||||
{record.walletAddress}
|
||||
</div>
|
||||
<div style={{ marginBottom: '8px', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||
<Tag color={record.isDefault ? 'gold' : 'default'} style={{ margin: 0 }}>
|
||||
{record.isDefault ? '默认' : '普通'}
|
||||
</Tag>
|
||||
<Tag color={allConfigured ? 'success' : partialConfigured ? 'warning' : 'default'} style={{ margin: 0 }}>
|
||||
{allConfigured ? '完整配置' : partialConfigured ? '部分配置' : '未配置'}
|
||||
</Tag>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
color: '#1890ff'
|
||||
}}>
|
||||
总余额: {balanceLoading[record.id] ? (
|
||||
<Spin size="small" style={{ marginLeft: '4px' }} />
|
||||
) : balanceMap[record.id]?.total && balanceMap[record.id].total !== '-' ? (
|
||||
`${balanceMap[record.id].total} USDC`
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</div>
|
||||
{balanceMap[record.id] && balanceMap[record.id].available !== '-' && (
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
marginTop: '4px'
|
||||
}}>
|
||||
可用: {balanceMap[record.id].available} USDC | 仓位: {balanceMap[record.id].position} USDC
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 100,
|
||||
render: (_: any, record: Account) => (
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
block
|
||||
onClick={() => handleShowDetail(record)}
|
||||
style={{ minHeight: '32px' }}
|
||||
>
|
||||
查看详情
|
||||
</Button>
|
||||
{!record.isDefault && (
|
||||
<Button
|
||||
size="small"
|
||||
block
|
||||
icon={<StarOutlined />}
|
||||
onClick={() => handleSetDefault(record)}
|
||||
style={{ minHeight: '32px' }}
|
||||
>
|
||||
设为默认
|
||||
</Button>
|
||||
)}
|
||||
<Popconfirm
|
||||
title="确定要删除这个账户吗?"
|
||||
description={
|
||||
record.apiKeyConfigured
|
||||
? "删除账户前,请确保已取消所有活跃订单。删除后无法恢复,请谨慎操作!"
|
||||
: "删除后无法恢复,请谨慎操作!"
|
||||
}
|
||||
onConfirm={() => handleDelete(record)}
|
||||
okText="确定删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
block
|
||||
danger
|
||||
style={{ minHeight: '32px' }}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: isMobile ? '0' : undefined,
|
||||
margin: isMobile ? '0 -8px' : undefined
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: isMobile ? '12px' : '16px',
|
||||
flexWrap: 'wrap',
|
||||
gap: '12px',
|
||||
padding: isMobile ? '0 8px' : '0'
|
||||
}}>
|
||||
<Title level={isMobile ? 3 : 2} style={{ margin: 0, fontSize: isMobile ? '18px' : undefined }}>
|
||||
账户管理
|
||||
</Title>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => navigate('/accounts/import')}
|
||||
size={isMobile ? 'middle' : 'large'}
|
||||
block={isMobile}
|
||||
style={isMobile ? { minHeight: '44px' } : undefined}
|
||||
>
|
||||
导入账户
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card style={{
|
||||
margin: isMobile ? '0 -8px' : '0',
|
||||
borderRadius: isMobile ? '0' : undefined
|
||||
}}>
|
||||
{isMobile ? (
|
||||
<Table
|
||||
dataSource={accounts}
|
||||
columns={mobileColumns}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
pageSize: 10,
|
||||
showSizeChanger: false,
|
||||
simple: true,
|
||||
size: 'small'
|
||||
}}
|
||||
scroll={{ x: 'max-content' }}
|
||||
size="small"
|
||||
style={{ fontSize: '14px' }}
|
||||
/>
|
||||
) : (
|
||||
<Table
|
||||
dataSource={accounts}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
pageSize: 20,
|
||||
showSizeChanger: true
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 账户详情 Modal */}
|
||||
<Modal
|
||||
title={detailAccount ? (detailAccount.accountName || `账户 ${detailAccount.id}`) : '账户详情'}
|
||||
open={detailModalVisible}
|
||||
onCancel={() => {
|
||||
setDetailModalVisible(false)
|
||||
setDetailAccount(null)
|
||||
setDetailBalance(null)
|
||||
}}
|
||||
footer={[
|
||||
<Button
|
||||
key="refresh"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleRefreshDetailBalance}
|
||||
loading={detailBalanceLoading}
|
||||
disabled={!detailAccount}
|
||||
>
|
||||
刷新余额
|
||||
</Button>,
|
||||
<Button
|
||||
key="close"
|
||||
onClick={() => {
|
||||
setDetailModalVisible(false)
|
||||
setDetailAccount(null)
|
||||
setDetailBalance(null)
|
||||
}}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
]}
|
||||
width={isMobile ? '95%' : 800}
|
||||
style={{ top: isMobile ? 20 : 50 }}
|
||||
destroyOnClose
|
||||
maskClosable
|
||||
closable
|
||||
>
|
||||
{detailAccount ? (
|
||||
<div>
|
||||
<Descriptions
|
||||
column={isMobile ? 1 : 2}
|
||||
bordered
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
>
|
||||
<Descriptions.Item label="账户ID">
|
||||
{detailAccount.id}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="账户名称">
|
||||
{detailAccount.accountName || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="钱包地址" span={isMobile ? 1 : 2}>
|
||||
<span style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: isMobile ? '11px' : '13px',
|
||||
wordBreak: 'break-all',
|
||||
lineHeight: '1.4',
|
||||
display: 'block'
|
||||
}}>
|
||||
{detailAccount.walletAddress || '-'}
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="默认账户">
|
||||
<Tag color={detailAccount.isDefault ? 'gold' : 'default'}>
|
||||
{detailAccount.isDefault ? '是' : '否'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="总余额" span={isMobile ? 1 : 2}>
|
||||
{detailBalanceLoading ? (
|
||||
<Spin size="small" />
|
||||
) : detailBalance ? (
|
||||
<span style={{ fontWeight: 'bold', color: '#1890ff', fontSize: '16px' }}>
|
||||
{detailBalance.total} USDC
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: '#999' }}>-</span>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="可用余额">
|
||||
{detailBalanceLoading ? (
|
||||
<Spin size="small" />
|
||||
) : detailBalance ? (
|
||||
<span style={{ color: '#52c41a' }}>
|
||||
{detailBalance.available} USDC
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: '#999' }}>-</span>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="仓位余额">
|
||||
{detailBalanceLoading ? (
|
||||
<Spin size="small" />
|
||||
) : detailBalance ? (
|
||||
<span style={{ color: '#1890ff' }}>
|
||||
{detailBalance.position} USDC
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: '#999' }}>-</span>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Descriptions
|
||||
column={isMobile ? 1 : 2}
|
||||
bordered
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
title="API 凭证配置"
|
||||
>
|
||||
<Descriptions.Item label="API Key">
|
||||
<Tag color={detailAccount.apiKeyConfigured ? 'success' : 'default'}>
|
||||
{detailAccount.apiKeyConfigured ? '已配置' : '未配置'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="API Secret">
|
||||
<Tag color={detailAccount.apiSecretConfigured ? 'success' : 'default'}>
|
||||
{detailAccount.apiSecretConfigured ? '已配置' : '未配置'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="API Passphrase">
|
||||
<Tag color={detailAccount.apiPassphraseConfigured ? 'success' : 'default'}>
|
||||
{detailAccount.apiPassphraseConfigured ? '已配置' : '未配置'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="配置状态">
|
||||
{detailAccount.apiKeyConfigured && detailAccount.apiSecretConfigured && detailAccount.apiPassphraseConfigured ? (
|
||||
<Tag color="success">完整配置</Tag>
|
||||
) : (
|
||||
<Tag color="warning">部分配置</Tag>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{(detailAccount.totalOrders !== undefined || detailAccount.totalPnl !== undefined) && (
|
||||
<>
|
||||
<Divider />
|
||||
<Descriptions
|
||||
column={isMobile ? 1 : 2}
|
||||
bordered
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
title="交易统计"
|
||||
>
|
||||
{detailAccount.totalOrders !== undefined && (
|
||||
<Descriptions.Item label="总订单数">
|
||||
{detailAccount.totalOrders}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
{detailAccount.totalPnl !== undefined && (
|
||||
<Descriptions.Item label="总盈亏">
|
||||
<span style={{
|
||||
fontWeight: 'bold',
|
||||
color: detailAccount.totalPnl && detailAccount.totalPnl.startsWith('-') ? '#ff4d4f' : '#52c41a'
|
||||
}}>
|
||||
{detailAccount.totalPnl} USDC
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||
<Spin size="large" />
|
||||
<div style={{ marginTop: '16px' }}>加载中...</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccountList
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card, Form, Input, Button, Switch, Radio, InputNumber, message, Typography, Space } from 'antd'
|
||||
import { SaveOutlined } from '@ant-design/icons'
|
||||
import { apiService } from '../services/api'
|
||||
import type { CopyTradingConfig } from '../types'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
|
||||
const { Title } = Typography
|
||||
|
||||
const ConfigPage: React.FC = () => {
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const [form] = Form.useForm()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [config, setConfig] = useState<CopyTradingConfig | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig()
|
||||
}, [])
|
||||
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const response = await apiService.config.get()
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
const data = response.data.data
|
||||
setConfig(data)
|
||||
form.setFieldsValue(data)
|
||||
} else {
|
||||
message.error(response.data.msg || '获取配置失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '获取配置失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await apiService.config.update(values)
|
||||
if (response.data.code === 0) {
|
||||
message.success('更新配置成功')
|
||||
fetchConfig()
|
||||
} else {
|
||||
message.error(response.data.msg || '更新配置失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '更新配置失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<Title level={2} style={{ margin: 0 }}>跟单配置</Title>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
size={isMobile ? 'middle' : 'large'}
|
||||
>
|
||||
<Form.Item
|
||||
label="跟单金额模式"
|
||||
name="copyMode"
|
||||
rules={[{ required: true, message: '请选择跟单金额模式' }]}
|
||||
>
|
||||
<Radio.Group>
|
||||
<Radio value="RATIO">比例模式</Radio>
|
||||
<Radio value="FIXED">固定金额模式</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) => prevValues.copyMode !== currentValues.copyMode}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const copyMode = getFieldValue('copyMode')
|
||||
return copyMode === 'RATIO' ? (
|
||||
<Form.Item
|
||||
label="跟单比例"
|
||||
name="copyRatio"
|
||||
rules={[{ required: true, message: '请输入跟单比例' }]}
|
||||
help="跟单金额 = Leader 订单金额 × 跟单比例"
|
||||
>
|
||||
<InputNumber
|
||||
min={0.1}
|
||||
max={10}
|
||||
step={0.1}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="例如:1.0 表示 1:1 跟单"
|
||||
/>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item
|
||||
label="固定跟单金额"
|
||||
name="fixedAmount"
|
||||
rules={[{ required: true, message: '请输入固定跟单金额' }]}
|
||||
help="无论 Leader 订单大小如何,跟单金额都固定"
|
||||
>
|
||||
<InputNumber
|
||||
min={0.01}
|
||||
step={0.01}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="USDC"
|
||||
/>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="单笔订单最大金额"
|
||||
name="maxOrderSize"
|
||||
rules={[{ required: true, message: '请输入最大金额' }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={0.01}
|
||||
step={0.01}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="USDC"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="单笔订单最小金额"
|
||||
name="minOrderSize"
|
||||
rules={[{ required: true, message: '请输入最小金额' }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={0.01}
|
||||
step={0.01}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="USDC"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="每日最大亏损限制"
|
||||
name="maxDailyLoss"
|
||||
rules={[{ required: true, message: '请输入最大亏损限制' }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={0.01}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="USDC"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="每日最大跟单订单数"
|
||||
name="maxDailyOrders"
|
||||
rules={[{ required: true, message: '请输入最大订单数' }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="价格容忍度"
|
||||
name="priceTolerance"
|
||||
rules={[{ required: true, message: '请输入价格容忍度' }]}
|
||||
help="百分比,允许价格在 Leader 价格 ± 容忍度范围内调整"
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.1}
|
||||
style={{ width: '100%' }}
|
||||
addonAfter="%"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="跟单延迟"
|
||||
name="delaySeconds"
|
||||
rules={[{ required: true, message: '请输入跟单延迟' }]}
|
||||
help="延迟 N 秒后跟单(0 表示立即跟单)"
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
style={{ width: '100%' }}
|
||||
addonAfter="秒"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="轮询间隔"
|
||||
name="pollIntervalSeconds"
|
||||
rules={[{ required: true, message: '请输入轮询间隔' }]}
|
||||
help="轮询 Leader 交易的间隔(仅在 WebSocket 不可用时使用)"
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
style={{ width: '100%' }}
|
||||
addonAfter="秒"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="优先使用 WebSocket 推送"
|
||||
name="useWebSocket"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="启用全局跟单"
|
||||
name="enabled"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
icon={<SaveOutlined />}
|
||||
loading={loading}
|
||||
size={isMobile ? 'middle' : 'large'}
|
||||
>
|
||||
保存配置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ConfigPage
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Card, Form, Input, Button, Select, Switch, message, Typography } from 'antd'
|
||||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
||||
import { apiService } from '../services/api'
|
||||
import { useAccountStore } from '../store/accountStore'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
|
||||
const { Title } = Typography
|
||||
const { Option } = Select
|
||||
|
||||
const LeaderAdd: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const { accounts, fetchAccounts } = useAccountStore()
|
||||
const [form] = Form.useForm()
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchAccounts()
|
||||
}, [fetchAccounts])
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await apiService.leaders.add({
|
||||
leaderAddress: values.leaderAddress,
|
||||
leaderName: values.leaderName,
|
||||
accountId: values.accountId,
|
||||
category: values.category,
|
||||
enabled: values.enabled !== false
|
||||
})
|
||||
|
||||
if (response.data.code === 0) {
|
||||
message.success('添加 Leader 成功')
|
||||
navigate('/leaders')
|
||||
} else {
|
||||
message.error(response.data.msg || '添加 Leader 失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '添加 Leader 失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/leaders')}
|
||||
style={{ marginBottom: '16px' }}
|
||||
>
|
||||
返回
|
||||
</Button>
|
||||
<Title level={2} style={{ margin: 0 }}>添加 Leader</Title>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
size={isMobile ? 'middle' : 'large'}
|
||||
initialValues={{
|
||||
enabled: true
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
label="Leader 钱包地址"
|
||||
name="leaderAddress"
|
||||
rules={[
|
||||
{ required: true, message: '请输入 Leader 钱包地址' },
|
||||
{
|
||||
pattern: /^0x[a-fA-F0-9]{40}$/,
|
||||
message: '钱包地址格式不正确'
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Input placeholder="0x..." />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Leader 名称"
|
||||
name="leaderName"
|
||||
>
|
||||
<Input placeholder="可选,用于标识 Leader" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="使用的账户"
|
||||
name="accountId"
|
||||
help="选择用于跟单此 Leader 的账户,不选择则使用默认账户"
|
||||
>
|
||||
<Select placeholder="选择账户(可选)" allowClear>
|
||||
{accounts.map(account => (
|
||||
<Option key={account.id} value={account.id}>
|
||||
{account.accountName || account.walletAddress} {account.isDefault && '(默认)'}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="分类筛选"
|
||||
name="category"
|
||||
help="仅跟单该分类的交易,不选择则跟单所有分类"
|
||||
>
|
||||
<Select placeholder="选择分类(可选)" allowClear>
|
||||
<Option value="sports">Sports</Option>
|
||||
<Option value="crypto">Crypto</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="启用跟单"
|
||||
name="enabled"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
size={isMobile ? 'middle' : 'large'}
|
||||
>
|
||||
添加 Leader
|
||||
</Button>
|
||||
<Button onClick={() => navigate('/leaders')}>
|
||||
取消
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LeaderAdd
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Card, Table, Button, Space, Tag, Popconfirm, message } from 'antd'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'
|
||||
import { apiService } from '../services/api'
|
||||
import { useState } from 'react'
|
||||
import type { Leader } from '../types'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
|
||||
const LeaderList: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const [leaders, setLeaders] = useState<Leader[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchLeaders()
|
||||
}, [])
|
||||
|
||||
const fetchLeaders = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await apiService.leaders.list()
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
setLeaders(response.data.data.list || [])
|
||||
} else {
|
||||
message.error(response.data.msg || '获取 Leader 列表失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '获取 Leader 列表失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (leaderId: number) => {
|
||||
try {
|
||||
const response = await apiService.leaders.delete({ leaderId })
|
||||
if (response.data.code === 0) {
|
||||
message.success('删除 Leader 成功')
|
||||
fetchLeaders()
|
||||
} else {
|
||||
message.error(response.data.msg || '删除 Leader 失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '删除 Leader 失败')
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'Leader 名称',
|
||||
dataIndex: 'leaderName',
|
||||
key: 'leaderName',
|
||||
render: (text: string, record: Leader) => text || `Leader ${record.id}`
|
||||
},
|
||||
{
|
||||
title: '钱包地址',
|
||||
dataIndex: 'leaderAddress',
|
||||
key: 'leaderAddress',
|
||||
render: (address: string) => (
|
||||
<span style={{ fontFamily: 'monospace' }}>{address}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '分类',
|
||||
dataIndex: 'category',
|
||||
key: 'category',
|
||||
render: (category: string | undefined) => category ? (
|
||||
<Tag color={category === 'sports' ? 'blue' : 'green'}>{category}</Tag>
|
||||
) : <Tag>全部</Tag>
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'enabled',
|
||||
key: 'enabled',
|
||||
render: (enabled: boolean) => (
|
||||
<Tag color={enabled ? 'success' : 'default'}>
|
||||
{enabled ? '启用' : '禁用'}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '跟单比例',
|
||||
dataIndex: 'copyRatio',
|
||||
key: 'copyRatio',
|
||||
render: (ratio: string) => `${ratio}x`
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: any, record: Leader) => (
|
||||
<Space size="small">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => navigate(`/leaders/edit?id=${record.id}`)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定要删除这个 Leader 吗?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '16px',
|
||||
flexWrap: 'wrap',
|
||||
gap: '12px'
|
||||
}}>
|
||||
<h2>Leader 管理</h2>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => navigate('/leaders/add')}
|
||||
size={isMobile ? 'middle' : 'large'}
|
||||
>
|
||||
添加 Leader
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Table
|
||||
dataSource={leaders}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
pageSize: isMobile ? 10 : 20,
|
||||
showSizeChanger: !isMobile
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LeaderList
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card, Table, Tag, Space, message } from 'antd'
|
||||
import { apiService } from '../services/api'
|
||||
import type { CopyOrder } from '../types'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
|
||||
const OrderList: React.FC = () => {
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const [orders, setOrders] = useState<CopyOrder[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [pagination, setPagination] = useState({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: 0
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrders()
|
||||
}, [pagination.current, pagination.pageSize])
|
||||
|
||||
const fetchOrders = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await apiService.orders.list({
|
||||
page: pagination.current,
|
||||
limit: pagination.pageSize
|
||||
})
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
setOrders(response.data.data.list || [])
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
total: response.data.data?.total || 0
|
||||
}))
|
||||
} else {
|
||||
message.error(response.data.msg || '获取订单列表失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '获取订单列表失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'filled':
|
||||
return 'success'
|
||||
case 'cancelled':
|
||||
return 'default'
|
||||
case 'failed':
|
||||
return 'error'
|
||||
default:
|
||||
return 'processing'
|
||||
}
|
||||
}
|
||||
|
||||
const getSideColor = (side: string) => {
|
||||
return side === 'BUY' ? 'green' : 'red'
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'Leader',
|
||||
dataIndex: 'leaderName',
|
||||
key: 'leaderName',
|
||||
render: (text: string, record: CopyOrder) => text || record.leaderAddress.slice(0, 10) + '...'
|
||||
},
|
||||
{
|
||||
title: '市场',
|
||||
dataIndex: 'marketId',
|
||||
key: 'marketId',
|
||||
render: (marketId: string) => (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '12px' }}>
|
||||
{marketId.slice(0, 10)}...
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '分类',
|
||||
dataIndex: 'category',
|
||||
key: 'category',
|
||||
render: (category: string) => (
|
||||
<Tag color={category === 'sports' ? 'blue' : 'green'}>{category}</Tag>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '方向',
|
||||
dataIndex: 'side',
|
||||
key: 'side',
|
||||
render: (side: string) => (
|
||||
<Tag color={getSideColor(side)}>{side}</Tag>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '价格',
|
||||
dataIndex: 'price',
|
||||
key: 'price'
|
||||
},
|
||||
{
|
||||
title: '数量',
|
||||
dataIndex: 'size',
|
||||
key: 'size'
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => (
|
||||
<Tag color={getStatusColor(status)}>{status}</Tag>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '盈亏',
|
||||
dataIndex: 'pnl',
|
||||
key: 'pnl',
|
||||
render: (pnl: string | undefined) => pnl ? (
|
||||
<span style={{ color: pnl.startsWith('-') ? 'red' : 'green' }}>
|
||||
{pnl} USDC
|
||||
</span>
|
||||
) : '-'
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
render: (timestamp: number) => new Date(timestamp).toLocaleString()
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<h2>订单管理</h2>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Table
|
||||
dataSource={orders}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
total: pagination.total,
|
||||
showSizeChanger: !isMobile,
|
||||
onChange: (page, pageSize) => {
|
||||
setPagination(prev => ({ ...prev, current: page, pageSize }))
|
||||
}
|
||||
}}
|
||||
scroll={isMobile ? { x: 800 } : undefined}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OrderList
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card, Row, Col, Statistic, message } from 'antd'
|
||||
import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons'
|
||||
import { apiService } from '../services/api'
|
||||
import type { Statistics as StatisticsType } from '../types'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
|
||||
const Statistics: React.FC = () => {
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const [stats, setStats] = useState<StatisticsType | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatistics()
|
||||
}, [])
|
||||
|
||||
const fetchStatistics = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await apiService.statistics.global()
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
setStats(response.data.data)
|
||||
} else {
|
||||
message.error(response.data.msg || '获取统计信息失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '获取统计信息失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<h2>统计信息</h2>
|
||||
</div>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="总订单数"
|
||||
value={stats?.totalOrders || 0}
|
||||
loading={loading}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="总盈亏"
|
||||
value={stats?.totalPnl || '0'}
|
||||
precision={2}
|
||||
prefix={stats?.totalPnl && parseFloat(stats.totalPnl) >= 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
|
||||
valueStyle={{ color: stats?.totalPnl && parseFloat(stats.totalPnl || '0') >= 0 ? '#3f8600' : '#cf1322' }}
|
||||
suffix="USDC"
|
||||
loading={loading}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="胜率"
|
||||
value={stats?.winRate || '0'}
|
||||
precision={2}
|
||||
suffix="%"
|
||||
loading={loading}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="平均盈亏"
|
||||
value={stats?.avgPnl || '0'}
|
||||
precision={2}
|
||||
suffix="USDC"
|
||||
loading={loading}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="最大盈利"
|
||||
value={stats?.maxProfit || '0'}
|
||||
precision={2}
|
||||
prefix={<ArrowUpOutlined />}
|
||||
valueStyle={{ color: '#3f8600' }}
|
||||
suffix="USDC"
|
||||
loading={loading}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="最大亏损"
|
||||
value={stats?.maxLoss || '0'}
|
||||
precision={2}
|
||||
prefix={<ArrowDownOutlined />}
|
||||
valueStyle={{ color: '#cf1322' }}
|
||||
suffix="USDC"
|
||||
loading={loading}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Statistics
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import axios, { AxiosInstance } from 'axios'
|
||||
import type { ApiResponse } from '../types'
|
||||
|
||||
/**
|
||||
* API 基础配置
|
||||
*/
|
||||
const apiClient: AxiosInstance = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 请求拦截器
|
||||
*/
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* 响应拦截器
|
||||
*/
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
return response
|
||||
},
|
||||
(error) => {
|
||||
if (error.response) {
|
||||
console.error('API 错误:', error.response.data)
|
||||
} else if (error.request) {
|
||||
console.error('网络错误:', error.request)
|
||||
} else {
|
||||
console.error('请求错误:', error.message)
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* API 服务
|
||||
*/
|
||||
export const apiService = {
|
||||
/**
|
||||
* 账户管理 API
|
||||
*/
|
||||
accounts: {
|
||||
/**
|
||||
* 导入账户
|
||||
*/
|
||||
import: (data: any) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/import', data),
|
||||
|
||||
/**
|
||||
* 更新账户
|
||||
*/
|
||||
update: (data: any) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/update', data),
|
||||
|
||||
/**
|
||||
* 删除账户
|
||||
*/
|
||||
delete: (data: { accountId: number }) =>
|
||||
apiClient.post<ApiResponse<void>>('/copy-trading/accounts/delete', data),
|
||||
|
||||
/**
|
||||
* 查询账户列表
|
||||
*/
|
||||
list: () =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/list', {}),
|
||||
|
||||
/**
|
||||
* 查询账户详情
|
||||
*/
|
||||
detail: (data: { accountId?: number }) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/detail', data),
|
||||
|
||||
/**
|
||||
* 查询账户余额
|
||||
*/
|
||||
balance: (data: { accountId?: number }) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/accounts/balance', data),
|
||||
|
||||
/**
|
||||
* 设置默认账户
|
||||
*/
|
||||
setDefault: (data: { accountId: number }) =>
|
||||
apiClient.post<ApiResponse<void>>('/copy-trading/accounts/set-default', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* Leader 管理 API
|
||||
*/
|
||||
leaders: {
|
||||
/**
|
||||
* 添加 Leader
|
||||
*/
|
||||
add: (data: any) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/leaders/add', data),
|
||||
|
||||
/**
|
||||
* 更新 Leader
|
||||
*/
|
||||
update: (data: any) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/leaders/update', data),
|
||||
|
||||
/**
|
||||
* 删除 Leader
|
||||
*/
|
||||
delete: (data: { leaderId: number }) =>
|
||||
apiClient.post<ApiResponse<void>>('/copy-trading/leaders/delete', data),
|
||||
|
||||
/**
|
||||
* 查询 Leader 列表
|
||||
*/
|
||||
list: (data: { enabled?: boolean; category?: string } = {}) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/leaders/list', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 配置管理 API
|
||||
*/
|
||||
config: {
|
||||
/**
|
||||
* 获取全局配置
|
||||
*/
|
||||
get: () =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/config/get', {}),
|
||||
|
||||
/**
|
||||
* 更新全局配置
|
||||
*/
|
||||
update: (data: any) =>
|
||||
apiClient.post<ApiResponse<void>>('/copy-trading/config/update', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 订单管理 API
|
||||
*/
|
||||
orders: {
|
||||
/**
|
||||
* 查询跟单订单列表
|
||||
*/
|
||||
list: (data: any) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/orders/list', data),
|
||||
|
||||
/**
|
||||
* 取消跟单订单
|
||||
*/
|
||||
cancel: (data: { copyOrderId: number }) =>
|
||||
apiClient.post<ApiResponse<void>>('/copy-trading/orders/cancel', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 统计 API
|
||||
*/
|
||||
statistics: {
|
||||
/**
|
||||
* 获取全局统计
|
||||
*/
|
||||
global: (data: { startTime?: number; endTime?: number } = {}) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/statistics/global', data),
|
||||
|
||||
/**
|
||||
* 获取 Leader 统计
|
||||
*/
|
||||
leader: (data: { leaderId: number; startTime?: number; endTime?: number }) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/statistics/leader', data),
|
||||
|
||||
/**
|
||||
* 获取分类统计
|
||||
*/
|
||||
category: (data: { category: string; startTime?: number; endTime?: number }) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/statistics/category', data)
|
||||
}
|
||||
}
|
||||
|
||||
export default apiClient
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { create } from 'zustand'
|
||||
import type { Account } from '../types'
|
||||
import { apiService } from '../services/api'
|
||||
|
||||
interface AccountStore {
|
||||
accounts: Account[]
|
||||
currentAccount: Account | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
|
||||
// Actions
|
||||
fetchAccounts: () => Promise<void>
|
||||
setCurrentAccount: (account: Account | null) => void
|
||||
importAccount: (data: any) => Promise<void>
|
||||
updateAccount: (data: any) => Promise<void>
|
||||
deleteAccount: (accountId: number) => Promise<void>
|
||||
setDefaultAccount: (accountId: number) => Promise<void>
|
||||
fetchAccountDetail: (accountId: number) => Promise<Account>
|
||||
fetchAccountBalance: (accountId: number) => Promise<{
|
||||
availableBalance: string
|
||||
positionBalance: string
|
||||
totalBalance: string
|
||||
positions: any[]
|
||||
}>
|
||||
}
|
||||
|
||||
export const useAccountStore = create<AccountStore>((set, get) => ({
|
||||
accounts: [],
|
||||
currentAccount: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
fetchAccounts: async () => {
|
||||
set({ loading: true, error: null })
|
||||
try {
|
||||
const response = await apiService.accounts.list()
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
const accounts = response.data.data.list || []
|
||||
set({ accounts, loading: false })
|
||||
|
||||
// 设置默认账户为当前账户
|
||||
const defaultAccount = accounts.find((a: Account) => a.isDefault)
|
||||
if (defaultAccount) {
|
||||
set({ currentAccount: defaultAccount })
|
||||
}
|
||||
} else {
|
||||
set({ error: response.data.msg || '获取账户列表失败', loading: false })
|
||||
}
|
||||
} catch (error: any) {
|
||||
set({ error: error.message || '获取账户列表失败', loading: false })
|
||||
}
|
||||
},
|
||||
|
||||
setCurrentAccount: (account) => {
|
||||
set({ currentAccount: account })
|
||||
},
|
||||
|
||||
importAccount: async (data) => {
|
||||
set({ loading: true, error: null })
|
||||
try {
|
||||
const response = await apiService.accounts.import(data)
|
||||
if (response.data.code === 0) {
|
||||
await get().fetchAccounts()
|
||||
} else {
|
||||
set({ error: response.data.msg || '导入账户失败', loading: false })
|
||||
throw new Error(response.data.msg || '导入账户失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
set({ error: error.message || '导入账户失败', loading: false })
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
updateAccount: async (data) => {
|
||||
set({ loading: true, error: null })
|
||||
try {
|
||||
const response = await apiService.accounts.update(data)
|
||||
if (response.data.code === 0) {
|
||||
await get().fetchAccounts()
|
||||
} else {
|
||||
set({ error: response.data.msg || '更新账户失败', loading: false })
|
||||
throw new Error(response.data.msg || '更新账户失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
set({ error: error.message || '更新账户失败', loading: false })
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
deleteAccount: async (accountId) => {
|
||||
set({ loading: true, error: null })
|
||||
try {
|
||||
const response = await apiService.accounts.delete({ accountId })
|
||||
if (response.data.code === 0) {
|
||||
await get().fetchAccounts()
|
||||
} else {
|
||||
set({ error: response.data.msg || '删除账户失败', loading: false })
|
||||
throw new Error(response.data.msg || '删除账户失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
set({ error: error.message || '删除账户失败', loading: false })
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
setDefaultAccount: async (accountId) => {
|
||||
set({ loading: true, error: null })
|
||||
try {
|
||||
const response = await apiService.accounts.setDefault({ accountId })
|
||||
if (response.data.code === 0) {
|
||||
await get().fetchAccounts()
|
||||
} else {
|
||||
set({ error: response.data.msg || '设置默认账户失败', loading: false })
|
||||
throw new Error(response.data.msg || '设置默认账户失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
set({ error: error.message || '设置默认账户失败', loading: false })
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
fetchAccountDetail: async (accountId) => {
|
||||
set({ loading: true, error: null })
|
||||
try {
|
||||
const response = await apiService.accounts.detail({ accountId })
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
set({ loading: false })
|
||||
return response.data.data
|
||||
} else {
|
||||
const errorMsg = response.data.msg || '获取账户详情失败'
|
||||
set({ error: errorMsg, loading: false })
|
||||
throw new Error(errorMsg)
|
||||
}
|
||||
} catch (error: any) {
|
||||
set({ error: error.message || '获取账户详情失败', loading: false })
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
fetchAccountBalance: async (accountId) => {
|
||||
try {
|
||||
const response = await apiService.accounts.balance({ accountId })
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
return response.data.data
|
||||
} else {
|
||||
throw new Error(response.data.msg || '获取账户余额失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 移动端适配 */
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 桌面端适配 */
|
||||
@media (min-width: 769px) {
|
||||
body {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* API 统一响应格式
|
||||
*/
|
||||
export interface ApiResponse<T> {
|
||||
code: number
|
||||
data: T | null
|
||||
msg: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 账户信息
|
||||
*/
|
||||
export interface Account {
|
||||
id: number
|
||||
walletAddress: string
|
||||
accountName?: string
|
||||
isDefault: boolean
|
||||
apiKeyConfigured: boolean
|
||||
apiSecretConfigured: boolean
|
||||
apiPassphraseConfigured: boolean
|
||||
balance?: string
|
||||
totalOrders?: number
|
||||
totalPnl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 账户列表响应
|
||||
*/
|
||||
export interface AccountListResponse {
|
||||
list: Account[]
|
||||
total: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 账户导入请求
|
||||
*/
|
||||
export interface AccountImportRequest {
|
||||
privateKey: string
|
||||
walletAddress: string
|
||||
accountName?: string
|
||||
apiKey?: string
|
||||
apiSecret?: string
|
||||
apiPassphrase?: string
|
||||
isDefault?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 账户更新请求
|
||||
*/
|
||||
export interface AccountUpdateRequest {
|
||||
accountId: number
|
||||
accountName?: string
|
||||
apiKey?: string
|
||||
apiSecret?: string
|
||||
apiPassphrase?: string
|
||||
isDefault?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Leader 信息
|
||||
*/
|
||||
export interface Leader {
|
||||
id: number
|
||||
leaderAddress: string
|
||||
leaderName?: string
|
||||
accountId?: number
|
||||
category?: string
|
||||
enabled: boolean
|
||||
copyRatio: string
|
||||
totalOrders?: number
|
||||
totalPnl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Leader 列表响应
|
||||
*/
|
||||
export interface LeaderListResponse {
|
||||
list: Leader[]
|
||||
total: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Leader 添加请求
|
||||
*/
|
||||
export interface LeaderAddRequest {
|
||||
leaderAddress: string
|
||||
leaderName?: string
|
||||
accountId?: number
|
||||
category?: string
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 跟单配置
|
||||
*/
|
||||
export interface CopyTradingConfig {
|
||||
copyMode: 'RATIO' | 'FIXED'
|
||||
copyRatio: string
|
||||
fixedAmount?: string
|
||||
maxOrderSize: string
|
||||
minOrderSize: string
|
||||
maxDailyLoss: string
|
||||
maxDailyOrders: number
|
||||
priceTolerance: string
|
||||
delaySeconds: number
|
||||
pollIntervalSeconds: number
|
||||
useWebSocket: boolean
|
||||
websocketReconnectInterval: number
|
||||
websocketMaxRetries: number
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 跟单订单
|
||||
*/
|
||||
export interface CopyOrder {
|
||||
id: number
|
||||
accountId: number
|
||||
leaderId: number
|
||||
leaderAddress: string
|
||||
leaderName?: string
|
||||
marketId: string
|
||||
category: string
|
||||
side: 'BUY' | 'SELL'
|
||||
price: string
|
||||
size: string
|
||||
copyRatio: string
|
||||
orderId?: string
|
||||
status: string
|
||||
filledSize: string
|
||||
pnl?: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单列表响应
|
||||
*/
|
||||
export interface OrderListResponse {
|
||||
list: CopyOrder[]
|
||||
total: number
|
||||
page: number
|
||||
limit: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计信息
|
||||
*/
|
||||
export interface Statistics {
|
||||
totalOrders: number
|
||||
totalPnl: string
|
||||
winRate: string
|
||||
avgPnl: string
|
||||
maxProfit: string
|
||||
maxLoss: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { ethers } from 'ethers'
|
||||
|
||||
/**
|
||||
* 从私钥推导钱包地址
|
||||
*/
|
||||
export function getAddressFromPrivateKey(privateKey: string): string {
|
||||
try {
|
||||
// 移除 0x 前缀(如果有)
|
||||
const cleanKey = privateKey.startsWith('0x') ? privateKey.slice(2) : privateKey
|
||||
|
||||
// 创建钱包
|
||||
const wallet = new ethers.Wallet(`0x${cleanKey}`)
|
||||
return wallet.address
|
||||
} catch (error) {
|
||||
throw new Error(`无效的私钥: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从助记词推导钱包地址
|
||||
* @param mnemonic 助记词(12或24个单词,用空格分隔)
|
||||
* @param index 派生路径索引(默认0,使用第一个地址)
|
||||
*/
|
||||
export function getAddressFromMnemonic(mnemonic: string, index: number = 0): string {
|
||||
try {
|
||||
// 验证助记词格式
|
||||
if (!isValidMnemonic(mnemonic)) {
|
||||
throw new Error('助记词格式不正确')
|
||||
}
|
||||
|
||||
// ethers.js v6: 如果 index 为 0,可以直接使用 Wallet.fromPhrase
|
||||
// 它默认使用路径 m/44'/60'/0'/0/0
|
||||
if (index === 0) {
|
||||
const wallet = ethers.Wallet.fromPhrase(mnemonic.trim())
|
||||
return wallet.address
|
||||
}
|
||||
|
||||
// 对于其他索引,使用 HDNodeWallet
|
||||
// 从助记词创建 Mnemonic 对象
|
||||
const mnemonicObj = ethers.Mnemonic.fromPhrase(mnemonic.trim())
|
||||
|
||||
// 使用标准 BIP44 路径直接创建钱包:m/44'/60'/0'/0/index
|
||||
// ethers.js v6: HDNodeWallet.fromMnemonic 可以直接指定路径
|
||||
const derivationPath = `m/44'/60'/0'/0/${index}`
|
||||
const wallet = ethers.HDNodeWallet.fromMnemonic(mnemonicObj, derivationPath)
|
||||
|
||||
return wallet.address
|
||||
} catch (error: any) {
|
||||
// 如果直接指定路径失败,尝试分步派生
|
||||
try {
|
||||
const mnemonicObj = ethers.Mnemonic.fromPhrase(mnemonic.trim())
|
||||
// 先创建根节点(不指定路径)
|
||||
const rootNode = ethers.HDNodeWallet.fromMnemonic(mnemonicObj)
|
||||
// 使用相对路径(不以 m/ 开头)
|
||||
const relativePath = `44'/60'/0'/0/${index}`
|
||||
const wallet = rootNode.derivePath(relativePath)
|
||||
return wallet.address
|
||||
} catch (fallbackError: any) {
|
||||
throw new Error(`无效的助记词: ${error.message || error}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从助记词导出私钥
|
||||
* @param mnemonic 助记词(12或24个单词,用空格分隔)
|
||||
* @param index 派生路径索引(默认0,使用第一个地址)
|
||||
*/
|
||||
export function getPrivateKeyFromMnemonic(mnemonic: string, index: number = 0): string {
|
||||
try {
|
||||
// 验证助记词格式
|
||||
if (!isValidMnemonic(mnemonic)) {
|
||||
throw new Error('助记词格式不正确')
|
||||
}
|
||||
|
||||
// ethers.js v6: 如果 index 为 0,可以直接使用 Wallet.fromPhrase
|
||||
// 它默认使用路径 m/44'/60'/0'/0/0
|
||||
if (index === 0) {
|
||||
const wallet = ethers.Wallet.fromPhrase(mnemonic.trim())
|
||||
return wallet.privateKey
|
||||
}
|
||||
|
||||
// 对于其他索引,使用 HDNodeWallet
|
||||
// 从助记词创建 Mnemonic 对象
|
||||
const mnemonicObj = ethers.Mnemonic.fromPhrase(mnemonic.trim())
|
||||
|
||||
// 使用标准 BIP44 路径直接创建钱包:m/44'/60'/0'/0/index
|
||||
// ethers.js v6: HDNodeWallet.fromMnemonic 可以直接指定路径
|
||||
const derivationPath = `m/44'/60'/0'/0/${index}`
|
||||
const wallet = ethers.HDNodeWallet.fromMnemonic(mnemonicObj, derivationPath)
|
||||
|
||||
return wallet.privateKey
|
||||
} catch (error: any) {
|
||||
// 如果直接指定路径失败,尝试分步派生
|
||||
try {
|
||||
const mnemonicObj = ethers.Mnemonic.fromPhrase(mnemonic.trim())
|
||||
// 先创建根节点(不指定路径)
|
||||
const rootNode = ethers.HDNodeWallet.fromMnemonic(mnemonicObj)
|
||||
// 使用相对路径(不以 m/ 开头)
|
||||
const relativePath = `44'/60'/0'/0/${index}`
|
||||
const wallet = rootNode.derivePath(relativePath)
|
||||
return wallet.privateKey
|
||||
} catch (fallbackError: any) {
|
||||
throw new Error(`无法从助记词导出私钥: ${error.message || error}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证助记词格式
|
||||
*/
|
||||
export function isValidMnemonic(mnemonic: string): boolean {
|
||||
try {
|
||||
if (!mnemonic || !mnemonic.trim()) {
|
||||
return false
|
||||
}
|
||||
|
||||
const words = mnemonic.trim().split(/\s+/)
|
||||
// 助记词应该是 12 或 24 个单词
|
||||
if (words.length !== 12 && words.length !== 24) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 使用 ethers 验证助记词
|
||||
ethers.Mnemonic.fromPhrase(mnemonic.trim())
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证钱包地址格式
|
||||
*/
|
||||
export function isValidWalletAddress(address: string): boolean {
|
||||
return /^0x[a-fA-F0-9]{40}$/.test(address)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证私钥格式
|
||||
*/
|
||||
export function isValidPrivateKey(privateKey: string): boolean {
|
||||
try {
|
||||
const cleanKey = privateKey.startsWith('0x') ? privateKey.slice(2) : privateKey
|
||||
return /^[a-fA-F0-9]{64}$/.test(cleanKey)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user