Compare commits

..

37 Commits

Author SHA1 Message Date
WrBug 10b374b4d9 Merge branch 'pre_release' into feature_nba_quant
# Conflicts:
#	backend/src/main/kotlin/com/wrbug/polymarketbot/api/PolymarketGammaApi.kt
#	frontend/src/components/Layout.tsx
#	frontend/src/types/index.ts
2026-01-03 19:53:03 +08:00
WrBug 6e5ccdfe5c fix: 修复 walletType 字段问题和 WebSocket 内存泄漏,优化账户导入 UI
后端修复:
- 添加 walletType 字段到 Account 实体和数据库(V17 迁移)
- 修复 refreshProxyAddress 方法使用正确的 walletType
- 修复 WebSocketTicketService 内存泄漏(添加定时清理任务)

前端优化:
- 账户导入改为 Modal 弹窗,提升用户体验
- 默认钱包类型改为 Web3 Wallet(safe),并将其选项排在首位
- 更新多语言文本:将 MetaMask(浏览器钱包)改为 Web3钱包
- 修复 walletTypeHelp Tooltip 换行显示问题
2026-01-03 18:29:39 +08:00
WrBug 21da06d8b0 Merge pull request #12 from wry5560/feature/wallet-type-support
feat: 添加WebSocket票据认证和钱包类型支持
2026-01-03 17:57:27 +08:00
wry5560 5a83444b56 feat: 添加WebSocket票据认证和钱包类型支持
- 新增WebSocket票据服务,用于短期有效的WebSocket连接认证
- 支持Magic和Safe两种钱包类型,分别对应邮箱/OAuth登录和MetaMask用户
- 添加登录频率限制和安全防护
- 优化日志记录,屏蔽敏感信息
- 升级ethers.js到v6.16.0
- 新增多语言钱包类型说明
- 重构代理地址计算逻辑,支持CREATE2计算Magic代理地址
2026-01-03 12:27:59 +08:00
WrBug 591e678f73 Merge pull request #11 from WrBug/pre_release
Release v1.1.3: 添加 Telegram 通知功能
2026-01-02 05:13:45 +08:00
WrBug 1cfdcb88d3 feat: 优化 Telegram 通知功能
- 简化消息内容,仅保留标题、版本号等关键信息
- Docker 构建前发送构建中通知
- Docker 构建成功后添加部署文档链接
- PR 合并消息标题改为 'main 分支代码更新'
2026-01-02 05:12:24 +08:00
WrBug 2cc9cf82ba Merge pull request #10 from WrBug/pre_release
Release v1.1.3: 添加 Telegram 通知功能
2026-01-02 05:05:52 +08:00
WrBug 00f0898d98 fix: 修复 workflow YAML 语法错误,移除 heredoc 格式
- 将 Python 脚本的 heredoc 格式改为使用多个 echo 命令
- 避免 GitHub Actions YAML 解析器将 heredoc 误判为 YAML 语法
- 确保 workflow 文件符合 GitHub Actions 语法规范
2026-01-02 05:04:02 +08:00
WrBug 1de9b5e958 fix: PR 仅关闭时不发送 Telegram 通知
- 在发送通知步骤中添加 PR 合并状态检查
- 如果 PR 仅关闭(未合并),直接退出,不发送通知
- 确保只有合并到 main 分支的 PR 才会发送通知
2026-01-02 05:02:10 +08:00
WrBug c8e422a94b fix: 修复 workflow YAML 语法错误,移除 heredoc 格式
- 修复 docker-build.yml 和 telegram-notify.yml 中的 heredoc 格式问题
- 使用 $'\n' 格式构建多行字符串,避免 YAML 解析器误判
- 确保所有 workflow 文件符合 GitHub Actions YAML 语法规范
2026-01-02 04:58:14 +08:00
WrBug 60e9f9235d fix: 修复 workflow YAML 语法错误
- 修复 docker-build.yml 和 telegram-notify.yml 中的多行字符串格式问题
- 使用 heredoc 格式构建消息,避免 YAML 解析器误判
- 使用 $'\n' 格式追加内容,避免多行字符串导致 YAML 解析问题
2026-01-02 04:57:00 +08:00
WrBug 46baa416f4 fix: 修复 telegram-notify.yml YAML 语法错误
- 使用 heredoc 格式构建消息,避免 YAML 解析器误判
- 修复第 75 行的多行字符串格式问题
2026-01-02 04:52:50 +08:00
WrBug ecdb8af14a feat: 添加 Telegram 通知功能
- 添加 PR 合并到 main 分支时的 Telegram 通知
- 添加 Docker 镜像构建成功时的 Telegram 通知
- 支持 Markdown 转 HTML 格式
- 移除作者、仓库、变更统计、提交记录等冗余信息
2026-01-02 04:48:23 +08:00
WrBug d6027e48eb fix: 优化订单状态更新逻辑,避免误删订单和匹配明细
- 先检查 HTTP 状态码,非 200 的都跳过,不删除订单
- 只有当 HTTP 200 且响应体为 null 时,才表示订单不存在
- 保护已部分卖出的订单,如果已部分卖出则保留用于统计
- 避免因临时网络问题或 API 错误导致订单和 sell_match_detail 被误删
2026-01-02 04:13:10 +08:00
WrBug eb45013a93 Merge pull request #9 from WrBug/pre_release
fix: 修复前端编译错误(v1.1.2 后续修复)
2026-01-02 00:28:58 +08:00
WrBug f97bd5b9d9 docs: 更新 v1.1.2 发布说明,添加前端编译错误修复 2026-01-02 00:25:43 +08:00
WrBug df272156cf fix: 修复前端编译错误,移除不存在的 bestBid 属性引用 2026-01-02 00:24:31 +08:00
WrBug 502b4fb0b7 Merge pull request #8 from WrBug/pre_release
Release v1.1.2
2026-01-02 00:21:56 +08:00
WrBug 64f6a2b897 docs: 添加 v1.1.2 发布说明 2026-01-02 00:19:14 +08:00
WrBug f7f2411b9d 优化市场价格服务:移除降级查询逻辑,仅保留链上和订单簿查询
- 移除 CLOB Trades、Gamma Market Status、Gamma Market Price 查询逻辑
- 只保留链上 RPC 查询(市场结算结果)和 CLOB 订单簿查询
- 如果所有数据源都失败,抛出 IllegalStateException 异常
- 价格截位到 4 位小数(向下截断,不四舍五入)
- 移除未使用的导入和方法(MarketResponse, JsonUtils, OutcomeResult 等)
- 所有调用方都已正确处理异常,无需额外修改
2026-01-02 00:14:42 +08:00
WrBug d96eb3e00a fix: 恢复 V1 migration 文件,避免 checksum 不匹配
保持 V1__init_database.sql 的原有内容不变,避免破坏已有数据库的 migration checksum。
failed_trade 表的删除将通过 V16 migration 处理。
2026-01-01 22:33:23 +08:00
WrBug cc1a732984 refactor: 移除下单失败存储数据库的功能
- 删除 FailedTrade 实体类和 FailedTradeRepository
- 从 CopyOrderTrackingService 中移除失败交易存储逻辑
- 移除 recordFailedTrade 方法
- 移除检查失败交易的代码
- 创建 Flyway migration V16 删除 failed_trade 表
- 从 V1__init_database.sql 中移除 failed_trade 表创建语句

下单失败时仅记录日志,不再存储到数据库
2026-01-01 22:24:47 +08:00
WrBug ec06003157 feat: 自动使用当前分支名作为 Docker 版本号
- deploy.sh 自动获取当前 Git 分支名作为版本号
- 分支名中的 / 自动替换为 -(Docker tag 不支持 /)
- docker-compose.yml 启用 build args,从环境变量读取版本号
- 前端页面将显示当前分支名作为版本号
- 如果没有 Git 仓库或获取失败,使用默认值 'dev'
2026-01-01 22:10:21 +08:00
WrBug 3d05b13298 fix: 修复内存泄漏问题,缓存和复用 Retrofit/OkHttpClient 实例
问题:
- 每次调用 createClobApi、createGammaApi 等方法都创建新的 OkHttpClient 和 Retrofit 实例
- OkHttpClient 包含连接池、线程池等资源,导致内存不断增长
- 运行几小时后内存从 400MB 涨到 1GB+

解决方案:
- 为不需要认证的 API 创建共享的 OkHttpClient 实例
- Gamma API、Data API、GitHub API、不带认证的 CLOB API 使用单例客户端
- 带认证的 CLOB API 按钱包地址缓存(每个账户一个客户端)
- RPC API 按 RPC URL 缓存
- Builder Relayer API 按 relayerUrl 缓存
- 添加 @PreDestroy 方法清理缓存

效果:
- 大幅减少内存占用,避免内存泄漏
- 复用连接池和线程池,提高性能
- 内存占用将保持稳定,不再持续增长
2026-01-01 22:07:24 +08:00
WrBug fe2db11b75 其他代码修改
- CopyOrderTrackingRepository: 添加查询方法
- PolymarketClobService: 功能更新
- CopyOrderTrackingService: 逻辑优化
- OrderStatusUpdateService: 功能增强
- TelegramNotificationService: 通知优化
2026-01-01 12:03:05 +08:00
WrBug 5c18cbd95d 统一 Gson 使用,改为依赖注入方式
- 在 GsonConfig 中统一配置 Gson Bean(lenient 模式)
- 所有 Service 类通过构造函数注入 Gson 实例
- RetrofitFactory、BlockchainService、PolymarketApiKeyService 等统一使用注入的 Gson
- AccountService、PositionCheckService 添加 JsonUtils 注入
- OnChainWsUtils(object 单例)使用私有 Gson 实例(与 GsonConfig 配置一致)
- 移除所有 GsonConverterFactory.create() 无参调用,统一使用注入的 Gson
2026-01-01 12:01:17 +08:00
WrBug ad2fa4eef2 Merge pull request #7 from WrBug/pre_release
Release v1.1.1: 链上监听优化、市场查询优化和 Bug 修复
2025-12-28 05:13:18 +08:00
WrBug 8357546f3c docs: 更新 v1.1.1 release 文档,包含所有改动内容 2025-12-28 05:10:19 +08:00
WrBug a62141ea5e chore: 更新 RELEASE.md 为 v1.1.1 2025-12-28 05:04:45 +08:00
WrBug 9157203653 fix: 修复移动端 API 健康页面缺少数据显示
- 移动端添加 URL 地址显示
- 移动端添加状态文本显示(正常/异常/未配置)
- 移动端添加消息/状态信息显示
- 移动端和桌面端显示信息保持一致
2025-12-28 05:04:27 +08:00
WrBug 23fc20763a fix: 修复移动端 API 健康页面缺少数据显示
- 移动端添加 URL 地址显示
- 移动端添加状态文本显示(正常/异常/未配置)
- 移动端添加消息/状态信息显示
- 移动端和桌面端显示信息保持一致
2025-12-28 05:02:54 +08:00
WrBug e96a0b6279 feat: 优化 Telegram 推送消息格式
- 添加价格和数量截位处理:
  * 价格保留最多4位小数(截断,不四舍五入)
  * 数量保留最多2位小数(截断,不四舍五入)
- 优化账户信息显示格式:
  * 有账户名和钱包地址时显示:账户名(0x123...123)
  * 只有账户名时显示账户名
  * 只有钱包地址时显示脱敏后的地址
  * 都没有时显示未知账户
- 统一使用 buildAccountInfo 函数构建账户信息
- 应用到所有消息类型:订单成功、订单失败、订单被过滤、仓位赎回
2025-12-28 04:58:30 +08:00
WrBug 85d8619fe7 refactor: 统一发布说明文件,使用 RELEASE.md 替代版本化文件
- 创建 RELEASE.md,包含最新的 v1.1.0 发布说明
- 删除 RELEASE_v1.0.1.md 和 RELEASE_v1.1.0.md
- 仅保留最新的发布说明文件,简化维护
2025-12-28 04:53:47 +08:00
WrBug 89fb980da7 refactor: 移除 polygon.rpc.url 配置,使用 RpcNodeService 统一管理 RPC 节点
- 删除 application.properties 中的 polygon.rpc.url 配置
- 更新 ApiHealthCheckService 直接使用 RpcNodeService.getHttpUrl()
- 删除所有 Docker Compose 配置中的 POLYGON_RPC_URL 环境变量
- 删除所有部署脚本中的 POLYGON_RPC_URL 环境变量
- 更新所有文档,移除 POLYGON_RPC_URL 相关说明
- 删除 application.properties 中无用的 position.push 配置项
- 修正日志配置中的包名(polyhermes -> polymarketbot)

现在系统通过 RpcNodeService 从数据库读取 RPC 节点配置,用户可以通过系统设置页面管理 RPC 节点,不再需要环境变量配置。
2025-12-28 04:51:07 +08:00
WrBug 9f0b22fab5 feat: 优化市场状态查询和自动订单通知
- 使用 Retrofit 替代直接 HTTP 请求查询市场信息(通过 clob_token_ids)
- 实现链上查询市场结算结果(方案1):通过 ConditionalTokens 合约的 getCondition 方法实时获取市场结算状态
- 自动生成的订单(AUTO_、AUTO_FIFO_、AUTO_WS_)不发送 Telegram 通知
- 添加 ABI 编码解析工具方法(decodeUint256Array、decodeConditionResult)
- 优化 PositionCheckService:优先使用链上查询,失败时降级到 API 查询
2025-12-28 00:02:29 +08:00
WrBug b5908a9bbf Merge pull request #6 from WrBug/pre_release
feat: 添加最大仓位限制配置功能 (v1.0.3)
2025-12-23 01:02:07 +08:00
WrBug e9e1111df9 feat: 实现 NBA 量化交易系统
- 后端实现:
  - 实现 NBA 比赛数据服务,从 Polymarket API 获取数据
  - 实现数据库存储和增量拉取逻辑(优先从 DB 获取,数据不足时增量拉取)
  - 使用 sports_market_types 参数直接筛选 moneyline 类型
  - 实现分页拉取逻辑(基于 gameStartTime 和 createdAt)
  - 移除 nba_markets 相关的外键约束(V12 迁移)
  - 修复数据拉取逻辑:超过 3 天的数据不拉取

- 前端实现:
  - 实现策略创建/编辑/列表页面
  - 实现交易信号展示页面和统计页面
  - 修复重复请求问题(使用 useCallback 包装 fetchGames)
  - 支持选择单场比赛进行配置
  - 使用西8区时间格式化显示

- 数据库:
  - 创建 NBA 量化交易相关表(V11 迁移)
  - 移除外键约束(V12 迁移)

- 文档:
  - 添加产品需求文档、技术方案、算法文档等
2025-12-13 08:05:08 +08:00
122 changed files with 16014 additions and 2357 deletions
+89
View File
@@ -36,6 +36,49 @@ jobs:
echo "Extracted version: $VERSION"
echo "Full tag: $TAG_NAME"
- name: Send Telegram notification (build started)
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
run: |
# 检查必要的环境变量
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
echo "⚠️ Telegram Bot Token 或 Chat ID 未配置,跳过通知"
exit 0
fi
# 获取构建信息
VERSION="${{ steps.extract_version.outputs.VERSION }}"
TAG="${{ steps.extract_version.outputs.TAG }}"
RELEASE_URL="${{ github.event.release.html_url }}"
# 构建消息内容(仅包含关键信息)
MESSAGE="🔨 <b>Docker 镜像构建中</b>"$'\n'$'\n'"📦 <b>版本:</b> ${VERSION}"$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"
# 发送 Telegram 消息(使用 jq 转义 JSON
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg chat_id "$TELEGRAM_CHAT_ID" \
--arg text "$MESSAGE" \
'{chat_id: $chat_id, text: $text, parse_mode: "HTML", disable_web_page_preview: false}')" > /tmp/telegram_response.json
# 检查发送结果
if [ $? -eq 0 ]; then
RESPONSE=$(cat /tmp/telegram_response.json)
if echo "$RESPONSE" | grep -q '"ok":true'; then
echo "✅ Telegram 通知发送成功"
else
echo "❌ Telegram 通知发送失败: $RESPONSE"
# 通知失败不应该导致整个 job 失败
exit 0
fi
else
echo "❌ 发送 Telegram 消息时发生错误"
# 通知失败不应该导致整个 job 失败
exit 0
fi
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
@@ -65,3 +108,49 @@ jobs:
GITHUB_REPO_URL=https://github.com/WrBug/PolyHermes
cache-from: type=registry,ref=wrbug/polyhermes:latest
cache-to: type=inline
- name: Send Telegram notification
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
run: |
# 检查必要的环境变量
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
echo "⚠️ Telegram Bot Token 或 Chat ID 未配置,跳过通知"
exit 0
fi
# 获取构建信息
VERSION="${{ steps.extract_version.outputs.VERSION }}"
TAG="${{ steps.extract_version.outputs.TAG }}"
RELEASE_NAME="${{ github.event.release.name }}"
RELEASE_URL="${{ github.event.release.html_url }}"
REPO_NAME="${{ github.repository }}"
# 构建消息内容(仅包含关键信息)
DEPLOY_DOC_URL="https://github.com/WrBug/PolyHermes/blob/main/docs/zh/DEPLOYMENT.md"
MESSAGE="✅ <b>Docker 镜像构建成功</b>"$'\n'$'\n'"📦 <b>版本:</b> ${VERSION}"$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker 部署文档</a>"
# 发送 Telegram 消息(使用 jq 转义 JSON
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg chat_id "$TELEGRAM_CHAT_ID" \
--arg text "$MESSAGE" \
'{chat_id: $chat_id, text: $text, parse_mode: "HTML", disable_web_page_preview: false}')" > /tmp/telegram_response.json
# 检查发送结果
if [ $? -eq 0 ]; then
RESPONSE=$(cat /tmp/telegram_response.json)
if echo "$RESPONSE" | grep -q '"ok":true'; then
echo "✅ Telegram 通知发送成功"
else
echo "❌ Telegram 通知发送失败: $RESPONSE"
# 构建成功,通知失败不应该导致整个 job 失败
exit 0
fi
else
echo "❌ 发送 Telegram 消息时发生错误"
# 构建成功,通知失败不应该导致整个 job 失败
exit 0
fi
+103
View File
@@ -0,0 +1,103 @@
name: Telegram Notification on PR Merge
on:
pull_request:
types:
- closed # 当 PR 被关闭(合并或关闭)时触发
jobs:
notify:
runs-on: ubuntu-latest
# 只在 PR 被合并到 main 分支时执行
if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Get PR details
id: pr_details
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUMBER="${{ github.event.pull_request.number }}"
REPO="${{ github.repository }}"
# 获取 PR 详细信息
PR_RESPONSE=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/${REPO}/pulls/${PR_NUMBER}")
# 获取 PR 变更的文件列表
FILES_RESPONSE=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/${REPO}/pulls/${PR_NUMBER}/files")
# 提取 PR 描述(body),保留换行,限制长度
PR_BODY=$(echo "$PR_RESPONSE" | jq -r '.body // ""')
if [ ${#PR_BODY} -gt 500 ]; then
PR_BODY="${PR_BODY:0:500}..."
fi
# 保存到输出变量(使用 base64 编码避免特殊字符问题)
echo "pr_body<<EOF" >> $GITHUB_OUTPUT
echo "$PR_BODY" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Send Telegram notification
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
run: |
# 检查 PR 是否被合并(而不是仅关闭)
PR_MERGED="${{ github.event.pull_request.merged }}"
if [ "$PR_MERGED" != "true" ]; then
echo "ℹ️ PR 仅关闭,未合并,跳过通知"
exit 0
fi
# 检查必要的环境变量
# 注意:TELEGRAM_CHAT_ID 可以是个人聊天 ID(正数)或群组 ID(负数,如 -1001234567890
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
echo "⚠️ Telegram Bot Token 或 Chat ID 未配置,跳过通知"
exit 0
fi
# 获取 PR 基本信息
PR_NUMBER="${{ github.event.pull_request.number }}"
PR_TITLE="${{ github.event.pull_request.title }}"
PR_URL="${{ github.event.pull_request.html_url }}"
PR_MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}"
# 获取 PR 详细信息
PR_BODY="${{ steps.pr_details.outputs.pr_body }}"
# 转义 PR 标题中的 HTML 特殊字符
PR_TITLE_ESCAPED=$(echo "$PR_TITLE" | sed 's/&/\&amp;/g' | sed 's/</\&lt;/g' | sed 's/>/\&gt;/g')
# 构建消息内容(仅包含关键信息)
MESSAGE="🚀 <b>main 分支代码更新</b>"$'\n'$'\n'"📝 <b>PR #${PR_NUMBER}:</b> ${PR_TITLE_ESCAPED}"$'\n'"🔗 <a href=\"${PR_URL}\">查看 PR</a>"
# 发送 Telegram 消息(使用 jq 转义 JSON
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg chat_id "$TELEGRAM_CHAT_ID" \
--arg text "$MESSAGE" \
'{chat_id: $chat_id, text: $text, parse_mode: "HTML", disable_web_page_preview: false}')" > /tmp/telegram_response.json
# 检查发送结果
if [ $? -eq 0 ]; then
RESPONSE=$(cat /tmp/telegram_response.json)
if echo "$RESPONSE" | grep -q '"ok":true'; then
echo "✅ Telegram 通知发送成功"
else
echo "❌ Telegram 通知发送失败: $RESPONSE"
exit 1
fi
else
echo "❌ 发送 Telegram 消息时发生错误"
exit 1
fi
-133
View File
@@ -1,133 +0,0 @@
# 🎉 v1.0.1 版本发布公告
## 📅 发布日期
2025年12月(具体日期待定)
## 🚀 主要更新
### 性能优化
#### 📊 订单簿请求优化
- 优化订单簿获取逻辑,仅在需要时请求,避免不必要的 API 调用
- 当未配置需要订单簿的过滤条件(价差、订单深度)时,跳过订单簿获取
- 订单簿只请求一次,在过滤检查中复用,提高性能
#### 💰 卖出价格计算优化
- 卖出改为市价卖出:优先使用订单簿的 bestBid(最高买入价)
- 如果 bestBid 获取失败,自动降级使用 Leader 的交易价格
- 卖出价格固定按 90% 计算,不再使用价格容忍度配置
- 提高卖出订单的成交率
#### ⚙️ 价格容忍度默认值
- 如果价格容忍度配置为 0,自动使用默认值 5%
- 确保买入订单能够正常应用价格调整,提高成交率
### 代码优化
#### 🎯 使用枚举优化过滤逻辑
- 新增 `FilterResult` 数据类和 `FilterStatus` 枚举
- 使用类型安全的枚举替代字符串判断,提高代码可维护性
- 移除无用字段 `isBuyOrder` 参数
- 优化 `checkFilters` 方法返回值,使用数据类封装结果
#### 📝 代码结构优化
- 减少 if-else 嵌套,使用 Kotlin 链式调用和空安全操作符
- 优化卖出价格计算逻辑,使用 `runCatching``?.let` 简化代码
- 提取公共方法 `calculateFallbackSellPrice`,提高代码复用性
### 功能改进
#### 🗑️ 移除最小订单簿深度功能
- 移除 `minOrderbookDepth`(最小订单簿深度)配置项
- 简化过滤逻辑,只保留 `minOrderDepth`(最小订单深度)
- 更新前端界面,移除相关配置项和提示文案
#### 📈 优化最小订单深度逻辑
- 修改 `minOrderDepth` 检查逻辑,检查所有方向(买盘+卖盘)的总深度
- 不再区分买卖方向,提供更全面的市场流动性评估
#### 🎯 市场结算判断优化
- 添加市场已关闭时的结算判断逻辑
- 如果市场已关闭且该 outcome 赢了(价格 >= 0.99),返回价格为 1
- 如果市场已关闭且该 outcome 输了(价格 <= 0.01),返回价格为 0
- 当没有仓位但有未完成订单时,正确判断市场结算状态并设置价格
### Bug 修复
- ✅ 修复盈亏和持仓价值计算逻辑未实现的问题
- ✅ 修复过滤检查中订单簿可能重复请求的问题
- ✅ 修复健康检查路径不一致问题(从 `/api/health` 改为 `/api/system/health`
## 📦 如何更新
### Docker 部署
```bash
# 拉取最新镜像
docker pull wrbug/polyhermes:v1.0.1
# 或使用 latest 标签
docker pull wrbug/polyhermes:latest
# 重启容器
docker-compose down
docker-compose up -d
```
### 源码部署
```bash
# 拉取最新代码
git fetch origin
git checkout v1.0.1
# 重新构建
cd backend
./gradlew bootJar
# 重启服务
# 根据您的部署方式重启服务
```
## 📚 文档更新
- 更新前端多语言文案,优化提示信息
- 移除最小订单簿深度相关的文档说明
## 🔗 相关链接
- **GitHub Release**: https://github.com/WrBug/PolyHermes/releases/tag/v1.0.1
- **完整更新日志**: https://github.com/WrBug/PolyHermes/compare/v1.0.0...v1.0.1
- **Docker Hub**: https://hub.docker.com/r/wrbug/polyhermes
## ⚠️ 重要提醒
**请务必使用官方 Docker 镜像源,避免财产损失!**
### ✅ 官方 Docker Hub 镜像
**官方镜像地址**`wrbug/polyhermes`
```bash
# ✅ 正确:使用官方镜像
docker pull wrbug/polyhermes:v1.0.1
# ❌ 错误:不要使用其他来源的镜像
# 任何非官方来源的镜像都可能包含恶意代码,导致您的私钥和资产被盗
```
### 🔗 官方渠道
请通过以下**唯一官方渠道**获取 PolyHermes
* **GitHub 仓库**https://github.com/WrBug/PolyHermes
* **Twitter**@polyhermes
* **Telegram 群组**:加入群组
---
**⭐ 如果这个项目对您有帮助,请给个 Star 支持一下!**
**💬 如有问题或建议,欢迎在 GitHub Issues 中反馈。**
-288
View File
@@ -1,288 +0,0 @@
# 🎉 v1.1.0 版本更新指南
## 📅 发布日期
2025年12月26日
## 🎯 这次更新对您意味着什么?
### ⚡ 更快的跟单响应速度
- **实时链上监听**:通过 WebSocket 实时监听链上交易,跟单响应速度提升 50% 以上
- **双重保障**:链上监听和轮询同时运行,确保不错过任何交易机会
- **自动去重**:系统自动处理重复数据,确保每笔交易只执行一次
### 💰 更准确的盈亏统计
- **实际成交价追踪**:系统会自动更新卖出订单的实际成交价,而不是下单时的价格
- **自动价格更新**:每 5 秒自动查询并更新订单的实际成交价
- **精确盈亏计算**:基于实际成交价计算盈亏,统计数据更准确
### 🔧 更灵活的系统配置
- **RPC 节点管理**:可以添加、编辑、删除自定义 RPC 节点
- **节点启用/禁用**:可以随时启用或禁用节点,无需删除配置
- **智能节点选择**:系统自动选择可用的节点,禁用的节点会被忽略
### 🐛 问题修复
- **修复卖出订单失败问题**:修复了部分情况下卖出订单因 API 凭证问题导致的失败
- **修复订单精度错误**:修复了卖出订单因精度问题导致的 API 错误
## 📦 如何更新
### 方式一:使用 Docker(推荐)
#### 步骤 1:备份数据(强烈推荐)
**备份不是必须的,但强烈推荐!**
**为什么需要备份?**
- Docker 更新不会删除数据(数据存储在独立的数据卷中)
- 但数据库结构可能会变更(本次更新会添加 `price_updated` 字段)
- 如果迁移失败或出现问题,备份可以帮助恢复数据
- 生产环境建议必须备份,开发环境可以跳过
**如何备份?**
```bash
# 方式 1:使用 mysqldump 备份数据库(推荐)
docker exec polyhermes-mysql mysqldump -u root -p polyhermes > backup_$(date +%Y%m%d_%H%M%S).sql
# 方式 2:备份整个 MySQL 数据卷
docker run --rm -v polyhermes_mysql-data:/data -v $(pwd):/backup alpine tar czf /backup/mysql_backup_$(date +%Y%m%d_%H%M%S).tar.gz /data
# 方式 3:如果已有定期备份,可以跳过此步骤
```
**什么情况下可以跳过备份?**
- ✅ 开发环境或测试环境
- ✅ 数据不重要或可以重新生成
- ✅ 已经有定期自动备份
- ✅ 确定迁移不会失败(查看更新日志确认)
**什么情况下必须备份?**
- ⚠️ 生产环境
- ⚠️ 包含重要交易数据
- ⚠️ 不确定迁移是否安全
#### 步骤 2:停止当前服务
```bash
# 进入部署目录
cd /path/to/polyhermes
# 停止服务
docker-compose -f docker-compose.prod.yml down
```
#### 步骤 3:拉取新版本
```bash
# 拉取 v1.1.0 版本
docker pull wrbug/polyhermes:v1.1.0
# 或者拉取最新版本(推荐)
docker pull wrbug/polyhermes:latest
```
#### 步骤 4:更新配置(如果需要)
如果您想固定使用 v1.1.0 版本,可以编辑 `docker-compose.prod.yml`
```yaml
services:
app:
image: wrbug/polyhermes:v1.1.0 # 修改这里
```
#### 步骤 5:启动服务
```bash
# 启动服务
docker-compose -f docker-compose.prod.yml up -d
# 查看日志,确认服务正常启动
docker-compose -f docker-compose.prod.yml logs -f
```
#### 步骤 6:验证更新
1. 访问系统首页,查看页面标题是否显示 `v1.1.0`
2. 检查系统功能是否正常
3. 查看日志是否有错误信息
### 方式二:一键更新脚本
如果您使用 `latest` 标签,可以使用以下命令一键更新:
```bash
# 进入部署目录
cd /path/to/polyhermes
# 拉取最新镜像并重启
docker-compose -f docker-compose.prod.yml pull
docker-compose -f docker-compose.prod.yml up -d
# 查看日志
docker-compose -f docker-compose.prod.yml logs -f
```
## ⚠️ 更新注意事项
### 1. 数据库迁移
- 本次更新会自动执行数据库迁移(添加 `price_updated` 字段)
- 迁移过程不会影响现有数据,只是添加新字段
- 迁移是安全的,但如果迁移失败,请检查数据库权限和连接
- **如果迁移失败,可以使用备份恢复数据**
### 2. 服务中断时间
- 更新过程中服务会短暂中断(通常 1-2 分钟)
- 建议在低峰期进行更新
- 更新期间正在进行的跟单操作可能会受影响
### 3. 配置检查
- 更新后请检查 RPC 节点配置是否正确
- 如果使用自定义 RPC 节点,请确认节点状态正常
### 4. 功能验证
更新后建议验证以下功能:
- ✅ 跟单功能是否正常
- ✅ 订单价格更新是否正常
- ✅ RPC 节点管理是否正常
- ✅ 盈亏统计是否准确
## 🆕 新功能使用指南
### 1. RPC 节点管理
#### 如何添加自定义 RPC 节点?
1. 登录系统,进入 **系统设置****RPC 节点设置**
2. 点击 **添加节点** 按钮
3. 填写节点信息:
- **节点名称**:给节点起个名字(如:Alchemy Polygon
- **RPC URL**:节点的 HTTP/HTTPS 地址
- **启用状态**:默认启用,可以稍后禁用
4. 点击 **保存**
#### 如何启用/禁用节点?
1. 在 RPC 节点列表中,找到要操作的节点
2. 点击 **启用/禁用** 开关
3. 系统会自动更新节点状态
**提示**
- 禁用的节点不会被使用,但配置会保留
- 可以随时重新启用禁用的节点
- 建议至少保留一个启用的节点
### 2. 实际成交价追踪
#### 这个功能做什么?
系统会自动追踪卖出订单的实际成交价,而不是使用下单时的价格。这样可以:
- 更准确地计算盈亏
- 了解订单的实际执行情况
- 优化卖出策略
#### 如何查看实际成交价?
1. 进入 **跟单统计****卖出订单**
2. 查看订单列表,**卖出价格** 列显示的是实际成交价
3. 如果价格旁边有更新标记,说明价格已从订单详情中获取
**注意**
- 价格更新是自动的,每 5 秒检查一次
- 如果订单还未成交,价格可能还是下单时的价格
- 部分成交的订单会使用加权平均价格
## 🔍 常见问题
### Q1: 更新后无法启动怎么办?
**A:** 请按以下步骤排查:
1. 检查日志:`docker-compose -f docker-compose.prod.yml logs`
2. 检查数据库连接是否正常
3. 检查端口是否被占用
4. 如果问题持续,可以回退到之前的版本:
```bash
# 修改 docker-compose.prod.yml 中的镜像标签为之前的版本
# 例如:image: wrbug/polyhermes:v1.0.3
docker-compose -f docker-compose.prod.yml up -d
```
### Q2: 更新后跟单不工作怎么办?
**A:** 请检查:
1. RPC 节点是否正常(进入 RPC 节点设置查看)
2. 跟单配置是否启用
3. 账户 API 凭证是否有效
4. 查看系统日志是否有错误信息
### Q3: 如何回退到之前的版本?
**A:** 按以下步骤操作:
1. 停止当前服务:`docker-compose -f docker-compose.prod.yml down`
2. 修改 `docker-compose.prod.yml` 中的镜像标签为之前的版本
3. 启动服务:`docker-compose -f docker-compose.prod.yml up -d`
**注意**:回退版本时,数据库结构可能不兼容,建议先备份数据库。
### Q4: 更新后数据会丢失吗?
**A:** 不会。更新过程不会删除任何数据。但为了安全起见,建议更新前备份数据库。
### Q5: 如何查看当前版本?
**A:** 有两种方式:
1. **页面查看**:登录系统后,页面标题会显示版本号(如:PolyHermes v1.1.0
2. **命令行查看**
```bash
docker inspect polyhermes | grep -i version
```
## 📚 相关文档
- [完整更新日志](https://github.com/WrBug/PolyHermes/compare/v1.0.3...v1.1.0)
- [部署文档](docs/zh/DEPLOYMENT.md)
- [版本管理文档](docs/zh/VERSION_MANAGEMENT.md)
## 🔗 获取帮助
如果更新过程中遇到问题:
1. 查看 [GitHub Issues](https://github.com/WrBug/PolyHermes/issues)
2. 查看系统日志:`docker-compose -f docker-compose.prod.yml logs -f`
3. 在 GitHub 上提交 Issue,描述您的问题
## ⚠️ 安全提醒
**请务必使用官方 Docker 镜像源,避免财产损失!**
### ✅ 官方 Docker Hub 镜像
**官方镜像地址**`wrbug/polyhermes`
```bash
# ✅ 正确:使用官方镜像
docker pull wrbug/polyhermes:v1.1.0
# ❌ 错误:不要使用其他来源的镜像
# 任何非官方来源的镜像都可能包含恶意代码,导致您的私钥和资产被盗
```
### 🔗 官方渠道
请通过以下**唯一官方渠道**获取 PolyHermes
* **GitHub 仓库**https://github.com/WrBug/PolyHermes
* **Twitter**@polyhermes
* **Telegram 群组**:加入群组
---
**⭐ 如果这个项目对您有帮助,请给个 Star 支持一下!**
**💬 如有问题或建议,欢迎在 GitHub Issues 中反馈。**
+1028
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -270,7 +270,6 @@ DB_USERNAME=root
DB_PASSWORD=your_password_here
SPRING_PROFILES_ACTIVE=prod
SERVER_PORT=80
POLYGON_RPC_URL=https://polygon-rpc.com
JWT_SECRET=your-jwt-secret-key-change-in-production
ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production
EOF
@@ -337,7 +336,6 @@ cd frontend
| `DB_USERNAME` | 数据库用户名 | `root` |
| `DB_PASSWORD` | 数据库密码 | - |
| `SERVER_PORT` | 后端服务端口 | `8000` |
| `POLYGON_RPC_URL` | Polygon RPC 地址 | `https://polygon-rpc.com` |
| `JWT_SECRET` | JWT 密钥 | - |
| `ADMIN_RESET_PASSWORD_KEY` | 管理员密码重置密钥 | - |
| `CRYPTO_SECRET_KEY` | 加密密钥(用于加密存储私钥和 API Key) | - |
-2
View File
@@ -270,7 +270,6 @@ DB_USERNAME=root
DB_PASSWORD=your_password_here
SPRING_PROFILES_ACTIVE=prod
SERVER_PORT=80
POLYGON_RPC_URL=https://polygon-rpc.com
JWT_SECRET=your-jwt-secret-key-change-in-production
ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production
EOF
@@ -337,7 +336,6 @@ cd frontend
| `DB_USERNAME` | Database username | `root` |
| `DB_PASSWORD` | Database password | - |
| `SERVER_PORT` | Backend service port | `8000` |
| `POLYGON_RPC_URL` | Polygon RPC address | `https://polygon-rpc.com` |
| `JWT_SECRET` | JWT secret key | - |
| `ADMIN_RESET_PASSWORD_KEY` | Admin password reset key | - |
| `CRYPTO_SECRET_KEY` | Encryption key (for encrypting stored private keys and API Keys) | - |
+278
View File
@@ -0,0 +1,278 @@
# v1.1.2
## 🚀 主要功能
### 🐛 修复内存泄漏问题
- 修复 Retrofit/OkHttpClient 实例重复创建导致的内存泄漏问题
- 为不需要认证的 API 创建共享的 OkHttpClient 实例(Gamma API、Data API、GitHub API 等)
- 带认证的 CLOB API 按钱包地址缓存(每个账户一个客户端)
- RPC API 按 RPC URL 缓存,Builder Relayer API 按 relayerUrl 缓存
- 添加 `@PreDestroy` 方法清理缓存,确保资源正确释放
- **效果**:内存占用从运行几小时后从 400MB 涨到 1GB+ 变为保持稳定,大幅减少内存占用
### 📊 市场价格服务优化
- 移除降级查询逻辑,仅保留链上 RPC 查询和 CLOB 订单簿查询
- 移除 CLOB Trades、Gamma Market Status、Gamma Market Price 查询逻辑
- 如果所有数据源都失败,抛出明确的异常信息
- 价格截位到 4 位小数(向下截断,不四舍五入)
- 简化代码逻辑,提高查询效率和准确性
### 🔧 代码架构优化
- 统一 Gson 使用,改为依赖注入方式
-`GsonConfig` 中统一配置 Gson Beanlenient 模式)
- 所有 Service 类通过构造函数注入 Gson 实例
- 移除所有 `GsonConverterFactory.create()` 无参调用,统一使用注入的 Gson
- 提高代码一致性和可维护性
### 🗑️ 功能清理
- 移除下单失败存储数据库的功能
- 删除 `FailedTrade` 实体类和 `FailedTradeRepository`
-`CopyOrderTrackingService` 中移除失败交易存储逻辑
- 创建 Flyway migration V16 删除 `failed_trade`
- 下单失败时仅记录日志,不再存储到数据库,简化数据模型
### 🚀 部署优化
- 自动使用当前分支名作为 Docker 版本号
- 分支名中的 `/` 自动替换为 `-`Docker tag 不支持 `/
- `docker-compose.yml` 启用 build args,从环境变量读取版本号
- 前端页面将显示当前分支名作为版本号
- 如果没有 Git 仓库或获取失败,使用默认值 `dev`
## 🐛 Bug 修复
### 修复 Flyway Migration 问题
- 恢复 V1 migration 文件,避免 checksum 不匹配
- 保持 `V1__init_database.sql` 的原有内容不变
- `failed_trade` 表的删除通过 V16 migration 处理
- 确保已有数据库的 migration checksum 保持一致
### 修复前端编译错误
- 修复 `PositionList.tsx` 中引用不存在的 `bestBid` 属性导致的编译错误
- 使用 `currentPrice` 替代 `bestBid`,确保前端代码可以正常编译
## 📚 文档更新
- 新增智能资金分析文档(`docs/zh/smart-money-analysis.md`
- 详细说明智能资金分析功能的使用方法和策略
## 🔧 技术改进
- 优化 `RetrofitFactory`,实现客户端实例缓存和复用
- 优化 `CopyOrderTrackingService`,移除失败交易相关逻辑
- 优化 `OrderStatusUpdateService`,增强订单状态更新功能
- 优化 `TelegramNotificationService`,改进通知逻辑
- 优化 `PositionCheckService`,简化代码结构
- 优化 `PolymarketClobService`,改进 API 调用逻辑
## 📦 数据库变更
- 删除 `failed_trade` 表(Migration: V16
## 🔗 相关链接
- **GitHub Release**: https://github.com/WrBug/PolyHermes/releases/tag/v1.1.2
- **完整更新日志**: https://github.com/WrBug/PolyHermes/compare/v1.1.1...v1.1.2
- **Docker Hub**: https://hub.docker.com/r/wrbug/polyhermes
## 📊 统计信息
- **文件变更**: 29 个文件
- **代码变更**: +1597 行 / -678 行
- **主要提交**: 8 个提交
## ⚠️ 重要提醒
**请务必使用官方 Docker 镜像源,避免财产损失!**
### ✅ 官方 Docker Hub 镜像
**官方镜像地址**`wrbug/polyhermes`
```bash
# ✅ 正确:使用官方镜像
docker pull wrbug/polyhermes:v1.1.2
# ❌ 错误:不要使用其他来源的镜像
# 任何非官方来源的镜像都可能包含恶意代码,导致您的私钥和资产被盗
```
### 🔗 官方渠道
请通过以下**唯一官方渠道**获取 PolyHermes
* **GitHub 仓库**https://github.com/WrBug/PolyHermes
* **Twitter**@polyhermes
* **Telegram 群组**:加入群组
---
**⭐ 如果这个项目对您有帮助,请给个 Star 支持一下!**
---
# v1.1.1
## 🚀 主要功能
### 🔗 链上 WebSocket 监听优化
- 创建 `UnifiedOnChainWsService` 统一管理 WebSocket 连接,所有服务共享同一个连接
- 创建 `OnChainWsUtils` 工具类,提取公共的链上 WebSocket 相关功能
- 创建 `AccountOnChainMonitorService` 监听账户链上卖出和赎回事件
- 优化 `OnChainWsService`,复用公共代码,减少代码重复
- 支持通过链上 WebSocket 实时监听账户的卖出和赎回交易,自动更新订单状态
### 📊 市场状态查询优化
- 优化市场结算状态查询,优先使用链上查询 `ConditionalTokens.getCondition`
- 如果链上查询失败,自动降级到 Gamma API 查询
- 提供更实时和准确的市场结算结果
### 🔕 自动订单通知优化
- 自动生成的订单(AUTO_、AUTO_FIFO_、AUTO_WS_ 前缀)不再发送 Telegram 通知
- 优化 `OrderStatusUpdateService`,跳过自动生成订单的通知处理
- 减少不必要的通知,提升用户体验
## 🐛 Bug 修复
### 修复移动端 API 健康页面缺少数据显示
- 移动端添加 URL 地址显示
- 移动端添加状态文本显示(正常/异常/未配置)
- 移动端添加消息/状态信息显示
- 移动端和桌面端显示信息保持一致
## 🔧 功能优化
### 优化 Telegram 推送消息格式
- 添加价格和数量截位处理:
* 价格保留最多4位小数(截断,不四舍五入)
* 数量保留最多2位小数(截断,不四舍五入)
- 优化账户信息显示格式:
* 有账户名和钱包地址时显示:账户名(0x123...123)
* 只有账户名时显示账户名
* 只有钱包地址时显示脱敏后的地址
* 都没有时显示未知账户
### 配置优化
- 移除 `polygon.rpc.url` 配置,使用 RpcNodeService 统一管理 RPC 节点
- 删除无用的 `position.push.polling-interval``position.push.heartbeat-timeout` 配置项
- 修正日志配置中的包名(polyhermes -> polymarketbot
- 更新 `ApiHealthCheckService` 直接使用 `RpcNodeService.getHttpUrl()`
## 📚 文档更新
- 统一发布说明文件,使用 RELEASE.md 替代版本化文件(RELEASE_v1.0.1.md、RELEASE_v1.1.0.md
- 更新所有部署文档,移除 POLYGON_RPC_URL 相关说明
- 更新所有 Docker Compose 配置文件,移除 POLYGON_RPC_URL 环境变量
- 更新所有部署脚本,移除 POLYGON_RPC_URL 环境变量定义
## 🔧 技术改进
- 重构链上 WebSocket 服务,提取公共代码到 `OnChainWsUtils`
- 创建统一的 WebSocket 连接管理服务 `UnifiedOnChainWsService`
- 添加链上查询市场结算结果的功能(`BlockchainService.getCondition`
- 添加 ABI 编码/解码工具方法(`EthereumUtils.decodeConditionResult`
- 优化代码结构,减少代码重复,提高可维护性
---
# v1.1.0
## 🚀 主要功能
### 🔗 链上 WebSocket 实时监听
- 实现通过 Polygon RPC `eth_subscribe` 实时监听链上交易
- 支持监听 USDC Transfer 和 ERC1155 Transfer 事件
- 实现并行监控策略:链上 WebSocket 和轮询同时运行,哪个数据先返回用哪个
- 支持通过 `eth_unsubscribe` 取消单个 Leader 的订阅,无需重新连接
- 优化 WebSocket 连接管理:只创建一个连接,没有跟单配置时自动取消
- 跟单配置生效/失效时及时更新 WebSocket 订阅
- 使用 Gson 替换所有 JSON 解析,提高解析稳定性
- 添加 Mutex 保证线程安全,防止并发处理导致的数据重复
### 📊 RPC 节点管理
- 实现 RPC 节点管理功能,支持添加、编辑、删除自定义 RPC 节点
- 支持 RPC 节点启用/禁用功能,禁用的节点会被自动忽略
- 前端添加启用/禁用开关,支持实时切换节点状态
- 健康检查只检查启用的节点,提高检查效率
- 节点选择时自动过滤禁用的节点
### 💰 卖出订单价格轮询更新
- 添加 `price_updated` 字段到 `sell_match_record` 表,用于标记价格是否已更新
- 创建 `OrderStatusUpdateService` 定时任务服务,每 5 秒轮询一次:
- 更新卖出订单的实际成交价(通过 orderId 查询订单详情)
- 清理已删除账户的订单记录
- 支持加权平均价格计算,处理部分成交的订单
- 添加 orderId 格式验证:非 0x 开头的直接标记为已更新,0x 开头的等待定时任务更新
- 下单完成后不再立即查询价格,直接保存,等待定时任务更新
## 🐛 Bug 修复
### 修复跟单卖出订单的 API 凭证解密问题
- 修复 `processSellTrade` 中 API 凭证未解密的问题,与 `processBuyTrade` 保持一致
- 确保卖出订单能够正常使用 API 凭证进行认证
### 修复 SELL 订单精度问题
- 修复 SELL 订单的 `makerAmount``takerAmount` 精度问题:
- `makerAmount` (shares) 最多 2 位小数(符合 API 要求)
- `takerAmount` (USDC) 最多 4 位小数(符合 API 要求)
- 确保订单能够正常提交到 Polymarket API
## 📚 文档更新
- 添加 Docker 版本更新说明(中英文)
- 添加链上 WebSocket 监听策略文档
- 添加跟单逻辑总结文档
- 更新部署文档,包含详细的版本更新步骤
## 🔧 技术改进
- 使用 Gson 替换 ObjectMapper,提高 JSON 解析稳定性
- `JsonRpcResponse.result` 使用 `JsonElement` 类型,支持灵活的 JSON 结构
- 优化 WebSocket 连接管理,减少不必要的连接
- 添加线程安全机制,使用 Kotlin Coroutines Mutex
- 启用 Spring 定时任务功能(`@EnableScheduling`
## 📦 数据库变更
- 新增 `price_updated` 字段到 `sell_match_record` 表(Migration: V13
## 🔗 相关链接
- **GitHub Release**: https://github.com/WrBug/PolyHermes/releases/tag/v1.1.1
- **完整更新日志**: https://github.com/WrBug/PolyHermes/compare/v1.1.0...v1.1.1
- **Docker Hub**: https://hub.docker.com/r/wrbug/polyhermes
## 📊 统计信息
- **文件变更**: 32 个文件
- **代码变更**: +1872 行 / -1503 行
- **主要提交**: 7 个提交
## ⚠️ 重要提醒
**请务必使用官方 Docker 镜像源,避免财产损失!**
### ✅ 官方 Docker Hub 镜像
**官方镜像地址**`wrbug/polyhermes`
```bash
# ✅ 正确:使用官方镜像
docker pull wrbug/polyhermes:v1.1.1
# ❌ 错误:不要使用其他来源的镜像
# 任何非官方来源的镜像都可能包含恶意代码,导致您的私钥和资产被盗
```
### 🔗 官方渠道
请通过以下**唯一官方渠道**获取 PolyHermes
* **GitHub 仓库**https://github.com/WrBug/PolyHermes
* **Twitter**@polyhermes
* **Telegram 群组**:加入群组
---
**⭐ 如果这个项目对您有帮助,请给个 Star 支持一下!**
-55
View File
@@ -1,55 +0,0 @@
# v1.0.1
## 🚀 性能优化
### 📊 订单簿请求优化
* 优化订单簿获取逻辑,仅在需要时请求,避免不必要的 API 调用
* 当未配置需要订单簿的过滤条件(价差、订单深度)时,跳过订单簿获取
* 订单簿只请求一次,在过滤检查中复用,提高性能
### 💰 卖出价格计算优化
* 卖出改为市价卖出:优先使用订单簿的 bestBid(最高买入价)
* 如果 bestBid 获取失败,自动降级使用 Leader 的交易价格
* 卖出价格固定按 90% 计算,不再使用价格容忍度配置
* 提高卖出订单的成交率
### ⚙️ 价格容忍度默认值
* 如果价格容忍度配置为 0,自动使用默认值 5%
* 确保买入订单能够正常应用价格调整,提高成交率
## 🔧 代码优化
### 🎯 使用枚举优化过滤逻辑
* 新增 `FilterResult` 数据类和 `FilterStatus` 枚举
* 使用类型安全的枚举替代字符串判断,提高代码可维护性
* 移除无用字段 `isBuyOrder` 参数
* 优化 `checkFilters` 方法返回值,使用数据类封装结果
### 📝 代码结构优化
* 减少 if-else 嵌套,使用 Kotlin 链式调用和空安全操作符
* 优化卖出价格计算逻辑,使用 `runCatching``?.let` 简化代码
* 提取公共方法 `calculateFallbackSellPrice`,提高代码复用性
## 🗑️ 功能移除
### 移除最小订单簿深度功能
* 移除 `minOrderbookDepth`(最小订单簿深度)配置项
* 简化过滤逻辑,只保留 `minOrderDepth`(最小订单深度)
* 更新前端界面,移除相关配置项和提示文案
### 优化最小订单深度逻辑
* 修改 `minOrderDepth` 检查逻辑,检查所有方向(买盘+卖盘)的总深度
* 不再区分买卖方向,提供更全面的市场流动性评估
## 🐛 Bug 修复
* 修复盈亏和持仓价值计算逻辑未实现的问题
* 修复过滤检查中订单簿可能重复请求的问题
## 📚 文档更新
* 更新前端多语言文案,优化提示信息
* 移除最小订单簿深度相关的文档说明
**Full Changelog**: https://github.com/WrBug/polymarket-bot/compare/v1.0.0...v1.0.1
-95
View File
@@ -1,95 +0,0 @@
# v1.1.0
## 🚀 主要功能
### 🔗 链上 WebSocket 实时监听
- 实现通过 Polygon RPC `eth_subscribe` 实时监听链上交易
- 支持监听 USDC Transfer 和 ERC1155 Transfer 事件
- 实现并行监控策略:链上 WebSocket 和轮询同时运行,哪个数据先返回用哪个
- 支持通过 `eth_unsubscribe` 取消单个 Leader 的订阅,无需重新连接
- 优化 WebSocket 连接管理:只创建一个连接,没有跟单配置时自动取消
- 跟单配置生效/失效时及时更新 WebSocket 订阅
- 使用 Gson 替换所有 JSON 解析,提高解析稳定性
- 添加 Mutex 保证线程安全,防止并发处理导致的数据重复
### 📊 RPC 节点管理
- 实现 RPC 节点管理功能,支持添加、编辑、删除自定义 RPC 节点
- 支持 RPC 节点启用/禁用功能,禁用的节点会被自动忽略
- 前端添加启用/禁用开关,支持实时切换节点状态
- 健康检查只检查启用的节点,提高检查效率
- 节点选择时自动过滤禁用的节点
### 💰 卖出订单价格轮询更新
- 添加 `price_updated` 字段到 `sell_match_record` 表,用于标记价格是否已更新
- 创建 `OrderStatusUpdateService` 定时任务服务,每 5 秒轮询一次:
- 更新卖出订单的实际成交价(通过 orderId 查询订单详情)
- 清理已删除账户的订单记录
- 支持加权平均价格计算,处理部分成交的订单
- 添加 orderId 格式验证:非 0x 开头的直接标记为已更新,0x 开头的等待定时任务更新
- 下单完成后不再立即查询价格,直接保存,等待定时任务更新
## 🐛 Bug 修复
### 修复跟单卖出订单的 API 凭证解密问题
- 修复 `processSellTrade` 中 API 凭证未解密的问题,与 `processBuyTrade` 保持一致
- 确保卖出订单能够正常使用 API 凭证进行认证
### 修复 SELL 订单精度问题
- 修复 SELL 订单的 `makerAmount``takerAmount` 精度问题:
- `makerAmount` (shares) 最多 2 位小数(符合 API 要求)
- `takerAmount` (USDC) 最多 4 位小数(符合 API 要求)
- 确保订单能够正常提交到 Polymarket API
## 📚 文档更新
- 添加 Docker 版本更新说明(中英文)
- 添加链上 WebSocket 监听策略文档
- 添加跟单逻辑总结文档
- 更新部署文档,包含详细的版本更新步骤
## 🔧 技术改进
- 使用 Gson 替换 ObjectMapper,提高 JSON 解析稳定性
- `JsonRpcResponse.result` 使用 `JsonElement` 类型,支持灵活的 JSON 结构
- 优化 WebSocket 连接管理,减少不必要的连接
- 添加线程安全机制,使用 Kotlin Coroutines Mutex
- 启用 Spring 定时任务功能(`@EnableScheduling`
## 📦 数据库变更
- 新增 `price_updated` 字段到 `sell_match_record` 表(Migration: V13
## 🔗 相关链接
- **GitHub Release**: https://github.com/WrBug/PolyHermes/releases/tag/v1.1.0
- **完整更新日志**: https://github.com/WrBug/PolyHermes/compare/v1.0.3...v1.1.0
- **Docker Hub**: https://hub.docker.com/r/wrbug/polyhermes
## ⚠️ 重要提醒
**请务必使用官方 Docker 镜像源,避免财产损失!**
### ✅ 官方 Docker Hub 镜像
**官方镜像地址**`wrbug/polyhermes`
```bash
# ✅ 正确:使用官方镜像
docker pull wrbug/polyhermes:v1.1.0
# ❌ 错误:不要使用其他来源的镜像
# 任何非官方来源的镜像都可能包含恶意代码,导致您的私钥和资产被盗
```
### 🔗 官方渠道
请通过以下**唯一官方渠道**获取 PolyHermes
* **GitHub 仓库**https://github.com/WrBug/PolyHermes
* **Twitter**@polyhermes
* **Telegram 群组**:加入群组
---
**⭐ 如果这个项目对您有帮助,请给个 Star 支持一下!**
-3
View File
@@ -154,9 +154,6 @@ SPRING_PROFILES_ACTIVE=prod
# 服务器端口
SERVER_PORT=8000
# Polygon RPC
POLYGON_RPC_URL=https://polygon-rpc.com
# JWT 密钥(已自动生成随机值,生产环境建议修改)
JWT_SECRET=${JWT_SECRET}
-1
View File
@@ -14,7 +14,6 @@ services:
- DB_USERNAME=${DB_USERNAME:-root}
- DB_PASSWORD=${DB_PASSWORD:-}
- SERVER_PORT=8000
- POLYGON_RPC_URL=${POLYGON_RPC_URL:-https://polygon-rpc.com}
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
- ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY:-change-me-in-production}
depends_on:
@@ -0,0 +1,102 @@
package com.wrbug.polymarketbot.api
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Query
/**
* NBA Stats API 接口
* Base URL: https://stats.nba.com/stats/
*
* 注意:NBA Stats API 需要设置正确的请求头:
* - User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
* - Referer: https://www.nba.com/
* - Accept: application/json
*/
interface NbaStatsApi {
/**
* 获取赛程和比分
* @param GameDate 比赛日期,格式:YYYY-MM-DD,不传则获取今天的比赛
* @param LeagueID 联盟ID,默认:00 (NBA)
* @param DayOffset 日期偏移,默认:0
* @return ScoreboardResponse
*/
@GET("Scoreboard")
suspend fun getScoreboard(
@Query("GameDate") gameDate: String? = null,
@Query("LeagueID") leagueId: String = "00",
@Query("DayOffset") dayOffset: Int = 0
): Response<ScoreboardResponse>
}
/**
* NBA Stats API Scoreboard 响应
*/
data class ScoreboardResponse(
val resultSets: List<ResultSet>
)
/**
* Result Set
*/
data class ResultSet(
val name: String,
val headers: List<String>,
val rowSet: List<List<Any?>>
)
/**
* Game Header (从 Scoreboard 的 resultSets[0] 获取)
* Headers: ["GAME_DATE_EST", "GAME_SEQUENCE", "GAME_ID", "GAME_STATUS_ID", "GAME_STATUS_TEXT",
* "GAMECODE", "HOME_TEAM_ID", "VISITOR_TEAM_ID", "SEASON", "LIVE_PERIOD",
* "LIVE_PC_TIME", "NATL_TV_BROADCASTER_ABBREV", "LIVE_PERIOD_TIME_BCAST", "WH_STATUS"]
*/
data class GameHeader(
val gameDateEst: String,
val gameSequence: Int,
val gameId: String,
val gameStatusId: Int,
val gameStatusText: String,
val gameCode: String,
val homeTeamId: Int,
val visitorTeamId: Int,
val season: String,
val livePeriod: Int?,
val livePcTime: String?,
val natlTvBroadcasterAbbrev: String?,
val livePeriodTimeBcast: String?,
val whStatus: Int?
)
/**
* Line Score (从 Scoreboard 的 resultSets[1] 获取)
* Headers: ["GAME_DATE_EST", "GAME_SEQUENCE", "GAME_ID", "TEAM_ID", "TEAM_ABBREVIATION",
* "TEAM_NAME", "PTS_QTR1", "PTS_QTR2", "PTS_QTR3", "PTS_QTR4", "PTS_OT1",
* "PTS_OT2", "PTS_OT3", "PTS_OT4", "PTS", "FG_PCT", "FT_PCT", "FG3_PCT",
* "AST", "REB", "TOV"]
*/
data class LineScore(
val gameDateEst: String,
val gameSequence: Int,
val gameId: String,
val teamId: Int,
val teamAbbreviation: String,
val teamName: String,
val ptsQtr1: Int?,
val ptsQtr2: Int?,
val ptsQtr3: Int?,
val ptsQtr4: Int?,
val ptsOt1: Int?,
val ptsOt2: Int?,
val ptsOt3: Int?,
val ptsOt4: Int?,
val pts: Int,
val fgPct: Double?,
val ftPct: Double?,
val fg3Pct: Double?,
val ast: Int?,
val reb: Int?,
val tov: Int?
)
@@ -13,19 +13,56 @@ import retrofit2.http.Query
interface PolymarketGammaApi {
/**
* 根据 condition ID 列表获取市场信息
* 获取体育元数据信息
* 文档: https://docs.polymarket.com/api-reference/sports/get-sports-metadata-information
* @return 体育元数据数组
*/
@GET("/sports")
suspend fun getSports(): Response<List<SportsMetadataResponse>>
/**
* 根据条件获取市场信息
* 文档: https://docs.polymarket.com/api-reference/markets/list-markets
* @param conditionIds condition ID 数组(16 进制字符串,如 "0x..."
* @param clobTokenIds CLOB token ID 数组(用于通过 tokenId 查询市场)
* @param includeTag 是否包含标签信息
* @param tags 标签 ID 数组,用于过滤市场(如 NBA 的 tag ID)
* @param active 是否只返回活跃的市场
* @param closed 是否包含已关闭的市场
* @param archived 是否包含已归档的市场
* @param limit 返回的市场数量限制
* @param startDateMin 最小开始日期(ISO 8601 格式,UTC 时区,如 "2025-12-01T00:00:00Z"
* @param sportsMarketTypes 体育市场类型数组(如 ["moneyline"] 用于筛选 moneyline 类型)
* @return 市场信息数组
*/
@GET("/markets")
suspend fun listMarkets(
@Query("condition_ids") conditionIds: List<String>? = null,
@Query("include_tag") includeTag: Boolean? = null
@Query("clob_token_ids") clobTokenIds: List<String>? = null,
@Query("include_tag") includeTag: Boolean? = null,
@Query("tags") tags: List<String>? = null,
@Query("active") active: Boolean? = null,
@Query("closed") closed: Boolean? = null,
@Query("archived") archived: Boolean? = null,
@Query("limit") limit: Int? = null,
@Query("start_date_min") startDateMin: String? = null,
@Query("sports_market_types") sportsMarketTypes: List<String>? = null,
): Response<List<MarketResponse>>
}
/**
* 体育元数据响应
* 文档: https://docs.polymarket.com/api-reference/sports/get-sports-metadata-information
*/
data class SportsMetadataResponse(
val sport: String? = null, // 体育标识符或缩写(如 "NBA"
val image: String? = null, // 体育 logo 或图片 URL
val resolution: String? = null, // 官方决议源 URL
val ordering: String? = null, // 显示顺序(通常是 "home" 或 "away"
val tags: String? = null, // 逗号分隔的标签 ID 列表
val series: String? = null // 系列标识符
)
/**
* 市场响应(根据 Gamma API 文档)
*/
@@ -51,6 +88,23 @@ data class MarketResponse(
val liquidityNum: Double? = null,
val lastTradePrice: Double? = null,
val bestBid: Double? = null,
val bestAsk: Double? = null
val bestAsk: Double? = null,
// 以下字段可能存在于响应中,但不在标准文档中
val clobTokenIds: String? = null, // CLOB token IDs(可能是 JSON 字符串或数组)
val clob_token_ids: String? = null // 下划线格式(兼容不同 API 版本)
val tags: List<MarketTag>? = null, // 市场标签列表
val sportsMarketType: String? = null, // 市场类型:moneyline, spread 等
val gameStartTime: String? = null, // 比赛开始时间(格式:2025-12-13 00:00:00+00
val createdAt: String? = null, // 市场创建时间(ISO 8601 格式)
val resolutionSource: String? = null // 决议源 URL(如 "https://www.nba.com/"
)
/**
* 市场标签
*/
data class MarketTag(
val id: String? = null,
val label: String? = null,
val slug: String? = null
)
@@ -0,0 +1,26 @@
package com.wrbug.polymarketbot.config
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
/**
* Gson 配置类
* 统一配置 Gson 实例,使用 lenient 模式允许解析格式不严格的 JSON
*/
@Configuration
class GsonConfig {
/**
* 创建 Gson Bean
* 使用 lenient 模式,允许解析格式不严格的 JSON
*/
@Bean
fun gson(): Gson {
return GsonBuilder()
.setLenient()
.create()
}
}
@@ -36,20 +36,19 @@ class JwtAuthenticationInterceptor(
handler: Any
): Boolean {
val path = request.requestURI
val method = request.method
// 只拦截POST请求
if (method != "POST") {
// 只拦截 /api/** 路径
if (!path.startsWith("/api/")) {
return true
}
// 排除不需要鉴权的路径
if (excludePaths.contains(path)) {
return true
}
// 只拦截 /api/** 路径
if (!path.startsWith("/api/")) {
// 允许 OPTIONS 请求(CORS 预检请求)
if (request.method == "OPTIONS") {
return true
}
@@ -1,5 +1,6 @@
package com.wrbug.polymarketbot.config
import com.google.gson.Gson
import com.wrbug.polymarketbot.api.PolymarketClobApi
import com.wrbug.polymarketbot.util.createClient
import org.springframework.beans.factory.annotation.Value
@@ -18,7 +19,9 @@ import retrofit2.converter.gson.GsonConverterFactory
* - 账户 API Key 在调用时动态设置,不在此处配置
*/
@Configuration
class RetrofitConfig {
class RetrofitConfig(
private val gson: Gson
) {
@Value("\${polymarket.clob.base-url}")
private lateinit var clobBaseUrl: String
@@ -37,7 +40,7 @@ class RetrofitConfig {
return Retrofit.Builder()
.baseUrl(clobBaseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(PolymarketClobApi::class.java)
}
@@ -21,10 +21,6 @@ class WebMvcConfig(
// 再注册JWT认证拦截器
registry.addInterceptor(jwtAuthenticationInterceptor)
.addPathPatterns("/api/**")
registry.addInterceptor(jwtAuthenticationInterceptor)
.addPathPatterns("/api/**")
registry.addInterceptor(jwtAuthenticationInterceptor)
.addPathPatterns("/api/**")
}
}
@@ -1,6 +1,7 @@
package com.wrbug.polymarketbot.config
import com.wrbug.polymarketbot.repository.UserRepository
import com.wrbug.polymarketbot.service.auth.WebSocketTicketService
import com.wrbug.polymarketbot.util.JwtUtils
import org.slf4j.LoggerFactory
import org.springframework.http.server.ServerHttpRequest
@@ -11,12 +12,13 @@ import org.springframework.web.socket.server.HandshakeInterceptor
/**
* WebSocket 握手拦截器
* 用于验证 JWT token
* 优先使用短期票据验证,其次使用 JWT token
*/
@Component
class WebSocketAuthInterceptor(
private val jwtUtils: JwtUtils,
private val userRepository: UserRepository
private val userRepository: UserRepository,
private val webSocketTicketService: WebSocketTicketService
) : HandshakeInterceptor {
private val logger = LoggerFactory.getLogger(WebSocketAuthInterceptor::class.java)
@@ -27,22 +29,36 @@ class WebSocketAuthInterceptor(
wsHandler: WebSocketHandler,
attributes: MutableMap<String, Any>
): Boolean {
// 从查询参数或请求头获取 token
val token = getTokenFromRequest(request)
if (token == null) {
logger.warn("WebSocket 连接缺少认证令牌: ${request.uri}")
// 优先使用票据验证(推荐方式,不暴露 JWT)
val ticket = getTicketFromRequest(request)
if (ticket != null) {
val username = webSocketTicketService.validateAndConsumeTicket(ticket)
if (username != null) {
attributes["username"] = username
logger.debug("WebSocket 连接票据认证成功: username=$username")
return true
}
logger.warn("WebSocket 连接票据验证失败(可能已过期或已使用)")
response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED)
return false
}
// 兼容旧方式:使用 JWT token(不推荐,但保持向后兼容)
val token = getTokenFromRequest(request)
if (token == null) {
logger.warn("WebSocket 连接缺少认证令牌: ${request.uri.path}")
response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED)
return false
}
// 验证 token
if (!jwtUtils.validateToken(token)) {
logger.warn("WebSocket 连接 token 验证失败: ${request.uri}")
logger.warn("WebSocket 连接 token 验证失败")
response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED)
return false
}
// 验证tokenVersion(检查token是否因密码修改而失效)
val username = jwtUtils.getUsernameFromToken(token)
if (username != null) {
@@ -50,21 +66,21 @@ class WebSocketAuthInterceptor(
if (user != null) {
val tokenVersion = jwtUtils.getTokenVersionFromToken(token)
if (tokenVersion == null || tokenVersion != user.tokenVersion) {
logger.warn("WebSocket 连接 token 版本不匹配,token已失效: username=$username, tokenVersion=$tokenVersion, userTokenVersion=${user.tokenVersion}, uri=${request.uri}")
logger.warn("WebSocket 连接 token 版本不匹配,token已失效: username=$username")
response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED)
return false
}
}
// 获取用户名并存入 attributes,供后续使用
attributes["username"] = username
logger.debug("WebSocket 连接认证成功: username=$username, uri=${request.uri}")
logger.debug("WebSocket 连接 JWT 认证成功: username=$username")
} else {
logger.warn("WebSocket 连接无法获取用户名: ${request.uri}")
logger.warn("WebSocket 连接无法获取用户名")
response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED)
return false
}
return true
}
@@ -78,7 +94,22 @@ class WebSocketAuthInterceptor(
}
/**
* 从请求中获取 token
* 从请求中获取票据
*/
private fun getTicketFromRequest(request: ServerHttpRequest): String? {
val queryParams = request.uri.query ?: return null
val params = queryParams.split("&")
for (param in params) {
val parts = param.split("=", limit = 2)
if (parts.size == 2 && parts[0] == "ticket") {
return parts[1]
}
}
return null
}
/**
* 从请求中获取 token(兼容旧方式)
* 支持从查询参数 token 或请求头 Authorization 获取
*/
private fun getTokenFromRequest(request: ServerHttpRequest): String? {
@@ -93,13 +124,13 @@ class WebSocketAuthInterceptor(
}
}
}
// 从请求头获取
val authHeader = request.headers.getFirst("Authorization")
if (authHeader != null && authHeader.startsWith("Bearer ")) {
return authHeader.substring(7)
}
return null
}
}
@@ -2,6 +2,7 @@ package com.wrbug.polymarketbot.config
import com.wrbug.polymarketbot.websocket.PolymarketWebSocketHandler
import com.wrbug.polymarketbot.websocket.UnifiedWebSocketHandler
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Configuration
import org.springframework.web.socket.config.annotation.EnableWebSocket
import org.springframework.web.socket.config.annotation.WebSocketConfigurer
@@ -16,21 +17,46 @@ import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry
class WebSocketConfig(
private val polymarketWebSocketHandler: PolymarketWebSocketHandler,
private val unifiedWebSocketHandler: UnifiedWebSocketHandler,
private val webSocketAuthInterceptor: WebSocketAuthInterceptor
private val webSocketAuthInterceptor: WebSocketAuthInterceptor,
@Value("\${websocket.allowed-origins:}") private val allowedOriginsConfig: String
) : WebSocketConfigurer {
/**
* 获取允许的 WebSocket 来源
* 如果配置了 WEBSOCKET_ALLOWED_ORIGINS 环境变量,使用配置的域名
* 否则使用 setAllowedOriginPatterns 允许同源访问
*/
private fun getAllowedOrigins(): Array<String> {
return if (allowedOriginsConfig.isNotBlank()) {
allowedOriginsConfig.split(",").map { it.trim() }.toTypedArray()
} else {
emptyArray()
}
}
override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) {
val origins = getAllowedOrigins()
// Polymarket RTDS 转发端点(转发外部 Polymarket 实时数据流)
// 注意:此端点不需要鉴权,因为它只是转发外部数据
registry.addHandler(polymarketWebSocketHandler, "/ws/polymarket")
.setAllowedOrigins("*") // 生产环境应该配置具体的域名
val polymarketHandler = registry.addHandler(polymarketWebSocketHandler, "/ws/polymarket")
if (origins.isNotEmpty()) {
polymarketHandler.setAllowedOrigins(*origins)
} else {
// 使用 setAllowedOriginPatterns 替代 setAllowedOrigins("*"),更安全
polymarketHandler.setAllowedOriginPatterns("*")
}
// 统一 WebSocket 端点(所有推送服务统一使用此路径,通过 channel 区分)
// 支持的频道:position(仓位推送)、order(订单推送,待实现)等
// 支持的频道:position(仓位推送)、order(订单推送)等
// 需要 JWT 鉴权
registry.addHandler(unifiedWebSocketHandler, "/ws")
.addInterceptors(webSocketAuthInterceptor) // 添加鉴权拦截器
.setAllowedOrigins("*") // 生产环境应该配置具体的域名
val unifiedHandler = registry.addHandler(unifiedWebSocketHandler, "/ws")
.addInterceptors(webSocketAuthInterceptor)
if (origins.isNotEmpty()) {
unifiedHandler.setAllowedOrigins(*origins)
} else {
unifiedHandler.setAllowedOriginPatterns("*")
}
}
}
@@ -95,6 +95,65 @@ class AccountController(
}
}
/**
* 刷新账户的代理地址
* 使用最新的代理地址计算逻辑(支持 Magic 和 Safe 两种类型)
*/
@PostMapping("/refresh-proxy")
fun refreshProxyAddress(@RequestBody request: AccountDetailRequest): ResponseEntity<ApiResponse<AccountDto>> {
return try {
if (request.accountId == null || request.accountId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ACCOUNT_ID_INVALID, messageSource = messageSource))
}
val result = accountService.refreshProxyAddress(request.accountId)
result.fold(
onSuccess = { account ->
ResponseEntity.ok(ApiResponse.success(account))
},
onFailure = { e ->
logger.error("刷新代理地址失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> ResponseEntity.ok(
ApiResponse.error(
ErrorCode.PARAM_ERROR,
e.message,
messageSource
)
)
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
)
} catch (e: Exception) {
logger.error("刷新代理地址异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
/**
* 批量刷新所有账户的代理地址
*/
@PostMapping("/refresh-all-proxies")
fun refreshAllProxyAddresses(): ResponseEntity<ApiResponse<List<AccountDto>>> {
return try {
val result = accountService.refreshAllProxyAddresses()
result.fold(
onSuccess = { accounts ->
ResponseEntity.ok(ApiResponse.success(accounts))
},
onFailure = { e ->
logger.error("批量刷新代理地址失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("批量刷新代理地址异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
/**
* 删除账户
*/
@@ -3,6 +3,7 @@ package com.wrbug.polymarketbot.controller.auth
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.auth.AuthService
import com.wrbug.polymarketbot.service.auth.WebSocketTicketService
import jakarta.servlet.http.HttpServletRequest
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
@@ -16,7 +17,8 @@ import org.springframework.web.bind.annotation.*
@RequestMapping("/api/auth")
class AuthController(
private val authService: AuthService,
private val messageSource: MessageSource
private val messageSource: MessageSource,
private val webSocketTicketService: WebSocketTicketService
) {
private val logger = LoggerFactory.getLogger(AuthController::class.java)
@@ -25,7 +27,10 @@ class AuthController(
* 登录接口
*/
@PostMapping("/login")
fun login(@RequestBody request: LoginRequest): ResponseEntity<ApiResponse<LoginResponse>> {
fun login(
@RequestBody request: LoginRequest,
httpRequest: HttpServletRequest
): ResponseEntity<ApiResponse<LoginResponse>> {
return try {
if (request.username.isBlank()) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, "用户名不能为空", messageSource))
@@ -33,15 +38,19 @@ class AuthController(
if (request.password.isBlank()) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, "密码不能为空", messageSource))
}
val result = authService.login(request.username, request.password)
val ipAddress = getClientIpAddress(httpRequest)
val result = authService.login(request.username, request.password, ipAddress)
result.fold(
onSuccess = { loginResponse ->
ResponseEntity.ok(ApiResponse.success(loginResponse))
},
onFailure = { e ->
logger.error("登录失败: ${e.message}", e)
when (e) {
is IllegalStateException -> {
// 限速或锁定错误
ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, e.message ?: "登录失败", messageSource))
}
is IllegalArgumentException -> {
if (e.message == ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR.message) {
ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR, messageSource = messageSource))
@@ -49,15 +58,36 @@ class AuthController(
ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, e.message, messageSource))
}
}
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, "登录失败: ${e.message}", messageSource))
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, "登录失败", messageSource))
}
}
)
} catch (e: Exception) {
logger.error("登录异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, "登录失败: ${e.message}", messageSource))
ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, "登录失败", messageSource))
}
}
/**
* 获取客户端IP地址
*/
private fun getClientIpAddress(request: HttpServletRequest): String {
var ip = request.getHeader("X-Forwarded-For")
if (ip.isNullOrBlank() || "unknown".equals(ip, ignoreCase = true)) {
ip = request.getHeader("X-Real-IP")
}
if (ip.isNullOrBlank() || "unknown".equals(ip, ignoreCase = true)) {
ip = request.getHeader("Proxy-Client-IP")
}
if (ip.isNullOrBlank() || "unknown".equals(ip, ignoreCase = true)) {
ip = request.remoteAddr
}
// 处理多个IP的情况
if (ip.contains(",")) {
ip = ip.split(",")[0].trim()
}
return ip
}
/**
* 重置密码接口
@@ -132,5 +162,27 @@ class AuthController(
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "检查首次使用失败: ${e.message}", messageSource))
}
}
/**
* 获取 WebSocket 连接票据
* 返回一个短期有效(30秒)的一次性票据,用于 WebSocket 连接认证
* 避免在 WebSocket URL 中暴露 JWT
*/
@PostMapping("/ws-ticket")
fun getWebSocketTicket(httpRequest: HttpServletRequest): ResponseEntity<ApiResponse<WebSocketTicketResponse>> {
return try {
// 从请求属性中获取用户名(由 JWT 拦截器设置)
val username = httpRequest.getAttribute("username") as? String
if (username == null) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, "未认证", messageSource))
}
val ticket = webSocketTicketService.generateTicket(username)
ResponseEntity.ok(ApiResponse.success(WebSocketTicketResponse(ticket = ticket)))
} catch (e: Exception) {
logger.error("获取 WebSocket 票据异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "获取票据失败", messageSource))
}
}
}
@@ -4,8 +4,10 @@ import com.wrbug.polymarketbot.api.LatestPriceResponse
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.accounts.AccountService
import com.wrbug.polymarketbot.service.common.MarketPriceService
import com.wrbug.polymarketbot.service.common.PolymarketClobService
import kotlinx.coroutines.runBlocking
import java.math.BigDecimal
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
import org.springframework.http.ResponseEntity
@@ -20,14 +22,16 @@ import org.springframework.web.bind.annotation.*
class MarketController(
private val accountService: AccountService,
private val clobService: PolymarketClobService,
private val marketPriceService: MarketPriceService,
private val messageSource: MessageSource
) {
private val logger = LoggerFactory.getLogger(MarketController::class.java)
/**
* 获取市场价格(通过 Gamma API
* 使用 Gamma API 获取价格信息,因为 Gamma API 支持 condition_ids 参数
* 获取市场价格
* 使用 MarketPriceService 获取当前市场价格(支持多数据源降级)
* 返回当前价格,前端接收后自行填充到 bestBid 字段
*/
@PostMapping("/price")
fun getMarketPrice(@RequestBody request: MarketPriceRequest): ResponseEntity<ApiResponse<MarketPriceResponse>> {
@@ -36,16 +40,16 @@ class MarketController(
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_MARKET_ID_EMPTY, messageSource = messageSource))
}
val result = runBlocking { accountService.getMarketPrice(request.marketId, request.outcomeIndex) }
result.fold(
onSuccess = { response ->
ResponseEntity.ok(ApiResponse.success(response))
},
onFailure = { e ->
logger.error("获取市场价格失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_MARKET_PRICE_FETCH_FAILED, e.message, messageSource))
}
val outcomeIndex = request.outcomeIndex ?: 0
val price = runBlocking {
marketPriceService.getCurrentMarketPrice(request.marketId, outcomeIndex)
}
val response = MarketPriceResponse(
marketId = request.marketId,
currentPrice = price.toString()
)
ResponseEntity.ok(ApiResponse.success(response))
} catch (e: Exception) {
logger.error("获取市场价格异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_MARKET_PRICE_FETCH_FAILED, e.message, messageSource))
@@ -0,0 +1,69 @@
package com.wrbug.polymarketbot.controller.nba
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.nba.NbaGameService
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
/**
* NBA 比赛控制器
*/
@RestController
@RequestMapping("/api/nba/games")
class NbaGameController(
private val nbaGameService: NbaGameService,
private val messageSource: MessageSource
) {
private val logger = LoggerFactory.getLogger(NbaGameController::class.java)
/**
* 获取 NBA 比赛列表
*/
@PostMapping("/list")
fun getNbaGames(@RequestBody request: NbaGameListRequest): ResponseEntity<ApiResponse<NbaGameListResponse>> {
return try {
val result = runBlocking {
nbaGameService.getNbaGames(request)
}
result.fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("获取 NBA 比赛列表失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("获取 NBA 比赛列表异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
/**
* 获取 7 天内的所有球队(用于策略配置)
*/
@PostMapping("/teams")
fun getTeamsInNext7Days(): ResponseEntity<ApiResponse<List<String>>> {
return try {
val result = runBlocking {
nbaGameService.getTeamsInNext7Days()
}
result.fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("获取球队列表失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("获取球队列表异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
}
@@ -0,0 +1,70 @@
package com.wrbug.polymarketbot.controller.nba
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.nba.NbaMarketService
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
/**
* NBA 市场控制器
*/
@RestController
@RequestMapping("/api/nba/markets")
class NbaMarketController(
private val nbaMarketService: NbaMarketService,
private val messageSource: MessageSource
) {
private val logger = LoggerFactory.getLogger(NbaMarketController::class.java)
/**
* 获取 NBA 市场列表
*/
@PostMapping("/list")
fun getNbaMarkets(@RequestBody request: NbaMarketListRequest): ResponseEntity<ApiResponse<NbaMarketListResponse>> {
return try {
val result = runBlocking {
nbaMarketService.getNbaMarkets(request)
}
result.fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("获取 NBA 市场列表失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("获取 NBA 市场列表异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
/**
* 从 NBA 市场中获取球队列表(用于策略配置)
* 从市场名称中解析出所有唯一的球队名称
*/
@PostMapping("/teams")
fun getTeamsFromMarkets(): ResponseEntity<ApiResponse<List<String>>> {
return try {
val result = runBlocking {
nbaMarketService.getTeamsFromMarkets(active = true)
}
result.fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("获取球队列表失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("获取球队列表异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
}
@@ -0,0 +1,166 @@
package com.wrbug.polymarketbot.controller.nba
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.nba.NbaQuantitativeStrategyService
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
/**
* NBA 量化策略控制器
*/
@RestController
@RequestMapping("/api/nba/strategies")
class NbaQuantitativeStrategyController(
private val strategyService: NbaQuantitativeStrategyService,
private val messageSource: MessageSource
) {
private val logger = LoggerFactory.getLogger(NbaQuantitativeStrategyController::class.java)
/**
* 创建策略
*/
@PostMapping("/create")
fun createStrategy(@RequestBody request: NbaQuantitativeStrategyCreateRequest): ResponseEntity<ApiResponse<NbaQuantitativeStrategyDto>> {
return try {
if (request.strategyName.isBlank()) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, customMsg = "策略名称不能为空", messageSource = messageSource))
}
if (request.accountId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, customMsg = "账户ID无效", messageSource = messageSource))
}
val result = runBlocking { strategyService.createStrategy(request) }
result.fold(
onSuccess = { strategy ->
ResponseEntity.ok(ApiResponse.success(strategy))
},
onFailure = { e ->
logger.error("创建策略失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("创建策略异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
/**
* 更新策略
*/
@PostMapping("/update")
fun updateStrategy(@RequestBody request: NbaQuantitativeStrategyUpdateRequest): ResponseEntity<ApiResponse<NbaQuantitativeStrategyDto>> {
return try {
if (request.id <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, customMsg = "策略ID无效", messageSource = messageSource))
}
val result = runBlocking { strategyService.updateStrategy(request) }
result.fold(
onSuccess = { strategy ->
ResponseEntity.ok(ApiResponse.success(strategy))
},
onFailure = { e ->
logger.error("更新策略失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("更新策略异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
/**
* 获取策略列表
*/
@PostMapping("/list")
fun getStrategyList(@RequestBody request: NbaQuantitativeStrategyListRequest): ResponseEntity<ApiResponse<NbaQuantitativeStrategyListResponse>> {
return try {
val result = runBlocking { strategyService.getStrategyList(request) }
result.fold(
onSuccess = { response ->
ResponseEntity.ok(ApiResponse.success(response))
},
onFailure = { e ->
logger.error("获取策略列表失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("获取策略列表异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
/**
* 获取策略详情
*/
@PostMapping("/detail")
fun getStrategyDetail(@RequestBody request: NbaQuantitativeStrategyDetailRequest): ResponseEntity<ApiResponse<NbaQuantitativeStrategyDto>> {
return try {
if (request.id == null || request.id <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, customMsg = "策略ID不能为空", messageSource = messageSource))
}
val result = runBlocking { strategyService.getStrategyDetail(request.id) }
result.fold(
onSuccess = { strategy ->
ResponseEntity.ok(ApiResponse.success(strategy))
},
onFailure = { e ->
logger.error("获取策略详情失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("获取策略详情异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
/**
* 删除策略
*/
@PostMapping("/delete")
fun deleteStrategy(@RequestBody request: NbaQuantitativeStrategyDeleteRequest): ResponseEntity<ApiResponse<Unit>> {
return try {
if (request.id == null || request.id <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, customMsg = "策略ID不能为空", messageSource = messageSource))
}
val result = runBlocking { strategyService.deleteStrategy(request.id) }
result.fold(
onSuccess = {
ResponseEntity.ok(ApiResponse.success(Unit))
},
onFailure = { e ->
logger.error("删除策略失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("删除策略异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
}
/**
* 策略详情请求
*/
data class NbaQuantitativeStrategyDetailRequest(
val id: Long?
)
/**
* 策略删除请求
*/
data class NbaQuantitativeStrategyDeleteRequest(
val id: Long?
)
@@ -7,7 +7,8 @@ data class AccountImportRequest(
val privateKey: String, // 私钥(前端加密后传输)
val walletAddress: String, // 钱包地址(前端从私钥推导,用于验证)
val accountName: String? = null,
val isEnabled: Boolean = true // 是否启用(用于订单推送等功能的开关)
val isEnabled: Boolean = true, // 是否启用(用于订单推送等功能的开关)
val walletType: String = "magic" // 钱包类型:magic(邮箱/OAuth登录)或 safeMetaMask浏览器钱包)
)
/**
@@ -72,6 +73,7 @@ data class AccountDto(
val proxyAddress: String, // Polymarket 代理钱包地址
val accountName: String?,
val isEnabled: Boolean, // 是否启用(用于订单推送等功能的开关)
val walletType: String = "magic", // 钱包类型:magic(邮箱/OAuth登录)或 safeMetaMask浏览器钱包)
val apiKeyConfigured: Boolean, // API Key 是否已配置(不返回实际 Key)
val apiSecretConfigured: Boolean, // API Secret 是否已配置
val apiPassphraseConfigured: Boolean, // API Passphrase 是否已配置
@@ -195,14 +197,11 @@ data class LatestPriceRequest(
)
/**
* 市场价格响应
* 市场当前价格响应
*/
data class MarketPriceResponse(
val marketId: String,
val lastPrice: String?, // 最新成交价
val bestBid: String?, // 最优买价(用于卖出参考)
val bestAsk: String?, // 最优卖价(用于买入参考)
val midpoint: String? // 中间价
val currentPrice: String // 当前价格(通过 MarketPriceService 获取,支持多数据源降级)
)
/**
@@ -14,3 +14,10 @@ data class CheckFirstUseResponse(
val isFirstUse: Boolean
)
/**
* WebSocket 票据响应
*/
data class WebSocketTicketResponse(
val ticket: String
)
@@ -0,0 +1,40 @@
package com.wrbug.polymarketbot.dto
import java.time.LocalDate
/**
* NBA 比赛 DTO
*/
data class NbaGameDto(
val id: Long?,
val nbaGameId: String?,
val homeTeam: String,
val awayTeam: String,
val gameDate: LocalDate,
val gameTime: Long?,
val gameStatus: String,
val homeScore: Int,
val awayScore: Int,
val period: Int,
val timeRemaining: String?,
val polymarketMarketId: String?
)
/**
* NBA 比赛列表响应
*/
data class NbaGameListResponse(
val list: List<NbaGameDto>,
val total: Long
)
/**
* NBA 比赛列表请求
* 前端传递时间戳(毫秒),后端转换为西8区时间
*/
data class NbaGameListRequest(
val startTimestamp: Long? = null, // 开始时间戳(毫秒)
val endTimestamp: Long? = null, // 结束时间戳(毫秒)
val gameStatus: String? = null
)
@@ -0,0 +1,45 @@
package com.wrbug.polymarketbot.dto
/**
* NBA 市场 DTO
*/
data class NbaMarketDto(
val id: String? = null,
val question: String? = null,
val conditionId: String? = null,
val slug: String? = null,
val description: String? = null,
val category: String? = null,
val active: Boolean? = null,
val closed: Boolean? = null,
val archived: Boolean? = null,
val volume: String? = null,
val liquidity: String? = null,
val endDate: String? = null,
val startDate: String? = null,
val outcomes: String? = null,
val outcomePrices: String? = null,
val volumeNum: Double? = null,
val liquidityNum: Double? = null,
val lastTradePrice: Double? = null,
val bestBid: Double? = null,
val bestAsk: Double? = null
)
/**
* NBA 市场列表响应
*/
data class NbaMarketListResponse(
val list: List<NbaMarketDto>,
val total: Long
)
/**
* NBA 市场列表请求
*/
data class NbaMarketListRequest(
val active: Boolean? = true,
val closed: Boolean? = false,
val archived: Boolean? = false
)
@@ -0,0 +1,213 @@
package com.wrbug.polymarketbot.dto
import java.math.BigDecimal
import java.time.LocalDate
/**
* NBA 量化策略 DTO
*/
data class NbaQuantitativeStrategyDto(
val id: Long?,
val strategyName: String,
val strategyDescription: String?,
val accountId: Long,
val accountName: String?,
val enabled: Boolean,
val filterTeams: List<String>?,
val filterDateFrom: LocalDate?,
val filterDateTo: LocalDate?,
val filterGameImportance: String?,
val minWinProbabilityDiff: BigDecimal,
val minWinProbability: BigDecimal?,
val maxWinProbability: BigDecimal?,
val minTradeValue: BigDecimal,
val minRemainingTime: Int?,
val maxRemainingTime: Int?,
val minScoreDiff: Int?,
val maxScoreDiff: Int?,
val buyAmountStrategy: String,
val fixedBuyAmount: BigDecimal?,
val buyRatio: BigDecimal?,
val baseBuyAmount: BigDecimal?,
val buyTiming: String,
val delayBuySeconds: Int,
val buyDirection: String,
val enableSell: Boolean,
val takeProfitThreshold: BigDecimal?,
val stopLossThreshold: BigDecimal?,
val probabilityReversalThreshold: BigDecimal?,
val sellRatio: BigDecimal,
val sellTiming: String,
val delaySellSeconds: Int,
val priceStrategy: String,
val fixedPrice: BigDecimal?,
val priceOffset: BigDecimal,
val maxPosition: BigDecimal,
val minPosition: BigDecimal,
val maxGamePosition: BigDecimal?,
val maxDailyLoss: BigDecimal?,
val maxDailyOrders: Int?,
val maxDailyProfit: BigDecimal?,
val priceTolerance: BigDecimal,
val minProbabilityThreshold: BigDecimal?,
val maxProbabilityThreshold: BigDecimal?,
val baseStrengthWeight: BigDecimal,
val recentFormWeight: BigDecimal,
val lineupIntegrityWeight: BigDecimal,
val starStatusWeight: BigDecimal,
val environmentWeight: BigDecimal,
val matchupAdvantageWeight: BigDecimal,
val scoreDiffWeight: BigDecimal,
val momentumWeight: BigDecimal,
val dataUpdateFrequency: Int,
val analysisFrequency: Int,
val pushFailedOrders: Boolean,
val pushFrequency: String,
val batchPushInterval: Int,
val createdAt: Long,
val updatedAt: Long
)
/**
* NBA 量化策略创建请求
*/
data class NbaQuantitativeStrategyCreateRequest(
val strategyName: String,
val strategyDescription: String? = null,
val accountId: Long,
val enabled: Boolean = true,
val filterTeams: List<String>? = null,
val filterDateFrom: LocalDate? = null,
val filterDateTo: LocalDate? = null,
val filterGameImportance: String? = null,
val minWinProbabilityDiff: BigDecimal? = null,
val minWinProbability: BigDecimal? = null,
val maxWinProbability: BigDecimal? = null,
val minTradeValue: BigDecimal? = null,
val minRemainingTime: Int? = null,
val maxRemainingTime: Int? = null,
val minScoreDiff: Int? = null,
val maxScoreDiff: Int? = null,
val buyAmountStrategy: String? = null,
val fixedBuyAmount: BigDecimal? = null,
val buyRatio: BigDecimal? = null,
val baseBuyAmount: BigDecimal? = null,
val buyTiming: String? = null,
val delayBuySeconds: Int? = null,
val buyDirection: String? = null,
val enableSell: Boolean? = null,
val takeProfitThreshold: BigDecimal? = null,
val stopLossThreshold: BigDecimal? = null,
val probabilityReversalThreshold: BigDecimal? = null,
val sellRatio: BigDecimal? = null,
val sellTiming: String? = null,
val delaySellSeconds: Int? = null,
val priceStrategy: String? = null,
val fixedPrice: BigDecimal? = null,
val priceOffset: BigDecimal? = null,
val maxPosition: BigDecimal? = null,
val minPosition: BigDecimal? = null,
val maxGamePosition: BigDecimal? = null,
val maxDailyLoss: BigDecimal? = null,
val maxDailyOrders: Int? = null,
val maxDailyProfit: BigDecimal? = null,
val priceTolerance: BigDecimal? = null,
val minProbabilityThreshold: BigDecimal? = null,
val maxProbabilityThreshold: BigDecimal? = null,
val baseStrengthWeight: BigDecimal? = null,
val recentFormWeight: BigDecimal? = null,
val lineupIntegrityWeight: BigDecimal? = null,
val starStatusWeight: BigDecimal? = null,
val environmentWeight: BigDecimal? = null,
val matchupAdvantageWeight: BigDecimal? = null,
val scoreDiffWeight: BigDecimal? = null,
val momentumWeight: BigDecimal? = null,
val dataUpdateFrequency: Int? = null,
val analysisFrequency: Int? = null,
val pushFailedOrders: Boolean? = null,
val pushFrequency: String? = null,
val batchPushInterval: Int? = null
)
/**
* NBA 量化策略更新请求
*/
data class NbaQuantitativeStrategyUpdateRequest(
val id: Long,
val strategyName: String? = null,
val strategyDescription: String? = null,
val enabled: Boolean? = null,
val filterTeams: List<String>? = null,
val filterDateFrom: LocalDate? = null,
val filterDateTo: LocalDate? = null,
val filterGameImportance: String? = null,
val minWinProbabilityDiff: BigDecimal? = null,
val minWinProbability: BigDecimal? = null,
val maxWinProbability: BigDecimal? = null,
val minTradeValue: BigDecimal? = null,
val minRemainingTime: Int? = null,
val maxRemainingTime: Int? = null,
val minScoreDiff: Int? = null,
val maxScoreDiff: Int? = null,
val buyAmountStrategy: String? = null,
val fixedBuyAmount: BigDecimal? = null,
val buyRatio: BigDecimal? = null,
val baseBuyAmount: BigDecimal? = null,
val buyTiming: String? = null,
val delayBuySeconds: Int? = null,
val buyDirection: String? = null,
val enableSell: Boolean? = null,
val takeProfitThreshold: BigDecimal? = null,
val stopLossThreshold: BigDecimal? = null,
val probabilityReversalThreshold: BigDecimal? = null,
val sellRatio: BigDecimal? = null,
val sellTiming: String? = null,
val delaySellSeconds: Int? = null,
val priceStrategy: String? = null,
val fixedPrice: BigDecimal? = null,
val priceOffset: BigDecimal? = null,
val maxPosition: BigDecimal? = null,
val minPosition: BigDecimal? = null,
val maxGamePosition: BigDecimal? = null,
val maxDailyLoss: BigDecimal? = null,
val maxDailyOrders: Int? = null,
val maxDailyProfit: BigDecimal? = null,
val priceTolerance: BigDecimal? = null,
val minProbabilityThreshold: BigDecimal? = null,
val maxProbabilityThreshold: BigDecimal? = null,
val baseStrengthWeight: BigDecimal? = null,
val recentFormWeight: BigDecimal? = null,
val lineupIntegrityWeight: BigDecimal? = null,
val starStatusWeight: BigDecimal? = null,
val environmentWeight: BigDecimal? = null,
val matchupAdvantageWeight: BigDecimal? = null,
val scoreDiffWeight: BigDecimal? = null,
val momentumWeight: BigDecimal? = null,
val dataUpdateFrequency: Int? = null,
val analysisFrequency: Int? = null,
val pushFailedOrders: Boolean? = null,
val pushFrequency: String? = null,
val batchPushInterval: Int? = null
)
/**
* NBA 量化策略列表请求
*/
data class NbaQuantitativeStrategyListRequest(
val accountId: Long? = null,
val enabled: Boolean? = null,
val strategyName: String? = null,
val page: Int? = 1,
val limit: Int? = 20
)
/**
* NBA 量化策略列表响应
*/
data class NbaQuantitativeStrategyListResponse(
val list: List<NbaQuantitativeStrategyDto>,
val total: Long,
val page: Int,
val limit: Int
)
@@ -40,6 +40,9 @@ data class Account(
@Column(name = "is_enabled", nullable = false)
val isEnabled: Boolean = true, // 是否启用(用于订单推送等功能的开关)
@Column(name = "wallet_type", nullable = false, length = 20)
val walletType: String = "magic", // 钱包类型:magic(邮箱/OAuth登录)或 safeMetaMask浏览器钱包)
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@@ -1,55 +0,0 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
/**
* 失败交易实体
* 记录处理失败的交易信息
*/
@Entity
@Table(name = "failed_trade")
data class FailedTrade(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "leader_id", nullable = false)
val leaderId: Long,
@Column(name = "leader_trade_id", nullable = false, length = 100)
val leaderTradeId: String, // Leader 的交易ID
@Column(name = "trade_type", nullable = false, length = 10)
val tradeType: String, // BUY 或 SELL
@Column(name = "copy_trading_id", nullable = false)
val copyTradingId: Long,
@Column(name = "account_id", nullable = false)
val accountId: Long,
@Column(name = "market_id", nullable = false, length = 100)
val marketId: String,
@Column(name = "side", nullable = false, length = 10)
val side: String, // YES/NO
@Column(name = "price", nullable = false, length = 50)
val price: String, // 价格(字符串格式)
@Column(name = "size", nullable = false, length = 50)
val size: String, // 数量(字符串格式)
@Column(name = "error_message", columnDefinition = "TEXT")
val errorMessage: String? = null, // 错误信息
@Column(name = "retry_count", nullable = false)
val retryCount: Int = 0, // 重试次数
@Column(name = "failed_at", nullable = false)
val failedAt: Long = System.currentTimeMillis(),
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis()
)
@@ -0,0 +1,55 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
import java.time.LocalDate
/**
* NBA 比赛实体
*/
@Entity
@Table(name = "nba_games")
data class NbaGame(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "nba_game_id", unique = true, length = 100)
val nbaGameId: String? = null,
@Column(name = "home_team", nullable = false, length = 100)
val homeTeam: String,
@Column(name = "away_team", nullable = false, length = 100)
val awayTeam: String,
@Column(name = "game_date", nullable = false)
val gameDate: LocalDate,
@Column(name = "game_time")
val gameTime: Long? = null,
@Column(name = "game_status", length = 50)
val gameStatus: String = "scheduled",
@Column(name = "home_score")
val homeScore: Int = 0,
@Column(name = "away_score")
val awayScore: Int = 0,
@Column(name = "period")
val period: Int = 0,
@Column(name = "time_remaining", length = 50)
val timeRemaining: String? = null,
@Column(name = "polymarket_market_id", length = 100)
val polymarketMarketId: String? = null,
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@Column(name = "updated_at", nullable = false)
var updatedAt: Long = System.currentTimeMillis()
)
@@ -0,0 +1,63 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
/**
* NBA 市场实体(Polymarket 市场信息)
*/
@Entity
@Table(name = "nba_markets")
data class NbaMarket(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "polymarket_market_id", unique = true, nullable = false, length = 100)
val polymarketMarketId: String,
@Column(name = "condition_id", unique = true, nullable = false, length = 100)
val conditionId: String,
@Column(name = "market_slug", length = 255)
val marketSlug: String? = null,
@Column(name = "market_question", columnDefinition = "TEXT")
val marketQuestion: String? = null,
@Column(name = "market_description", columnDefinition = "TEXT")
val marketDescription: String? = null,
@Column(name = "category", length = 50)
val category: String = "sports",
@Column(name = "active")
val active: Boolean = true,
@Column(name = "closed")
val closed: Boolean = false,
@Column(name = "archived")
val archived: Boolean = false,
@Column(name = "volume", length = 50)
val volume: String? = null,
@Column(name = "liquidity", length = 50)
val liquidity: String? = null,
@Column(name = "outcomes", columnDefinition = "TEXT")
val outcomes: String? = null,
@Column(name = "end_date", length = 50)
val endDate: String? = null,
@Column(name = "start_date", length = 50)
val startDate: String? = null,
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@Column(name = "updated_at", nullable = false)
var updatedAt: Long = System.currentTimeMillis()
)
@@ -0,0 +1,196 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
import java.math.BigDecimal
import java.time.LocalDate
/**
* NBA 量化策略配置实体
*/
@Entity
@Table(name = "nba_quantitative_strategies")
data class NbaQuantitativeStrategy(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "strategy_name", nullable = false, length = 100)
val strategyName: String,
@Column(name = "strategy_description", columnDefinition = "TEXT")
val strategyDescription: String? = null,
@Column(name = "account_id", nullable = false)
val accountId: Long,
@Column(name = "enabled")
val enabled: Boolean = true,
// 比赛筛选参数
@Column(name = "filter_teams", columnDefinition = "TEXT")
val filterTeams: String? = null,
@Column(name = "filter_date_from")
val filterDateFrom: LocalDate? = null,
@Column(name = "filter_date_to")
val filterDateTo: LocalDate? = null,
@Column(name = "filter_game_importance", length = 50)
val filterGameImportance: String? = null,
// 触发条件参数
@Column(name = "min_win_probability_diff", precision = 5, scale = 4)
val minWinProbabilityDiff: BigDecimal = BigDecimal("0.1"),
@Column(name = "min_win_probability", precision = 5, scale = 4)
val minWinProbability: BigDecimal? = null,
@Column(name = "max_win_probability", precision = 5, scale = 4)
val maxWinProbability: BigDecimal? = null,
@Column(name = "min_trade_value", precision = 5, scale = 4)
val minTradeValue: BigDecimal = BigDecimal("0.05"),
@Column(name = "min_remaining_time")
val minRemainingTime: Int? = null,
@Column(name = "max_remaining_time")
val maxRemainingTime: Int? = null,
@Column(name = "min_score_diff")
val minScoreDiff: Int? = null,
@Column(name = "max_score_diff")
val maxScoreDiff: Int? = null,
// 买入规则参数
@Column(name = "buy_amount_strategy", length = 20)
val buyAmountStrategy: String = "FIXED",
@Column(name = "fixed_buy_amount", precision = 20, scale = 8)
val fixedBuyAmount: BigDecimal? = null,
@Column(name = "buy_ratio", precision = 5, scale = 4)
val buyRatio: BigDecimal? = null,
@Column(name = "base_buy_amount", precision = 20, scale = 8)
val baseBuyAmount: BigDecimal? = null,
@Column(name = "buy_timing", length = 20)
val buyTiming: String = "IMMEDIATE",
@Column(name = "delay_buy_seconds")
val delayBuySeconds: Int = 0,
@Column(name = "buy_direction", length = 10)
val buyDirection: String = "AUTO",
// 卖出规则参数
@Column(name = "enable_sell")
val enableSell: Boolean = true,
@Column(name = "take_profit_threshold", precision = 5, scale = 4)
val takeProfitThreshold: BigDecimal? = null,
@Column(name = "stop_loss_threshold", precision = 5, scale = 4)
val stopLossThreshold: BigDecimal? = null,
@Column(name = "probability_reversal_threshold", precision = 5, scale = 4)
val probabilityReversalThreshold: BigDecimal? = null,
@Column(name = "sell_ratio", precision = 5, scale = 4)
val sellRatio: BigDecimal = BigDecimal("1.0"),
@Column(name = "sell_timing", length = 20)
val sellTiming: String = "IMMEDIATE",
@Column(name = "delay_sell_seconds")
val delaySellSeconds: Int = 0,
// 价格策略参数
@Column(name = "price_strategy", length = 20)
val priceStrategy: String = "MARKET",
@Column(name = "fixed_price", precision = 5, scale = 4)
val fixedPrice: BigDecimal? = null,
@Column(name = "price_offset", precision = 5, scale = 4)
val priceOffset: BigDecimal = BigDecimal.ZERO,
// 风险控制参数
@Column(name = "max_position", precision = 20, scale = 8)
val maxPosition: BigDecimal = BigDecimal("50"),
@Column(name = "min_position", precision = 20, scale = 8)
val minPosition: BigDecimal = BigDecimal("5"),
@Column(name = "max_game_position", precision = 20, scale = 8)
val maxGamePosition: BigDecimal? = null,
@Column(name = "max_daily_loss", precision = 20, scale = 8)
val maxDailyLoss: BigDecimal? = null,
@Column(name = "max_daily_orders")
val maxDailyOrders: Int? = null,
@Column(name = "max_daily_profit", precision = 20, scale = 8)
val maxDailyProfit: BigDecimal? = null,
@Column(name = "price_tolerance", precision = 5, scale = 4)
val priceTolerance: BigDecimal = BigDecimal("0.05"),
@Column(name = "min_probability_threshold", precision = 5, scale = 4)
val minProbabilityThreshold: BigDecimal? = null,
@Column(name = "max_probability_threshold", precision = 5, scale = 4)
val maxProbabilityThreshold: BigDecimal? = null,
// 算法权重参数
@Column(name = "base_strength_weight", precision = 5, scale = 4)
val baseStrengthWeight: BigDecimal = BigDecimal("0.3"),
@Column(name = "recent_form_weight", precision = 5, scale = 4)
val recentFormWeight: BigDecimal = BigDecimal("0.25"),
@Column(name = "lineup_integrity_weight", precision = 5, scale = 4)
val lineupIntegrityWeight: BigDecimal = BigDecimal("0.2"),
@Column(name = "star_status_weight", precision = 5, scale = 4)
val starStatusWeight: BigDecimal = BigDecimal("0.15"),
@Column(name = "environment_weight", precision = 5, scale = 4)
val environmentWeight: BigDecimal = BigDecimal("0.1"),
@Column(name = "matchup_advantage_weight", precision = 5, scale = 4)
val matchupAdvantageWeight: BigDecimal = BigDecimal("0.2"),
@Column(name = "score_diff_weight", precision = 5, scale = 4)
val scoreDiffWeight: BigDecimal = BigDecimal("0.3"),
@Column(name = "momentum_weight", precision = 5, scale = 4)
val momentumWeight: BigDecimal = BigDecimal("0.2"),
// 系统配置参数
@Column(name = "data_update_frequency")
val dataUpdateFrequency: Int = 30,
@Column(name = "analysis_frequency")
val analysisFrequency: Int = 30,
@Column(name = "push_failed_orders")
val pushFailedOrders: Boolean = false,
@Column(name = "push_frequency", length = 20)
val pushFrequency: String = "REALTIME",
@Column(name = "batch_push_interval")
val batchPushInterval: Int = 1,
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@Column(name = "updated_at", nullable = false)
var updatedAt: Long = System.currentTimeMillis()
)
@@ -0,0 +1,50 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
import java.math.BigDecimal
import java.time.LocalDate
/**
* NBA 策略执行统计实体
*/
@Entity
@Table(name = "nba_strategy_statistics")
data class NbaStrategyStatistics(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "strategy_id", nullable = false)
val strategyId: Long,
@Column(name = "stat_date", nullable = false)
val statDate: LocalDate,
@Column(name = "total_signals")
val totalSignals: Int = 0,
@Column(name = "buy_signals")
val buySignals: Int = 0,
@Column(name = "sell_signals")
val sellSignals: Int = 0,
@Column(name = "success_signals")
val successSignals: Int = 0,
@Column(name = "failed_signals")
val failedSignals: Int = 0,
@Column(name = "total_profit", precision = 20, scale = 8)
val totalProfit: BigDecimal = BigDecimal.ZERO,
@Column(name = "total_volume", precision = 20, scale = 8)
val totalVolume: BigDecimal = BigDecimal.ZERO,
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@Column(name = "updated_at", nullable = false)
var updatedAt: Long = System.currentTimeMillis()
)
@@ -0,0 +1,64 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
import java.math.BigDecimal
/**
* NBA 交易信号实体
*/
@Entity
@Table(name = "nba_trading_signals")
data class NbaTradingSignal(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "strategy_id", nullable = false)
val strategyId: Long,
@Column(name = "game_id")
val gameId: Long? = null,
@Column(name = "market_id")
val marketId: Long? = null,
@Column(name = "signal_type", nullable = false, length = 10)
val signalType: String,
@Column(name = "direction", nullable = false, length = 10)
val direction: String,
@Column(name = "price", nullable = false, precision = 5, scale = 4)
val price: BigDecimal,
@Column(name = "quantity", nullable = false, precision = 20, scale = 8)
val quantity: BigDecimal,
@Column(name = "total_amount", nullable = false, precision = 20, scale = 8)
val totalAmount: BigDecimal,
@Column(name = "reason", columnDefinition = "TEXT")
val reason: String? = null,
@Column(name = "win_probability", precision = 5, scale = 4)
val winProbability: BigDecimal? = null,
@Column(name = "trade_value", precision = 5, scale = 4)
val tradeValue: BigDecimal? = null,
@Column(name = "signal_status", length = 20)
val signalStatus: String = "GENERATED",
@Column(name = "execution_result", columnDefinition = "TEXT")
val executionResult: String? = null,
@Column(name = "error_message", columnDefinition = "TEXT")
val errorMessage: String? = null,
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@Column(name = "updated_at", nullable = false)
var updatedAt: Long = System.currentTimeMillis()
)
@@ -0,0 +1,66 @@
package com.wrbug.polymarketbot.enums
/**
* Polymarket 体育项目 Tag ID 枚举
* 用于标识不同体育项目在 Polymarket 中的 tag ID
*/
enum class SportsTagId(val tagId: String, val displayName: String) {
/**
* 美国职业篮球联赛
*/
NBA("745", "NBA"),
/**
* 美国职业棒球大联盟
*/
MLB("100381", "MLB"),
/**
* 美国国家橄榄球联盟
*/
NFL("450", "NFL"),
/**
* 美国大学橄榄球
*/
CFB("100351", "CFB"),
/**
* 美国国家冰球联盟
*/
NHL("899", "NHL"),
/**
* 游戏/电子竞技
*/
GAMES("100639", "GAMES"),
/**
* 美国大学篮球
*/
CBB("101178", "CBB");
companion object {
/**
* 根据 tag ID 查找枚举
*/
fun fromTagId(tagId: String): SportsTagId? {
return values().find { it.tagId == tagId }
}
/**
* 根据显示名称查找枚举
*/
fun fromDisplayName(displayName: String): SportsTagId? {
return values().find { it.displayName.equals(displayName, ignoreCase = true) }
}
/**
* 获取所有 tag IDs 列表
*/
fun getAllTagIds(): List<String> {
return values().map { it.tagId }
}
}
}
@@ -55,5 +55,11 @@ interface CopyOrderTrackingRepository : JpaRepository<CopyOrderTracking, Long> {
* 查询未发送通知的买入订单(用于轮询更新)
*/
fun findByNotificationSentFalse(): List<CopyOrderTracking>
/**
* 查询指定时间之前创建的订单(用于检查30秒后未成交的订单)
*/
@Query("SELECT t FROM CopyOrderTracking t WHERE t.createdAt <= :beforeTime")
fun findByCreatedAtBefore(beforeTime: Long): List<CopyOrderTracking>
}
@@ -1,23 +0,0 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.FailedTrade
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
/**
* 失败交易Repository
*/
@Repository
interface FailedTradeRepository : JpaRepository<FailedTrade, Long> {
/**
* 根据Leader ID和交易ID查询
*/
fun findByLeaderIdAndLeaderTradeId(leaderId: Long, leaderTradeId: String): FailedTrade?
/**
* 检查是否存在失败的交易
*/
fun existsByLeaderIdAndLeaderTradeId(leaderId: Long, leaderTradeId: String): Boolean
}
@@ -0,0 +1,27 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.NbaGame
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
import java.time.LocalDate
@Repository
interface NbaGameRepository : JpaRepository<NbaGame, Long> {
fun findByNbaGameId(nbaGameId: String): NbaGame?
fun findByGameDate(gameDate: LocalDate): List<NbaGame>
fun findByGameDateBetween(startDate: LocalDate, endDate: LocalDate): List<NbaGame>
fun findByGameStatus(gameStatus: String): List<NbaGame>
fun findByHomeTeamAndAwayTeamAndGameDate(homeTeam: String, awayTeam: String, gameDate: LocalDate): NbaGame?
fun findByPolymarketMarketId(polymarketMarketId: String): NbaGame?
/**
* 查询最新的比赛(按创建时间倒序)
*/
fun findFirstByOrderByCreatedAtDesc(): NbaGame?
/**
* 根据创建时间查询比赛
*/
fun findByCreatedAtGreaterThan(createdAt: Long): List<NbaGame>
}
@@ -0,0 +1,15 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.NbaMarket
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface NbaMarketRepository : JpaRepository<NbaMarket, Long> {
fun findByConditionId(conditionId: String): NbaMarket?
fun findByPolymarketMarketId(polymarketMarketId: String): NbaMarket?
fun findByActiveAndClosed(active: Boolean, closed: Boolean): List<NbaMarket>
fun findByCategory(category: String): List<NbaMarket>
fun findByActive(active: Boolean): List<NbaMarket>
}
@@ -0,0 +1,14 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.NbaQuantitativeStrategy
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface NbaQuantitativeStrategyRepository : JpaRepository<NbaQuantitativeStrategy, Long> {
fun findByAccountId(accountId: Long): List<NbaQuantitativeStrategy>
fun findByAccountIdAndEnabled(accountId: Long, enabled: Boolean): List<NbaQuantitativeStrategy>
fun findByEnabled(enabled: Boolean): List<NbaQuantitativeStrategy>
fun findByStrategyName(strategyName: String): NbaQuantitativeStrategy?
}
@@ -0,0 +1,14 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.NbaStrategyStatistics
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
import java.time.LocalDate
@Repository
interface NbaStrategyStatisticsRepository : JpaRepository<NbaStrategyStatistics, Long> {
fun findByStrategyId(strategyId: Long): List<NbaStrategyStatistics>
fun findByStrategyIdAndStatDate(strategyId: Long, statDate: LocalDate): NbaStrategyStatistics?
fun findByStrategyIdAndStatDateBetween(strategyId: Long, startDate: LocalDate, endDate: LocalDate): List<NbaStrategyStatistics>
}
@@ -0,0 +1,18 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.NbaTradingSignal
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
import java.time.Instant
@Repository
interface NbaTradingSignalRepository : JpaRepository<NbaTradingSignal, Long> {
fun findByStrategyId(strategyId: Long): List<NbaTradingSignal>
fun findByGameId(gameId: Long): List<NbaTradingSignal>
fun findByMarketId(marketId: Long): List<NbaTradingSignal>
fun findBySignalType(signalType: String): List<NbaTradingSignal>
fun findBySignalStatus(signalStatus: String): List<NbaTradingSignal>
fun findByStrategyIdAndSignalType(strategyId: Long, signalType: String): List<NbaTradingSignal>
fun findByStrategyIdAndCreatedAtBetween(strategyId: Long, startTime: Long, endTime: Long): List<NbaTradingSignal>
}
@@ -37,7 +37,8 @@ class AccountService(
private val orderSigningService: OrderSigningService,
private val cryptoUtils: CryptoUtils,
private val telegramNotificationService: TelegramNotificationService? = null, // 可选,避免循环依赖
private val relayClientService: RelayClientService
private val relayClientService: RelayClientService,
private val jsonUtils: JsonUtils
) {
private val logger = LoggerFactory.getLogger(AccountService::class.java)
@@ -98,8 +99,9 @@ class AccountService(
}
// 5. 获取代理地址(必须成功,否则导入失败)
// 根据用户选择的钱包类型计算代理地址
val proxyAddress = runBlocking {
val proxyResult = blockchainService.getProxyAddress(request.walletAddress)
val proxyResult = blockchainService.getProxyAddress(request.walletAddress, request.walletType)
if (proxyResult.isSuccess) {
val address = proxyResult.getOrNull()
if (address != null) {
@@ -149,6 +151,7 @@ class AccountService(
accountName = accountName,
isDefault = false, // 不再支持默认账户
isEnabled = request.isEnabled,
walletType = request.walletType, // 保存钱包类型
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis()
)
@@ -199,6 +202,85 @@ class AccountService(
}
}
/**
* 刷新账户的代理地址
* 使用最新的代理地址计算逻辑(支持 Magic 和 Safe 两种类型)
*/
@Transactional
fun refreshProxyAddress(accountId: Long): Result<AccountDto> {
return try {
val account = accountRepository.findById(accountId)
.orElse(null) ?: return Result.failure(IllegalArgumentException("账户不存在"))
// 重新获取代理地址(使用保存的钱包类型)
val proxyAddress = runBlocking {
val proxyResult = blockchainService.getProxyAddress(account.walletAddress, account.walletType)
if (proxyResult.isSuccess) {
proxyResult.getOrNull()
?: throw IllegalStateException("获取代理地址返回空值")
} else {
val error = proxyResult.exceptionOrNull()
throw IllegalStateException("获取代理地址失败: ${error?.message}")
}
}
// 更新账户
val updated = account.copy(
proxyAddress = proxyAddress,
updatedAt = System.currentTimeMillis()
)
val saved = accountRepository.save(updated)
logger.info("刷新代理地址成功: accountId=${accountId}, oldProxy=${account.proxyAddress}, newProxy=${proxyAddress}")
Result.success(toDto(saved))
} catch (e: Exception) {
logger.error("刷新代理地址失败: accountId=${accountId}", e)
Result.failure(e)
}
}
/**
* 刷新所有账户的代理地址
*/
@Transactional
fun refreshAllProxyAddresses(): Result<List<AccountDto>> {
return try {
val accounts = accountRepository.findAll()
val updatedAccounts = mutableListOf<AccountDto>()
accounts.forEach { account ->
try {
val proxyAddress = runBlocking {
val proxyResult = blockchainService.getProxyAddress(account.walletAddress, account.walletType)
if (proxyResult.isSuccess) {
proxyResult.getOrNull()
} else {
null
}
}
if (proxyAddress != null && proxyAddress != account.proxyAddress) {
val updated = account.copy(
proxyAddress = proxyAddress,
updatedAt = System.currentTimeMillis()
)
val saved = accountRepository.save(updated)
logger.info("刷新代理地址成功: accountId=${account.id}, oldProxy=${account.proxyAddress}, newProxy=${proxyAddress}")
updatedAccounts.add(toDto(saved))
}
} catch (e: Exception) {
logger.warn("刷新账户 ${account.id} 代理地址失败: ${e.message}")
}
}
logger.info("批量刷新代理地址完成: 更新了 ${updatedAccounts.size} 个账户")
Result.success(updatedAccounts)
} catch (e: Exception) {
logger.error("批量刷新代理地址失败", e)
Result.failure(e)
}
}
/**
* 删除账户
*/
@@ -362,6 +444,7 @@ class AccountService(
proxyAddress = account.proxyAddress,
accountName = account.accountName,
isEnabled = account.isEnabled,
walletType = account.walletType,
apiKeyConfigured = account.apiKey != null,
apiSecretConfigured = account.apiSecret != null,
apiPassphraseConfigured = account.apiPassphrase != null,
@@ -1121,7 +1204,7 @@ class AccountService(
// 如果目标 outcome 不是第一个(index != 0),需要转换价格
// 对于二元市场:第二个 outcome 的价格 = 1 - 第一个 outcome 的价格
if (outcomeIndex != null && outcomeIndex > 0) {
val outcomes = JsonUtils.parseStringArray(market.outcomes)
val outcomes = jsonUtils.parseStringArray(market.outcomes)
// 只对二元市场进行价格转换
if (outcomes.size == 2) {
// 保存原始第一个 outcome 的价格
@@ -1153,13 +1236,13 @@ class AccountService(
null
}
// 优先使用 lastPrice(最近成交价),如果没有则使用 bestBid,最后使用 midpoint
val currentPrice = lastPrice ?: bestBid ?: midpoint ?: "0"
Result.success(
MarketPriceResponse(
marketId = marketId,
lastPrice = lastPrice,
bestBid = bestBid,
bestAsk = bestAsk,
midpoint = midpoint
currentPrice = currentPrice
)
)
} else {
@@ -21,8 +21,7 @@ import org.springframework.context.i18n.LocaleContextHolder
import com.wrbug.polymarketbot.service.system.SystemConfigService
import com.wrbug.polymarketbot.service.system.RelayClientService
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.JsonUtils
import com.wrbug.polymarketbot.service.common.MarketPriceService
import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.util.concurrent.ConcurrentHashMap
@@ -45,7 +44,7 @@ class PositionCheckService(
private val telegramNotificationService: TelegramNotificationService?,
private val accountRepository: AccountRepository,
private val messageSource: MessageSource,
private val retrofitFactory: RetrofitFactory
private val marketPriceService: MarketPriceService
) {
private val logger = LoggerFactory.getLogger(PositionCheckService::class.java)
@@ -427,118 +426,10 @@ class PositionCheckService(
/**
* 获取当前市场最新价(用于更新订单卖出价)
* 优先使用 bestBid(最优买价),如果没有则使用 midpoint(中间价)
* 如果市场已关闭:
* - 该 outcome 赢了,返回 1
* - 该 outcome 输了,返回 0
* 委托给 MarketPriceService 处理
*/
private suspend fun getCurrentMarketPrice(marketId: String, outcomeIndex: Int): BigDecimal {
return try {
// 先获取市场信息,检查市场是否已关闭
val gammaApi = retrofitFactory.createGammaApi()
val marketResponse = gammaApi.listMarkets(conditionIds = listOf(marketId))
if (marketResponse.isSuccessful && marketResponse.body() != null) {
val markets = marketResponse.body()!!
val market = markets.firstOrNull()
if (market != null && market.closed == true) {
// 市场已关闭,检查该 outcome 是赢了还是输了
val outcomeResult = checkOutcomeResult(market, outcomeIndex)
when (outcomeResult) {
OutcomeResult.WON -> {
logger.info("市场已关闭且该 outcome 赢了,返回价格为 1: marketId=$marketId, outcomeIndex=$outcomeIndex")
return BigDecimal.ONE
}
OutcomeResult.LOST -> {
logger.info("市场已关闭且该 outcome 输了,返回价格为 0: marketId=$marketId, outcomeIndex=$outcomeIndex")
return BigDecimal.ZERO
}
OutcomeResult.UNKNOWN -> {
// 无法判断,继续使用正常价格逻辑
}
}
}
}
// 如果市场未关闭或无法判断输赢,获取正常价格
val priceResult = accountService.getMarketPrice(marketId, outcomeIndex)
val marketPrice = priceResult.getOrNull()
if (marketPrice != null) {
// 优先使用 bestBid(最优买价,用于卖出参考),如果没有则使用 midpoint
val priceStr = marketPrice.bestBid ?: marketPrice.midpoint ?: marketPrice.lastPrice
priceStr?.toSafeBigDecimal() ?: BigDecimal.ZERO
} else {
BigDecimal.ZERO
}
} catch (e: Exception) {
logger.error("获取市场最新价失败: marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}", e)
BigDecimal.ZERO
}
}
/**
* Outcome 结果枚举
*/
private enum class OutcomeResult {
WON, // 赢了
LOST, // 输了
UNKNOWN // 无法判断
}
/**
* 检查该 outcome 的结果(赢了、输了或无法判断)
* @param market 市场信息
* @param outcomeIndex outcome 索引
* @return OutcomeResult
*/
private fun checkOutcomeResult(market: com.wrbug.polymarketbot.api.MarketResponse, outcomeIndex: Int): OutcomeResult {
return try {
// 优先使用 outcomePrices(结算价格数组)
val outcomePrices = market.outcomePrices
if (outcomePrices != null && outcomePrices.isNotBlank()) {
val prices = JsonUtils.parseStringArray(outcomePrices)
if (outcomeIndex < prices.size) {
val price = prices[outcomeIndex].toSafeBigDecimal()
// 如果价格 >= 0.99,认为赢了
if (price >= BigDecimal("0.99")) {
return OutcomeResult.WON
}
// 如果价格 <= 0.01,认为输了
if (price <= BigDecimal("0.01")) {
return OutcomeResult.LOST
}
// 其他情况,无法判断
return OutcomeResult.UNKNOWN
}
}
// 如果没有 outcomePrices,使用 bestBid 和 bestAsk 判断
val bestBid = market.bestBid ?: 0.0
val bestAsk = market.bestAsk ?: 0.0
// 如果目标 outcome 不是第一个(index != 0),需要转换价格
val targetBid = if (outcomeIndex > 0) {
// 第二个 outcome 的 bestBid = 1 - 第一个 outcome 的 bestAsk
BigDecimal.ONE.subtract(BigDecimal.valueOf(bestAsk))
} else {
BigDecimal.valueOf(bestBid)
}
// 如果 bestBid >= 0.99,认为赢了
if (targetBid >= BigDecimal("0.99")) {
return OutcomeResult.WON
}
// 如果 bestBid <= 0.01,认为输了
if (targetBid <= BigDecimal("0.01")) {
return OutcomeResult.LOST
}
// 其他情况,无法判断
OutcomeResult.UNKNOWN
} catch (e: Exception) {
logger.warn("检查 outcome 结果失败: marketId=${market.conditionId}, outcomeIndex=$outcomeIndex, error=${e.message}", e)
OutcomeResult.UNKNOWN
}
return marketPriceService.getCurrentMarketPrice(marketId, outcomeIndex)
}
@@ -628,7 +519,8 @@ class PositionCheckService(
outcomeIndex = outcomeIndex,
totalMatchedQuantity = totalMatchedQuantity,
sellPrice = sellPrice,
totalRealizedPnl = totalRealizedPnl
totalRealizedPnl = totalRealizedPnl,
priceUpdated = true // 自动生成的订单,直接标记为已处理,不发送通知
)
val savedRecord = sellMatchRecordRepository.save(matchRecord)
@@ -742,7 +634,8 @@ class PositionCheckService(
outcomeIndex = outcomeIndex,
totalMatchedQuantity = totalMatchedQuantity,
sellPrice = sellPrice,
totalRealizedPnl = totalRealizedPnl
totalRealizedPnl = totalRealizedPnl,
priceUpdated = true // 自动生成的订单,直接标记为已处理,不发送通知
)
val savedRecord = sellMatchRecordRepository.save(matchRecord)
@@ -31,22 +31,44 @@ class AuthService(
private lateinit var resetPasswordKey: String
/**
* 登录
* 登录带IP限速保护
*/
fun login(username: String, password: String): Result<LoginResponse> {
fun login(username: String, password: String, ipAddress: String): Result<LoginResponse> {
return try {
// 检查登录频率限制
rateLimitService.checkLoginRateLimit(ipAddress).fold(
onSuccess = { },
onFailure = { e ->
return Result.failure(IllegalStateException(e.message ?: "登录频率限制"))
}
)
val user = userRepository.findByUsername(username)
?: return Result.failure(IllegalArgumentException(ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR.message))
// 验证密码
if (!passwordEncoder.matches(password, user.password)) {
logger.warn("登录失败:密码错误,username=$username")
if (user == null) {
// 记录失败尝试
val lockoutMsg = rateLimitService.recordLoginFailure(ipAddress)
if (lockoutMsg != null) {
return Result.failure(IllegalStateException(lockoutMsg))
}
return Result.failure(IllegalArgumentException(ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR.message))
}
// 验证密码
if (!passwordEncoder.matches(password, user.password)) {
// 记录失败尝试
val lockoutMsg = rateLimitService.recordLoginFailure(ipAddress)
if (lockoutMsg != null) {
return Result.failure(IllegalStateException(lockoutMsg))
}
return Result.failure(IllegalArgumentException(ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR.message))
}
// 登录成功,清除失败记录
rateLimitService.clearLoginFailures(ipAddress)
// 生成JWT token(包含tokenVersion,用于使修改密码后的旧token失效)
val token = jwtUtils.generateToken(username, user.tokenVersion)
logger.info("用户登录成功:username=$username")
Result.success(LoginResponse(token = token))
} catch (e: Exception) {
@@ -0,0 +1,93 @@
package com.wrbug.polymarketbot.service.auth
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import java.security.SecureRandom
import java.util.concurrent.ConcurrentHashMap
/**
* WebSocket 票据服务
* 用于生成短期有效的一次性票据避免在 WebSocket URL 中暴露 JWT
*/
@Service
class WebSocketTicketService {
companion object {
// 票据有效期(30秒)
private const val TICKET_VALIDITY_MS = 30_000L
// 票据长度(32字节 = 64个十六进制字符)
private const val TICKET_LENGTH = 32
}
private val secureRandom = SecureRandom()
// 存储票据:ticket -> TicketInfo
private val tickets = ConcurrentHashMap<String, TicketInfo>()
/**
* 票据信息
*/
data class TicketInfo(
val username: String,
val createdAt: Long,
val expiresAt: Long
)
/**
* 为用户生成 WebSocket 连接票据
* @param username 用户名
* @return 一次性票据
*/
fun generateTicket(username: String): String {
// 清理过期票据
cleanupExpiredTickets()
// 生成随机票据
val bytes = ByteArray(TICKET_LENGTH)
secureRandom.nextBytes(bytes)
val ticket = bytes.joinToString("") { "%02x".format(it) }
val now = System.currentTimeMillis()
tickets[ticket] = TicketInfo(
username = username,
createdAt = now,
expiresAt = now + TICKET_VALIDITY_MS
)
return ticket
}
/**
* 验证并消费票据一次性使用
* @param ticket 票据
* @return 用户名如果票据无效则返回 null
*/
fun validateAndConsumeTicket(ticket: String): String? {
val ticketInfo = tickets.remove(ticket) ?: return null
// 检查是否过期
if (System.currentTimeMillis() > ticketInfo.expiresAt) {
return null
}
return ticketInfo.username
}
/**
* 清理过期票据
*/
private fun cleanupExpiredTickets() {
val now = System.currentTimeMillis()
tickets.entries.removeIf { it.value.expiresAt < now }
}
/**
* 定时清理过期票据每分钟执行一次
* 防止过期票据长时间占用内存
*/
@Scheduled(fixedRate = 60_000) // 60秒 = 60000毫秒
fun scheduledCleanup() {
cleanupExpiredTickets()
}
}
@@ -1,5 +1,6 @@
package com.wrbug.polymarketbot.service.common
import com.google.gson.Gson
import com.wrbug.polymarketbot.api.EthereumRpcApi
import com.wrbug.polymarketbot.api.JsonRpcRequest
import com.wrbug.polymarketbot.api.JsonRpcResponse
@@ -29,7 +30,8 @@ class BlockchainService(
private val dataApiBaseUrl: String,
private val retrofitFactory: RetrofitFactory,
private val relayClientService: RelayClientService,
private val rpcNodeService: RpcNodeService
private val rpcNodeService: RpcNodeService,
private val gson: Gson
) {
private val logger = LoggerFactory.getLogger(BlockchainService::class.java)
@@ -37,9 +39,16 @@ class BlockchainService(
// USDC 合约地址(Polygon 主网,Polymarket 使用 Polygon
private val usdcContractAddress = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
// Polymarket 代理工厂合约地址(Polygon 主网)
// Polymarket Safe 代理工厂合约地址(Polygon 主网,用于 MetaMask 用户
// 合约地址: 0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b
private val proxyFactoryContractAddress = "0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b"
private val safeProxyFactoryAddress = "0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b"
// Polymarket Magic 代理工厂合约地址(Polygon 主网,用于邮箱/OAuth 登录用户)
// 合约地址: 0xaB45c5A4B0c941a2F231C04C3f49182e1A254052
private val magicProxyFactoryAddress = "0xaB45c5A4B0c941a2F231C04C3f49182e1A254052"
// Magic Proxy 的 init code hash(用于 CREATE2 计算)
private val magicProxyInitCodeHash = "0xd21df8dc65880a8606f09fe0ce3df9b8869287ab0b058be05aa9e8af6330a00b"
// ConditionalTokens 合约地址(Polygon 主网)
private val conditionalTokensAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"
@@ -64,7 +73,7 @@ class BlockchainService(
Retrofit.Builder()
.baseUrl("$baseUrl/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(PolymarketDataApi::class.java)
}
@@ -76,60 +85,162 @@ class BlockchainService(
/**
* 获取 Polymarket 代理钱包地址
* 通过 RPC 调用代理工厂合约获取用户的代理钱包地址
* 根据指定的钱包类型返回对应的代理地址
*
* Polymarket 有两种代理钱包类型
* 1. Magic Proxy邮箱/OAuth 登录用户- 使用 CREATE2 计算地址
* 2. Safe ProxyMetaMask 钱包用户- 通过合约调用获取地址
*
* @param walletAddress 用户的钱包地址EOA
* @param walletType 钱包类型"magic"默认 "safe"
* @return 代理钱包地址
*/
suspend fun getProxyAddress(walletAddress: String, walletType: String = "magic"): Result<String> {
return try {
when (walletType.lowercase()) {
"safe" -> {
// Safe ProxyMetaMask 用户)
val safeProxyResult = getSafeProxyAddress(walletAddress)
if (safeProxyResult.isSuccess) {
val safeProxyAddress = safeProxyResult.getOrNull()!!
logger.debug("使用 Safe Proxy 地址: $safeProxyAddress")
Result.success(safeProxyAddress)
} else {
Result.failure(safeProxyResult.exceptionOrNull() ?: Exception("获取 Safe Proxy 地址失败"))
}
}
else -> {
// Magic Proxy(邮箱/OAuth 登录用户)- 默认
val magicProxyAddress = calculateMagicProxyAddress(walletAddress)
logger.debug("使用 Magic Proxy 地址: $magicProxyAddress")
Result.success(magicProxyAddress)
}
}
} catch (e: Exception) {
logger.error("获取代理地址失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 计算 Magic Proxy 地址使用 CREATE2
* 用于邮箱/OAuth 登录的用户
*
* CREATE2 地址计算公式
* address = keccak256(0xff ++ factory ++ salt ++ initCodeHash)[12:]
* salt = keccak256(eoaAddress)
*
* @param walletAddress 用户的钱包地址EOA
* @return Magic 代理钱包地址
*/
fun calculateMagicProxyAddress(walletAddress: String): String {
// 计算 salt = keccak256(eoaAddress)
val eoaBytes = EthereumUtils.hexToBytes(walletAddress.lowercase())
val salt = EthereumUtils.keccak256(eoaBytes)
// 计算 CREATE2 地址
// data = 0xff ++ factory ++ salt ++ initCodeHash
val prefix = byteArrayOf(0xff.toByte())
val factoryBytes = EthereumUtils.hexToBytes(magicProxyFactoryAddress)
val initCodeHashBytes = EthereumUtils.hexToBytes(magicProxyInitCodeHash)
val data = prefix + factoryBytes + salt + initCodeHashBytes
val hash = EthereumUtils.keccak256(data)
// 取后 20 字节作为地址
return "0x" + hash.copyOfRange(12, 32).joinToString("") { "%02x".format(it) }
}
/**
* 获取 Safe Proxy 地址
* 通过 RPC 调用 Safe 代理工厂合约获取用户的代理钱包地址
* 用于 MetaMask 钱包用户
*
* @param walletAddress 用户的钱包地址
* @return 代理钱包地址
*/
suspend fun getProxyAddress(walletAddress: String): Result<String> {
private suspend fun getSafeProxyAddress(walletAddress: String): Result<String> {
return try {
val rpcApi = polygonRpcApi
// 计算函数选择器
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,
"to" to safeProxyFactoryAddress,
"data" to data
),
"latest"
)
)
// 发送 RPC 请求
val response = rpcApi.call(rpcRequest)
if (!response.isSuccessful || response.body() == null) {
throw Exception("RPC 请求失败: ${response.code()} ${response.message()}")
return Result.failure(Exception("RPC 请求失败: ${response.code()} ${response.message()}"))
}
val rpcResponse = response.body()!!
// 检查错误
if (rpcResponse.error != null) {
throw Exception("RPC 错误: ${rpcResponse.error.message}")
return Result.failure(Exception("RPC 错误: ${rpcResponse.error.message}"))
}
// 使用 Gson 解析 resultJsonElement
val hexResult = rpcResponse.result?.asString
?: throw Exception("RPC 响应格式错误: result 为空")
val hexResult = rpcResponse.result?.asString
?: return Result.failure(Exception("RPC 响应格式错误: result 为空"))
// 解析代理地址
val proxyAddress = EthereumUtils.decodeAddress(hexResult)
Result.success(proxyAddress)
} catch (e: Exception) {
logger.error("获取代理地址失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 检查地址是否是合约
* @param address 地址
* @return 如果地址有代码是合约返回 true
*/
private suspend fun isContract(address: String): Boolean {
return try {
val rpcApi = polygonRpcApi
val rpcRequest = JsonRpcRequest(
method = "eth_getCode",
params = listOf(address, "latest")
)
val response = rpcApi.call(rpcRequest)
if (!response.isSuccessful || response.body() == null) {
return false
}
val rpcResponse = response.body()!!
if (rpcResponse.error != null) {
return false
}
val code = rpcResponse.result?.asString ?: "0x"
// 如果代码不是 "0x" 或 "0x0",则是合约
code != "0x" && code != "0x0"
} catch (e: Exception) {
logger.warn("检查合约地址失败: ${e.message}")
false
}
}
/**
* 查询账户 USDC 余额
@@ -602,6 +713,73 @@ class BlockchainService(
return Result.success(txHash)
}
/**
* 从链上查询市场条件Condition的结算结果
* 通过调用 ConditionalTokens 合约的 getCondition 函数获取 payouts
*
* @param conditionId 市场条件IDbytes32必须是 0x 开头的 66 位十六进制字符串
* @return Result<Pair<payoutDenominator, payouts>>
* - payoutDenominator: 支付分母通常为 1
* - payouts: 每个 outcome 的支付金额数组0 1
* - 如果 payouts[outcomeIndex] == 1表示该 outcome 赢了
* - 如果 payouts[outcomeIndex] == 0表示该 outcome 输了
* - 如果 payouts 为空表示市场尚未结算
*/
suspend fun getCondition(conditionId: String): Result<Pair<BigInteger, List<BigInteger>>> {
return try {
// 验证 conditionId 格式
if (conditionId.isBlank() || !conditionId.startsWith("0x") || conditionId.length != 66) {
return Result.failure(IllegalArgumentException("conditionId 格式错误,必须是 0x 开头的 66 位十六进制字符串"))
}
val rpcApi = polygonRpcApi
// 构建 getCondition(bytes32) 函数调用
// 函数签名: getCondition(bytes32)
val functionSelector = EthereumUtils.getFunctionSelector("getCondition(bytes32)")
val encodedConditionId = EthereumUtils.encodeBytes32(conditionId)
val data = functionSelector + encodedConditionId
// 构建 JSON-RPC 请求
val rpcRequest = JsonRpcRequest(
method = "eth_call",
params = listOf(
mapOf(
"to" to conditionalTokensAddress,
"data" to data
),
"latest"
)
)
// 发送 RPC 请求
val response = rpcApi.call(rpcRequest)
if (!response.isSuccessful || response.body() == null) {
return Result.failure(Exception("RPC 请求失败: ${response.code()} ${response.message()}"))
}
val rpcResponse = response.body()!!
// 检查错误
if (rpcResponse.error != null) {
return Result.failure(Exception("RPC 错误: ${rpcResponse.error.message}"))
}
// 使用 Gson 解析 resultJsonElement
val hexResult = rpcResponse.result?.asString
?: return Result.failure(Exception("RPC 响应格式错误: result 为空"))
// 解析 ABI 编码的返回结果
val (payoutDenominator, payouts) = EthereumUtils.decodeConditionResult(hexResult)
Result.success(Pair(payoutDenominator, payouts))
} catch (e: Exception) {
logger.error("查询市场条件失败: conditionId=$conditionId, ${e.message}", e)
Result.failure(e)
}
}
/**
* 查询交易详情用于调试和分析
* @param txHash 交易哈希
@@ -0,0 +1,211 @@
package com.wrbug.polymarketbot.service.common
import com.wrbug.polymarketbot.api.PolymarketClobApi
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 org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.math.BigInteger
/**
* 市场价格服务
* 统一封装从不同数据源获取市场价格的逻辑
* 数据源包括
* 1. 链上 RPC 查询市场结算结果
* 2. CLOB API订单簿价格
*/
@Service
class MarketPriceService(
private val blockchainService: BlockchainService,
private val retrofitFactory: RetrofitFactory,
private val accountRepository: AccountRepository,
private val cryptoUtils: CryptoUtils
) {
private val logger = LoggerFactory.getLogger(MarketPriceService::class.java)
/**
* 获取当前市场最新价
* 优先级
* 1. 链上查询市场结算结果如果已结算返回 1.0 0.0
* 2. CLOB API 查询订单簿价格最准确使用 bestBid
*
* 价格会被截位到 4 位小数向下截断不四舍五入用于显示和后续计算
*
* @param marketId 市场ID
* @param outcomeIndex 结果索引
* @return 市场价格已截位到 4 位小数
* @throws IllegalStateException 如果所有数据源都失败
*/
suspend fun getCurrentMarketPrice(marketId: String, outcomeIndex: Int): BigDecimal {
// 1. 优先从链上查询市场结算结果
val chainPrice = getPriceFromChainCondition(marketId, outcomeIndex)
if (chainPrice != null) {
// 截位到 4 位小数(向下截断,不四舍五入)
return chainPrice.setScale(4, java.math.RoundingMode.DOWN)
}
// 2. 从 CLOB API 查询订单簿价格(最准确)
val orderbookPrice = getPriceFromClobOrderbook(marketId, outcomeIndex)
if (orderbookPrice != null) {
// 截位到 4 位小数(向下截断,不四舍五入)
return orderbookPrice.setScale(4, java.math.RoundingMode.DOWN)
}
// 如果所有数据源都失败,抛出异常
val errorMsg = "无法获取市场价格: marketId=$marketId, outcomeIndex=$outcomeIndex (链上查询和订单簿查询均失败)"
logger.error(errorMsg)
throw IllegalStateException(errorMsg)
}
/**
* 从链上查询市场结算结果获取价格
* 如果市场已结算
* - payout > 0赢了 返回 1.0
* - payout == 0输了 返回 0.0
* 如果市场未结算或查询失败返回 null
*/
private suspend fun getPriceFromChainCondition(marketId: String, outcomeIndex: Int): BigDecimal? {
return try {
val chainResult = blockchainService.getCondition(marketId)
chainResult.fold(
onSuccess = { (_, payouts) ->
// 如果 payouts 不为空,说明市场已结算
if (payouts.isNotEmpty() && outcomeIndex < payouts.size) {
val payout = payouts[outcomeIndex]
when {
payout > BigInteger.ZERO -> {
logger.info("从链上查询到市场已结算,该 outcome 赢了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
return BigDecimal.ONE
}
payout == BigInteger.ZERO -> {
logger.info("从链上查询到市场已结算,该 outcome 输了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
return BigDecimal.ZERO
}
else -> {
logger.warn("从链上查询到异常的 payout 值: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
null
}
}
} else {
logger.debug("从链上查询到市场尚未结算: marketId=$marketId, payouts=${payouts.size}")
null
}
},
onFailure = { e ->
logger.debug("链上查询市场条件失败,降级到 API 查询: marketId=$marketId, error=${e.message}")
null
}
)
} catch (e: Exception) {
logger.debug("链上查询市场条件异常: marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}")
null
}
}
/**
* CLOB API 查询订单簿价格
* 获取订单簿的 bestBid bestAsk计算 midpoint = (bestBid + bestAsk) / 2
* 订单簿数据最准确反映当前市场真实价格
* 如果查询失败返回 null
*/
private suspend fun getPriceFromClobOrderbook(marketId: String, outcomeIndex: Int): BigDecimal? {
return try {
// 获取 tokenId(用于查询特定 outcome 的订单簿)
val tokenIdResult = blockchainService.getTokenId(marketId, outcomeIndex)
if (!tokenIdResult.isSuccess) {
return null
}
val tokenId = tokenIdResult.getOrNull() ?: return null
// 尝试使用带鉴权的 CLOB API,如果没有则使用不带鉴权的 API
val clobApi = try {
getAuthenticatedClobApi() ?: retrofitFactory.createClobApiWithoutAuth()
} catch (e: Exception) {
logger.debug("获取带鉴权的 CLOB API 失败,使用不带鉴权的 API: ${e.message}")
retrofitFactory.createClobApiWithoutAuth()
}
val orderbookResponse = clobApi.getOrderbook(tokenId = tokenId, market = null)
if (!orderbookResponse.isSuccessful || orderbookResponse.body() == null) {
return null
}
val orderbook = orderbookResponse.body()!!
// 获取 bestBid(最高买入价):从 bids 中找到价格最大的
// bids 表示买入订单列表,价格越高表示愿意出的价格越高
val bestBid = orderbook.bids
.mapNotNull { it.price.toSafeBigDecimal() }
.maxOrNull()
// 获取 bestAsk(最低卖出价):从 asks 中找到价格最小的
// asks 表示卖出订单列表,价格越低表示愿意卖的价格越低
val bestAsk = orderbook.asks
.mapNotNull { it.price.toSafeBigDecimal() }
.minOrNull()
// 由于主要用于卖出场景,优先使用 bestBid(最高买入价,卖给愿意买入的人)
// 如果没有 bestBid,则使用 midpoint 或 bestAsk
if (bestBid != null) {
logger.debug("从订单簿获取价格(bestBid: marketId=$marketId, outcomeIndex=$outcomeIndex, bestBid=$bestBid, bestAsk=$bestAsk")
return bestBid
} else if (bestAsk != null && bestAsk > BigDecimal.ZERO) {
// 如果没有 bestBid,使用 bestAsk 作为备选
logger.debug("从订单簿获取价格(bestAsk: marketId=$marketId, outcomeIndex=$outcomeIndex, bestAsk=$bestAsk")
return bestAsk
}
null
} catch (e: Exception) {
logger.debug("CLOB API 查询订单簿失败: marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}")
null
}
}
/**
* 获取带鉴权的 CLOB API 客户端
* 使用第一个有 API 凭证的账户
* 如果都没有返回 null
*/
private fun getAuthenticatedClobApi(): PolymarketClobApi? {
return try {
// 使用第一个有 API 凭证的账户
val account = accountRepository.findAllByOrderByCreatedAtAsc()
.firstOrNull { it.apiKey != null && it.apiSecret != null && it.apiPassphrase != null }
if (account == null || account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
return null
}
// 解密 API 凭证
val apiKey = account.apiKey
val apiSecret = try {
cryptoUtils.decrypt(account.apiSecret)
} catch (e: Exception) {
logger.debug("解密 API Secret 失败: ${e.message}")
return null
}
val apiPassphrase = try {
cryptoUtils.decrypt(account.apiPassphrase)
} catch (e: Exception) {
logger.debug("解密 API Passphrase 失败: ${e.message}")
return null
}
// 创建带鉴权的 CLOB API 客户端
retrofitFactory.createClobApi(apiKey, apiSecret, apiPassphrase, account.walletAddress)
} catch (e: Exception) {
logger.debug("获取带鉴权的 CLOB API 失败: ${e.message}")
null
}
}
}
@@ -1,5 +1,6 @@
package com.wrbug.polymarketbot.service.common
import com.google.gson.Gson
import com.wrbug.polymarketbot.api.ApiKeyResponse
import com.wrbug.polymarketbot.api.PolymarketClobApi
import com.wrbug.polymarketbot.util.PolymarketL1AuthInterceptor
@@ -19,7 +20,8 @@ import retrofit2.converter.gson.GsonConverterFactory
@Service
class PolymarketApiKeyService(
@Value("\${polymarket.clob.base-url}")
private val clobBaseUrl: String
private val clobBaseUrl: String,
private val gson: Gson
) {
private val logger = LoggerFactory.getLogger(PolymarketApiKeyService::class.java)
@@ -52,34 +54,36 @@ class PolymarketApiKeyService(
try {
// 先尝试获取现有的 API Keyderive
val deriveResult = deriveApiKey(privateKey, walletAddress, chainId)
val maskedAddress = "${walletAddress.take(6)}...${walletAddress.takeLast(4)}"
if (deriveResult.isSuccess) {
val creds = deriveResult.getOrNull()
if (creds != null && isApiCreds(creds)) {
logger.info("成功获取现有 API Key: ${walletAddress}")
logger.debug("成功获取现有 API Key: $maskedAddress")
return@runBlocking Result.success(creds)
}
}
// 如果获取失败或返回无效,尝试创建新的
logger.info("获取现有 API Key 失败,尝试创建新的: ${walletAddress}")
logger.debug("获取现有 API Key 失败,尝试创建新的: $maskedAddress")
val createResult = createApiKey(privateKey, walletAddress, chainId)
if (createResult.isSuccess) {
val creds = createResult.getOrNull()
if (creds != null && isApiCreds(creds)) {
logger.info("成功创建新 API Key: ${walletAddress}")
logger.debug("成功创建新 API Key: $maskedAddress")
return@runBlocking Result.success(creds)
}
}
// 两个都失败
val error = createResult.exceptionOrNull() ?: deriveResult.exceptionOrNull()
val errorMsg = error?.message ?: "未知错误"
logger.error("获取和创建 API Key 都失败: ${walletAddress}", error)
logger.error("获取和创建 API Key 都失败: $maskedAddress", error)
Result.failure(
IllegalStateException("无法获取或创建 API Key: $errorMsg")
)
} catch (e: Exception) {
logger.error("创建或获取 API Key 异常: ${walletAddress}", e)
val maskedAddress = "${walletAddress.take(6)}...${walletAddress.takeLast(4)}"
logger.error("创建或获取 API Key 异常: $maskedAddress", e)
Result.failure(e)
}
}
@@ -222,7 +226,7 @@ class PolymarketApiKeyService(
return Retrofit.Builder()
.baseUrl(clobBaseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(PolymarketClobApi::class.java)
}
@@ -236,7 +240,7 @@ class PolymarketApiKeyService(
return Retrofit.Builder()
.baseUrl(clobBaseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(PolymarketClobApi::class.java)
}
@@ -300,13 +300,23 @@ class PolymarketClobService(
)
val response = authenticatedClobApi.getOrder(orderId)
if (response.isSuccessful && response.body() != null) {
Result.success(response.body()!!)
if (response.isSuccessful) {
val body = response.body()
if (body != null) {
Result.success(body)
} else {
// 响应体为空,可能是订单不存在或已过期
logger.warn("获取订单详情失败: 响应体为空, orderId=$orderId, code=${response.code()}")
Result.failure(Exception("订单不存在或已过期: orderId=$orderId"))
}
} else {
Result.failure(Exception("获取订单详情失败: ${response.code()} ${response.message()}"))
// HTTP 状态码不是 2xx
val errorBody = response.errorBody()?.string()?.take(200) ?: "无错误详情"
logger.warn("获取订单详情失败: HTTP ${response.code()}, orderId=$orderId, errorBody=$errorBody")
Result.failure(Exception("获取订单详情失败: HTTP ${response.code()} ${response.message()}"))
}
} catch (e: Exception) {
logger.error("获取订单详情异常: ${e.message}", e)
logger.error("获取订单详情异常: orderId=$orderId, ${e.message}", e)
Result.failure(e)
}
}
@@ -3,50 +3,133 @@ package com.wrbug.polymarketbot.service.common
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicReference
/**
* 频率限制服务使用内存缓存全局限制
* 频率限制服务使用内存缓存
*/
@Service
class RateLimitService {
private val logger = LoggerFactory.getLogger(RateLimitService::class.java)
// 重置密码限速配置
@Value("\${rate-limit.reset-password.max-attempts:3}")
private var maxAttempts: Int = 3
private var resetPasswordMaxAttempts: Int = 3
@Value("\${rate-limit.reset-password.window-seconds:60}")
private var windowSeconds: Long = 60
private var resetPasswordWindowSeconds: Long = 60
// 登录限速配置
@Value("\${rate-limit.login.max-attempts:5}")
private var loginMaxAttempts: Int = 5
@Value("\${rate-limit.login.window-seconds:300}")
private var loginWindowSeconds: Long = 300 // 5分钟
@Value("\${rate-limit.login.lockout-seconds:900}")
private var loginLockoutSeconds: Long = 900 // 15分钟
// 全局尝试记录列表(时间戳),所有请求共享
private val resetPasswordAttempts = AtomicReference<MutableList<Long>>(mutableListOf())
// 登录失败尝试记录(IP -> 时间戳列表)
private val loginFailedAttempts = ConcurrentHashMap<String, MutableList<Long>>()
// 登录锁定记录(IP -> 锁定结束时间)
private val loginLockouts = ConcurrentHashMap<String, Long>()
/**
* 检查重置密码频率限制全局限制不按IP
* @return Result如果超过限制则返回失败
*/
fun checkResetPasswordRateLimit(): Result<Unit> {
val now = System.currentTimeMillis()
val windowStart = now - (windowSeconds * 1000)
val windowStart = now - (resetPasswordWindowSeconds * 1000)
// 获取当前尝试记录列表
val attempts = resetPasswordAttempts.get()
// 清理过期记录(超过时间窗口的记录)
val validAttempts = attempts.filter { it >= windowStart }.toMutableList()
// 检查是否超过限制
if (validAttempts.size >= maxAttempts) {
logger.warn("重置密码频率限制触发: attempts=${validAttempts.size}/$maxAttempts")
return Result.failure(IllegalStateException("频率限制:1分钟内最多尝试${maxAttempts}次,请稍后再试"))
if (validAttempts.size >= resetPasswordMaxAttempts) {
logger.warn("重置密码频率限制触发: attempts=${validAttempts.size}/$resetPasswordMaxAttempts")
return Result.failure(IllegalStateException("频率限制:1分钟内最多尝试${resetPasswordMaxAttempts}次,请稍后再试"))
}
// 记录本次尝试
validAttempts.add(now)
resetPasswordAttempts.set(validAttempts)
return Result.success(Unit)
}
/**
* 检查登录频率限制按IP限制
* @param ipAddress 客户端IP地址
* @return Result如果被锁定或超过限制则返回失败
*/
fun checkLoginRateLimit(ipAddress: String): Result<Unit> {
val now = System.currentTimeMillis()
// 检查是否被锁定
val lockoutEndTime = loginLockouts[ipAddress]
if (lockoutEndTime != null) {
if (now < lockoutEndTime) {
val remainingSeconds = (lockoutEndTime - now) / 1000
logger.warn("登录锁定中: ip=$ipAddress, remainingSeconds=$remainingSeconds")
return Result.failure(IllegalStateException("账户已被锁定,请${remainingSeconds}秒后再试"))
} else {
// 锁定已过期,清除锁定记录
loginLockouts.remove(ipAddress)
loginFailedAttempts.remove(ipAddress)
}
}
return Result.success(Unit)
}
/**
* 记录登录失败尝试
* @param ipAddress 客户端IP地址
* @return 如果触发锁定返回锁定信息否则返回 null
*/
fun recordLoginFailure(ipAddress: String): String? {
val now = System.currentTimeMillis()
val windowStart = now - (loginWindowSeconds * 1000)
// 获取或创建该IP的尝试记录
val attempts = loginFailedAttempts.computeIfAbsent(ipAddress) { mutableListOf() }
// 清理过期记录并添加新记录
synchronized(attempts) {
attempts.removeIf { it < windowStart }
attempts.add(now)
// 检查是否需要锁定
if (attempts.size >= loginMaxAttempts) {
val lockoutEndTime = now + (loginLockoutSeconds * 1000)
loginLockouts[ipAddress] = lockoutEndTime
logger.warn("登录锁定触发: ip=$ipAddress, attempts=${attempts.size}, lockoutSeconds=$loginLockoutSeconds")
return "登录失败次数过多,账户已被锁定${loginLockoutSeconds / 60}分钟"
}
}
val remainingAttempts = loginMaxAttempts - attempts.size
logger.warn("登录失败: ip=$ipAddress, attempts=${attempts.size}/$loginMaxAttempts, remainingAttempts=$remainingAttempts")
return null
}
/**
* 登录成功时清除失败记录
* @param ipAddress 客户端IP地址
*/
fun clearLoginFailures(ipAddress: String) {
loginFailedAttempts.remove(ipAddress)
loginLockouts.remove(ipAddress)
}
}
@@ -151,13 +151,14 @@ class CopyTradingService(
val saved = copyTradingRepository.save(copyTrading)
// 如果跟单已启用,更新 Leader 监听(增量更新,不重启所有监听)
// 如果跟单已启用,更新 Leader 监听和账户监听(增量更新,不重启所有监听)
if (saved.enabled) {
kotlinx.coroutines.runBlocking {
try {
monitorService.updateLeaderMonitoring(saved.leaderId)
monitorService.updateAccountMonitoring(saved.accountId)
} catch (e: Exception) {
logger.error("更新 Leader 监听失败", e)
logger.error("更新监听失败", e)
}
}
}
@@ -219,12 +220,13 @@ class CopyTradingService(
val saved = copyTradingRepository.save(updated)
// 更新 Leader 监听(增量更新,根据 enabled 状态决定添加或移除)
// 更新 Leader 监听和账户监听(增量更新,根据 enabled 状态决定添加或移除)
kotlinx.coroutines.runBlocking {
try {
monitorService.updateLeaderMonitoring(saved.leaderId)
monitorService.updateAccountMonitoring(saved.accountId)
} catch (e: Exception) {
logger.error("更新 Leader 监听失败", e)
logger.error("更新监听失败", e)
}
}
@@ -323,14 +325,16 @@ class CopyTradingService(
?: return Result.failure(IllegalArgumentException("跟单配置不存在"))
val leaderId = copyTrading.leaderId
val accountId = copyTrading.accountId
copyTradingRepository.delete(copyTrading)
// 更新 Leader 监听(检查该 Leader 是否还有其他启用的跟单配置)
// 更新 Leader 监听和账户监听(检查是否还有其他启用的跟单配置)
kotlinx.coroutines.runBlocking {
try {
monitorService.removeLeaderMonitoring(leaderId)
monitorService.updateAccountMonitoring(accountId)
} catch (e: Exception) {
logger.error("更新 Leader 监听失败", e)
logger.error("更新监听失败", e)
}
}
@@ -0,0 +1,341 @@
package com.wrbug.polymarketbot.service.copytrading.monitor
import com.wrbug.polymarketbot.api.*
import com.wrbug.polymarketbot.entity.Account
import com.wrbug.polymarketbot.entity.CopyOrderTracking
import com.wrbug.polymarketbot.entity.SellMatchDetail
import com.wrbug.polymarketbot.entity.SellMatchRecord
import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository
import com.wrbug.polymarketbot.repository.CopyTradingRepository
import com.wrbug.polymarketbot.repository.SellMatchDetailRepository
import com.wrbug.polymarketbot.repository.SellMatchRecordRepository
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.multi
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import jakarta.annotation.PreDestroy
import kotlinx.coroutines.*
import okhttp3.OkHttpClient
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.util.concurrent.ConcurrentHashMap
/**
* 跟单账户链上 WebSocket 监听服务
* 通过统一服务订阅跟单账户的卖出和赎回事件
* 用于更新订单状态不再依赖轮询
*/
@Service
class AccountOnChainMonitorService(
private val unifiedOnChainWsService: UnifiedOnChainWsService,
private val retrofitFactory: RetrofitFactory,
private val accountRepository: AccountRepository,
private val copyTradingRepository: CopyTradingRepository,
private val copyOrderTrackingRepository: CopyOrderTrackingRepository,
private val sellMatchRecordRepository: SellMatchRecordRepository,
private val sellMatchDetailRepository: SellMatchDetailRepository
) {
private val logger = LoggerFactory.getLogger(AccountOnChainMonitorService::class.java)
// 存储需要监听的账户:accountId -> Account
private val monitoredAccounts = ConcurrentHashMap<Long, Account>()
/**
* 启动链上 WebSocket 监听
* 通过统一服务订阅所有跟单账户
*/
fun start(accounts: List<Account>) {
// 如果没有账户,取消所有订阅
if (accounts.isEmpty()) {
logger.info("没有需要监听的跟单账户,取消所有订阅")
stop()
return
}
// 更新账户列表
monitoredAccounts.clear()
accounts.forEach { account ->
addAccount(account)
}
}
/**
* 添加账户监听
* 通过统一服务订阅该账户的地址
*/
fun addAccount(account: Account) {
if (account.id == null) {
logger.warn("账户 ID 为空,跳过: ${account.proxyAddress}")
return
}
val accountId = account.id!!
// 如果已经在监听列表中,不重复添加
if (monitoredAccounts.containsKey(accountId)) {
return
}
monitoredAccounts[accountId] = account
// 通过统一服务订阅
val subscriptionId = "ACCOUNT_$accountId"
unifiedOnChainWsService.subscribe(
subscriptionId = subscriptionId,
address = account.proxyAddress,
entityType = "ACCOUNT",
entityId = accountId,
callback = { txHash, httpClient, rpcApi ->
handleAccountTransaction(accountId, txHash, httpClient, rpcApi)
}
)
logger.info("已添加跟单账户进行链上监听: accountId=${accountId}, address=${account.proxyAddress}")
}
/**
* 处理账户的交易
*/
private suspend fun handleAccountTransaction(accountId: Long, txHash: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
val account = monitoredAccounts[accountId] ?: return
try {
// 获取交易 receipt
val receiptRequest = JsonRpcRequest(
method = "eth_getTransactionReceipt",
params = listOf(txHash)
)
val receiptResponse = rpcApi.call(receiptRequest)
if (!receiptResponse.isSuccessful || receiptResponse.body() == null) {
return
}
val receiptRpcResponse = receiptResponse.body()!!
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null) {
return
}
// 使用 Gson 解析 receipt JSON
val receiptJson = receiptRpcResponse.result.asJsonObject
// 获取区块号和时间戳
val blockNumber = receiptJson.get("blockNumber")?.asString
val blockTimestamp = if (blockNumber != null) {
OnChainWsUtils.getBlockTimestamp(blockNumber, rpcApi)
} else {
null
}
// 解析 receipt 中的 Transfer 日志
val logs = receiptJson.getAsJsonArray("logs") ?: return
val (erc20Transfers, erc1155Transfers) = OnChainWsUtils.parseReceiptTransfers(logs)
// 解析交易信息
val trade = OnChainWsUtils.parseTradeFromTransfers(
txHash = txHash,
timestamp = blockTimestamp,
walletAddress = account.proxyAddress,
erc20Transfers = erc20Transfers,
erc1155Transfers = erc1155Transfers,
retrofitFactory = retrofitFactory
)
if (trade != null && trade.side == "SELL") {
// 检测到卖出或赎回事件,更新订单状态
handleAccountSellOrRedeem(account, trade)
}
} catch (e: Exception) {
logger.error("处理账户交易失败: accountId=$accountId, txHash=$txHash, ${e.message}", e)
}
}
/**
* 处理账户的卖出或赎回事件
* 更新对应的订单状态
*/
private suspend fun handleAccountSellOrRedeem(account: Account, trade: TradeResponse) {
try {
// 获取该账户的所有启用的跟单配置
val copyTradings = copyTradingRepository.findByAccountId(account.id!!)
.filter { it.enabled }
if (copyTradings.isEmpty()) {
return
}
// 使用 trade 中已有的市场信息
val marketId = trade.market // conditionId
val outcomeIndex = trade.outcomeIndex ?: 0
// 计算卖出价格
val sellPrice = trade.price.toSafeBigDecimal()
// 为每个跟单配置更新订单状态
for (copyTrading in copyTradings) {
// 查找该跟单配置下所有未卖出的订单(remaining_quantity > 0
val unmatchedOrders = copyOrderTrackingRepository.findByCopyTradingId(copyTrading.id!!)
.filter {
it.remainingQuantity > BigDecimal.ZERO &&
it.marketId == marketId &&
it.outcomeIndex == outcomeIndex
}
.sortedBy { it.createdAt } // 按创建时间排序(FIFO
if (unmatchedOrders.isEmpty()) {
continue
}
// 卖出数量就是交易的 size
val soldQuantity = trade.size.toSafeBigDecimal()
// 更新订单状态为已卖出
updateOrdersAsSoldByFIFO(
unmatchedOrders,
soldQuantity,
sellPrice,
copyTrading.id!!,
marketId,
outcomeIndex
)
logger.info("跟单账户卖出/赎回事件处理完成: accountId=${account.id}, copyTradingId=${copyTrading.id}, txHash=${trade.id}, soldQuantity=$soldQuantity, sellPrice=$sellPrice")
}
} catch (e: Exception) {
logger.error("处理账户卖出/赎回事件失败: accountId=${account.id}, txHash=${trade.id}, error=${e.message}", e)
}
}
/**
* FIFO 顺序更新订单为已卖出
*/
private suspend fun updateOrdersAsSoldByFIFO(
orders: List<CopyOrderTracking>,
soldQuantity: BigDecimal,
sellPrice: BigDecimal,
copyTradingId: Long,
marketId: String,
outcomeIndex: Int
) {
var remainingSoldQuantity = soldQuantity
val matchDetails = mutableListOf<SellMatchDetail>()
var totalMatchedQuantity = BigDecimal.ZERO
var totalRealizedPnl = BigDecimal.ZERO
for (order in orders) {
if (remainingSoldQuantity <= BigDecimal.ZERO) {
break
}
val currentOrderRemaining = order.remainingQuantity.toSafeBigDecimal()
val matchedQty = minOf(currentOrderRemaining, remainingSoldQuantity)
if (matchedQty <= BigDecimal.ZERO) {
continue
}
// 计算盈亏
val buyPrice = order.price.toSafeBigDecimal()
val realizedPnl = sellPrice.subtract(buyPrice).multi(matchedQty)
// 创建匹配明细
val detail = SellMatchDetail(
matchRecordId = 0, // 稍后设置
trackingId = order.id!!,
buyOrderId = order.buyOrderId,
matchedQuantity = matchedQty,
buyPrice = buyPrice,
sellPrice = sellPrice,
realizedPnl = realizedPnl
)
matchDetails.add(detail)
totalMatchedQuantity = totalMatchedQuantity.add(matchedQty)
totalRealizedPnl = totalRealizedPnl.add(realizedPnl)
// 更新订单状态
order.matchedQuantity = order.matchedQuantity.add(matchedQty)
order.remainingQuantity = currentOrderRemaining.subtract(matchedQty)
order.status = if (order.remainingQuantity <= BigDecimal.ZERO) "fully_matched" else "partially_matched"
order.updatedAt = System.currentTimeMillis()
copyOrderTrackingRepository.save(order)
remainingSoldQuantity = remainingSoldQuantity.subtract(matchedQty)
}
// 如果有匹配的订单,创建卖出记录
if (totalMatchedQuantity > BigDecimal.ZERO && matchDetails.isNotEmpty()) {
val timestamp = System.currentTimeMillis()
val sellOrderId = "AUTO_WS_${timestamp}_${copyTradingId}" // 区分 WS 自动卖出
val leaderSellTradeId = "AUTO_WS_${timestamp}"
val matchRecord = SellMatchRecord(
copyTradingId = copyTradingId,
sellOrderId = sellOrderId,
leaderSellTradeId = leaderSellTradeId,
marketId = marketId,
side = outcomeIndex.toString(),
outcomeIndex = outcomeIndex,
totalMatchedQuantity = totalMatchedQuantity,
sellPrice = sellPrice,
totalRealizedPnl = totalRealizedPnl,
priceUpdated = true // WS 实时获取,直接标记为已更新
)
val savedRecord = sellMatchRecordRepository.save(matchRecord)
// 保存匹配明细
for (detail in matchDetails) {
val savedDetail = detail.copy(matchRecordId = savedRecord.id!!)
sellMatchDetailRepository.save(savedDetail)
}
logger.info("创建跟单账户链上自动卖出记录: copyTradingId=$copyTradingId, marketId=$marketId, totalMatched=$totalMatchedQuantity, totalPnl=$totalRealizedPnl")
}
}
/**
* 移除账户监听
* 取消该账户的订阅
*/
fun removeAccount(accountId: Long) {
monitoredAccounts.remove(accountId)
// 通过统一服务取消订阅
val subscriptionId = "ACCOUNT_$accountId"
unifiedOnChainWsService.unsubscribe(subscriptionId)
logger.info("已移除跟单账户的链上监听: accountId=$accountId")
}
/**
* 更新账户监听状态
*/
fun updateAccountMonitoring(accountId: Long) {
val account = accountRepository.findById(accountId).orElse(null)
if (account != null && account.isEnabled) {
addAccount(account)
} else {
removeAccount(accountId)
}
}
/**
* 停止监听
*/
fun stop() {
// 取消所有账户的订阅
val accountIds = monitoredAccounts.keys.toList()
for (accountId in accountIds) {
removeAccount(accountId)
}
monitoredAccounts.clear()
}
@PreDestroy
fun destroy() {
stop()
}
}
@@ -2,6 +2,7 @@ package com.wrbug.polymarketbot.service.copytrading.monitor
import com.wrbug.polymarketbot.entity.CopyTrading
import com.wrbug.polymarketbot.entity.Leader
import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.repository.CopyTradingRepository
import com.wrbug.polymarketbot.repository.LeaderRepository
import jakarta.annotation.PostConstruct
@@ -14,13 +15,16 @@ import org.springframework.stereotype.Service
* 跟单监听服务主服务
* 管理所有Leader的交易监听
* 同时运行链上 WebSocket 监听和轮询监听并行处理
* 同时监听跟单账户的卖出/赎回事件通过链上 WebSocket
*/
@Service
class CopyTradingMonitorService(
private val copyTradingRepository: CopyTradingRepository,
private val leaderRepository: LeaderRepository,
private val accountRepository: AccountRepository,
private val pollingService: CopyTradingPollingService,
private val onChainWsService: OnChainWsService
private val onChainWsService: OnChainWsService,
private val accountOnChainMonitorService: AccountOnChainMonitorService
) {
private val logger = LoggerFactory.getLogger(CopyTradingMonitorService::class.java)
@@ -50,11 +54,13 @@ class CopyTradingMonitorService(
// 停止轮询和链上 WS 监听
pollingService.stop()
onChainWsService.stop()
accountOnChainMonitorService.stop()
}
/**
* 启动监听
* 同时启动链上 WebSocket 监听和轮询监听并行运行
* 同时启动跟单账户的链上 WebSocket 监听用于检测卖出/赎回事件
*/
suspend fun startMonitoring() {
// 1. 获取所有启用的跟单关系
@@ -70,12 +76,21 @@ class CopyTradingMonitorService(
leaderRepository.findById(leaderId).orElse(null)
}
// 3. 同时启动链上 WebSocket 监听和轮询监听(并行运行
// 链上 WS 监听(实时,秒级延迟)
// 3. 获取所有需要监听的跟单账户(去重
val accountIds = enabledCopyTradings.map { it.accountId }.distinct()
val accounts = accountIds.mapNotNull { accountId ->
accountRepository.findById(accountId).orElse(null)
}
// 4. 同时启动链上 WebSocket 监听和轮询监听(并行运行)
// 链上 WS 监听 Leader 的交易(实时,秒级延迟)
onChainWsService.start(leaders)
// 轮询监听(延迟,2秒间隔,作为备份)
// 轮询监听 Leader 的交易(延迟,2秒间隔,作为备份)
pollingService.start(leaders)
// 5. 启动跟单账户的链上 WebSocket 监听(用于检测卖出/赎回事件)
accountOnChainMonitorService.start(accounts)
}
/**
@@ -125,6 +140,15 @@ class CopyTradingMonitorService(
// 有启用的跟单配置,确保在监听列表中
onChainWsService.addLeader(leader)
pollingService.addLeader(leader)
// 更新账户监听(添加该配置关联的账户)
val accountIds = copyTradings.map { it.accountId }.distinct()
accountIds.forEach { accountId ->
val account = accountRepository.findById(accountId).orElse(null)
if (account != null) {
accountOnChainMonitorService.addAccount(account)
}
}
} else {
// 没有启用的跟单配置,移除监听
onChainWsService.removeLeader(leaderId)
@@ -132,6 +156,25 @@ class CopyTradingMonitorService(
}
}
/**
* 更新账户监听当跟单配置状态改变时调用
* 根据当前状态决定添加或移除账户监听
*/
suspend fun updateAccountMonitoring(accountId: Long) {
val copyTradings = copyTradingRepository.findByAccountId(accountId)
.filter { it.enabled }
val account = accountRepository.findById(accountId).orElse(null)
?: return
if (copyTradings.isNotEmpty()) {
// 有启用的跟单配置,确保账户在监听列表中
accountOnChainMonitorService.addAccount(account)
} else {
// 没有启用的跟单配置,移除账户监听
accountOnChainMonitorService.removeAccount(accountId)
}
}
/**
* 重新启动监听当跟单关系状态改变时调用
* 注意这个方法会停止所有监听并重新启动建议使用 updateLeaderMonitoring 进行增量更新
@@ -22,15 +22,14 @@ import java.util.concurrent.ConcurrentHashMap
@Service
class CopyTradingWebSocketService(
private val copyOrderTrackingService: CopyOrderTrackingService,
private val templateRepository: CopyTradingTemplateRepository
private val templateRepository: CopyTradingTemplateRepository,
private val gson: Gson
) {
private val logger = LoggerFactory.getLogger(CopyTradingWebSocketService::class.java)
@Value("\${polymarket.websocket.url:wss://ws-live-data.polymarket.com}")
private var websocketUrl: String = "wss://ws-live-data.polymarket.com"
private val gson = Gson()
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
// 存储每个Leader的WebSocket客户端:leaderId -> WebSocketClient
@@ -1,37 +1,24 @@
package com.wrbug.polymarketbot.service.copytrading.monitor
import com.google.gson.Gson
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.google.gson.reflect.TypeToken
import com.wrbug.polymarketbot.api.*
import com.wrbug.polymarketbot.entity.Leader
import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
import com.wrbug.polymarketbot.service.system.RpcNodeService
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.createClient
import com.wrbug.polymarketbot.util.getProxyConfig
import jakarta.annotation.PreDestroy
import kotlinx.coroutines.*
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.ByteString
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import java.math.BigInteger
import java.util.concurrent.ConcurrentHashMap
/**
* 链上 WebSocket 监听服务
* 通过 Polygon RPC eth_subscribe 实时监听链上交易
* 通过统一服务订阅 Leader 链上交易
*/
@Service
class OnChainWsService(
private val rpcNodeService: RpcNodeService,
private val unifiedOnChainWsService: UnifiedOnChainWsService,
private val retrofitFactory: RetrofitFactory,
private val copyOrderTrackingService: CopyOrderTrackingService,
private val leaderRepository: LeaderRepository
@@ -39,84 +26,31 @@ class OnChainWsService(
private val logger = LoggerFactory.getLogger(OnChainWsService::class.java)
// Gson 实例,用于解析 JSON
private val gson = Gson()
@Value("\${copy.trading.onchain.ws.reconnect.delay:3000}")
private var reconnectDelay: Long = 3000 // 重连延迟(毫秒),默认3秒
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
// 存储需要监听的LeaderleaderId -> Leader
private val monitoredLeaders = ConcurrentHashMap<Long, Leader>()
// 存储每个 Leader 的订阅 IDleaderId -> List<subscriptionId>
// 每个 Leader 有 6 个订阅:USDC from/to, ERC1155 TransferSingle from/to, ERC1155 TransferBatch from/to
private val leaderSubscriptions = ConcurrentHashMap<Long, MutableList<String>>()
// 存储请求 ID 到 Leader ID 的映射:requestId -> leaderId
// 用于在收到订阅响应时,将 subscription ID 关联到对应的 Leader
private val requestIdToLeaderId = ConcurrentHashMap<Int, Long>()
// WebSocket 连接
private var webSocket: WebSocket? = null
@Volatile
private var isConnected = false
// 订阅ID计数器(用于请求 ID
private var requestIdCounter = 0
// 合约地址
companion object {
private const val USDC_CONTRACT = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
private const val ERC1155_CONTRACT = "0x4d97dcd97ec945f40cf65f87097ace5ea0476045"
private const val ERC20_TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
private const val ERC1155_TRANSFER_SINGLE_TOPIC = "0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62"
private const val ERC1155_TRANSFER_BATCH_TOPIC = "0x4a39dc06d4c0dbc64b70af90fd698a233a518aa5d07e595d983b8c0526c8f7fb"
}
// 连接任务(确保只有一个连接任务在运行)
private var connectionJob: Job? = null
/**
* 启动链上 WebSocket 监听
* 只创建一个 WebSocket 连接为所有 Leader 订阅
* 通过统一服务订阅所有 Leader
*/
fun start(leaders: List<Leader>) {
// 如果没有 Leader不启动连接
// 如果没有 Leader取消所有订阅
if (leaders.isEmpty()) {
logger.info("没有需要监听的 Leader不启动链上 WebSocket 连接")
logger.info("没有需要监听的 Leader取消所有订阅")
stop()
return
}
// 如果连接任务已经在运行,先停止旧任务
if (connectionJob != null && connectionJob!!.isActive) {
logger.info("停止旧的连接任务,准备重新启动")
connectionJob?.cancel()
connectionJob = null
// 关闭旧连接
webSocket?.close(1000, "重新启动")
webSocket = null
isConnected = false
}
// 更新 Leader 列表
monitoredLeaders.clear()
leaders.forEach { leader ->
addLeader(leader)
}
// 启动连接任务(只创建一个)
connectionJob = scope.launch {
startConnection()
}
}
/**
* 添加Leader监听
* 如果 Leader 已经在监听列表中不重复添加
* 如果已连接立即订阅
* 通过统一服务订阅该 Leader 的地址
*/
fun addLeader(leader: Leader) {
if (leader.id == null) {
@@ -133,406 +67,28 @@ class OnChainWsService(
}
monitoredLeaders[leaderId] = leader
// 通过统一服务订阅
val subscriptionId = "LEADER_$leaderId"
unifiedOnChainWsService.subscribe(
subscriptionId = subscriptionId,
address = leader.leaderAddress,
entityType = "LEADER",
entityId = leaderId,
callback = { txHash, httpClient, rpcApi ->
handleLeaderTransaction(leaderId, txHash, httpClient, rpcApi)
}
)
logger.info("添加 Leader 监听: ${leader.leaderName} (${leader.leaderAddress})")
// 如果已连接,立即订阅
if (isConnected && webSocket != null) {
scope.launch {
subscribeLeader(leader)
}
} else {
// 如果未连接,启动连接(如果连接任务未运行)
if (connectionJob == null || !connectionJob!!.isActive) {
connectionJob = scope.launch {
startConnection()
}
}
}
}
/**
* 移除Leader监听
* 通过 eth_unsubscribe 取消该 Leader 的所有订阅
* 如果没有 Leader 关闭 WebSocket 连接
* 处理 Leader 的交易
*/
fun removeLeader(leaderId: Long) {
val leader = monitoredLeaders.remove(leaderId)
if (leader != null) {
logger.info("移除 Leader 监听: ${leader.leaderName} (${leader.leaderAddress})")
}
private suspend fun handleLeaderTransaction(leaderId: Long, txHash: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
val leader = monitoredLeaders[leaderId] ?: return
// 取消该 Leader 的所有订阅
val subscriptions = leaderSubscriptions.remove(leaderId)
if (subscriptions != null && subscriptions.isNotEmpty() && isConnected && webSocket != null) {
logger.info("取消 Leader ${leader?.leaderName}${subscriptions.size} 个订阅")
subscriptions.forEach { subscriptionId ->
unsubscribe(subscriptionId)
}
}
// 如果没有 Leader 了,关闭连接
if (monitoredLeaders.isEmpty()) {
logger.info("没有需要监听的 Leader,关闭链上 WebSocket 连接")
stop()
}
}
/**
* 停止所有监听
*/
fun stop() {
connectionJob?.cancel()
connectionJob = null
// 取消所有订阅
if (isConnected && webSocket != null) {
leaderSubscriptions.values.flatten().forEach { subscriptionId ->
unsubscribe(subscriptionId)
}
}
webSocket?.close(1000, "正常关闭")
webSocket = null
isConnected = false
monitoredLeaders.clear()
leaderSubscriptions.clear()
requestIdToLeaderId.clear()
}
/**
* 启动连接带重连机制
* 只创建一个 WebSocket 连接
*/
private suspend fun startConnection() {
while (scope.isActive) {
try {
// 检查是否有需要监听的 Leader
if (monitoredLeaders.isEmpty()) {
logger.info("没有需要监听的 Leader,停止连接")
// 确保关闭连接
webSocket?.close(1000, "没有 Leader")
webSocket = null
isConnected = false
break
}
// 如果已经连接,不需要重新连接
if (isConnected && webSocket != null) {
// 等待连接断开
waitForDisconnect()
// 连接断开后继续重连循环
continue
}
// 从后台配置获取 WS RPC URL
val wsUrl = rpcNodeService.getWsUrl()
val httpUrl = rpcNodeService.getHttpUrl()
logger.info("连接链上 WebSocket: $wsUrl (监听 ${monitoredLeaders.size} 个 Leader)")
// 创建 HTTP 客户端(用于 RPC 调用)
val httpClient = createHttpClient()
// 创建 RPC API 客户端
val rpcApi = retrofitFactory.createEthereumRpcApi(httpUrl)
// 连接 WebSocket(只创建一个连接,会先关闭旧连接)
connectWebSocket(wsUrl, httpClient, rpcApi)
// 等待连接建立(最多等待 15 秒)
// 注意:onOpen 回调是异步的,需要等待一段时间
var waitCount = 0
val maxWait = 15 // 最多等待 15 秒
while (!isConnected && waitCount < maxWait && scope.isActive) {
delay(1000)
waitCount++
// 每 3 秒打印一次日志,方便调试
if (waitCount % 3 == 0) {
logger.debug("等待 WebSocket 连接建立... (${waitCount}/${maxWait}秒)")
}
}
// 检查连接状态(同时检查 isConnected 和 webSocket 状态)
val actuallyConnected = isConnected && webSocket != null
// 如果连接失败,等待重连延迟后继续
if (!actuallyConnected) {
logger.warn("WebSocket 连接超时或失败: isConnected=$isConnected, webSocket=${webSocket != null}, 等待重连")
delay(reconnectDelay)
continue
}
logger.info("WebSocket 连接已建立,开始监听")
// 连接成功后持续监听
waitForDisconnect()
// 连接断开后,如果没有 Leader 了,不再重连
if (monitoredLeaders.isEmpty()) {
logger.info("没有需要监听的 Leader,停止重连")
break
}
// 连接断开后,等待一下再重连(避免立即重连)
logger.info("WebSocket 连接断开,等待 ${reconnectDelay}ms 后重连")
delay(reconnectDelay)
} catch (e: Exception) {
// 如果没有 Leader 了,不再重连
if (monitoredLeaders.isEmpty()) {
logger.info("没有需要监听的 Leader,停止重连")
// 确保关闭连接
webSocket?.close(1000, "没有 Leader")
webSocket = null
isConnected = false
break
}
logger.warn("链上 WebSocket 连接失败,等待重连: ${e.message}")
// 确保关闭旧连接
webSocket?.close(1000, "重连前关闭")
webSocket = null
isConnected = false
delay(reconnectDelay)
}
}
}
/**
* 创建 HTTP 客户端
*/
private fun createHttpClient(): OkHttpClient {
val proxy = getProxyConfig()
val builder = createClient()
if (proxy != null) {
builder.proxy(proxy)
}
return builder.build()
}
/**
* 连接 WebSocket
* 确保只创建一个连接创建新连接前先关闭旧连接
*/
private fun connectWebSocket(wsUrl: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
// 先关闭旧连接(如果存在)
val oldWebSocket = webSocket
if (oldWebSocket != null) {
try {
oldWebSocket.close(1000, "重新连接")
} catch (e: Exception) {
logger.debug("关闭旧 WebSocket 连接时出错: ${e.message}")
}
}
webSocket = null
isConnected = false
val request = Request.Builder()
.url(wsUrl)
.build()
// 创建新连接(只创建一个)
webSocket = httpClient.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
isConnected = true
logger.info("链上 WebSocket 连接成功")
// 订阅所有 Leader
scope.launch {
monitoredLeaders.values.forEach { leader ->
subscribeLeader(leader)
}
}
}
override fun onMessage(webSocket: WebSocket, text: String) {
scope.launch {
handleMessage(text, httpClient, rpcApi)
}
}
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
scope.launch {
handleMessage(bytes.utf8(), httpClient, rpcApi)
}
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
isConnected = false
logger.warn("链上 WebSocket 连接关闭: code=$code, reason=$reason")
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
isConnected = false
logger.warn("链上 WebSocket 连接已关闭: code=$code, reason=$reason")
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
logger.error("链上 WebSocket 连接失败: ${t.message}", t)
isConnected = false
// 注意:这里不直接重连,由 startConnection 循环处理重连逻辑
}
})
}
/**
* 等待连接断开
*/
private suspend fun waitForDisconnect() {
while (isConnected && scope.isActive) {
delay(1000)
}
}
/**
* 订阅 Leader 钱包地址
* 每个 Leader 6 个订阅保存订阅 ID 以便后续取消
*/
private suspend fun subscribeLeader(leader: Leader) {
if (webSocket == null || !isConnected || leader.id == null) {
return
}
val walletAddress = leader.leaderAddress.lowercase()
val walletTopic = addressToTopic32(walletAddress)
val leaderId = leader.id!!
// 初始化该 Leader 的订阅列表
if (!leaderSubscriptions.containsKey(leaderId)) {
leaderSubscriptions[leaderId] = mutableListOf()
}
try {
// 订阅 USDC Transfer (from wallet)
subscribeLogs(USDC_CONTRACT, listOf(ERC20_TRANSFER_TOPIC, walletTopic), leaderId)
// 订阅 USDC Transfer (to wallet)
subscribeLogs(USDC_CONTRACT, listOf(ERC20_TRANSFER_TOPIC, null, walletTopic), leaderId)
// 订阅 ERC1155 TransferSingle (from wallet)
subscribeLogs(ERC1155_CONTRACT, listOf(ERC1155_TRANSFER_SINGLE_TOPIC, null, walletTopic), leaderId)
// 订阅 ERC1155 TransferSingle (to wallet)
subscribeLogs(ERC1155_CONTRACT, listOf(ERC1155_TRANSFER_SINGLE_TOPIC, null, null, walletTopic), leaderId)
// 订阅 ERC1155 TransferBatch (from wallet)
subscribeLogs(ERC1155_CONTRACT, listOf(ERC1155_TRANSFER_BATCH_TOPIC, null, walletTopic), leaderId)
// 订阅 ERC1155 TransferBatch (to wallet)
subscribeLogs(ERC1155_CONTRACT, listOf(ERC1155_TRANSFER_BATCH_TOPIC, null, null, walletTopic), leaderId)
logger.debug("已订阅 Leader 钱包地址: ${leader.leaderName} (${walletAddress})")
} catch (e: Exception) {
logger.error("订阅 Leader 失败: leaderId=$leaderId, address=$walletAddress", e)
}
}
/**
* 订阅日志
* @param address 合约地址
* @param topics 主题列表
* @param leaderId Leader ID用于关联订阅响应
*/
private fun subscribeLogs(address: String, topics: List<String?>, leaderId: Long) {
val ws = webSocket ?: return
val params = mapOf(
"address" to address.lowercase(),
"topics" to topics
)
val subscribeParams = listOf("logs", params)
val requestId = ++requestIdCounter
// 保存请求 ID 到 Leader ID 的映射
requestIdToLeaderId[requestId] = leaderId
val request = mapOf(
"jsonrpc" to "2.0",
"id" to requestId,
"method" to "eth_subscribe",
"params" to subscribeParams
)
val json = gson.toJson(request)
ws.send(json)
}
/**
* 取消订阅
* @param subscriptionId 订阅 ID
*/
private fun unsubscribe(subscriptionId: String) {
val ws = webSocket ?: return
val request = mapOf(
"jsonrpc" to "2.0",
"id" to (++requestIdCounter),
"method" to "eth_unsubscribe",
"params" to listOf(subscriptionId)
)
val json = gson.toJson(request)
ws.send(json)
logger.debug("已发送取消订阅请求: subscriptionId=$subscriptionId")
}
/**
* 处理 WebSocket 消息
*/
private suspend fun handleMessage(message: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
try {
// 使用 Gson 解析消息
val messageJson = gson.fromJson(message, JsonObject::class.java)
// 处理订阅响应(包含 subscription ID
val id = messageJson.get("id")
if (id != null && !id.isJsonNull) {
val result = messageJson.get("result")
if (result != null && result.isJsonPrimitive && result.asJsonPrimitive.isString) {
// 这是订阅响应,result 是 subscription ID(字符串)
val requestId = id.asInt
val subscriptionId = result.asString
val leaderId = requestIdToLeaderId.remove(requestId)
if (leaderId != null) {
// 保存订阅 ID 到 Leader
leaderSubscriptions.getOrPut(leaderId) { mutableListOf() }.add(subscriptionId)
logger.debug("收到订阅响应: leaderId=$leaderId, subscriptionId=$subscriptionId")
}
}
return
}
// 处理订阅通知(交易日志)
val method = messageJson.get("method")?.asString
if (method != "eth_subscription") {
return
}
val params = messageJson.getAsJsonObject("params") ?: return
// result 是一个对象,包含日志信息
val result = params.getAsJsonObject("result") ?: return
// 从 result 对象中获取 transactionHash(关键数据)
val txHash = result.get("transactionHash")?.asString
if (txHash.isNullOrEmpty()) {
logger.debug("订阅通知中缺少 transactionHash,跳过处理")
return
}
// 处理交易
processTransaction(txHash, httpClient, rpcApi)
} catch (e: Exception) {
logger.error("处理 WebSocket 消息失败: ${e.message}", e)
logger.debug("消息内容: $message", e)
}
}
/**
* 处理交易
*/
private suspend fun processTransaction(txHash: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
try {
// 获取交易 receipt
val receiptRequest = JsonRpcRequest(
@@ -550,376 +106,72 @@ class OnChainWsService(
return
}
// 使用 Gson 解析 receipt JSONresult 是 JsonElement
// 使用 Gson 解析 receipt JSON
val receiptJson = receiptRpcResponse.result.asJsonObject
// 获取区块号和时间戳
val blockNumber = receiptJson.get("blockNumber")?.asString
val blockTimestamp = if (blockNumber != null) {
getBlockTimestamp(blockNumber, rpcApi)
OnChainWsUtils.getBlockTimestamp(blockNumber, rpcApi)
} else {
null
}
// 解析 receipt 中的 Transfer 日志
val logs = receiptJson.getAsJsonArray("logs") ?: return
val (erc20Transfers, erc1155Transfers) = parseReceiptTransfers(logs)
val (erc20Transfers, erc1155Transfers) = OnChainWsUtils.parseReceiptTransfers(logs)
// 为每个 Leader 处理交易
for (leader in monitoredLeaders.values) {
val trade = parseTradeFromTransfers(
txHash = txHash,
timestamp = blockTimestamp,
walletAddress = leader.leaderAddress,
erc20Transfers = erc20Transfers,
erc1155Transfers = erc1155Transfers
// 解析交易信息
val trade = OnChainWsUtils.parseTradeFromTransfers(
txHash = txHash,
timestamp = blockTimestamp,
walletAddress = leader.leaderAddress,
erc20Transfers = erc20Transfers,
erc1155Transfers = erc1155Transfers,
retrofitFactory = retrofitFactory
)
if (trade != null) {
// 调用 processTrade 处理交易
copyOrderTrackingService.processTrade(
leaderId = leaderId,
trade = trade,
source = "onchain-ws"
)
if (trade != null) {
// 调用 processTrade 处理交易(元数据已在 parseTradeFromTransfers 中补齐)
copyOrderTrackingService.processTrade(
leaderId = leader.id!!,
trade = trade,
source = "onchain-ws"
)
}
}
} catch (e: Exception) {
logger.error("处理交易失败: txHash=$txHash, ${e.message}", e)
logger.error("处理 Leader 交易失败: leaderId=$leaderId, txHash=$txHash, ${e.message}", e)
}
}
/**
* 解析 receipt 中的 Transfer 日志
* 移除Leader监听
* 取消该 Leader 的订阅
*/
private fun parseReceiptTransfers(logs: com.google.gson.JsonArray): Pair<List<Erc20Transfer>, List<Erc1155Transfer>> {
val erc20 = mutableListOf<Erc20Transfer>()
val erc1155 = mutableListOf<Erc1155Transfer>()
fun removeLeader(leaderId: Long) {
monitoredLeaders.remove(leaderId)
for (logElement in logs) {
val log = logElement.asJsonObject
val address = log.get("address")?.asString?.lowercase() ?: continue
val topicsArray = log.getAsJsonArray("topics") ?: continue
val topics = topicsArray.mapNotNull { it.asString }
if (topics.isEmpty()) continue
val t0 = topics[0].lowercase()
val data = log.get("data")?.asString ?: "0x"
// USDC ERC20 Transfer
if (address == USDC_CONTRACT.lowercase() && t0 == ERC20_TRANSFER_TOPIC && topics.size >= 3) {
val from = topicToAddress(topics[1])
val to = topicToAddress(topics[2])
val value = hexToBigInt(data)
erc20.add(Erc20Transfer(from, to, value))
continue
}
// ERC1155 TransferSingle
if (t0 == ERC1155_TRANSFER_SINGLE_TOPIC && topics.size >= 4) {
val from = topicToAddress(topics[2])
val to = topicToAddress(topics[3])
val bytes = bytesFromHex(data)
if (bytes.size >= 64) {
val tokenId = sliceBigInt32(bytes, 0)
val value = sliceBigInt32(bytes, 32)
erc1155.add(Erc1155Transfer(from, to, tokenId, value))
}
continue
}
// ERC1155 TransferBatch
if (t0 == ERC1155_TRANSFER_BATCH_TOPIC && topics.size >= 4) {
val from = topicToAddress(topics[2])
val to = topicToAddress(topics[3])
val bytes = bytesFromHex(data)
if (bytes.size < 64) continue
val offIds = sliceBigInt32(bytes, 0).toInt()
val offVals = sliceBigInt32(bytes, 32).toInt()
if (offIds + 32 > bytes.size || offVals + 32 > bytes.size) continue
val nIds = sliceBigInt32(bytes, offIds).toInt()
val nVals = sliceBigInt32(bytes, offVals).toInt()
if (nIds != nVals) continue
val idsStart = offIds + 32
val valsStart = offVals + 32
for (i in 0 until nIds) {
val ib = idsStart + i * 32
val vb = valsStart + i * 32
if (ib + 32 > bytes.size || vb + 32 > bytes.size) break
val tokenId = sliceBigInt32(bytes, ib)
val value = sliceBigInt32(bytes, vb)
erc1155.add(Erc1155Transfer(from, to, tokenId, value))
}
}
}
// 通过统一服务取消订阅
val subscriptionId = "LEADER_$leaderId"
unifiedOnChainWsService.unsubscribe(subscriptionId)
return Pair(erc20, erc1155)
logger.info("移除 Leader 监听: leaderId=$leaderId")
}
/**
* Transfer 日志解析交易信息
* 停止监听
*/
private suspend fun parseTradeFromTransfers(
txHash: String,
timestamp: Long?,
walletAddress: String,
erc20Transfers: List<Erc20Transfer>,
erc1155Transfers: List<Erc1155Transfer>
): TradeResponse? {
val wallet = walletAddress.lowercase()
// 计算 USDC 流入和流出
val usdcOut = erc20Transfers.filter { it.from.lowercase() == wallet }
.fold(BigInteger.ZERO) { acc, t -> acc + t.value }
val usdcIn = erc20Transfers.filter { it.to.lowercase() == wallet }
.fold(BigInteger.ZERO) { acc, t -> acc + t.value }
// 计算 ERC1155 流入和流出(按 tokenId 聚合)
val inById = mutableMapOf<BigInteger, BigInteger>()
val outById = mutableMapOf<BigInteger, BigInteger>()
for (t in erc1155Transfers) {
if (t.to.lowercase() == wallet) {
inById[t.tokenId] = (inById[t.tokenId] ?: BigInteger.ZERO) + t.value
}
if (t.from.lowercase() == wallet) {
outById[t.tokenId] = (outById[t.tokenId] ?: BigInteger.ZERO) + t.value
}
fun stop() {
// 取消所有 Leader 的订阅
val leaderIds = monitoredLeaders.keys.toList()
for (leaderId in leaderIds) {
removeLeader(leaderId)
}
// 找到最大的流入和流出 tokenId
fun best(map: Map<BigInteger, BigInteger>): Pair<BigInteger?, BigInteger> =
map.entries.maxByOrNull { it.value }?.let { it.key to it.value } ?: (null to BigInteger.ZERO)
val (bestInId, bestInVal) = best(inById)
val (bestOutId, bestOutVal) = best(outById)
// 判断交易方向
var side: String? = null
var asset: BigInteger? = null
var sizeRaw = BigInteger.ZERO
var usdcRaw = BigInteger.ZERO
if (bestInId != null && bestInVal > BigInteger.ZERO && usdcOut > BigInteger.ZERO) {
// BUY: 收到 token,支付 USDC
side = "BUY"
asset = bestInId
sizeRaw = bestInVal
usdcRaw = usdcOut
} else if (bestOutId != null && bestOutVal > BigInteger.ZERO && usdcIn > BigInteger.ZERO) {
// SELL: 卖出 token,收到 USDC
side = "SELL"
asset = bestOutId
sizeRaw = bestOutVal
usdcRaw = usdcIn
} else {
// 无法判断交易方向
return null
}
// 计算价格和数量(USDC 有 6 位小数,shares 也有 6 位小数)
val usdcSize = usdcRaw.toBigDecimal().divide(BigInteger("1000000").toBigDecimal(), 8, java.math.RoundingMode.DOWN)
val size = sizeRaw.toBigDecimal().divide(BigInteger("1000000").toBigDecimal(), 8, java.math.RoundingMode.DOWN)
val price = if (size.signum() > 0) {
usdcSize.divide(size, 8, java.math.RoundingMode.DOWN)
} else {
return null
}
// 尝试通过 Gamma API 查询市场信息(通过 tokenId)
val marketInfo = fetchMarketByTokenId(asset.toString())
// 创建 TradeResponse
return TradeResponse(
id = txHash,
market = marketInfo?.conditionId ?: "",
side = side,
price = price.toPlainString(),
size = size.toPlainString(),
timestamp = (timestamp ?: System.currentTimeMillis() / 1000).toString(),
user = walletAddress,
outcomeIndex = marketInfo?.outcomeIndex,
outcome = marketInfo?.outcome
)
}
/**
* 通过 Gamma API 查询市场信息通过 tokenId
*/
private suspend fun fetchMarketByTokenId(tokenId: String): MarketInfo? {
return try {
// 使用 HTTP 请求直接调用 Gamma API(因为 Retrofit 接口可能不支持 clob_token_ids 参数)
val httpClient = createHttpClient()
val url = "https://gamma-api.polymarket.com/markets?clob_token_ids=$tokenId"
val request = okhttp3.Request.Builder()
.url(url)
.get()
.build()
val response = httpClient.newCall(request).execute()
if (!response.isSuccessful || response.body == null) {
return null
}
val responseBody = response.body!!.string()
// 使用 Gson 解析市场列表
val marketsType = object : TypeToken<List<JsonObject>>() {}.type
val markets = gson.fromJson<List<JsonObject>>(responseBody, marketsType)
if (markets.isEmpty()) {
return null
}
val market = markets.first()
// 解析 clob_token_ids(可能是 JSON 字符串或数组)
val clobTokenIdsRaw = market.get("clobTokenIds") ?: market.get("clob_token_ids")
val clobTokenIds = when {
clobTokenIdsRaw == null || clobTokenIdsRaw.isJsonNull -> null
clobTokenIdsRaw.isJsonPrimitive && clobTokenIdsRaw.asJsonPrimitive.isString -> {
// 尝试解析 JSON 字符串
try {
val listType = object : TypeToken<List<String>>() {}.type
gson.fromJson<List<String>>(clobTokenIdsRaw.asString, listType)
} catch (e: Exception) {
null
}
}
clobTokenIdsRaw.isJsonArray -> {
clobTokenIdsRaw.asJsonArray.mapNotNull { it.asString }
}
else -> null
}
// 解析 outcomes(可能是 JSON 字符串或数组)
val outcomesRaw = market.get("outcomes")
val outcomes = when {
outcomesRaw == null || outcomesRaw.isJsonNull -> null
outcomesRaw.isJsonPrimitive && outcomesRaw.asJsonPrimitive.isString -> {
try {
val listType = object : TypeToken<List<String>>() {}.type
gson.fromJson<List<String>>(outcomesRaw.asString, listType)
} catch (e: Exception) {
null
}
}
outcomesRaw.isJsonArray -> {
outcomesRaw.asJsonArray.mapNotNull { it.asString }
}
else -> null
}
// 查找 tokenId 在 clobTokenIds 中的索引
val outcomeIndex = clobTokenIds?.indexOfFirst {
it.equals(tokenId, ignoreCase = true)
}?.takeIf { it >= 0 }
// 获取 outcome 名称
val outcome = if (outcomeIndex != null && outcomes != null && outcomeIndex < outcomes.size) {
outcomes[outcomeIndex]
} else {
null
}
val conditionId = market.get("conditionId")?.asString ?: return null
MarketInfo(
conditionId = conditionId,
outcomeIndex = outcomeIndex,
outcome = outcome
)
} catch (e: Exception) {
logger.warn("通过 Gamma API 查询市场信息失败: tokenId=$tokenId, ${e.message}")
null
}
}
/**
* 市场信息 Gamma API 获取
*/
private data class MarketInfo(
val conditionId: String,
val outcomeIndex: Int?,
val outcome: String?
)
/**
* 获取区块时间戳
*/
private suspend fun getBlockTimestamp(blockNumber: String, rpcApi: EthereumRpcApi): Long? {
return try {
val blockRequest = JsonRpcRequest(
method = "eth_getBlockByNumber",
params = listOf(blockNumber, false)
)
val blockResponse = rpcApi.call(blockRequest)
if (!blockResponse.isSuccessful || blockResponse.body() == null) {
return null
}
val blockRpcResponse = blockResponse.body()!!
if (blockRpcResponse.error != null || blockRpcResponse.result == null) {
return null
}
// 使用 Gson 解析 block JSONresult 是 JsonElement
val blockJson = blockRpcResponse.result.asJsonObject
val timestampHex = blockJson.get("timestamp")?.asString ?: return null
hexToBigInt(timestampHex).toLong()
} catch (e: Exception) {
logger.warn("获取区块时间戳失败: ${e.message}")
null
}
}
// 辅助函数
private data class Erc20Transfer(val from: String, val to: String, val value: BigInteger)
private data class Erc1155Transfer(val from: String, val to: String, val tokenId: BigInteger, val value: BigInteger)
private fun topicToAddress(topic: String): String {
val t = topic.removePrefix("0x").lowercase()
return "0x" + t.takeLast(40)
}
private fun hexToBigInt(hex: String): BigInteger {
val h = hex.removePrefix("0x")
if (h.isEmpty()) return BigInteger.ZERO
return BigInteger(h, 16)
}
private fun bytesFromHex(hex: String): ByteArray {
val s = hex.removePrefix("0x")
if (s.isEmpty()) return ByteArray(0)
val out = ByteArray(s.length / 2)
var i = 0
while (i < s.length) {
out[i / 2] = s.substring(i, i + 2).toInt(16).toByte()
i += 2
}
return out
}
private fun sliceBigInt32(b: ByteArray, offset: Int): BigInteger {
val sub = b.copyOfRange(offset, offset + 32)
return BigInteger(1, sub)
}
/**
* 地址转换为 32 字节 topic 24 字节为 0 8 字节为地址
*/
private fun addressToTopic32(address: String): String {
val addr = address.removePrefix("0x").lowercase()
return "0x" + "0".repeat(24) + addr
monitoredLeaders.clear()
}
@PreDestroy
fun destroy() {
stop()
scope.cancel()
}
}
@@ -0,0 +1,377 @@
package com.wrbug.polymarketbot.service.copytrading.monitor
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.JsonArray
import com.google.gson.reflect.TypeToken
import com.wrbug.polymarketbot.api.*
import com.wrbug.polymarketbot.service.system.RpcNodeService
import com.wrbug.polymarketbot.util.RetrofitFactory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import org.slf4j.LoggerFactory
import java.math.BigInteger
/**
* 链上 WebSocket 工具类
* 提取公共的交易解析工具函数等逻辑
*/
object OnChainWsUtils {
private val logger = LoggerFactory.getLogger(OnChainWsUtils::class.java)
// 创建 Gson 实例(与 GsonConfig 中的配置一致,使用 lenient 模式)
private val gson: Gson = GsonBuilder()
.setLenient()
.create()
/**
* 解析 JSON 字符串数组
* @param jsonString JSON 字符串 "[\"Yes\", \"No\"]"
* @return 字符串列表如果解析失败返回空列表
*/
private 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()
}
}
// 合约地址
const val USDC_CONTRACT = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
const val ERC1155_CONTRACT = "0x4d97dcd97ec945f40cf65f87097ace5ea0476045"
const val ERC20_TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
const val ERC1155_TRANSFER_SINGLE_TOPIC = "0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62"
const val ERC1155_TRANSFER_BATCH_TOPIC = "0x4a39dc06d4c0dbc64b70af90fd698a233a518aa5d07e595d983b8c0526c8f7fb"
/**
* ERC20 Transfer 数据类
*/
data class Erc20Transfer(
val from: String,
val to: String,
val value: BigInteger
)
/**
* ERC1155 Transfer 数据类
*/
data class Erc1155Transfer(
val from: String,
val to: String,
val tokenId: BigInteger,
val value: BigInteger
)
/**
* 市场信息数据类
*/
data class MarketInfo(
val conditionId: String,
val outcomeIndex: Int?, // 可空,因为可能找不到对应的 tokenId
val outcome: String?
)
/**
* 解析 receipt 中的 Transfer 日志
*/
fun parseReceiptTransfers(logs: JsonArray): Pair<List<Erc20Transfer>, List<Erc1155Transfer>> {
val erc20 = mutableListOf<Erc20Transfer>()
val erc1155 = mutableListOf<Erc1155Transfer>()
for (logElement in logs) {
val log = logElement.asJsonObject
val address = log.get("address")?.asString?.lowercase() ?: continue
val topicsArray = log.getAsJsonArray("topics") ?: continue
val topics = topicsArray.mapNotNull { it.asString }
if (topics.isEmpty()) continue
val t0 = topics[0].lowercase()
val data = log.get("data")?.asString ?: "0x"
// USDC ERC20 Transfer
if (address == USDC_CONTRACT.lowercase() && t0 == ERC20_TRANSFER_TOPIC && topics.size >= 3) {
val from = topicToAddress(topics[1])
val to = topicToAddress(topics[2])
val value = hexToBigInt(data)
erc20.add(Erc20Transfer(from, to, value))
continue
}
// ERC1155 TransferSingle
if (t0 == ERC1155_TRANSFER_SINGLE_TOPIC && topics.size >= 4) {
val from = topicToAddress(topics[2])
val to = topicToAddress(topics[3])
val bytes = bytesFromHex(data)
if (bytes.size >= 64) {
val tokenId = sliceBigInt32(bytes, 0)
val value = sliceBigInt32(bytes, 32)
erc1155.add(Erc1155Transfer(from, to, tokenId, value))
}
continue
}
// ERC1155 TransferBatch
if (t0 == ERC1155_TRANSFER_BATCH_TOPIC && topics.size >= 4) {
val from = topicToAddress(topics[2])
val to = topicToAddress(topics[3])
val bytes = bytesFromHex(data)
if (bytes.size < 64) continue
val offIds = sliceBigInt32(bytes, 0).toInt()
val offVals = sliceBigInt32(bytes, 32).toInt()
if (offIds + 32 > bytes.size || offVals + 32 > bytes.size) continue
val nIds = sliceBigInt32(bytes, offIds).toInt()
val nVals = sliceBigInt32(bytes, offVals).toInt()
if (nIds != nVals) continue
val idsStart = offIds + 32
val valsStart = offVals + 32
for (i in 0 until nIds) {
val ib = idsStart + i * 32
val vb = valsStart + i * 32
if (ib + 32 > bytes.size || vb + 32 > bytes.size) break
val tokenId = sliceBigInt32(bytes, ib)
val value = sliceBigInt32(bytes, vb)
erc1155.add(Erc1155Transfer(from, to, tokenId, value))
}
}
}
return Pair(erc20, erc1155)
}
/**
* Transfer 日志解析交易信息
*/
suspend fun parseTradeFromTransfers(
txHash: String,
timestamp: Long?,
walletAddress: String,
erc20Transfers: List<Erc20Transfer>,
erc1155Transfers: List<Erc1155Transfer>,
retrofitFactory: RetrofitFactory
): TradeResponse? {
val wallet = walletAddress.lowercase()
// 计算 USDC 流入和流出
val usdcOut = erc20Transfers.filter { it.from.lowercase() == wallet }
.fold(BigInteger.ZERO) { acc, t -> acc + t.value }
val usdcIn = erc20Transfers.filter { it.to.lowercase() == wallet }
.fold(BigInteger.ZERO) { acc, t -> acc + t.value }
// 计算 ERC1155 流入和流出(按 tokenId 聚合)
val inById = mutableMapOf<BigInteger, BigInteger>()
val outById = mutableMapOf<BigInteger, BigInteger>()
for (t in erc1155Transfers) {
if (t.to.lowercase() == wallet) {
inById[t.tokenId] = (inById[t.tokenId] ?: BigInteger.ZERO) + t.value
}
if (t.from.lowercase() == wallet) {
outById[t.tokenId] = (outById[t.tokenId] ?: BigInteger.ZERO) + t.value
}
}
// 找到最大的流入和流出 tokenId
fun best(map: Map<BigInteger, BigInteger>): Pair<BigInteger?, BigInteger> =
map.entries.maxByOrNull { it.value }?.let { it.key to it.value } ?: (null to BigInteger.ZERO)
val (bestInId, bestInVal) = best(inById)
val (bestOutId, bestOutVal) = best(outById)
// 判断交易方向
var side: String? = null
var asset: BigInteger? = null
var sizeRaw = BigInteger.ZERO
var usdcRaw = BigInteger.ZERO
if (bestInId != null && bestInVal > BigInteger.ZERO && usdcOut > BigInteger.ZERO) {
// BUY: 收到 token,支付 USDC
side = "BUY"
asset = bestInId
sizeRaw = bestInVal
usdcRaw = usdcOut
} else if (bestOutId != null && bestOutVal > BigInteger.ZERO && usdcIn > BigInteger.ZERO) {
// SELL: 卖出 token,收到 USDC
side = "SELL"
asset = bestOutId
sizeRaw = bestOutVal
usdcRaw = usdcIn
} else {
// 无法判断交易方向
return null
}
// 计算价格和数量(USDC 有 6 位小数,shares 也有 6 位小数)
val usdcSize = usdcRaw.toBigDecimal().divide(BigInteger("1000000").toBigDecimal(), 8, java.math.RoundingMode.DOWN)
val size = sizeRaw.toBigDecimal().divide(BigInteger("1000000").toBigDecimal(), 8, java.math.RoundingMode.DOWN)
val price = if (size.signum() > 0) {
usdcSize.divide(size, 8, java.math.RoundingMode.DOWN)
} else {
return null
}
// 尝试通过 Gamma API 查询市场信息(通过 tokenId)
val marketInfo = fetchMarketByTokenId(asset.toString(), retrofitFactory)
// 创建 TradeResponse
return TradeResponse(
id = txHash,
market = marketInfo?.conditionId ?: "",
side = side,
price = price.toPlainString(),
size = size.toPlainString(),
timestamp = (timestamp ?: System.currentTimeMillis() / 1000).toString(),
user = walletAddress,
outcomeIndex = marketInfo?.outcomeIndex,
outcome = marketInfo?.outcome
)
}
/**
* 通过 Gamma API 查询市场信息通过 tokenId
* 使用 Retrofit 接口支持 clob_token_ids 参数
*/
suspend fun fetchMarketByTokenId(tokenId: String, retrofitFactory: RetrofitFactory): MarketInfo? {
return try {
val gammaApi = retrofitFactory.createGammaApi()
val marketsResponse = gammaApi.listMarkets(
conditionIds = null,
clobTokenIds = listOf(tokenId),
includeTag = null
)
if (!marketsResponse.isSuccessful || marketsResponse.body() == null) {
return null
}
val markets = marketsResponse.body()!!
val market = markets.firstOrNull()
if (market == null) {
return null
}
// 解析 clobTokenIds(可能是 JSON 字符串或数组)
val clobTokenIdsRaw = market.clobTokenIds ?: market.clob_token_ids
val clobTokenIds = when {
clobTokenIdsRaw == null -> null
else -> {
// 解析 JSON 字符串
parseStringArray(clobTokenIdsRaw)
}
}
// 解析 outcomes(可能是 JSON 字符串或数组)
val outcomes = parseStringArray(market.outcomes)
// 查找 tokenId 在 clobTokenIds 中的索引
val outcomeIndex = clobTokenIds?.indexOfFirst { token ->
token.equals(tokenId, ignoreCase = true)
}?.takeIf { it >= 0 }
// 获取 outcome 名称
val outcome = if (outcomeIndex != null && outcomes.isNotEmpty() && outcomeIndex < outcomes.size) {
outcomes[outcomeIndex]
} else {
null
}
val conditionId = market.conditionId ?: return null
MarketInfo(
conditionId = conditionId,
outcomeIndex = outcomeIndex,
outcome = outcome
)
} catch (e: Exception) {
logger.warn("查询市场信息失败: tokenId=$tokenId, error=${e.message}")
null
}
}
/**
* 获取区块时间戳
*/
suspend fun getBlockTimestamp(blockNumber: String, rpcApi: EthereumRpcApi): Long? {
return try {
val blockRequest = JsonRpcRequest(
method = "eth_getBlockByNumber",
params = listOf(blockNumber, false)
)
val blockResponse = rpcApi.call(blockRequest)
if (blockResponse.isSuccessful && blockResponse.body() != null) {
val blockRpcResponse = blockResponse.body()!!
if (blockRpcResponse.error == null && blockRpcResponse.result != null) {
val blockJson = blockRpcResponse.result.asJsonObject
val timestampHex = blockJson.get("timestamp")?.asString
if (timestampHex != null) {
BigInteger(timestampHex.removePrefix("0x"), 16).toLong() * 1000 // 转换为毫秒
} else {
null
}
} else {
null
}
} else {
null
}
} catch (e: Exception) {
logger.warn("获取区块时间戳失败: blockNumber=$blockNumber, error=${e.message}")
null
}
}
/**
* 工具函数地址转 topic32字节左对齐
*/
fun addressToTopic32(address: String): String {
val clean = address.removePrefix("0x").lowercase()
return "0x" + clean.padStart(64, '0')
}
/**
* 工具函数topic 转地址
*/
fun topicToAddress(topic: String): String {
val clean = topic.removePrefix("0x").lowercase()
return "0x" + clean.takeLast(40)
}
/**
* 工具函数十六进制转 BigInteger
*/
fun hexToBigInt(hex: String): BigInteger {
val clean = hex.removePrefix("0x")
return if (clean.isBlank()) BigInteger.ZERO else BigInteger(clean, 16)
}
/**
* 工具函数十六进制转字节数组
*/
fun bytesFromHex(hex: String): ByteArray {
val clean = hex.removePrefix("0x")
return ByteArray(clean.length / 2) { i ->
clean.substring(i * 2, i * 2 + 2).toInt(16).toByte()
}
}
/**
* 工具函数从字节数组切片 BigInteger32字节
*/
fun sliceBigInt32(bytes: ByteArray, offset: Int): BigInteger {
if (offset + 32 > bytes.size) return BigInteger.ZERO
val slice = bytes.sliceArray(offset until offset + 32)
return BigInteger(1, slice)
}
}
@@ -0,0 +1,565 @@
package com.wrbug.polymarketbot.service.copytrading.monitor
import com.google.gson.Gson
import com.google.gson.JsonObject
import com.wrbug.polymarketbot.api.*
import com.wrbug.polymarketbot.service.system.RpcNodeService
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.createClient
import com.wrbug.polymarketbot.util.getProxyConfig
import jakarta.annotation.PostConstruct
import jakarta.annotation.PreDestroy
import kotlinx.coroutines.*
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.ByteString
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import java.util.concurrent.ConcurrentHashMap
/**
* 统一的链上 WebSocket 服务
* 管理唯一的 WebSocket 连接其他服务通过订阅的方式接收链上事件
*/
@Service
class UnifiedOnChainWsService(
private val rpcNodeService: RpcNodeService,
private val retrofitFactory: RetrofitFactory,
private val gson: Gson
) {
private val logger = LoggerFactory.getLogger(UnifiedOnChainWsService::class.java)
@Value("\${copy.trading.onchain.ws.reconnect.delay:3000}")
private var reconnectDelay: Long = 3000 // 重连延迟(毫秒),默认3秒
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
// WebSocket 连接(唯一)
private var webSocket: WebSocket? = null
@Volatile
private var isConnected = false
// 订阅ID计数器(用于请求 ID
private var requestIdCounter = 0
// 连接任务(确保只有一个连接任务在运行)
private var connectionJob: Job? = null
// 存储所有订阅:subscriptionId -> 订阅信息
private val subscriptions = ConcurrentHashMap<String, SubscriptionInfo>()
// 存储请求 ID 到订阅 ID 的映射:requestId -> subscriptionId
// 用于在收到订阅响应时,将 subscription ID 关联到对应的订阅
private val requestIdToSubscriptionId = ConcurrentHashMap<Int, String>()
// 存储 RPC subscriptionId 到订阅 ID 的映射:rpcSubscriptionId -> subscriptionId
// 用于在收到日志通知时,知道是哪个订阅
private val rpcSubscriptionIdToSubscriptionId = ConcurrentHashMap<String, String>()
/**
* 订阅信息
*/
data class SubscriptionInfo(
val subscriptionId: String, // 订阅的唯一标识
val address: String, // 要监听的地址(Leader 地址或账户代理地址)
val entityType: String, // 实体类型:LEADER 或 ACCOUNT
val entityId: Long, // 实体 IDLeader ID 或 Account ID
val callback: suspend (String, OkHttpClient, EthereumRpcApi) -> Unit // 回调函数
)
/**
* 订阅地址监听
* @param subscriptionId 订阅的唯一标识建议格式"{entityType}_{entityId}"
* @param address 要监听的地址Leader 地址或账户代理地址
* @param entityType 实体类型LEADER ACCOUNT
* @param entityId 实体 IDLeader ID Account ID
* @param callback 回调函数当检测到该地址的交易时调用
* @return 是否订阅成功
*/
fun subscribe(
subscriptionId: String,
address: String,
entityType: String,
entityId: Long,
callback: suspend (String, OkHttpClient, EthereumRpcApi) -> Unit
): Boolean {
try {
// 如果已经订阅,先取消
if (subscriptions.containsKey(subscriptionId)) {
unsubscribe(subscriptionId)
}
// 创建订阅信息
val subscription = SubscriptionInfo(
subscriptionId = subscriptionId,
address = address.lowercase(),
entityType = entityType,
entityId = entityId,
callback = callback
)
subscriptions[subscriptionId] = subscription
// 如果已连接,立即订阅
if (isConnected) {
scope.launch {
subscribeAddress(subscription)
}
} else {
// 如果未连接,启动连接
startConnection()
}
logger.info("订阅地址监听: subscriptionId=$subscriptionId, address=$address, entityType=$entityType, entityId=$entityId")
return true
} catch (e: Exception) {
logger.error("订阅地址监听失败: subscriptionId=$subscriptionId, address=$address, error=${e.message}", e)
return false
}
}
/**
* 取消订阅
*/
fun unsubscribe(subscriptionId: String) {
val subscription = subscriptions.remove(subscriptionId)
if (subscription != null && isConnected) {
// 取消该订阅的所有 RPC 订阅
scope.launch {
// 查找该订阅的所有 RPC subscriptionId
val rpcSubscriptionIds = rpcSubscriptionIdToSubscriptionId.entries
.filter { it.value == subscriptionId }
.map { it.key }
for (rpcSubId in rpcSubscriptionIds) {
unsubscribeRpc(rpcSubId)
rpcSubscriptionIdToSubscriptionId.remove(rpcSubId)
}
}
logger.info("取消订阅: subscriptionId=$subscriptionId")
}
// 如果没有订阅了,停止连接
if (subscriptions.isEmpty()) {
stop()
}
}
/**
* 启动连接如果还没有连接
*/
private fun startConnection() {
// 如果没有订阅,不启动连接
if (subscriptions.isEmpty()) {
return
}
// 如果连接任务已经在运行,不重复启动
if (connectionJob != null && connectionJob!!.isActive) {
return
}
// 启动连接任务
connectionJob = scope.launch {
startConnectionLoop()
}
}
/**
* 启动连接循环
*/
private suspend fun startConnectionLoop() {
while (scope.isActive) {
try {
// 如果没有订阅,停止连接
if (subscriptions.isEmpty()) {
logger.info("没有订阅,停止连接")
stop()
break
}
// 如果已经连接,等待断开
if (isConnected && webSocket != null) {
waitForDisconnect()
continue
}
// 获取可用的 RPC 节点
val wsUrl = rpcNodeService.getWsUrl()
val httpUrl = rpcNodeService.getHttpUrl()
if (wsUrl.isBlank() || httpUrl.isBlank()) {
logger.warn("没有可用的 RPC 节点,等待重试...")
delay(reconnectDelay)
continue
}
logger.info("连接链上 WebSocket: $wsUrl (${subscriptions.size} 个订阅)")
// 创建 HTTP 客户端(用于 RPC 调用)
val httpClient = createHttpClient()
// 创建 RPC API 客户端
val rpcApi = retrofitFactory.createEthereumRpcApi(httpUrl)
// 连接 WebSocket
connectWebSocket(wsUrl, httpClient, rpcApi)
// 等待连接建立
waitForConnect()
// 如果连接成功,订阅所有地址
if (isConnected) {
logger.info("WebSocket 连接已建立,开始订阅")
for (subscription in subscriptions.values) {
subscribeAddress(subscription)
}
// 等待连接断开
waitForDisconnect()
}
// 连接断开后,如果没有订阅了,不再重连
if (subscriptions.isEmpty()) {
logger.info("没有订阅,停止重连")
break
}
// 等待后重连
logger.info("WebSocket 连接断开,等待 ${reconnectDelay}ms 后重连")
delay(reconnectDelay)
} catch (e: Exception) {
logger.error("连接异常: ${e.message}", e)
delay(reconnectDelay)
}
}
}
/**
* 创建 HTTP 客户端
*/
private fun createHttpClient(): OkHttpClient {
val proxy = getProxyConfig()
val builder = createClient()
if (proxy != null) {
builder.proxy(proxy)
}
return builder.build()
}
/**
* 连接 WebSocket
*/
private fun connectWebSocket(wsUrl: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
// 先关闭旧连接
webSocket?.close(1000, "重新连接")
webSocket = null
isConnected = false
val request = Request.Builder()
.url(wsUrl)
.build()
webSocket = httpClient.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
isConnected = true
logger.info("链上 WebSocket 连接成功")
}
override fun onMessage(webSocket: WebSocket, text: String) {
scope.launch {
handleMessage(text, httpClient, rpcApi)
}
}
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
scope.launch {
handleMessage(bytes.utf8(), httpClient, rpcApi)
}
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
isConnected = false
logger.warn("链上 WebSocket 连接关闭: code=$code, reason=$reason")
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
isConnected = false
logger.warn("链上 WebSocket 连接已关闭: code=$code, reason=$reason")
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
logger.error("链上 WebSocket 连接失败: ${t.message}", t)
isConnected = false
}
})
}
/**
* 等待连接建立
*/
private suspend fun waitForConnect() {
var waited = 0L
val timeout = 15000L // 15秒超时
while (!isConnected && waited < timeout) {
delay(100)
waited += 100
}
if (!isConnected) {
logger.warn("WebSocket 连接超时,等待重连")
}
}
/**
* 等待连接断开
*/
private suspend fun waitForDisconnect() {
while (isConnected && scope.isActive) {
delay(1000)
}
}
/**
* 订阅地址为每个地址订阅 6 个事件
*/
private suspend fun subscribeAddress(subscription: SubscriptionInfo) {
if (webSocket == null || !isConnected) {
return
}
val address = subscription.address
val walletTopic = OnChainWsUtils.addressToTopic32(address)
val subscriptionId = subscription.subscriptionId
try {
// 订阅 USDC Transfer (from wallet)
subscribeLogs(OnChainWsUtils.USDC_CONTRACT, listOf(OnChainWsUtils.ERC20_TRANSFER_TOPIC, walletTopic), subscriptionId)
// 订阅 USDC Transfer (to wallet)
subscribeLogs(OnChainWsUtils.USDC_CONTRACT, listOf(OnChainWsUtils.ERC20_TRANSFER_TOPIC, null, walletTopic), subscriptionId)
// 订阅 ERC1155 TransferSingle (from wallet)
subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_SINGLE_TOPIC, null, walletTopic), subscriptionId)
// 订阅 ERC1155 TransferSingle (to wallet)
subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_SINGLE_TOPIC, null, null, walletTopic), subscriptionId)
// 订阅 ERC1155 TransferBatch (from wallet)
subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_BATCH_TOPIC, null, walletTopic), subscriptionId)
// 订阅 ERC1155 TransferBatch (to wallet)
subscribeLogs(OnChainWsUtils.ERC1155_CONTRACT, listOf(OnChainWsUtils.ERC1155_TRANSFER_BATCH_TOPIC, null, null, walletTopic), subscriptionId)
logger.debug("已订阅地址: subscriptionId=$subscriptionId, address=$address")
} catch (e: Exception) {
logger.error("订阅地址失败: subscriptionId=$subscriptionId, address=$address, error=${e.message}", e)
}
}
/**
* 订阅日志
*/
private fun subscribeLogs(address: String, topics: List<String?>, subscriptionId: String) {
val ws = webSocket ?: return
val params = mapOf(
"address" to address.lowercase(),
"topics" to topics.filterNotNull()
)
val requestId = ++requestIdCounter
requestIdToSubscriptionId[requestId] = subscriptionId
val request = mapOf(
"jsonrpc" to "2.0",
"id" to requestId,
"method" to "eth_subscribe",
"params" to listOf("logs", params)
)
val message = gson.toJson(request)
ws.send(message)
}
/**
* 取消 RPC 订阅
*/
private fun unsubscribeRpc(rpcSubscriptionId: String) {
val ws = webSocket ?: return
val requestId = ++requestIdCounter
val request = mapOf(
"jsonrpc" to "2.0",
"id" to requestId,
"method" to "eth_unsubscribe",
"params" to listOf(rpcSubscriptionId)
)
val message = gson.toJson(request)
ws.send(message)
}
/**
* 处理 WebSocket 消息
*/
private suspend fun handleMessage(text: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
try {
val message = gson.fromJson(text, JsonObject::class.java)
// 处理订阅响应
if (message.has("result") && message.has("id")) {
val requestId = message.get("id")?.asInt
val rpcSubscriptionId = message.get("result")?.asString
if (requestId != null && rpcSubscriptionId != null) {
val subscriptionId = requestIdToSubscriptionId.remove(requestId)
if (subscriptionId != null) {
// 保存 RPC subscriptionId 到订阅的映射
rpcSubscriptionIdToSubscriptionId[rpcSubscriptionId] = subscriptionId
logger.debug("订阅成功: subscriptionId=$subscriptionId, rpcSubscriptionId=$rpcSubscriptionId")
}
}
return
}
// 处理日志通知
if (message.has("params")) {
val params = message.getAsJsonObject("params")
val subscriptionIdParam = params.get("subscription")?.asString
val result = params.getAsJsonObject("result")
if (result != null) {
val txHash = result.get("transactionHash")?.asString
if (txHash != null && subscriptionIdParam != null) {
// 根据 RPC subscriptionId 找到对应的订阅
val subscriptionId = rpcSubscriptionIdToSubscriptionId[subscriptionIdParam]
if (subscriptionId != null) {
// 处理交易,分发给对应的订阅者
processTransactionForSubscription(txHash, subscriptionId, httpClient, rpcApi)
} else {
// 如果没有找到订阅,可能是新订阅还未建立映射,尝试处理所有订阅
processTransaction(txHash, httpClient, rpcApi)
}
}
}
}
} catch (e: Exception) {
logger.error("处理 WebSocket 消息失败: ${e.message}", e)
}
}
/**
* 处理交易为特定订阅
* 直接调用订阅的回调
*/
private suspend fun processTransactionForSubscription(
txHash: String,
subscriptionId: String,
httpClient: OkHttpClient,
rpcApi: EthereumRpcApi
) {
val subscription = subscriptions[subscriptionId] ?: return
try {
subscription.callback(txHash, httpClient, rpcApi)
} catch (e: Exception) {
logger.error("调用订阅回调失败: subscriptionId=$subscriptionId, txHash=$txHash, error=${e.message}", e)
}
}
/**
* 处理交易为所有订阅用于兼容
* 解析交易中的 Transfer 事件分发给所有订阅者
*/
private suspend fun processTransaction(txHash: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
try {
// 获取交易 receipt
val receiptRequest = JsonRpcRequest(
method = "eth_getTransactionReceipt",
params = listOf(txHash)
)
val receiptResponse = rpcApi.call(receiptRequest)
if (!receiptResponse.isSuccessful || receiptResponse.body() == null) {
return
}
val receiptRpcResponse = receiptResponse.body()!!
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null) {
return
}
// 使用 Gson 解析 receipt JSON
val receiptJson = receiptRpcResponse.result.asJsonObject
// 解析 receipt 中的 Transfer 日志
val logs = receiptJson.getAsJsonArray("logs") ?: return
val (erc20Transfers, erc1155Transfers) = OnChainWsUtils.parseReceiptTransfers(logs)
// 为每个订阅检查是否匹配,如果匹配则调用回调
for (subscription in subscriptions.values) {
val address = subscription.address
// 检查该地址是否参与了交易(通过检查 Transfer 日志)
val isInvolved = erc20Transfers.any {
it.from.lowercase() == address || it.to.lowercase() == address
} || erc1155Transfers.any {
it.from.lowercase() == address || it.to.lowercase() == address
}
if (isInvolved) {
// 该地址参与了交易,调用回调
try {
subscription.callback(txHash, httpClient, rpcApi)
} catch (e: Exception) {
logger.error("调用订阅回调失败: subscriptionId=${subscription.subscriptionId}, txHash=$txHash, error=${e.message}", e)
}
}
}
} catch (e: Exception) {
logger.error("处理交易失败: txHash=$txHash, ${e.message}", e)
}
}
/**
* 停止连接
*/
fun stop() {
connectionJob?.cancel()
connectionJob = null
// 关闭 WebSocket 连接
webSocket?.close(1000, "停止监听")
webSocket = null
isConnected = false
// 清空订阅信息
subscriptions.clear()
requestIdToSubscriptionId.clear()
rpcSubscriptionIdToSubscriptionId.clear()
}
@PostConstruct
fun init() {
// 服务启动时不自动连接,等待有订阅时再连接
logger.info("统一链上 WebSocket 服务已初始化")
}
@PreDestroy
fun destroy() {
stop()
scope.cancel()
}
}
@@ -172,25 +172,15 @@ class OrderSigningService {
// 5. 确保 maker 地址也是小写格式
val makerAddressLower = makerAddress.lowercase()
// 打印签名前的订单参数
logger.info("========== 订单签名前参数 ==========")
logger.info("订单方向: $side")
logger.info("价格: $price")
logger.info("数量: $size")
logger.info("Token ID: $tokenId")
logger.info("Maker 地址: $makerAddressLower")
logger.info("Signer 地址: $signerAddress")
logger.info("Taker 地址: $taker")
logger.info("Maker Amount (wei): ${amounts.makerAmount}")
logger.info("Taker Amount (wei): ${amounts.takerAmount}")
logger.info("Salt: $salt")
logger.info("Expiration: $expiration")
logger.info("Nonce: $nonce")
logger.info("Fee Rate BPS: $feeRateBps")
logger.info("Signature Type: $signatureType")
logger.info("Exchange Contract: $EXCHANGE_CONTRACT")
logger.info("Chain ID: $CHAIN_ID")
logger.info("====================================")
// 打印签名前的订单参数DEBUG 级别,避免敏感信息泄露)
logger.debug("========== 订单签名前参数 ==========")
logger.debug("订单方向: $side, 价格: $price, 数量: $size")
logger.debug("Token ID: $tokenId")
logger.debug("Maker: ${makerAddressLower.take(10)}...${makerAddressLower.takeLast(6)}")
logger.debug("Signer: ${signerAddress.take(10)}...${signerAddress.takeLast(6)}")
logger.debug("Amounts - Maker: ${amounts.makerAmount}, Taker: ${amounts.takerAmount}")
logger.debug("Salt: $salt, Expiration: $expiration, Nonce: $nonce, FeeRateBPS: $feeRateBps")
logger.debug("Signature Type: $signatureType, Chain ID: $CHAIN_ID")
// 6. 构建订单数据并签名
val signature = signOrder(
@@ -37,7 +37,6 @@ open class CopyOrderTrackingService(
private val sellMatchRecordRepository: SellMatchRecordRepository,
private val sellMatchDetailRepository: SellMatchDetailRepository,
private val processedTradeRepository: ProcessedTradeRepository,
private val failedTradeRepository: FailedTradeRepository,
private val filteredOrderRepository: FilteredOrderRepository,
private val copyTradingRepository: CopyTradingRepository,
private val accountRepository: AccountRepository,
@@ -125,88 +124,82 @@ open class CopyOrderTrackingService(
return mutex.withLock {
try {
// 1. 检查是否已处理(去重,包括失败状态)
val existingProcessed = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
// 1. 检查是否已处理(去重,包括失败状态)
val existingProcessed = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
if (existingProcessed != null) {
if (existingProcessed.status == "FAILED") {
if (existingProcessed != null) {
if (existingProcessed.status == "FAILED") {
return@withLock Result.success(Unit)
}
}
return@withLock Result.success(Unit)
}
}
// 检查是否已记录为失败交易
val failedTrade = failedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
if (failedTrade != null) {
return@withLock Result.success(Unit)
// 2. 处理交易逻辑
val result = when (trade.side.uppercase()) {
"BUY" -> processBuyTrade(leaderId, trade)
"SELL" -> processSellTrade(leaderId, trade)
else -> {
logger.warn("未知的交易方向: ${trade.side}")
Result.failure(IllegalArgumentException("未知的交易方向: ${trade.side}"))
}
}
// 2. 处理交易逻辑
val result = when (trade.side.uppercase()) {
"BUY" -> processBuyTrade(leaderId, trade)
"SELL" -> processSellTrade(leaderId, trade)
else -> {
logger.warn("未知的交易方向: ${trade.side}")
Result.failure(IllegalArgumentException("未知的交易方向: ${trade.side}"))
}
}
if (result.isFailure) {
logger.error(
"处理交易失败: leaderId=$leaderId, tradeId=${trade.id}, side=${trade.side}",
result.exceptionOrNull()
)
if (result.isFailure) {
logger.error(
"处理交易失败: leaderId=$leaderId, tradeId=${trade.id}, side=${trade.side}",
result.exceptionOrNull()
)
return@withLock result
}
}
// 3. 标记为已处理(成功状态)
// 3. 标记为已处理(成功状态)
// 由于使用了 Mutex,这里理论上不会出现并发冲突,但保留异常处理作为兜底
try {
val processed = ProcessedTrade(
leaderId = leaderId,
leaderTradeId = trade.id,
tradeType = trade.side.uppercase(),
source = source,
status = "SUCCESS",
processedAt = System.currentTimeMillis()
)
processedTradeRepository.save(processed)
} catch (e: Exception) {
// 检查是否是唯一键冲突异常(理论上不会发生,但保留作为兜底)
if (isUniqueConstraintViolation(e)) {
val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
if (existing != null) {
if (existing.status == "FAILED") {
logger.debug("交易已标记为失败,跳过处理: leaderId=$leaderId, tradeId=${trade.id}")
return@withLock Result.success(Unit)
}
logger.debug("交易已处理(并发检测): leaderId=$leaderId, tradeId=${trade.id}, status=${existing.status}")
return@withLock Result.success(Unit)
} else {
// 如果检查不到,可能是事务隔离级别问题,等待一下再查询
delay(100)
val existingAfterDelay =
processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
if (existingAfterDelay != null) {
logger.debug("延迟查询到记录(并发检测): leaderId=$leaderId, tradeId=${trade.id}, status=${existingAfterDelay.status}")
return@withLock Result.success(Unit)
}
logger.warn(
"保存ProcessedTrade时发生唯一约束冲突,但查询不到记录: leaderId=$leaderId, tradeId=${trade.id}",
e
)
return@withLock Result.success(Unit)
}
} else {
// 其他类型的异常,重新抛出
throw e
}
}
Result.success(Unit)
try {
val processed = ProcessedTrade(
leaderId = leaderId,
leaderTradeId = trade.id,
tradeType = trade.side.uppercase(),
source = source,
status = "SUCCESS",
processedAt = System.currentTimeMillis()
)
processedTradeRepository.save(processed)
} catch (e: Exception) {
logger.error("处理交易异常: leaderId=$leaderId, tradeId=${trade.id}", e)
Result.failure(e)
// 检查是否是唯一键冲突异常(理论上不会发生,但保留作为兜底)
if (isUniqueConstraintViolation(e)) {
val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
if (existing != null) {
if (existing.status == "FAILED") {
logger.debug("交易已标记为失败,跳过处理: leaderId=$leaderId, tradeId=${trade.id}")
return@withLock Result.success(Unit)
}
logger.debug("交易已处理(并发检测): leaderId=$leaderId, tradeId=${trade.id}, status=${existing.status}")
return@withLock Result.success(Unit)
} else {
// 如果检查不到,可能是事务隔离级别问题,等待一下再查询
delay(100)
val existingAfterDelay =
processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
if (existingAfterDelay != null) {
logger.debug("延迟查询到记录(并发检测): leaderId=$leaderId, tradeId=${trade.id}, status=${existingAfterDelay.status}")
return@withLock Result.success(Unit)
}
logger.warn(
"保存ProcessedTrade时发生唯一约束冲突,但查询不到记录: leaderId=$leaderId, tradeId=${trade.id}",
e
)
return@withLock Result.success(Unit)
}
} else {
// 其他类型的异常,重新抛出
throw e
}
}
Result.success(Unit)
} catch (e: Exception) {
logger.error("处理交易异常: leaderId=$leaderId, tradeId=${trade.id}", e)
Result.failure(e)
}
}
}
@@ -491,27 +484,6 @@ open class CopyOrderTrackingService(
if (createOrderResult.isFailure) {
// 提取错误信息(只保留 code 和 errorBody
val exception = createOrderResult.exceptionOrNull()
val errorMsg = buildFullErrorMessage(
exception,
"BUY",
buyPrice.toString(),
finalBuyQuantity.toString(),
trade.id
)
// 记录失败交易到数据库
// retryCount = MAX_RETRY_ATTEMPTS - 1,表示已重试的次数
recordFailedTrade(
leaderId = leaderId,
trade = trade,
copyTradingId = copyTrading.id!!,
accountId = copyTrading.accountId,
side = "BUY",
price = buyPrice.toString(),
size = finalBuyQuantity.toString(),
errorMessage = errorMsg,
retryCount = MAX_RETRY_ATTEMPTS - 1 // 已重试次数
)
// 发送订单失败通知(异步,不阻塞,仅在 pushFailedOrders 为 true 时发送)
if (copyTrading.pushFailedOrders) {
@@ -594,7 +566,7 @@ open class CopyOrderTrackingService(
)
copyOrderTrackingRepository.save(tracking)
logger.info("买入订单已保存,等待轮询任务获取实际数据后发送通知: orderId=$realOrderId, copyTradingId=${copyTrading.id}")
} catch (e: Exception) {
logger.error("处理买入交易失败: copyTradingId=${copyTrading.id}, tradeId=${trade.id}", e)
@@ -971,31 +943,14 @@ open class CopyOrderTrackingService(
)
if (createOrderResult.isFailure) {
// 创建订单失败,记录到失败表
// 创建订单失败,记录错误日志
val exception = createOrderResult.exceptionOrNull()
val errorMsg = buildFullErrorMessage(
exception,
"SELL",
sellPrice.toString(),
totalMatched.toString(),
leaderSellTrade.id
)
recordFailedTrade(
leaderId = copyTrading.leaderId,
trade = leaderSellTrade,
copyTradingId = copyTrading.id!!,
accountId = copyTrading.accountId,
side = "SELL", // 订单方向是SELL
price = sellPrice.toString(),
size = totalMatched.toString(),
errorMessage = errorMsg,
retryCount = 1 // 已重试一次
)
logger.error("创建卖出订单失败: copyTradingId=${copyTrading.id}, tradeId=${leaderSellTrade.id}, error=${exception?.message}")
return
}
val realSellOrderId = createOrderResult.getOrNull() ?: return
// 12. 下单时直接使用下单价格保存,等待定时任务更新实际成交价
// priceUpdated 统一由定时任务更新,下单时统一设置为 false(非0x开头的除外)
val priceUpdated = !realSellOrderId.startsWith("0x", ignoreCase = true)
@@ -1248,98 +1203,6 @@ open class CopyOrderTrackingService(
return "code=$code, errorBody=$errorBody"
}
/**
* 记录失败交易到数据库
* 注意此方法在 @Transactional 方法中被调用会自动继承事务
*/
private suspend fun recordFailedTrade(
leaderId: Long,
trade: TradeResponse,
copyTradingId: Long,
accountId: Long,
side: String,
price: String,
size: String,
errorMessage: String,
retryCount: Int
) {
try {
// 确保错误信息不超过数据库字段限制(TEXT类型通常支持65535字符)
val maxErrorMessageLength = 50000 // 保留一些余量
val finalErrorMessage = if (errorMessage.length > maxErrorMessageLength) {
errorMessage.substring(0, maxErrorMessageLength) + "... (截断)"
} else {
errorMessage
}
val failedTrade = FailedTrade(
leaderId = leaderId,
leaderTradeId = trade.id,
tradeType = trade.side.uppercase(),
copyTradingId = copyTradingId,
accountId = accountId,
marketId = trade.market,
side = side,
price = price,
size = size,
errorMessage = finalErrorMessage,
retryCount = retryCount,
failedAt = System.currentTimeMillis()
)
failedTradeRepository.save(failedTrade)
// 记录日志,确认已保存到数据库
logger.info("失败交易已保存到数据库: leaderId=$leaderId, tradeId=${trade.id}, errorMessageLength=${finalErrorMessage.length}")
// 标记为已处理(失败状态),避免重复处理
// 注意:并发情况下可能多个请求同时处理同一笔交易,需要处理唯一约束冲突
try {
val processed = ProcessedTrade(
leaderId = leaderId,
leaderTradeId = trade.id,
tradeType = trade.side.uppercase(),
source = "polling",
status = "FAILED",
processedAt = System.currentTimeMillis()
)
processedTradeRepository.save(processed)
} catch (e: Exception) {
// 检查是否是唯一键冲突异常
if (isUniqueConstraintViolation(e)) {
// 唯一约束冲突,说明已经处理过了(可能是并发请求)
// 检查现有记录的状态
val existing = processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
if (existing != null) {
if (existing.status == "SUCCESS") {
logger.warn("交易已成功处理,但尝试记录为失败(并发冲突): leaderId=$leaderId, tradeId=${trade.id}")
} else {
logger.debug("交易已标记为失败(并发检测): leaderId=$leaderId, tradeId=${trade.id}")
}
} else {
// 如果查询不到,等待一下再查询(可能是事务隔离级别问题)
delay(100)
val existingAfterDelay =
processedTradeRepository.findByLeaderIdAndLeaderTradeId(leaderId, trade.id)
if (existingAfterDelay != null) {
logger.debug("延迟查询到记录(并发检测): leaderId=$leaderId, tradeId=${trade.id}, status=${existingAfterDelay.status}")
} else {
logger.warn(
"保存ProcessedTrade失败记录时发生唯一约束冲突,但查询不到记录: leaderId=$leaderId, tradeId=${trade.id}",
e
)
}
}
} else {
// 其他类型的异常,记录但不抛出(避免影响其他交易的处理)
logger.warn("保存ProcessedTrade失败记录时发生异常: leaderId=$leaderId, tradeId=${trade.id}", e)
}
}
logger.warn("已记录失败交易: leaderId=$leaderId, tradeId=${trade.id}, error=$errorMessage")
} catch (e: Exception) {
logger.error("记录失败交易异常: leaderId=$leaderId, tradeId=${trade.id}", e)
}
}
/**
* 更新订单状态
@@ -1496,12 +1359,18 @@ open class CopyOrderTrackingService(
return try {
// 1. 查询订单详情
val orderResponse = clobApi.getOrder(orderId)
if (!orderResponse.isSuccessful || orderResponse.body() == null) {
logger.warn("查询订单详情失败: orderId=$orderId, code=${orderResponse.code()}")
if (!orderResponse.isSuccessful) {
val errorBody = orderResponse.errorBody()?.string()?.take(200) ?: "无错误详情"
logger.warn("查询订单详情失败: orderId=$orderId, code=${orderResponse.code()}, errorBody=$errorBody")
return fallbackPrice
}
val order = orderResponse.body()
if (order == null) {
// 响应体为空,可能是订单不存在或已过期
logger.warn("查询订单详情失败: 响应体为空, orderId=$orderId, code=${orderResponse.code()}")
return fallbackPrice
}
val order = orderResponse.body()!!
// 2. 如果订单未成交,使用下单价格
if (order.status != "FILLED" && order.sizeMatched.toSafeBigDecimal() <= BigDecimal.ZERO) {
@@ -341,9 +341,9 @@ class CopyTradingStatisticsService(
// 传递 outcomeIndex 参数,确保获取对应 outcome 的价格
val result = accountService.getMarketPrice(marketId, outcomeIndex)
result.onSuccess { response ->
// 使用中间价,如果没有则使用最后价格
val price = response.midpoint ?: response.lastPrice
if (price != null) {
// 使用当前价格
val price = response.currentPrice
if (price.isNotBlank() && price != "0") {
// 使用 "marketId:outcomeIndex" 作为 key
val key = "$marketId:$outcomeIndex"
prices[key] = price
@@ -56,10 +56,13 @@ class OrderStatusUpdateService(
// 1. 清理已删除账户的订单
cleanupDeletedAccountOrders()
// 2. 更新卖出订单的实际成交价并发送通知(priceUpdated 共用字段)
// 2. 检查30秒前创建的订单,如果未成交则删除
checkAndDeleteUnfilledOrders()
// 3. 更新卖出订单的实际成交价并发送通知(priceUpdated 共用字段)
updatePendingSellOrderPrices()
// 3. 更新买入订单的实际数据并发送通知
// 4. 更新买入订单的实际数据并发送通知
updatePendingBuyOrders()
} catch (e: Exception) {
logger.error("订单状态更新异常: ${e.message}", e)
@@ -130,6 +133,131 @@ class OrderStatusUpdateService(
}
}
/**
* 检查30秒前创建的订单如果未成交则删除
* 首次检测但加入缓存中30s后还没有成交则删除
*/
@Transactional
private suspend fun checkAndDeleteUnfilledOrders() {
try {
// 计算30秒前的时间戳
val thirtySecondsAgo = System.currentTimeMillis() - 30000
// 查询30秒前创建的订单
val ordersToCheck = copyOrderTrackingRepository.findByCreatedAtBefore(thirtySecondsAgo)
if (ordersToCheck.isEmpty()) {
return
}
logger.debug("检查 ${ordersToCheck.size} 个30秒前创建的订单是否成交")
// 按账户分组,避免重复创建 API 客户端
val ordersByAccount = ordersToCheck.groupBy { it.accountId }
for ((accountId, orders) in ordersByAccount) {
try {
// 获取账户
val account = accountRepository.findById(accountId).orElse(null)
if (account == null) {
logger.warn("账户不存在,跳过检查: accountId=$accountId")
continue
}
// 检查账户是否配置了 API 凭证
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
logger.debug("账户未配置 API 凭证,跳过检查: accountId=${account.id}")
continue
}
// 解密 API 凭证
val apiSecret = try {
cryptoUtils.decrypt(account.apiSecret!!)
} catch (e: Exception) {
logger.warn("解密 API Secret 失败: accountId=${account.id}, error=${e.message}")
continue
}
val apiPassphrase = try {
cryptoUtils.decrypt(account.apiPassphrase!!)
} catch (e: Exception) {
logger.warn("解密 API Passphrase 失败: accountId=${account.id}, error=${e.message}")
continue
}
// 创建带认证的 CLOB API 客户端
val clobApi = retrofitFactory.createClobApi(
account.apiKey!!,
apiSecret,
apiPassphrase,
account.walletAddress
)
// 检查每个订单
for (order in orders) {
try {
// 查询订单详情
val orderResponse = clobApi.getOrder(order.buyOrderId)
// 先检查 HTTP 状态码,非 200 的都跳过
if (orderResponse.code() != 200) {
// HTTP 非 200,记录日志并跳过,等待下次轮询
// 不删除订单,因为可能是临时网络问题或 API 错误
val errorBody = orderResponse.errorBody()?.string()?.take(200) ?: "无错误详情"
logger.debug("订单查询失败(HTTP非200),等待下次轮询: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, code=${orderResponse.code()}, errorBody=$errorBody")
continue
}
// HTTP 200,检查响应体
// 响应体也可能返回字符串 "null"Gson 解析时会返回 null
val orderDetail = orderResponse.body()
if (orderDetail == null) {
// HTTP 200 且响应体为 null(或字符串 "null"),表示订单不存在
// 检查订单是否已部分卖出,如果已部分卖出则保留订单用于统计
val hasMatchedDetails = sellMatchDetailRepository.findByTrackingId(order.id!!).isNotEmpty()
if (hasMatchedDetails || order.matchedQuantity > BigDecimal.ZERO) {
logger.debug("订单不存在但已部分卖出,保留订单用于统计: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}")
continue
}
// 订单不存在且未部分卖出,删除本地订单
logger.info("订单不存在(HTTP 200 但响应体为空),删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
try {
copyOrderTrackingRepository.deleteById(order.id!!)
logger.info("已删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
} catch (e: Exception) {
logger.error("删除本地订单失败: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, error=${e.message}", e)
}
continue
}
// 检查订单是否成交
// 如果订单状态不是 FILLED 且已成交数量为0,说明未成交,删除
val sizeMatched = orderDetail.sizeMatched?.toSafeBigDecimal() ?: BigDecimal.ZERO
if (orderDetail.status != "FILLED" && sizeMatched <= BigDecimal.ZERO) {
logger.info("订单30秒后仍未成交,删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, status=${orderDetail.status}, sizeMatched=$sizeMatched")
try {
copyOrderTrackingRepository.deleteById(order.id!!)
logger.info("已删除未成交订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
} catch (e: Exception) {
logger.error("删除未成交订单失败: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, error=${e.message}", e)
}
} else {
logger.debug("订单已成交或部分成交,保留: orderId=${order.buyOrderId}, status=${orderDetail.status}, sizeMatched=$sizeMatched")
}
} catch (e: Exception) {
logger.error("检查订单失败: orderId=${order.buyOrderId}, error=${e.message}", e)
}
}
} catch (e: Exception) {
logger.error("检查账户订单失败: accountId=$accountId, error=${e.message}", e)
}
}
} catch (e: Exception) {
logger.error("检查未成交订单异常: ${e.message}", e)
}
}
/**
* 更新待更新的卖出订单价格
* 注意priceUpdated 现在同时表示价格已更新和通知已发送共用字段
@@ -194,16 +322,27 @@ class OrderStatusUpdateService(
// 如果 orderId 不是 0x 开头,直接标记为已处理(priceUpdated = true 表示已处理,包括价格更新和通知发送)
if (!record.sellOrderId.startsWith("0x", ignoreCase = true)) {
logger.debug("卖出订单ID非0x开头,直接标记为已处理: orderId=${record.sellOrderId}")
// 发送通知(使用临时数据)
sendSellOrderNotification(
record = record,
useTemporaryData = true,
account = account,
copyTrading = copyTrading,
clobApi = clobApi,
apiSecret = apiSecret,
apiPassphrase = apiPassphrase
)
// 检查是否为自动生成的订单(AUTO_ 或 AUTO_FIFO_ 开头),如果是则不发送通知
val isAutoOrder = record.sellOrderId.startsWith("AUTO_", ignoreCase = true) ||
record.sellOrderId.startsWith("AUTO_FIFO_", ignoreCase = true) ||
record.sellOrderId.startsWith("AUTO_WS_", ignoreCase = true)
if (!isAutoOrder) {
// 非自动订单,发送通知(使用临时数据)
sendSellOrderNotification(
record = record,
useTemporaryData = true,
account = account,
copyTrading = copyTrading,
clobApi = clobApi,
apiSecret = apiSecret,
apiPassphrase = apiPassphrase
)
} else {
logger.debug("自动生成的订单,跳过发送通知: orderId=${record.sellOrderId}")
}
// 标记为已处理(priceUpdated = true 同时表示价格已更新和通知已发送)
val updatedRecord = SellMatchRecord(
id = record.id,
@@ -223,6 +362,32 @@ class OrderStatusUpdateService(
continue
}
// 检查是否为自动生成的订单(AUTO_ 或 AUTO_FIFO_ 开头),如果是则跳过发送通知
val isAutoOrder = record.sellOrderId.startsWith("AUTO_", ignoreCase = true) ||
record.sellOrderId.startsWith("AUTO_FIFO_", ignoreCase = true) ||
record.sellOrderId.startsWith("AUTO_WS_", ignoreCase = true)
if (isAutoOrder) {
logger.debug("自动生成的订单,跳过发送通知并直接标记为已处理: orderId=${record.sellOrderId}")
// 直接标记为已处理,不发送通知
val updatedRecord = SellMatchRecord(
id = record.id,
copyTradingId = record.copyTradingId,
sellOrderId = record.sellOrderId,
leaderSellTradeId = record.leaderSellTradeId,
marketId = record.marketId,
side = record.side,
outcomeIndex = record.outcomeIndex,
totalMatchedQuantity = record.totalMatchedQuantity,
sellPrice = record.sellPrice,
totalRealizedPnl = record.totalRealizedPnl,
priceUpdated = true, // 标记为已处理
createdAt = record.createdAt
)
sellMatchRecordRepository.save(updatedRecord)
continue
}
// 查询订单详情,获取实际成交价
val actualSellPrice = trackingService.getActualExecutionPrice(
orderId = record.sellOrderId,
@@ -419,12 +584,36 @@ class OrderStatusUpdateService(
// 查询订单详情
val orderResponse = clobApi.getOrder(order.buyOrderId)
if (!orderResponse.isSuccessful || orderResponse.body() == null) {
logger.debug("查询订单详情失败,等待下次轮询: orderId=${order.buyOrderId}, code=${orderResponse.code()}")
// 先检查 HTTP 状态码,非 200 的都跳过
if (orderResponse.code() != 200) {
val errorBody = orderResponse.errorBody()?.string()?.take(200) ?: "无错误详情"
logger.debug("查询订单详情失败(HTTP非200),等待下次轮询: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, code=${orderResponse.code()}, errorBody=$errorBody")
continue
}
val orderDetail = orderResponse.body()!!
// HTTP 200,检查响应体
// 响应体也可能返回字符串 "null"Gson 解析时会返回 null
val orderDetail = orderResponse.body()
if (orderDetail == null) {
// HTTP 200 且响应体为 null(或字符串 "null"),表示订单不存在
// 检查订单是否已部分卖出,如果已部分卖出则保留订单用于统计
val hasMatchedDetails = sellMatchDetailRepository.findByTrackingId(order.id!!).isNotEmpty()
if (hasMatchedDetails || order.matchedQuantity > BigDecimal.ZERO) {
logger.debug("订单不存在但已部分卖出,保留订单用于统计: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}")
continue
}
// 订单不存在且未部分卖出,删除本地订单
logger.info("订单不存在(HTTP 200 但响应体为空),删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
try {
copyOrderTrackingRepository.deleteById(order.id!!)
logger.info("已删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
} catch (e: Exception) {
logger.error("删除本地订单失败: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, error=${e.message}", e)
}
continue
}
// 获取实际价格和数量
val actualPrice = orderDetail.price?.toSafeBigDecimal() ?: order.price
@@ -0,0 +1,579 @@
package com.wrbug.polymarketbot.service.nba
import com.wrbug.polymarketbot.api.PolymarketGammaApi
import com.wrbug.polymarketbot.dto.NbaGameDto
import com.wrbug.polymarketbot.dto.NbaGameListRequest
import com.wrbug.polymarketbot.dto.NbaGameListResponse
import com.wrbug.polymarketbot.entity.NbaGame
import com.wrbug.polymarketbot.enums.SportsTagId
import com.wrbug.polymarketbot.repository.NbaGameRepository
import com.wrbug.polymarketbot.util.RetrofitFactory
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
/**
* NBA 比赛服务
* 从数据库和 Polymarket API 获取比赛数据
* 优先从数据库获取如果数据不足则增量拉取 API 数据
*/
@Service
class NbaGameService(
private val retrofitFactory: RetrofitFactory,
private val nbaGameRepository: NbaGameRepository
) {
private val logger = LoggerFactory.getLogger(NbaGameService::class.java)
/**
* 获取 NBA 比赛列表
* 优先从数据库获取如果数据不足则增量拉取 API 数据
* 前端传递时间戳后端转换为西8区时间用于过滤
*/
suspend fun getNbaGames(request: NbaGameListRequest): Result<NbaGameListResponse> {
return try {
// 将时间戳转换为西8区(PST/PDT)的日期范围
val pstZone = ZoneId.of("America/Los_Angeles")
val startTimestamp = request.startTimestamp ?: ZonedDateTime.now(pstZone).toInstant().toEpochMilli()
val endTimestamp = request.endTimestamp ?: ZonedDateTime.now(pstZone).plusDays(7).toInstant().toEpochMilli()
val startDate = Instant.ofEpochMilli(startTimestamp).atZone(pstZone).toLocalDate()
val endDate = Instant.ofEpochMilli(endTimestamp).atZone(pstZone).toLocalDate()
// 1. 先从数据库获取数据
val dbGames = nbaGameRepository.findByGameDateBetween(startDate, endDate)
logger.info("从数据库获取到 ${dbGames.size} 个比赛(日期范围:$startDate$endDate")
// 2. 检查是否需要增量拉取
val needFetch = shouldFetchFromApi(dbGames)
if (needFetch) {
logger.info("数据库数据不足,开始增量拉取 API 数据")
// 3. 获取数据库最新的 createdAt,用于增量拉取
val latestGame = nbaGameRepository.findFirstByOrderByCreatedAtDesc()
val incrementalStartDateMin = latestGame?.createdAt?.let {
// 将数据库的 createdAt(时间戳)转换为 UTC ISO 8601 格式
Instant.ofEpochMilli(it)
.atZone(java.time.ZoneOffset.UTC)
.format(DateTimeFormatter.ISO_INSTANT)
} ?: run {
// 如果没有数据库数据,使用一周前的时间
Instant.now()
.minusSeconds(7 * 24 * 60 * 60)
.atZone(java.time.ZoneOffset.UTC)
.format(DateTimeFormatter.ISO_INSTANT)
}
logger.info("使用增量拉取起始时间: $incrementalStartDateMin")
// 4. 增量拉取 API 数据
val apiGames = fetchGamesFromApi(startDate, endDate, incrementalStartDateMin)
// 5. 保存新数据到数据库
if (apiGames.isNotEmpty()) {
saveGamesToDatabase(apiGames)
}
// 6. 合并数据库数据和 API 数据
val allGames = (dbGames + apiGames.map { dtoToEntity(it) }).distinctBy {
"${it.homeTeam}_${it.awayTeam}_${it.gameDate}"
}
// 转换为 DTO
val gameDtos = allGames.map { entityToDto(it) }
// 根据状态过滤
val filteredGames = if (request.gameStatus != null) {
gameDtos.filter { it.gameStatus == request.gameStatus }
} else {
gameDtos
}
Result.success(
NbaGameListResponse(
list = filteredGames,
total = filteredGames.size.toLong()
)
)
} else {
// 数据库数据充足,直接返回
val gameDtos = dbGames.map { entityToDto(it) }
// 根据状态过滤
val filteredGames = if (request.gameStatus != null) {
gameDtos.filter { it.gameStatus == request.gameStatus }
} else {
gameDtos
}
Result.success(
NbaGameListResponse(
list = filteredGames,
total = filteredGames.size.toLong()
)
)
}
} catch (e: Exception) {
logger.error("获取 NBA 比赛列表失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 判断是否需要从 API 拉取数据
* 逻辑
* 1. 如果数据库没有数据需要拉取
* 2. 如果数据库最新数据的 gameTime 在未来 3 天内0-3 不需要拉取
* 3. 如果数据库最新数据的 gameTime 超过 3 >3 不需要拉取数据太远
* 4. 如果数据库最新数据的 gameTime 已经过去<0需要拉取数据过期
*/
private fun shouldFetchFromApi(dbGames: List<NbaGame>): Boolean {
if (dbGames.isEmpty()) {
logger.info("数据库没有数据,需要从 API 拉取")
return true
}
// 检查最新数据的 gameTime(未来最远的比赛)
val latestGame = dbGames.maxByOrNull { it.gameTime ?: 0L }
if (latestGame?.gameTime == null) {
logger.info("数据库最新数据没有 gameTime,需要从 API 拉取")
return true
}
// 计算最新数据的 gameTime 距离现在的时间(以天为单位)
val now = Instant.now().toEpochMilli()
val gameTime = latestGame.gameTime
val daysDiff = (gameTime - now) / (24 * 60 * 60 * 1000)
// 如果数据已经过去(daysDiff < 0),需要拉取
if (daysDiff < 0) {
logger.info("数据库最新数据的 gameTime 已经过去(${daysDiff} 天前),需要从 API 拉取")
return true
}
// 如果数据在未来 3 天内(0 <= daysDiff <= 3),不需要拉取
if (daysDiff >= 0 && daysDiff <= 3) {
logger.info("数据库数据充足(最新数据 ${daysDiff} 天后,在未来 3 天内),无需从 API 拉取")
return false
}
// 如果数据超过 3 天(daysDiff > 3),不需要拉取(数据太远)
logger.info("数据库最新数据的 gameTime 超过 3 天(${daysDiff} 天后),数据太远,不需要从 API 拉取")
return false
}
/**
* API 拉取比赛数据
*/
private suspend fun fetchGamesFromApi(
startDate: LocalDate,
endDate: LocalDate,
startDateMin: String
): List<NbaGameDto> {
// 从 Polymarket API 获取 NBA 市场(分页拉取)
val gammaApi = retrofitFactory.createGammaApi()
val nbaTagId = SportsTagId.NBA.tagId
// 计算未来3天的时间点(UTC
val threeDaysLater = Instant.now()
.plusSeconds(3 * 24 * 60 * 60) // 加上3天(秒数)
val allMarkets = mutableListOf<com.wrbug.polymarketbot.api.MarketResponse>()
var hasMore = true
var pageCount = 0
var currentStartDateMin = startDateMin
while (hasMore) {
pageCount++
logger.debug("分页拉取第 $pageCount 页,start_date_min: $currentStartDateMin")
val response = gammaApi.listMarkets(
conditionIds = null,
includeTag = true,
tags = listOf(nbaTagId),
active = true, // 只获取活跃的市场
closed = false,
archived = false,
limit = 500, // 使用 500 作为 limit
startDateMin = currentStartDateMin,
sportsMarketTypes = listOf("moneyline") // 直接通过 API 筛选 moneyline 类型
)
if (!response.isSuccessful || response.body() == null) {
logger.error("获取 NBA 市场失败: ${response.code()} ${response.message()}")
break
}
val markets = response.body()!!
logger.info("$pageCount 页获取到 ${markets.size} 个市场")
if (markets.isEmpty()) {
// 没有更多数据了
hasMore = false
break
}
// 先记录最后一项的 createdAt(用于下一次分页)
val lastMarket = markets.last()
val lastCreatedAt = lastMarket.createdAt
if (lastCreatedAt == null) {
// 如果最后一个元素没有 createdAt,停止分页
hasMore = false
logger.warn("数组最后一个元素缺少 createdAt,停止分页")
break
}
// 移除非 NBA 项(根据 resolutionSource 判断)
val nbaMarkets = markets.filter { market ->
!market.resolutionSource.isNullOrBlank() &&
market.resolutionSource!!.lowercase().contains("nba")
}
logger.info("$pageCount 页过滤后剩余 ${nbaMarkets.size} 个 NBA 市场")
// 添加到总列表(只添加 NBA 市场)
allMarkets.addAll(nbaMarkets)
// 从后往前遍历,找到第一个有 gameStartTime 字段的数据(在 NBA 市场中查找)
var foundGameStartTime: String? = null
for (i in nbaMarkets.size - 1 downTo 0) {
val market = nbaMarkets[i]
if (!market.gameStartTime.isNullOrBlank()) {
foundGameStartTime = market.gameStartTime
logger.debug("从后往前找到第 ${i + 1} 个有 gameStartTime 的 NBA 市场: $foundGameStartTime")
break
}
}
if (foundGameStartTime == null) {
// 如果整页都没有 gameStartTime,使用 createdAt 继续分页
currentStartDateMin = lastCreatedAt
logger.debug("本页没有找到 gameStartTime,使用最后一个元素的 createdAt 继续分页")
continue
}
// 解析 gameStartTime(格式:2025-12-13 00:00:00+00
val gameStartDate = try {
// 尝试解析格式 "2025-12-13 00:00:00+00"
val dateTimeStr = foundGameStartTime.replace(" ", "T")
// 如果时区是 +00,转换为 Z
val normalizedStr = if (dateTimeStr.endsWith("+00")) {
dateTimeStr.replace("+00", "Z")
} else if (dateTimeStr.endsWith("-00")) {
dateTimeStr.replace("-00", "Z")
} else {
dateTimeStr
}
val instant = Instant.parse(normalizedStr)
// 转换为日期(以天为单位,不考虑时间)
instant.atZone(java.time.ZoneOffset.UTC).toLocalDate()
} catch (e: Exception) {
logger.warn("解析 gameStartTime 失败: $foundGameStartTime, error: ${e.message}")
null
}
if (gameStartDate == null) {
// 无法解析 gameStartTime,使用 createdAt 继续分页
currentStartDateMin = lastCreatedAt
logger.debug("无法解析 gameStartTime,使用最后一个元素的 createdAt 继续分页")
continue
}
// 计算未来 3 天的日期(以天为单位,不考虑时间)
val threeDaysLaterDate = Instant.now()
.plusSeconds(3 * 24 * 60 * 60) // 加上3天(秒数)
.atZone(java.time.ZoneOffset.UTC)
.toLocalDate()
// 判断 gameStartDate 是否在未来 3 天以内(包括第 3 天)
val daysBetween = java.time.temporal.ChronoUnit.DAYS.between(
Instant.now().atZone(java.time.ZoneOffset.UTC).toLocalDate(),
gameStartDate
)
if (daysBetween <= 3 && daysBetween >= 0) {
// 如果在 3 天内(包括第 3 天),使用数组最后一个元素的 createdAt 继续分页
currentStartDateMin = lastCreatedAt
logger.info("找到的 gameStartTime ($foundGameStartTime, 日期: $gameStartDate) 在未来 ${daysBetween} 天内,继续分页")
} else {
// 如果不在 3 天内,停止分页
hasMore = false
logger.info("找到的 gameStartTime ($foundGameStartTime, 日期: $gameStartDate) 不在未来 3 天内(相差 ${daysBetween} 天),停止分页")
}
}
logger.info("分页拉取完成,共获取 ${allMarkets.size} 个 NBA moneyline 市场(${pageCount} 页)")
// 注意:allMarkets 已经通过 API 的 sports_market_types 参数过滤了 moneyline 类型
// 并且已经过滤了非 NBA 项(根据 resolutionSource),这里直接使用即可
// 将市场转换为比赛数据
val games = allMarkets.mapNotNull { market ->
convertMarketToGame(market, startDate, endDate)
}
// 去重:相同的主队、客队和日期只保留一个
val uniqueGames = games.groupBy { "${it.homeTeam}_${it.awayTeam}_${it.gameDate}" }
.map { it.value.first() }
return uniqueGames
}
/**
* 保存比赛数据到数据库
*/
@Transactional
private fun saveGamesToDatabase(games: List<NbaGameDto>) {
if (games.isEmpty()) {
return
}
var savedCount = 0
var updatedCount = 0
games.forEach { dto ->
try {
// 尝试根据 nbaGameId 或 polymarketMarketId 查找现有记录
val existing = dto.nbaGameId?.let {
nbaGameRepository.findByNbaGameId(it)
} ?: dto.polymarketMarketId?.let {
nbaGameRepository.findByPolymarketMarketId(it)
}
if (existing != null) {
// 更新现有记录(data class 的 copy 方法)
val updated = NbaGame(
id = existing.id,
nbaGameId = existing.nbaGameId,
homeTeam = dto.homeTeam,
awayTeam = dto.awayTeam,
gameDate = dto.gameDate,
gameTime = dto.gameTime,
gameStatus = dto.gameStatus,
homeScore = dto.homeScore,
awayScore = dto.awayScore,
period = dto.period,
timeRemaining = dto.timeRemaining,
polymarketMarketId = dto.polymarketMarketId,
createdAt = existing.createdAt,
updatedAt = System.currentTimeMillis()
)
nbaGameRepository.save(updated)
updatedCount++
} else {
// 创建新记录
val entity = dtoToEntity(dto)
nbaGameRepository.save(entity)
savedCount++
}
} catch (e: Exception) {
logger.error("保存比赛数据失败: ${dto.nbaGameId}, error: ${e.message}", e)
}
}
logger.info("保存比赛数据完成:新增 $savedCount 条,更新 $updatedCount")
}
/**
* DTO 转实体
*/
private fun dtoToEntity(dto: NbaGameDto): NbaGame {
return NbaGame(
id = null,
nbaGameId = dto.nbaGameId,
homeTeam = dto.homeTeam,
awayTeam = dto.awayTeam,
gameDate = dto.gameDate,
gameTime = dto.gameTime,
gameStatus = dto.gameStatus,
homeScore = dto.homeScore,
awayScore = dto.awayScore,
period = dto.period,
timeRemaining = dto.timeRemaining,
polymarketMarketId = dto.polymarketMarketId,
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis()
)
}
/**
* 实体转 DTO
*/
private fun entityToDto(entity: NbaGame): NbaGameDto {
return NbaGameDto(
id = entity.id,
nbaGameId = entity.nbaGameId,
homeTeam = entity.homeTeam,
awayTeam = entity.awayTeam,
gameDate = entity.gameDate,
gameTime = entity.gameTime,
gameStatus = entity.gameStatus,
homeScore = entity.homeScore,
awayScore = entity.awayScore,
period = entity.period,
timeRemaining = entity.timeRemaining,
polymarketMarketId = entity.polymarketMarketId
)
}
/**
* 获取 7 天内的所有球队去重
*/
suspend fun getTeamsInNext7Days(): Result<List<String>> {
return try {
// 使用当前西8区时间计算7天范围
val pstZone = ZoneId.of("America/Los_Angeles")
val now = ZonedDateTime.now(pstZone)
val startTimestamp = now.toInstant().toEpochMilli()
val endTimestamp = now.plusDays(7).toInstant().toEpochMilli()
val gamesResult = getNbaGames(
NbaGameListRequest(
startTimestamp = startTimestamp,
endTimestamp = endTimestamp
)
)
gamesResult.fold(
onSuccess = { response ->
val teams = mutableSetOf<String>()
response.list.forEach { game ->
teams.add(game.homeTeam)
teams.add(game.awayTeam)
}
Result.success(teams.sorted())
},
onFailure = { exception -> Result.failure(exception) }
)
} catch (e: Exception) {
logger.error("获取球队列表失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* Polymarket 市场转换为比赛数据
*/
private fun convertMarketToGame(
market: com.wrbug.polymarketbot.api.MarketResponse,
startDate: LocalDate,
endDate: LocalDate
): NbaGameDto? {
if (market.question.isNullOrBlank()) {
return null
}
// 解析市场名称,提取球队和日期信息
val parsed = NbaMarketNameParser.parse(market.question)
if (parsed.homeTeam == null || parsed.awayTeam == null) {
// 无法解析出两个球队,跳过
return null
}
// 确定比赛日期
val gameDate = parsed.gameDate ?: run {
// 如果没有解析出日期,尝试从 startDate 或 endDate 中提取
parseDateFromMarketDates(market.startDate, market.endDate) ?: return null
}
// 检查日期是否在请求范围内
if (gameDate.isBefore(startDate) || gameDate.isAfter(endDate)) {
return null
}
// 解析比赛时间(从 endDate 或 startDate 中提取,转换为西8区时间戳)
val gameTime = parseGameTimeFromMarket(market.startDate, market.endDate, gameDate)
// 确定比赛状态
val gameStatus = when {
market.closed == true -> "finished"
market.archived == true -> "finished"
market.active == true -> "scheduled"
else -> "scheduled"
}
return NbaGameDto(
id = null,
nbaGameId = market.conditionId ?: market.id, // 使用 conditionId 或 id 作为 gameId
homeTeam = parsed.homeTeam,
awayTeam = parsed.awayTeam,
gameDate = gameDate,
gameTime = gameTime, // 西8区时间戳(毫秒)
gameStatus = gameStatus,
homeScore = 0, // Polymarket 不提供比分
awayScore = 0,
period = 0,
timeRemaining = null,
polymarketMarketId = market.id
)
}
/**
* 从市场的 startDate endDate 中解析日期
*/
private fun parseDateFromMarketDates(startDate: String?, endDate: String?): LocalDate? {
val dateStr = endDate ?: startDate ?: return null
return try {
// 尝试解析 ISO 8601 格式
if (dateStr.contains("T")) {
val instant = Instant.parse(dateStr)
val pstZone = ZoneId.of("America/Los_Angeles")
instant.atZone(pstZone).toLocalDate()
} else {
// 尝试解析日期字符串
LocalDate.parse(dateStr, DateTimeFormatter.ISO_DATE)
}
} catch (e: Exception) {
logger.debug("解析市场日期失败: $dateStr, error: ${e.message}")
null
}
}
/**
* 从市场的日期时间中解析比赛时间转换为西8区时间戳
*/
private fun parseGameTimeFromMarket(
startDate: String?,
endDate: String?,
gameDate: LocalDate
): Long? {
val dateTimeStr = endDate ?: startDate ?: return null
return try {
val pstZone = ZoneId.of("America/Los_Angeles")
// 尝试解析 ISO 8601 格式
val instant = if (dateTimeStr.contains("T")) {
Instant.parse(dateTimeStr)
} else {
// 如果没有时间部分,使用默认时间(晚上8点)
val defaultTime = gameDate.atTime(20, 0)
defaultTime.atZone(pstZone).toInstant()
}
// 转换为西8区时间戳
instant.atZone(pstZone).toInstant().toEpochMilli()
} catch (e: Exception) {
logger.debug("解析比赛时间失败: $dateTimeStr, error: ${e.message}")
// 解析失败时,使用默认时间(晚上8点 PST)
try {
val defaultTime = gameDate.atTime(20, 0)
val pstZone = ZoneId.of("America/Los_Angeles")
defaultTime.atZone(pstZone).toInstant().toEpochMilli()
} catch (e2: Exception) {
null
}
}
}
}
@@ -0,0 +1,314 @@
package com.wrbug.polymarketbot.service.nba
import org.slf4j.LoggerFactory
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.regex.Pattern
/**
* NBA 市场名称解析器
* Polymarket 市场名称中提取球队和日期信息
*/
object NbaMarketNameParser {
private val logger = LoggerFactory.getLogger(NbaMarketNameParser::class.java)
// NBA 球队名称映射(支持多种格式)
private val teamNameMapping = mapOf(
// 完整名称
"atlanta hawks" to "Atlanta Hawks",
"boston celtics" to "Boston Celtics",
"brooklyn nets" to "Brooklyn Nets",
"charlotte hornets" to "Charlotte Hornets",
"chicago bulls" to "Chicago Bulls",
"cleveland cavaliers" to "Cleveland Cavaliers",
"dallas mavericks" to "Dallas Mavericks",
"denver nuggets" to "Denver Nuggets",
"detroit pistons" to "Detroit Pistons",
"golden state warriors" to "Golden State Warriors",
"houston rockets" to "Houston Rockets",
"indiana pacers" to "Indiana Pacers",
"la clippers" to "LA Clippers",
"los angeles lakers" to "Los Angeles Lakers",
"memphis grizzlies" to "Memphis Grizzlies",
"miami heat" to "Miami Heat",
"milwaukee bucks" to "Milwaukee Bucks",
"minnesota timberwolves" to "Minnesota Timberwolves",
"new orleans pelicans" to "New Orleans Pelicans",
"new york knicks" to "New York Knicks",
"oklahoma city thunder" to "Oklahoma City Thunder",
"orlando magic" to "Orlando Magic",
"philadelphia 76ers" to "Philadelphia 76ers",
"phoenix suns" to "Phoenix Suns",
"portland trail blazers" to "Portland Trail Blazers",
"sacramento kings" to "Sacramento Kings",
"san antonio spurs" to "San Antonio Spurs",
"toronto raptors" to "Toronto Raptors",
"utah jazz" to "Utah Jazz",
"washington wizards" to "Washington Wizards",
// 常见缩写和别名
"hawks" to "Atlanta Hawks",
"celtics" to "Boston Celtics",
"nets" to "Brooklyn Nets",
"hornets" to "Charlotte Hornets",
"bulls" to "Chicago Bulls",
"cavaliers" to "Cleveland Cavaliers",
"cavs" to "Cleveland Cavaliers",
"mavericks" to "Dallas Mavericks",
"mavs" to "Dallas Mavericks",
"nuggets" to "Denver Nuggets",
"pistons" to "Detroit Pistons",
"warriors" to "Golden State Warriors",
"rockets" to "Houston Rockets",
"pacers" to "Indiana Pacers",
"clippers" to "LA Clippers",
"lakers" to "Los Angeles Lakers",
"grizzlies" to "Memphis Grizzlies",
"heat" to "Miami Heat",
"bucks" to "Milwaukee Bucks",
"timberwolves" to "Minnesota Timberwolves",
"wolves" to "Minnesota Timberwolves",
"pelicans" to "New Orleans Pelicans",
"knicks" to "New York Knicks",
"thunder" to "Oklahoma City Thunder",
"magic" to "Orlando Magic",
"76ers" to "Philadelphia 76ers",
"sixers" to "Philadelphia 76ers",
"suns" to "Phoenix Suns",
"trail blazers" to "Portland Trail Blazers",
"blazers" to "Portland Trail Blazers",
"kings" to "Sacramento Kings",
"spurs" to "San Antonio Spurs",
"raptors" to "Toronto Raptors",
"jazz" to "Utah Jazz",
"wizards" to "Washington Wizards",
"wiz" to "Washington Wizards"
)
/**
* 解析结果
*/
data class ParsedMarketInfo(
val homeTeam: String?,
val awayTeam: String?,
val gameDate: LocalDate?,
val confidence: Double // 置信度 0.0-1.0
)
/**
* 解析市场名称
* @param marketName 市场名称
* @return 解析结果
*/
fun parse(marketName: String?): ParsedMarketInfo {
if (marketName.isNullOrBlank()) {
return ParsedMarketInfo(null, null, null, 0.0)
}
val normalized = marketName.lowercase()
var homeTeam: String? = null
var awayTeam: String? = null
var gameDate: LocalDate? = null
var confidence = 0.0
// 尝试提取球队名称
val teams = extractTeams(normalized)
if (teams.size >= 2) {
// 通常第一个是客队,第二个是主队
awayTeam = teams[0]
homeTeam = teams[1]
confidence += 0.5
} else if (teams.size == 1) {
// 只有一个球队,无法确定主客场
awayTeam = teams[0]
confidence += 0.2
}
// 尝试提取日期
val date = extractDate(normalized)
if (date != null) {
gameDate = date
confidence += 0.3
}
return ParsedMarketInfo(homeTeam, awayTeam, gameDate, confidence.coerceAtMost(1.0))
}
/**
* 提取球队名称
*/
private fun extractTeams(text: String): List<String> {
val teams = mutableListOf<String>()
// 常见的球队名称模式
val patterns = listOf(
// "Team1 vs Team2" 或 "Team1 @ Team2"
Pattern.compile("(\\w+(?:\\s+\\w+)*?)\\s+(?:vs|@|v\\.?|versus)\\s+(\\w+(?:\\s+\\w+)*?)", Pattern.CASE_INSENSITIVE),
// "Will Team1 beat Team2"
Pattern.compile("will\\s+(\\w+(?:\\s+\\w+)*?)\\s+beat\\s+(\\w+(?:\\s+\\w+)*?)", Pattern.CASE_INSENSITIVE),
// "Team1 win" 或 "Team1 wins"
Pattern.compile("(\\w+(?:\\s+\\w+)*?)\\s+win", Pattern.CASE_INSENSITIVE)
)
for (pattern in patterns) {
val matcher = pattern.matcher(text)
if (matcher.find()) {
val team1 = normalizeTeamName(matcher.group(1) ?: "")
val team2 = if (matcher.groupCount() >= 2) {
normalizeTeamName(matcher.group(2) ?: "")
} else null
if (team1 != null) {
teams.add(team1)
}
if (team2 != null) {
teams.add(team2)
}
if (teams.size >= 2) {
break
}
}
}
// 如果模式匹配失败,尝试直接查找球队名称
if (teams.isEmpty()) {
for ((key, value) in teamNameMapping) {
if (text.contains(key, ignoreCase = true)) {
if (!teams.contains(value)) {
teams.add(value)
}
}
}
}
return teams.distinct()
}
/**
* 标准化球队名称
*/
private fun normalizeTeamName(name: String): String? {
val normalized = name.trim().lowercase()
return teamNameMapping[normalized] ?: teamNameMapping.entries.firstOrNull {
normalized.contains(it.key, ignoreCase = true)
}?.value
}
/**
* 提取日期
*/
private fun extractDate(text: String): LocalDate? {
// 尝试多种日期格式
try {
// 格式1: "Dec 15, 2024" 或 "December 15, 2024"
val pattern1 = Pattern.compile("(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\\s+(\\d{1,2}),?\\s+(\\d{4})", Pattern.CASE_INSENSITIVE)
val matcher1 = pattern1.matcher(text)
if (matcher1.find()) {
val monthStr = matcher1.group(1)?.lowercase() ?: return null
val day = matcher1.group(2)?.toIntOrNull() ?: return null
val year = matcher1.group(3)?.toIntOrNull() ?: return null
val monthMap = mapOf(
"jan" to 1, "january" to 1,
"feb" to 2, "february" to 2,
"mar" to 3, "march" to 3,
"apr" to 4, "april" to 4,
"may" to 5,
"jun" to 6, "june" to 6,
"jul" to 7, "july" to 7,
"aug" to 8, "august" to 8,
"sep" to 9, "september" to 9,
"oct" to 10, "october" to 10,
"nov" to 11, "november" to 11,
"dec" to 12, "december" to 12
)
val month = monthMap.entries.firstOrNull { monthStr.startsWith(it.key) }?.value
if (month != null) {
return try {
LocalDate.of(year, month, day)
} catch (e: Exception) {
null
}
}
}
// 格式2: "2024-12-15"
val pattern2 = Pattern.compile("(\\d{4})[-/](\\d{1,2})[-/](\\d{1,2})")
val matcher2 = pattern2.matcher(text)
if (matcher2.find()) {
val year = matcher2.group(1)?.toIntOrNull() ?: return null
val month = matcher2.group(2)?.toIntOrNull() ?: return null
val day = matcher2.group(3)?.toIntOrNull() ?: return null
return try {
LocalDate.of(year, month, day)
} catch (e: Exception) {
null
}
}
// 格式3: "12/15/2024" 或 "12/15/24"
val pattern3 = Pattern.compile("(\\d{1,2})/(\\d{1,2})/(\\d{2,4})")
val matcher3 = pattern3.matcher(text)
if (matcher3.find()) {
val month = matcher3.group(1)?.toIntOrNull() ?: return null
val day = matcher3.group(2)?.toIntOrNull() ?: return null
val yearStr = matcher3.group(3) ?: return null
val year = if (yearStr.length == 2) {
// 两位年份,假设是 2000-2099
val y = yearStr.toIntOrNull() ?: return null
if (y < 50) 2000 + y else 1900 + y
} else {
yearStr.toIntOrNull() ?: return null
}
return try {
LocalDate.of(year, month, day)
} catch (e: Exception) {
null
}
}
} catch (e: Exception) {
logger.debug("解析日期失败: ${e.message}")
}
return null
}
/**
* 匹配比赛和市场
* @param homeTeam 主队名称
* @param awayTeam 客队名称
* @param gameDate 比赛日期
* @param parsedMarket 解析的市场信息
* @return 是否匹配
*/
fun matchGame(
homeTeam: String,
awayTeam: String,
gameDate: LocalDate,
parsedMarket: ParsedMarketInfo
): Boolean {
// 检查日期是否匹配(允许1天误差)
val dateMatch = parsedMarket.gameDate?.let { marketDate ->
val daysDiff = kotlin.math.abs(java.time.temporal.ChronoUnit.DAYS.between(gameDate, marketDate))
daysDiff <= 1
} ?: false
if (!dateMatch && parsedMarket.gameDate != null) {
return false
}
// 检查球队是否匹配
val homeMatch = parsedMarket.homeTeam?.let {
normalizeTeamName(it)?.equals(normalizeTeamName(homeTeam), ignoreCase = true)
} ?: false
val awayMatch = parsedMarket.awayTeam?.let {
normalizeTeamName(it)?.equals(normalizeTeamName(awayTeam), ignoreCase = true)
} ?: false
// 如果两个球队都匹配,或者至少一个匹配且日期匹配
return (homeMatch && awayMatch) || ((homeMatch || awayMatch) && dateMatch)
}
}
@@ -0,0 +1,165 @@
package com.wrbug.polymarketbot.service.nba
import com.wrbug.polymarketbot.api.MarketResponse
import com.wrbug.polymarketbot.api.PolymarketGammaApi
import com.wrbug.polymarketbot.dto.NbaMarketDto
import com.wrbug.polymarketbot.dto.NbaMarketListRequest
import com.wrbug.polymarketbot.dto.NbaMarketListResponse
import com.wrbug.polymarketbot.enums.SportsTagId
import com.wrbug.polymarketbot.util.RetrofitFactory
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
/**
* NBA 市场服务
* 用于从 Polymarket 获取 NBA 相关的市场信息
*/
@Service
class NbaMarketService(
private val retrofitFactory: RetrofitFactory
) {
private val logger = LoggerFactory.getLogger(NbaMarketService::class.java)
/**
* 获取 NBA tag ID 列表
* 直接使用枚举中定义的已知 tag ID无需调用 API
*/
suspend fun getNbaTagIds(): Result<List<String>> {
// 直接使用枚举中定义的 NBA tag ID
val nbaTagId = SportsTagId.NBA.tagId
logger.debug("使用枚举中的 NBA tag ID: $nbaTagId")
return Result.success(listOf(nbaTagId))
}
/**
* 获取 NBA 市场列表
* 使用 NBA tag IDs 过滤市场
*
* @param request 请求参数
* @return NBA 市场列表响应
*/
suspend fun getNbaMarkets(request: NbaMarketListRequest): Result<NbaMarketListResponse> {
return try {
// 先获取 NBA 的 tag IDs
val tagIdsResult = getNbaTagIds()
if (tagIdsResult.isFailure) {
return Result.failure(tagIdsResult.exceptionOrNull() ?: Exception("无法获取 NBA tag IDs"))
}
val tagIds = tagIdsResult.getOrNull() ?: return Result.failure(IllegalStateException("NBA tag IDs 为空"))
if (tagIds.isEmpty()) {
logger.warn("NBA tag IDs 为空,无法过滤市场")
return Result.success(NbaMarketListResponse(
list = emptyList(),
total = 0L
))
}
// 调用 /markets 接口,使用 tag IDs 过滤
val gammaApi = retrofitFactory.createGammaApi()
val response = gammaApi.listMarkets(
conditionIds = null,
includeTag = true,
tags = tagIds,
active = request.active,
closed = request.closed,
archived = request.archived
)
if (response.isSuccessful && response.body() != null) {
val markets = response.body()!!
logger.info("获取到 ${markets.size} 个 NBA 市场")
// 转换为 DTO
val marketDtos = markets.map { market ->
NbaMarketDto(
id = market.id,
question = market.question,
conditionId = market.conditionId,
slug = market.slug,
description = market.description,
category = market.category,
active = market.active,
closed = market.closed,
archived = market.archived,
volume = market.volume,
liquidity = market.liquidity,
endDate = market.endDate,
startDate = market.startDate,
outcomes = market.outcomes,
outcomePrices = market.outcomePrices,
volumeNum = market.volumeNum,
liquidityNum = market.liquidityNum,
lastTradePrice = market.lastTradePrice,
bestBid = market.bestBid,
bestAsk = market.bestAsk
)
}
Result.success(NbaMarketListResponse(
list = marketDtos,
total = marketDtos.size.toLong()
))
} else {
logger.error("获取 NBA 市场失败: ${response.code()} ${response.message()}")
val errorBody = response.errorBody()?.string()
logger.error("错误响应体: $errorBody")
Result.failure(Exception("获取 NBA 市场失败: ${response.code()} ${response.message()}"))
}
} catch (e: Exception) {
logger.error("获取 NBA 市场异常: ${e.message}", e)
Result.failure(e)
}
}
/**
* NBA 市场中提取球队列表
* 解析市场名称提取所有唯一的球队名称
*
* @param active 是否只从活跃市场提取默认 true
* @return 球队名称列表去重排序
*/
suspend fun getTeamsFromMarkets(active: Boolean = true): Result<List<String>> {
return try {
// 获取 NBA 市场列表
val marketsResult = getNbaMarkets(
NbaMarketListRequest(
active = active,
closed = false,
archived = false
)
)
if (marketsResult.isFailure) {
return Result.failure(marketsResult.exceptionOrNull() ?: Exception("无法获取 NBA 市场"))
}
val markets = marketsResult.getOrNull()?.list ?: return Result.success(emptyList())
// 使用市场名称解析器提取球队
val teams = mutableSetOf<String>()
markets.forEach { market ->
if (!market.question.isNullOrBlank()) {
val parsed = NbaMarketNameParser.parse(market.question)
if (parsed != null) {
// 提取主队和客队
parsed.homeTeam?.let { teams.add(it) }
parsed.awayTeam?.let { teams.add(it) }
}
}
}
// 排序并返回
val sortedTeams = teams.sorted()
logger.info("${markets.size} 个市场中提取到 ${sortedTeams.size} 个球队")
Result.success(sortedTeams)
} catch (e: Exception) {
logger.error("从市场提取球队列表失败: ${e.message}", e)
Result.failure(e)
}
}
}
@@ -0,0 +1,346 @@
package com.wrbug.polymarketbot.service.nba
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.entity.NbaQuantitativeStrategy
import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.repository.NbaQuantitativeStrategyRepository
import com.wrbug.polymarketbot.util.JsonUtils
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import org.slf4j.LoggerFactory
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
/**
* NBA 量化策略服务
*/
@Service
class NbaQuantitativeStrategyService(
private val strategyRepository: NbaQuantitativeStrategyRepository,
private val accountRepository: AccountRepository
) {
private val logger = LoggerFactory.getLogger(NbaQuantitativeStrategyService::class.java)
/**
* 创建策略
*/
@Transactional
suspend fun createStrategy(request: NbaQuantitativeStrategyCreateRequest): Result<NbaQuantitativeStrategyDto> {
return try {
// 验证账户是否存在
val account = accountRepository.findById(request.accountId).orElse(null)
if (account == null) {
return Result.failure(IllegalArgumentException("账户不存在"))
}
// 验证策略名称是否重复
val existing = strategyRepository.findByStrategyName(request.strategyName)
if (existing != null) {
return Result.failure(IllegalArgumentException("策略名称已存在"))
}
// 创建策略实体
val strategy = NbaQuantitativeStrategy(
strategyName = request.strategyName,
strategyDescription = request.strategyDescription,
accountId = request.accountId,
enabled = request.enabled,
filterTeams = request.filterTeams?.let { JsonUtils.toJson(it) },
filterDateFrom = request.filterDateFrom,
filterDateTo = request.filterDateTo,
filterGameImportance = request.filterGameImportance,
minWinProbabilityDiff = request.minWinProbabilityDiff ?: BigDecimal("0.1"),
minWinProbability = request.minWinProbability,
maxWinProbability = request.maxWinProbability,
minTradeValue = request.minTradeValue ?: BigDecimal("0.05"),
minRemainingTime = request.minRemainingTime,
maxRemainingTime = request.maxRemainingTime,
minScoreDiff = request.minScoreDiff,
maxScoreDiff = request.maxScoreDiff,
buyAmountStrategy = request.buyAmountStrategy ?: "FIXED",
fixedBuyAmount = request.fixedBuyAmount,
buyRatio = request.buyRatio,
baseBuyAmount = request.baseBuyAmount,
buyTiming = request.buyTiming ?: "IMMEDIATE",
delayBuySeconds = request.delayBuySeconds ?: 0,
buyDirection = request.buyDirection ?: "AUTO",
enableSell = request.enableSell ?: true,
takeProfitThreshold = request.takeProfitThreshold,
stopLossThreshold = request.stopLossThreshold,
probabilityReversalThreshold = request.probabilityReversalThreshold,
sellRatio = request.sellRatio ?: BigDecimal("1.0"),
sellTiming = request.sellTiming ?: "IMMEDIATE",
delaySellSeconds = request.delaySellSeconds ?: 0,
priceStrategy = request.priceStrategy ?: "MARKET",
fixedPrice = request.fixedPrice,
priceOffset = request.priceOffset ?: BigDecimal.ZERO,
maxPosition = request.maxPosition ?: BigDecimal("50"),
minPosition = request.minPosition ?: BigDecimal("5"),
maxGamePosition = request.maxGamePosition,
maxDailyLoss = request.maxDailyLoss,
maxDailyOrders = request.maxDailyOrders,
maxDailyProfit = request.maxDailyProfit,
priceTolerance = request.priceTolerance ?: BigDecimal("0.05"),
minProbabilityThreshold = request.minProbabilityThreshold,
maxProbabilityThreshold = request.maxProbabilityThreshold,
baseStrengthWeight = request.baseStrengthWeight ?: BigDecimal("0.3"),
recentFormWeight = request.recentFormWeight ?: BigDecimal("0.25"),
lineupIntegrityWeight = request.lineupIntegrityWeight ?: BigDecimal("0.2"),
starStatusWeight = request.starStatusWeight ?: BigDecimal("0.15"),
environmentWeight = request.environmentWeight ?: BigDecimal("0.1"),
matchupAdvantageWeight = request.matchupAdvantageWeight ?: BigDecimal("0.2"),
scoreDiffWeight = request.scoreDiffWeight ?: BigDecimal("0.3"),
momentumWeight = request.momentumWeight ?: BigDecimal("0.2"),
dataUpdateFrequency = request.dataUpdateFrequency ?: 30,
analysisFrequency = request.analysisFrequency ?: 30,
pushFailedOrders = request.pushFailedOrders ?: false,
pushFrequency = request.pushFrequency ?: "REALTIME",
batchPushInterval = request.batchPushInterval ?: 1
)
val saved = strategyRepository.save(strategy)
Result.success(toDto(saved))
} catch (e: Exception) {
logger.error("创建策略失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 更新策略
*/
@Transactional
suspend fun updateStrategy(request: NbaQuantitativeStrategyUpdateRequest): Result<NbaQuantitativeStrategyDto> {
return try {
val strategy = strategyRepository.findById(request.id).orElse(null)
if (strategy == null) {
return Result.failure(IllegalArgumentException("策略不存在"))
}
// 更新字段(只更新提供的字段)
val updated = strategy.copy(
strategyName = request.strategyName ?: strategy.strategyName,
strategyDescription = request.strategyDescription ?: strategy.strategyDescription,
enabled = request.enabled ?: strategy.enabled,
filterTeams = request.filterTeams?.let { JsonUtils.toJson(it) } ?: strategy.filterTeams,
filterDateFrom = request.filterDateFrom ?: strategy.filterDateFrom,
filterDateTo = request.filterDateTo ?: strategy.filterDateTo,
filterGameImportance = request.filterGameImportance ?: strategy.filterGameImportance,
minWinProbabilityDiff = request.minWinProbabilityDiff ?: strategy.minWinProbabilityDiff,
minWinProbability = request.minWinProbability ?: strategy.minWinProbability,
maxWinProbability = request.maxWinProbability ?: strategy.maxWinProbability,
minTradeValue = request.minTradeValue ?: strategy.minTradeValue,
minRemainingTime = request.minRemainingTime ?: strategy.minRemainingTime,
maxRemainingTime = request.maxRemainingTime ?: strategy.maxRemainingTime,
minScoreDiff = request.minScoreDiff ?: strategy.minScoreDiff,
maxScoreDiff = request.maxScoreDiff ?: strategy.maxScoreDiff,
buyAmountStrategy = request.buyAmountStrategy ?: strategy.buyAmountStrategy,
fixedBuyAmount = request.fixedBuyAmount ?: strategy.fixedBuyAmount,
buyRatio = request.buyRatio ?: strategy.buyRatio,
baseBuyAmount = request.baseBuyAmount ?: strategy.baseBuyAmount,
buyTiming = request.buyTiming ?: strategy.buyTiming,
delayBuySeconds = request.delayBuySeconds ?: strategy.delayBuySeconds,
buyDirection = request.buyDirection ?: strategy.buyDirection,
enableSell = request.enableSell ?: strategy.enableSell,
takeProfitThreshold = request.takeProfitThreshold ?: strategy.takeProfitThreshold,
stopLossThreshold = request.stopLossThreshold ?: strategy.stopLossThreshold,
probabilityReversalThreshold = request.probabilityReversalThreshold ?: strategy.probabilityReversalThreshold,
sellRatio = request.sellRatio ?: strategy.sellRatio,
sellTiming = request.sellTiming ?: strategy.sellTiming,
delaySellSeconds = request.delaySellSeconds ?: strategy.delaySellSeconds,
priceStrategy = request.priceStrategy ?: strategy.priceStrategy,
fixedPrice = request.fixedPrice ?: strategy.fixedPrice,
priceOffset = request.priceOffset ?: strategy.priceOffset,
maxPosition = request.maxPosition ?: strategy.maxPosition,
minPosition = request.minPosition ?: strategy.minPosition,
maxGamePosition = request.maxGamePosition ?: strategy.maxGamePosition,
maxDailyLoss = request.maxDailyLoss ?: strategy.maxDailyLoss,
maxDailyOrders = request.maxDailyOrders ?: strategy.maxDailyOrders,
maxDailyProfit = request.maxDailyProfit ?: strategy.maxDailyProfit,
priceTolerance = request.priceTolerance ?: strategy.priceTolerance,
minProbabilityThreshold = request.minProbabilityThreshold ?: strategy.minProbabilityThreshold,
maxProbabilityThreshold = request.maxProbabilityThreshold ?: strategy.maxProbabilityThreshold,
baseStrengthWeight = request.baseStrengthWeight ?: strategy.baseStrengthWeight,
recentFormWeight = request.recentFormWeight ?: strategy.recentFormWeight,
lineupIntegrityWeight = request.lineupIntegrityWeight ?: strategy.lineupIntegrityWeight,
starStatusWeight = request.starStatusWeight ?: strategy.starStatusWeight,
environmentWeight = request.environmentWeight ?: strategy.environmentWeight,
matchupAdvantageWeight = request.matchupAdvantageWeight ?: strategy.matchupAdvantageWeight,
scoreDiffWeight = request.scoreDiffWeight ?: strategy.scoreDiffWeight,
momentumWeight = request.momentumWeight ?: strategy.momentumWeight,
dataUpdateFrequency = request.dataUpdateFrequency ?: strategy.dataUpdateFrequency,
analysisFrequency = request.analysisFrequency ?: strategy.analysisFrequency,
pushFailedOrders = request.pushFailedOrders ?: strategy.pushFailedOrders,
pushFrequency = request.pushFrequency ?: strategy.pushFrequency,
batchPushInterval = request.batchPushInterval ?: strategy.batchPushInterval,
updatedAt = System.currentTimeMillis()
)
val saved = strategyRepository.save(updated)
Result.success(toDto(saved))
} catch (e: Exception) {
logger.error("更新策略失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 获取策略列表
*/
suspend fun getStrategyList(request: NbaQuantitativeStrategyListRequest): Result<NbaQuantitativeStrategyListResponse> {
return try {
val page = request.page ?: 1
val limit = request.limit ?: 20
val pageable = PageRequest.of(page - 1, limit, Sort.by(Sort.Direction.DESC, "createdAt"))
val strategies = when {
request.accountId != null && request.enabled != null -> {
strategyRepository.findByAccountIdAndEnabled(request.accountId, request.enabled)
}
request.accountId != null -> {
strategyRepository.findByAccountId(request.accountId)
}
request.enabled != null -> {
strategyRepository.findByEnabled(request.enabled)
}
else -> {
strategyRepository.findAll(pageable).content
}
}
// 过滤策略名称(如果提供)
val filtered = if (request.strategyName != null) {
strategies.filter { it.strategyName.contains(request.strategyName, ignoreCase = true) }
} else {
strategies
}
val total = filtered.size.toLong()
val dtoList = filtered.map { toDto(it) }
Result.success(
NbaQuantitativeStrategyListResponse(
list = dtoList,
total = total,
page = page,
limit = limit
)
)
} catch (e: Exception) {
logger.error("获取策略列表失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 获取策略详情
*/
suspend fun getStrategyDetail(id: Long): Result<NbaQuantitativeStrategyDto> {
return try {
val strategy = strategyRepository.findById(id).orElse(null)
if (strategy == null) {
return Result.failure(IllegalArgumentException("策略不存在"))
}
Result.success(toDto(strategy))
} catch (e: Exception) {
logger.error("获取策略详情失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 删除策略
*/
@Transactional
suspend fun deleteStrategy(id: Long): Result<Unit> {
return try {
val strategy = strategyRepository.findById(id).orElse(null)
if (strategy == null) {
return Result.failure(IllegalArgumentException("策略不存在"))
}
strategyRepository.delete(strategy)
Result.success(Unit)
} catch (e: Exception) {
logger.error("删除策略失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 获取启用的策略列表
*/
suspend fun getEnabledStrategies(): List<NbaQuantitativeStrategy> {
return strategyRepository.findByEnabled(true)
}
/**
* 转换为 DTO
*/
private fun toDto(strategy: NbaQuantitativeStrategy): NbaQuantitativeStrategyDto {
val account = accountRepository.findById(strategy.accountId).orElse(null)
return NbaQuantitativeStrategyDto(
id = strategy.id,
strategyName = strategy.strategyName,
strategyDescription = strategy.strategyDescription,
accountId = strategy.accountId,
accountName = account?.accountName,
enabled = strategy.enabled,
filterTeams = strategy.filterTeams?.let { JsonUtils.parseStringList(it) },
filterDateFrom = strategy.filterDateFrom,
filterDateTo = strategy.filterDateTo,
filterGameImportance = strategy.filterGameImportance,
minWinProbabilityDiff = strategy.minWinProbabilityDiff,
minWinProbability = strategy.minWinProbability,
maxWinProbability = strategy.maxWinProbability,
minTradeValue = strategy.minTradeValue,
minRemainingTime = strategy.minRemainingTime,
maxRemainingTime = strategy.maxRemainingTime,
minScoreDiff = strategy.minScoreDiff,
maxScoreDiff = strategy.maxScoreDiff,
buyAmountStrategy = strategy.buyAmountStrategy,
fixedBuyAmount = strategy.fixedBuyAmount,
buyRatio = strategy.buyRatio,
baseBuyAmount = strategy.baseBuyAmount,
buyTiming = strategy.buyTiming,
delayBuySeconds = strategy.delayBuySeconds,
buyDirection = strategy.buyDirection,
enableSell = strategy.enableSell,
takeProfitThreshold = strategy.takeProfitThreshold,
stopLossThreshold = strategy.stopLossThreshold,
probabilityReversalThreshold = strategy.probabilityReversalThreshold,
sellRatio = strategy.sellRatio,
sellTiming = strategy.sellTiming,
delaySellSeconds = strategy.delaySellSeconds,
priceStrategy = strategy.priceStrategy,
fixedPrice = strategy.fixedPrice,
priceOffset = strategy.priceOffset,
maxPosition = strategy.maxPosition,
minPosition = strategy.minPosition,
maxGamePosition = strategy.maxGamePosition,
maxDailyLoss = strategy.maxDailyLoss,
maxDailyOrders = strategy.maxDailyOrders,
maxDailyProfit = strategy.maxDailyProfit,
priceTolerance = strategy.priceTolerance,
minProbabilityThreshold = strategy.minProbabilityThreshold,
maxProbabilityThreshold = strategy.maxProbabilityThreshold,
baseStrengthWeight = strategy.baseStrengthWeight,
recentFormWeight = strategy.recentFormWeight,
lineupIntegrityWeight = strategy.lineupIntegrityWeight,
starStatusWeight = strategy.starStatusWeight,
environmentWeight = strategy.environmentWeight,
matchupAdvantageWeight = strategy.matchupAdvantageWeight,
scoreDiffWeight = strategy.scoreDiffWeight,
momentumWeight = strategy.momentumWeight,
dataUpdateFrequency = strategy.dataUpdateFrequency,
analysisFrequency = strategy.analysisFrequency,
pushFailedOrders = strategy.pushFailedOrders,
pushFrequency = strategy.pushFrequency,
batchPushInterval = strategy.batchPushInterval,
createdAt = strategy.createdAt,
updatedAt = strategy.updatedAt
)
}
}
@@ -28,8 +28,6 @@ class ApiHealthCheckService(
private val dataApiBaseUrl: String,
@Value("\${polymarket.gamma.base-url}")
private val gammaBaseUrl: String,
@Value("\${polygon.rpc.url:}")
private val polygonRpcUrl: String,
@Value("\${polymarket.rtds.ws-url}")
private val polymarketWsUrl: String,
@Value("\${polymarket.builder.relayer-url:}")
@@ -187,27 +185,11 @@ class ApiHealthCheckService(
/**
* 检查 Polygon RPC
* 使用动态获取的可用节点而不是固定的配置
* 使用动态获取的可用节点RpcNodeService 总是返回一个有效的 URL包括默认节点
*/
private suspend fun checkPolygonRpc(): ApiHealthCheckDto = withContext(Dispatchers.IO) {
// 优先使用动态获取可用节点
val rpcUrl = try {
rpcNodeService.getHttpUrl()
} catch (e: Exception) {
logger.debug("获取可用 RPC 节点失败,使用配置的默认值: ${e.message}")
// 如果获取失败,使用配置的默认值作为兜底
if (polygonRpcUrl.isNotBlank()) {
polygonRpcUrl
} else {
return@withContext ApiHealthCheckDto(
name = "Polygon RPC",
url = "未配置",
status = "skipped",
message = "未配置 Polygon RPC URL 且没有可用的节点"
)
}
}
// 使用 RpcNodeService 获取可用节点(总是返回有效值,包括默认节点)
val rpcUrl = rpcNodeService.getHttpUrl()
checkJsonRpcApi("Polygon RPC", rpcUrl)
}
@@ -333,15 +333,10 @@ class RelayClientService(
// 打包签名(参考 builder-relayer-client/src/utils/index.ts 的 splitAndPackSig
val packedSignature = splitAndPackSig(safeSignature)
// 调试日志
// 调试日志(地址已遮蔽)
logger.debug("=== Builder Relayer 签名调试 ===")
logger.debug("Safe Address: $proxyAddress")
logger.debug("From Address: $fromAddress")
logger.debug("To: ${safeTx.to}")
logger.debug("Data: $redeemCallData")
logger.debug("Nonce: $proxyNonce")
logger.debug("Packed Signature: $packedSignature")
logger.debug("Signature Length: ${packedSignature.length} (expected: 132 with 0x)")
logger.debug("Safe: ${proxyAddress.take(10)}..., From: ${fromAddress.take(10)}..., Nonce: $proxyNonce")
logger.debug("Signature Length: ${packedSignature.length}")
// 构建 TransactionRequest(参考 builder-relayer-client/src/builder/safe.ts
// 注意:根据 TypeScript 实现,data 和 signature 都应该带 0x 前缀
@@ -364,13 +359,7 @@ class RelayClientService(
metadata = "Redeem positions via Builder Relayer"
)
logger.debug("Request Type: ${request.type}")
logger.debug("Request From: ${request.from}")
logger.debug("Request To: ${request.to}")
logger.debug("Request ProxyWallet: ${request.proxyWallet}")
logger.debug("Request Data Length: ${request.data.length}")
logger.debug("Request Signature Length: ${request.signature.length}")
logger.debug("Request Nonce: ${request.nonce}")
logger.debug("Request: type=${request.type}, dataLen=${request.data.length}, sigLen=${request.signature.length}, nonce=${request.nonce}")
// 调用 Builder Relayer API(认证头通过拦截器添加)
val response = relayerApi.submitTransaction(request)
@@ -25,6 +25,19 @@ class SystemConfigService(
const val CONFIG_KEY_BUILDER_SECRET = "builder.secret"
const val CONFIG_KEY_BUILDER_PASSPHRASE = "builder.passphrase"
const val CONFIG_KEY_AUTO_REDEEM = "auto_redeem"
/**
* 遮蔽敏感信息仅显示前4位和后4位
* 例如abcd1234...wxyz5678
*/
fun maskSensitiveValue(value: String?): String? {
if (value == null) return null
return when {
value.length <= 8 -> "****" // 太短则完全遮蔽
value.length <= 16 -> "${value.take(2)}...${value.takeLast(2)}"
else -> "${value.take(4)}...${value.takeLast(4)}"
}
}
}
/**
@@ -36,26 +49,26 @@ class SystemConfigService(
val builderPassphrase = getConfigValue(CONFIG_KEY_BUILDER_PASSPHRASE)
val autoRedeem = isAutoRedeemEnabled()
// 获取完整的 API Key(用于前端展示
val builderApiKeyDisplay = builderApiKey?.let {
// 获取遮蔽后的显示值(仅显示部分字符,用于前端确认配置
val builderApiKeyDisplay = builderApiKey?.let {
try {
cryptoUtils.decrypt(it)
maskSensitiveValue(cryptoUtils.decrypt(it))
} catch (e: Exception) {
null
}
}
val builderSecretDisplay = builderSecret?.let {
try {
cryptoUtils.decrypt(it)
maskSensitiveValue(cryptoUtils.decrypt(it))
} catch (e: Exception) {
null
}
}
val builderPassphraseDisplay = builderPassphrase?.let {
try {
cryptoUtils.decrypt(it)
maskSensitiveValue(cryptoUtils.decrypt(it))
} catch (e: Exception) {
null
}
@@ -97,21 +97,28 @@ class TelegramNotificationService(
if ((actualPrice == null || actualSize == null) && orderId != null && clobApi != null && apiKey != null && apiSecret != null && apiPassphrase != null && walletAddressForApi != null) {
try {
val orderResponse = clobApi.getOrder(orderId)
if (orderResponse.isSuccessful && orderResponse.body() != null) {
val order = orderResponse.body()!!
if (actualPrice == null) {
actualPrice = order.price
}
if (actualSize == null) {
actualSize = order.originalSize // 使用 originalSize 作为订单数量
}
actualSide = order.side // 使用订单详情中的 side
if (actualOutcome == null) {
actualOutcome = order.outcome // 使用订单详情中的 outcome(市场方向)
if (orderResponse.isSuccessful) {
val order = orderResponse.body()
if (order != null) {
if (actualPrice == null) {
actualPrice = order.price
}
if (actualSize == null) {
actualSize = order.originalSize // 使用 originalSize 作为订单数量
}
actualSide = order.side // 使用订单详情中的 side
if (actualOutcome == null) {
actualOutcome = order.outcome // 使用订单详情中的 outcome(市场方向)
}
} else {
logger.debug("查询订单详情失败: 响应体为空, orderId=$orderId")
}
} else {
val errorBody = orderResponse.errorBody()?.string()?.take(200) ?: "无错误详情"
logger.debug("查询订单详情失败: orderId=$orderId, code=${orderResponse.code()}, errorBody=$errorBody")
}
} catch (e: Exception) {
logger.warn("查询订单详情失败: ${e.message}", e)
logger.warn("查询订单详情失败: orderId=$orderId, ${e.message}", e)
}
}
@@ -306,18 +313,8 @@ class TelegramNotificationService(
else -> filterType
}
// 优先使用账户名称,如果没有账户名称才显示钱包地址
val accountInfo: String = when {
!accountName.isNullOrBlank() -> {
accountName!!
}
!walletAddress.isNullOrBlank() -> {
maskAddress(walletAddress!!)
}
else -> {
unknownAccount
}
}
// 构建账户信息(格式:账户名(钱包地址)
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
val time = DateUtils.formatDateTime()
@@ -369,13 +366,17 @@ class TelegramNotificationService(
""
}
// 格式化价格和数量
val priceDisplay = formatPrice(price)
val sizeDisplay = formatQuantity(size)
return """🚫 <b>$orderFiltered</b>
📊 <b>$orderInfo</b>
$marketLabel: $marketDisplay$outcomeDisplay
$sideLabel: <b>$sideDisplay</b>
$priceLabel: <code>$price</code>
$quantityLabel: <code>$size</code> shares
$priceLabel: <code>$priceDisplay</code>
$quantityLabel: <code>$sizeDisplay</code> shares
$amountLabel: <code>$amountDisplay</code> USDC
$accountLabel: $escapedAccountInfo
@@ -580,6 +581,68 @@ class TelegramNotificationService(
}
}
/**
* 格式化价格显示保留最多4位小数截断不四舍五入
*/
private fun formatPrice(price: String): String {
return try {
val priceDecimal = price.toSafeBigDecimal()
val formatted = if (priceDecimal.scale() > 4) {
priceDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros()
} else {
priceDecimal.stripTrailingZeros()
}
formatted.toPlainString()
} catch (e: Exception) {
price
}
}
/**
* 格式化数量显示保留最多2位小数截断不四舍五入
*/
private fun formatQuantity(quantity: String): String {
return try {
val quantityDecimal = quantity.toSafeBigDecimal()
val formatted = if (quantityDecimal.scale() > 2) {
quantityDecimal.setScale(2, java.math.RoundingMode.DOWN).stripTrailingZeros()
} else {
quantityDecimal.stripTrailingZeros()
}
formatted.toPlainString()
} catch (e: Exception) {
quantity
}
}
/**
* 构建账户信息显示格式账户名(钱包地址)
*/
private fun buildAccountInfo(
accountName: String?,
walletAddress: String?,
unknownAccount: String
): String {
return when {
!accountName.isNullOrBlank() && !walletAddress.isNullOrBlank() -> {
// 有账户名和钱包地址:账户名(钱包地址)
"${accountName}(${maskAddress(walletAddress)})"
}
!accountName.isNullOrBlank() -> {
// 只有账户名
accountName
}
!walletAddress.isNullOrBlank() -> {
// 只有钱包地址
maskAddress(walletAddress)
}
else -> {
// 都没有
unknownAccount
}
}
}
/**
* 构建订单成功消息
*/
@@ -623,18 +686,8 @@ class TelegramNotificationService(
else -> side
}
// 优先使用账户名称,如果没有账户名称才显示钱包地址
val accountInfo: String = when {
!accountName.isNullOrBlank() -> {
accountName!!
}
!walletAddress.isNullOrBlank() -> {
maskAddress(walletAddress!!)
}
else -> {
unknownAccount
}
}
// 构建账户信息(格式:账户名(钱包地址)
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
// 构建跟单信息(如果有)
val copyTradingInfo = mutableListOf<String>()
@@ -704,14 +757,18 @@ class TelegramNotificationService(
""
}
// 格式化价格和数量
val priceDisplay = formatPrice(price)
val sizeDisplay = formatQuantity(size)
return """✅ <b>$orderCreatedSuccess</b>
📊 <b>$orderInfo</b>
$orderIdLabel: <code>${orderId ?: unknown}</code>
$marketLabel: $marketDisplay$outcomeDisplay
$sideLabel: <b>$sideDisplay</b>
$priceLabel: <code>$price</code>
$quantityLabel: <code>$size</code> shares
$priceLabel: <code>$priceDisplay</code>
$quantityLabel: <code>$sizeDisplay</code> shares
$amountLabel: <code>$amountDisplay</code> USDC
$accountLabel: $escapedAccountInfo$escapedCopyTradingInfo
@@ -758,18 +815,8 @@ class TelegramNotificationService(
else -> side
}
// 优先使用账户名称,如果没有账户名称才显示钱包地址
val accountInfo: String = when {
!accountName.isNullOrBlank() -> {
accountName!!
}
!walletAddress.isNullOrBlank() -> {
maskAddress(walletAddress!!)
}
else -> {
unknownAccount
}
}
// 构建账户信息(格式:账户名(钱包地址)
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
val time = DateUtils.formatDateTime()
@@ -828,13 +875,17 @@ class TelegramNotificationService(
""
}
// 格式化价格和数量
val priceDisplay = formatPrice(price)
val sizeDisplay = formatQuantity(size)
return """❌ <b>$orderCreatedFailed</b>
📊 <b>$orderInfo</b>
$marketLabel: $marketDisplay$outcomeDisplay
$sideLabel: <b>$sideDisplay</b>
$priceLabel: <code>$price</code>
$quantityLabel: <code>$size</code> shares
$priceLabel: <code>$priceDisplay</code>
$quantityLabel: <code>$sizeDisplay</code> shares
$amountLabel: <code>$amountDisplay</code> USDC
$accountLabel: $escapedAccountInfo
@@ -899,18 +950,8 @@ class TelegramNotificationService(
val timeLabel = messageSource.getMessage("notification.order.time", null, "时间", locale)
val unknownAccount: String = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", locale) ?: "未知账户"
// 优先使用账户名称,如果没有账户名称才显示钱包地址
val accountInfo: String = when {
!accountName.isNullOrBlank() -> {
accountName!!
}
!walletAddress.isNullOrBlank() -> {
maskAddress(walletAddress!!)
}
else -> {
unknownAccount
}
}
// 构建账户信息(格式:账户名(钱包地址)
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
val time = DateUtils.formatDateTime()
@@ -933,12 +974,7 @@ class TelegramNotificationService(
// 构建仓位列表
val positionsText = positions.joinToString("\n") { position ->
val quantityDisplay = try {
val quantityDecimal = position.quantity.toSafeBigDecimal()
quantityDecimal.stripTrailingZeros().toPlainString()
} catch (e: Exception) {
position.quantity
}
val quantityDisplay = formatQuantity(position.quantity)
val valueDisplay = try {
val valueDecimal = position.value.toSafeBigDecimal()
val formatted = if (valueDecimal.scale() > 4) {
@@ -19,7 +19,7 @@ object EthereumUtils {
* @return 函数选择器例如 "0x12345678"
*/
fun getFunctionSelector(functionSignature: String): String {
val hash = keccak256(functionSignature.toByteArray())
val hash = keccak256Hex(functionSignature.toByteArray())
return "0x" + hash.substring(0, 8)
}
@@ -77,16 +77,109 @@ object EthereumUtils {
return BigInteger(cleanHex, 16)
}
/**
* ABI 编码的响应中解析 uint256 数组
* ABI 编码格式
* - offset (32 bytes): 数组数据的位置偏移量
* - length (32 bytes): 数组长度
* - data: 每个元素 32 字节
* @param hexResult 十六进制结果完整的 ABI 编码响应
* @param offset 数组数据的偏移位置字节数 offset 位置开始读取
* @return BigInteger 数组
*/
fun decodeUint256Array(hexResult: String, offset: Int = 0): List<BigInteger> {
val cleanHex = hexResult.removePrefix("0x")
if (cleanHex.length < (offset + 1) * 64) {
return emptyList()
}
// 从 offset 位置开始读取
val startPos = offset * 64 // 每个 uint256 是 64 个十六进制字符
val lengthHex = cleanHex.substring(startPos, startPos + 64)
val length = BigInteger(lengthHex, 16).toInt()
if (length <= 0 || length > 100) { // 防止异常数据
return emptyList()
}
val result = mutableListOf<BigInteger>()
for (i in 0 until length) {
val elementStart = startPos + 64 + (i * 64) // 跳过长度字段
if (elementStart + 64 > cleanHex.length) {
break
}
val elementHex = cleanHex.substring(elementStart, elementStart + 64)
result.add(BigInteger(elementHex, 16))
}
return result
}
/**
* ABI 编码的元组响应中解析数据
* 用于解析 getCondition 返回的 (uint256 payoutDenominator, uint256[] payouts)
* @param hexResult 十六进制结果
* @return Pair<payoutDenominator, payouts>
*/
fun decodeConditionResult(hexResult: String): Pair<BigInteger, List<BigInteger>> {
val cleanHex = hexResult.removePrefix("0x")
// 第一个 32 字节:payoutDenominator
val payoutDenominatorHex = cleanHex.substring(0, 64)
val payoutDenominator = BigInteger(payoutDenominatorHex, 16)
// 第二个 32 字节:payouts 数组的偏移量(通常是 0x40 = 64 字节)
val offsetHex = cleanHex.substring(64, 128)
val offset = BigInteger(offsetHex, 16).toInt() / 32 // 转换为 32 字节单位
// 从 offset 位置解析数组
val payouts = decodeUint256Array(hexResult, offset)
return Pair(payoutDenominator, payouts)
}
/**
* 将十六进制字符串转换为字节数组
* @param hex 十六进制字符串带或不带 0x 前缀
* @return 字节数组
*/
fun hexToBytes(hex: String): ByteArray {
val cleanHex = hex.removePrefix("0x")
return ByteArray(cleanHex.length / 2) { i ->
cleanHex.substring(i * 2, i * 2 + 2).toInt(16).toByte()
}
}
/**
* 将字节数组转换为十六进制字符串
* @param bytes 字节数组
* @return 十六进制字符串 0x 前缀
*/
fun bytesToHex(bytes: ByteArray): String {
return "0x" + bytes.joinToString("") { "%02x".format(it) }
}
/**
* 计算 Keccak-256 哈希Ethereum 标准
* 使用 BouncyCastle 库实现真正的 Keccak-256
* @param data 输入数据
* @return 32 字节的哈希值
*/
private fun keccak256(data: ByteArray): String {
fun keccak256(data: ByteArray): ByteArray {
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) }
return hash
}
/**
* 计算 Keccak-256 哈希并返回十六进制字符串
* @param data 输入数据
* @return 十六进制哈希字符串
*/
fun keccak256Hex(data: ByteArray): String {
return keccak256(data).joinToString("") { "%02x".format(it) }
}
}
@@ -2,14 +2,16 @@ package com.wrbug.polymarketbot.util
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import org.springframework.stereotype.Component
/**
* JSON 工具类
* 用于解析 JSON 字符串
*/
object JsonUtils {
private val gson = Gson()
@Component
class JsonUtils(
private val gson: Gson
) {
/**
* 解析 JSON 字符串数组
@@ -28,5 +30,29 @@ object JsonUtils {
emptyList()
}
}
/**
* 解析 JSON 字符串列表parseStringArray 的别名
*/
fun parseStringList(jsonString: String?): List<String> {
return parseStringArray(jsonString)
}
/**
* 将对象转换为 JSON 字符串
* @param obj 要转换的对象
* @return JSON 字符串
*/
fun toJson(obj: Any?): String? {
if (obj == null) {
return null
}
return try {
gson.toJson(obj)
} catch (e: Exception) {
null
}
}
}
@@ -0,0 +1,64 @@
package com.wrbug.polymarketbot.util
import com.wrbug.polymarketbot.api.NbaStatsApi
import org.slf4j.LoggerFactory
import java.time.LocalDate
import java.time.format.DateTimeFormatter
/**
* NBA API 验证工具
* 用于验证 API 调用是否正确
*/
object NbaApiValidator {
private val logger = LoggerFactory.getLogger(NbaApiValidator::class.java)
/**
* 验证 API 调用
*/
suspend fun validateApi(nbaStatsApi: NbaStatsApi): Boolean {
return try {
val today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))
logger.info("验证 NBA Stats API,日期: $today")
val response = nbaStatsApi.getScoreboard(gameDate = today)
logger.info("API 响应状态码: ${response.code()}")
logger.info("API 响应消息: ${response.message()}")
if (response.isSuccessful && response.body() != null) {
val scoreboard = response.body()!!
logger.info("ResultSets 数量: ${scoreboard.resultSets.size}")
scoreboard.resultSets.forEachIndexed { index, resultSet ->
logger.info("ResultSet[$index]: name=${resultSet.name}, headers=${resultSet.headers.size}, rows=${resultSet.rowSet.size}")
if (resultSet.headers.isNotEmpty()) {
logger.info(" Headers: ${resultSet.headers.take(10)}")
}
if (resultSet.rowSet.isNotEmpty()) {
val firstRow = resultSet.rowSet.first()
logger.info(" First row size: ${firstRow.size}")
logger.info(" First row (first 5): ${firstRow.take(5)}")
}
}
// 检查是否有 GameHeader 和 LineScore
val hasGameHeader = scoreboard.resultSets.any { it.name == "GameHeader" }
val hasLineScore = scoreboard.resultSets.any { it.name == "LineScore" }
logger.info("包含 GameHeader: $hasGameHeader")
logger.info("包含 LineScore: $hasLineScore")
hasGameHeader && hasLineScore
} else {
logger.error("API 调用失败")
val errorBody = response.errorBody()?.string()
logger.error("错误响应体: $errorBody")
false
}
} catch (e: Exception) {
logger.error("验证 API 异常: ${e.message}", e)
false
}
}
}
@@ -1,10 +1,10 @@
package com.wrbug.polymarketbot.util
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.wrbug.polymarketbot.api.BuilderRelayerApi
import com.wrbug.polymarketbot.api.EthereumRpcApi
import com.wrbug.polymarketbot.api.GitHubApi
import com.wrbug.polymarketbot.api.NbaStatsApi
import com.wrbug.polymarketbot.api.PolymarketClobApi
import com.wrbug.polymarketbot.api.PolymarketDataApi
import com.wrbug.polymarketbot.api.PolymarketGammaApi
@@ -24,21 +24,116 @@ import org.springframework.stereotype.Component
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
import jakarta.annotation.PreDestroy
/**
* Retrofit 客户端工厂
* 用于创建带认证的 Polymarket CLOB API 客户端和 Ethereum RPC API 客户端
*
* 注意为了避免内存泄漏本类会缓存和复用客户端实例
*/
@Component
class RetrofitFactory(
@Value("\${polymarket.clob.base-url}")
private val clobBaseUrl: String,
@Value("\${polymarket.gamma.base-url}")
private val gammaBaseUrl: String
private val gammaBaseUrl: String,
private val gson: Gson
) {
private val logger = LoggerFactory.getLogger(RetrofitFactory::class.java)
// 共享的 OkHttpClient(用于不需要认证的 API)
private val sharedOkHttpClient: OkHttpClient by lazy {
createClient().build()
}
// 共享的 OkHttpClient(用于需要跟随重定向的 API)
private val sharedOkHttpClientWithRedirect: OkHttpClient by lazy {
createClient()
.followRedirects(true)
.followSslRedirects(true)
.build()
}
// 缓存 Gamma API 客户端(单例)
private val gammaApi: PolymarketGammaApi by lazy {
val baseUrl = if (gammaBaseUrl.endsWith("/")) {
gammaBaseUrl.dropLast(1)
} else {
gammaBaseUrl
}
Retrofit.Builder()
.baseUrl("$baseUrl/")
.client(sharedOkHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(PolymarketGammaApi::class.java)
}
// 缓存 Data API 客户端(单例)
private val dataApi: PolymarketDataApi by lazy {
val baseUrl = "https://data-api.polymarket.com"
Retrofit.Builder()
.baseUrl("$baseUrl/")
.client(sharedOkHttpClientWithRedirect)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(PolymarketDataApi::class.java)
}
// 缓存 GitHub API 客户端(单例)
private val githubApi: GitHubApi by lazy {
val baseUrl = "https://api.github.com"
// 添加拦截器,设置 Accept 头以获取 reactions 数据
val githubInterceptor = object : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().newBuilder()
.header("Accept", "application/vnd.github+json")
.build()
return chain.proceed(request)
}
}
val okHttpClient = createClient()
.addInterceptor(githubInterceptor)
.build()
Retrofit.Builder()
.baseUrl("$baseUrl/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(GitHubApi::class.java)
}
// 缓存不带认证的 CLOB API 客户端(单例)
private val clobApiWithoutAuth: PolymarketClobApi by lazy {
Retrofit.Builder()
.baseUrl(clobBaseUrl)
.client(sharedOkHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(PolymarketClobApi::class.java)
}
// 缓存带认证的 CLOB API 客户端:walletAddress -> PolymarketClobApi
// 注意:每个账户使用不同的 API Key,需要不同的客户端
private val clobApiCache = ConcurrentHashMap<String, PolymarketClobApi>()
// 缓存 RPC API 客户端:rpcUrl -> EthereumRpcApi
private val rpcApiCache = ConcurrentHashMap<String, EthereumRpcApi>()
// 缓存 Builder Relayer API 客户端:relayerUrl -> BuilderRelayerApi
private val builderRelayerApiCache = ConcurrentHashMap<String, BuilderRelayerApi>()
/**
* 创建带认证的 Polymarket CLOB API 客户端
* 按钱包地址缓存避免重复创建
* @param apiKey API Key
* @param apiSecret API Secret
* @param apiPassphrase API Passphrase
@@ -51,67 +146,46 @@ class RetrofitFactory(
apiPassphrase: String,
walletAddress: String
): PolymarketClobApi {
val authInterceptor = PolymarketAuthInterceptor(apiKey, apiSecret, apiPassphrase, walletAddress)
// 添加响应日志拦截器,用于调试 JSON 解析错误
val responseLoggingInterceptor = ResponseLoggingInterceptor()
val okHttpClient = createClient()
.addInterceptor(authInterceptor)
.addInterceptor(responseLoggingInterceptor)
.build()
// 创建 lenient 模式的 Gson,允许解析格式不严格的 JSON
val gson = GsonBuilder()
.setLenient()
.create()
return Retrofit.Builder()
.baseUrl(clobBaseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(PolymarketClobApi::class.java)
// 使用钱包地址作为缓存键(每个账户使用不同的 API Key)
return clobApiCache.computeIfAbsent(walletAddress) {
val authInterceptor = PolymarketAuthInterceptor(apiKey, apiSecret, apiPassphrase, walletAddress)
// 添加响应日志拦截器,用于调试 JSON 解析错误
val responseLoggingInterceptor = ResponseLoggingInterceptor()
val okHttpClient = createClient()
.addInterceptor(authInterceptor)
.addInterceptor(responseLoggingInterceptor)
.build()
Retrofit.Builder()
.baseUrl(clobBaseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(PolymarketClobApi::class.java)
}
}
/**
* 创建不带认证的 Polymarket CLOB API 客户端
* 用于不需要认证的查询接口
* @return PolymarketClobApi 客户端
* @return PolymarketClobApi 客户端单例
*/
fun createClobApiWithoutAuth(): PolymarketClobApi {
// 添加响应日志拦截器,用于调试 JSON 解析错误
val responseLoggingInterceptor = ResponseLoggingInterceptor()
val okHttpClient = createClient()
.addInterceptor(responseLoggingInterceptor)
.build()
// 创建 lenient 模式的 Gson,允许解析格式不严格的 JSON
val gson = GsonBuilder()
.setLenient()
.create()
return Retrofit.Builder()
.baseUrl(clobBaseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(PolymarketClobApi::class.java)
return clobApiWithoutAuth
}
/**
* 创建 Ethereum RPC API 客户端
* 使用固定的 baseUrl通过拦截器动态替换为实际的 RPC URL
* 如果 RPC 不可用将抛出异常
* RPC URL 缓存避免重复创建
* @param rpcUrl RPC 节点 URL
* @return EthereumRpcApi 客户端
* @throws IllegalArgumentException 如果 RPC URL 无效或不可用
*/
fun createEthereumRpcApi(rpcUrl: String): EthereumRpcApi {
// 使用固定的 baseUrlRetrofit 要求 baseUrl 必须以 / 结尾)
val fixedBaseUrl = "https://polyrpc.polyhermes/"
// 确保实际的 RPC URL 以 / 结尾
val actualRpcUrl = if (rpcUrl.endsWith("/")) {
rpcUrl
@@ -119,27 +193,28 @@ class RetrofitFactory(
"$rpcUrl/"
}
// 验证 RPC 是否可用
validateRpcAvailability(actualRpcUrl)
// 创建 URL 替换拦截器
val urlReplaceInterceptor = RpcUrlReplaceInterceptor(fixedBaseUrl, actualRpcUrl)
val okHttpClient = createClient()
.addInterceptor(urlReplaceInterceptor)
.build()
// 创建 lenient 模式的 Gson
val gson = GsonBuilder()
.setLenient()
.create()
return Retrofit.Builder()
.baseUrl(fixedBaseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(EthereumRpcApi::class.java)
// 使用 RPC URL 作为缓存键
return rpcApiCache.computeIfAbsent(actualRpcUrl) {
// 验证 RPC 是否可用(仅在新创建时验证)
validateRpcAvailability(actualRpcUrl)
// 使用固定的 baseUrlRetrofit 要求 baseUrl 必须以 / 结尾)
val fixedBaseUrl = "https://polyrpc.polyhermes/"
// 创建 URL 替换拦截器
val urlReplaceInterceptor = RpcUrlReplaceInterceptor(fixedBaseUrl, actualRpcUrl)
val okHttpClient = createClient()
.addInterceptor(urlReplaceInterceptor)
.build()
Retrofit.Builder()
.baseUrl(fixedBaseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(EthereumRpcApi::class.java)
}
}
/**
@@ -149,8 +224,6 @@ class RetrofitFactory(
* @throws IllegalArgumentException 如果 RPC 不可用
*/
private fun validateRpcAvailability(rpcUrl: String) {
val logger = LoggerFactory.getLogger(RetrofitFactory::class.java)
try {
// 解析 URL
val httpUrl = rpcUrl.toHttpUrlOrNull()
@@ -218,56 +291,24 @@ class RetrofitFactory(
/**
* 创建 Polymarket Gamma API 客户端
* Gamma API 是公开 API不需要认证
* @return PolymarketGammaApi 客户端
* @return PolymarketGammaApi 客户端单例
*/
fun createGammaApi(): PolymarketGammaApi {
val baseUrl = if (gammaBaseUrl.endsWith("/")) {
gammaBaseUrl.dropLast(1)
} else {
gammaBaseUrl
}
val okHttpClient = createClient().build()
// 创建 lenient 模式的 Gson
val gson = GsonBuilder()
.setLenient()
.create()
return Retrofit.Builder()
.baseUrl("$baseUrl/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(PolymarketGammaApi::class.java)
return gammaApi
}
/**
* 创建 Polymarket Data API 客户端
* Data API 是公开 API不需要认证
* @return PolymarketDataApi 客户端
* @return PolymarketDataApi 客户端单例
*/
fun createDataApi(): PolymarketDataApi {
val baseUrl = "https://data-api.polymarket.com"
val okHttpClient = createClient()
.followRedirects(true)
.followSslRedirects(true)
.build()
// 创建 lenient 模式的 Gson
val gson = GsonBuilder()
.setLenient()
.create()
return Retrofit.Builder()
.baseUrl("$baseUrl/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(PolymarketDataApi::class.java)
return dataApi
}
/**
* 创建 Builder Relayer API 客户端
* relayerUrl 缓存避免重复创建
* @param relayerUrl Builder Relayer URL
* @param apiKey Builder API Key
* @param secret Builder Secret
@@ -286,57 +327,61 @@ class RetrofitFactory(
relayerUrl
}
// 添加 Builder 认证拦截器
val builderAuthInterceptor = BuilderAuthInterceptor(apiKey, secret, passphrase)
val okHttpClient = createClient()
.addInterceptor(builderAuthInterceptor)
.build()
val gson = GsonBuilder()
.setLenient()
.create()
return Retrofit.Builder()
.baseUrl("$baseUrl/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(BuilderRelayerApi::class.java)
// 使用 baseUrl 作为缓存键(注意:如果 API Key 变化,需要清理缓存)
return builderRelayerApiCache.computeIfAbsent(baseUrl) {
// 添加 Builder 认证拦截器
val builderAuthInterceptor = BuilderAuthInterceptor(apiKey, secret, passphrase)
val okHttpClient = createClient()
.addInterceptor(builderAuthInterceptor)
.build()
Retrofit.Builder()
.baseUrl("$baseUrl/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(BuilderRelayerApi::class.java)
}
}
/**
* 创建 GitHub API 客户端
* GitHub API 是公开 API不需要认证但建议使用 token 提高速率限制
* 添加 Accept 头以获取 reactions 数据
* @return GitHubApi 客户端
* @return GitHubApi 客户端单例
*/
fun createGitHubApi(): GitHubApi {
val baseUrl = "https://api.github.com"
// 添加拦截器,设置 Accept 头以获取 reactions 数据
val githubInterceptor = object : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().newBuilder()
.header("Accept", "application/vnd.github+json")
.build()
return chain.proceed(request)
}
}
val okHttpClient = createClient()
.addInterceptor(githubInterceptor)
.build()
val gson = GsonBuilder()
.setLenient()
.create()
return Retrofit.Builder()
.baseUrl("$baseUrl/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(GitHubApi::class.java)
return githubApi
}
/**
* 清理缓存用于测试或配置变更时
*/
@PreDestroy
fun destroy() {
logger.info("清理 RetrofitFactory 缓存")
clobApiCache.clear()
rpcApiCache.clear()
builderRelayerApiCache.clear()
}
/**
* 清理指定钱包地址的 CLOB API 缓存
* 用于 API Key 变更时
*/
fun clearClobApiCache(walletAddress: String) {
clobApiCache.remove(walletAddress)
logger.debug("已清理 CLOB API 缓存: $walletAddress")
}
/**
* 清理指定 RPC URL RPC API 缓存
* 用于 RPC 节点变更时
*/
fun clearRpcApiCache(rpcUrl: String) {
val actualRpcUrl = if (rpcUrl.endsWith("/")) rpcUrl else "$rpcUrl/"
rpcApiCache.remove(actualRpcUrl)
logger.debug("已清理 RPC API 缓存: $actualRpcUrl")
}
}
@@ -371,6 +416,44 @@ class RpcUrlReplaceInterceptor(
return chain.proceed(newRequest)
}
/**
* 创建 NBA Stats API 客户端
* NBA Stats API 是公开 API但需要设置正确的请求头
* @return NbaStatsApi 客户端
*/
fun createNbaStatsApi(): NbaStatsApi {
val baseUrl = "https://stats.nba.com/stats/"
// 添加拦截器,设置 NBA Stats API 需要的请求头
val nbaStatsInterceptor = object : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().newBuilder()
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
.header("Referer", "https://www.nba.com/")
.header("Accept", "application/json")
.header("Accept-Language", "en-US,en;q=0.9")
.header("Origin", "https://www.nba.com")
.build()
return chain.proceed(request)
}
}
val okHttpClient = createClient()
.addInterceptor(nbaStatsInterceptor)
.build()
val gson = GsonBuilder()
.setLenient()
.create()
return Retrofit.Builder()
.baseUrl(baseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(NbaStatsApi::class.java)
}
}
/**
@@ -393,22 +476,35 @@ class ResponseLoggingInterceptor : Interceptor {
val responseBody = response.peekBody(2048)
val responseBodyString = responseBody.string()
// 检查是否是有效的 JSON
val isJson = responseBodyString.trim().startsWith("{") ||
responseBodyString.trim().startsWith("[")
// 检查响应体是否为空
val isEmpty = responseBodyString.isBlank()
if (!isJson || !response.isSuccessful) {
// 检查是否是有效的 JSON
val trimmedBody = responseBodyString.trim()
val isJson = !isEmpty && (
trimmedBody.startsWith("{") ||
trimmedBody.startsWith("[")
)
// 如果响应体为空或不是 JSON,记录警告
if (isEmpty || !isJson) {
val bodyPreview = if (isEmpty) {
"(空响应体)"
} else {
trimmedBody.take(500)
}
logger.warn(
"API 响应异常: method=${request.method}, url=${request.url}, " +
"code=${response.code}, isJson=$isJson, " +
"responseBody=${responseBodyString.take(500)}"
"code=${response.code}, isJson=$isJson, isEmpty=$isEmpty, " +
"responseBody=$bodyPreview"
)
}
} catch (e: Exception) {
// 如果读取响应体失败,记录异常但不影响响应
logger.debug("读取响应体失败: ${e.message}")
}
}
return response
}
}
@@ -40,23 +40,12 @@ polymarket.rtds.ws-url=wss://ws-subscriptions-clob.polymarket.com
polymarket.data-api.base-url=https://data-api.polymarket.com
polymarket.gamma.base-url=https://gamma-api.polymarket.com
# Polygon RPC 配置(用于查询链上余额)
# 可选:如果未配置,将无acc法查询 USDC 余额,但仍可通过 Subgraph API 查询持仓
# 示例:https://polygon-rpc.com 或 https://polygon-mainnet.infura.io/v3/YOUR_PROJECT_ID
polygon.rpc.url=${POLYGON_RPC_URL:https://polygon-rpc.com}
# Builder Relayer 配置(用于 Gasless 交易)
# 从 polymarket.com/settings?tab=builder 获取 Builder API 凭证
# Builder API Key、Secret、Passphrase 现在通过系统设置页面配置,存储在数据库中
# 如果未配置,将使用手动发送交易的方式(需要用户支付 gas)
polymarket.builder.relayer-url=${POLYMARKET_BUILDER_RELAYER_URL:https://relayer-v2.polymarket.com/}
# 仓位推送配置
# 轮询间隔(毫秒),默认3秒
position.push.polling-interval=${POSITION_PUSH_POLLING_INTERVAL:3000}
# 心跳超时时间(毫秒),默认60秒,超过此时间未收到心跳则清理连接
position.push.heartbeat-timeout=${POSITION_PUSH_HEARTBEAT_TIMEOUT:60000}
# 跟单轮询配置
# 轮询间隔(毫秒),默认2秒
copy.trading.polling.interval=${COPY_TRADING_POLLING_INTERVAL:2000}
@@ -0,0 +1,184 @@
-- NBA 量化交易系统数据库表
-- 1. NBA 市场表(Polymarket 市场信息)
CREATE TABLE IF NOT EXISTS nba_markets (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
polymarket_market_id VARCHAR(100) UNIQUE NOT NULL COMMENT 'Polymarket 市场 ID',
condition_id VARCHAR(100) UNIQUE NOT NULL COMMENT 'Condition ID',
market_slug VARCHAR(255) COMMENT '市场 slug',
market_question TEXT COMMENT '市场名称/问题',
market_description TEXT COMMENT '市场描述',
category VARCHAR(50) DEFAULT 'sports' COMMENT '分类',
active BOOLEAN DEFAULT true COMMENT '是否活跃',
closed BOOLEAN DEFAULT false COMMENT '是否已关闭',
archived BOOLEAN DEFAULT false COMMENT '是否已归档',
volume VARCHAR(50) COMMENT '交易量',
liquidity VARCHAR(50) COMMENT '流动性',
outcomes TEXT COMMENT '结果选项(JSON',
end_date VARCHAR(50) COMMENT '结束日期',
start_date VARCHAR(50) COMMENT '开始日期',
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
INDEX idx_condition_id (condition_id),
INDEX idx_active (active),
INDEX idx_closed (closed),
INDEX idx_category (category)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='NBA市场表(Polymarket市场)';
-- 2. NBA 比赛表(NBA 比赛信息)
CREATE TABLE IF NOT EXISTS nba_games (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
nba_game_id VARCHAR(100) UNIQUE COMMENT 'NBA 比赛 ID(来自 NBA API',
home_team VARCHAR(100) NOT NULL COMMENT '主队名称',
away_team VARCHAR(100) NOT NULL COMMENT '客队名称',
game_date DATE NOT NULL COMMENT '比赛日期',
game_time BIGINT COMMENT '比赛时间(时间戳,毫秒)',
game_status VARCHAR(50) DEFAULT 'scheduled' COMMENT '比赛状态:scheduled/active/finished',
home_score INT DEFAULT 0 COMMENT '主队得分',
away_score INT DEFAULT 0 COMMENT '客队得分',
period INT DEFAULT 0 COMMENT '当前节次',
time_remaining VARCHAR(50) COMMENT '剩余时间',
polymarket_market_id VARCHAR(100) COMMENT '关联的 Polymarket 市场 ID',
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
INDEX idx_game_date (game_date),
INDEX idx_game_status (game_status),
INDEX idx_home_team (home_team),
INDEX idx_away_team (away_team),
INDEX idx_polymarket_market_id (polymarket_market_id),
FOREIGN KEY (polymarket_market_id) REFERENCES nba_markets(polymarket_market_id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='NBA比赛表';
-- 3. 量化策略配置表
CREATE TABLE IF NOT EXISTS nba_quantitative_strategies (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
strategy_name VARCHAR(100) NOT NULL COMMENT '策略名称',
strategy_description TEXT COMMENT '策略描述',
account_id BIGINT NOT NULL COMMENT '关联账户 ID',
enabled BOOLEAN DEFAULT true COMMENT '是否启用',
-- 比赛筛选参数
filter_teams TEXT COMMENT '关注的球队列表(JSON',
filter_date_from DATE COMMENT '日期范围开始',
filter_date_to DATE COMMENT '日期范围结束',
filter_game_importance VARCHAR(50) COMMENT '比赛重要性:all/regular/playoff/key',
-- 触发条件参数
min_win_probability_diff DECIMAL(5, 4) DEFAULT 0.1000 COMMENT '最小获胜概率差异',
min_win_probability DECIMAL(5, 4) COMMENT '最小获胜概率',
max_win_probability DECIMAL(5, 4) COMMENT '最大获胜概率',
min_trade_value DECIMAL(5, 4) DEFAULT 0.0500 COMMENT '最小交易价值',
min_remaining_time INT COMMENT '最小剩余时间(分钟)',
max_remaining_time INT COMMENT '最大剩余时间(分钟)',
min_score_diff INT COMMENT '最小分差',
max_score_diff INT COMMENT '最大分差',
-- 买入规则参数
buy_amount_strategy VARCHAR(20) DEFAULT 'FIXED' COMMENT '买入金额策略:FIXED/RATIO/DYNAMIC',
fixed_buy_amount DECIMAL(20, 8) COMMENT '固定买入金额(USDC',
buy_ratio DECIMAL(5, 4) COMMENT '买入比例(0-1',
base_buy_amount DECIMAL(20, 8) COMMENT '基础买入金额(USDC',
buy_timing VARCHAR(20) DEFAULT 'IMMEDIATE' COMMENT '买入时机:IMMEDIATE/DELAYED',
delay_buy_seconds INT DEFAULT 0 COMMENT '延迟买入时间(秒)',
buy_direction VARCHAR(10) DEFAULT 'AUTO' COMMENT '买入方向:AUTO/YES/NO',
-- 卖出规则参数
enable_sell BOOLEAN DEFAULT true COMMENT '是否启用卖出',
take_profit_threshold DECIMAL(5, 4) COMMENT '止盈阈值(0-1',
stop_loss_threshold DECIMAL(5, 4) COMMENT '止损阈值(-1-0',
probability_reversal_threshold DECIMAL(5, 4) COMMENT '概率反转阈值(0-1',
sell_ratio DECIMAL(5, 4) DEFAULT 1.0000 COMMENT '卖出比例(0-1',
sell_timing VARCHAR(20) DEFAULT 'IMMEDIATE' COMMENT '卖出时机:IMMEDIATE/DELAYED',
delay_sell_seconds INT DEFAULT 0 COMMENT '延迟卖出时间(秒)',
-- 价格策略参数
price_strategy VARCHAR(20) DEFAULT 'MARKET' COMMENT '价格策略:FIXED/MARKET/DYNAMIC',
fixed_price DECIMAL(5, 4) COMMENT '固定价格(0-1',
price_offset DECIMAL(5, 4) DEFAULT 0.0000 COMMENT '价格偏移(-0.1-0.1',
-- 风险控制参数
max_position DECIMAL(20, 8) DEFAULT 50.00000000 COMMENT '最大持仓(USDC',
min_position DECIMAL(20, 8) DEFAULT 5.00000000 COMMENT '最小持仓(USDC',
max_game_position DECIMAL(20, 8) COMMENT '单场比赛最大持仓(USDC',
max_daily_loss DECIMAL(20, 8) COMMENT '每日亏损限制(USDC',
max_daily_orders INT COMMENT '每日订单限制',
max_daily_profit DECIMAL(20, 8) COMMENT '每日盈利目标(USDC',
price_tolerance DECIMAL(5, 4) DEFAULT 0.0500 COMMENT '价格容忍度(0-1',
min_probability_threshold DECIMAL(5, 4) COMMENT '最小概率阈值(0.5-1.0',
max_probability_threshold DECIMAL(5, 4) COMMENT '最大概率阈值(0.0-0.5',
-- 算法权重参数(高级)
base_strength_weight DECIMAL(5, 4) DEFAULT 0.3000 COMMENT '基础实力权重',
recent_form_weight DECIMAL(5, 4) DEFAULT 0.2500 COMMENT '近期状态权重',
lineup_integrity_weight DECIMAL(5, 4) DEFAULT 0.2000 COMMENT '阵容完整度权重',
star_status_weight DECIMAL(5, 4) DEFAULT 0.1500 COMMENT '球星状态权重',
environment_weight DECIMAL(5, 4) DEFAULT 0.1000 COMMENT '环境因素权重',
matchup_advantage_weight DECIMAL(5, 4) DEFAULT 0.2000 COMMENT '对位优势权重',
score_diff_weight DECIMAL(5, 4) DEFAULT 0.3000 COMMENT '分差调整权重',
momentum_weight DECIMAL(5, 4) DEFAULT 0.2000 COMMENT '势头调整权重',
-- 系统配置参数
data_update_frequency INT DEFAULT 30 COMMENT '数据更新频率(秒)',
analysis_frequency INT DEFAULT 30 COMMENT '分析频率(秒)',
push_failed_orders BOOLEAN DEFAULT false COMMENT '是否推送失败订单',
push_frequency VARCHAR(20) DEFAULT 'REALTIME' COMMENT '推送频率:REALTIME/BATCH',
batch_push_interval INT DEFAULT 1 COMMENT '批量推送间隔(秒)',
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
INDEX idx_account_id (account_id),
INDEX idx_enabled (enabled),
INDEX idx_strategy_name (strategy_name),
FOREIGN KEY (account_id) REFERENCES wallet_accounts(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='NBA量化策略配置表';
-- 4. 交易信号表
CREATE TABLE IF NOT EXISTS nba_trading_signals (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
strategy_id BIGINT NOT NULL COMMENT '策略 ID',
game_id BIGINT COMMENT '比赛 ID',
market_id BIGINT COMMENT '市场 ID',
signal_type VARCHAR(10) NOT NULL COMMENT '信号类型:BUY/SELL',
direction VARCHAR(10) NOT NULL COMMENT '方向:YES/NO',
price DECIMAL(5, 4) NOT NULL COMMENT '价格(0-1',
quantity DECIMAL(20, 8) NOT NULL COMMENT '数量',
total_amount DECIMAL(20, 8) NOT NULL COMMENT '总金额(USDC',
reason TEXT COMMENT '触发原因',
win_probability DECIMAL(5, 4) COMMENT '获胜概率',
trade_value DECIMAL(5, 4) COMMENT '交易价值',
signal_status VARCHAR(20) DEFAULT 'GENERATED' COMMENT '信号状态:GENERATED/EXECUTING/SUCCESS/FAILED',
execution_result TEXT COMMENT '执行结果',
error_message TEXT COMMENT '错误信息',
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
INDEX idx_strategy_id (strategy_id),
INDEX idx_game_id (game_id),
INDEX idx_market_id (market_id),
INDEX idx_signal_type (signal_type),
INDEX idx_signal_status (signal_status),
INDEX idx_created_at (created_at),
FOREIGN KEY (strategy_id) REFERENCES nba_quantitative_strategies(id) ON DELETE CASCADE,
FOREIGN KEY (game_id) REFERENCES nba_games(id) ON DELETE SET NULL,
FOREIGN KEY (market_id) REFERENCES nba_markets(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='NBA交易信号表';
-- 5. 策略执行统计表
CREATE TABLE IF NOT EXISTS nba_strategy_statistics (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
strategy_id BIGINT NOT NULL COMMENT '策略 ID',
stat_date DATE NOT NULL COMMENT '统计日期',
total_signals INT DEFAULT 0 COMMENT '总信号数',
buy_signals INT DEFAULT 0 COMMENT '买入信号数',
sell_signals INT DEFAULT 0 COMMENT '卖出信号数',
success_signals INT DEFAULT 0 COMMENT '成功信号数',
failed_signals INT DEFAULT 0 COMMENT '失败信号数',
total_profit DECIMAL(20, 8) DEFAULT 0.00000000 COMMENT '总盈亏(USDC',
total_volume DECIMAL(20, 8) DEFAULT 0.00000000 COMMENT '总交易量(USDC',
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
UNIQUE KEY uk_strategy_date (strategy_id, stat_date),
INDEX idx_strategy_id (strategy_id),
INDEX idx_stat_date (stat_date),
FOREIGN KEY (strategy_id) REFERENCES nba_quantitative_strategies(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='NBA策略执行统计表';
@@ -0,0 +1,37 @@
-- 移除 NBA 市场相关的外键约束
-- 由于不再在数据库中存储市场信息,需要移除这些外键约束
-- 1. 移除 nba_games 表的外键约束
SET @fk_name = (SELECT CONSTRAINT_NAME
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'nba_games'
AND COLUMN_NAME = 'polymarket_market_id'
AND REFERENCED_TABLE_NAME = 'nba_markets'
LIMIT 1);
SET @sql = IF(@fk_name IS NOT NULL,
CONCAT('ALTER TABLE nba_games DROP FOREIGN KEY ', @fk_name),
'SELECT "Foreign key constraint not found"');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 2. 移除 nba_trading_signals 表的外键约束(如果存在)
SET @fk_name = (SELECT CONSTRAINT_NAME
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'nba_trading_signals'
AND COLUMN_NAME = 'market_id'
AND REFERENCED_TABLE_NAME = 'nba_markets'
LIMIT 1);
SET @sql = IF(@fk_name IS NOT NULL,
CONCAT('ALTER TABLE nba_trading_signals DROP FOREIGN KEY ', @fk_name),
'SELECT "Foreign key constraint not found"');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
@@ -0,0 +1,8 @@
-- ============================================
-- V16: 删除失败交易记录表
-- 移除下单失败存储到数据库的功能
-- ============================================
-- 删除失败交易记录表
DROP TABLE IF EXISTS failed_trade;
@@ -0,0 +1,35 @@
-- ============================================
-- 添加 wallet_type 字段到 wallet_accounts 表
-- 用于区分 Magic 和 Safe 两种钱包类型
-- ============================================
-- 使用存储过程检查并添加字段(如果不存在)
DELIMITER $$
CREATE PROCEDURE IF NOT EXISTS add_wallet_type_if_not_exists()
BEGIN
DECLARE column_exists INT DEFAULT 0;
SELECT COUNT(*) INTO column_exists
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'wallet_accounts'
AND COLUMN_NAME = 'wallet_type';
IF column_exists = 0 THEN
ALTER TABLE wallet_accounts
ADD COLUMN wallet_type VARCHAR(20) NOT NULL DEFAULT 'magic' COMMENT '钱包类型:magic(邮箱/OAuth登录)或 safeMetaMask浏览器钱包)' AFTER is_enabled;
END IF;
END$$
DELIMITER ;
-- 执行存储过程
CALL add_wallet_type_if_not_exists();
-- 删除存储过程
DROP PROCEDURE IF EXISTS add_wallet_type_if_not_exists;
-- 为现有账户设置默认walletType为magic(如果值为NULL
UPDATE wallet_accounts SET wallet_type = 'magic' WHERE wallet_type IS NULL OR wallet_type = '';
@@ -0,0 +1,59 @@
package com.wrbug.polymarketbot.service.nba
import com.wrbug.polymarketbot.util.RetrofitFactory
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
/**
* NBA 比赛服务测试
* 用于验证 API 调用是否正确
*/
@SpringBootTest
class NbaGameServiceTest {
@Autowired
private lateinit var retrofitFactory: RetrofitFactory
@Test
fun testNbaStatsApi() {
runBlocking {
try {
val nbaStatsApi = retrofitFactory.createNbaStatsApi()
// 测试获取今天的比赛
val today = java.time.LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd"))
println("测试日期: $today")
val response = nbaStatsApi.getScoreboard(gameDate = today)
println("响应状态码: ${response.code()}")
println("响应消息: ${response.message()}")
if (response.isSuccessful && response.body() != null) {
val scoreboard = response.body()!!
println("ResultSets 数量: ${scoreboard.resultSets.size}")
scoreboard.resultSets.forEachIndexed { index, resultSet ->
println("ResultSet[$index]: name=${resultSet.name}, headers=${resultSet.headers.size}, rows=${resultSet.rowSet.size}")
if (resultSet.headers.isNotEmpty()) {
println(" Headers: ${resultSet.headers.take(5)}...")
}
if (resultSet.rowSet.isNotEmpty()) {
println(" First row size: ${resultSet.rowSet.first().size}")
println(" First row: ${resultSet.rowSet.first().take(5)}...")
}
}
} else {
println("API 调用失败")
println("错误响应体: ${response.errorBody()?.string()}")
}
} catch (e: Exception) {
println("测试异常: ${e.message}")
e.printStackTrace()
}
}
}
}
+15 -5
View File
@@ -71,9 +71,6 @@ SERVER_PORT=80
# MySQL 端口(可选,用于外部连接,默认 3307 避免与本地 MySQL 冲突)
MYSQL_PORT=3307
# Polygon RPC
POLYGON_RPC_URL=https://polygon-rpc.com
# JWT 密钥(已自动生成随机值,生产环境建议修改)
JWT_SECRET=${JWT_SECRET}
@@ -159,7 +156,18 @@ deploy() {
# 注意:这里需要手动修改 docker-compose.yml,或者使用环境变量
warn "请确保 docker-compose.yml 中已配置使用 image: wrbug/polyhermes:latest"
else
info "构建 Docker 镜像(本地构建,版本号将显示为 dev)..."
# 获取当前分支名作为版本号
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "dev")
# 如果分支名包含 /,替换为 -(Docker tag 不支持 /
DOCKER_VERSION=$(echo "$CURRENT_BRANCH" | tr '/' '-')
info "构建 Docker 镜像(本地构建,版本号: ${DOCKER_VERSION}..."
# 设置构建参数(通过环境变量传递给 docker-compose.yml
export VERSION=${DOCKER_VERSION}
export GIT_TAG=${DOCKER_VERSION}
export GITHUB_REPO_URL=https://github.com/WrBug/PolyHermes
docker-compose build
fi
@@ -199,7 +207,9 @@ main() {
info "访问地址: http://localhost:${SERVER_PORT:-80}"
echo ""
if [ "$USE_DOCKER_HUB" != "true" ]; then
info "提示:本地构建的版本号显示为 'dev'"
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "dev")
DOCKER_VERSION=$(echo "$CURRENT_BRANCH" | tr '/' '-')
info "提示:本地构建的版本号为当前分支名: ${DOCKER_VERSION}"
info "生产环境推荐使用 Docker Hub 镜像:"
info " ./deploy.sh --use-docker-hub"
info " 或修改 docker-compose.yml 使用 image: wrbug/polyhermes:latest"
-6
View File
@@ -38,12 +38,6 @@ SERVER_PORT=80
# Optional, for external connections, default 3307 to avoid conflicts with local MySQL
MYSQL_PORT=3307
# ============================================
# Polygon RPC
# ============================================
# Polygon 网络 RPC 地址 / Polygon network RPC address
POLYGON_RPC_URL=https://polygon-rpc.com
# ============================================
# 安全配置 / Security Configuration
# ============================================
-1
View File
@@ -24,7 +24,6 @@ services:
- DB_USERNAME=${DB_USERNAME:-root}
- DB_PASSWORD=${DB_PASSWORD:-}
- SERVER_PORT=8000
- POLYGON_RPC_URL=${POLYGON_RPC_URL:-https://polygon-rpc.com}
# ⚠️ 安全警告:以下两个环境变量不能使用默认值,否则容器启动会失败
# 请在 .env 文件中设置,或通过环境变量传入
# 生成随机密钥:openssl rand -hex 32 (ADMIN_RESET_PASSWORD_KEY) 或 openssl rand -hex 64 (JWT_SECRET)
+5 -6
View File
@@ -8,11 +8,11 @@ services:
build:
context: .
dockerfile: Dockerfile
# 本地构建时可以传递版本号参数(可选
# args:
# VERSION: ${VERSION:-dev}
# GIT_TAG: ${GIT_TAG:-}
# GITHUB_REPO_URL: https://github.com/WrBug/PolyHermes
# 本地构建时可以传递版本号参数(自动使用当前分支名
args:
VERSION: ${VERSION:-dev}
GIT_TAG: ${GIT_TAG:-${VERSION:-dev}}
GITHUB_REPO_URL: ${GITHUB_REPO_URL:-https://github.com/WrBug/PolyHermes}
container_name: polyhermes
ports:
- "${SERVER_PORT:-80}:80"
@@ -22,7 +22,6 @@ services:
- DB_USERNAME=${DB_USERNAME:-root}
- DB_PASSWORD=${DB_PASSWORD:-}
- SERVER_PORT=8000
- POLYGON_RPC_URL=${POLYGON_RPC_URL:-https://polygon-rpc.com}
# ⚠️ 安全警告:以下两个环境变量不能使用默认值,否则容器启动会失败
# 请在 .env 文件中设置,或通过环境变量传入
# 生成随机密钥:openssl rand -hex 32 (ADMIN_RESET_PASSWORD_KEY) 或 openssl rand -hex 64 (JWT_SECRET)
-3
View File
@@ -163,7 +163,6 @@ DB_USERNAME=root
DB_PASSWORD=your_password_here
SPRING_PROFILES_ACTIVE=prod
SERVER_PORT=80
POLYGON_RPC_URL=https://polygon-rpc.com
JWT_SECRET=your-jwt-secret-key-change-in-production
ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production
EOF
@@ -393,7 +392,6 @@ DB_USERNAME=root
DB_PASSWORD=your_password_here
SPRING_PROFILES_ACTIVE=prod
SERVER_PORT=8000
POLYGON_RPC_URL=https://polygon-rpc.com
JWT_SECRET=your-jwt-secret-key-change-in-production
ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production
EOF
@@ -554,7 +552,6 @@ serve -s dist -l 3000
| `DB_USERNAME` | Database username | `root` | Yes (Production) |
| `DB_PASSWORD` | Database password | - | Yes (Production) |
| `SERVER_PORT` | Server port | `8000` | No |
| `POLYGON_RPC_URL` | Polygon RPC address | `https://polygon-rpc.com` | No |
| `JWT_SECRET` | JWT secret key | - | Yes (Production) |
| `ADMIN_RESET_PASSWORD_KEY` | Admin password reset key | - | Yes (Production) |
-3
View File
@@ -100,9 +100,6 @@ spring.datasource.password=${DB_PASSWORD:password}
# Server port
server.port=${SERVER_PORT:8000}
# Polygon RPC
polygon.rpc.url=${POLYGON_RPC_URL:https://polygon-rpc.com}
# JWT secret
jwt.secret=${JWT_SECRET:change-me-in-production}
-3
View File
@@ -165,7 +165,6 @@ DB_USERNAME=root
DB_PASSWORD=your_password_here
SPRING_PROFILES_ACTIVE=prod
SERVER_PORT=80
POLYGON_RPC_URL=https://polygon-rpc.com
JWT_SECRET=your-jwt-secret-key-change-in-production
ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production
EOF
@@ -397,7 +396,6 @@ DB_USERNAME=root
DB_PASSWORD=your_password_here
SPRING_PROFILES_ACTIVE=prod
SERVER_PORT=8000
POLYGON_RPC_URL=https://polygon-rpc.com
JWT_SECRET=your-jwt-secret-key-change-in-production
ADMIN_RESET_PASSWORD_KEY=your-admin-reset-key-change-in-production
EOF
@@ -558,7 +556,6 @@ serve -s dist -l 3000
| `DB_USERNAME` | 数据库用户名 | `root` | 是(生产) |
| `DB_PASSWORD` | 数据库密码 | - | 是(生产) |
| `SERVER_PORT` | 服务器端口 | `8000` | 否 |
| `POLYGON_RPC_URL` | Polygon RPC 地址 | `https://polygon-rpc.com` | 否 |
| `JWT_SECRET` | JWT 密钥 | - | 是(生产) |
| `ADMIN_RESET_PASSWORD_KEY` | 管理员密码重置密钥 | - | 是(生产) |
-3
View File
@@ -93,9 +93,6 @@ spring.datasource.password=${DB_PASSWORD:password}
# 服务器端口
server.port=${SERVER_PORT:8000}
# Polygon RPC
polygon.rpc.url=${POLYGON_RPC_URL:https://polygon-rpc.com}
# JWT 密钥
jwt.secret=${JWT_SECRET:change-me-in-production}
@@ -0,0 +1,999 @@
# NBA 量化交易系统技术方案
## 一、概述
### 1.1 产品定位
本系统是一个基于 NBA 比赛数据的量化交易系统,用户在前端配置量化策略参数,系统后台实时获取 NBA 比赛数据,根据配置的策略参数进行量化分析,自动生成买入/卖出信号,并通过 WebSocket 实时推送给前端展示。
### 1.2 产品流程
```
用户在前端配置策略参数
后台保存配置并启动量化任务
后台实时获取 NBA 比赛数据
后台执行量化分析逻辑
生成买入/卖出信号
通过 WebSocket 推送给前端
前端展示交易信号和结果
```
### 1.3 核心功能
1. **策略配置管理**:用户在前端配置量化策略参数(如触发条件、买入卖出规则、风险控制参数等)
2. **数据获取**:后台实时获取 NBA 比赛数据、球队统计、球员统计等
3. **量化分析**:根据配置的策略参数和实时数据,执行量化分析逻辑
4. **信号生成**:生成买入/卖出信号,包含市场、方向、价格、数量等信息
5. **实时推送**:通过 WebSocket 将交易信号实时推送给前端
6. **结果展示**:前端展示交易信号、执行结果、统计数据等
### 1.4 技术栈
- **后端框架**: Spring Boot 3.2.0 + Kotlin
- **HTTP 客户端**: Retrofit 2.9.0 + OkHttp 4.12.0
- **数据存储**: MySQL 8.2.0(结构化数据)+ Redis(缓存)
- **爬虫框架**: Jsoup(用于 Basketball Reference
- **任务调度**: Spring Scheduler(定时任务)
- **WebSocket**: Spring WebSocket(实时推送)
---
## 二、系统架构设计
### 2.1 整体架构
```
┌─────────────────────────────────────────────────────────────┐
│ 前端层 (Frontend Layer) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 策略配置页面 │ │ 交易信号展示 │ │ 结果统计页面 │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
│ HTTP API / WebSocket
┌─────────────────────────────────────────────────────────────┐
│ 应用层 (Application Layer) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 策略配置API │ │ 量化任务API │ │ WebSocket │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 服务层 (Service Layer) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 策略配置服务 │ │ 量化分析服务 │ │ 数据推送服务 │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ NBA数据服务 │ │ 信号生成服务 │ │ 任务调度服务 │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 数据层 (Data Layer) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ NBA API │ │ 数据存储 │ │ 缓存服务 │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
### 2.2 模块划分
#### 2.2.1 前端模块
- **策略配置模块**:用户配置量化策略参数
- **交易信号展示模块**:实时展示买入/卖出信号
- **结果统计模块**:展示交易结果和统计数据
#### 2.2.2 后端模块
- **策略配置管理模块**:管理用户配置的策略参数
- **NBA 数据获取模块**:从 NBA Stats API 和 Basketball Reference 获取数据
- **量化分析模块**:执行量化分析逻辑,生成交易信号
- **信号推送模块**:通过 WebSocket 推送交易信号
- **任务调度模块**:管理量化任务的启动、停止、调度
---
## 三、产品功能设计
### 3.1 策略配置功能
#### 3.1.1 配置参数
用户在前端可以配置以下参数:
**基础配置**
- 策略名称:用户自定义策略名称
- 启用状态:是否启用该策略
- 关联账户:选择用于交易的账户
**触发条件配置**
- 比赛筛选:选择关注的比赛(可按球队、日期、重要性等筛选)
- 数据指标:选择用于分析的指标(如分差、剩余时间、球队实力等)
- 触发阈值:设置触发买入/卖出的阈值条件
**交易规则配置**
- 买入规则:配置买入条件、买入金额、买入时机等
- 卖出规则:配置卖出条件、卖出金额、卖出时机等
- 价格策略:配置价格计算方式(如固定价格、动态价格等)
**风险控制配置**
- 最大持仓:单次最大买入金额
- 最小持仓:单次最小买入金额
- 每日亏损限制:每日最大亏损金额
- 每日订单限制:每日最大订单数量
- 价格容忍度:允许的价格偏差范围
**高级配置**
- 数据更新频率:NBA 数据更新频率(如 30 秒、1 分钟等)
- 分析频率:量化分析执行频率
- 推送设置:是否推送失败订单、推送频率等
#### 3.1.2 配置管理
- **创建策略**:用户在前端创建新的量化策略配置
- **编辑策略**:用户可以修改已有策略的配置参数
- **删除策略**:用户可以删除不需要的策略
- **启用/禁用策略**:用户可以启用或禁用策略,禁用后停止执行量化分析
- **策略列表**:展示所有策略配置,支持搜索、筛选、排序
### 3.2 量化分析功能
#### 3.2.1 数据获取
系统后台实时获取以下数据:
**比赛数据**
- 实时比分和分差
- 比赛状态(未开始/进行中/已结束)
- 当前节次和剩余时间
- Play-by-Play 数据(每个回合的详细记录)
**统计数据**
- 球队统计数据(进攻效率、防守效率、净效率值等)
- 球员统计数据(得分、篮板、助攻、真实命中率等)
- 高级统计数据(PER、BPM、VORP、DRPM 等)
**历史数据**
- 历史比赛数据
- 历史对战记录
- 历史统计数据
#### 3.2.2 量化分析逻辑
系统根据用户配置的策略参数和实时获取的 NBA 数据,执行量化分析:
**核心思想**
- 不是简单的条件判断(如"主队落后买入")
- 而是综合分析两支队伍的实力、状态、对位关系等因素
- 计算每支队伍的综合评分和获胜概率
- 评估交易价值和风险
- 选择最优的交易方向和时机
**数据预处理**
- 数据清洗和验证
- 数据标准化和归一化
- 特征工程(提取关键特征)
**综合评分计算**
- 计算主队和客队的基础实力评分(净效率值、攻防效率、节奏等)
- 计算近期状态评分(近期胜率、净效率值变化、势头等)
- 计算阵容完整度评分(基于缺失球员的VORP)
- 计算球星状态评分(PER、TS%、健康状况等)
- 计算环境因素评分(主客场、休息天数、背靠背等)
- 综合计算主队和客队的综合评分
**对位分析**
- 分析球星对位优势(防守限制效果、历史对位数据)
- 分析阵容克制关系(内线、外线、快攻优势)
- 计算对位综合评分
**实时状态分析**(比赛进行中):
- 分析当前分差和剩余时间
- 分析势头动量(过去N回合的净胜分)
- 实时调整综合评分
**获胜概率计算**
- 基于综合评分计算基础获胜概率
- 根据对位优势调整概率
- 根据实时状态调整概率(比赛进行中)
- 最终得到主队和客队的获胜概率
**交易价值计算**
- 计算预期收益(基于获胜概率和价格)
- 计算风险调整收益(考虑不确定性、价格波动、流动性)
- 计算交易价值评分
**策略执行**
- 根据配置的触发条件(最小获胜概率差异、最小交易价值等),判断是否满足买入/卖出条件
- 根据配置的交易规则,计算买入/卖出价格和数量
- 根据配置的风险控制参数,验证交易是否合规
**信号生成**
- 生成买入信号:包含市场 ID、方向(YES/NO,由系统自动判断)、价格、数量、原因等
- 生成卖出信号:包含市场 ID、方向、价格、数量、原因等
- 信号验证:验证信号的有效性和合规性
### 3.3 信号推送功能
#### 3.3.1 推送方式
系统通过 WebSocket 实时推送交易信号给前端:
**推送内容**
- 买入信号:市场信息、方向、价格、数量、触发原因、时间戳等
- 卖出信号:市场信息、方向、价格、数量、触发原因、时间戳等
- 信号状态:信号生成、信号执行中、信号执行成功、信号执行失败等
**推送频率**
- 实时推送:信号生成后立即推送
- 批量推送:多个信号可以批量推送(减少网络开销)
#### 3.3.2 订阅管理
前端通过 WebSocket 订阅交易信号:
**订阅方式**
- 订阅所有策略的信号
- 订阅特定策略的信号
- 订阅特定市场的信号
**订阅管理**
- 前端可以动态订阅/取消订阅
- 支持多个前端客户端同时订阅
- 连接断开后自动重连和恢复订阅
### 3.4 结果展示功能
#### 3.4.1 实时信号展示
前端实时展示交易信号:
**信号列表**
- 展示所有生成的交易信号
- 支持按策略、市场、时间等筛选
- 支持按时间、价格等排序
**信号详情**
- 展示信号的详细信息(市场、方向、价格、数量、原因等)
- 展示信号的执行状态和结果
- 展示信号的历史记录
#### 3.4.2 统计展示
前端展示交易统计:
**策略统计**
- 每个策略的信号数量
- 每个策略的成功率
- 每个策略的盈亏情况
**总体统计**
- 总信号数量
- 总成功率
- 总盈亏情况
- 每日/每周/每月统计
---
## 四、数据源集成方案
### 4.1 NBA Stats API 集成
#### 4.1.1 API 基础信息
- **Base URL**: `https://stats.nba.com/stats/`
- **认证**: 无需认证,但需要设置正确的请求头
- **请求限制**: 建议 < 10 请求/秒
- **数据格式**: JSON
#### 4.1.2 请求头配置
需要设置以下请求头:
- User-Agent: 浏览器标识
- Referer: 来源页面
- Accept: 接受 JSON 格式
- Accept-Language: 语言设置
#### 4.1.3 主要接口
**比赛数据接口**
- 赛程和比分接口:获取指定日期的所有比赛
- 比赛统计接口:获取比赛的详细统计数据
- Play-by-Play 接口:获取比赛的回合数据
**统计数据接口**
- 球队统计面板接口:获取球队的统计数据
- 球队关键时刻数据接口:获取球队关键时刻的表现
- 球员统计面板接口:获取球员的统计数据
- 球员单场数据接口:获取球员的单场数据
#### 4.1.4 数据获取策略
**实时数据获取**
- 比赛进行时:每 30 秒轮询一次比赛数据
- 比赛未开始:每 5 分钟轮询一次(检查比赛状态)
- 比赛结束:每 10 分钟轮询一次(确保数据完整)
**统计数据获取**
- 每天更新一次球队和球员统计数据
- 比赛结束后立即更新相关统计数据
### 4.2 Basketball Reference 爬虫集成
#### 4.2.1 数据来源
- **Base URL**: `https://www.basketball-reference.com`
- **数据格式**: HTML 页面
- **优势**: 提供高级统计数据(PER、BPM、VORP、DRPM 等)
#### 4.2.2 爬取策略
**请求频率控制**
- 请求间隔:至少 1 秒(避免反爬虫)
- 请求队列:使用队列管理请求,控制并发数
**数据爬取**
- 球员高级统计数据:从球员页面爬取 PER、BPM、VORP、DRPM 等
- 球队高级统计数据:从球队页面爬取相关数据
- 阵容统计数据:从阵容页面爬取阵容组合数据
**数据缓存**
- 爬取的数据存储到数据库,避免重复请求
- 高级统计数据更新频率较低,可以缓存较长时间
### 4.3 数据存储方案
#### 4.3.1 数据库设计
**策略配置表**
- 存储用户配置的策略参数
- 包含策略名称、配置参数、启用状态等字段
**比赛数据表**
- 存储比赛基本信息(比赛 ID、日期、主客场、比分等)
- 存储比赛状态(未开始/进行中/已结束)
- 存储当前节次和剩余时间
**统计数据表**
- 球队统计表:存储球队的统计数据
- 球员统计表:存储球员的统计数据
- Play-by-Play 表:存储比赛的回合数据
**交易信号表**
- 存储生成的交易信号
- 包含市场信息、方向、价格、数量、状态等字段
- 包含信号的执行结果和统计信息
#### 4.3.2 缓存策略
**Redis 缓存**
- 比赛数据缓存:缓存 5 分钟(实时数据)
- 统计数据缓存:缓存 1 小时(统计数据更新较慢)
- 高级统计缓存:缓存 24 小时(Basketball Reference 更新较慢)
**缓存更新**
- 数据更新时同步更新缓存
- 缓存过期后自动从数据库或 API 重新获取
---
## 五、实时性保证策略
### 5.1 数据实时性要求
不同数据类型的实时性要求:
| 数据类型 | 实时性要求 | 更新频率 | 延迟容忍度 |
|---------|-----------|---------|-----------|
| **比赛比分** | 极高 | 30秒 | < 1分钟 |
| **Play-by-Play** | 极高 | 30秒 | < 1分钟 |
| **比赛状态** | 高 | 30秒 | < 2分钟 |
| **统计数据** | 中 | 5分钟 | < 10分钟 |
| **历史数据** | 低 | 每天 | 无要求 |
### 5.2 实时性保证方案
#### 5.2.1 多层级数据更新策略
**第一层:数据源层**
- HTTP 轮询:从 NBA Stats API 轮询获取数据
- 第三方 WebSocket API:如果可用,使用 WebSocket 实时接收数据
- 数据缓存:使用 Redis 缓存数据,减少 API 调用
**第二层:数据处理层**
- 数据变化检测:只处理变化的数据,减少不必要的处理
- 数据验证:验证数据的有效性和完整性
- 增量更新:只更新变化的数据,提高效率
**第三层:数据推送层**
- WebSocket 推送:通过 WebSocket 实时推送数据给前端
- 订阅管理:管理前端的订阅,只推送订阅的数据
- 批量推送:多个数据可以批量推送,减少网络开销
**第四层:前端展示层**
- 实时更新:前端实时接收和展示数据
- 降级轮询:WebSocket 失败时降级到 HTTP 轮询
- 数据缓存:前端缓存数据,减少重复请求
#### 5.2.2 数据变化检测机制
**数据快照比较**
- 保存上次的数据快照
- 比较新数据和快照,只处理变化的数据
- 更新快照,用于下次比较
**增量更新**
- 只保存和推送新增或变化的数据
- 减少数据库写入和网络传输
- 提高系统性能
#### 5.2.3 动态轮询频率调整
根据比赛状态动态调整轮询频率:
- **比赛未开始**5 分钟轮询一次
- **比赛进行中**30 秒轮询一次
- **最后 5 分钟**15 秒轮询一次(关键时刻)
- **加时赛**20 秒轮询一次
- **比赛结束**10 分钟轮询一次(确保数据完整)
#### 5.2.4 WebSocket 连接管理
**连接保活**
- 定期发送心跳消息,保持连接活跃
- 检测连接状态,及时处理断开情况
**自动重连**
- 连接断开后自动重连
- 使用指数退避策略,避免频繁重连
- 重连后恢复订阅,确保数据不丢失
**连接监控**
- 监控连接健康状态
- 记录连接统计信息
- 异常情况告警
#### 5.2.5 前端降级策略
**WebSocket 失败降级**
- WebSocket 连接失败时,自动降级到 HTTP 轮询
- 前端定期轮询获取数据,确保数据不中断
- WebSocket 恢复后,自动切换回 WebSocket
**数据缓存**
- 前端缓存最近的数据
- 网络中断时,使用缓存数据展示
- 网络恢复后,同步最新数据
### 5.3 实时性监控
#### 5.3.1 数据延迟监控
**延迟统计**
- 记录数据从获取到推送的延迟时间
- 统计平均延迟、最大延迟等指标
- 延迟超过阈值时触发告警
**性能指标**
- API 调用响应时间
- 数据处理时间
- WebSocket 推送延迟
#### 5.3.2 告警机制
**告警条件**
- 数据延迟超过阈值(如 1 分钟)
- API 调用失败率超过阈值
- WebSocket 连接断开
- 数据更新异常
**告警方式**
- 日志记录
- 系统通知
- 邮件/短信通知(可选)
---
## 六、量化分析逻辑设计
### 6.1 分析流程
#### 6.1.1 数据准备
**数据获取**
- 获取实时比赛数据(比分、分差、剩余时间等)
- 获取统计数据(球队统计、球员统计等)
- 获取历史数据(历史比赛、历史统计等)
**数据预处理**
- 数据清洗:去除异常值和缺失值
- 数据验证:验证数据的有效性和完整性
- 数据标准化:将数据标准化为统一的格式
#### 6.1.2 特征提取
**基础特征**
- 当前分差
- 剩余时间
- 当前节次
- 比赛状态
**统计特征**
- 球队实力指标(净效率值、攻防效率等)
- 球员表现指标(PER、TS%、USG% 等)
- 历史对战数据
**衍生特征**
- 分差/剩余时间比
- 球队实力差
- 势头动量(过去 N 回合的净胜分)
- 球星爆发因子(本场表现 vs 赛季平均)
#### 6.1.3 策略执行
**综合评分和对位分析**
- 计算主队和客队的综合评分(基础实力、近期状态、阵容完整度、球星状态、环境因素)
- 分析对位优势(球星对位、阵容克制)
- 计算实时状态调整(分差、势头动量,仅比赛进行中)
**获胜概率和交易价值计算**
- 基于综合评分计算基础获胜概率
- 根据对位优势和实时状态调整概率
- 计算预期收益和风险调整收益
- 计算交易价值评分
**触发条件判断**
- 判断获胜概率差异是否达到阈值(如主队获胜概率 > 0.55 或 < 0.45
- 判断交易价值评分是否达到阈值(如 > 0.05)
- 判断其他触发条件(剩余时间、价格等)
**交易规则计算**
- 根据获胜概率确定买入方向(YES 或 NO)
- 根据配置的交易规则,计算买入/卖出价格(固定价格/市场价格/动态价格)
- 根据配置的交易规则,计算买入/卖出数量(固定金额/按比例/动态计算)
- 根据配置的风险控制参数,验证交易是否合规
**信号生成**
- 生成买入信号:包含市场 ID、方向(由系统自动判断)、价格、数量、原因(包含综合评分、获胜概率、交易价值等)等
- 生成卖出信号:包含市场 ID、方向、价格、数量、原因等
- 信号验证:验证信号的有效性和合规性
### 6.2 策略配置映射
#### 6.2.1 配置参数到分析逻辑的映射
**触发条件配置**
- 比赛筛选 → 数据过滤逻辑
- 数据指标 → 特征提取逻辑
- 触发阈值 → 条件判断逻辑
**交易规则配置**
- 买入规则 → 买入信号生成逻辑
- 卖出规则 → 卖出信号生成逻辑
- 价格策略 → 价格计算逻辑
**风险控制配置**
- 最大/最小持仓 → 数量限制逻辑
- 每日亏损限制 → 风险检查逻辑
- 每日订单限制 → 订单计数逻辑
- 价格容忍度 → 价格验证逻辑
### 6.3 信号生成规则
#### 6.3.1 买入信号生成
**触发条件**
- 满足配置的买入触发条件
- 通过风险控制检查
- 市场状态正常(可交易)
**信号内容**
- 市场 ID:目标市场的唯一标识
- 方向:YES 或 NO
- 价格:买入价格(根据价格策略计算)
- 数量:买入数量(根据交易规则计算)
- 原因:触发买入的原因(如"分差达到阈值")
- 时间戳:信号生成时间
#### 6.3.2 卖出信号生成
**触发条件**
- 满足配置的卖出触发条件
- 通过风险控制检查
- 市场状态正常(可交易)
**信号内容**
- 市场 ID:目标市场的唯一标识
- 方向:YES 或 NO
- 价格:卖出价格(根据价格策略计算)
- 数量:卖出数量(根据交易规则计算)
- 原因:触发卖出的原因(如"达到止盈条件")
- 时间戳:信号生成时间
---
## 七、WebSocket 推送方案
### 7.1 推送架构
#### 7.1.1 推送流程
```
量化分析服务生成交易信号
信号推送服务接收信号
检查订阅关系(哪些前端订阅了该策略)
通过 WebSocket 推送给订阅的前端
前端接收并展示信号
```
#### 7.1.2 订阅管理
**订阅方式**
- 前端通过 WebSocket 发送订阅消息
- 支持订阅所有策略的信号
- 支持订阅特定策略的信号
- 支持订阅特定市场的信号
**订阅存储**
- 后端维护订阅关系(策略 ID → 前端会话列表)
- 支持多个前端同时订阅同一策略
- 支持前端动态订阅/取消订阅
**订阅恢复**
- 前端连接断开后,自动清理订阅关系
- 前端重连后,可以重新订阅
### 7.2 推送消息格式
#### 7.2.1 买入信号消息
包含以下字段:
- 消息类型:买入信号
- 策略 ID:生成信号的策略标识
- 市场 ID:目标市场标识
- 方向:YES 或 NO
- 价格:买入价格
- 数量:买入数量
- 原因:触发原因
- 时间戳:信号生成时间
#### 7.2.2 卖出信号消息
包含以下字段:
- 消息类型:卖出信号
- 策略 ID:生成信号的策略标识
- 市场 ID:目标市场标识
- 方向:YES 或 NO
- 价格:卖出价格
- 数量:卖出数量
- 原因:触发原因
- 时间戳:信号生成时间
#### 7.2.3 信号状态更新消息
包含以下字段:
- 消息类型:状态更新
- 信号 ID:信号的唯一标识
- 状态:执行中/执行成功/执行失败
- 结果:执行结果详情
- 时间戳:状态更新时间
### 7.3 推送优化
#### 7.3.1 批量推送
**批量策略**
- 多个信号可以批量推送,减少网络开销
- 批量大小可配置(如每批 10 个信号)
- 批量推送间隔可配置(如每 1 秒推送一次)
**批量格式**
- 单个消息包含多个信号
- 前端解析后分别处理每个信号
#### 7.3.2 推送频率控制
**频率限制**
- 每个策略的信号推送频率可配置
- 避免过于频繁的推送,影响前端性能
- 支持优先级:重要信号立即推送,普通信号批量推送
#### 7.3.3 推送失败处理
**失败重试**
- 推送失败时,记录失败信息
- 支持重试机制(如重试 3 次)
- 重试失败后,记录到数据库,后续可以查询
**降级策略**
- WebSocket 推送失败时,可以降级到 HTTP 轮询
- 前端定期轮询获取信号,确保不丢失
---
## 八、错误处理和容错机制
### 8.1 数据获取错误处理
#### 8.1.1 API 调用失败
**重试策略**
- API 调用失败时,自动重试(如重试 3 次)
- 使用指数退避策略,避免频繁重试
- 重试失败后,记录错误日志
**降级策略**
- API 调用失败时,使用缓存数据
- 缓存数据过期时,使用数据库数据
- 所有数据源都失败时,记录错误并告警
#### 8.1.2 数据解析错误
**错误处理**
- 数据格式异常时,记录错误日志
- 跳过异常数据,继续处理其他数据
- 数据缺失时,使用默认值或历史数据
### 8.2 量化分析错误处理
#### 8.2.1 分析逻辑错误
**错误捕获**
- 分析逻辑执行时,捕获所有异常
- 记录错误日志,包含错误详情和上下文
- 错误不影响其他策略的执行
**错误恢复**
- 错误发生后,跳过本次分析
- 等待下次分析周期,重新执行
- 连续错误时,触发告警
#### 8.2.2 信号生成错误
**错误处理**
- 信号生成失败时,记录错误日志
- 不生成无效信号,避免误导用户
- 错误原因推送给前端(可选)
### 8.3 推送错误处理
#### 8.3.1 WebSocket 推送失败
**失败处理**
- 推送失败时,记录失败信息
- 支持重试机制(如重试 3 次)
- 重试失败后,记录到数据库
**降级策略**
- WebSocket 推送失败时,可以降级到 HTTP 轮询
- 前端定期轮询获取信号,确保不丢失
#### 8.3.2 前端连接断开
**连接管理**
- 检测前端连接断开,清理订阅关系
- 前端重连后,可以重新订阅
- 连接断开期间,信号暂存到数据库
---
## 九、性能优化方案
### 9.1 数据获取优化
#### 9.1.1 请求频率控制
**频率限制**
- 控制 API 请求频率,避免触发速率限制
- 使用请求队列,管理并发请求
- 请求失败时,使用指数退避策略
#### 9.1.2 数据缓存
**缓存策略**
- 实时数据缓存 5 分钟
- 统计数据缓存 1 小时
- 高级统计缓存 24 小时
**缓存更新**
- 数据更新时同步更新缓存
- 缓存过期后自动刷新
### 9.2 量化分析优化
#### 9.2.1 增量分析
**变化检测**
- 只分析变化的数据,减少不必要的计算
- 使用数据快照比较,检测数据变化
- 只处理变化的数据,提高分析效率
#### 9.2.2 并行分析
**并行策略**
- 多个策略可以并行分析
- 使用线程池管理分析任务
- 避免资源竞争,确保分析准确性
### 9.3 推送优化
#### 9.3.1 批量推送
**批量策略**
- 多个信号批量推送,减少网络开销
- 批量大小和间隔可配置
- 重要信号可以立即推送
#### 9.3.2 推送频率控制
**频率限制**
- 控制推送频率,避免前端性能问题
- 支持优先级:重要信号立即推送
- 普通信号批量推送
---
## 十、监控和日志
### 10.1 系统监控
#### 10.1.1 性能监控
**监控指标**
- API 调用响应时间
- 数据处理时间
- WebSocket 推送延迟
- 系统资源使用率(CPU、内存等)
**监控方式**
- 定期记录性能指标
- 性能指标超过阈值时触发告警
- 提供性能统计报表
#### 10.1.2 业务监控
**监控指标**
- 策略执行次数
- 信号生成数量
- 信号推送成功率
- 数据更新延迟
**监控方式**
- 记录业务指标到数据库
- 提供业务统计报表
- 异常情况触发告警
### 10.2 日志管理
#### 10.2.1 日志级别
**日志分类**
- 错误日志:记录系统错误和异常
- 警告日志:记录警告信息
- 信息日志:记录关键操作信息
- 调试日志:记录详细调试信息
#### 10.2.2 日志内容
**关键操作日志**
- 策略配置变更
- 量化分析执行
- 信号生成和推送
- API 调用和错误
**日志格式**
- 统一日志格式,便于解析和分析
- 包含时间戳、日志级别、模块、消息等字段
- 支持结构化日志(JSON 格式)
---
## 十一、实施步骤
### 11.1 第一阶段:基础功能开发(2-3 周)
**任务清单**
- 完成策略配置管理功能(前端配置页面 + 后端 API)
- 完成 NBA 数据获取功能(API 集成 + 数据存储)
- 完成基础量化分析功能(简单策略逻辑)
- 完成信号推送功能(WebSocket 推送)
### 11.2 第二阶段:量化分析完善(2-3 周)
**任务清单**
- 完善量化分析逻辑(支持复杂策略)
- 完善特征提取和数据处理
- 完善信号生成和验证
- 完善错误处理和容错机制
### 11.3 第三阶段:实时性优化(1-2 周)
**任务清单**
- 优化数据获取频率和策略
- 优化 WebSocket 推送机制
- 实现数据变化检测和增量更新
- 实现前端降级策略
### 11.4 第四阶段:性能优化和监控(1-2 周)
**任务清单**
- 性能优化(缓存、并行处理等)
- 系统监控和日志完善
- 压力测试和性能调优
- 文档完善
---
## 十二、注意事项
### 12.1 API 限制
- **NBA Stats API**: 建议请求频率 < 10 请求/秒
- **Basketball Reference**: 建议请求频率 < 1 请求/秒
- 需要设置正确的请求头,避免被拒绝
### 12.2 数据质量
- API 返回的数据格式可能不一致,需要做好异常处理
- Basketball Reference 的 HTML 结构可能变化,需要定期检查
- 需要验证数据的合理性(如得分不超过 200 分)
### 12.3 法律合规
- 遵守 NBA Stats API 的使用条款
- 遵守 Basketball Reference 的爬虫协议(robots.txt
- 不要过度请求,避免对服务器造成负担
### 12.4 风险提示
- 量化交易存在风险,需要做好风险控制
- 信号仅供参考,不构成投资建议
- 需要明确告知用户风险和使用条款
---
## 十三、扩展建议
### 13.1 功能扩展
**策略模板**
- 提供预定义的策略模板
- 用户可以基于模板快速创建策略
- 支持策略模板的导入和导出
**回测功能**
- 支持历史数据回测
- 评估策略的历史表现
- 优化策略参数
**多账户支持**
- 支持多个交易账户
- 每个账户可以配置不同的策略
- 统一管理和监控
### 13.2 技术扩展
**机器学习集成**
- 使用机器学习模型优化策略
- 自动学习和优化参数
- 提高信号准确性
**多数据源整合**
- 整合多个数据源(如 ESPN、Sportradar 等)
- 提高数据完整性和准确性
- 降低对单一数据源的依赖
**分布式部署**
- 支持分布式部署,提高系统性能
- 支持负载均衡和容错
- 支持水平扩展
+158
View File
@@ -0,0 +1,158 @@
# NBA Stats API 验证检查清单
## 一、API 接口定义检查
### 1.1 接口路径
- ✅ **路径**: `/Scoreboard`
- ✅ **Base URL**: `https://stats.nba.com/stats/`
- ✅ **完整 URL**: `https://stats.nba.com/stats/Scoreboard`
### 1.2 请求参数
- ✅ **GameDate**: 日期格式 `YYYY-MM-DD`(如 `2024-12-15`
- ✅ **LeagueID**: 联盟ID,默认 `"00"` (NBA)
- ✅ **DayOffset**: 日期偏移,默认 `0`
**注意**: NBA Stats API 的参数名称是**大小写敏感**的:
- ✅ `GameDate` (正确)
- ❌ `gameDate` (错误)
- ✅ `LeagueID` (正确)
- ❌ `leagueId` (错误)
### 1.3 请求头设置
- ✅ **User-Agent**: `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36`
- ✅ **Referer**: `https://www.nba.com/`
- ✅ **Accept**: `application/json`
- ✅ **Accept-Language**: `en-US,en;q=0.9`
- ✅ **Origin**: `https://www.nba.com`
## 二、响应格式检查
### 2.1 响应结构
NBA Stats API 返回的 JSON 结构:
```json
{
"resultSets": [
{
"name": "GameHeader",
"headers": ["GAME_DATE_EST", "GAME_SEQUENCE", "GAME_ID", ...],
"rowSet": [[...], [...]]
},
{
"name": "LineScore",
"headers": ["GAME_DATE_EST", "GAME_SEQUENCE", "GAME_ID", "TEAM_ID", ...],
"rowSet": [[...], [...]]
}
]
}
```
### 2.2 ResultSet 名称
- ✅ **GameHeader**: 比赛基本信息
- ✅ **LineScore**: 比分信息(每支球队一行)
### 2.3 GameHeader 字段顺序
根据注释,字段顺序应该是:
```
[0] GAME_DATE_EST
[1] GAME_SEQUENCE
[2] GAME_ID
[3] GAME_STATUS_ID
[4] GAME_STATUS_TEXT
[5] GAMECODE
[6] HOME_TEAM_ID
[7] VISITOR_TEAM_ID
[8] SEASON
[9] LIVE_PERIOD
[10] LIVE_PC_TIME
[11] NATL_TV_BROADCASTER_ABBREV
[12] LIVE_PERIOD_TIME_BCAST
[13] WH_STATUS
```
### 2.4 LineScore 字段顺序
根据注释,字段顺序应该是:
```
[0] GAME_DATE_EST
[1] GAME_SEQUENCE
[2] GAME_ID
[3] TEAM_ID
[4] TEAM_ABBREVIATION
[5] TEAM_NAME
[6] PTS_QTR1
[7] PTS_QTR2
[8] PTS_QTR3
[9] PTS_QTR4
[10] PTS_OT1
[11] PTS_OT2
[12] PTS_OT3
[13] PTS_OT4
[14] PTS
[15] FG_PCT
[16] FT_PCT
[17] FG3_PCT
[18] AST
[19] REB
[20] TOV
```
## 三、代码实现检查
### 3.1 接口定义 ✅
```kotlin
@GET("Scoreboard")
suspend fun getScoreboard(
@Query("GameDate") gameDate: String? = null,
@Query("LeagueID") leagueId: String = "00",
@Query("DayOffset") dayOffset: Int = 0
): Response<ScoreboardResponse>
```
- ✅ 参数名称大小写正确
- ✅ 参数类型正确
### 3.2 请求头设置 ✅
- ✅ 已设置所有必需的请求头
- ✅ User-Agent 格式正确
### 3.3 响应解析 ⚠️
**潜在问题**
1. **ResultSet 查找方式**: 使用 `firstOrNull { it.name == "GameHeader" }` 可能不够准确
- 建议:使用索引 `resultSets[0]``resultSets.getOrNull(0)`
- 或者:先检查 `resultSets.size >= 2`
2. **字段索引**: 当前代码假设字段顺序固定
- 建议:根据 `headers` 数组动态查找字段索引,而不是硬编码索引
3. **错误处理**: 当前有基本的错误处理,但可以更详细
## 四、建议的改进
### 4.1 使用 headers 动态查找字段
```kotlin
// 根据 headers 查找字段索引,而不是硬编码
val gameDateIndex = headers.indexOf("GAME_DATE_EST")
val gameIdIndex = headers.indexOf("GAME_ID")
// ...
```
### 4.2 增强错误处理
- 记录完整的响应内容(用于调试)
- 验证 headers 数量是否匹配
- 验证 rowSet 数据是否完整
### 4.3 添加响应验证
- 验证 resultSets 数量
- 验证每个 resultSet 的 name
- 验证 headers 和 rowSet 的对应关系
## 五、测试建议
1. **单元测试**: 测试 API 调用和响应解析
2. **集成测试**: 测试完整的获取流程
3. **错误场景测试**: 测试 API 失败、数据不完整等情况
## 六、已知问题
1. **API 限制**: NBA Stats API 可能需要特定的请求头,否则可能返回 403 或空数据
2. **数据格式**: 响应格式可能因日期而异(有比赛 vs 无比赛)
3. **时区问题**: `GAME_DATE_EST` 是 EST 时区,需要注意时区转换
@@ -0,0 +1,218 @@
# NBA 量化交易系统后端实现总结
## 已完成部分
### 1. 数据库设计 ✅
**迁移文件**: `V11__create_nba_quantitative_trading_tables.sql`
创建了以下表:
- `nba_markets`: NBA 市场表(Polymarket 市场信息)
- `nba_games`: NBA 比赛表
- `nba_quantitative_strategies`: 量化策略配置表
- `nba_trading_signals`: 交易信号表
- `nba_strategy_statistics`: 策略执行统计表
### 2. 实体类 ✅
已创建以下实体类:
- `NbaMarket.kt`: NBA 市场实体
- `NbaGame.kt`: NBA 比赛实体
- `NbaQuantitativeStrategy.kt`: 量化策略配置实体
- `NbaTradingSignal.kt`: 交易信号实体
- `NbaStrategyStatistics.kt`: 策略统计实体
### 3. Repository 层 ✅
已创建以下 Repository
- `NbaMarketRepository.kt`
- `NbaGameRepository.kt`
- `NbaQuantitativeStrategyRepository.kt`
- `NbaTradingSignalRepository.kt`
- `NbaStrategyStatisticsRepository.kt`
### 4. DTO 层 ✅
已创建:
- `NbaQuantitativeStrategyDto.kt`: 包含创建、更新、列表请求和响应 DTO
### 5. Service 层(部分完成)✅
已创建:
- `NbaQuantitativeStrategyService.kt`: 策略管理服务
- 创建策略
- 更新策略
- 获取策略列表
- 获取策略详情
- 删除策略
- 获取启用的策略列表
### 6. Controller 层(部分完成)✅
已创建:
- `NbaQuantitativeStrategyController.kt`: 策略管理 API
- `POST /api/nba/strategies/create`: 创建策略
- `POST /api/nba/strategies/update`: 更新策略
- `POST /api/nba/strategies/list`: 获取策略列表
- `POST /api/nba/strategies/detail`: 获取策略详情
- `POST /api/nba/strategies/delete`: 删除策略
---
## 待完成部分
### 1. NBA 市场数据获取服务 ⏳
**需要创建**:
- `NbaMarketService.kt`:
- 从 Polymarket API 获取市场列表
- 同步市场数据到数据库
- 根据条件查询市场
- 匹配 NBA 比赛和市场
**参考文档**: `docs/zh/polymarket-nba-markets-fetching-solution.md`
### 2. NBA 比赛数据获取服务 ⏳
**需要创建**:
- `NbaGameService.kt`:
- 从 NBA API 获取比赛数据
- 同步比赛数据到数据库
- 实时更新比赛状态
- 匹配比赛和市场
**需要集成**: NBA Stats API 或第三方 NBA 数据 API
### 3. 量化分析服务 ⏳
**需要创建**:
- `NbaQuantitativeAnalysisService.kt`:
- 综合评分计算
- 对位分析
- 实时状态分析
- 获胜概率计算
- 交易价值计算
**参考文档**: `docs/zh/nba-quantitative-strategy-algorithm.md`
### 4. 交易信号生成服务 ⏳
**需要创建**:
- `NbaTradingSignalService.kt`:
- 生成买入信号
- 生成卖出信号
- 风险控制检查
- 信号验证
### 5. WebSocket 推送服务 ⏳
**需要创建**:
- `NbaTradingSignalPushService.kt`:
- WebSocket 连接管理
- 信号推送
- 订阅管理
**参考**: 现有的 `OrderPushService.kt`
### 6. 定时任务和同步服务 ⏳
**需要创建**:
- `NbaMarketSyncScheduler.kt`: 同步 NBA 市场数据
- `NbaGameSyncScheduler.kt`: 同步 NBA 比赛数据
- `NbaQuantitativeAnalysisScheduler.kt`: 定时执行量化分析
### 7. 其他 Controller ⏳
**需要创建**:
- `NbaMarketController.kt`: 市场数据 API
- `NbaGameController.kt`: 比赛数据 API
- `NbaTradingSignalController.kt`: 交易信号 API
- `NbaStatisticsController.kt`: 统计 API
### 8. 其他 DTO ⏳
**需要创建**:
- `NbaMarketDto.kt`
- `NbaGameDto.kt`
- `NbaTradingSignalDto.kt`
- `NbaStatisticsDto.kt`
---
## 实现优先级
### 第一阶段(核心功能)
1. ✅ 数据库设计和实体类
2. ✅ 策略管理服务(CRUD
3. ⏳ NBA 市场数据获取服务
4. ⏳ NBA 比赛数据获取服务
### 第二阶段(量化分析)
5. ⏳ 量化分析服务
6. ⏳ 交易信号生成服务
7. ⏳ 风险控制逻辑
### 第三阶段(实时推送)
8. ⏳ WebSocket 推送服务
9. ⏳ 定时任务和同步服务
### 第四阶段(完善功能)
10. ⏳ 统计服务
11. ⏳ 其他 API 接口
12. ⏳ 错误处理和日志
---
## 技术要点
### 1. 数据同步策略
- 市场数据:每天全量同步,每小时增量同步
- 比赛数据:实时更新(比赛进行中)
- 分析频率:根据策略配置(默认 30 秒)
### 2. 量化分析算法
- 综合评分计算(基础实力、近期状态、阵容完整度等)
- 对位分析(球星对位、阵容克制)
- 实时状态分析(分差、势头)
- 获胜概率计算
- 交易价值计算
### 3. 风险控制
- 持仓限制检查
- 每日限制检查
- 价格容忍度检查
- 概率置信度检查
### 4. WebSocket 推送
- 实时推送交易信号
- 支持订阅/取消订阅
- 连接管理和重连机制
---
## 下一步工作
1. **实现 NBA 市场数据获取服务**
- 参考 `polymarket-nba-markets-fetching-solution.md`
- 实现批量查询和过滤逻辑
2. **实现 NBA 比赛数据获取服务**
- 集成 NBA Stats API
- 实现比赛数据同步和更新
3. **实现量化分析服务**
- 参考 `nba-quantitative-strategy-algorithm.md`
- 实现综合评分、对位分析、概率计算等核心算法
4. **实现交易信号生成服务**
- 集成量化分析结果
- 实现买入/卖出信号生成逻辑
5. **实现 WebSocket 推送服务**
- 参考现有的 `OrderPushService`
- 实现信号推送和订阅管理
---
**文档结束**
+100
View File
@@ -0,0 +1,100 @@
# NBA 比赛数据来源对比
## 一、数据来源对比
### 1.1 NBA Stats API
**优势**
- ✅ 官方数据源,数据准确可靠
- ✅ 包含完整的比赛数据(实时比分、状态、统计等)
- ✅ 实时更新,数据最新
- ✅ 免费使用
- ✅ 支持历史数据查询
**劣势**
- ❌ 不包含 Polymarket 市场信息
- ❌ 需要额外的匹配逻辑来关联 Polymarket 市场
**适用场景**
- 获取比赛基本信息(球队、日期、时间)
- 获取实时比分和比赛状态
- 获取比赛统计数据
### 1.2 Polymarket API
**优势**
- ✅ 直接获取交易市场信息
- ✅ 包含市场 ID、价格、流动性等交易相关数据
- ✅ 市场名称可能包含比赛信息
**劣势**
- ❌ 不包含实时比分和比赛状态
- ❌ 无法直接搜索或筛选 NBA 市场(需要知道 condition_ids
- ❌ 市场名称格式不统一,解析困难
- ❌ 数据不完整(缺少比赛详细信息)
**适用场景**
- 获取可交易的市场列表
- 获取市场价格和流动性信息
- 匹配比赛和交易市场
## 二、推荐方案
### 2.1 混合方案(推荐)
**策略**:结合两种数据源,发挥各自优势
**实现方式**
1. **主要数据源:NBA Stats API**
- 获取完整的比赛数据(球队、日期、时间、比分、状态等)
- 实时更新比赛状态
2. **补充数据源:Polymarket API**
- 获取市场信息(市场 ID、价格、流动性等)
- 通过球队名称和日期匹配比赛和市场
3. **数据匹配**
- 使用球队名称和比赛日期匹配
- 建立比赛和市场的关联关系
### 2.2 数据流程
```
1. 从 NBA Stats API 获取比赛列表
2. 从 Polymarket API 获取市场列表(如果知道 condition_ids
3. 解析市场名称,提取球队和日期信息
4. 匹配比赛和市场(通过球队名称和日期)
5. 合并数据,返回完整的比赛和市场信息
```
## 三、当前实现
当前系统使用 **NBA Stats API** 作为主要数据源,原因:
- 数据完整可靠
- 实时更新
- 免费使用
- 无需预先知道 condition_ids
如果需要 Polymarket 市场信息,可以:
1. 在获取比赛数据后,通过球队名称和日期匹配 Polymarket 市场
2. 或者单独维护一个 Polymarket 市场列表,定期同步
## 四、建议
**对于获取比赛数据**
- ✅ 使用 NBA Stats API(当前实现)
- ✅ 实时、准确、完整
**对于获取市场信息**
- ✅ 使用 Polymarket API(需要知道 condition_ids
- ✅ 或者从市场名称解析(如果格式统一)
**最佳实践**
- 主要使用 NBA Stats API 获取比赛数据
- 使用 Polymarket API 获取市场信息并匹配
- 建立比赛和市场的关联关系
@@ -0,0 +1,150 @@
# NBA 比赛数据获取实现
## 一、概述
本系统从 NBA Stats API 实时获取 NBA 比赛数据:
- **数据源**NBA Stats API(官方数据源,实时、准确、完整)
- **不依赖数据库**:所有数据从 API 实时获取
## 二、实现架构
### 2.1 数据流程
```
1. 从 NBA Stats API 获取比赛列表(实时)
2. 解析 API 响应,转换为 DTO
3. 返回比赛数据给前端
```
### 2.2 关于 Polymarket 市场匹配
**当前实现**
- 不进行市场匹配(因为 Polymarket API 限制)
- 比赛数据中的 `polymarketMarketId` 字段为 `null`
**未来扩展**
- 如果将来有办法获取 condition_ids,可以从 Polymarket API 实时获取市场信息
- 已保留 `NbaMarketNameParser` 工具类,可用于市场名称解析
### 2.2 核心组件
#### 1. NbaGameService
- **功能**:从 NBA Stats API 实时获取比赛数据
- **数据源**NBA Stats API(官方数据源)
- **特点**
- 实时获取,不依赖数据库
- 支持日期范围查询
- 支持按状态过滤
#### 2. NbaMarketNameParser(保留,供将来使用)
- **功能**:解析 Polymarket 市场名称,提取球队和日期信息
- **支持格式**
- "Team1 vs Team2"
- "Team1 @ Team2"
- "Will Team1 beat Team2"
- "Team1 win"
- **日期格式**
- "Dec 15, 2024"
- "2024-12-15"
- "12/15/2024"
- **说明**:当前未使用,保留以备将来扩展
## 三、实现细节
### 3.1 比赛数据获取
**获取流程**
1. 根据日期范围,每天调用一次 NBA Stats API
2. 解析 Scoreboard 响应,提取比赛信息
3. 组合 GameHeader 和 LineScore 数据
4. 转换为 NbaGameDto 返回
**数据字段**
- 比赛基本信息(球队、日期、时间)
- 实时比分和状态
- 比赛节次和剩余时间
- 球队统计信息
### 3.2 关于市场匹配(未来扩展)
**当前状态**
- 不进行市场匹配
- `polymarketMarketId` 字段为 `null`
**未来扩展方案**
如果将来需要匹配市场,可以考虑:
1. 从其他来源获取 condition_ids(如爬取、手动维护等)
2. 使用 Polymarket API 实时查询这些市场
3. 使用 `NbaMarketNameParser` 解析市场名称并匹配
### 3.3 数据返回
**NbaGameDto** 包含:
- 比赛基本信息(从 NBA Stats API
- `polymarketMarketId`:当前为 `null`(未来可扩展)
## 四、使用方式
### 4.1 API 调用
```kotlin
// 获取比赛列表(自动匹配市场)
val request = NbaGameListRequest(
startDate = "2024-12-15",
endDate = "2024-12-22"
)
val result = nbaGameService.getNbaGames(request)
```
### 4.2 返回数据
```kotlin
data class NbaGameDto(
val nbaGameId: String?,
val homeTeam: String,
val awayTeam: String,
val gameDate: LocalDate,
val gameStatus: String,
val homeScore: Int,
val awayScore: Int,
val polymarketMarketId: String? // 匹配的市场 ID
)
```
## 五、优势
1. **数据完整**:从 NBA Stats API 获取完整的比赛数据
2. **实时更新**:比赛数据实时获取,不依赖数据库
3. **简单高效**:直接调用 API,无需维护数据库
4. **官方数据源**:数据准确可靠
## 六、注意事项
1. **API 限制**
- NBA Stats API 需要设置正确的请求头
- 建议控制请求频率(< 10 请求/秒)
2. **市场匹配**
- 当前不进行市场匹配
- 如果需要匹配,需要解决 Polymarket API 的限制(需要知道 condition_ids
3. **性能考虑**
- 日期范围查询会多次调用 API(每天一次)
- 建议合理设置日期范围,避免查询过长的时间段
## 七、后续优化
1. **缓存机制**
- 缓存 API 响应,减少重复请求
- 设置合理的缓存时间(如 30 秒)
2. **错误处理**
- 实现重试机制(指数退避)
- 处理 API 临时不可用的情况
3. **市场匹配(可选)**
- 如果将来有办法获取 condition_ids,可以实现市场匹配
- 使用 `NbaMarketNameParser` 进行市场名称解析
@@ -0,0 +1,834 @@
# NBA 量化交易策略算法文档
## 一、算法概述
### 1.1 算法目标
本算法基于 NBA 比赛数据,通过量化分析两支队伍的实力、状态、对位关系等因素,计算出哪支队伍具有更高的获胜概率和交易价值,从而生成买入/卖出交易信号。
### 1.2 算法流程
```
获取比赛数据(主队 vs 客队)
数据预处理和特征提取
计算两队综合评分
计算获胜概率和预期收益
生成交易信号(买入/卖出)
风险控制检查
输出交易信号
```
### 1.3 核心思想
**不是简单的"主队落后买入",而是**
- 综合分析两支队伍的各项指标
- 计算每支队伍的相对优势和劣势
- 评估交易价值和风险
- 选择最优的交易方向和时机
---
## 二、数据预处理
### 2.1 基础数据获取
**比赛基本信息**
- 比赛 ID、日期、主客场
- 当前比分、分差、节次、剩余时间
- 比赛状态(未开始/进行中/已结束)
**球队基础数据**
- 主队和客队的基本信息
- 当前赛季的统计数据
- 近期表现数据(近 5 场/10 场)
**球员数据**
- 核心球员的统计数据
- 球员健康状况和轮休情况
- 球员近期表现
### 2.2 数据清洗
**异常值处理**
- 检查数据合理性(如得分不超过 200 分)
- 处理缺失值(使用历史平均值或默认值)
- 处理异常数据(如负分差、超时时间等)
**数据标准化**
- 将不同量纲的数据标准化到统一范围
- 使用 Z-score 或 Min-Max 归一化
- 确保数据在 [0, 1] 或 [-1, 1] 范围内
### 2.3 特征工程
**基础特征**
- 当前分差(主队得分 - 客队得分)
- 剩余时间(分钟)
- 当前节次(1-4,加时)
- 比赛状态
**统计特征**
- 球队净效率值(进攻效率 - 防守效率)
- 球队比赛节奏(Pace
- 球队关键时刻表现(Clutch 数据)
- 球队主客场表现差异
**动态特征**
- 势头动量(过去 N 回合的净胜分)
- 手感热度(近期命中率)
- 阵容对位优势
- 球星爆发因子
---
## 三、综合评分算法
### 3.1 球队实力评分
#### 3.1.1 基础实力评分
**计算公式**
```
基础实力评分 = 净效率值评分 × W1 + 攻防效率评分 × W2 + 节奏评分 × W3
```
**评分组成**
- **净效率值评分**:基于球队赛季平均净效率值,归一化到 [0, 1]
- 评分 = (净效率值 - 联盟最低值) / (联盟最高值 - 联盟最低值)
- **攻防效率评分**:综合考虑进攻效率和防守效率
- 进攻效率评分 = (进攻效率排名 - 1) / (总球队数 - 1),排名越靠前评分越高
- 防守效率评分 = (防守效率排名 - 1) / (总球队数 - 1),排名越靠前评分越高
- 攻防效率评分 = (进攻效率评分 + 防守效率评分) / 2
- **节奏评分**:基于比赛节奏(Pace
- 节奏评分 = (Pace - 联盟最低值) / (联盟最高值 - 联盟最低值)
**权重设置**
- W1 = 0.5(净效率值权重最高)
- W2 = 0.3(攻防效率权重)
- W3 = 0.2(节奏权重)
#### 3.1.2 近期状态评分
**计算公式**
```
近期状态评分 = 近期胜率评分 × W1 + 近期净效率值变化 × W2 + 势头评分 × W3
```
**评分组成**
- **近期胜率评分**:近 5 场或 10 场胜率
- 评分 = 近期胜率(0-1
- **近期净效率值变化**:近期净效率值与赛季平均的差值
- 变化 = (近期净效率值 - 赛季平均净效率值) / 赛季平均净效率值
- 评分 = (变化 + 1) / 2(归一化到 [0, 1]
- **势头评分**:基于连胜/连败场次
- 连胜:评分 = min(连胜场次 / 5, 1)
- 连败:评分 = max(1 - 连败场次 / 5, 0)
**权重设置**
- W1 = 0.4(胜率权重)
- W2 = 0.4(净效率值变化权重)
- W3 = 0.2(势头权重)
#### 3.1.3 阵容完整度评分
**计算公式**
```
阵容完整度评分 = 1 - (缺失球员总VORP / 球队总VORP)
```
**评分说明**
- 如果所有核心球员都在,评分为 1.0
- 如果有核心球员缺席,根据缺失球员的 VORP 值降低评分
- 缺失球员越多、重要性越高,评分越低
#### 3.1.4 球星状态评分
**计算公式**
```
球星状态评分 = 核心球星PER评分 × W1 + 核心球星TS%评分 × W2 + 核心球星健康评分 × W3
```
**评分组成**
- **核心球星 PER 评分**:基于近期 PER 与赛季平均的对比
- 评分 = (近期PER - 赛季平均PER) / 赛季平均PER + 1,归一化到 [0, 1]
- **核心球星 TS% 评分**:基于近期真实命中率
- 评分 = (近期TS% - 赛季平均TS%) / 赛季平均TS% + 1,归一化到 [0, 1]
- **核心球星健康评分**
- 健康:1.0
- 出战成疑:0.7
- 缺席:0.3
**权重设置**
- W1 = 0.4PER 权重)
- W2 = 0.4TS% 权重)
- W3 = 0.2(健康权重)
#### 3.1.5 环境因素评分
**计算公式**
```
环境因素评分 = 主客场优势评分 × W1 + 休息天数评分 × W2 + 背靠背影响评分 × W3
```
**评分组成**
- **主客场优势评分**
- 主场:1.0
- 客场:基于客场胜率,评分 = 客场胜率 / 主场胜率
- **休息天数评分**
- 休息 1 天:0.9
- 休息 2 天:1.0(最优)
- 休息 3 天以上:0.95
- **背靠背影响评分**
- 非背靠背:1.0
- 背靠背第二场:0.85
**权重设置**
- W1 = 0.5(主客场权重最高)
- W2 = 0.3(休息天数权重)
- W3 = 0.2(背靠背权重)
### 3.2 综合评分计算
**主队综合评分**
```
主队综合评分 = 基础实力评分 × 0.3
+ 近期状态评分 × 0.25
+ 阵容完整度评分 × 0.2
+ 球星状态评分 × 0.15
+ 环境因素评分 × 0.1
```
**客队综合评分**
```
客队综合评分 = 基础实力评分 × 0.3
+ 近期状态评分 × 0.25
+ 阵容完整度评分 × 0.2
+ 球星状态评分 × 0.15
+ 环境因素评分 × 0.1
```
**相对优势评分**
```
主队相对优势 = 主队综合评分 - 客队综合评分
```
---
## 四、对位分析算法
### 4.1 球星对位分析
**对位优势评分**
```
对位优势评分 = 防守限制效果评分 × W1 + 历史对位数据评分 × W2
```
**评分组成**
- **防守限制效果评分**:基于防守球员的 DRPM 和进攻球员的 ORPM
- 如果防守球员 DRPM 高,且进攻球员 ORPM 高,则限制效果好
- 评分 = (防守球员DRPM - 联盟平均DRPM) / (联盟最高DRPM - 联盟平均DRPM)
- **历史对位数据评分**:基于历史对位时的表现
- 对位效率差 = (对位时进攻球员TS% - 赛季平均TS%)
- 评分 = 1 - (对位效率差 / 最大可能效率差),归一化到 [0, 1]
**权重设置**
- W1 = 0.6(防守限制效果权重)
- W2 = 0.4(历史对位数据权重)
### 4.2 阵容克制分析
**阵容克制评分**
```
阵容克制评分 = 内线优势评分 × W1 + 外线优势评分 × W2 + 快攻优势评分 × W3
```
**评分组成**
- **内线优势评分**
- 如果主队内线得分占比高,且客队内线防守效率低,则主队有优势
- 评分 = (主队内线得分占比 - 客队内线防守效率排名归一化) / 2
- **外线优势评分**
- 如果主队三分出手占比高,且客队三分防守效率低,则主队有优势
- 评分 = (主队三分出手占比 - 客队三分防守效率排名归一化) / 2
- **快攻优势评分**
- 如果主队快攻得分占比高,且客队快攻防守效率低,则主队有优势
- 评分 = (主队快攻得分占比 - 客队快攻防守效率排名归一化) / 2
**权重设置**
- W1 = 0.4(内线权重)
- W2 = 0.4(外线权重)
- W3 = 0.2(快攻权重)
### 4.3 对位综合评分
**主队对位优势**
```
主队对位优势 = 主队球星对位优势 - 客队球星对位优势
+ 主队阵容克制优势 - 客队阵容克制优势
```
---
## 五、实时状态分析算法
### 5.1 比赛进行中的实时分析
#### 5.1.1 当前分差分析
**分差优势评分**
```
分差优势评分 = (当前分差 / 最大可能分差) × 时间调整系数
```
**时间调整系数**
- 比赛早期(第1-2节):系数 = 0.3(分差影响较小)
- 比赛中期(第3节):系数 = 0.6(分差影响中等)
- 比赛后期(第4节):系数 = 1.0(分差影响最大)
- 最后5分钟:系数 = 1.2(分差影响极大)
#### 5.1.2 势头动量分析
**势头动量评分**
```
势头动量 = (过去N回合净胜分) / (N × 平均单回合得分)
```
**评分说明**
- 如果主队过去 10 回合净胜 +8 分,则主队势头强劲
- 势头动量 > 0.3:势头强劲
- 势头动量 < -0.3:势头疲软
#### 5.1.3 实时综合评分调整
**实时调整公式**
```
实时调整评分 = 基础综合评分
+ 分差优势评分 × 0.3
+ 势头动量评分 × 0.2
- 时间衰减系数 × 0.1
```
**时间衰减系数**
- 比赛越接近结束,基础评分的影响越小,实时状态的影响越大
- 时间衰减系数 = (剩余时间 / 总时间) × 0.5
### 5.2 比赛未开始时的预测分析
**预测综合评分**
```
预测综合评分 = 基础综合评分
+ 对位优势评分 × 0.2
+ 环境因素评分 × 0.1
```
---
## 六、获胜概率计算
### 6.1 基础获胜概率
**基于综合评分的概率**
```
基础获胜概率 = 1 / (1 + exp(-k × (主队综合评分 - 客队综合评分)))
```
**参数说明**
- k 为调整系数,通常取 5-10
- 如果主队综合评分 > 客队综合评分,则主队获胜概率 > 0.5
- 如果主队综合评分 = 客队综合评分,则主队获胜概率 = 0.5
### 6.2 对位调整概率
**对位调整**
```
调整后概率 = 基础获胜概率 + 对位优势调整值
```
**对位优势调整值**
- 如果主队对位优势 > 0.1,则调整值 = +0.05
- 如果主队对位优势 < -0.1,则调整值 = -0.05
- 调整值范围:[-0.1, +0.1]
### 6.3 实时状态调整概率
**实时调整**(仅比赛进行中):
```
最终概率 = 调整后概率
+ 分差调整值 × 0.3
+ 势头调整值 × 0.2
```
**分差调整值**
- 如果主队领先,且剩余时间充足,则调整值 = +0.05
- 如果主队落后,且剩余时间不足,则调整值 = -0.05
**势头调整值**
- 如果主队势头强劲,则调整值 = +0.03
- 如果主队势头疲软,则调整值 = -0.03
### 6.4 概率归一化
**确保概率在 [0, 1] 范围内**:
```
最终概率 = max(0, min(1, 最终概率))
```
---
## 七、交易价值计算
### 7.1 预期收益计算
**预期收益公式**
```
预期收益 = (获胜概率 × 获胜收益) - ((1 - 获胜概率) × 失败损失)
```
**收益和损失**
- 如果买入主队获胜(YES),价格为 P
- 获胜收益 = (1 - P) × 投入金额
- 失败损失 = P × 投入金额
- 如果买入主队失败(NO),价格为 P
- 获胜收益 = P × 投入金额
- 失败损失 = (1 - P) × 投入金额
### 7.2 风险调整收益
**风险调整公式**
```
风险调整收益 = 预期收益 × (1 - 风险系数)
```
**风险系数计算**
```
风险系数 = 概率不确定性 × 0.5 + 价格波动风险 × 0.3 + 流动性风险 × 0.2
```
**风险组成**
- **概率不确定性**:如果获胜概率接近 0.5,则不确定性高
- 不确定性 = 1 - |获胜概率 - 0.5| × 2
- **价格波动风险**:基于市场价格的波动性
- 如果价格波动大,则风险高
- **流动性风险**:基于市场的流动性
- 如果市场流动性低,则风险高
### 7.3 交易价值评分
**交易价值评分**
```
交易价值评分 = 风险调整收益 / 投入金额
```
**评分说明**
- 交易价值评分 > 0.1:高价值交易,强烈推荐
- 交易价值评分 > 0.05:中等价值交易,推荐
- 交易价值评分 > 0:低价值交易,可考虑
- 交易价值评分 <= 0:无价值交易,不推荐
---
## 八、交易信号生成算法
### 8.1 信号生成条件
**买入信号生成条件**
1. **获胜概率条件**
- 主队获胜概率 > 阈值(如 0.55)或 < 阈值(如 0.45
- 如果主队获胜概率 > 0.55,买入主队获胜(YES
- 如果主队获胜概率 < 0.45,买入主队失败(NO
2. **交易价值条件**
- 交易价值评分 > 用户配置的最小交易价值(如 0.05)
3. **价格条件**
- 当前市场价格在合理范围内
- 价格偏差在用户配置的容忍度内
4. **时间条件**
- 比赛未结束
- 剩余时间充足(如果用户配置了最小剩余时间)
### 8.2 信号方向确定
**方向判断逻辑**
```
如果 主队获胜概率 > 0.55:
方向 = YES(买入主队获胜)
目标价格 = 市场价格(如果合理)
如果 主队获胜概率 < 0.45:
方向 = NO(买入主队失败)
目标价格 = 1 - 市场价格(如果合理)
如果 0.45 <= 主队获胜概率 <= 0.55
不生成信号(概率太接近,不确定性高)
```
### 8.3 信号价格计算
**价格计算策略**
**策略一:固定价格**(用户配置)
```
信号价格 = 用户配置的固定价格
```
**策略二:市场价格**(默认)
```
信号价格 = 当前市场价格
```
**策略三:动态价格**(基于概率)
```
如果 方向 = YES
信号价格 = 主队获胜概率 × (1 + 价格偏移)
如果 方向 = NO
信号价格 = (1 - 主队获胜概率) × (1 + 价格偏移)
```
**价格偏移**
- 价格偏移 = 用户配置的价格偏移百分比(如 ±5%)
- 用于调整价格,提高交易成功率
### 8.4 信号数量计算
**数量计算策略**
**策略一:固定金额**(用户配置)
```
信号数量 = 用户配置的固定金额 / 信号价格
```
**策略二:按比例**(用户配置)
```
信号数量 = 账户余额 × 用户配置的比例 / 信号价格
```
**策略三:动态计算**(基于交易价值)
```
基础数量 = 用户配置的基础金额 / 信号价格
调整系数 = min(交易价值评分 / 0.1, 2.0) // 最多放大2倍
信号数量 = 基础数量 × 调整系数
```
### 8.5 信号原因生成
**触发原因说明**
```
触发原因 = "主队综合评分: {主队评分}, 客队综合评分: {客队评分}, "
+ "获胜概率: {获胜概率}, 交易价值: {交易价值评分}, "
+ "主要优势: {主要优势项}"
```
**主要优势项识别**
- 如果基础实力评分差异最大,则主要优势 = "基础实力"
- 如果近期状态评分差异最大,则主要优势 = "近期状态"
- 如果对位优势明显,则主要优势 = "对位优势"
- 如果实时状态优势明显,则主要优势 = "实时状态"
---
## 九、卖出信号生成算法
### 9.1 卖出条件
**卖出信号生成条件**
1. **止盈条件**
- 当前持仓的预期收益达到用户配置的止盈阈值(如 20%)
- 或市场价格达到用户配置的目标价格
2. **止损条件**
- 当前持仓的预期亏损达到用户配置的止损阈值(如 -10%)
- 或市场价格跌破用户配置的止损价格
3. **概率反转条件**
- 获胜概率发生反转(如从 0.6 降到 0.4)
- 反转幅度超过用户配置的阈值(如 0.15)
4. **时间条件**
- 比赛接近结束(剩余时间 < 用户配置的最小剩余时间)
- 或比赛已结束
### 9.2 卖出价格计算
**卖出价格**
```
卖出价格 = 当前市场价格
```
**价格调整**(可选):
```
如果 止盈卖出:
卖出价格 = min(当前市场价格, 目标价格)
如果 止损卖出:
卖出价格 = max(当前市场价格, 止损价格)
```
### 9.3 卖出数量计算
**卖出数量**
```
如果 全部卖出:
卖出数量 = 当前持仓数量
如果 部分卖出:
卖出数量 = 当前持仓数量 × 用户配置的卖出比例
```
---
## 十、风险控制算法
### 10.1 持仓限制检查
**最大持仓检查**
```
如果 信号数量 × 信号价格 > 用户配置的最大持仓:
信号数量 = 用户配置的最大持仓 / 信号价格
记录警告:超过最大持仓限制,已调整数量
```
**最小持仓检查**
```
如果 信号数量 × 信号价格 < 用户配置的最小持仓:
不生成信号
记录原因:金额低于最小持仓限制
```
### 10.2 每日限制检查
**每日亏损限制**
```
今日已亏损 = 今日所有交易的累计亏损
如果 今日已亏损 + 预期最大亏损 > 用户配置的每日亏损限制:
不生成信号
记录原因:超过每日亏损限制
```
**每日订单限制**
```
今日订单数 = 今日已生成的信号数量
如果 今日订单数 >= 用户配置的每日订单限制:
不生成信号
记录原因:超过每日订单限制
```
### 10.3 价格容忍度检查
**价格偏差检查**
```
价格偏差 = |信号价格 - 当前市场价格| / 当前市场价格
如果 价格偏差 > 用户配置的价格容忍度:
不生成信号
记录原因:价格偏差超过容忍度
```
### 10.4 概率置信度检查
**概率置信度**
```
如果 0.45 <= 获胜概率 <= 0.55
不生成信号
记录原因:获胜概率太接近,不确定性高
```
**最小概率阈值**(可选):
```
如果 获胜概率 < 用户配置的最小概率阈值:
不生成信号
记录原因:获胜概率低于最小阈值
```
---
## 十一、算法参数配置
### 11.1 用户可配置参数
**触发条件参数**
- 最小获胜概率差异:主队和客队获胜概率的最小差异(如 0.1)
- 最小交易价值:交易价值评分的最小值(如 0.05)
- 最小剩余时间:生成信号时的最小剩余时间(如 5 分钟)
**交易规则参数**
- 买入金额策略:固定金额/按比例/动态计算
- 买入金额:固定金额或比例值
- 价格策略:固定价格/市场价格/动态价格
- 价格偏移:价格偏移百分比(如 ±5%)
**风险控制参数**
- 最大持仓:单次最大买入金额
- 最小持仓:单次最小买入金额
- 每日亏损限制:每日最大亏损金额
- 每日订单限制:每日最大订单数量
- 价格容忍度:允许的价格偏差百分比
**卖出规则参数**
- 止盈阈值:预期收益达到多少时卖出(如 20%)
- 止损阈值:预期亏损达到多少时卖出(如 -10%)
- 概率反转阈值:概率反转多少时卖出(如 0.15)
- 卖出比例:部分卖出时的比例(如 50%)
### 11.2 系统默认参数
**评分权重**(可根据历史数据优化):
- 基础实力权重:0.3
- 近期状态权重:0.25
- 阵容完整度权重:0.2
- 球星状态权重:0.15
- 环境因素权重:0.1
**概率计算参数**
- k 值(逻辑回归系数):7.5
- 对位调整范围:[-0.1, +0.1]
- 实时调整权重:分差 0.3,势头 0.2
**风险系数权重**
- 概率不确定性权重:0.5
- 价格波动风险权重:0.3
- 流动性风险权重:0.2
---
## 十二、算法优化建议
### 12.1 参数优化
**历史回测优化**
- 使用历史数据回测不同参数组合
- 找到最优的参数配置
- 定期重新优化参数
**A/B 测试**
- 同时运行多组参数配置
- 对比不同参数的效果
- 选择最优参数组合
### 12.2 模型优化
**机器学习集成**
- 使用机器学习模型预测获胜概率
- 使用梯度提升决策树(XGBoost/LightGBM
- 定期重新训练模型
**特征工程优化**
- 添加更多特征(如历史对战数据、伤病信息等)
- 使用特征选择技术筛选重要特征
- 使用特征交互提高预测准确性
### 12.3 实时优化
**动态权重调整**
- 根据比赛阶段动态调整权重
- 比赛早期:基础实力权重高
- 比赛后期:实时状态权重高
**自适应阈值**
- 根据市场情况动态调整阈值
- 如果市场波动大,提高概率阈值
- 如果市场稳定,降低概率阈值
---
## 十三、算法输出
### 13.1 信号输出格式
**买入信号**
```
{
"signalType": "BUY",
"strategyId": "策略ID",
"gameId": "比赛ID",
"marketId": "市场ID",
"direction": "YES" | "NO",
"price": 0.65,
"quantity": 10.0,
"totalAmount": 6.5,
"reason": "主队综合评分: 0.72, 客队综合评分: 0.58, 获胜概率: 0.62, 交易价值: 0.08, 主要优势: 基础实力",
"winProbability": 0.62,
"tradeValue": 0.08,
"timestamp": 1234567890
}
```
**卖出信号**
```
{
"signalType": "SELL",
"strategyId": "策略ID",
"gameId": "比赛ID",
"marketId": "市场ID",
"direction": "YES" | "NO",
"price": 0.75,
"quantity": 10.0,
"totalAmount": 7.5,
"reason": "止盈卖出,预期收益: 15%",
"profit": 1.0,
"profitRate": 0.15,
"timestamp": 1234567890
}
```
### 13.2 算法执行日志
**执行日志格式**
```
{
"timestamp": 1234567890,
"strategyId": "策略ID",
"gameId": "比赛ID",
"step": "数据获取" | "特征提取" | "评分计算" | "信号生成",
"status": "SUCCESS" | "FAILED" | "SKIPPED",
"message": "执行信息",
"data": {} // 相关数据
}
```
---
## 十四、算法验证
### 14.1 回测验证
**回测流程**
1. 使用历史比赛数据
2. 模拟算法执行过程
3. 计算信号生成情况
4. 计算交易结果和盈亏
5. 评估算法效果
**评估指标**
- 信号生成数量
- 信号准确率(实际获胜 vs 预测获胜)
- 总盈亏金额
- 盈亏比率
- 最大回撤
### 14.2 实时验证
**实时监控**
- 监控算法执行情况
- 记录信号生成和交易结果
- 对比预测和实际结果
- 评估算法实时表现
**持续优化**
- 根据实时表现调整参数
- 优化算法逻辑
- 提高预测准确性
---
**文档结束**
@@ -0,0 +1,732 @@
# NBA 量化交易系统产品需求文档
## 文档信息
- **文档版本**: v1.0
- **创建日期**: 2024-12
- **文档类型**: 产品需求文档 (PRD)
- **目标用户**: 产品经理、开发团队、测试团队
---
## 一、产品概述
### 1.1 产品定位
NBA 量化交易系统是一个基于 NBA 比赛数据的智能量化交易平台,帮助用户通过配置量化策略参数,自动分析 NBA 比赛数据,生成买入/卖出交易信号,实现自动化交易决策。
### 1.2 产品目标
**核心目标**
- 提供简单易用的策略配置界面,降低量化交易门槛
- 实时获取和分析 NBA 比赛数据,提供及时的交易信号
- 支持多种量化策略,满足不同用户需求
- 提供完善的风险控制机制,保障交易安全
**用户价值**
- **自动化决策**:无需人工盯盘,系统自动分析并生成交易信号
- **数据驱动**:基于专业的 NBA 数据分析,提高决策准确性
- **风险可控**:完善的风险控制参数,降低交易风险
- **实时响应**:实时数据更新和信号推送,不错过交易机会
### 1.3 目标用户
**主要用户群体**
1. **量化交易爱好者**:对 NBA 和量化交易都有兴趣的用户
2. **NBA 数据分析师**:希望将数据分析转化为交易信号的用户
3. **自动化交易用户**:希望通过自动化系统进行交易的用户
**用户特征**
- 对 NBA 比赛有一定了解
- 对量化交易有基本认知
- 希望通过数据驱动的方式进行交易决策
- 需要自动化工具提高交易效率
---
## 二、产品功能需求
### 2.1 策略配置管理
#### 2.1.1 策略创建
**功能描述**
用户可以在前端创建新的量化策略,配置策略的各项参数。
**功能点**
- **策略基本信息**
- 策略名称:用户自定义策略名称(必填,1-50 字符)
- 策略描述:策略的简要说明(可选,最多 200 字符)
- 关联账户:选择用于交易的账户(必填)
- 启用状态:创建时默认启用,可后续修改
- **触发条件配置**
- 比赛筛选:
- 按球队筛选:选择关注的球队(可选,不选择则分析所有比赛)
- 按日期筛选:选择关注的日期范围
- 按重要性筛选:选择比赛重要性(常规赛/季后赛/关键战)
- 数据指标选择:
- 基础指标:分差、剩余时间、当前节次、比赛状态
- 统计指标:球队净效率值、球员 PER、真实命中率等
- 高级指标:势头动量、球星爆发因子、阵容对位系数等
- 说明:系统会综合分析两支队伍的各项指标,计算综合评分和获胜概率
- 触发阈值设置:
- 最小获胜概率差异:主队和客队获胜概率的最小差异(如 0.1 表示至少 10% 的差异)
- 最小交易价值:交易价值评分的最小值(如 0.05)
- 最小剩余时间:生成信号时的最小剩余时间(如 5 分钟,可选)
- 说明:只有当系统计算出某支队伍获胜概率明显高于对手,且交易价值达到阈值时,才会生成买入信号
- **交易规则配置**
- 买入规则:
- 买入条件:系统自动分析两支队伍,当某支队伍获胜概率明显高于对手且交易价值达到阈值时触发买入
- 买入金额:固定金额或按比例计算或动态计算(基于交易价值)
- 买入时机:立即买入或延迟买入
- 买入方向:由系统自动判断(YES 或 NO,基于获胜概率高的队伍)
- 说明:系统会综合分析两支队伍,选择获胜概率更高、交易价值更大的方向进行买入
- 卖出规则:
- 卖出条件:满足止盈、止损或概率反转条件时触发卖出
- 卖出金额:全部卖出或部分卖出
- 卖出时机:立即卖出或延迟卖出
- 价格策略:
- 固定价格:使用固定价格
- 市场价格:使用当前市场价格
- 动态价格:根据获胜概率和市场情况动态计算价格
- 价格偏移:允许的价格偏差范围(用于调整价格,提高交易成功率)
- **风险控制配置**
- 持仓限制:
- 最大持仓:单次最大买入金额(必填,>= 1 USDC
- 最小持仓:单次最小买入金额(必填,>= 1 USDC
- 每日限制:
- 每日亏损限制:每日最大亏损金额(可选)
- 每日订单限制:每日最大订单数量(可选)
- 价格容忍度:
- 价格偏差范围:允许的价格偏差百分比(可选,0-100%)
- **高级配置**
- 数据更新频率:NBA 数据更新频率(30 秒/1 分钟/5 分钟)
- 分析频率:量化分析执行频率(30 秒/1 分钟/5 分钟)
- 推送设置:
- 是否推送失败订单:默认关闭
- 推送频率:实时推送/批量推送
**交互流程**
1. 用户点击"创建策略"按钮
2. 进入策略配置页面
3. 填写策略基本信息
4. 配置触发条件(可添加多个条件)
5. 配置交易规则
6. 配置风险控制参数
7. 配置高级选项
8. 点击"保存"按钮,系统验证配置
9. 保存成功,返回策略列表
**验证规则**
- 策略名称不能为空,不能重复
- 必须选择关联账户
- 必须配置至少一个触发条件
- 必须配置买入或卖出规则
- 最大持仓必须 >= 最小持仓
- 价格容忍度必须在 0-100% 范围内
#### 2.1.2 策略编辑
**功能描述**
用户可以编辑已有策略的配置参数。
**功能点**
- 支持修改策略的所有配置参数
- 支持启用/禁用策略
- 修改后立即生效(如果策略正在运行,会重新加载配置)
**交互流程**
1. 用户在策略列表中点击"编辑"按钮
2. 进入策略编辑页面(与创建页面类似)
3. 修改配置参数
4. 点击"保存"按钮
5. 系统验证并保存配置
6. 返回策略列表
#### 2.1.3 策略删除
**功能描述**
用户可以删除不需要的策略。
**功能点**
- 删除前需要确认(防止误删)
- 删除策略时,会停止该策略的所有量化任务
- 删除后,该策略的历史信号记录会保留(用于统计分析)
**交互流程**
1. 用户在策略列表中点击"删除"按钮
2. 弹出确认对话框
3. 用户确认删除
4. 系统删除策略并停止相关任务
5. 返回策略列表
#### 2.1.4 策略列表
**功能描述**
展示所有策略配置,支持搜索、筛选、排序。
**功能点**
- **列表展示**
- 策略名称
- 关联账户
- 启用状态(启用/禁用)
- 创建时间
- 最后更新时间
- 操作按钮(编辑/删除/启用/禁用)
- **搜索功能**
- 按策略名称搜索
- 按账户名称搜索
- **筛选功能**
- 按启用状态筛选(全部/启用/禁用)
- 按账户筛选
- **排序功能**
- 按创建时间排序(最新/最旧)
- 按更新时间排序(最新/最旧)
- 按策略名称排序(A-Z/Z-A)
- **分页功能**
- 每页显示 20 条记录
- 支持翻页
**交互流程**
1. 用户进入策略列表页面
2. 系统加载策略列表
3. 用户可以搜索、筛选、排序
4. 用户可以点击操作按钮进行编辑、删除、启用/禁用
### 2.2 交易信号展示
#### 2.2.1 实时信号列表
**功能描述**
实时展示系统生成的交易信号。
**功能点**
- **信号展示**
- 信号类型(买入/卖出)
- 策略名称
- 市场信息(市场 ID、市场标题)
- 方向(YES/NO
- 价格
- 数量
- 触发原因
- 生成时间
- 信号状态(已生成/执行中/执行成功/执行失败)
- **实时更新**
- 通过 WebSocket 实时接收新信号
- 新信号自动添加到列表顶部
- 信号状态更新时实时刷新
- **筛选功能**
- 按信号类型筛选(全部/买入/卖出)
- 按策略筛选
- 按状态筛选(全部/已生成/执行中/成功/失败)
- 按时间范围筛选
- **排序功能**
- 按生成时间排序(最新/最旧)
- 按价格排序(高到低/低到高)
- **分页功能**
- 每页显示 50 条记录
- 支持翻页
**交互流程**
1. 用户进入交易信号页面
2. 系统建立 WebSocket 连接
3. 系统订阅交易信号推送
4. 实时接收并展示信号
5. 用户可以筛选、排序、查看详情
#### 2.2.2 信号详情
**功能描述**
展示信号的详细信息。
**功能点**
- **基本信息**
- 信号 ID
- 信号类型(买入/卖出)
- 策略名称
- 生成时间
- **市场信息**
- 市场 ID
- 市场标题
- 市场描述
- 市场分类
- **交易信息**
- 方向(YES/NO
- 价格
- 数量
- 总金额
- **触发信息**
- 触发原因(详细说明)
- 触发条件(展示满足的条件)
- 触发时的比赛数据(分差、剩余时间等)
- **执行信息**
- 执行状态
- 执行结果
- 执行时间
- 错误信息(如果执行失败)
**交互流程**
1. 用户在信号列表中点击某个信号
2. 弹出信号详情对话框
3. 展示信号的详细信息
4. 用户可以关闭对话框
### 2.3 结果统计
#### 2.3.1 策略统计
**功能描述**
展示每个策略的统计信息。
**功能点**
- **统计指标**
- 信号总数:该策略生成的信号总数
- 买入信号数:买入信号数量
- 卖出信号数:卖出信号数量
- 成功率:执行成功的信号占比
- 总盈亏:该策略的总盈亏金额
- 平均盈亏:平均每个信号的盈亏金额
- **时间维度**
- 今日统计
- 本周统计
- 本月统计
- 全部统计
- **可视化展示**
- 使用图表展示统计趋势
- 使用饼图展示信号类型分布
- 使用柱状图展示每日信号数量
**交互流程**
1. 用户进入策略统计页面
2. 系统加载策略列表和统计信息
3. 用户选择某个策略查看详细统计
4. 用户可以切换时间维度查看不同时期的统计
#### 2.3.2 总体统计
**功能描述**
展示所有策略的总体统计信息。
**功能点**
- **总体指标**
- 总策略数:启用的策略数量
- 总信号数:所有策略生成的信号总数
- 总成功率:所有信号的平均成功率
- 总盈亏:所有策略的总盈亏金额
- 平均盈亏:平均每个信号的盈亏金额
- **趋势分析**
- 信号数量趋势(按日/周/月)
- 成功率趋势(按日/周/月)
- 盈亏趋势(按日/周/月)
- **排行榜**
- 信号数量排行榜(按策略)
- 成功率排行榜(按策略)
- 盈亏排行榜(按策略)
**交互流程**
1. 用户进入总体统计页面
2. 系统加载总体统计信息
3. 用户可以查看趋势图表和排行榜
4. 用户可以切换时间维度查看不同时期的统计
### 2.4 NBA 数据展示(可选功能)
#### 2.4.1 比赛数据展示
**功能描述**
展示 NBA 比赛的实时数据。
**功能点**
- **比赛列表**
- 展示今日/本周/本月的比赛
- 显示比赛状态(未开始/进行中/已结束)
- 显示比分和分差
- **比赛详情**
- 比赛基本信息(日期、主客场、比分等)
- 实时比分和分差
- 当前节次和剩余时间
- Play-by-Play 数据
**交互流程**
1. 用户进入 NBA 数据页面
2. 系统加载比赛列表
3. 用户可以查看比赛详情
4. 实时更新比赛数据(如果比赛正在进行)
---
## 三、用户场景
### 3.1 场景一:创建量化策略
**用户**:量化交易爱好者
**场景描述**
用户希望创建一个量化策略,系统自动分析比赛的两支队伍,根据综合评分和获胜概率,选择可盈利的队伍进行买入。
**操作流程**
1. 用户登录系统,进入策略配置页面
2. 点击"创建策略"按钮
3. 填写策略名称:"综合实力分析策略"
4. 选择关联账户
5. 配置触发条件:
- 比赛筛选:选择关注的比赛(可按球队、日期筛选)
- 数据指标选择:选择用于分析的指标
- 基础指标:分差、剩余时间、当前节次
- 统计指标:球队净效率值、球员PER、真实命中率
- 高级指标:势头动量、球星爆发因子、阵容对位系数
- 触发阈值设置:
- 最小获胜概率差异:0.1(主队和客队获胜概率差异至少 10%)
- 最小交易价值:0.05(交易价值评分至少 0.05)
6. 配置买入规则:
- 买入条件:当系统计算出某支队伍获胜概率明显高于对手,且交易价值评分达到阈值时触发买入
- 买入金额:固定金额 10 USDC
- 买入方向:由系统自动判断(YES 或 NO,基于获胜概率)
- 价格策略:动态价格(基于获胜概率和市场情况)
7. 配置风险控制:
- 最大持仓:50 USDC
- 最小持仓:5 USDC
- 每日亏损限制:100 USDC
8. 点击"保存"按钮
9. 系统验证配置并保存
10. 策略创建成功,自动启用
**预期结果**
- 策略创建成功并启用
- 系统开始监控 NBA 比赛数据
- 当满足条件时,自动生成买入信号
### 3.2 场景二:查看交易信号
**用户**:量化交易爱好者
**场景描述**
用户希望实时查看系统生成的交易信号,了解策略的执行情况。
**操作流程**
1. 用户登录系统,进入交易信号页面
2. 系统自动建立 WebSocket 连接
3. 系统订阅交易信号推送
4. 实时接收并展示新信号
5. 用户可以查看信号详情:
- 点击某个信号,查看详细信息
- 查看触发原因和触发时的比赛数据
- 查看执行状态和结果
6. 用户可以筛选信号:
- 按策略筛选:只查看特定策略的信号
- 按类型筛选:只查看买入或卖出信号
- 按状态筛选:只查看成功或失败的信号
**预期结果**
- 实时接收并展示交易信号
- 可以查看信号的详细信息
- 可以按需筛选和排序信号
### 3.3 场景三:查看策略统计
**用户**:量化交易爱好者
**场景描述**
用户希望查看策略的执行效果,了解策略的盈亏情况。
**操作流程**
1. 用户登录系统,进入策略统计页面
2. 系统加载所有策略的统计信息
3. 用户选择某个策略查看详细统计:
- 查看信号总数、成功率、总盈亏等指标
- 查看信号数量趋势图
- 查看盈亏趋势图
4. 用户可以切换时间维度:
- 查看今日统计
- 查看本周统计
- 查看本月统计
- 查看全部统计
5. 用户可以查看总体统计:
- 查看所有策略的总体指标
- 查看趋势分析图表
- 查看排行榜
**预期结果**
- 清晰展示策略的执行效果
- 提供多维度的时间统计
- 帮助用户优化策略配置
---
## 四、界面设计说明
### 4.1 策略配置页面
#### 4.1.1 页面布局
**整体布局**
- 顶部:页面标题"创建策略"或"编辑策略"
- 左侧:配置导航(基本信息、触发条件、交易规则、风险控制、高级配置)
- 中间:配置表单区域
- 底部:操作按钮(保存、取消)
**配置表单**
- 使用分步骤表单,引导用户逐步配置
- 每个步骤有清晰的说明和示例
- 必填项用红色星号标记
- 提供实时验证和错误提示
#### 4.1.2 关键组件
**触发条件配置组件**
- 条件列表:展示已添加的触发条件
- 添加条件按钮:点击后弹出条件配置对话框
- 条件编辑:支持编辑和删除已有条件
- 条件组合:支持设置条件之间的逻辑关系(AND/OR)
**交易规则配置组件**
- 买入规则配置区域
- 卖出规则配置区域
- 价格策略选择(固定价格/动态价格)
- 价格偏移设置
**风险控制配置组件**
- 持仓限制输入框
- 每日限制输入框
- 价格容忍度滑块
### 4.2 交易信号页面
#### 4.2.1 页面布局
**整体布局**
- 顶部:筛选和搜索栏
- 中间:信号列表(表格形式)
- 底部:分页组件
**信号列表表格**
- 列:信号类型、策略名称、市场信息、方向、价格、数量、触发原因、生成时间、状态、操作
- 支持列排序
- 支持行点击查看详情
#### 4.2.2 关键组件
**实时信号提示**
- 新信号到达时,顶部显示提示消息
- 信号列表自动滚动到顶部
- 新信号高亮显示(3 秒后恢复正常)
**信号状态标签**
- 已生成:蓝色标签
- 执行中:黄色标签
- 执行成功:绿色标签
- 执行失败:红色标签
**信号详情对话框**
- 模态对话框形式
- 展示信号的完整信息
- 支持关闭和复制信息
### 4.3 统计页面
#### 4.3.1 页面布局
**整体布局**
- 顶部:时间维度选择(今日/本周/本月/全部)
- 左侧:策略列表(如果查看策略统计)
- 中间:统计图表和数据表格
- 右侧:关键指标卡片
**统计图表**
- 使用 ECharts 或类似图表库
- 支持交互(缩放、筛选等)
- 支持导出图片
#### 4.3.2 关键组件
**指标卡片**
- 大数字显示关键指标
- 支持对比(与上期对比)
- 支持趋势箭头(上升/下降)
**趋势图表**
- 折线图:展示趋势变化
- 柱状图:展示数量对比
- 饼图:展示分布情况
---
## 五、非功能性需求
### 5.1 性能需求
**响应时间**
- 页面加载时间:< 2 秒
- API 响应时间:< 1 秒
- WebSocket 推送延迟:< 100 毫秒
**并发性能**
- 支持至少 100 个并发用户
- 支持至少 1000 个策略同时运行
- 支持至少 10000 个信号/天的处理能力
### 5.2 可用性需求
**系统可用性**
- 系统可用性:>= 99.5%
- 故障恢复时间:< 5 分钟
**数据实时性**
- NBA 数据更新延迟:< 1 分钟
- 交易信号推送延迟:< 5 秒
### 5.3 安全性需求
**数据安全**
- 用户配置数据加密存储
- API 接口需要身份认证
- WebSocket 连接需要身份认证
**风险控制**
- 所有交易信号需要经过风险控制检查
- 异常情况自动告警
- 支持紧急停止策略
### 5.4 兼容性需求
**浏览器兼容**
- Chrome(最新 2 个版本)
- Firefox(最新 2 个版本)
- Safari(最新 2 个版本)
- Edge(最新 2 个版本)
**设备兼容**
- 桌面端:1920x1080 及以上分辨率
- 平板端:768x1024 及以上分辨率
- 移动端:375x667 及以上分辨率(响应式设计)
---
## 六、产品特性
### 6.1 核心特性
1. **简单易用**
- 直观的配置界面,降低使用门槛
- 清晰的步骤引导,帮助用户快速上手
- 丰富的帮助文档和示例
2. **实时响应**
- 实时获取 NBA 比赛数据
- 实时执行量化分析
- 实时推送交易信号
3. **数据驱动**
- 基于专业的 NBA 数据分析
- 支持多种数据指标和特征
- 提供数据可视化展示
4. **风险可控**
- 完善的风险控制参数
- 自动风险检查机制
- 异常情况告警
### 6.2 扩展特性
1. **策略模板**
- 提供预定义的策略模板
- 用户可以基于模板快速创建策略
- 支持模板的导入和导出
2. **回测功能**
- 支持历史数据回测
- 评估策略的历史表现
- 优化策略参数
3. **多账户支持**
- 支持多个交易账户
- 每个账户可以配置不同的策略
- 统一管理和监控
---
## 七、产品路线图
### 7.1 第一阶段:MVP 版本(2-3 个月)
**核心功能**
- 策略配置管理(创建、编辑、删除、列表)
- 基础量化分析(简单策略逻辑)
- 交易信号生成和推送
- 基础统计展示
**目标**
- 验证产品可行性
- 收集用户反馈
- 优化核心功能
### 7.2 第二阶段:功能完善(2-3 个月)
**新增功能**
- 完善量化分析逻辑(支持复杂策略)
- 完善特征提取和数据处理
- 完善统计和可视化
- 策略模板功能
**目标**
- 提升产品功能完整性
- 提升用户体验
- 扩大用户群体
### 7.3 第三阶段:高级功能(3-4 个月)
**新增功能**
- 回测功能
- 机器学习集成
- 多账户支持
- 高级统计分析
**目标**
- 提升产品竞争力
- 满足高级用户需求
- 建立产品壁垒
---
## 八、附录
### 8.1 术语表
- **量化策略**:基于数据分析和算法模型的交易策略
- **交易信号**:系统生成的买入或卖出建议
- **触发条件**:满足哪些条件时生成交易信号
- **风险控制**:限制交易风险的各种参数和机制
- **WebSocket**:一种实时通信协议,用于推送数据
### 8.2 参考文档
- NBA 比赛分析框架文档(NBA.md)
- 技术方案文档(nba-api-integration-technical-solution.md
- API 接口文档(待补充)
- 用户手册(待补充)
---
**文档结束**
@@ -0,0 +1,567 @@
# NBA 量化策略前端可配置参数清单
## 一、参数分类
前端可配置参数分为以下几类:
1. **策略基本信息**:策略名称、描述等
2. **比赛筛选参数**:选择关注的比赛
3. **触发条件参数**:控制何时生成交易信号
4. **交易规则参数**:控制如何执行交易
5. **风险控制参数**:限制交易风险
6. **算法权重参数**(高级):调整算法内部权重
7. **系统配置参数**:数据更新频率、推送设置等
---
## 二、详细参数列表
### 2.1 策略基本信息(必填)
| 参数名称 | 类型 | 必填 | 默认值 | 说明 | UI 组件 |
|---------|------|------|--------|------|---------|
| **策略名称** | String | ✅ | - | 用户自定义策略名称,1-50 字符 | Input |
| **策略描述** | String | ❌ | - | 策略的简要说明,最多 200 字符 | TextArea |
| **关联账户** | Long | ✅ | - | 选择用于交易的账户 | Select |
| **启用状态** | Boolean | ❌ | true | 是否启用该策略 | Switch |
---
### 2.2 比赛筛选参数(可选)
| 参数名称 | 类型 | 必填 | 默认值 | 说明 | UI 组件 |
|---------|------|------|--------|------|---------|
| **关注球队** | List\<String\> | ❌ | [] | 选择关注的球队(可选,不选择则分析所有比赛) | MultiSelect |
| **日期范围** | DateRange | ❌ | 今日 | 选择关注的日期范围 | DateRangePicker |
| **比赛重要性** | Enum | ❌ | 全部 | 选择比赛重要性(全部/常规赛/季后赛/关键战) | Select |
| **主客场筛选** | Enum | ❌ | 全部 | 筛选主队或客队(全部/仅主队/仅客队) | Select |
---
### 2.3 触发条件参数(必填)
#### 2.3.1 概率阈值参数
| 参数名称 | 类型 | 必填 | 默认值 | 取值范围 | 说明 | UI 组件 |
|---------|------|------|--------|----------|------|---------|
| **最小获胜概率差异** | Decimal | ✅ | 0.1 | 0.05 - 0.5 | 主队和客队获胜概率的最小差异(如 0.1 表示至少 10% 的差异) | InputNumber (0-1, 步长 0.01) |
| **最小获胜概率** | Decimal | ❌ | 0.55 | 0.5 - 1.0 | 生成买入信号时的最小获胜概率(可选,不设置则不限制) | InputNumber (0-1, 步长 0.01) |
| **最大获胜概率** | Decimal | ❌ | - | 0.0 - 0.5 | 生成买入信号时的最大获胜概率(可选,用于反向策略) | InputNumber (0-1, 步长 0.01) |
#### 2.3.2 交易价值参数
| 参数名称 | 类型 | 必填 | 默认值 | 取值范围 | 说明 | UI 组件 |
|---------|------|------|--------|----------|------|---------|
| **最小交易价值** | Decimal | ✅ | 0.05 | 0.0 - 1.0 | 交易价值评分的最小值,只有达到此值才会生成信号 | InputNumber (0-1, 步长 0.01) |
#### 2.3.3 时间条件参数
| 参数名称 | 类型 | 必填 | 默认值 | 取值范围 | 说明 | UI 组件 |
|---------|------|------|--------|----------|------|---------|
| **最小剩余时间** | Integer | ❌ | 5 | 0 - 48 | 生成信号时的最小剩余时间(分钟),0 表示不限制 | InputNumber (0-48) |
| **最大剩余时间** | Integer | ❌ | - | 0 - 48 | 生成信号时的最大剩余时间(分钟),可选 | InputNumber (0-48) |
| **比赛阶段限制** | Enum | ❌ | 全部 | 全部/仅比赛前/仅比赛中/仅比赛后 | 限制在哪个阶段生成信号 | Select |
#### 2.3.4 分差条件参数(可选)
| 参数名称 | 类型 | 必填 | 默认值 | 取值范围 | 说明 | UI 组件 |
|---------|------|------|--------|----------|------|---------|
| **最小分差** | Integer | ❌ | - | -50 - 50 | 生成信号时的最小分差(主队得分 - 客队得分),可选 | InputNumber |
| **最大分差** | Integer | ❌ | - | -50 - 50 | 生成信号时的最大分差,可选 | InputNumber |
| **分差范围** | Enum | ❌ | 全部 | 全部/小分差(<5分)/中分差(5-15分)/大分差(>15分) | 限制分差范围 | Select |
---
### 2.4 交易规则参数
#### 2.4.1 买入规则参数
| 参数名称 | 类型 | 必填 | 默认值 | 取值范围 | 说明 | UI 组件 |
|---------|------|------|--------|----------|------|---------|
| **买入金额策略** | Enum | ✅ | FIXED | FIXED/RATIO/DYNAMIC | 买入金额计算方式 | Select |
| **固定买入金额** | Decimal | 条件必填 | 10 | > 0 | 固定金额策略时的买入金额(USDC | InputNumber |
| **买入比例** | Decimal | 条件必填 | 0.1 | 0.01 - 1.0 | 按比例策略时的买入比例(账户余额的百分比) | InputNumber (0-1, 步长 0.01) |
| **基础买入金额** | Decimal | 条件必填 | 10 | > 0 | 动态计算策略时的基础金额(USDC | InputNumber |
| **买入时机** | Enum | ✅ | IMMEDIATE | IMMEDIATE/DELAYED | 立即买入或延迟买入 | Select |
| **延迟买入时间** | Integer | 条件必填 | 0 | 0 - 300 | 延迟买入的秒数(仅在延迟买入时生效) | InputNumber (0-300) |
| **买入方向** | Enum | ✅ | AUTO | AUTO/YES/NO | 买入方向:AUTO=系统自动判断,YES/NO=固定方向 | Select |
| **说明** | - | - | - | - | 系统会自动分析两支队伍,选择获胜概率更高、交易价值更大的方向 | Info |
#### 2.4.2 卖出规则参数
| 参数名称 | 类型 | 必填 | 默认值 | 取值范围 | 说明 | UI 组件 |
|---------|------|------|--------|----------|------|---------|
| **启用卖出** | Boolean | ✅ | true | - | 是否启用卖出功能 | Switch |
| **止盈阈值** | Decimal | ❌ | 0.2 | 0.0 - 1.0 | 预期收益达到多少时卖出(如 0.2 表示 20% | InputNumber (0-1, 步长 0.01) |
| **止损阈值** | Decimal | ❌ | -0.1 | -1.0 - 0.0 | 预期亏损达到多少时卖出(如 -0.1 表示 -10% | InputNumber (-1-0, 步长 0.01) |
| **概率反转阈值** | Decimal | ❌ | 0.15 | 0.0 - 1.0 | 获胜概率反转多少时卖出(如 0.15 表示 15% | InputNumber (0-1, 步长 0.01) |
| **卖出比例** | Decimal | ✅ | 1.0 | 0.1 - 1.0 | 卖出时的比例(1.0 表示全部卖出) | InputNumber (0-1, 步长 0.1) |
| **卖出时机** | Enum | ✅ | IMMEDIATE | IMMEDIATE/DELAYED | 立即卖出或延迟卖出 | Select |
| **延迟卖出时间** | Integer | 条件必填 | 0 | 0 - 300 | 延迟卖出的秒数(仅在延迟卖出时生效) | InputNumber (0-300) |
#### 2.4.3 价格策略参数
| 参数名称 | 类型 | 必填 | 默认值 | 取值范围 | 说明 | UI 组件 |
|---------|------|------|--------|----------|------|---------|
| **价格策略** | Enum | ✅ | MARKET | FIXED/MARKET/DYNAMIC | 价格计算方式 | Select |
| **固定价格** | Decimal | 条件必填 | - | 0.01 - 0.99 | 固定价格策略时的价格(仅在固定价格时生效) | InputNumber (0-1, 步长 0.01) |
| **价格偏移** | Decimal | ❌ | 0.0 | -0.1 - 0.1 | 价格偏移百分比(用于调整价格,提高交易成功率,如 0.05 表示 +5% | InputNumber (-0.1-0.1, 步长 0.01) |
---
### 2.5 风险控制参数
#### 2.5.1 持仓限制参数
| 参数名称 | 类型 | 必填 | 默认值 | 取值范围 | 说明 | UI 组件 |
|---------|------|------|--------|----------|------|---------|
| **最大持仓** | Decimal | ✅ | 50 | >= 1 | 单次最大买入金额(USDC | InputNumber (>= 1) |
| **最小持仓** | Decimal | ✅ | 5 | >= 1 | 单次最小买入金额(USDC | InputNumber (>= 1) |
| **单场比赛最大持仓** | Decimal | ❌ | - | >= 1 | 单场比赛的最大持仓金额(USDC),可选 | InputNumber (>= 1) |
#### 2.5.2 每日限制参数
| 参数名称 | 类型 | 必填 | 默认值 | 取值范围 | 说明 | UI 组件 |
|---------|------|------|--------|----------|------|---------|
| **每日亏损限制** | Decimal | ❌ | 100 | > 0 | 每日最大亏损金额(USDC),可选 | InputNumber (> 0) |
| **每日订单限制** | Integer | ❌ | 20 | > 0 | 每日最大订单数量,可选 | InputNumber (> 0) |
| **每日盈利目标** | Decimal | ❌ | - | > 0 | 每日盈利目标(USDC),达到后停止交易,可选 | InputNumber (> 0) |
#### 2.5.3 价格容忍度参数
| 参数名称 | 类型 | 必填 | 默认值 | 取值范围 | 说明 | UI 组件 |
|---------|------|------|--------|----------|------|---------|
| **价格容忍度** | Decimal | ❌ | 0.05 | 0.0 - 1.0 | 允许的价格偏差百分比(如 0.05 表示 5% | InputNumber (0-1, 步长 0.01) |
#### 2.5.4 概率置信度参数
| 参数名称 | 类型 | 必填 | 默认值 | 取值范围 | 说明 | UI 组件 |
|---------|------|------|--------|----------|------|---------|
| **最小概率阈值** | Decimal | ❌ | - | 0.5 - 1.0 | 最小获胜概率阈值(可选,不设置则不限制) | InputNumber (0.5-1.0, 步长 0.01) |
| **最大概率阈值** | Decimal | ❌ | - | 0.0 - 0.5 | 最大获胜概率阈值(可选,用于反向策略) | InputNumber (0-0.5, 步长 0.01) |
---
### 2.6 算法权重参数(高级,可选)
#### 2.6.1 综合评分权重参数
| 参数名称 | 类型 | 必填 | 默认值 | 取值范围 | 说明 | UI 组件 |
|---------|------|------|--------|----------|------|---------|
| **基础实力权重** | Decimal | ❌ | 0.3 | 0.0 - 1.0 | 基础实力评分在综合评分中的权重 | InputNumber (0-1, 步长 0.05) |
| **近期状态权重** | Decimal | ❌ | 0.25 | 0.0 - 1.0 | 近期状态评分在综合评分中的权重 | InputNumber (0-1, 步长 0.05) |
| **阵容完整度权重** | Decimal | ❌ | 0.2 | 0.0 - 1.0 | 阵容完整度评分在综合评分中的权重 | InputNumber (0-1, 步长 0.05) |
| **球星状态权重** | Decimal | ❌ | 0.15 | 0.0 - 1.0 | 球星状态评分在综合评分中的权重 | InputNumber (0-1, 步长 0.05) |
| **环境因素权重** | Decimal | ❌ | 0.1 | 0.0 - 1.0 | 环境因素评分在综合评分中的权重 | InputNumber (0-1, 步长 0.05) |
| **说明** | - | - | - | - | 所有权重之和应该等于 1.0,系统会自动归一化 | Info |
#### 2.6.2 对位分析权重参数
| 参数名称 | 类型 | 必填 | 默认值 | 取值范围 | 说明 | UI 组件 |
|---------|------|------|--------|----------|------|---------|
| **对位优势权重** | Decimal | ❌ | 0.2 | 0.0 - 1.0 | 对位优势在获胜概率调整中的权重 | InputNumber (0-1, 步长 0.05) |
#### 2.6.3 实时状态权重参数(仅比赛进行中)
| 参数名称 | 类型 | 必填 | 默认值 | 取值范围 | 说明 | UI 组件 |
|---------|------|------|--------|----------|------|---------|
| **分差调整权重** | Decimal | ❌ | 0.3 | 0.0 - 1.0 | 分差调整在实时概率调整中的权重 | InputNumber (0-1, 步长 0.05) |
| **势头调整权重** | Decimal | ❌ | 0.2 | 0.0 - 1.0 | 势头调整在实时概率调整中的权重 | InputNumber (0-1, 步长 0.05) |
---
### 2.7 系统配置参数
#### 2.7.1 数据更新参数
| 参数名称 | 类型 | 必填 | 默认值 | 取值范围 | 说明 | UI 组件 |
|---------|------|------|--------|----------|------|---------|
| **数据更新频率** | Integer | ✅ | 30 | 10/30/60/300 | NBA 数据更新频率(秒) | Select |
| **分析频率** | Integer | ✅ | 30 | 10/30/60/300 | 量化分析执行频率(秒) | Select |
#### 2.7.2 推送设置参数
| 参数名称 | 类型 | 必填 | 默认值 | 取值范围 | 说明 | UI 组件 |
|---------|------|------|--------|----------|------|---------|
| **推送失败订单** | Boolean | ❌ | false | - | 是否推送失败订单 | Switch |
| **推送频率** | Enum | ❌ | REALTIME | REALTIME/BATCH | 实时推送或批量推送 | Select |
| **批量推送间隔** | Integer | 条件必填 | 1 | 1 - 60 | 批量推送的时间间隔(秒,仅在批量推送时生效) | InputNumber (1-60) |
---
## 三、参数分组和页面布局建议
### 3.1 参数分组
**基础配置组**(第一页):
- 策略基本信息
- 比赛筛选参数
**触发条件组**(第二页):
- 概率阈值参数
- 交易价值参数
- 时间条件参数
- 分差条件参数
**交易规则组**(第三页):
- 买入规则参数
- 卖出规则参数
- 价格策略参数
**风险控制组**(第四页):
- 持仓限制参数
- 每日限制参数
- 价格容忍度参数
- 概率置信度参数
**高级配置组**(第五页,可折叠):
- 算法权重参数
- 系统配置参数
### 3.2 UI 组件建议
**表单布局**
- 使用分步骤表单(Stepper),引导用户逐步配置
- 每个步骤有清晰的标题和说明
- 必填项用红色星号标记
- 提供实时验证和错误提示
**参数展示**
- 使用折叠面板(Collapse)组织相关参数
- 高级参数默认折叠,用户可展开查看
- 提供参数说明和示例
- 提供参数推荐值(基于历史数据)
**交互优化**
- 条件显示:某些参数只在特定条件下显示(如固定价格只在价格策略=固定时显示)
- 联动验证:相关参数之间进行联动验证(如最大持仓 >= 最小持仓)
- 实时预览:显示参数配置后的预期效果(如预期信号数量、预期交易频率等)
---
## 四、参数验证规则
### 4.1 必填参数验证
- 策略名称:不能为空,1-50 字符,不能重复
- 关联账户:必须选择
- 最小获胜概率差异:必须 > 0
- 最小交易价值:必须 >= 0
- 最大持仓:必须 >= 1
- 最小持仓:必须 >= 1
- 数据更新频率:必须选择
- 分析频率:必须选择
### 4.2 数值范围验证
- 概率相关参数:必须在 [0, 1] 范围内
- 金额相关参数:必须 > 0
- 时间相关参数:必须在合理范围内(如 0-48 分钟)
- 权重相关参数:必须在 [0, 1] 范围内,且总和应该接近 1.0
### 4.3 逻辑关系验证
- 最大持仓 >= 最小持仓
- 最大剩余时间 >= 最小剩余时间(如果都设置了)
- 最大分差 >= 最小分差(如果都设置了)
- 最大概率阈值 <= 最小概率阈值(如果都设置了)
- 权重总和应该在 0.9 - 1.1 范围内(系统会自动归一化)
### 4.4 条件依赖验证
- 如果买入金额策略 = FIXED,则固定买入金额必填
- 如果买入金额策略 = RATIO,则买入比例必填
- 如果买入金额策略 = DYNAMIC,则基础买入金额必填
- 如果价格策略 = FIXED,则固定价格必填
- 如果买入时机 = DELAYED,则延迟买入时间必填
- 如果卖出时机 = DELAYED,则延迟卖出时间必填
- 如果推送频率 = BATCH,则批量推送间隔必填
---
## 五、参数默认值建议
### 5.1 新手推荐配置
**保守策略**(适合新手):
- 最小获胜概率差异:0.15(更严格的条件)
- 最小交易价值:0.08(更高的价值要求)
- 最大持仓:20 USDC(较小的持仓)
- 最小持仓:5 USDC
- 每日亏损限制:50 USDC
- 价格容忍度:0.03(3%,较严格)
**稳健策略**(推荐):
- 最小获胜概率差异:0.1(标准条件)
- 最小交易价值:0.05(标准价值)
- 最大持仓:50 USDC
- 最小持仓:5 USDC
- 每日亏损限制:100 USDC
- 价格容忍度:0.055%
**激进策略**(适合有经验的用户):
- 最小获胜概率差异:0.05(较宽松的条件)
- 最小交易价值:0.03(较低的价值要求)
- 最大持仓:100 USDC(较大的持仓)
- 最小持仓:10 USDC
- 每日亏损限制:200 USDC
- 价格容忍度:0.1(10%,较宽松)
### 5.2 参数模板
系统可以提供预定义的参数模板:
- **保守模板**:使用保守策略的默认值
- **稳健模板**:使用稳健策略的默认值
- **激进模板**:使用激进策略的默认值
- **自定义模板**:用户保存的自定义配置
### 5.3 基于历史数据的推荐值
#### 5.3.1 推荐值功能概述
系统可以根据历史数据分析和回测结果,为每个可配置参数提供智能推荐值,帮助用户快速配置策略,提高策略效果。
#### 5.3.2 推荐值计算方式
**方式一:基于历史回测的推荐值**
**计算流程**
1. 使用历史比赛数据(如过去 1-3 个赛季)
2. 对不同的参数组合进行回测
3. 评估每个参数组合的效果(准确率、盈亏、风险等)
4. 选择最优的参数组合作为推荐值
**评估指标**
- 信号准确率:预测获胜 vs 实际获胜的比例
- 总盈亏:累计盈亏金额
- 盈亏比率:盈利信号的平均盈亏 / 亏损信号的平均盈亏
- 最大回撤:最大连续亏损
- 夏普比率:风险调整后的收益
**推荐值选择**
- 选择综合评分最高的参数组合
- 综合评分 = 准确率 × 0.3 + 盈亏比率 × 0.3 + 夏普比率 × 0.2 + (1 - 最大回撤率) × 0.2
**方式二:基于用户策略的推荐值**
**计算方式**
- 分析用户已有策略的参数配置
- 统计表现最好的策略的参数分布
- 计算参数的平均值或中位数作为推荐值
**适用场景**
- 新用户:使用所有用户的最佳策略参数
- 老用户:使用该用户自己的最佳策略参数
**方式三:基于市场环境的推荐值**
**动态调整**
- 根据当前市场环境(波动性、流动性等)动态调整推荐值
- 市场波动大时:提高概率阈值,降低持仓
- 市场稳定时:降低概率阈值,提高持仓
**环境因素**
- 市场波动性:基于近期价格的波动率
- 市场流动性:基于订单深度和交易量
- 比赛重要性:季后赛 vs 常规赛
#### 5.3.3 推荐值展示方式
**在参数输入框中的展示**
- 在参数输入框旁边显示推荐值按钮(如"使用推荐值"图标)
- 点击后自动填充推荐值
- 推荐值用不同颜色标识(如蓝色高亮)
- 显示推荐值的来源标签(如"基于历史回测"、"基于您的策略"、"基于市场环境"
- 显示推荐值的置信度(如"高置信度"、"中置信度"、"低置信度"
**推荐值详细说明**
- 点击推荐值按钮后,弹出推荐值说明卡片
- 显示推荐值的计算依据
- 显示使用该推荐值的预期效果(如"预期准确率 65%"、"预期盈亏比 1.5"
- 显示推荐值的适用场景(如"适合保守策略"、"适合激进策略"
- 显示推荐值的验证数据(如"基于 500 场历史比赛验证")
**参数对比展示**
- 在参数配置页面底部显示"推荐值对比"面板
- 显示当前配置值 vs 推荐值的对比
- 显示预期效果对比(如准确率、盈亏等)
- 提供"应用所有推荐值"按钮,一键应用所有推荐值
- 提供"应用部分推荐值"选项,让用户选择应用哪些推荐值
#### 5.3.4 推荐值更新机制
**更新频率**
- 每周更新一次:基于最新的历史数据
- 每月深度更新:重新进行完整的回测分析
- 实时更新:根据市场环境变化实时调整(仅市场环境相关参数)
**触发更新**
- 新赛季开始:使用新赛季的数据重新计算
- 市场环境变化:检测到市场环境显著变化时更新
- 用户请求:用户手动触发更新
**更新通知**
- 在策略配置页面显示"推荐值已更新"提示
- 显示更新内容(哪些参数有变化)
- 提供"查看更新"按钮,查看详细的更新说明
- 提供"应用更新"按钮,一键应用更新的推荐值
#### 5.3.5 推荐值个性化
**基于用户偏好的推荐**
**偏好设置**
- 风险偏好:保守/稳健/激进
- 交易频率偏好:低频/中频/高频
- 持仓偏好:小持仓/中持仓/大持仓
**个性化推荐**
- 根据用户偏好筛选推荐值
- 优先推荐符合用户偏好的参数组合
- 提供多个推荐选项(保守推荐、稳健推荐、激进推荐)
**基于策略类型的推荐**
**策略类型**
- 保守策略:高概率阈值、小持仓、严格风险控制
- 稳健策略:中等概率阈值、中等持仓、标准风险控制
- 激进策略:低概率阈值、大持仓、宽松风险控制
**类型推荐**
- 用户选择策略类型后,自动推荐该类型的参数值
- 提供策略类型模板,一键应用
#### 5.3.6 推荐值验证和反馈
**推荐值验证**
**验证方式**
- 使用历史数据验证推荐值的有效性
- 计算推荐值的回测准确率
- 评估推荐值的风险水平
**验证结果展示**
- 显示推荐值的验证状态(如"已验证"、"待验证"
- 显示验证数据(如"基于 500 场历史比赛验证")
- 显示验证指标(如准确率、盈亏比等)
- 显示验证时间(如"2024-12-01 验证"
**用户反馈机制**
**反馈收集**
- 用户使用推荐值后,可以反馈效果
- 收集用户对推荐值的满意度(1-5 星)
- 收集用户的实际使用效果数据(准确率、盈亏等)
**反馈应用**
- 根据用户反馈调整推荐算法
- 优化推荐值的准确性
- 提高推荐值的适用性
#### 5.3.7 推荐值功能实现
**前端实现**
**UI 组件**
- 推荐值按钮:在参数输入框旁边显示推荐值图标按钮
- 推荐值卡片:点击后显示推荐值的详细说明
- 推荐值对比面板:显示当前值 vs 推荐值的对比
- 推荐值应用按钮:一键应用所有推荐值
**交互流程**
1. 用户进入策略配置页面
2. 系统异步加载推荐值(不阻塞页面加载)
3. 在参数输入框旁边显示推荐值按钮(如果该参数有推荐值)
4. 用户点击推荐值按钮,查看推荐值详情
5. 用户可以选择应用推荐值或手动调整
6. 用户可以在底部查看所有推荐值的对比
**后端实现**
**推荐值计算服务**
- 历史回测服务:执行历史数据回测
- 推荐值计算服务:计算推荐值
- 推荐值缓存服务:缓存推荐值,提高响应速度
**API 接口**
- 获取推荐值接口:根据参数类型和用户偏好返回推荐值
- 更新推荐值接口:触发推荐值更新
- 推荐值验证接口:验证推荐值的有效性
- 用户反馈接口:收集用户反馈
**数据存储**
**推荐值存储**
- 存储推荐值计算结果
- 存储推荐值的计算依据和验证结果
- 存储用户反馈数据
**缓存策略**
- 推荐值缓存 24 小时(每天更新一次)
- 市场环境变化时立即更新缓存
- 用户请求时优先使用缓存,异步更新
#### 5.3.8 推荐值参数列表
以下参数支持推荐值功能:
**触发条件参数**
- 最小获胜概率差异:基于历史回测的最优值
- 最小交易价值:基于历史回测的最优值
- 最小剩余时间:基于历史回测的最优值
**交易规则参数**
- 买入金额策略:基于用户策略的推荐
- 固定买入金额:基于用户策略的推荐
- 买入比例:基于用户策略的推荐
- 价格策略:基于市场环境的推荐
- 价格偏移:基于历史回测的最优值
**风险控制参数**
- 最大持仓:基于用户偏好和账户余额的推荐
- 最小持仓:基于用户偏好和账户余额的推荐
- 每日亏损限制:基于用户账户余额的推荐
- 每日订单限制:基于历史数据的推荐
- 价格容忍度:基于市场环境的推荐
**卖出规则参数**
- 止盈阈值:基于历史回测的最优值
- 止损阈值:基于历史回测的最优值
- 概率反转阈值:基于历史回测的最优值
**算法权重参数**(高级):
- 综合评分权重:基于历史回测的最优值
- 对位分析权重:基于历史回测的最优值
- 实时状态权重:基于历史回测的最优值
---
## 六、参数说明和帮助
### 6.1 参数说明
每个参数都应该有清晰的说明:
- **参数名称**:简洁明了的名称
- **参数说明**:详细说明参数的作用和影响
- **取值范围**:明确的范围和单位
- **推荐值**:基于历史数据的推荐值
- **示例**:具体的使用示例
### 6.2 帮助文档
提供以下帮助内容:
- **参数说明文档**:详细的参数说明文档
- **策略配置指南**:如何配置策略的指南
- **常见问题**:常见配置问题和解答
- **最佳实践**:配置策略的最佳实践
### 6.3 实时提示
在配置过程中提供实时提示:
- **参数影响提示**:说明参数调整对策略的影响
- **风险提示**:高风险配置的警告提示
- **优化建议**:基于当前配置的优化建议
---
**文档结束**

Some files were not shown because too many files have changed in this diff Show More