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,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;
|
||||
|
||||
Reference in New Issue
Block a user