Compare commits

...

106 Commits

Author SHA1 Message Date
WrBug 83a415fb7e 优化代理检查结果展示与地域检测文案
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 00:54:22 +08:00
WrBug 727fa7ed58 fix: 支持部署时连接宿主机 MySQL
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 01:02:55 +08:00
WrBug 8ab8ae5205 style: 全局将 USDC 展示替换为 $ 符号
前端所有金额展示从 "100.00 USDC" 改为 "$100.00" 格式,
后端 Telegram 通知模板和错误提示同步更新。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 22:23:49 +08:00
WrBug 0769022878 feat: 添加 CLOB 2.0 迁移弹窗提醒与账户页迁移引导
登录后首次进入显示迁移提醒弹窗,引导用户前往账户页;
账户页首次进入时显示 Alert 提示 USDC.e → pUSD 迁移按钮;
移动端账户卡片补充 swap 操作按钮。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 21:59:15 +08:00
WrBug 06a122dd34 fix: 迁移链路外统一改为仅识别 pUSD
移除链上监听中对 USDC.e 的抵押品匹配,并将解包与审批相关说明统一为 pUSD 语义,避免在非 swap/wrap 流程继续使用 USDC.e。

Made-with: Cursor
2026-04-28 21:40:41 +08:00
WrBug fb6b469e9f fix: 修复 CLOB V2 下代理交易与链上成交解析问题
调整 CLOB API 地址并优化 Magic Proxy 的执行与 gas 限制,避免 wrap 与 relay 调用在 V2 环境下异常回滚。同时补充 pUSD/USDC.e 兼容与 ERC1155-only 成交价格回填逻辑,提升链上成交解析稳定性。

Made-with: Cursor
2026-04-28 21:34:43 +08:00
WrBug 22a6544373 feat: CLOB V1 → V2 完整迁移
- 更新 EIP-712 签名结构: domain version "1"→"2", 移除 taker/expiration/nonce/feeRateBps, 新增 timestamp/metadata/builder
- 更新合约地址为 V2 (CTF_EXCHANGE, NEG_RISK_EXCHANGE)
- CLOB URL 切换到 clob-v2.polymarket.com
- 抵押品代币 USDC.e → pUSD (0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB)
- 新增 USDC.e → pUSD wrap 功能 (CollateralOnramp 合约)
- SignedOrderObject 补充 API payload 必需的 taker/expiration 字段
- NewOrderRequest 补充 deferExec/postOnly 字段
- 前端账户管理新增 pUSD 迁移按钮
- 移除所有 feeRateBps 相关逻辑

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 22:24:50 +08:00
WrBug 04b7505094 Merge branch 'dev'
Made-with: Cursor
2026-03-30 12:55:20 +08:00
WrBug 2056417749 Merge pull request #41 from WrBug/ai_fix/n_39
fix: backtest equity curve bugs
2026-03-30 11:32:36 +08:00
WrBug 748c871af4 fix: backtest equity curve bugs (#39)
- Fix SELL logic: correctly reduce position.quantity after selling (core bug)
  Previously positions were never decremented, causing sold positions to be
  settled again at backtest end, inflating final balance.
- Fix settleRemainingPositions: profitLoss now correctly computes
  settlementValue - cost (was incorrectly using settlementValue.negate())
- Fix max drawdown calculation: use current balance instead of previous
  iteration's runningBalance
- Frontend: add comment noting chart shows cash balance, not total equity
2026-03-28 18:06:06 +08:00
WrBug 53f1381c3b chore: 移除 Bug 报告模板中手动添加 label 的文案
Made-with: Cursor
2026-03-10 12:53:06 +08:00
WrBug 4a3ebb674e chore: 移除 Bug 报告模板中的 AI 自动修复确认复选框
Made-with: Cursor
2026-03-10 12:46:41 +08:00
WrBug 2b570a432a fix(ci): 解析 Issue 编号时 grep 无匹配导致 step 退出
PR 描述中无 Closes/Fixes/Resolves #N 时,grep 返回 1,set -e 导致脚本
在未执行分支名回退逻辑前即退出。为管道添加 || true,使无匹配时继续
从分支名 ai_fix/N_xxx 解析 Issue 编号。

Made-with: Cursor
2026-03-10 12:44:52 +08:00
WrBug 5412a0eb49 Merge pull request #37 from WrBug/ai_fix/35_bug_1usdc
fix: #35 [Bug] / 价差策略按比例买入,购买的价值小于1usdc时,会下单失败
2026-03-10 12:40:12 +08:00
WrBug 7b4e702da9 ci(close-issue): add Telegram notification when AI-fixed issue is closed
- Get issue details (title, url) for TG message
- Send TG via TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID (same as docker-build)
- Use GH_TOKEN from GITHUB_TOKEN for gh CLI

Made-with: Cursor
2026-03-10 12:39:33 +08:00
WrBug e32697e7ee ci: add workflow to close issue when PR is merged
- Trigger on pull_request closed (merged) to main
- Parse issue number from PR body (Closes #N) or branch name (ai_fix/N_xxx)
- Close linked issue via GitHub API if still open

Made-with: Cursor
2026-03-10 12:34:35 +08:00
WrBug d8a75fc8dd fix: #35 [Bug] / 价差策略按比例买入,购买的价值小于1usdc时,会下单失败 2026-03-10 12:18:03 +08:00
WrBug 6cb48bb6cc Update AI bug report template to bilingual (English/Chinese) 2026-03-10 02:59:34 +08:00
WrBug e78785de7c Add AI bug report template 2026-03-10 02:59:33 +08:00
WrBug 915d4570df Update AI bug report template to bilingual (English/Chinese) 2026-03-10 02:39:50 +08:00
WrBug 48d6e82f43 Add AI bug report template 2026-03-10 02:30:26 +08:00
WrBug 46c32df421 fix(notification): 优化移动端消息设置页面交互体验
- 修复移动端 Tooltip 与点击事件冲突导致变量无法复制的问题
- 添加移动端备用复制方法(textarea + execCommand)
- 移除变量标签中的复制图标
- 移动端模板输入框调整为 15 行
- 调整系统设置菜单顺序

Made-with: Cursor
2026-03-02 23:32:26 +08:00
WrBug 686d14b6e5 fix(account): 卖出订单通知与创建订单失败错误展示
- 手动卖出订单通知:side 固定为 SELL,outcome 使用 request.side(市场方向)
- 创建订单失败时从 API errorBody 解析 error 字段(Gson),用于 TG 通知展示
- 成功/失败分支均使用 NewOrderResponse.getErrorMessage() 获取错误文案

Made-with: Cursor
2026-03-02 23:08:23 +08:00
WrBug 561ebf0ce3 feat(订单推送/通知): 按实际成交价与 size_matched 展示价格与数量
- 实际成交价公式: original_size * price / size_matched,数量展示用 size_matched
- OrderPushService/OrderStatusUpdateService/CryptoTail: 用公式计算 avgFilledPrice,移除 getTrades 依赖
- TelegramNotificationService: 有 avgFilledPrice 时展示数量用 filled,ORDER_SUCCESS/CRYPTO_TAIL 一致
- 前端订单推送: 有成交时展示 size_matched;无 orderDetail 时用 WebSocket 数据计算价格与数量
- OrderDetailDto 增加 avgFilledPrice;PolymarketClobService 移除 getAvgFilledPriceFromTradeIds

Made-with: Cursor
2026-03-02 23:05:15 +08:00
WrBug b8e10f340c feat(skills): 添加 create-release skill 支持自动发布公告
- 将 create-release.sh 脚本改造为 Cursor skill
- 支持生成中英文 Release 内容
- 自动在 Issue #1 发布面向用户的公告
- 支持 commit 过滤(排除版本内修复)

Made-with: Cursor
2026-03-02 22:16:28 +08:00
WrBug 9c70dc762f fix(frontend): 移除未使用变量与导入以消除 TS 报错
Made-with: Cursor
2026-03-02 22:07:04 +08:00
WrBug 8f0a49493c refactor(notification): 优化模板配置布局并完善多语言支持
- 布局优化:模板类型改为顶部Tabs,内容区70%+变量面板30%
- 变量Tag UI优化:更精致的样式和悬浮效果
- 多语言:添加模板类型描述、变量label/description的i18n key(三语言)
- 后端精简:移除DTO中冗余的label/description/templateTypeName字段
- 清理:移除未使用的templates状态和fetchTemplates方法

Made-with: Cursor
2026-03-02 22:02:00 +08:00
WrBug e7af4d4821 feat: 消息推送自定义模板与独立设置页
后端:
- 新增通知模板表与实体、DTO、Repository、NotificationTemplateService
- 支持 {{variable}} 模板语法,提供模板类型与变量接口
- TelegramNotificationService 改为通过模板渲染发送(ORDER_SUCCESS/ORDER_FAILED/ORDER_FILTERED/CRYPTO_TAIL_SUCCESS/REDEEM_SUCCESS/REDEEM_NO_RETURN)
- 模板 CRUD、重置默认、测试发送接口
- 补全订单过滤相关 i18n key(zh/en/zh-TW)

前端:
- 消息推送设置抽离为独立页 /system-settings/notification
- 系统设置概览改为入口卡片,侧栏增加「消息推送设置」菜单
- NotificationSettingsPage: 机器人配置 + 模板配置双卡片布局(与概览一致)
- 模板配置支持选择类型、编辑内容、变量面板(点击复制、悬停说明)、保存/重置/测试
- 多语言 key 补全(notificationSettings.templates.*、templateTypes.*)

Made-with: Cursor
2026-03-02 21:31:45 +08:00
WrBug 83bc209489 refactor(ci): simplify docker-build workflow and update TG notifications
- Remove package-only build type, unify to single build flow
- Add TG notification after package upload (before Docker build)
- Add TG notification after Docker image push
- Update notification messages to English
- Rename "Tag" to "Version" in notifications

Made-with: Cursor
2026-03-02 17:58:36 +08:00
WrBug 9b6cc63158 refactor(frontend): 优化仓位管理列表视图列结构
将12列精简为8列:
- 合并"数量"和"平均价格"为"持仓"列
- 合并"当前价格"、"当前价值"、"盈亏"、"已实现盈亏"为"当前价值/盈亏"列
- 减少列宽度,提升表格紧凑性

Made-with: Cursor
2026-03-02 17:54:03 +08:00
WrBug 96d224a5a3 fix(frontend): 移除 CryptoTailStrategyList 未使用的 useRef 导入
Made-with: Cursor
2026-03-02 17:44:10 +08:00
WrBug 97249db546 style(copy-trading): 跟单列表操作列改为图标按钮样式
- 操作列编辑、统计等改为 Tooltip+图标点击,与策略列表风格统一
- 操作列宽度 200 -> 160

Made-with: Cursor
2026-03-02 17:40:44 +08:00
WrBug 46e10ebdd8 feat(crypto-tail): 加密价差策略收益曲线与交互优化
- 后端: 收益曲线 API (pnl-curve)、CryptoTailStrategyService.getPnlCurve、gt 扩展导入
- 前端: CryptoTailPnlCurveModal 弹窗,统计卡片+时间筛选+ECharts 累计收益图
- 策略列表: 桌面/移动端「收益曲线」入口,图标改为蓝色(#1890ff)
- 切换时间范围保留旧数据避免图表容器卸载导致空白
- 今日/7天/30天用折线、全部用平滑曲线
- 多语言: viewPnlCurve、pnlCurve.* (zh-CN/zh-TW/en)

Made-with: Cursor
2026-03-02 17:39:23 +08:00
WrBug 4ebfacfc21 feat(frontend): 优化多个列表页面 UI,添加空状态提示
- 优化 AccountList、BacktestList、CopyTradingList、CryptoTailStrategyList、LeaderList、TemplateList、UserList 页面操作栏
- 添加空数据状态提示
- 添加相关多语言翻译 key

Made-with: Cursor
2026-03-01 06:48:51 +08:00
WrBug f86749e85d feat(frontend): 优化 Leader 列表 UI,使用图标操作栏
- 移动端:Cell 样式美化,渐变背景头部,资产常驻显示
- PC 端:操作列改用图标,合并跟单/回测数到图标 Badge
- 跟单/回测数量用 Badge 显示,数量为 0 时半透明禁用

Made-with: Cursor
2026-03-01 05:57:34 +08:00
WrBug 3cc37dac55 feat(backend): 优化通知推送,区分输赢并显示可用余额
- 赎回通知区分输赢:赢的仓位显示"赎回成功",输的仓位显示"已结算(无收益)"
- 买入/卖出/赎回通知消息中添加钱包可用余额显示
- 新增多语言文案支持

Made-with: Cursor
2026-03-01 05:41:04 +08:00
WrBug 5483a1aa77 feat(frontend): 优化加密价差监控页面体验
- 刷新页面时保留当前选择的策略(localStorage 缓存)
- 移动端下单弹窗改为 BottomSheet 样式,更紧凑
- 买入按钮实时显示当前价格,如 "买入 Up (0.50)"
- 周期切换时自动关闭弹窗并提示用户

Made-with: Cursor
2026-02-27 04:11:55 +08:00
WrBug e5acad5091 fix(frontend): 限制下单价格最高为 0.99
- 修改所有价格限制从 1 改为 0.99
- 影响位置:
  - handleOpenManualOrderModal: 默认价格计算
  - handlePriceChange: 手动修改价格
  - handleFetchLatestPrice: 获取最新价
  - handleSizeChange: 修改数量时重新计算价格
  - handleManualOrder: 提交订单时
- 确保价格不会超过 Polymarket 的最高限制 0.99

Made-with: Cursor
2026-02-26 21:36:59 +08:00
WrBug 1688fa9633 feat(frontend): 优化"最大"按钮计算,支持保留2位小数
- 修改前:只能整数数量,导致余额浪费
  - 示例:余额2.95U,价格0.86 → 数量3张(浪费0.37U)
- 修改后:保留2位小数,充分利用余额
  - 示例:余额2.95U,价格0.86 → 数量3.43张(利用全部余额)

Made-with: Cursor
2026-02-26 21:30:18 +08:00
WrBug 1967d97c31 fix(frontend): 修复 RATIO 模式下金额被错误截断的问题
- 移除 RATIO 模式下对 defaultAmountUsdc 的 Math.floor 取整
- 保持精确的金额用于计算数量,确保符合用户预期
- 修复示例:60%比例,余额2.95U,价格0.682
  - 修复前:数量 = 1.47(金额被截断为1U)
  - 修复后:数量 = 2.60(金额正确为1.77U)

Made-with: Cursor
2026-02-26 21:26:12 +08:00
WrBug 79b154515d feat(frontend): 手动下单功能增强与金额模式支持
- 后端返回 amountMode 和 amountValue 字段,支持前端获取策略配置
- 手动下单确认弹窗显示用户可用余额(绿色高亮)
- 价格输入框右侧添加"获取最新价"按钮,支持实时更新价格
- 数量输入框右侧添加"最大"按钮,根据余额自动计算最大购买数量
- 修正数量计算逻辑:FIXED 和 RATIO 模式均按实际金额计算数量,保留2位小数
  - FIXED 模式:使用配置的固定金额
  - RATIO 模式:按比例计算可用余额
- 数量计算使用保留小数方式(而非向上取整),确保精确匹配投入金额
  - 示例:固定1U,价格0.4,自动填充2.5张
  - 示例:比例30%,余额20U,价格0.4,自动填充15张

Made-with: Cursor
2026-02-26 21:19:06 +08:00
WrBug 9b4d8fc001 feat(frontend): 优化加密价差策略监控页面移动端体验
- 移动端策略选择器:标题与下拉框垂直布局,支持换行显示
- 下拉框选项支持文本换行,避免长策略名称被截断
- 移动端手动下单按钮改为底部悬浮固定显示
- 两个按钮均分屏幕宽度,符合移动端触摸操作规范

Made-with: Cursor
2026-02-26 20:50:46 +08:00
WrBug cb1f43871a fix(crypto-tail): 周期切换时按周期拉取 tokenIds,修复手动下单 orderbook 不存在
- 后端 initMonitor 支持可选 periodStartUnix,按指定周期取市场与 tokenIds
- 前端周期切换时调用 monitorInit 传入 pushPeriod,确保 tokenIds 与当前周期一致
- 修复切换周期无变化及手动下单报 orderbook does not exist

Made-with: Cursor
2026-02-26 20:43:27 +08:00
WrBug a2be5b7f52 feat(crypto-tail): 价差策略监控页手动下单与 UI 优化
- 后端:手动下单 API,trigger_type 区分 AUTO/MANUAL,价格 4 位小数向上取整
- 前端:监控页手动买入 Up/Down 按钮、二次确认弹窗、策略信息移动端适配
- 下单价格最多 4 位小数;确认页方向仅显示 Up/Down;按钮文案改为「买入 Up/Down」

Made-with: Cursor
2026-02-26 19:23:00 +08:00
WrBug 708d6ddb41 fix(binance): 修复 K 线 WebSocket 断开后无法重连导致 openPrice/closePrice 丢失的问题
当 WebSocket 因网络问题断开时,updateSubscriptions() 只检查订阅集合是否相同,
未检查连接是否真的存在,导致断开后无法重连。
现在会额外检查需要的连接是否都存在,如果缺失则重新建立连接。

Made-with: Cursor
2026-02-26 10:28:28 +08:00
WrBug 0fb015f6d3 Merge pull request #33 from WrBug/dev
Release v2.3.3: 加密价差策略与尾盘监控优化
2026-02-25 21:34:54 +08:00
WrBug c5d59dfdf9 refactor: 尾盘策略统一更名为加密价差策略
- 前端:菜单父级「加密价差策略」,子项「策略配置」「实时监控」;i18n 与类型注释全部替换
- 后端:i18n、ErrorCode、DTO/实体/服务注释与日志「尾盘」→「加密价差策略」;TG 通知文案
- 文档:README、中英文用户指南、UI 规格、任务、流程、价差流程、市场数据、动态系数等全部更新
- 不修改 API 路径、路由、表名与类名,保持兼容

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 21:30:50 +08:00
WrBug 4b277eaeab feat(frontend): 尾盘父级菜单与图标优化
- 尾盘策略、尾盘监控归入「尾盘」父级菜单,与跟单交易结构一致
- 菜单图标:父级 LineChart,尾盘策略 RocketOutlined,尾盘监控 DashboardOutlined
- 多语言:新增 menu.cryptoTail(尾盘/尾盤/Crypto Tail)
- 含 CryptoTailMonitor 及后端 DTO/Service 等修改

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 21:17:44 +08:00
WrBug f35bad78d4 feat(i18n): 系统更新功能多语言支持与缺失 key 补全
- 新增 systemUpdate 区块(zh-CN/zh-TW/en)覆盖系统更新页与版本提示
- zh-CN 补充 account.walletType
- SystemUpdate 页与 Layout 版本 tag 全部改为 useTranslation,移除硬编码中文

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 21:14:59 +08:00
WrBug 0740abcf16 fix(crypto-tail): 监控分时图推送节流与前端采样,避免1s内多条数据导致点过密
- 后端: price_change 推送节流,每策略 1s 内最多推送 1 次,其余靠定时 1.5s 推送补足
- 前端: 同周期内分时图追加点时至少间隔 1s 才追加,保证曲线连续且不过密
- 最新价等展示仍实时更新,仅图表序列做采样

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 20:01:29 +08:00
WrBug 377da4fff6 fix(cryptotail): 优化 WebSocket 订阅管理与日志清理
- 使用 createClient() 懒加载 OkHttpClient
- 修复订阅计数归零时未移除策略的问题
- 简化 handleMessage,移除 isFromCurrentPeriod 参数和 book 事件处理
- 清理调试日志,保留关键业务日志

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 19:27:49 +08:00
WrBug 84c79d8812 feat(cryptotail): 尾盘监控双连接与分时图优化
- 监控 WebSocket 拆分为当前周期连接与下一周期连接,周期切换时关闭过期连接并新建下一周期
- 下一周期市场未创建时也建立第二条空连接,保证始终两条连接
- refreshSubscription 增加 Mutex 防重入,避免周期结束时定时器与消息同时触发导致重复执行
- 修复 initMonitor/buildPushData 中 getCurrentOpenClose、spreadMode/spreadValue 等 API 与实体字段引用
- 移除重复的 buildSubscriptionMap、buildPushData 等方法,修复 StrategyPriceData.periodStartUnix
- 前端分时图:市场价折线增加 connectNulls,新周期默认 0.5 价格展示
- 多语言与监控页入口、API 类型与 WebSocket 订阅集成

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 17:00:41 +08:00
WrBug a0c2d7995b Merge pull request #32 from WrBug/dev
Release v2.3.2: 尾盘策略多市场与币安按需订阅
2026-02-20 23:50:10 +08:00
WrBug d3196a783f feat(cryptotail): 尾盘策略多市场与币安按需订阅
- 新增 ETH/SOL/XRP 市场选项(5m/15m),Binance 多币种 K 线支持
- 币安 K 线按策略按周期订阅:仅在有启用策略时建连,只订阅用到的市场+周期(如仅 btc 5m 只建 btc 5m)
- BinanceKlineAutoSpreadService 支持完整 slug(如 eth-updown-5m)解析
- 尾盘服务 PreDestroy 与内存/缓存清理(OrderbookWs、Notification、Settlement、Execution)
- 健康检查:无策略时币安 WS 显示「未订阅」;前端币安提醒仅在有启用策略时展示
- 前端:up/down 标签去掉箭头图标

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-20 23:45:24 +08:00
WrBug 4c989a48c4 fix(cryptotail): 防止 refreshAndSubscribe 并发重复调用导致日志刷屏
- 增加 refreshLock 与 isRefreshing 状态,串行化刷新逻辑
- 刷新进行中时直接跳过后续调用,避免 1s 内大量重复「倒计时」「已过时间窗口」日志

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-20 23:18:52 +08:00
WrBug 7c1f8df590 Merge pull request #31 from WrBug/dev
feat: 账户设置、尾盘策略与文档整理
2026-02-18 03:29:38 +08:00
WrBug 5bb46ebb97 docs: 尾盘策略文档归集至 docs/crypto-tail-strategy/,前端链接同步
- 将尾盘策略相关文档移至 docs/crypto-tail-strategy/{zh,en}/
- 新增 docs/crypto-tail-strategy/README.md 目录说明
- 前端配置指南链接改为 docs/crypto-tail-strategy/${lang}/crypto-tail-strategy-user-guide.md

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 03:25:30 +08:00
WrBug cbcebf6e28 chore: 账户设置与杂项修复
- BlockchainService: 新增 isProxyDeployed、getUsdcAllowance(账户设置检查/授权用)
- RetrofitFactory: 移除 RPC URL 替换 debug 日志
- AccountImportForm: 修复 checkSetupStatus/onSuccess 参数类型(使用 newAccount.id)
- AccountSetupStatusBlock: 移除未使用的 isMobile 与 useMediaQuery 引用

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 03:17:40 +08:00
WrBug 2013a2eb70 fix: 尾盘策略 book 取最高买价为 bestBid,移除 tokenToEntries null 日志
- book 事件 bids 为升序,改为遍历取最高价作为 bestBid(修复 marketPrice 恒为 0.01)
- 移除 onBestBid 中 tokenToEntries null 的 debug 日志

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 03:16:22 +08:00
WrBug de89175c8e feat: 账户设置后端 action、Safe 部署与设置引导弹窗复用
账户设置:
- 后端: execute-setup-step API(步骤1 Safe 一键部署/步骤2 启用交易/步骤3 代币授权)
- Safe 部署: Eip712Encoder SafeCreate/CreateProxy,RelayClientService.deploySafeViaBuilderRelayer
- BuilderRelayerApi: SignatureParams 增加 paymentToken/payment/paymentReceiver,TransactionRequest.nonce 可选
- 导入成功有未完成步骤时先弹设置引导再关导入弹窗;设置弹窗复用 AccountSetupStatusBlock(embedded)
- AccountSetupStatusBlock: 5s 轮询、embedded 模式、onAllCompleted、移除全部完成 Tag、授权信息始终展示且右对齐额度列
- 多语言: 步骤1/2/3 文案与 actionSuccess/actionFailed

CryptoTail:
- SpreadDirection/SpreadMode 枚举及 Converter,V38 迁移,策略列表与 api/types 调整

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 03:01:35 +08:00
WrBug 4e1bb0bbcf feat: 添加尾盘策略配置指南文档和前端链接
- 新增中文和英文配置指南文档(docs/zh|en/crypto-tail-strategy-user-guide.md)
- 在尾盘策略列表页面添加配置指南链接按钮
- 根据用户语言自动跳转到对应语言的 GitHub 文档预览
- 添加多语言翻译键(中文简体/繁体/英文)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 00:54:30 +08:00
WrBug 24a4487d0b fix(cryptotail): 尾盘策略失败订单原因移除 HTTP 状态码,便于移动端显示
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 04:33:27 +08:00
WrBug 24bb7bed40 feat(backend): 自动赎回 Builder Relayer 限流与配额处理
- RelayClientService: 429 限流时指数退避重试;解析 quota exceeded/resets in N seconds 并记录冷却时间;暴露 isBuilderRelayerQuotaBlocked 与 getBuilderRelayerQuotaBlockedRemainingSeconds
- PositionCheckService: checkRedeemablePositions 防重入;配额冷却期内跳过自动赎回

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 04:32:52 +08:00
WrBug 6b56f62532 chore(scripts): 添加 unwrap-wcol 脚本入口与 ethers 依赖
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 01:49:45 +08:00
WrBug c38529546f feat(system): Builder API Key 等配置前端显示完整值
- 后端 SystemConfigService 返回解密后的完整 builderApiKey/Secret/Passphrase,不再脱敏
- 移除未使用的 maskSensitiveValue 方法
- 更新 DTO 与前端类型注释为「完整显示」

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 01:47:52 +08:00
WrBug 08e1219f77 feat(neg-risk): WCOL 解包轮询与 Safe/Magic 一致说明
- 新增 WcolUnwrapJobService:每 20s 轮询解包 WCOL,未配置 Builder Key 时直接跳过
- AccountService:赎回流程不再等待确认与解包,由轮询统一处理;新增 runWcolUnwrapForAllAccounts
- BlockchainService:unwrapWcolForProxy 注释说明 Safe/Magic 逻辑一致;waitForTransactionConfirmed 避免 inline lambda 中 continue
- RelayClientService:createUnwrapWcolTx 注释说明共用逻辑;移除 Builder Relayer 调试日志
- docs/neg-risk-redeem.md:更新解包流程与 Safe/Magic 一致说明
- scripts/unwrap-wcol.js:新增脚本支持 Safe/EOA 解包与 Builder Relayer

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 01:46:55 +08:00
WrBug 62842f5aa8 Merge branch 'feature/crypto-market-tail-session-strategy' into dev 2026-02-16 00:12:38 +08:00
WrBug 13662dd0d5 fix(redeem): Neg Risk 市场使用 WrappedCollateral 赎回,并补充文档
- RelayClientService: 增加 negRiskWrappedCollateralAddress,createRedeemTx 支持 isNegRisk 选用抵押品
- BlockchainService: redeemPositions/redeemPositionsBatch 支持 isNegRisk,批量请求改为 (conditionId, indexSets, isNegRisk)
- AccountService: 赎回前按市场查询 getNegRiskByConditionId,批量与单笔均传入 isNegRisk
- docs/neg-risk-redeem.md: 新增 Neg Risk 赎回逻辑与对应 JS/TS 代码说明

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 00:11:40 +08:00
WrBug 07c3548401 Merge pull request #30 from WrBug/feature/crypto-market-tail-session-strategy
feat(crypto-tail): 加密市场尾盘策略完整实现与优化
2026-02-15 02:07:30 +08:00
WrBug 0f50939625 feat(尾盘策略): 触发记录分页与时间筛选、策略列表与表单优化
触发记录:
- 成功/失败分 Tab,失败展示失败原因
- 分页与按天时间范围筛选(日历,结束日不超过今天)
- 后端 startDate/endDate、Repository 时间范围查询
- 弹窗美化:时间范围卡片、空状态、分页总数;移除订单 ID 列
- 查看触发记录按钮文案改为「订单」

策略列表:
- 状态列恢复为 Switch,PC 端放在第一列
- 新增账户列;移除最近触发列
- 文案换行(wordBreak/whiteSpace);时间区间 PC 换行、移动端单行
- 时间区间列宽缩小;移动端 Switch 在底部操作区
- 编辑时账户不可修改;策略名称 placeholder 改为「选填,留空将自动生成」
- 多语言:account、viewTriggers、strategyNamePlaceholder 等

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-15 01:41:53 +08:00
WrBug a64d1edbf4 feat(crypto-tail): 尾盘策略页币安 API 状态监控与重连间隔调整
- 尾盘策略页:进入时检测币安 API/WebSocket,异常时红色强提醒
- 异常时提供「重新检测」按钮,不跳转 API 健康页、不做 60s 轮询
- 文案面向小白:无法连接币安 API、需行情数据、稍后重新检测
- 多语言:zh-CN/zh-TW/en 新增 cryptoTailStrategy.binanceApiAlert
- 币安 K 线 WS 重连延迟由 10s 改为 3s

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-15 01:09:34 +08:00
WrBug a62f1bd6e8 feat(cryptotail): 尾盘策略首次满足条件时打印日志
- 在价格与时间区间首次满足且本周期未触发时打印日志
- 日志包含:开盘价、收盘价、当前市场价格、方向、策略名
- 日志放在 passMinSpreadCheck 前
- 使用 Caffeine LRU 缓存(容量100)保证每周期只打印一次

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-15 00:58:14 +08:00
WrBug 63ce4107c6 fix(copytrading): 修复跟单 invalid signature 与 orderbook 不存在
- tokenId: Activity 解析时写入 trade.tokenId=asset,链上解析时写入 tokenId,优先使用避免与 CLOB 不一致
- Neg Risk: 从 Gamma 读取 negRisk,按市场选择 CTF Exchange / Neg Risk Exchange 签约
- 签名: OrderSigningService 支持 exchangeContract 参数,Credentials 路径与 v 提取与 EIP-712 一致
- 校验: 创建订单前校验 signer 与 account.walletAddress 一致
- TradeResponse/EventResponse/MarketResponse 增加 tokenId、negRisk 等字段
- MarketService 增加 getMarketInfoByTokenId、getNegRiskByConditionId

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-15 00:47:07 +08:00
WrBug a136d68e8c fix(cryptotail): 尾盘策略订单簿 WebSocket 订阅与连接管理优化
CryptoTailOrderbookWsService:
- 订阅更新时关闭 WS 后重连(移除不可靠的 unsubscribe)
- tokenIds 未变时不关闭 WS,仅更新倒计时与价差预计算
- 重连前检查是否有启用策略,避免创建无订阅的空连接
- 重连延迟改为 3 秒
- connect() 使用 synchronized 保证线程安全
- handleMessage/onBestBid 在 closedForNoStrategies 时提前返回
- tokenToEntries null 日志改为 DEBUG

CryptoTailStrategyService:
- delete() 发布 CryptoTailStrategyChangedEvent

CryptoTailStrategyExecutionService:
- 移除 AUTO 模式价差校验的 INFO 日志

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 22:50:04 +08:00
WrBug 61a5077d4d fix(crypto-tail): 多策略订阅时增加重试与跳过原因日志
- buildSubscriptionMap 中 fetchEventBySlug 失败时不再静默 continue,按原因打 warn/debug 日志
- 新增 fetchEventBySlugWithRetry,失败最多重试 3 次、间隔 1s,避免瞬时失败导致只订阅到其中一个策略

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 21:29:46 +08:00
WrBug bd529d9041 feat(cryptotail): 尾盘策略打印初始价差
- 周期开始预计算 AUTO 价差后打印 baseSpreadUp/baseSpreadDown
- 触发价差校验时打印初始价差、系数、有效最小价差、当前K线价差及是否通过

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 21:00:01 +08:00
WrBug 5cdcc487d4 feat(cryptotail): AUTO 最小价差 100%→50% 动态系数,progress 按毫秒计算
- BinanceKlineAutoSpreadService: 缓存 100% 基准价差,新增 getAutoMinSpreadBase
- CryptoTailStrategyExecutionService: 按窗口内毫秒进度算 coefficient,effectiveMinSpread = baseSpread × (1 - 0.5×progress)
- 新增方案文档 docs/crypto-tail-auto-spread-dynamic-coefficient.md

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 20:48:08 +08:00
WrBug ca2b1acbb9 fix(cryptotail): 尾盘策略下单取消重试并打印完整报错信息
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 20:34:27 +08:00
WrBug 77b681a40e chore: 跟单订单状态日志与尾盘 auto 系数调整
- OrderStatusUpdateService: 移除「检查 N 个30秒前创建的订单是否成交」debug 日志
- BinanceKlineAutoSpreadService: auto 模式最小价差系数由 80% 改为 70%
- CryptoTailStrategyDto: 同步更新自动最小价差注释

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 19:35:37 +08:00
WrBug c6f9e5db61 fix(cryptotail): 已结算的触发不再请求 getOrder,直接标记 notification_sent=true
- 轮询时若 trigger.resolved 为 true,跳过 CLOB getOrder 请求
- 将 notification_sent 设为 true 并保存,避免已结算订单无限轮询

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 18:09:25 +08:00
WrBug 2ec4ae3f98 refactor(cryptotail): 取消 FIXED 模式预签订单,改为触发时再签名
- 移除 PeriodContext.preSignedOrderByOutcome,周期开始仅预取参数(账户、解密、费率、CLOB 等)
- ensurePeriodContext 不再预签两个 outcome 的订单
- placeOrderForTrigger 统一 FIXED/RATIO 流程,均于触发时计算 size 并签名提交
- 每次触发仅对当前 outcome 签名一次,避免预签两张仅用一张的浪费

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 16:01:57 +08:00
WrBug 5074bb4f28 fix(order): 修复订单签名 salt 并发碰撞问题
- 使用 AtomicLong + 时间戳保证 generateSalt 在并发下唯一
- 避免尾盘策略 FIXED 模式预签双单等场景产生相同 salt 导致订单冲突

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 15:32:19 +08:00
WrBug 9fef4bea59 feat: 健康检查加币安 API/WS,订单簿 bids 空防护,自动价差历史取 20 根
- ApiHealthCheckService: 新增币安 API(ping)、币安 WebSocket(5m/15m 连接状态)
- BinanceKlineService: 连接状态追踪 getConnectionStatuses 供健康检查
- CryptoTailOrderbookWsService: book 事件 bids 为空时不再取 [0],避免 Index 0 out of bounds
- BinanceKlineAutoSpreadService + 文档 + i18n: 历史 K 线由 30 根改为 20 根

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 15:27:22 +08:00
WrBug b50e43c239 feat(crypto-tail): 策略最小价差(无/固定/自动) + 前端默认与文案
- 后端: 最小价差 DB/Entity/DTO、Binance K线 REST+WS、自动价差 IQR 预计算与执行时校验
- 前端: 最小价差(自动-固定-无),默认自动,label 旁 info 说明,选择自动不展示建议约
- i18n: minSpreadModeTip 说明不写死标的
- 文档: crypto-tail-strategy-min-spread-flow.md
- scripts: Binance K线拉取与 WS 示例

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 15:16:44 +08:00
WrBug 7ec9311df2 feat(cryptotail): 创建/修改策略时未填标题则自动生成并入库
- create: name 为空时生成「尾盘策略-{marketSlugPrefix}-{yyyyMMddHHmmss}」
- update: 未填且原无标题时同样生成并更新

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 13:13:55 +08:00
WrBug 1f6cf1ecaf feat(cryptotail): 尾盘 TG 改为轮询实现并修复自调用
- 新增 notification_sent 字段与 V36 迁移,轮询未发 TG 的 trigger
- 新增 CryptoTailOrderNotificationPollingService:每 5 秒轮询,CLOB getOrder 后发 TG,与跟单一致
- 通过 ApplicationContextAware + getSelf() 解决 @Transactional 自调用问题
- 删除 CryptoTailOrderNotificationSubscriber,移除 WS 推送方式
- Repository 新增 findByStatusAndOrderIdIsNotNullAndNotificationSentFalseOrderByCreatedAtAsc

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 13:11:21 +08:00
WrBug 9413507997 feat(cryptotail): 尾盘下单成功 TG 通知,通过 subscribe 订阅订单广播
- CryptoTailStrategyTriggerRepository 新增 findByOrderId 用于 WS 匹配
- TelegramNotificationService 新增 sendCryptoTailOrderSuccessNotification 及多语言
- 新增 CryptoTailOrderNotificationSubscriber,与跟单一致通过 OrderPushService.subscribeAllEnabled 订阅订单推送,匹配尾盘订单后发 TG
- i18n: notification.tail.order.success, notification.tail.strategy (zh-CN/zh-TW/en)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 07:57:52 +08:00
WrBug bf0e52fc01 chore(cryptotail): 移除 CryptoTailStrategyScheduler,runCycle 未实现且触发由 WS 负责
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 07:33:34 +08:00
WrBug c520eb7777 fix(cryptotail): 结算用 activity 取成交并优先 usdcSize 更新投入金额,尾盘调度与执行整理
- CryptoTailSettlementService: 实际成交从 Data API getUserActivity 获取,优先用 activity.usdcSize 更新 amountUsdc;仅匹配 type=TRADE,排除 REDEEM;先修正 triggerPrice/amountUsdc 再算 realizedPnl 并一次性写库
- CryptoTailStrategyExecutionService: 与结算/调度相关的整理与精简
- CryptoTailStrategyScheduler: 新增策略变更后触发一轮检查的调度

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 07:29:47 +08:00
WrBug d6d4b1200e fix(cryptotail): 下单成功后拉取实际成交价写库,结算拉取失败补日志
- 执行服务:createOrder 成功后 delay 800ms 再 getOrder 取实际 price/sizeMatched,
  用真实触发价与投入金额写 trigger 记录,表现从首条即正确;两处调用传入 L2 凭证
- 结算服务:fetchOrderFill 失败时打日志(orderId 空、账户/凭证、getOrder 失败等),
  便于排查表现未更新;注释说明表现依赖历史订单接口

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 07:09:01 +08:00
WrBug a1f3cfd3ff fix(account): 无跟单配置账户也执行自动赎回,支持尾盘策略仓位
- 移除 checkRedeemablePositions 中对 copyTradings.isEmpty() 的 continue
- 尾盘策略等仅用该账户、无跟单配置的账户可赎回仓位现会正常自动赎回
- 赎回成功后仍仅对有跟单配置的账户更新跟单订单状态

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 07:05:01 +08:00
WrBug 261dbdc23c feat(deploy): DOCKER_VERSION 支持从 .env 读取
版本号优先级:环境变量 > .env 中的 DOCKER_VERSION > 当前分支名
提示文案更新为可在 .env 或环境变量中设置

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 06:26:38 +08:00
WrBug 4c0d0afceb fix(frontend): 加密尾盘策略列表编辑与展示
- 编辑时按 marketSlugPrefix 取 interval,修复 15 分钟市场时间区间校验误报
- 投入方式:表格/卡片统一显示「投入方式: 比例/固定金额 + 数值」,兼容后端小写 amountMode
- 比例数值用 formatNumber(..., 2) 控制小数位,固定金额沿用 formatUSDC

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 06:24:12 +08:00
WrBug b79caceae6 fix: CLOB /time 用 ResponseBody 解析纯数字并支持 DOCKER_VERSION 环境变量
- PolymarketClobApi: getServerTime() 改为 Response<ResponseBody>,/time 返回纯时间戳非 JSON
- PolymarketApiKeyService: 抽取 fetchServerTimeOrNull() 复用,解析 body 为 Long
- deploy.sh: DOCKER_VERSION 支持环境变量,未设置时仍用分支名

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 06:11:22 +08:00
WrBug ed749fb606 feat(crypto-tail): 停用策略时关闭 WebSocket
- 无启用策略时关闭订单簿 WS 并取消重连(closedForNoStrategies 标志)
- 启动时若无启用策略则不建立连接
- 再次启用策略时 refreshAndSubscribe 会重新 connect 并订阅

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 05:47:05 +08:00
WrBug f200ba7f7c feat(crypto-tail): 触发记录与结算优化、前端收益与价格展示
后端:
- 结算轮询仅处理下单成功订单(status=success 且 orderId 非空)
- 实体通过 copy() 更新,不再直接改字段;结算时用订单 fill 回写 triggerPrice、amountUsdc
- Trigger 实体结算相关字段改为 val,统一 copy+save 更新

前端:
- 触发记录: 去掉市场列,保留触发价格(轮询后为实际成交价);每条收益列、涨跌色
- 策略列表: 总收益与胜率、涨跌色;价格区间格式 0.5 ~ 1
- 类型与 i18n: totalRealizedPnl/winRate/realizedPnl 等

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 05:44:19 +08:00
WrBug 0efd4a5fc3 feat(crypto-tail): 尾盘策略收益与胜率:轮询未结算订单并回写收益
- 新增 V35 迁移:trigger 表增加 condition_id/resolved/winner_outcome_index/realized_pnl/settled_at
- 新增 CryptoTailSettlementService:每 10s 轮询 success 未结算订单,Gamma+链上查结算,优先用 CLOB 订单实际成交价与成交量算收益
- 防重叠:与订单轮询一致使用 Job+CoroutineScope,上一轮未结束则跳过
- 策略/触发 DTO 暴露 totalRealizedPnl、胜率及单笔 resolved/realizedPnl

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 05:22:56 +08:00
WrBug 2238370088 feat(crypto-tail): 尾盘策略完整实现与优化
- 尾盘策略 CRUD、订单簿 WS 订阅、周期内触发下单
- 订单簿订阅日志增加市场 slug,便于排查
- 移除轮询,完全依赖 WebSocket(删除 CryptoTailStrategyScheduler)
- FIXED 模式数量改为小数、向上取整,与签名服务一致
- 前端策略列表页、多语言与 API 对接

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 05:01:10 +08:00
WrBug 364db124d1 Merge pull request #29 from WrBug/dev
Release v2.2.0: Magic 账户支持、按代理地址去重、导入与回测优化
2026-02-14 01:45:48 +08:00
WrBug f1ec0a330b feat(account): 按代理地址去重、默认账户名、前端重复提示与 DB 唯一约束
后端:
- 导入去重改为按 proxy_address(existsByProxyAddress),重复时返回 ACCOUNT_ALREADY_EXISTS(4601)
- 未填账户名时默认生成 SAFE/MAGIC-代理地址后4位(无中括号)
- Controller 识别 ACCOUNT_ALREADY_EXISTS 并返回对应错误码

前端:
- 导入失败时 message.error 提示;code=4601 时使用 accountImport.duplicateAccount 多语言
- store 抛出错误时附带 response.data.code 供表单判断

数据库 V33:
- 移除 wallet_address 唯一约束,新增 proxy_address 唯一约束
- 已存在账户的 wallet_type 统一更新为 safe

实体:
- Account.walletAddress 取消 unique,Account.proxyAddress 设为 unique

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 01:40:25 +08:00
WrBug 72de65d670 feat(frontend): 优化导入账户弹窗与文案
- 导入弹窗:增加步骤条、导入方式改为按钮、代理选项卡片紧凑展示
- 代理选项:标题改为「请选择账户类型」,地址完整显示,有资产标绿,小白说明放入卡片内
- 私钥/助记词:输入框默认两行、禁止换行(换行自动转空格/去除)
- 多语言:新增 proxyAddressHelp,更新 selectProxyOption 文案
- Modal 宽度与内边距微调;PositionList 移除未使用 useRef

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 01:23:29 +08:00
WrBug 3405a1cda3 feat: 钱包类型枚举与前端账号类型展示
后端:
- 新增 WalletType 枚举(MAGIC/SAFE),移除 safe/magic 字符串硬编码
- RelayClientService/BlockchainService/AccountService/OrderSigningService 使用枚举
- Builder Relayer API 类型使用常量 RELAYER_TYPE_PROXY/SAFE

前端:
- 账户列表、详情、导入 Modal 显示账号类型(Magic/Safe Tag)
- 导入账户 Modal 移除安全提示 Alert,移除 showAlert 与相关 i18n
- 移除无用 i18n key:securityTip、securityTipDesc、walletTypeMagic、walletTypeSafe、magicNotSupported
- 钱包类型 Tag 简化为仅显示 Magic 或 Safe

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 01:14:04 +08:00
WrBug fc6fa8b419 fix: 修复 Magic 账户赎回失败问题
主要修复:
1. 修复 PROXY ABI 编码问题:
   - 添加缺失的 tuple offset 字段(32字节)
   - 修正 data offset 从 192 改为 128(符合 ABI 规范)
   - 与 builder-relayer-client 保持一致

2. 添加 gas limit 动态估算:
   - 通过 eth_estimateGas 估算合理的 gas limit
   - 避免硬编码 10M 导致 Relay Hub gasleft() 检查失败
   - 估算失败时回退到默认值

3. 优化 API Key 检查逻辑:
   - 仅在涉及 Magic 账户时提前检查 Builder API Key
   - Safe 账户可在未配置 API Key 时使用手动交易

修复的问题:
- Magic 赎回交易失败:Not enough gasleft()
- PROXY 编码格式与官方不一致

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 00:59:39 +08:00
WrBug cf2c8a611c feat: 赎回批量执行与仓位页体验优化
- 赎回:同一账户多市场合并为一笔交易,减少 Relayer 调用次数
- 仓位页:可赎回统计静默刷新,赎回按钮不再常驻 loading
- 账户导入表单、API、多语言等相关改动

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 00:20:41 +08:00
WrBug bd323fca35 feat: 支持 Magic(邮箱/社交登录)账户导入和赎回
- 前端:启用 Magic 账户导入选项(移除 disabled 和"暂不支持"提示)
- 订单签名:根据钱包类型自动设置 signatureType(Magic=1, Safe=2)
- Builder Relayer API:添加 relay-payload 接口和 PROXY 请求类型支持
- RelayClientService:实现 Magic PROXY 赎回流程(Gasless,需 Builder API Key)
  - 获取 relay payload(relay 地址和 nonce)
  - 编码 ProxyFactory.proxy(calls) 调用数据
  - 创建并签名 PROXY 结构哈希
  - 提交 PROXY 类型交易到 Builder Relayer
- BlockchainService:redeemPositions 支持 walletType 参数
- AccountService:移除 Magic 账户赎回限制,传递 walletType 参数

Magic 账户赎回要求配置 Builder API Key(Gasless),Safe 账户保持原有逻辑不变。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 22:51:18 +08:00
WrBug a067a20a02 feat: 回测任务支持价格区间过滤
- 后端:
  - BacktestTask 实体类添加 minPrice 和 maxPrice 字段
  - BacktestDto 添加价格区间参数和配置字段
  - BacktestService 支持创建和查询价格区间配置
  - BacktestExecutionService 在 taskToCopyTrading 中设置价格区间
  - 添加数据库迁移脚本 V32__add_backtest_price_range_filter.sql

- 前端:
  - BacktestList 添加价格区间输入表单(最低价、最高价)
  - 详情页面显示价格区间配置
  - 一键创建跟单配置时包含价格区间
  - 添加中英繁三语言翻译
  - 修复价格输入框精度显示问题(0.4 不再显示为 0.40000000)

功能说明:
- 价格区间过滤与跟单配置保持一致,确保回测结果能准确反映实际跟单效果
- 支持只设置最低价、只设置最高价、或同时设置两者
- 留空表示不限制价格

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-11 23:54:49 +08:00
160 changed files with 23289 additions and 2725 deletions
+20 -1
View File
@@ -14,6 +14,10 @@
│ └── package.json
├── backend/ # 后端相关 skill
└── common/ # 通用 skill
└── create-release/ # 创建 GitHub Release
├── SKILL.md
└── scripts/
└── create-release.sh
```
- **SKILL.md**YAML frontmatter`name``description` 必填,`name` 须与父文件夹名一致、小写连字符)+ 给 Agent 的详细指令。
@@ -27,7 +31,8 @@
## 示例
- `frontend/check-i18n-keys/SKILL.md` + `frontend/check-i18n-keys/scripts/` — 检查前端多语言 key
- `frontend/check-i18n-keys/SKILL.md` + `frontend/check-i18n-keys/scripts/` — 检查前端多语言 key
- `common/create-release/SKILL.md` + `common/create-release/scripts/` — 创建 GitHub Release
## 运行 check-i18n-keys
@@ -37,3 +42,17 @@ npm install
npm run check-i18n
```
## 运行 create-release
```bash
cd .cursor/skills/common/create-release/scripts
./create-release.sh -t v1.0.0 -T "Release v1.0.0" -d "发布说明"
```
参数说明:
- `-t` 版本号(必需,格式 v1.0.0
- `-T` Release 标题
- `-d` Release 描述
- `-p` 标记为 Pre-release(自动加 -beta 后缀)
- `-y` 无交互模式
@@ -0,0 +1,341 @@
---
name: create-release
description: 创建 PolyHermes 项目的 GitHub Release。当用户要求发布版本、创建 release、打 tag 或发布新版本时使用。
---
# Create Release
创建 PolyHermes 项目的 GitHub Release,包括创建 Git tag、推送 tag、创建 GitHub Release(支持 pre-release),并自动在 Issue #1 发布公告。
## 使用时机
- 用户要求「发布版本」「创建 release」「打 tag」「发布新版本」时
- 用户要求「创建 pre-release」「beta 版本」时
- 用户提到「v1.x.x」等版本号相关操作时
## 前置条件
1. **GitHub CLI 已安装**:确保 `gh` 命令可用
2. **已登录 GitHub**:运行 `gh auth status` 确认
3. **工作目录干净**:建议先提交所有更改
## 指令
### 步骤 1:收集发布信息
询问用户以下信息:
- 版本号(格式:vX.Y.Z,如 v1.0.0
- 是否为 Pre-release(测试版本)
- Release 标题和描述(可选,如未提供则自动生成)
### 步骤 2:生成 Release 内容
**重要**:如果用户未提供描述,需要根据 Git commits 自动生成。
1. **获取上一个版本的 tag**
```bash
git describe --tags --abbrev=0 HEAD
```
2. **获取版本间的 commits**
```bash
git log <PREVIOUS_TAG>..HEAD --oneline --no-merges
```
3. **过滤 commit 规则**
- **排除**:版本内新增功能的修复 commit
- **判断方法**:如果一个 commit 的消息包含「fix」「修复」「bugfix」等关键词,且是针对同一版本内新增代码的修复,则不包含
- **保留**:新功能、性能优化、重构、文档更新等
4. **生成中英文 Release 内容**
- 格式要求:**中文在上,英文在下**
- 使用分隔线 `---` 分隔中英文部分
- 按功能类型分组(新功能、改进、修复等)
示例格式:
```markdown
## 新功能
- 添加了 A 功能
- 支持了 B 操作
## 改进
- 优化了 C 性能
---
## New Features
- Added feature A
- Supported operation B
## Improvements
- Optimized performance C
```
### 步骤 3:运行发布脚本
在项目根目录下执行:
```bash
cd .cursor/skills/common/create-release/scripts && \
chmod +x create-release.sh && \
./create-release.sh -t <VERSION> [-T "<TITLE>"] [-d "<DESCRIPTION>"] [-p] [-y]
```
### 步骤 4:发布公告到 Issue #1
**重要**:Release 创建成功后,必须自动在 Issue #1 下发布公告 comment。
1. **生成公告内容**(面向用户,通俗易懂):
公告格式模板:
```markdown
# 🎉 PolyHermes vX.X.X 版本发布公告
## 📅 发布日期
YYYY年MM月DD日
---
## ✨ 本次更新亮点
### 🚀 新功能
**功能名称**
- 用通俗的语言描述这个功能是什么
- 用户能从中获得什么好处
- 如何使用这个功能
### 🔧 改进优化
- 优化了 XXX,现在 XXX 更快/更稳定了
- 改进了 XXX 体验,操作更简单了
### 🐛 问题修复
- 修复了 XXX 问题,不再出现 XXX 情况
---
## 📦 如何更新
### Docker 部署(推荐)
```bash
# 拉取最新镜像
docker pull wrbug/polyhermes:vX.X.X
# 重启服务
docker-compose -f docker-compose.prod.yml down
docker-compose -f docker-compose.prod.yml up -d
```
---
## ⚠️ 安全提醒
**请务必使用官方 Docker 镜像源,避免财产损失!**
**官方镜像地址**`wrbug/polyhermes`
---
## 📚 相关链接
- **GitHub Release**: https://github.com/WrBug/PolyHermes/releases/tag/vX.X.X
- **Docker Hub**: https://hub.docker.com/r/wrbug/polyhermes
```
2. **公告内容编写原则**
- ✅ **通俗易懂**:避免技术术语,用用户能理解的语言
- ✅ **突出价值**:告诉用户这个更新对他们有什么好处
- ✅ **简洁明了**:每个功能点用 1-2 句话说明
- ✅ **包含操作指引**:告诉用户如何使用新功能
- ✅ **中英文双语**:中文在上,英文在下(可选)
- ❌ **避免**commit hash、代码细节、内部实现
3. **执行发布公告命令**
```bash
gh issue comment 1 --repo WrBug/PolyHermes --body "$(cat <<'EOF'
# 🎉 PolyHermes vX.X.X 版本发布公告
[公告内容...]
---
EOF
)"
```
或使用文件方式(内容较长时推荐):
```bash
echo "[公告内容...]" > /tmp/announcement.md
gh issue comment 1 --repo WrBug/PolyHermes --body-file /tmp/announcement.md
```
### 参数说明
| 参数 | 说明 | 示例 |
|------|------|------|
| `-t, --tag` | 版本号(必需) | `-t v1.0.0` |
| `-T, --title` | Release 标题 | `-T "Release v1.0.0"` |
| `-d, --description` | Release 描述 | `-d "## 新功能\n- 功能1"` |
| `-f, --description-file` | 从文件读取描述 | `-f CHANGELOG.md` |
| `-p, --prerelease` | 标记为 Pre-release(自动加 -beta 后缀) | `-p` |
| `-y, --yes` | 无交互模式 | `-y` |
## 版本号格式
- 必须格式:`v数字.数字.数字`(如 v1.0.0, v1.10.2, v1.1.12
- 如果指定 `--prerelease`,会自动拼接 `-beta` 后缀(如 v1.0.1 → v1.0.1-beta
## Release 内容生成规则
### 中英文格式
Release 描述**必须**使用中英文双语格式:
```markdown
## 中文标题
- 内容项1
- 内容项2
---
## English Title
- Item 1
- Item 2
```
### Commit 过滤规则
**需要排除的 commit 类型**
1. **版本内修复**:对同一版本新增功能的后续修复
- 例如:v1.0.1 新增了功能 A,然后有一个 commit 修复功能 A 的 bug → 不包含
- 判断依据:commit 消息包含「fix」「修复」「bugfix」且相关功能在本版本新增
2. **琐碎修改**
- typo 修正
- 代码格式调整
- 注释更新
**需要保留的 commit 类型**
1. 新功能(feat、feature
2. 改进/优化(improve、optimize、enhance
3. 重要 bug 修复(针对旧版本的 bug)
4. 重构(refactor
5. 文档更新(docs
### 分组建议
- **新功能 / New Features**
- **改进 / Improvements**
- **修复 / Bug Fixes**(仅包含对旧版本 bug 的修复)
- **其他 / Others**
## 公告内容示例
以下是一个面向用户的公告示例:
```markdown
# 🎉 PolyHermes v1.2.0 版本发布公告
## 📅 发布日期
2026年3月2日
---
## ✨ 本次更新亮点
### 🚀 新功能
**系统自动更新**
- 现在可以在网页上直接更新系统,无需手动重启 Docker
- 更新过程约 30-60 秒,系统会自动处理
- 如果更新失败,系统会自动恢复到旧版本
**RPC 节点管理**
- 可以在系统设置中添加、编辑、删除自定义 RPC 节点
- 可以随时启用或禁用节点
- 系统会自动选择可用的节点
### 🔧 改进优化
- **更快的跟单响应**:通过实时监听链上交易,跟单速度提升到秒级
- **更准确的盈亏统计**:系统会自动追踪实际成交价,统计数据更准确
- **内存占用优化**:修复了内存泄漏问题,系统可以长时间稳定运行
### 🐛 问题修复
- 修复了部分市场无法正确查询价格的问题
- 修复了卖出订单偶发失败的问题
---
## 📦 如何更新
### 方式一:网页更新(推荐)
1. 登录系统,进入 **系统设置** → **系统更新**
2. 点击 **检查更新**
3. 如果有新版本,点击 **立即升级**
4. 等待更新完成即可
### 方式二:Docker 更新
```bash
docker pull wrbug/polyhermes:v1.2.0
docker-compose -f docker-compose.prod.yml down
docker-compose -f docker-compose.prod.yml up -d
```
---
## ⚠️ 安全提醒
**请务必使用官方 Docker 镜像源!**
官方镜像:`wrbug/polyhermes`
---
## 📚 相关链接
- **GitHub Release**: https://github.com/WrBug/PolyHermes/releases/tag/v1.2.0
- **Docker Hub**: https://hub.docker.com/r/wrbug/polyhermes
```
## 发布流程
1. 验证版本号格式
2. 检查 Git 工作目录状态
3. 检查 tag 是否已存在
4. 生成 Release 内容(中英文)
5. 创建本地 tag
6. 推送 tag 到远程
7. 创建 GitHub Release
8. **生成面向用户的公告内容**
9. **发布公告到 Issue #1**
10. 返回 Release URL 和公告链接
## 注意事项
- Pre-release 版本不会触发 Telegram 通知
- GitHub Actions 会自动触发构建流程
- 如果 tag 已存在,会提示是否删除并重新创建
- **必须**在 Release 创建成功后发布公告到 Issue #1
## 可选目录说明
- `scripts/`:包含 `create-release.sh` 发布脚本
@@ -346,4 +346,3 @@ main() {
# 执行主函数
main "$@"
+201
View File
@@ -0,0 +1,201 @@
---
name: 🤖 Bug Report for AI Fix / AI Bug 报告
description: Bug 报告模板(提交后请手动添加 'fix via ai' 标签触发自动修复)
title: '[Bug] / '
assignees: []
body:
- type: markdown
attributes:
value: |
## Bug Report Template 🐛
本模板用于报告 PolyHermes 项目的 bug。
⚠️ **Important / 重要提示**
- After submission, if you need AI auto-fix, please manually add the `fix via ai` label
/ 提交后,如需 AI 自动修复,请手动添加 `fix via ai` 标签
- AI fixes will be on the `fix_issues_by_ai` branch / AI 修复将在 `fix_issues_by_ai` 分支上进行
- All AI fixes require human review before merging / 所有 AI 修复需要人工审核后才能合并
- Security vulnerabilities, database migrations, major changes are not recommended for AI auto-fix
/ 涉及安全漏洞、数据库迁移、重大变更等问题不建议使用 AI 自动修复
---
本模板用于报告 PolyHermes 项目的 bug。
This template is for reporting bugs in the PolyHermes project.
- type: textarea
id: description
attributes:
label: 📝 Bug Description / Bug 描述
description: Clearly and concisely describe the bug / 清晰简洁地描述这个 bug
placeholder: Describe the bug you encountered / 描述你遇到的问题...
validations:
required: true
- type: dropdown
id: type
attributes:
label: 🎯 Bug Type / 问题类型
description: Select the type of bug / 选择问题类型
options:
- Frontend bug (UI/UX/interaction) / 前端 bug (UI/UX/交互问题)
- Backend bug (API/logic/data) / 后端 bug (API/逻辑/数据处理)
- Database issue / 数据库问题
- Performance issue / 性能问题
- Configuration/Deployment / 配置/部署问题
- Documentation / 文档问题
- Other / 其他
validations:
required: true
- type: dropdown
id: scope
attributes:
label: 📍 Affected Scope / 影响范围
description: Select the scope of impact / 选择问题影响范围
options:
- Specific page/function only / 仅影响特定页面/功能
- Entire system / 影响整个系统
- Specific user role / 影响特定用户角色
- Only in specific environment / 仅在特定环境下重现
validations:
required: true
- type: textarea
id: steps
attributes:
label: 🔍 Steps to Reproduce / 复现步骤
description: Provide clear, detailed steps to reproduce the bug / 提供清晰、详细的步骤来重现这个 bug
placeholder: |
1. Visit page: `...` / 访问页面:`...`
2. Click button: `...` / 点击按钮:`...`
3. Input data: `...` / 输入数据:`...`
4. Submit form: `...` / 提交表单:`...`
5. Observe error: `...` / 观察到错误:`...`
validations:
required: true
- type: dropdown
id: frequency
attributes:
label: Reproduction Frequency / 复现频率
options:
- Always reproducible (100%) / 总是能复现 (100%)
- Frequently reproducible (50%+) / 经常能复现 (50%+)
- Occasionally reproducible (<50%) / 偶尔能复现 (<50%)
- Hard to reproduce / 很难复现
validations:
required: true
- type: textarea
id: expected
attributes:
label: 💻 Expected Behavior / 预期行为
description: Describe what you expected to happen / 描述你期望发生什么
placeholder: What should happen / 应该发生什么...
validations:
required: true
- type: textarea
id: actual
attributes:
label: ❌ Actual Behavior / 实际行为
description: Describe what actually happened / 描述实际发生了什么
placeholder: What actually happened / 实际发生了什么...
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: 📸 Screenshots / Recordings / 截图/录屏
description: If applicable, add screenshots or recordings to illustrate the problem (drag and drop files here)
/ 如果适用,添加截图或录屏来说明问题(可以拖拽文件到这里)
placeholder: Add screenshots or recordings / 添加截图或录屏...
- type: textarea
id: environment
attributes:
label: 🌐 Environment / 环境
description: Provide relevant environment information / 提供相关环境信息
value: |
**Browser (for frontend issues) / 浏览器(前端问题):**
- Browser: ______ / 浏览器:______
- Browser version: ______ / 浏览器版本:______
- Operating System: ______ / 操作系统:______
**Backend Environment (for backend issues) / 后端环境(后端问题):**
- Node.js version: ______ / Node.js 版本:______
- Database version: ______ / 数据库版本:______
- Docker version (if used): ______ / Docker 版本(如果使用):______
- Other relevant dependencies: ______ / 其他相关依赖版本:______
validations:
required: false
- type: textarea
id: related-files
attributes:
label: 📁 Related Files / Code / 相关文件/代码
description: Provide relevant file paths or code snippets / 提供可能涉及的文件路径或相关代码片段
placeholder: |
Possibly related files / 可能涉及的文件:
- frontend/src/components/...
- backend/src/main/kotlin/...
Error logs / 错误日志:
```
Paste error logs here / 粘贴错误日志
```
validations:
required: false
- type: textarea
id: suggestions
attributes:
label: 🎯 Fix Suggestions (Optional) / 修复建议(可选)
description: If you have fix ideas, describe them briefly / 如果你有修复思路,可以在这里简单描述
placeholder: |
Suggest adding ZZZ check in the YYY method of file XXX
/ 建议在 XXX 文件的 YYY 方法中,添加 ZZZ 检查
...
- type: dropdown
id: priority
attributes:
label: 🚨 Priority / 优先级
options:
- 🔴 High - Blocking core functionality, affects user experience / 高 - 阻塞核心功能,影响用户体验
- 🟡 Medium - Limited functionality but not blocking / 中 - 功能受限但不阻塞
- 🟢 Low - Minor issue, doesn't affect usage / 低 - 小问题,不影响使用
validations:
required: true
- type: textarea
id: additional
attributes:
label: 📝 Additional Information / 补充说明
description: Any other information that helps AI understand and fix the issue / 任何其他有助于 AI 理解和修复问题的信息
placeholder: |
- Was this bug introduced recently? / 这个 bug 是最近引入的吗?
- Is it related to a specific PR or commit? / 是否与某个特定的 PR 或 commit 相关?
- Does it only occur with specific datasets or users? / 是否只在特定数据集或特定用户情况下出现?
/ ...
Any other context / 其他任何上下文信息...
- type: checkboxes
id: ai-fix-approval
attributes:
label: 🤖 AI Auto-Fix Confirmation / AI 自动修复确认
description: If you need AI to auto-fix this issue, check the options below (remember to add 'fix via ai' label after submission)
/ 如需 AI 自动修复此 issue,请勾选下方选项(提交后记得添加 'fix via ai' 标签)
options:
- label: |
I understand the AI auto-fix workflow and agree to manually review the AI-created PR
/ 我已了解 AI 自动修复的工作流程,并同意在 AI 创建 PR 后进行人工审核
required: false
- label: |
This issue is suitable for AI auto-fix (not a security vulnerability, not a database migration, not a major change)
/ 此问题适合 AI 自动修复(非安全漏洞、非数据库迁移、非重大变更)
required: false
@@ -0,0 +1,127 @@
# PR 合并后自动关闭关联的 Issue
# 当 PR 从 ai_fix/N_xxx 分支合并到 main 时,关闭 #N 对应的 Issue(若 PR 描述中未含 Closes #N 则通过分支名解析)
# 关闭后发送 Telegram 通知(复用 TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID
name: Close issue on PR merge
on:
pull_request:
types: [closed]
branches: [main]
jobs:
close-issue:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: read
steps:
- name: Get PR info
id: pr
run: |
# 从 PR body 查找 Closes #N / Fixes #N(若已有则 GitHub 已自动关 issue,本 step 仅做解析)
BODY="${{ github.event.pull_request.body }}"
HEAD_REF="${{ github.event.pull_request.head.ref }}"
# 优先从 PR 描述解析(无匹配时 grep 会 exit 1,需 || true 避免 set -e 导致脚本退出)
ISSUE_NUM=$(echo "$BODY" | grep -oE '(Closes|Fixes|Resolves) #([0-9]+)' | head -1 | grep -oE '[0-9]+' || true)
if [ -z "$ISSUE_NUM" ]; then
# 从分支名解析 ai_fix/N_xxx
ISSUE_NUM=$(echo "$HEAD_REF" | sed -n 's|^ai_fix/\([0-9]*\)_.*|\1|p')
fi
if [ -z "$ISSUE_NUM" ]; then
echo "ISSUE_NUMBER=" >> $GITHUB_OUTPUT
echo "skip=true" >> $GITHUB_OUTPUT
echo "未从 PR 描述或分支名解析到 Issue 编号,跳过关闭"
exit 0
fi
echo "ISSUE_NUMBER=$ISSUE_NUM" >> $GITHUB_OUTPUT
echo "skip=false" >> $GITHUB_OUTPUT
echo "解析到 Issue #$ISSUE_NUM"
- name: Get issue details
if: steps.pr.outputs.skip != 'true'
id: issue
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
ISSUE_NUM="${{ steps.pr.outputs.ISSUE_NUMBER }}"
# 获取 issue 标题与 URL(用于 TG 消息)
JSON=$(gh issue view "$ISSUE_NUM" --json title,url 2>/dev/null || echo '{"title":"","url":""}')
TITLE=$(echo "$JSON" | jq -r '.title')
ISSUE_URL=$(echo "$JSON" | jq -r '.url')
echo "title<<EOF" >> $GITHUB_OUTPUT
echo "$TITLE" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
echo "url=$ISSUE_URL" >> $GITHUB_OUTPUT
- name: Close issue
if: steps.pr.outputs.skip != 'true'
uses: actions/github-script@v7
with:
script: |
const issueNumber = parseInt('${{ steps.pr.outputs.ISSUE_NUMBER }}', 10);
if (!issueNumber || isNaN(issueNumber)) {
console.log('No valid issue number, skip');
return;
}
const { data: issue } = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber
});
if (issue.state === 'open') {
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
state: 'closed'
});
console.log(`Issue #${issueNumber} closed.`);
} else {
console.log(`Issue #${issueNumber} already closed.`);
}
- name: Send Telegram notification
if: steps.pr.outputs.skip != 'true'
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
run: |
# 与 docker-build 一致:未配置则跳过
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
echo "⚠️ Telegram Bot Token 或 Chat ID 未配置,跳过通知"
exit 0
fi
ISSUE_NUM="${{ steps.pr.outputs.ISSUE_NUMBER }}"
ISSUE_TITLE="${{ steps.issue.outputs.title }}"
ISSUE_URL="${{ steps.issue.outputs.url }}"
PR_URL="${{ github.event.pull_request.html_url }}"
# 与 docker-build 相同的 HTML 消息格式
MESSAGE="✅ <b>AI 修复的 Issue 已关闭</b>"$'\n'$'\n'"🔢 <b>Issue:</b> #${ISSUE_NUM} ${ISSUE_TITLE}"$'\n'"📎 <a href=\"${ISSUE_URL}\">查看 Issue</a>"$'\n'"🔗 <a href=\"${PR_URL}\">查看 PR</a>"
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 0
fi
else
echo "❌ 发送 Telegram 消息时发生错误"
exit 0
fi
+53 -98
View File
@@ -6,14 +6,6 @@ on:
- published # 当通过 GitHub Releases 页面创建 release 时触发
workflow_dispatch:
inputs:
build_type:
description: '构建类型'
required: true
type: choice
options:
- package-only # 只打包产物
- package-and-docker # 打包产物 + Docker 镜像
default: 'package-and-docker'
version:
description: '版本号(例如: v1.0.0'
required: false
@@ -36,22 +28,6 @@ jobs:
with:
ref: ${{ github.event.release.tag_name || github.event.inputs.tag_name || github.event.inputs.version || github.ref }}
- name: Determine build type
id: build_config
run: |
# 确定构建类型
if [ "${{ github.event_name }}" = "release" ]; then
# Release 事件:默认只打包产物(不构建 Docker)
BUILD_TYPE="package-only"
echo "📦 Release 事件:将只打包产物(不构建 Docker)"
else
# workflow_dispatch 事件:使用用户输入
BUILD_TYPE="${{ github.event.inputs.build_type }}"
echo "🔧 手动触发:构建类型 = ${BUILD_TYPE}"
fi
echo "BUILD_TYPE=${BUILD_TYPE}" >> $GITHUB_OUTPUT
- name: Extract version and check if pre-release
id: extract_version
run: |
@@ -100,53 +76,6 @@ jobs:
echo "📦 这是正式版本: $TAG_NAME"
fi
- name: Send Telegram notification (build started)
if: steps.extract_version.outputs.IS_PRERELEASE == 'false' && steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
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
# 获取构建信息
TAG="${{ steps.extract_version.outputs.TAG }}"
if [ "${{ github.event_name }}" = "release" ]; then
RELEASE_URL="${{ github.event.release.html_url }}"
MESSAGE="🔨 <b>Release 构建中</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"
else
WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
MESSAGE="🔨 <b>构建中</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">查看 Workflow</a>"
fi
# 发送 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: Setup JDK 17
uses: actions/setup-java@v4
@@ -263,22 +192,66 @@ jobs:
checksums.txt
retention-days: 30
# ============ 发送产物上传成功通知 ============
- name: Send Telegram notification (package uploaded)
if: steps.extract_version.outputs.IS_PRERELEASE == 'false'
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
# 获取构建信息
TAG="${{ steps.extract_version.outputs.TAG }}"
if [ "${{ github.event_name }}" = "release" ]; then
RELEASE_URL="${{ github.event.release.html_url }}"
MESSAGE="✅ <b>PolyHermes Package Built</b>"$'\n'$'\n'"🏷️ <b>Version:</b> <code>${TAG}</code>"$'\n'"🔧 <b>Type:</b> Online Update"$'\n'"🔗 <a href=\"${RELEASE_URL}\">View Release</a>"$'\n'"📍 <b>Update Path:</b> System Management → Overview → Check for Updates"$'\n'$'\n'"🐳 Building Docker image..."
else
WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
MESSAGE="✅ <b>PolyHermes Package Built</b>"$'\n'$'\n'"🏷️ <b>Version:</b> <code>${TAG}</code>"$'\n'"🔧 <b>Type:</b> Online Update"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">View Workflow</a>"$'\n'$'\n'"🐳 Building Docker image..."
fi
# 发送 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 0
fi
else
echo "❌ 发送 Telegram 消息时发生错误"
exit 0
fi
# ============ Docker 构建 ============
- name: Set up Docker Buildx
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
uses: docker/setup-buildx-action@v3
with:
# 启用多架构构建支持
platforms: linux/amd64,linux/arm64
- name: Log in to Docker Hub
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Prepare Docker build context
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
run: |
echo "📦 准备 Docker 构建上下文..."
# 确保构建产物存在且可访问
@@ -295,7 +268,7 @@ jobs:
ls -lh backend/build/libs/*.jar
- name: Build and push Docker image
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
id: docker_build
uses: docker/build-push-action@v5
with:
context: .
@@ -314,13 +287,8 @@ jobs:
cache-from: type=registry,ref=wrbug/polyhermes:latest
cache-to: type=inline
- name: Skip Docker build notice
if: steps.build_config.outputs.BUILD_TYPE == 'package-only'
run: |
echo "⏭️ 跳过 Docker 镜像构建(构建类型:package-only"
echo "✅ 仅打包产物已完成"
- name: Send Telegram notification
# ============ 发送 Docker 构建成功通知 ============
- name: Send Telegram notification (Docker build completed)
if: steps.extract_version.outputs.IS_PRERELEASE == 'false'
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
@@ -335,25 +303,14 @@ jobs:
# 获取构建信息
VERSION="${{ steps.extract_version.outputs.VERSION }}"
TAG="${{ steps.extract_version.outputs.TAG }}"
BUILD_TYPE="${{ steps.build_config.outputs.BUILD_TYPE }}"
# 构建消息内容(仅包含关键信息)
DEPLOY_DOC_URL="https://github.com/WrBug/PolyHermes/blob/main/docs/zh/DEPLOYMENT.md"
if [ "${{ github.event_name }}" = "release" ]; then
RELEASE_URL="${{ github.event.release.html_url }}"
if [ "$BUILD_TYPE" = "package-and-docker" ]; then
MESSAGE="✅ <b>Release 构建成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker 部署文档</a>"
else
MESSAGE="✅ <b>Release 打包成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> 在线升级"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"$'\n'"📍 <b>升级路径:</b> 系统管理 → 概览 → 检查更新"
fi
MESSAGE="🐳 <b>Docker Image Built Successfully</b>"$'\n'$'\n'"🏷️ <b>Version:</b> <code>${TAG}</code>"$'\n'"📦 <b>Image:</b> <code>wrbug/polyhermes:${TAG}</code>"$'\n'"🔗 <a href=\"${RELEASE_URL}\">View Release</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker Deployment Guide</a>"
else
WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
if [ "$BUILD_TYPE" = "package-and-docker" ]; then
MESSAGE="✅ <b>构建成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">查看 Workflow</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker 部署文档</a>"
else
MESSAGE="✅ <b>打包成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> 在线升级"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">查看 Workflow</a>"$'\n'"📍 <b>升级路径:</b> 系统管理 → 概览 → 检查更新"
fi
MESSAGE="🐳 <b>Docker Image Built Successfully</b>"$'\n'$'\n'"🏷️ <b>Version:</b> <code>${TAG}</code>"$'\n'"📦 <b>Image:</b> <code>wrbug/polyhermes:${TAG}</code>"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">View Workflow</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker Deployment Guide</a>"
fi
# 发送 Telegram 消息(使用 jq 转义 JSON
@@ -371,11 +328,9 @@ jobs:
echo "✅ Telegram 通知发送成功"
else
echo "❌ Telegram 通知发送失败: $RESPONSE"
# 构建成功,通知失败不应该导致整个 job 失败
exit 0
fi
else
echo "❌ 发送 Telegram 消息时发生错误"
# 构建成功,通知失败不应该导致整个 job 失败
exit 0
fi
fi
+3 -2
View File
@@ -19,7 +19,7 @@ backend/gradle-app.setting
backend/.gradle
# 注意:gradle-wrapper.jar 应该被提交,不要忽略
# backend/gradle/wrapper/gradle-wrapper.jar
polyhub/
# Kotlin
*.kt.bak
*.class
@@ -112,4 +112,5 @@ __pycache__/
clob-client/
builder-relayer-client/
landing-page/
clob-client-v2/
settings.local.json
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,26 @@
package com.wrbug.polymarketbot.api
import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Query
/**
* 币安现货公开 API(K 线等)
* Base URL: https://api.binance.com
* 文档: https://developers.binance.com/docs/binance-spot-api-docs/rest-api
*/
interface BinanceApi {
/**
* K 线数据
* 返回每根 K 线: [openTime, open, high, low, close, volume, closeTime, ...]
*/
@GET("/api/v3/klines")
fun getKlines(
@Query("symbol") symbol: String,
@Query("interval") interval: String,
@Query("limit") limit: Int = 30,
@Query("startTime") startTime: Long? = null,
@Query("endTime") endTime: Long? = null
): Call<List<List<Any>>>
}
@@ -41,6 +41,17 @@ interface BuilderRelayerApi {
@Query("address") address: String,
@Query("type") type: String
): Response<NoncePayload>
/**
* 获取 Relay PayloadPROXY 类型执行时使用)
* GET /relay-payload?address={address}&type=PROXY
* 参考: builder-relayer-client endpoints GET_RELAY_PAYLOAD
*/
@GET("/relay-payload")
suspend fun getRelayPayload(
@Query("address") address: String,
@Query("type") type: String
): Response<RelayPayload>
/**
* 获取交易状态
@@ -81,7 +92,7 @@ interface BuilderRelayerApi {
val data: String, // 调用数据(十六进制字符串,带 0x 前缀)
@SerializedName("nonce")
val nonce: String, // Safe nonce字符串
val nonce: String? = null, // Safe nonceSAFE 必填,SAFE-CREATE 不传
@SerializedName("signature")
val signature: String, // Safe 签名(packed signature,十六进制字符串,带 0x 前缀)
@@ -96,6 +107,7 @@ interface BuilderRelayerApi {
/**
* 签名参数
* 参考: builder-relayer-client/src/types.ts 的 SignatureParams
* Safe 使用 operation/safeTxnGas/baseGas 等,PROXY 使用 relayHub/relay/relayerFee 等
*/
data class SignatureParams(
@SerializedName("gasPrice")
@@ -114,7 +126,29 @@ interface BuilderRelayerApi {
val gasToken: String? = null,
@SerializedName("refundReceiver")
val refundReceiver: String? = null
val refundReceiver: String? = null,
@SerializedName("relayerFee")
val relayerFee: String? = null,
@SerializedName("gasLimit")
val gasLimit: String? = null,
@SerializedName("relayHub")
val relayHub: String? = null,
@SerializedName("relay")
val relay: String? = null,
/** SAFE-CREATE 签名参数 */
@SerializedName("paymentToken")
val paymentToken: String? = null,
@SerializedName("payment")
val payment: String? = null,
@SerializedName("paymentReceiver")
val paymentReceiver: String? = null
)
/**
@@ -142,6 +176,17 @@ interface BuilderRelayerApi {
@SerializedName("nonce")
val nonce: String
)
/**
* Relay PayloadPROXY 执行时获取 relay 地址与 nonce
* 参考: builder-relayer-client types RelayPayload
*/
data class RelayPayload(
@SerializedName("address")
val address: String,
@SerializedName("nonce")
val nonce: String
)
/**
* Relayer 交易详情
@@ -1,6 +1,7 @@
package com.wrbug.polymarketbot.api
import com.google.gson.annotations.SerializedName
import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.http.*
@@ -72,7 +73,7 @@ interface PolymarketClobApi {
*/
@POST("/orders/batch")
suspend fun createOrdersBatch(
@Body request: CreateOrdersBatchRequest
@Body request: List<NewOrderRequest>
): Response<List<OrderResponse>>
/**
@@ -164,31 +165,34 @@ interface PolymarketClobApi {
/**
* 获取服务器时间
* 端点: /time
* 端点: /time 返回纯数字(Unix 时间戳),非 JSON
*/
@GET("/time")
suspend fun getServerTime(): Response<ServerTimeResponse>
suspend fun getServerTime(): Response<ResponseBody>
}
// 请求和响应数据类
/**
* 签名的订单对象(根据官方文档)
* 参考: https://docs.polymarket.com/developers/CLOB/orders/create-order
* V2 签名的订单对象
* EIP-712 签名字段: salt, maker, signer, tokenId, makerAmount, takerAmount, side, signatureType, timestamp, metadata, builder
* API payload 额外字段: taker, expiration (不在 EIP-712 签名中,但 API 请求需要)
* 参考: clob-client-v2/src/types/ordersV2.ts NewOrderV2
*/
data class SignedOrderObject(
val salt: Long, // random salt used to create unique order
val maker: String, // maker address (funder)
val signer: String, // signing address
val taker: String, // taker address (operator)
val taker: String, // taker address (zero address for public orders, NOT in EIP-712 signing)
val tokenId: String, // ERC1155 token ID of conditional token being traded
val makerAmount: String, // maximum amount maker is willing to spend
val takerAmount: String, // minimum amount taker will pay the maker in return
val expiration: String, // unix expiration timestamp
val nonce: String, // maker's exchange nonce of the order is associated
val feeRateBps: String, // fee rate basis points as required by the operator
val side: String, // buy or sell enum index ("BUY" or "SELL")
val signatureType: Int, // signature type enum index
val timestamp: String, // order creation time in milliseconds (V2)
val expiration: String, // expiration timestamp unix seconds, "0" = no expiration (NOT in EIP-712 signing)
val metadata: String, // bytes32 metadata (V2)
val builder: String, // bytes32 builder code (V2)
val signature: String // hex encoded signature
)
@@ -197,10 +201,11 @@ data class SignedOrderObject(
* 参考: https://docs.polymarket.com/developers/CLOB/orders/create-order
*/
data class NewOrderRequest(
val order: SignedOrderObject, // signed object
val order: SignedOrderObject, // V2 signed object
val owner: String, // api key of order owner
val orderType: String, // order type ("FOK", "GTC", "GTD", "FAK")
val deferExec: Boolean = false // defer execution flag
val deferExec: Boolean = false, // defer execution
val postOnly: Boolean = false // post only (maker-only)
)
/**
@@ -211,6 +216,7 @@ data class NewOrderResponse(
val success: Boolean, // boolean indicating if server-side error
@SerializedName("errorMsg")
val errorMsg: String? = null, // error message in case of unsuccessful placement
val error: String? = null, // error message (alternative field, e.g. "Trading restricted in your region...")
@SerializedName("orderID")
val orderId: String? = null, // id of orderAPI 返回字段名为 orderID
@SerializedName("transactionsHashes")
@@ -221,27 +227,17 @@ data class NewOrderResponse(
val takingAmount: String? = null, // taking amount
@SerializedName("makingAmount")
val makingAmount: String? = null // making amount
)
/**
* 旧的订单请求格式(已废弃,保留用于兼容)
* @deprecated 使用 NewOrderRequest 代替
*/
@Deprecated("使用 NewOrderRequest 代替,需要签名的订单对象")
data class CreateOrderRequest(
val market: String? = null, // condition ID(可选,如果提供tokenId则不需要)
val token_id: String? = null, // token ID(可选,如果提供market则不需要)
val side: String, // "BUY" or "SELL"
val price: String,
val size: String,
val type: String = "LIMIT",
val expiration: Long? = null
)
@Deprecated("使用 NewOrderRequest 代替")
data class CreateOrdersBatchRequest(
val orders: List<NewOrderRequest>
)
) {
/**
* 获取错误信息的便捷方法
* 优先返回 errorMsg,其次返回 error,最后返回默认消息
*/
fun getErrorMessage(): String {
return errorMsg?.takeIf { it.isNotBlank() }
?: error?.takeIf { it.isNotBlank() }
?: "创建订单失败"
}
}
data class CancelOrdersBatchRequest(
val orderIds: List<String>
@@ -334,7 +330,8 @@ data class TradeResponse(
val timestamp: String, // ISO 8601 格式字符串或时间戳
val user: String?,
val outcomeIndex: Int? = null, // 结果索引(0=YES, 1=NO
val outcome: String? = null // 结果名称(如 "Up", "Down"
val outcome: String? = null, // 结果名称(如 "Up", "Down"
val tokenId: String? = null // CLOB tokenId(链上解析时从 ERC1155 取得,与 Gamma clobTokenIds 一致,用于下单)
)
/**
@@ -363,13 +360,6 @@ data class ApiKeyResponse(
val passphrase: String
)
/**
* 服务器时间响应
*/
data class ServerTimeResponse(
val timestamp: Long
)
/**
* 费率响应
* 文档: https://docs.polymarket.com/developers/market-makers/maker-rebates-program#1-fetch-the-fee-rate
@@ -2,6 +2,7 @@ package com.wrbug.polymarketbot.api
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
/**
@@ -26,23 +27,56 @@ interface PolymarketGammaApi {
@Query("clob_token_ids") clobTokenIds: List<String>? = null,
@Query("include_tag") includeTag: Boolean? = null
): Response<List<MarketResponse>>
/**
* 根据 slug 获取事件(用于 5/15 分钟加密市场)
* GET /events/slug/{slug},如 btc-updown-5m-1771007400
* 返回事件含 marketsconditionId、endDate、clobTokenIds 等)
*/
@GET("/events/slug/{slug}")
suspend fun getEventBySlug(@Path("slug") slug: String): Response<GammaEventBySlugResponse>
}
/**
* Gamma 按 slug 返回的事件结构
*/
data class GammaEventBySlugResponse(
val id: String? = null,
val slug: String? = null,
val title: String? = null,
val startDate: String? = null,
val endDate: String? = null,
val markets: List<GammaEventMarketItem>? = null
)
/**
* 事件下的市场项(5/15 分钟市场为二元,通常两个 outcome)
*/
data class GammaEventMarketItem(
val conditionId: String? = null,
val question: String? = null,
val endDate: String? = null,
val startDate: String? = null,
val clobTokenIds: String? = null
)
/**
* 事件响应(从 MarketResponse.events 解析)
* Gamma API Event 含 negRisk,用于判断是否使用 Neg Risk Exchange 签约
*/
data class EventResponse(
val id: String? = null,
val ticker: String? = null,
val slug: String,
val title: String,
val slug: String? = null,
val title: String? = null,
val category: String? = null,
val active: Boolean? = null,
val closed: Boolean? = null,
val archived: Boolean? = null,
val startDate: String? = null,
val endDate: String? = null,
val createdAt: String? = null
val createdAt: String? = null,
val negRisk: Boolean? = null
)
/**
@@ -74,6 +108,8 @@ data class MarketResponse(
val events: List<EventResponse>? = null, // 事件列表(从 events[0] 获取 slug
// 以下字段可能存在于响应中,但不在标准文档中
val clobTokenIds: String? = null, // CLOB token IDs(可能是 JSON 字符串或数组)
val clob_token_ids: String? = null // 下划线格式(兼容不同 API 版本)
val clob_token_ids: String? = null, // 下划线格式(兼容不同 API 版本)
val negRisk: Boolean? = null, // 事件级 neg risk(部分 API 直接返回在 market
val negRiskOther: Boolean? = null // Market 级 neg risk 标记
)
@@ -0,0 +1,23 @@
package com.wrbug.polymarketbot.config
import com.wrbug.polymarketbot.service.common.WebSocketSubscriptionService
import com.wrbug.polymarketbot.service.cryptotail.CryptoTailMonitorService
import jakarta.annotation.PostConstruct
import org.springframework.context.annotation.Configuration
/**
* 加密价差策略监控服务配置
* 处理 WebSocketSubscriptionService 和 CryptoTailMonitorService 之间的循环依赖
*/
@Configuration
class MonitorServiceConfig(
private val webSocketSubscriptionService: WebSocketSubscriptionService,
private val cryptoTailMonitorService: CryptoTailMonitorService
) {
@PostConstruct
fun init() {
// 在所有 Bean 初始化后设置引用
webSocketSubscriptionService.setCryptoTailMonitorService(cryptoTailMonitorService)
}
}
@@ -44,5 +44,14 @@ object PolymarketConstants {
* 用于 Gasless 交易
*/
const val BUILDER_RELAYER_URL = "https://relayer-v2.polymarket.com/"
/**
* Polymarket Safe 代理工厂合约地址(Polygon 主网)
* 用于 Safe 类型账户的代理部署(SAFE-CREATE
*/
const val SAFE_PROXY_FACTORY_ADDRESS = "0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b"
/** SafeCreate 用 EIP-712 domain name,与 builder-relayer-client 一致 */
const val SAFE_FACTORY_EIP712_NAME = "Polymarket Contract Proxy Factory"
}
@@ -23,6 +23,50 @@ class AccountController(
private val logger = LoggerFactory.getLogger(AccountController::class.java)
/**
* 检查代理地址选项(用于导入前选择代理类型)
*/
@PostMapping("/check-proxy-options")
fun checkProxyOptions(@RequestBody request: CheckProxyOptionsRequest): ResponseEntity<ApiResponse<CheckProxyOptionsResponse>> {
return try {
if (request.walletAddress.isBlank()) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_WALLET_ADDRESS_EMPTY, messageSource = messageSource))
}
if (request.privateKey.isNullOrBlank() && request.mnemonic.isNullOrBlank()) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, "必须提供私钥或助记词", messageSource))
}
val result = runBlocking { accountService.checkProxyOptions(request) }
result.fold(
onSuccess = { response ->
ResponseEntity.ok(ApiResponse.success(response))
},
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))
}
}
/**
* 通过私钥导入账户
*/
@@ -45,14 +89,17 @@ class AccountController(
onFailure = { e ->
logger.error("导入账户失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> ResponseEntity.ok(
ApiResponse.error(
ErrorCode.PARAM_ERROR,
e.message,
messageSource
is IllegalArgumentException -> if (e.message == "ACCOUNT_ALREADY_EXISTS") {
ResponseEntity.ok(ApiResponse.error(ErrorCode.ACCOUNT_ALREADY_EXISTS, messageSource = messageSource))
} else {
ResponseEntity.ok(
ApiResponse.error(
ErrorCode.PARAM_ERROR,
e.message,
messageSource
)
)
)
}
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ACCOUNT_IMPORT_FAILED, e.message, messageSource))
}
}
@@ -157,6 +204,82 @@ class AccountController(
}
}
/**
* 检查账户设置状态(代理部署、交易启用、代币批准)
*/
@PostMapping("/check-setup-status")
fun checkSetupStatus(@RequestBody request: AccountDetailRequest): ResponseEntity<ApiResponse<AccountSetupStatusDto>> {
return try {
if (request.accountId == null || request.accountId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ACCOUNT_ID_INVALID, messageSource = messageSource))
}
val result = runBlocking { accountService.checkAccountSetupStatus(request.accountId) }
result.fold(
onSuccess = { status ->
ResponseEntity.ok(ApiResponse.success(status))
},
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))
}
}
/**
* 执行设置步骤(步骤1 返回跳转 URL,步骤2/3 由后端执行)
*/
@PostMapping("/execute-setup-step")
fun executeSetupStep(@RequestBody request: ExecuteSetupStepRequest): ResponseEntity<ApiResponse<ExecuteSetupStepResponse>> {
return try {
if (request.accountId == null || request.accountId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ACCOUNT_ID_INVALID, messageSource = messageSource))
}
val step = request.step ?: 0
if (step !in 1..3) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, "步骤必须为 1、2 或 3", messageSource))
}
val result = runBlocking { accountService.executeSetupStep(request.accountId, step) }
result.fold(
onSuccess = { response ->
ResponseEntity.ok(ApiResponse.success(response))
},
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))
}
}
/**
* 查询账户详情
*/
@@ -449,5 +572,53 @@ class AccountController(
}
}
/**
* 将 USDC.e wrap 为 pUSDV2 迁移)
*/
@PostMapping("/wrap-to-pusd")
fun wrapToPusd(@RequestBody request: Map<String, Any>): ResponseEntity<ApiResponse<Map<String, String?>>> {
return try {
val accountId = (request["accountId"] as? Number)?.toLong()
?: return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ACCOUNT_ID_INVALID, messageSource = messageSource))
val result = runBlocking { accountService.wrapUsdcToPusd(accountId) }
result.fold(
onSuccess = { txHash ->
ResponseEntity.ok(ApiResponse.success(mapOf("transactionHash" to txHash)))
},
onFailure = { e ->
logger.error("USDC.e → pUSD wrap 失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("USDC.e → pUSD wrap 异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
/**
* 查询 USDC.e 余额(V2 迁移用)
*/
@PostMapping("/usdce-balance")
fun getUsdceBalance(@RequestBody request: Map<String, Any>): ResponseEntity<ApiResponse<Map<String, String>>> {
return try {
val accountId = (request["accountId"] as? Number)?.toLong()
?: return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ACCOUNT_ID_INVALID, messageSource = messageSource))
val result = runBlocking { accountService.getUsdceBalance(accountId) }
result.fold(
onSuccess = { balance ->
ResponseEntity.ok(ApiResponse.success(mapOf("balance" to balance.toPlainString())))
},
onFailure = { e ->
logger.error("查询 USDC.e 余额失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("查询 USDC.e 余额异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
}
@@ -0,0 +1,283 @@
package com.wrbug.polymarketbot.controller.cryptotail
import com.wrbug.polymarketbot.dto.ApiResponse
import com.wrbug.polymarketbot.dto.CryptoTailStrategyCreateRequest
import com.wrbug.polymarketbot.dto.CryptoTailStrategyDeleteRequest
import com.wrbug.polymarketbot.dto.CryptoTailStrategyDto
import com.wrbug.polymarketbot.dto.CryptoTailStrategyListRequest
import com.wrbug.polymarketbot.dto.CryptoTailStrategyListResponse
import com.wrbug.polymarketbot.dto.CryptoTailStrategyTriggerListRequest
import com.wrbug.polymarketbot.dto.CryptoTailStrategyTriggerListResponse
import com.wrbug.polymarketbot.dto.CryptoTailStrategyUpdateRequest
import com.wrbug.polymarketbot.dto.CryptoTailMarketOptionDto
import com.wrbug.polymarketbot.dto.CryptoTailAutoMinSpreadResponse
import com.wrbug.polymarketbot.dto.CryptoTailMonitorInitRequest
import com.wrbug.polymarketbot.dto.CryptoTailMonitorInitResponse
import com.wrbug.polymarketbot.dto.CryptoTailManualOrderRequest
import com.wrbug.polymarketbot.dto.CryptoTailManualOrderResponse
import com.wrbug.polymarketbot.dto.CryptoTailPnlCurveRequest
import com.wrbug.polymarketbot.dto.CryptoTailPnlCurveResponse
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService
import com.wrbug.polymarketbot.service.cryptotail.CryptoTailStrategyService
import com.wrbug.polymarketbot.service.cryptotail.CryptoTailMonitorService
import com.wrbug.polymarketbot.service.cryptotail.CryptoTailStrategyExecutionService
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import kotlinx.coroutines.runBlocking
@RestController
@RequestMapping("/api/crypto-tail-strategy")
class CryptoTailStrategyController(
private val cryptoTailStrategyService: CryptoTailStrategyService,
private val cryptoTailMonitorService: CryptoTailMonitorService,
private val cryptoTailStrategyExecutionService: CryptoTailStrategyExecutionService,
private val binanceKlineAutoSpreadService: BinanceKlineAutoSpreadService,
private val messageSource: MessageSource
) {
private val logger = LoggerFactory.getLogger(CryptoTailStrategyController::class.java)
@PostMapping("/list")
fun list(@RequestBody request: CryptoTailStrategyListRequest): ResponseEntity<ApiResponse<CryptoTailStrategyListResponse>> {
return try {
val result = cryptoTailStrategyService.list(request)
result.fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("查询加密价差策略列表失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_LIST_FETCH_FAILED, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("查询加密价差策略列表异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_LIST_FETCH_FAILED, e.message, messageSource))
}
}
@PostMapping("/create")
fun create(@RequestBody request: CryptoTailStrategyCreateRequest): ResponseEntity<ApiResponse<CryptoTailStrategyDto>> {
return try {
val result = cryptoTailStrategyService.create(request)
result.fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("创建加密价差策略失败: ${e.message}", e)
val code = when (e.message) {
ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID
ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED
ErrorCode.CRYPTO_TAIL_STRATEGY_INTERVAL_INVALID.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_INTERVAL_INVALID
ErrorCode.CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID
else -> ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_CREATE_FAILED
}
ResponseEntity.ok(ApiResponse.error(code, messageSource = messageSource))
}
)
} catch (e: Exception) {
logger.error("创建加密价差策略异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_CREATE_FAILED, e.message, messageSource))
}
}
@PostMapping("/update")
fun update(@RequestBody request: CryptoTailStrategyUpdateRequest): ResponseEntity<ApiResponse<CryptoTailStrategyDto>> {
return try {
if (request.strategyId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource))
}
val result = cryptoTailStrategyService.update(request)
result.fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("更新加密价差策略失败: ${e.message}", e)
val code = when (e.message) {
ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND
ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID
ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED
ErrorCode.CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID
else -> ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_UPDATE_FAILED
}
ResponseEntity.ok(ApiResponse.error(code, messageSource = messageSource))
}
)
} catch (e: Exception) {
logger.error("更新加密价差策略异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_UPDATE_FAILED, e.message, messageSource))
}
}
@PostMapping("/delete")
fun delete(@RequestBody request: CryptoTailStrategyDeleteRequest): ResponseEntity<ApiResponse<Unit>> {
return try {
val strategyId = request.strategyId
if (strategyId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource))
}
val result = cryptoTailStrategyService.delete(strategyId)
result.fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(Unit)) },
onFailure = { e ->
logger.error("删除加密价差策略失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_DELETE_FAILED, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("删除加密价差策略异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_DELETE_FAILED, e.message, messageSource))
}
}
@PostMapping("/pnl-curve")
fun getPnlCurve(@RequestBody request: CryptoTailPnlCurveRequest): ResponseEntity<ApiResponse<CryptoTailPnlCurveResponse>> {
return try {
if (request.strategyId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource))
}
val result = cryptoTailStrategyService.getPnlCurve(request)
result.fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("查询收益曲线失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("查询收益曲线异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED, e.message, messageSource))
}
}
@PostMapping("/triggers")
fun getTriggerRecords(@RequestBody request: CryptoTailStrategyTriggerListRequest): ResponseEntity<ApiResponse<CryptoTailStrategyTriggerListResponse>> {
return try {
if (request.strategyId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource))
}
val result = cryptoTailStrategyService.getTriggerRecords(request)
result.fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("查询触发记录失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("查询触发记录异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED, e.message, messageSource))
}
}
@PostMapping("/market-options")
fun getMarketOptions(): ResponseEntity<ApiResponse<List<CryptoTailMarketOptionDto>>> {
return try {
val options = listOf(
CryptoTailMarketOptionDto(slug = "btc-updown-5m", title = "Bitcoin Up or Down - 5 minute", intervalSeconds = 300, periodStartUnix = 0L, endDate = null),
CryptoTailMarketOptionDto(slug = "btc-updown-15m", title = "Bitcoin Up or Down - 15 minute", intervalSeconds = 900, periodStartUnix = 0L, endDate = null),
CryptoTailMarketOptionDto(slug = "eth-updown-5m", title = "Ethereum Up or Down - 5 minute", intervalSeconds = 300, periodStartUnix = 0L, endDate = null),
CryptoTailMarketOptionDto(slug = "eth-updown-15m", title = "Ethereum Up or Down - 15 minute", intervalSeconds = 900, periodStartUnix = 0L, endDate = null),
CryptoTailMarketOptionDto(slug = "sol-updown-5m", title = "Solana Up or Down - 5 minute", intervalSeconds = 300, periodStartUnix = 0L, endDate = null),
CryptoTailMarketOptionDto(slug = "sol-updown-15m", title = "Solana Up or Down - 15 minute", intervalSeconds = 900, periodStartUnix = 0L, endDate = null),
CryptoTailMarketOptionDto(slug = "xrp-updown-5m", title = "XRP Up or Down - 5 minute", intervalSeconds = 300, periodStartUnix = 0L, endDate = null),
CryptoTailMarketOptionDto(slug = "xrp-updown-15m", title = "XRP Up or Down - 15 minute", intervalSeconds = 900, periodStartUnix = 0L, endDate = null)
)
ResponseEntity.ok(ApiResponse.success(options))
} catch (e: Exception) {
logger.error("获取市场选项异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
/**
* 自动最小价差预览:按「当前周期」计算一次并返回,仅用于前端展示参考。
* 实际触发时按每个周期在需要时计算,不依赖此接口。
*/
@PostMapping("/auto-min-spread")
fun getAutoMinSpread(@RequestBody request: java.util.Map<String, Any>): ResponseEntity<ApiResponse<CryptoTailAutoMinSpreadResponse>> {
return try {
val intervalSeconds = (request["intervalSeconds"] as? Number)?.toInt() ?: 300
if (intervalSeconds != 300 && intervalSeconds != 900) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, messageSource = messageSource))
}
val periodStartUnix = (request["periodStartUnix"] as? Number)?.toLong()
?: ((System.currentTimeMillis() / 1000 / intervalSeconds) * intervalSeconds)
// 默认使用 BTC 市场(向后兼容)
val marketSlugPrefix = (request["marketSlugPrefix"] as? String) ?: "btc-updown"
val pair = binanceKlineAutoSpreadService.computeAndCache(marketSlugPrefix, intervalSeconds, periodStartUnix)
?: return ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "fetch_failed", messageSource))
val body = CryptoTailAutoMinSpreadResponse(
minSpreadUp = pair.first.toPlainString(),
minSpreadDown = pair.second.toPlainString()
)
ResponseEntity.ok(ApiResponse.success(body))
} catch (e: Exception) {
logger.error("计算自动最小价差异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
/**
* 初始化加密价差策略监控
* 返回策略信息、开盘价、tokenIds等初始化数据
*/
@PostMapping("/monitor/init")
fun initMonitor(@RequestBody request: CryptoTailMonitorInitRequest): ResponseEntity<ApiResponse<CryptoTailMonitorInitResponse>> {
return try {
if (request.strategyId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource))
}
val result = cryptoTailMonitorService.initMonitor(request)
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))
}
}
/**
* 手动下单
* 用户主动触发下单,不检查任何条件,仅检查当前周期是否已下单
*/
@PostMapping("/manual-order")
fun manualOrder(@RequestBody request: CryptoTailManualOrderRequest): ResponseEntity<ApiResponse<CryptoTailManualOrderResponse>> {
return runBlocking {
try {
if (request.strategyId <= 0) {
return@runBlocking ResponseEntity.ok(
ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource)
)
}
val result = cryptoTailStrategyExecutionService.manualOrder(request)
result.fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("手动下单失败: ${e.message}", e)
val code = when (e.message) {
"策略不存在" -> ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND
"当前周期已下单" -> ErrorCode.PARAM_ERROR
"价格必须在 0~1 之间" -> ErrorCode.PARAM_ERROR
"数量不能少于 1" -> ErrorCode.PARAM_ERROR
"总金额不能少于 $1" -> ErrorCode.PARAM_ERROR
"总金额超过策略配置的投入金额" -> ErrorCode.PARAM_ERROR
else -> ErrorCode.SERVER_ERROR
}
ResponseEntity.ok(ApiResponse.error(code, 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.system
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.system.NotificationConfigService
import com.wrbug.polymarketbot.service.system.NotificationTemplateService
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
@@ -18,6 +19,7 @@ import org.springframework.web.bind.annotation.*
class NotificationController(
private val notificationConfigService: NotificationConfigService,
private val telegramNotificationService: TelegramNotificationService,
private val notificationTemplateService: NotificationTemplateService,
private val messageSource: MessageSource
) {
@@ -335,6 +337,155 @@ class NotificationController(
))
}
}
// ==================== 模板相关 API ====================
/**
* 获取所有模板类型
*/
@PostMapping("/templates/types")
fun getTemplateTypes(): ResponseEntity<ApiResponse<List<TemplateTypeInfoDto>>> {
return try {
val types = notificationTemplateService.getTemplateTypes()
ResponseEntity.ok(ApiResponse.success(types))
} catch (e: Exception) {
logger.error("获取模板类型失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource))
}
}
/**
* 获取所有模板
*/
@PostMapping("/templates/list")
fun getTemplates(): ResponseEntity<ApiResponse<List<NotificationTemplateDto>>> {
return try {
val templates = notificationTemplateService.getAllTemplates()
ResponseEntity.ok(ApiResponse.success(templates))
} catch (e: Exception) {
logger.error("获取模板列表失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource))
}
}
/**
* 获取单个模板
*/
@PostMapping("/templates/detail")
fun getTemplateDetail(@RequestBody request: TemplateDetailRequest): ResponseEntity<ApiResponse<NotificationTemplateDto>> {
return try {
if (request.templateType.isBlank()) {
return ResponseEntity.ok(ApiResponse.paramError("模板类型不能为空"))
}
val template = notificationTemplateService.getTemplate(request.templateType)
if (template == null) {
ResponseEntity.ok(ApiResponse.error(ErrorCode.NOT_FOUND, messageSource = messageSource))
} else {
ResponseEntity.ok(ApiResponse.success(template))
}
} catch (e: Exception) {
logger.error("获取模板详情失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource))
}
}
/**
* 获取模板可用变量
*/
@PostMapping("/templates/variables")
fun getTemplateVariables(@RequestBody request: TemplateDetailRequest): ResponseEntity<ApiResponse<TemplateVariablesResponse>> {
return try {
if (request.templateType.isBlank()) {
return ResponseEntity.ok(ApiResponse.paramError("模板类型不能为空"))
}
val variables = notificationTemplateService.getTemplateVariables(request.templateType)
if (variables == null) {
ResponseEntity.ok(ApiResponse.error(ErrorCode.NOT_FOUND, messageSource = messageSource))
} else {
ResponseEntity.ok(ApiResponse.success(variables))
}
} catch (e: Exception) {
logger.error("获取模板变量失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource))
}
}
/**
* 更新模板
*/
@PostMapping("/templates/update")
fun updateTemplate(@RequestBody request: UpdateTemplateRequestWithId): ResponseEntity<ApiResponse<NotificationTemplateDto>> {
return try {
if (request.templateType.isBlank()) {
return ResponseEntity.ok(ApiResponse.paramError("模板类型不能为空"))
}
if (request.templateContent.isBlank()) {
return ResponseEntity.ok(ApiResponse.paramError("模板内容不能为空"))
}
val template = notificationTemplateService.updateTemplate(request.templateType, request.templateContent)
ResponseEntity.ok(ApiResponse.success(template))
} catch (e: Exception) {
logger.error("更新模板失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource))
}
}
/**
* 重置模板为默认
*/
@PostMapping("/templates/reset")
fun resetTemplate(@RequestBody request: TemplateDetailRequest): ResponseEntity<ApiResponse<NotificationTemplateDto>> {
return try {
if (request.templateType.isBlank()) {
return ResponseEntity.ok(ApiResponse.paramError("模板类型不能为空"))
}
val template = notificationTemplateService.resetTemplate(request.templateType)
if (template == null) {
ResponseEntity.ok(ApiResponse.error(ErrorCode.NOT_FOUND, messageSource = messageSource))
} else {
ResponseEntity.ok(ApiResponse.success(template))
}
} catch (e: Exception) {
logger.error("重置模板失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource))
}
}
/**
* 发送模板测试消息
*/
@PostMapping("/templates/test")
fun testTemplate(@RequestBody request: TestTemplateRequest): ResponseEntity<ApiResponse<Boolean>> {
return try {
if (request.templateType.isBlank()) {
return ResponseEntity.ok(ApiResponse.paramError("模板类型不能为空"))
}
val success = runBlocking {
notificationTemplateService.sendTestMessage(request.templateType, request.templateContent)
}
if (success) {
ResponseEntity.ok(ApiResponse.success(true))
} else {
ResponseEntity.ok(ApiResponse.error(
ErrorCode.NOTIFICATION_TEST_FAILED,
messageSource = messageSource
))
}
} catch (e: Exception) {
logger.error("发送模板测试消息失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(
ErrorCode.NOTIFICATION_TEST_FAILED,
customMsg = "发送测试消息失败:${e.message}",
messageSource = messageSource
))
}
}
}
/**
@@ -384,3 +535,18 @@ data class NotificationConfigDeleteRequest(
val id: Long
)
/**
* 模板详情请求
*/
data class TemplateDetailRequest(
val templateType: String
)
/**
* 更新模板请求(带类型)
*/
data class UpdateTemplateRequestWithId(
val templateType: String,
val templateContent: String
)
@@ -3,6 +3,7 @@ package com.wrbug.polymarketbot.controller.system
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.system.ApiHealthCheckService
import com.wrbug.polymarketbot.service.system.GeoblockService
import com.wrbug.polymarketbot.service.system.ProxyConfigService
import jakarta.servlet.http.HttpServletRequest
import kotlinx.coroutines.runBlocking
@@ -19,6 +20,7 @@ import org.springframework.web.bind.annotation.*
class ProxyConfigController(
private val proxyConfigService: ProxyConfigService,
private val apiHealthCheckService: ApiHealthCheckService,
private val geoblockService: GeoblockService,
private val messageSource: MessageSource
) {
@@ -123,6 +125,32 @@ class ProxyConfigController(
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "API 健康检查失败:${e.message}", messageSource))
}
}
/**
* 检查交易服务器出口 IP 的地域限制(Polymarket Geoblock
*/
@PostMapping("/geoblock-check")
fun checkGeoblock(): ResponseEntity<ApiResponse<GeoblockCheckDto>> {
return try {
val result = runBlocking { geoblockService.checkGeoblock() }
if (result.isSuccess) {
ResponseEntity.ok(ApiResponse.success(result.getOrNull()))
} else {
val error = result.exceptionOrNull()
logger.error("Geoblock 检查失败", error)
ResponseEntity.ok(
ApiResponse.error(
ErrorCode.SERVER_ERROR,
error?.message ?: "Geoblock 检查失败",
messageSource
)
)
}
} catch (e: Exception) {
logger.error("Geoblock 检查异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "Geoblock 检查失败:${e.message}", messageSource))
}
}
}
@@ -11,6 +11,37 @@ data class AccountImportRequest(
val walletType: String = "magic" // 钱包类型:magic(邮箱/OAuth登录)或 safeMetaMask浏览器钱包)
)
/**
* 检查代理地址选项请求
*/
data class CheckProxyOptionsRequest(
val walletAddress: String, // EOA 地址(必需)
val privateKey: String? = null, // 私钥(加密,私钥导入时提供)
val mnemonic: String? = null // 助记词(加密,助记词导入时提供)
)
/**
* 代理地址选项信息
*/
data class ProxyOptionDto(
val walletType: String, // "magic" 或 "safe"
val proxyAddress: String, // 代理地址
val descriptionKey: String, // 说明文案的多语言 key(如 "accountImport.proxyOption.magic.description"
val availableBalance: String, // 可用余额
val positionBalance: String, // 仓位余额
val totalBalance: String, // 总余额
val positionCount: Int, // 持仓数量
val hasAssets: Boolean, // 是否有资产(余额>0 或持仓>0
val error: String? = null // 获取失败时的错误信息(可选)
)
/**
* 检查代理地址选项响应
*/
data class CheckProxyOptionsResponse(
val options: List<ProxyOptionDto> // 代理地址选项列表(私钥导入返回2个,助记词返回1个)
)
/**
* 账户更新请求
*/
@@ -37,9 +68,9 @@ data class SystemConfigDto(
val builderApiKeyConfigured: Boolean, // Builder API Key 是否已配置
val builderSecretConfigured: Boolean, // Builder Secret 是否已配置
val builderPassphraseConfigured: Boolean, // Builder Passphrase 是否已配置
val builderApiKeyDisplay: String? = null, // Builder API Key 显示值(部分显示,用于前端展示)
val builderSecretDisplay: String? = null, // Builder Secret 显示值(部分显示,用于前端展示)
val builderPassphraseDisplay: String? = null, // Builder Passphrase 显示值(部分显示,用于前端展示)
val builderApiKeyDisplay: String? = null, // Builder API Key 显示值(完整,用于前端展示)
val builderSecretDisplay: String? = null, // Builder Secret 显示值(完整,用于前端展示)
val builderPassphraseDisplay: String? = null, // Builder Passphrase 显示值(完整,用于前端展示)
val autoRedeemEnabled: Boolean = true // 自动赎回(系统级别配置,默认开启)
)
@@ -0,0 +1,63 @@
package com.wrbug.polymarketbot.dto
/**
* 账户设置状态检查结果
*/
data class AccountSetupStatusDto(
/**
* 步骤1:代理钱包是否已部署
*/
val proxyDeployed: Boolean,
/**
* 步骤2:交易是否已启用(API Key 是否已配置)
*/
val tradingEnabled: Boolean,
/**
* 步骤3:代币是否已批准
*/
val tokensApproved: Boolean,
/**
* 代币批准详情(各合约的授权额度)
* Key: 合约名称(CTF_CONTRACT, CTF_EXCHANGE, NEG_RISK_EXCHANGE, NEG_RISK_ADAPTER
* Value: 授权额度(USDC6位小数)
*/
val approvalDetails: Map<String, String>? = null,
/**
* 检查错误信息(如果有)
*/
val error: String? = null
)
/**
* 执行设置步骤请求
*/
data class ExecuteSetupStepRequest(
/** 账户 ID */
val accountId: Long? = null,
/** 步骤:1=部署代理, 2=启用交易, 3=批准代币 */
val step: Int? = null
)
/**
* 执行设置步骤响应
*/
data class ExecuteSetupStepResponse(
/** 是否由后端执行成功(步骤1 仅返回跳转链接,为 false) */
val success: Boolean = false,
/** 需跳转时由后端提供的 URL(步骤1 使用) */
val redirectUrl: String? = null,
/** 链上交易哈希(步骤3 批准代币成功时返回) */
val transactionHash: String? = null
)
/**
* 账户导入响应(扩展,包含设置状态)
*/
data class AccountImportResponse(
val account: AccountDto,
val setupStatus: AccountSetupStatusDto? = null // 设置状态检查结果(可选)
)
@@ -22,6 +22,8 @@ data class BacktestCreateRequest(
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
val keywords: List<String>? = null, // 关键字列表
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
val pageForResume: Int? = null // 用于恢复中断任务,从指定页码开始获取历史数据(从1开始)
)
@@ -166,7 +168,9 @@ data class BacktestConfigDto(
val supportSell: Boolean,
val keywordFilterMode: String?,
val keywords: List<String>?,
val maxPositionValue: String?
val maxPositionValue: String?,
val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String? // 最高价格(可选),NULL表示不限制最高价
)
/**
@@ -0,0 +1,21 @@
package com.wrbug.polymarketbot.dto
/**
* 加密价差策略手动下单请求
*/
data class CryptoTailManualOrderRequest(
/** 策略ID */
val strategyId: Long = 0L,
/** 当前周期开始时间 (Unix 秒) */
val periodStartUnix: Long = 0L,
/** 下单方向: UP or DOWN */
val direction: String = "UP",
/** 下单价格 */
val price: String = "0",
/** 下单数量 */
val size: String = "1",
/** 市场标题(用于记录) */
val marketTitle: String = "",
/** Token IDs */
val tokenIds: List<String> = emptyList()
)
@@ -0,0 +1,31 @@
package com.wrbug.polymarketbot.dto
/**
* 加密价差策略手动下单响应
*/
data class CryptoTailManualOrderResponse(
/** 是否成功 */
val success: Boolean = false,
/** 订单ID */
val orderId: String? = null,
/** 提示消息 */
val message: String = "",
/** 下单详情 */
val orderDetails: ManualOrderDetails? = null
)
/**
* 手动下单详情
*/
data class ManualOrderDetails(
/** 策略ID */
val strategyId: Long = 0L,
/** 方向 */
val direction: String = "",
/** 下单价格 */
val price: String = "",
/** 下单数量 */
val size: String = "",
/** 总金额 */
val totalAmount: String = ""
)
@@ -0,0 +1,111 @@
package com.wrbug.polymarketbot.dto
/**
* 加密价差策略监控初始化请求
*/
data class CryptoTailMonitorInitRequest(
/** 策略ID */
val strategyId: Long = 0L,
/** 指定周期开始时间 (Unix 秒),不传则用服务器当前周期 */
val periodStartUnix: Long? = null
)
/**
* 加密价差策略监控初始化响应
*/
data class CryptoTailMonitorInitResponse(
/** 策略ID */
val strategyId: Long = 0L,
/** 策略名称 */
val name: String = "",
/** 账户ID */
val accountId: Long = 0L,
/** 账户名称 */
val accountName: String = "",
/** 市场 slug 前缀 */
val marketSlugPrefix: String = "",
/** 市场标题 */
val marketTitle: String = "",
/** 周期秒数 (300=5m, 900=15m) */
val intervalSeconds: Int = 300,
/** 当前周期开始时间 (Unix 秒) */
val periodStartUnix: Long = 0L,
/** 时间窗口开始秒数 */
val windowStartSeconds: Int = 0,
/** 时间窗口结束秒数 */
val windowEndSeconds: Int = 0,
/** 最低价格 */
val minPrice: String = "0",
/** 最高价格 */
val maxPrice: String = "1",
/** 最小价差模式: NONE, FIXED, AUTO */
val minSpreadMode: String = "NONE",
/** 价差方向: MIN(显示周期内最小价差), MAX(显示周期内最大价差) */
val spreadDirection: String = "MIN",
/** 最小价差数值 (FIXED 时有值) */
val minSpreadValue: String? = null,
/** 自动计算的最小价差 (Up方向) */
val autoMinSpreadUp: String? = null,
/** 自动计算的最小价差 (Down方向) */
val autoMinSpreadDown: String? = null,
/** BTC 开盘价 USDC(来自币安 K 线 open */
val openPriceBtc: String? = null,
/** Up tokenId */
val tokenIdUp: String? = null,
/** Down tokenId */
val tokenIdDown: String? = null,
/** 当前时间 (毫秒时间戳) */
val currentTimestamp: Long = System.currentTimeMillis(),
/** 是否启用 */
val enabled: Boolean = true,
/** 投入金额模式: FIXED or RATIO */
val amountMode: String? = null,
/** 投入金额数值 */
val amountValue: String? = null
)
/**
* 加密价差策略监控实时推送数据
*/
data class CryptoTailMonitorPushData(
/** 策略ID */
val strategyId: Long = 0L,
/** 推送时间 (毫秒时间戳) */
val timestamp: Long = System.currentTimeMillis(),
/** 当前周期开始时间 (Unix 秒) */
val periodStartUnix: Long = 0L,
/** 当前周期市场标题(周期切换时更新) */
val marketTitle: String = "",
/** 当前价格 (Up方向,来自订单簿) */
val currentPriceUp: String? = null,
/** 当前价格 (Down方向,来自订单簿) */
val currentPriceDown: String? = null,
/** 当前价差 (Up方向: 1 - currentPriceUp) */
val spreadUp: String? = null,
/** 当前价差 (Down方向: currentPriceUp) */
val spreadDown: String? = null,
/** 最小价差线 (Up方向) */
val minSpreadLineUp: String? = null,
/** 最小价差线 (Down方向,USDC 价差) */
val minSpreadLineDown: String? = null,
/** BTC 开盘价 USDC(币安 K 线 open */
val openPriceBtc: String? = null,
/** BTC 最新价 USDC(币安 K 线 close,当前周期实时) */
val currentPriceBtc: String? = null,
/** BTC 价差 USDCcurrentPriceBtc - openPriceBtc */
val spreadBtc: String? = null,
/** 周期剩余秒数 */
val remainingSeconds: Int = 0,
/** 是否在时间窗口内 */
val inTimeWindow: Boolean = false,
/** 是否在价格区间内 (Up方向) */
val inPriceRangeUp: Boolean = false,
/** 是否在价格区间内 (Down方向) */
val inPriceRangeDown: Boolean = false,
/** 是否已触发 */
val triggered: Boolean = false,
/** 触发方向: UP, DOWN, null */
val triggerDirection: String? = null,
/** 周期是否已结束 */
val periodEnded: Boolean = false
)
@@ -0,0 +1,211 @@
package com.wrbug.polymarketbot.dto
/**
* 加密价差策略创建请求
* 金额与价格使用 String,后端转为 BigDecimal
*/
data class CryptoTailStrategyCreateRequest(
val accountId: Long = 0L,
val name: String? = null,
val marketSlugPrefix: String = "",
val intervalSeconds: Int = 300,
val windowStartSeconds: Int = 0,
val windowEndSeconds: Int = 0,
val minPrice: String = "0",
val maxPrice: String? = null,
val amountMode: String = "RATIO",
val amountValue: String = "0",
/** 价差模式: NONE, FIXED, AUTO */
val spreadMode: String = "NONE",
/** 价差数值 */
val spreadValue: String? = null,
/** 价差方向: MIN=最小价差, MAX=最大价差 */
val spreadDirection: String = "MIN",
val enabled: Boolean = true
)
/**
* 加密价差策略更新请求
*/
data class CryptoTailStrategyUpdateRequest(
val strategyId: Long = 0L,
val name: String? = null,
val windowStartSeconds: Int? = null,
val windowEndSeconds: Int? = null,
val minPrice: String? = null,
val maxPrice: String? = null,
val amountMode: String? = null,
val amountValue: String? = null,
/** 价差模式: NONE, FIXED, AUTO */
val spreadMode: String? = null,
/** 价差数值 */
val spreadValue: String? = null,
/** 价差方向: MIN=最小价差, MAX=最大价差 */
val spreadDirection: String? = null,
val enabled: Boolean? = null
)
/**
* 加密价差策略列表请求
*/
data class CryptoTailStrategyListRequest(
val accountId: Long? = null,
val enabled: Boolean? = null
)
/**
* 加密价差策略 DTO(列表与详情)
*/
data class CryptoTailStrategyDto(
val id: Long = 0L,
val accountId: Long = 0L,
val name: String? = null,
val marketSlugPrefix: String = "",
val marketTitle: String? = null,
val intervalSeconds: Int = 0,
val windowStartSeconds: Int = 0,
val windowEndSeconds: Int = 0,
val minPrice: String = "0",
val maxPrice: String = "1",
val amountMode: String = "RATIO",
val amountValue: String = "0",
/** 价差模式: NONE, FIXED, AUTO */
val spreadMode: String = "NONE",
/** 价差数值 */
val spreadValue: String? = null,
/** 价差方向: MIN=最小价差(价差>=配置值触发), MAX=最大价差(价差<=配置值触发) */
val spreadDirection: String = "MIN",
val enabled: Boolean = true,
val lastTriggerAt: Long? = null,
/** 已实现总收益 USDC(已结算订单的 realizedPnl 之和) */
val totalRealizedPnl: String? = null,
/** 已结算笔数(用于胜率分母) */
val settledCount: Long = 0L,
/** 已结算中赢的笔数(用于胜率分子) */
val winCount: Long = 0L,
/** 胜率 0~1(已结算时 = winCount/settledCount,无结算为 null */
val winRate: String? = null,
val createdAt: Long = 0L,
val updatedAt: Long = 0L
)
/**
* 加密价差策略列表响应
*/
data class CryptoTailStrategyListResponse(
val list: List<CryptoTailStrategyDto> = emptyList()
)
/**
* 加密价差策略删除请求
*/
data class CryptoTailStrategyDeleteRequest(
val strategyId: Long = 0L
)
/**
* 触发记录列表请求
* @param startDate 开始日期(当天 00:00:00.000 的时间戳毫秒),为 null 表示不限制
* @param endDate 结束日期(当天 23:59:59.999 的时间戳毫秒),为 null 表示不限制
*/
data class CryptoTailStrategyTriggerListRequest(
val strategyId: Long = 0L,
val page: Int = 1,
val pageSize: Int = 20,
val status: String? = null,
val startDate: Long? = null,
val endDate: Long? = null
)
/**
* 触发记录 DTO
*/
data class CryptoTailStrategyTriggerDto(
val id: Long = 0L,
val strategyId: Long = 0L,
val periodStartUnix: Long = 0L,
val marketTitle: String? = null,
val outcomeIndex: Int = 0,
val triggerPrice: String = "0",
val amountUsdc: String = "0",
val orderId: String? = null,
val status: String = "success",
val failReason: String? = null,
/** 是否已结算 */
val resolved: Boolean = false,
/** 已实现盈亏 USDC(结算后有值) */
val realizedPnl: String? = null,
/** 市场赢家 outcome 索引(结算后有值) */
val winnerOutcomeIndex: Int? = null,
val settledAt: Long? = null,
val createdAt: Long = 0L
)
/**
* 触发记录分页响应
*/
data class CryptoTailStrategyTriggerListResponse(
val list: List<CryptoTailStrategyTriggerDto> = emptyList(),
val total: Long = 0L
)
/**
* 自动价差计算响应(按 30 根历史 K 线 + IQR 剔除后 × 0.7
*/
data class CryptoTailAutoMinSpreadResponse(
val minSpreadUp: String = "0",
val minSpreadDown: String = "0"
)
/**
* 5/15 分钟市场项(供前端选择市场)
*/
data class CryptoTailMarketOptionDto(
val slug: String = "",
val title: String = "",
val intervalSeconds: Int = 0,
val periodStartUnix: Long = 0L,
val endDate: String? = null
)
/**
* 收益曲线请求
* @param strategyId 策略ID
* @param startDate 开始时间(毫秒时间戳),null 表示不限制
* @param endDate 结束时间(毫秒时间戳),null 表示不限制
*/
data class CryptoTailPnlCurveRequest(
val strategyId: Long = 0L,
val startDate: Long? = null,
val endDate: Long? = null
)
/**
* 收益曲线单点数据
*/
data class CryptoTailPnlCurvePoint(
/** 时间点(毫秒时间戳,结算时间或创建时间) */
val timestamp: Long = 0L,
/** 累计收益 USDC */
val cumulativePnl: String = "0",
/** 当笔收益 USDC */
val pointPnl: String = "0",
/** 截至该点累计已结算笔数 */
val settledCount: Long = 0L
)
/**
* 收益曲线响应
*/
data class CryptoTailPnlCurveResponse(
val strategyId: Long = 0L,
val strategyName: String = "",
/** 筛选范围内总已实现收益 USDC */
val totalRealizedPnl: String = "0",
val settledCount: Long = 0L,
val winCount: Long = 0L,
val winRate: String? = null,
/** 最大回撤 USDC(正数表示回撤幅度) */
val maxDrawdown: String? = null,
val curveData: List<CryptoTailPnlCurvePoint> = emptyList()
)
@@ -0,0 +1,23 @@
package com.wrbug.polymarketbot.dto
/**
* Polymarket Geoblock API 原始响应
*/
data class PolymarketGeoblockApiResponse(
val blocked: Boolean = false,
val ip: String = "",
val country: String = "",
val region: String = ""
)
/**
* 地域限制检查结果(返回给前端)
*/
data class GeoblockCheckDto(
val blocked: Boolean,
val ip: String,
val country: String,
val region: String,
val checkedAt: Long,
val source: String = "server"
)
@@ -0,0 +1,63 @@
package com.wrbug.polymarketbot.dto
/**
* 消息模板 DTO
*/
data class NotificationTemplateDto(
val id: Long? = null,
val templateType: String, // 模板类型
val templateContent: String, // 模板内容
val isDefault: Boolean = false, // 是否使用默认模板
val createdAt: Long? = null,
val updatedAt: Long? = null
)
/**
* 模板变量 DTO
*/
data class TemplateVariableDto(
val key: String, // 变量名,如 account_name
val category: String, // 分类:common, order, copy_trading, redeem, error
val sortOrder: Int = 0 // 排序顺序
)
/**
* 模板变量分类 DTO
*/
data class TemplateVariableCategoryDto(
val key: String, // 分类 key
val sortOrder: Int = 0 // 排序顺序
)
/**
* 模板变量列表响应
*/
data class TemplateVariablesResponse(
val templateType: String, // 模板类型
val categories: List<TemplateVariableCategoryDto>, // 分类列表
val variables: List<TemplateVariableDto> // 变量列表
)
/**
* 更新模板请求
*/
data class UpdateTemplateRequest(
val templateContent: String // 模板内容
)
/**
* 测试模板请求
*/
data class TestTemplateRequest(
val templateType: String, // 模板类型
val templateContent: String? = null // 可选,如果不提供则使用已保存的模板
)
/**
* 模板类型信息
*/
data class TemplateTypeInfoDto(
val type: String, // 模板类型
val name: String, // 类型名称
val description: String // 类型描述
)
@@ -51,18 +51,21 @@ data class OrderPushMessage(
/**
* 订单详情(通过 API 获取)
* @param price 订单限价(用户提交的买入/卖出价)
* @param avgFilledPrice 实际成交价 = original_size * price / size_matched(有成交时优先用于推送展示)
*/
data class OrderDetailDto(
val id: String, // 订单 ID
val market: String, // 市场 ID (condition ID)
val side: String, // BUY/SELL
val price: String, // 价
val price: String, // 订单限
val size: String, // 订单大小
val filled: String, // 已成交数量
val status: String, // 订单状态
val createdAt: String, // 创建时间(ISO 8601 格式)
val marketName: String? = null, // 市场名称(通过 Data API 获取)
val marketSlug: String? = null, // 市场 slug
val marketIcon: String? = null // 市场图标
val marketIcon: String? = null, // 市场图标
val avgFilledPrice: String? = null // 实际成交价 = original_size*price/size_matched(有成交时使用)
)
@@ -36,6 +36,18 @@ data class SubscriptionProxyConfigRequest(
val type: String // CLASH 或 SS
)
/**
* 代理检查时的 Polymarket 地域限制检测结果
*/
data class ProxyCheckGeoblockResult(
val checked: Boolean,
val blocked: Boolean? = null,
val ip: String? = null,
val country: String? = null,
val region: String? = null,
val message: String? = null
)
/**
* 代理检查响应
*/
@@ -43,15 +55,22 @@ data class ProxyCheckResponse(
val success: Boolean,
val message: String,
val responseTime: Long? = null, // 响应时间(毫秒)
val latency: Long? = null // 延迟(毫秒),与 responseTime 相同,用于前端显示
val latency: Long? = null, // 延迟(毫秒),与 responseTime 相同,用于前端显示
val geoblock: ProxyCheckGeoblockResult? = null
) {
companion object {
fun create(success: Boolean, message: String, responseTime: Long? = null): ProxyCheckResponse {
fun create(
success: Boolean,
message: String,
responseTime: Long? = null,
geoblock: ProxyCheckGeoblockResult? = null
): ProxyCheckResponse {
return ProxyCheckResponse(
success = success,
message = message,
responseTime = responseTime,
latency = responseTime // latency 和 responseTime 相同
latency = responseTime,
geoblock = geoblock
)
}
}
@@ -16,11 +16,11 @@ data class Account(
@Column(name = "private_key", nullable = false, length = 500)
val privateKey: String, // 私钥(AES 加密存储)
@Column(name = "wallet_address", unique = true, nullable = false, length = 42)
val walletAddress: String, // 钱包地址(从私钥推导)
@Column(name = "wallet_address", nullable = false, length = 42)
val walletAddress: String, // 钱包地址(从私钥推导),同一 EOA 可有多个账户(不同代理类型)
@Column(name = "proxy_address", nullable = false, length = 42)
val proxyAddress: String, // Polymarket 代理钱包地址(从合约获取,必须)
@Column(name = "proxy_address", unique = true, nullable = false, length = 42)
val proxyAddress: String, // Polymarket 代理钱包地址(从合约获取,必须),唯一
@Column(name = "api_key", length = 500)
val apiKey: String? = null, // Polymarket API Key(可选,明文存储)
@@ -76,6 +76,12 @@ data class BacktestTask(
@Column(name = "max_position_value", precision = 20, scale = 8)
val maxPositionValue: BigDecimal? = null, // 最大仓位金额(USDC),NULL表示不启用
@Column(name = "min_price", precision = 20, scale = 8)
val minPrice: BigDecimal? = null, // 最低价格(可选),NULL表示不限制最低价
@Column(name = "max_price", precision = 20, scale = 8)
val maxPrice: BigDecimal? = null, // 最高价格(可选),NULL表示不限制最高价
// 统计字段
@Column(name = "avg_holding_time")
var avgHoldingTime: Long? = null, // 平均持仓时间(毫秒)
@@ -0,0 +1,73 @@
package com.wrbug.polymarketbot.entity
import com.wrbug.polymarketbot.enums.SpreadDirection
import com.wrbug.polymarketbot.enums.SpreadDirectionConverter
import com.wrbug.polymarketbot.enums.SpreadMode
import com.wrbug.polymarketbot.enums.SpreadModeConverter
import jakarta.persistence.*
import java.math.BigDecimal
/**
* 加密价差策略实体
* 5/15 分钟 Up or Down 市场,在周期内时间窗口、价格进入区间时市价买入
*/
@Entity
@Table(name = "crypto_tail_strategy")
data class CryptoTailStrategy(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "account_id", nullable = false)
val accountId: Long = 0L,
@Column(name = "name", length = 255)
val name: String? = null,
@Column(name = "market_slug_prefix", nullable = false, length = 64)
val marketSlugPrefix: String = "",
@Column(name = "interval_seconds", nullable = false)
val intervalSeconds: Int = 300,
@Column(name = "window_start_seconds", nullable = false)
val windowStartSeconds: Int = 0,
@Column(name = "window_end_seconds", nullable = false)
val windowEndSeconds: Int = 0,
@Column(name = "min_price", nullable = false, precision = 20, scale = 8)
val minPrice: BigDecimal = BigDecimal.ONE,
@Column(name = "max_price", nullable = false, precision = 20, scale = 8)
val maxPrice: BigDecimal = BigDecimal.ONE,
@Column(name = "amount_mode", nullable = false, length = 10)
val amountMode: String = "RATIO",
@Column(name = "amount_value", nullable = false, precision = 20, scale = 8)
val amountValue: BigDecimal = BigDecimal.ZERO,
/** 价差模式: NONE=不校验, FIXED=固定值, AUTO=历史计算 */
@Convert(converter = SpreadModeConverter::class)
@Column(name = "spread_mode", nullable = false, columnDefinition = "TINYINT")
val spreadMode: SpreadMode = SpreadMode.NONE,
/** 价差数值(FIXED 时必填;AUTO 时可存计算值) */
@Column(name = "spread_value", precision = 20, scale = 8)
val spreadValue: BigDecimal? = null,
/** 价差方向: MIN=最小价差(价差>=配置值触发),MAX=最大价差(价差<=配置值触发) */
@Convert(converter = SpreadDirectionConverter::class)
@Column(name = "spread_direction", nullable = false, columnDefinition = "TINYINT")
val spreadDirection: SpreadDirection = SpreadDirection.MIN,
@Column(name = "enabled", nullable = false)
val enabled: Boolean = true,
@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,67 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
import java.math.BigDecimal
import com.wrbug.polymarketbot.util.toSafeBigDecimal
/**
* 加密价差策略触发记录
*/
@Entity
@Table(name = "crypto_tail_strategy_trigger")
data class CryptoTailStrategyTrigger(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "strategy_id", nullable = false)
val strategyId: Long = 0L,
@Column(name = "period_start_unix", nullable = false)
val periodStartUnix: Long = 0L,
@Column(name = "market_title", length = 500)
val marketTitle: String? = null,
@Column(name = "outcome_index", nullable = false)
val outcomeIndex: Int = 0,
@Column(name = "trigger_price", nullable = false, precision = 20, scale = 8)
val triggerPrice: BigDecimal = BigDecimal.ZERO,
@Column(name = "amount_usdc", nullable = false, precision = 20, scale = 8)
val amountUsdc: BigDecimal = BigDecimal.ZERO,
@Column(name = "order_id", length = 128)
val orderId: String? = null,
@Column(name = "condition_id", length = 66)
val conditionId: String? = null,
@Column(name = "resolved", nullable = false)
val resolved: Boolean = false,
@Column(name = "winner_outcome_index")
val winnerOutcomeIndex: Int? = null,
@Column(name = "realized_pnl", precision = 20, scale = 8)
val realizedPnl: BigDecimal? = null,
@Column(name = "settled_at")
val settledAt: Long? = null,
@Column(name = "status", nullable = false, length = 20)
val status: String = "success",
@Column(name = "fail_reason", length = 500)
val failReason: String? = null,
@Column(name = "trigger_type", nullable = false, length = 20)
val triggerType: String = "AUTO",
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@Column(name = "notification_sent", nullable = false)
var notificationSent: Boolean = false
)
@@ -0,0 +1,30 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
/**
* 消息推送模板实体
* 用于存储用户自定义的消息模板
*/
@Entity
@Table(name = "notification_templates")
data class NotificationTemplate(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "template_type", unique = true, nullable = false, length = 50)
val templateType: String, // ORDER_SUCCESS, ORDER_FAILED, ORDER_FILTERED, CRYPTO_TAIL_SUCCESS, REDEEM_SUCCESS, REDEEM_NO_RETURN
@Column(name = "template_content", nullable = false, columnDefinition = "TEXT")
var templateContent: String, // 模板内容,支持 {{variable}} 变量
@Column(name = "is_default", nullable = false)
var isDefault: Boolean = false, // 是否使用默认模板
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@Column(name = "updated_at", nullable = false)
var updatedAt: Long = System.currentTimeMillis()
)
@@ -158,6 +158,13 @@ enum class ErrorCode(
ACCOUNT_BALANCE_FETCH_FAILED(4707, "查询账户余额失败", "error.account_balance_fetch_failed"),
ACCOUNT_POSITIONS_FETCH_FAILED(4708, "查询仓位列表失败", "error.account_positions_fetch_failed"),
// 加密价差策略 (4710-4729)
CRYPTO_TAIL_STRATEGY_NOT_FOUND(4710, "加密价差策略不存在", "error.crypto_tail_strategy_not_found"),
CRYPTO_TAIL_STRATEGY_WINDOW_INVALID(4711, "时间区间开始不能大于结束", "error.crypto_tail_strategy_window_invalid"),
CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED(4712, "时间区间不能超过周期长度", "error.crypto_tail_strategy_window_exceed"),
CRYPTO_TAIL_STRATEGY_INTERVAL_INVALID(4713, "周期仅支持 300 或 900 秒", "error.crypto_tail_strategy_interval_invalid"),
CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID(4714, "投入方式仅支持 RATIO 或 FIXED", "error.crypto_tail_strategy_amount_mode_invalid"),
// 统计相关 (4801-4899)
STATISTICS_FETCH_FAILED(4801, "获取统计信息失败", "error.statistics_fetch_failed"),
ORDER_LIST_FETCH_FAILED(4802, "查询订单列表失败", "error.order_list_fetch_failed"),
@@ -250,7 +257,14 @@ enum class ErrorCode(
SERVER_BACKTEST_HISTORICAL_DATA_FETCH_FAILED(5610, "历史数据获取失败", "error.server.backtest_historical_data_fetch_failed"),
SERVER_BACKTEST_STOP_FAILED(5611, "停止回测任务失败", "error.server.backtest_stop_failed"),
SERVER_BACKTEST_RETRY_FAILED(5612, "重试回测任务失败", "error.server.backtest_retry_failed"),
SERVER_BACKTEST_RERUN_FAILED(5613, "按配置重新测试失败", "error.server.backtest_rerun_failed");
SERVER_BACKTEST_RERUN_FAILED(5613, "按配置重新测试失败", "error.server.backtest_rerun_failed"),
// 加密价差策略服务 (5620-5629)
SERVER_CRYPTO_TAIL_STRATEGY_CREATE_FAILED(5620, "创建加密价差策略失败", "error.server.crypto_tail_strategy_create_failed"),
SERVER_CRYPTO_TAIL_STRATEGY_UPDATE_FAILED(5621, "更新加密价差策略失败", "error.server.crypto_tail_strategy_update_failed"),
SERVER_CRYPTO_TAIL_STRATEGY_DELETE_FAILED(5622, "删除加密价差策略失败", "error.server.crypto_tail_strategy_delete_failed"),
SERVER_CRYPTO_TAIL_STRATEGY_LIST_FETCH_FAILED(5623, "查询加密价差策略列表失败", "error.server.crypto_tail_strategy_list_fetch_failed"),
SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED(5624, "查询触发记录失败", "error.server.crypto_tail_strategy_triggers_fetch_failed");
companion object {
/**
@@ -0,0 +1,50 @@
package com.wrbug.polymarketbot.enums
/**
* 价差方向枚举
*/
enum class SpreadDirection(val value: Int, val description: String) {
/**
* 最小价差:价差 >= 配置值时触发,买入价固定 0.99
*/
MIN(0, "最小价差"),
/**
* 最大价差:价差 <= 配置值时触发,买入价 = 触发价 + 0.02
*/
MAX(1, "最大价差");
companion object {
/**
* 从数值解析价差方向
*/
fun fromValue(value: Int?): SpreadDirection {
if (value == null) {
return MIN // 默认返回 MIN
}
return values().find { it.value == value }
?: throw IllegalArgumentException("未知的价差方向: $value")
}
/**
* 安全地从数值解析价差方向,解析失败返回默认值
*/
fun fromValueOrDefault(value: Int?, default: SpreadDirection = MIN): SpreadDirection {
if (value == null) {
return default
}
return values().find { it.value == value } ?: default
}
/**
* 从字符串解析价差方向(兼容旧逻辑)
*/
fun fromString(value: String?): SpreadDirection {
if (value.isNullOrBlank()) {
return MIN
}
return values().find { it.name.equals(value, ignoreCase = true) }
?: throw IllegalArgumentException("未知的价差方向: $value")
}
}
}
@@ -0,0 +1,20 @@
package com.wrbug.polymarketbot.enums
import jakarta.persistence.AttributeConverter
import jakarta.persistence.Converter
/**
* SpreadDirection 枚举的 JPA 转换器
* 数据库存储为 TINYINT (0 = MIN, 1 = MAX)
*/
@Converter(autoApply = false)
class SpreadDirectionConverter : AttributeConverter<SpreadDirection, Int> {
override fun convertToDatabaseColumn(attribute: SpreadDirection?): Int {
return attribute?.value ?: SpreadDirection.MIN.value
}
override fun convertToEntityAttribute(dbData: Int?): SpreadDirection {
return SpreadDirection.fromValueOrDefault(dbData)
}
}
@@ -0,0 +1,55 @@
package com.wrbug.polymarketbot.enums
/**
* 价差模式枚举
*/
enum class SpreadMode(val value: Int, val description: String) {
/**
* 不校验价差
*/
NONE(0, ""),
/**
* 固定值:用户输入一个数值
*/
FIXED(1, "固定"),
/**
* 自动:系统按历史 K 线计算建议价差
*/
AUTO(2, "自动");
companion object {
/**
* 从数值解析价差模式
*/
fun fromValue(value: Int?): SpreadMode {
if (value == null) {
return NONE // 默认返回 NONE
}
return values().find { it.value == value }
?: throw IllegalArgumentException("未知的价差模式: $value")
}
/**
* 安全地从数值解析价差模式,解析失败返回默认值
*/
fun fromValueOrDefault(value: Int?, default: SpreadMode = NONE): SpreadMode {
if (value == null) {
return default
}
return values().find { it.value == value } ?: default
}
/**
* 从字符串解析价差模式(兼容旧逻辑)
*/
fun fromString(value: String?): SpreadMode {
if (value.isNullOrBlank()) {
return NONE
}
return values().find { it.name.equals(value, ignoreCase = true) }
?: throw IllegalArgumentException("未知的价差模式: $value")
}
}
}
@@ -0,0 +1,20 @@
package com.wrbug.polymarketbot.enums
import jakarta.persistence.AttributeConverter
import jakarta.persistence.Converter
/**
* SpreadMode 枚举的 JPA 转换器
* 数据库存储为 TINYINT (0 = NONE, 1 = FIXED, 2 = AUTO)
*/
@Converter(autoApply = false)
class SpreadModeConverter : AttributeConverter<SpreadMode, Int> {
override fun convertToDatabaseColumn(attribute: SpreadMode?): Int {
return attribute?.value ?: SpreadMode.NONE.value
}
override fun convertToEntityAttribute(dbData: Int?): SpreadMode {
return SpreadMode.fromValueOrDefault(dbData)
}
}
@@ -0,0 +1,51 @@
package com.wrbug.polymarketbot.enums
/**
* 钱包类型枚举
*/
enum class WalletType(val value: String, val description: String) {
/**
* Magic 钱包(邮箱/OAuth 登录)
* 使用 PROXY 代理合约,通过 Builder Relayer 执行 Gasless 交易
*/
MAGIC("magic", "Magic(邮箱/OAuth登录)"),
/**
* Safe 钱包(MetaMask 等 Web3 钱包)
* 使用 Gnosis Safe 代理合约,支持 Builder Relayer Gasless 或手动交易
*/
SAFE("safe", "SafeWeb3钱包)");
companion object {
/**
* 从字符串值解析钱包类型(不区分大小写)
*/
fun fromString(value: String?): WalletType {
if (value.isNullOrBlank()) {
return SAFE // 默认返回 SAFE
}
return values().find { it.value.equals(value, ignoreCase = true) }
?: throw IllegalArgumentException("未知的钱包类型: $value")
}
/**
* 安全地从字符串值解析钱包类型(不区分大小写),解析失败返回默认值
*/
fun fromStringOrDefault(value: String?, default: WalletType = SAFE): WalletType {
if (value.isNullOrBlank()) {
return default
}
return values().find { it.value.equals(value, ignoreCase = true) } ?: default
}
/**
* 检查字符串是否为有效的钱包类型
*/
fun isValid(value: String?): Boolean {
if (value.isNullOrBlank()) {
return false
}
return values().any { it.value.equals(value, ignoreCase = true) }
}
}
}
@@ -0,0 +1,8 @@
package com.wrbug.polymarketbot.event
import org.springframework.context.ApplicationEvent
/**
* 加密价差策略创建/更新/启用状态变更后发布,用于立即触发一轮执行检查。
*/
class CryptoTailStrategyChangedEvent(source: Any) : ApplicationEvent(source)
@@ -29,5 +29,10 @@ interface AccountRepository : JpaRepository<Account, Long> {
* 检查钱包地址是否存在
*/
fun existsByWalletAddress(walletAddress: String): Boolean
/**
* 检查代理地址是否存在
*/
fun existsByProxyAddress(proxyAddress: String): Boolean
}
@@ -0,0 +1,11 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
import org.springframework.data.jpa.repository.JpaRepository
interface CryptoTailStrategyRepository : JpaRepository<CryptoTailStrategy, Long> {
fun findAllByAccountId(accountId: Long): List<CryptoTailStrategy>
fun findAllByEnabledTrue(): List<CryptoTailStrategy>
fun findByAccountIdAndEnabled(accountId: Long, enabled: Boolean): List<CryptoTailStrategy>
}
@@ -0,0 +1,55 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.CryptoTailStrategyTrigger
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import java.math.BigDecimal
interface CryptoTailStrategyTriggerRepository : JpaRepository<CryptoTailStrategyTrigger, Long> {
fun findByStrategyIdAndPeriodStartUnix(strategyId: Long, periodStartUnix: Long): CryptoTailStrategyTrigger?
fun findAllByStrategyIdOrderByCreatedAtDesc(strategyId: Long, pageable: Pageable): Page<CryptoTailStrategyTrigger>
fun findAllByStrategyIdAndStatusOrderByCreatedAtDesc(strategyId: Long, status: String, pageable: Pageable): Page<CryptoTailStrategyTrigger>
fun countByStrategyIdAndStatus(strategyId: Long, status: String): Long
fun findAllByStrategyIdAndCreatedAtBetweenOrderByCreatedAtDesc(strategyId: Long, startInclusive: Long, endInclusive: Long, pageable: Pageable): Page<CryptoTailStrategyTrigger>
fun findAllByStrategyIdAndStatusAndCreatedAtBetweenOrderByCreatedAtDesc(strategyId: Long, status: String, startInclusive: Long, endInclusive: Long, pageable: Pageable): Page<CryptoTailStrategyTrigger>
fun countByStrategyIdAndCreatedAtBetween(strategyId: Long, startInclusive: Long, endInclusive: Long): Long
fun countByStrategyIdAndStatusAndCreatedAtBetween(strategyId: Long, status: String, startInclusive: Long, endInclusive: Long): Long
/** 轮询结算:仅处理下单成功的订单(status=success 且 orderId 非空)、且未结算的触发记录 */
fun findByStatusAndResolvedAndOrderIdIsNotNullOrderByCreatedAtAsc(status: String, resolved: Boolean): List<CryptoTailStrategyTrigger>
/** 根据订单 ID 查询加密价差策略触发记录 */
fun findByOrderId(orderId: String): CryptoTailStrategyTrigger?
/** 轮询发 TGstatus=success、orderId 非空、未发过通知,按创建时间正序 */
fun findByStatusAndOrderIdIsNotNullAndNotificationSentFalseOrderByCreatedAtAsc(status: String): List<CryptoTailStrategyTrigger>
/** 策略已结算订单的总已实现盈亏(用于收益统计) */
@Query("SELECT COALESCE(SUM(t.realizedPnl), 0) FROM CryptoTailStrategyTrigger t WHERE t.strategyId = :strategyId AND t.resolved = true")
fun sumRealizedPnlByStrategyId(@Param("strategyId") strategyId: Long): BigDecimal?
/** 策略已结算订单笔数(用于胜率分母) */
@Query("SELECT COUNT(t) FROM CryptoTailStrategyTrigger t WHERE t.strategyId = :strategyId AND t.resolved = true")
fun countResolvedByStrategyId(@Param("strategyId") strategyId: Long): Long
/** 策略已结算中赢的笔数(outcome_index = winner_outcome_index */
@Query("SELECT COUNT(t) FROM CryptoTailStrategyTrigger t WHERE t.strategyId = :strategyId AND t.resolved = true AND t.outcomeIndex = t.winnerOutcomeIndex")
fun countWinsByStrategyId(@Param("strategyId") strategyId: Long): Long
/** 收益曲线:已结算记录,按结算时间(无则创建时间)在区间内升序 */
@Query(
"SELECT t FROM CryptoTailStrategyTrigger t WHERE t.strategyId = :strategyId AND t.resolved = true " +
"AND COALESCE(t.settledAt, t.createdAt) >= :start AND COALESCE(t.settledAt, t.createdAt) <= :end " +
"ORDER BY COALESCE(t.settledAt, t.createdAt) ASC"
)
fun findResolvedByStrategyIdAndTimeRangeOrderBySettledAsc(
@Param("strategyId") strategyId: Long,
@Param("start") start: Long,
@Param("end") end: Long
): List<CryptoTailStrategyTrigger>
}
@@ -0,0 +1,11 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.NotificationTemplate
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface NotificationTemplateRepository : JpaRepository<NotificationTemplate, Long> {
fun findByTemplateType(templateType: String): NotificationTemplate?
fun existsByTemplateType(templateType: String): Boolean
}
@@ -3,12 +3,17 @@ package com.wrbug.polymarketbot.service.accounts
import com.wrbug.polymarketbot.api.TradeResponse
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.entity.Account
import com.wrbug.polymarketbot.enums.WalletType
import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import com.wrbug.polymarketbot.util.eq
import com.wrbug.polymarketbot.util.gt
import com.wrbug.polymarketbot.util.JsonUtils
import com.wrbug.polymarketbot.util.fromJson
import com.wrbug.polymarketbot.util.getEventSlug
import com.google.gson.JsonObject
import com.google.gson.JsonPrimitive
import com.wrbug.polymarketbot.service.common.PolymarketClobService
import com.wrbug.polymarketbot.service.common.BlockchainService
import com.wrbug.polymarketbot.service.common.MarketService
@@ -66,11 +71,6 @@ class AccountService(
return Result.failure(IllegalArgumentException("无效的钱包地址格式"))
}
// 2. 检查地址是否已存在
if (accountRepository.existsByWalletAddress(request.walletAddress)) {
return Result.failure(IllegalArgumentException("该钱包地址已存在"))
}
// 3. 验证私钥和地址的对应关系
// 注意:前端已经验证了私钥和地址的对应关系,这里只做格式验证
// 如果需要更严格的验证,可以使用以太坊库(如 web3j)进行验证
@@ -104,7 +104,8 @@ class AccountService(
// 5. 获取代理地址(必须成功,否则导入失败)
// 根据用户选择的钱包类型计算代理地址
val proxyAddress = runBlocking {
val proxyResult = blockchainService.getProxyAddress(request.walletAddress, request.walletType)
val walletTypeEnum = WalletType.fromStringOrDefault(request.walletType, WalletType.MAGIC)
val proxyResult = blockchainService.getProxyAddress(request.walletAddress, walletTypeEnum)
if (proxyResult.isSuccess) {
val address = proxyResult.getOrNull()
if (address != null) {
@@ -120,25 +121,31 @@ class AccountService(
}
}
// 6. 按代理地址去重:该代理地址已存在则不允许重复导入
if (accountRepository.existsByProxyAddress(proxyAddress)) {
return Result.failure(IllegalArgumentException("ACCOUNT_ALREADY_EXISTS"))
}
// 7. 加密敏感信息
val encryptedPrivateKey = cryptoUtils.encrypt(request.privateKey)
val encryptedApiSecret = apiKeyCreds.secret?.let { cryptoUtils.encrypt(it) }
val encryptedApiPassphrase = apiKeyCreds.passphrase?.let { cryptoUtils.encrypt(it) }
val encryptedApiSecret = apiKeyCreds.secret.let { cryptoUtils.encrypt(it) }
val encryptedApiPassphrase = apiKeyCreds.passphrase.let { cryptoUtils.encrypt(it) }
// 8. 生成账户名称(如果未提供,使用钱包地址后位)
// 8. 生成账户名称(如果未提供,使用 SAFE/MAGIC-代理地址后4位)
val accountName = if (request.accountName.isNullOrBlank()) {
val walletAddress = request.walletAddress.trim()
// 取地址后四位(去掉 0x 前缀后取后四位)
val addressWithoutPrefix = if (walletAddress.startsWith("0x") || walletAddress.startsWith("0X")) {
walletAddress.substring(2)
val walletTypeEnum = WalletType.fromStringOrDefault(request.walletType, WalletType.MAGIC)
val typeLabel = walletTypeEnum.name.uppercase()
val proxyWithoutPrefix = if (proxyAddress.startsWith("0x") || proxyAddress.startsWith("0X")) {
proxyAddress.substring(2)
} else {
walletAddress
proxyAddress
}
if (addressWithoutPrefix.length >= 4) {
addressWithoutPrefix.substring(addressWithoutPrefix.length - 4).uppercase()
val suffix = if (proxyWithoutPrefix.length >= 4) {
proxyWithoutPrefix.substring(proxyWithoutPrefix.length - 4).uppercase()
} else {
addressWithoutPrefix.uppercase()
proxyWithoutPrefix.uppercase()
}
"$typeLabel-$suffix"
} else {
request.accountName.trim()
}
@@ -171,6 +178,401 @@ class AccountService(
}
}
/**
* 检查代理地址选项(用于账户导入前选择代理类型)
* 私钥导入:返回 Magic 和 Safe 两个选项
* 助记词导入:仅返回 Safe 选项
*/
suspend fun checkProxyOptions(request: CheckProxyOptionsRequest): Result<CheckProxyOptionsResponse> {
return try {
// 1. 验证钱包地址格式
if (!isValidWalletAddress(request.walletAddress)) {
return Result.failure(IllegalArgumentException("无效的钱包地址格式"))
}
// 2. 验证至少提供了私钥或助记词之一
if (request.privateKey.isNullOrBlank() && request.mnemonic.isNullOrBlank()) {
return Result.failure(IllegalArgumentException("必须提供私钥或助记词"))
}
val options = mutableListOf<ProxyOptionDto>()
// 3. 判断导入类型
val isPrivateKeyImport = !request.privateKey.isNullOrBlank()
if (isPrivateKeyImport) {
// 私钥导入:并行获取 Magic 和 Safe 代理地址及资产
coroutineScope {
val magicDeferred = async {
try {
val proxyAddress = blockchainService.getProxyAddress(request.walletAddress, WalletType.MAGIC).getOrNull()
if (proxyAddress != null) {
val balance = blockchainService.getWalletBalance(proxyAddress).getOrNull()
ProxyOptionDto(
walletType = WalletType.MAGIC.value,
proxyAddress = proxyAddress,
descriptionKey = "accountImport.proxyOption.magic.description",
availableBalance = balance?.availableBalance ?: "0",
positionBalance = balance?.positionBalance ?: "0",
totalBalance = balance?.totalBalance ?: "0",
positionCount = balance?.positions?.size ?: 0,
hasAssets = (balance?.availableBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
(balance?.positionBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
(balance?.positions?.isNotEmpty() == true),
error = null
)
} else {
ProxyOptionDto(
walletType = "magic",
proxyAddress = "",
descriptionKey = "accountImport.proxyOption.magic.description",
availableBalance = "0",
positionBalance = "0",
totalBalance = "0",
positionCount = 0,
hasAssets = false,
error = "获取 Magic 代理地址失败"
)
}
} catch (e: Exception) {
logger.warn("获取 Magic 代理地址或资产失败: ${e.message}", e)
ProxyOptionDto(
walletType = "magic",
proxyAddress = blockchainService.calculateMagicProxyAddress(request.walletAddress),
descriptionKey = "accountImport.proxyOption.magic.description",
availableBalance = "0",
positionBalance = "0",
totalBalance = "0",
positionCount = 0,
hasAssets = false,
error = "获取资产信息失败: ${e.message}"
)
}
}
val safeDeferred = async {
try {
val proxyAddress = blockchainService.getProxyAddress(request.walletAddress, WalletType.SAFE).getOrNull()
if (proxyAddress != null) {
val balance = blockchainService.getWalletBalance(proxyAddress).getOrNull()
ProxyOptionDto(
walletType = WalletType.SAFE.value,
proxyAddress = proxyAddress,
descriptionKey = "accountImport.proxyOption.safe.description",
availableBalance = balance?.availableBalance ?: "0",
positionBalance = balance?.positionBalance ?: "0",
totalBalance = balance?.totalBalance ?: "0",
positionCount = balance?.positions?.size ?: 0,
hasAssets = (balance?.availableBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
(balance?.positionBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
(balance?.positions?.isNotEmpty() == true),
error = null
)
} else {
ProxyOptionDto(
walletType = "safe",
proxyAddress = "",
descriptionKey = "accountImport.proxyOption.safe.description",
availableBalance = "0",
positionBalance = "0",
totalBalance = "0",
positionCount = 0,
hasAssets = false,
error = "获取 Safe 代理地址失败"
)
}
} catch (e: Exception) {
logger.warn("获取 Safe 代理地址或资产失败: ${e.message}", e)
ProxyOptionDto(
walletType = "safe",
proxyAddress = "",
descriptionKey = "accountImport.proxyOption.safe.description",
availableBalance = "0",
positionBalance = "0",
totalBalance = "0",
positionCount = 0,
hasAssets = false,
error = "获取资产信息失败: ${e.message}"
)
}
}
val magicOption = magicDeferred.await()
val safeOption = safeDeferred.await()
// Safe 在前,Magic 在后
options.add(safeOption)
options.add(magicOption)
}
} else {
// 助记词导入:仅获取 Safe 代理地址及资产
try {
val proxyAddress = blockchainService.getProxyAddress(request.walletAddress, WalletType.SAFE).getOrNull()
if (proxyAddress != null) {
val balance = blockchainService.getWalletBalance(proxyAddress).getOrNull()
options.add(
ProxyOptionDto(
walletType = "safe",
proxyAddress = proxyAddress,
descriptionKey = "accountImport.proxyOption.safe.description",
availableBalance = balance?.availableBalance ?: "0",
positionBalance = balance?.positionBalance ?: "0",
totalBalance = balance?.totalBalance ?: "0",
positionCount = balance?.positions?.size ?: 0,
hasAssets = (balance?.availableBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
(balance?.positionBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
(balance?.positions?.isNotEmpty() == true),
error = null
)
)
} else {
options.add(
ProxyOptionDto(
walletType = "safe",
proxyAddress = "",
descriptionKey = "accountImport.proxyOption.safe.description",
availableBalance = "0",
positionBalance = "0",
totalBalance = "0",
positionCount = 0,
hasAssets = false,
error = "获取 Safe 代理地址失败"
)
)
}
} catch (e: Exception) {
logger.warn("获取 Safe 代理地址或资产失败: ${e.message}", e)
options.add(
ProxyOptionDto(
walletType = "safe",
proxyAddress = "",
descriptionKey = "accountImport.proxyOption.safe.description",
availableBalance = "0",
positionBalance = "0",
totalBalance = "0",
positionCount = 0,
hasAssets = false,
error = "获取资产信息失败: ${e.message}"
)
)
}
}
Result.success(CheckProxyOptionsResponse(options = options))
} catch (e: Exception) {
logger.error("检查代理地址选项失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* Polymarket 代币批准检查:pUSD 需授权的 spender 合约地址(Polygon 主网)
* 来源:Polymarket/magic-safe-builder-example README §6 Token Approvals
* 及 neg-risk-ctf-adapter 仓库 addresses.json (chainId 137)
*/
private val setupApprovalSpenders = mapOf(
"CTF_CONTRACT" to "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045", // Conditional Tokens
"CTF_EXCHANGE" to "0xE111180000d2663C0091e4f400237545B87B996B", // 普通市场交易所
"NEG_RISK_EXCHANGE" to "0xe2222d279d744050d28e00520010520000310F59", // 负风险市场交易所
"NEG_RISK_ADAPTER" to "0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296" // 负风险适配器(非 WCOL 地址)
)
/** USDC 精度(6 位小数) */
private val usdcDecimals = java.math.BigDecimal("1000000")
/** ERC20 无限授权额度(type(uint256).max),Polymarket 默认使用无限授权 */
private val unlimitedAllowance = BigInteger("115792089237316195423570985008687907853269984665640564039457584007913129639935")
/**
* 检查账户设置状态(代理部署、交易启用、代币批准)
* @param accountId 账户 ID
* @return AccountSetupStatusDto
*/
suspend fun checkAccountSetupStatus(accountId: Long): Result<AccountSetupStatusDto> {
return try {
if (accountId <= 0) {
return Result.failure(IllegalArgumentException("账户 ID 无效"))
}
val account = accountRepository.findById(accountId).orElse(null)
?: return Result.failure(IllegalArgumentException("账户不存在"))
val proxyAddress = account.proxyAddress
if (proxyAddress.isBlank()) {
return Result.success(
AccountSetupStatusDto(
proxyDeployed = false,
tradingEnabled = account.apiKey != null && account.apiSecret != null && account.apiPassphrase != null,
tokensApproved = false,
approvalDetails = null,
error = "代理地址为空"
)
)
}
// 步骤1:代理钱包是否已部署
val proxyDeployed = blockchainService.isProxyDeployed(proxyAddress)
// 步骤2:交易是否已启用(API 凭证是否已配置)
val tradingEnabled = account.apiKey != null &&
account.apiSecret != null &&
account.apiPassphrase != null
// 步骤3:代币是否已批准(USDC 对各 spender 的 allowance,默认无限授权)
val approvalDetails = mutableMapOf<String, String>()
var tokensApproved = true
for ((name, spender) in setupApprovalSpenders) {
val allowanceResult = blockchainService.getUsdcAllowance(proxyAddress, spender)
val allowance = allowanceResult.getOrNull() ?: BigInteger.ZERO
val displayAmount = if (allowance >= unlimitedAllowance) {
"unlimited"
} else {
java.math.BigDecimal(allowance).divide(usdcDecimals, 6, java.math.RoundingMode.DOWN).toPlainString()
}
approvalDetails[name] = displayAmount
if (allowance <= BigInteger.ZERO) {
tokensApproved = false
}
}
Result.success(
AccountSetupStatusDto(
proxyDeployed = proxyDeployed,
tradingEnabled = tradingEnabled,
tokensApproved = tokensApproved,
approvalDetails = approvalDetails,
error = null
)
)
} catch (e: Exception) {
logger.error("检查账户设置状态失败: accountId=$accountId, ${e.message}", e)
Result.failure(e)
}
}
/** 步骤1 跳转 URL(代理部署需在 Polymarket 完成) */
private val setupStep1RedirectUrl = "https://polymarket.com/settings/wallet"
/**
* 执行设置步骤(由后端实现或返回跳转)
* 步骤1:仅返回跳转 URL,由用户前往 Polymarket 完成部署
* 步骤2:创建/派生 API Key 并更新账户
* 步骤3:通过代理钱包批量执行 USDC 授权
*/
suspend fun executeSetupStep(accountId: Long, step: Int): Result<ExecuteSetupStepResponse> {
return try {
if (accountId <= 0) {
return Result.failure(IllegalArgumentException("账户 ID 无效"))
}
val account = accountRepository.findById(accountId).orElse(null)
?: return Result.failure(IllegalArgumentException("账户不存在"))
when (step) {
1 -> {
val walletType = WalletType.fromStringOrDefault(account.walletType, WalletType.MAGIC)
if (walletType == WalletType.MAGIC) {
Result.success(
ExecuteSetupStepResponse(
success = false,
redirectUrl = setupStep1RedirectUrl
)
)
} else {
val proxyAddress = account.proxyAddress
if (proxyAddress.isBlank()) {
return Result.failure(IllegalArgumentException("代理地址为空"))
}
val alreadyDeployed = blockchainService.isProxyDeployed(proxyAddress)
if (alreadyDeployed) {
Result.success(ExecuteSetupStepResponse(success = true))
} else {
val privateKey = decryptPrivateKey(account)
val deployResult = relayClientService.deploySafeViaBuilderRelayer(
privateKey = privateKey,
proxyAddress = proxyAddress,
fromAddress = account.walletAddress
)
deployResult.fold(
onSuccess = { txHash ->
Result.success(
ExecuteSetupStepResponse(
success = true,
transactionHash = txHash
)
)
},
onFailure = { e ->
logger.error("Safe 部署失败: accountId=$accountId, ${e.message}", e)
Result.failure(e)
}
)
}
}
}
2 -> {
val privateKey = decryptPrivateKey(account)
val result = apiKeyService.createOrDeriveApiKey(
privateKey = privateKey,
walletAddress = account.walletAddress,
chainId = 137L
)
if (result.isFailure) {
val e = result.exceptionOrNull()
logger.error("启用交易(API Key)失败: accountId=$accountId, ${e?.message}", e)
return Result.failure(e ?: IllegalStateException("获取 API Key 失败"))
}
val creds = result.getOrNull()
?: return Result.failure(IllegalStateException("API Key 返回为空"))
val encryptedSecret = creds.secret.let { cryptoUtils.encrypt(it) }
val encryptedPassphrase = creds.passphrase.let { cryptoUtils.encrypt(it) }
val updated = account.copy(
apiKey = creds.apiKey,
apiSecret = encryptedSecret,
apiPassphrase = encryptedPassphrase,
updatedAt = System.currentTimeMillis()
)
accountRepository.save(updated)
orderPushService.refreshSubscriptions()
Result.success(ExecuteSetupStepResponse(success = true))
}
3 -> {
val proxyAddress = account.proxyAddress
if (proxyAddress.isBlank()) {
return Result.failure(IllegalArgumentException("代理地址为空,请先完成步骤1"))
}
val privateKey = decryptPrivateKey(account)
val walletType = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
val approveTxs = setupApprovalSpenders.values.map { spender ->
relayClientService.createUsdcApproveTx(spender, unlimitedAllowance)
}
val multiSendTx = relayClientService.createMultiSendTx(approveTxs)
val executeResult = relayClientService.execute(
privateKey = privateKey,
proxyAddress = proxyAddress,
safeTx = multiSendTx,
walletType = walletType
)
executeResult.fold(
onSuccess = { txHash ->
Result.success(
ExecuteSetupStepResponse(
success = true,
transactionHash = txHash
)
)
},
onFailure = { e ->
logger.error("代币授权执行失败: accountId=$accountId, ${e.message}", e)
Result.failure(e)
}
)
}
else -> Result.failure(IllegalArgumentException("无效的步骤: $step,应为 1、2 或 3"))
}
} catch (e: Exception) {
logger.error("执行设置步骤失败: accountId=$accountId, step=$step, ${e.message}", e)
Result.failure(e)
}
}
/**
* 更新账户信息
*/
@@ -537,7 +939,38 @@ class AccountService(
throw RuntimeException("解密私钥失败: ${e.message}", e)
}
}
/**
* 轮询用:遍历所有账户,对代理地址 WCOL 余额 > 0 的执行解包。
* 由 WcolUnwrapJobService 每 20 秒调用,赎回后无需在赎回流程内等待确认与解包。
*/
suspend fun runWcolUnwrapForAllAccounts() {
val accounts = accountRepository.findAllByOrderByCreatedAtAsc()
if (accounts.isEmpty()) return
for (account in accounts) {
try {
val privateKey = decryptPrivateKey(account)
val walletType = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
blockchainService.unwrapWcolForProxy(
privateKey = privateKey,
proxyAddress = account.proxyAddress,
walletType = walletType
).fold(
onSuccess = { txHash ->
if (txHash != null) {
logger.info("轮询解包 WCOL: accountId=${account.id}, proxy=${account.proxyAddress.take(10)}..., txHash=$txHash")
}
},
onFailure = { e ->
logger.warn("轮询解包 WCOL 失败 accountId=${account.id}: ${e.message}")
}
)
} catch (e: Exception) {
logger.warn("轮询解包 WCOL 跳过 accountId=${account.id}: ${e.message}")
}
}
}
/**
* 解密账户 API Secret
*/
@@ -698,7 +1131,7 @@ class AccountService(
// 3. 验证仓位是否存在并获取原始数量
val positionsResult = getAllPositions()
val (position, originalQuantity) = positionsResult.fold(
val (_, originalQuantity) = positionsResult.fold(
onSuccess = { positionListResponse ->
val position = positionListResponse.currentPositions.find {
it.accountId == request.accountId &&
@@ -731,7 +1164,7 @@ class AccountService(
onFailure = { e ->
return Result.failure(Exception("查询仓位失败: ${e.message}"))
}
) ?: return Result.failure(IllegalArgumentException("仓位不存在"))
)
// 4. 计算实际卖出数量
val sellQuantity = if (percentDecimal != null) {
@@ -811,23 +1244,10 @@ class AccountService(
else -> "GTC"
}
// GTC 和 FOK 订单的 expiration 必须为 "0"
// 只有 GTD 订单才需要设置具体的过期时间
val expiration = "0"
// 7. 解密私钥
val decryptedPrivateKey = decryptPrivateKey(account)
// 获取费率(根据 Polymarket Maker Rebates Program 要求
val feeRateResult = clobService.getFeeRate(tokenId)
val feeRateBps = if (feeRateResult.isSuccess) {
feeRateResult.getOrNull()?.toString() ?: "0"
} else {
logger.warn("获取费率失败,使用默认值 0: tokenId=$tokenId, error=${feeRateResult.exceptionOrNull()?.message}")
"0"
}
// 11. 创建并签名订单(使用计算后的卖出数量)
// 11. 创建并签名订单(使用计算后的卖出数量,按账户钱包类型使用对应 signatureType
val signedOrder = try {
orderSigningService.createAndSignOrder(
privateKey = decryptedPrivateKey,
@@ -836,10 +1256,7 @@ class AccountService(
side = "SELL",
price = sellPrice,
size = sellQuantity.toPlainString(), // 使用计算后的卖出数量
signatureType = 2, // Browser Wallet(与正确订单数据一致)
nonce = "0",
feeRateBps = feeRateBps, // 使用动态获取的费率
expiration = expiration
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
)
} catch (e: Exception) {
logger.error("创建并签名订单失败", e)
@@ -850,9 +1267,8 @@ class AccountService(
val newOrderRequest = com.wrbug.polymarketbot.api.NewOrderRequest(
order = signedOrder,
owner = account.apiKey!!, // API Key
orderType = orderType,
deferExec = false
owner = account.apiKey, // API Key
orderType = orderType
)
// 13. 解密 API 凭证并使用账户的API凭证创建订单
@@ -870,7 +1286,7 @@ class AccountService(
}
val clobApi = retrofitFactory.createClobApi(
account.apiKey!!,
account.apiKey,
apiSecret,
apiPassphrase,
account.walletAddress
@@ -901,13 +1317,22 @@ class AccountService(
// 使用当前时间作为订单创建时间
val orderTime = System.currentTimeMillis()
// 查询可用余额
val availableBalance = try {
blockchainService.getUsdcBalance(account.walletAddress, account.proxyAddress).getOrNull()
} catch (e: Exception) {
logger.warn("查询可用余额失败: accountId=${account.id}, ${e.message}")
null
}
telegramNotificationService?.sendOrderSuccessNotification(
orderId = orderId,
marketTitle = marketTitle,
marketId = request.marketId,
marketSlug = marketSlug,
side = request.side,
side = "SELL", // 手动卖出订单,方向固定为 SELL
outcome = request.side, // request.side 是市场方向(YES/NO
price = sellPrice, // 直接传递卖出价格
size = sellQuantity.toPlainString(), // 直接传递卖出数量
accountName = account.accountName,
@@ -918,7 +1343,8 @@ class AccountService(
apiPassphrase = try { cryptoUtils.decrypt(account.apiPassphrase!!) } catch (e: Exception) { null },
walletAddressForApi = account.walletAddress,
locale = locale,
orderTime = orderTime // 使用订单创建时间
orderTime = orderTime, // 使用订单创建时间
availableBalance = availableBalance
)
} catch (e: Exception) {
logger.warn("发送订单成功通知失败: ${e.message}", e)
@@ -938,7 +1364,7 @@ class AccountService(
)
)
} else {
val errorMsg = response.errorMsg ?: "未知错误"
val errorMsg = response.getErrorMessage()
val fullErrorMsg = "创建订单失败: accountId=${account.id}, marketId=${request.marketId}, side=${request.side}, orderType=${request.orderType}, price=${if (request.orderType == "LIMIT") sellPrice else "MARKET"}, quantity=${sellQuantity.toPlainString()}, errorMsg=$errorMsg"
logger.error(fullErrorMsg)
@@ -983,6 +1409,14 @@ class AccountService(
} catch (e: Exception) {
null
}
// 尝试从 errorBody 解析 error 字段(使用 Gson
val apiError = try {
(errorBody?.fromJson<JsonObject>()?.get("error") as? JsonPrimitive)?.asString
} catch (e: Exception) {
null
}
val fullErrorMsg = "创建订单失败: accountId=${account.id}, marketId=${request.marketId}, side=${request.side}, orderType=${request.orderType}, price=${if (request.orderType == "LIMIT") sellPrice else "MARKET"}, quantity=${sellQuantity.toPlainString()}, code=${orderResponse.code()}, message=${orderResponse.message()}${if (errorBody != null) ", errorBody=$errorBody" else ""}"
logger.error(fullErrorMsg)
@@ -1001,8 +1435,10 @@ class AccountService(
java.util.Locale("zh", "CN") // 默认简体中文
}
// 只传递后端返回的 msg,不传递完整堆栈
val errorMsg = orderResponse.body()?.errorMsg ?: "创建订单失败"
// 优先使用解析的 API error,其次使用响应体的 errorMsg,最后使用默认消息
val errorMsg = apiError
?: orderResponse.body()?.getErrorMessage()
?: "创建订单失败 (HTTP ${orderResponse.code()})"
telegramNotificationService?.sendOrderFailureNotification(
marketTitle = marketTitle,
@@ -1012,7 +1448,7 @@ class AccountService(
outcome = null, // 失败时可能没有 outcome
price = if (request.orderType == "LIMIT") sellPrice.toString() else "MARKET",
size = sellQuantity.toString(),
errorMessage = errorMsg, // 只传递后端返回的 msg
errorMessage = errorMsg, // 只传递后端返回的错误信息
accountName = account.accountName,
walletAddress = account.walletAddress,
locale = locale
@@ -1189,13 +1625,6 @@ class AccountService(
*/
suspend fun redeemPositions(request: PositionRedeemRequest): Result<PositionRedeemResponse> {
return try {
// 检查 Builder API Key 是否已配置
if (!relayClientService.isBuilderApiKeyConfigured()) {
return Result.failure(
IllegalStateException("Builder API Key 未配置,无法执行 Gasless 交易。请前往系统设置页面配置 Builder API Key。")
)
}
if (request.positions.isEmpty()) {
return Result.failure(IllegalArgumentException("赎回仓位列表不能为空"))
}
@@ -1217,7 +1646,17 @@ class AccountService(
accounts[accountId] = account
}
// 4. 验证并收集要赎回的仓位信息(按账户分组
// 4. 若涉及 Magic 账户,必须已配置 Builder API Key(提前判断,避免执行到深层再报错
val hasMagicAccount = accounts.values.any {
WalletType.fromStringOrDefault(it.walletType, WalletType.SAFE) == WalletType.MAGIC
}
if (hasMagicAccount && !relayClientService.isBuilderApiKeyConfigured()) {
return Result.failure(
IllegalStateException("Builder API Key 未配置,无法执行 Magic 账户赎回(Gasless)。请前往系统设置页面配置 Builder API Key。")
)
}
// 5. 验证并收集要赎回的仓位信息(按账户分组)
val accountRedeemData = mutableMapOf<Long, MutableList<Pair<AccountPositionDto, BigInteger>>>()
val accountRedeemedInfo =
mutableMapOf<Long, MutableList<com.wrbug.polymarketbot.dto.RedeemedPositionInfo>>()
@@ -1260,7 +1699,7 @@ class AccountService(
accountRedeemedInfo[accountId] = accountInfo
}
// 5. 对每个账户执行赎回
// 6. 对每个账户执行赎回Safe 与 Magic 均支持,Magic 通过 Builder Relayer PROXY Gasless 执行)
val accountTransactions = mutableListOf<com.wrbug.polymarketbot.dto.AccountRedeemTransaction>()
var totalRedeemedValue = BigDecimal.ZERO
@@ -1271,20 +1710,31 @@ class AccountService(
// 按市场分组(同一市场的仓位可以批量赎回)
val positionsByMarket = positions.groupBy { it.first.marketId }
// 对每个市场执行赎回
// 获取钱包类型
val walletTypeEnum = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
// 解密私钥(只需解密一次)
val decryptedPrivateKey = decryptPrivateKey(account)
// 执行赎回
var lastTxHash: String? = null
for ((marketId, marketPositions) in positionsByMarket) {
val indexSets = marketPositions.map { it.second }
// 解密私钥
val decryptedPrivateKey = decryptPrivateKey(account)
// Safe 钱包且有多个市场:使用 MultiSend 批量赎回
if (walletTypeEnum == WalletType.SAFE && positionsByMarket.size > 1) {
val redeemRequests = mutableListOf<Triple<String, List<BigInteger>, Boolean>>()
for ((marketId, marketPositions) in positionsByMarket) {
val indexSets = marketPositions.map { it.second }
val isNegRisk = marketService.getNegRiskByConditionId(marketId) == true
redeemRequests.add(Triple(marketId, indexSets, isNegRisk))
}
// 调用区块链服务赎回仓位
val redeemResult = blockchainService.redeemPositions(
logger.info("账户 $accountId: 使用 MultiSend 批量赎回 ${redeemRequests.size} 个市场")
val redeemResult = blockchainService.redeemPositionsBatch(
privateKey = decryptedPrivateKey,
proxyAddress = account.proxyAddress,
conditionId = marketId,
indexSets = indexSets
redeemRequests = redeemRequests,
walletType = walletTypeEnum
)
redeemResult.fold(
@@ -1292,11 +1742,38 @@ class AccountService(
lastTxHash = txHash
},
onFailure = { e ->
logger.error("账户 $accountId 市场 $marketId 赎回失败: ${e.message}", e)
return Result.failure(Exception("赎回失败: 账户 $accountId 市场 $marketId - ${e.message}"))
logger.error("账户 $accountId MultiSend 批量赎回失败: ${e.message}", e)
return Result.failure(Exception("赎回失败: 账户 $accountId - ${e.message}"))
}
)
} else {
// Magic 钱包或单个市场:逐笔赎回
for ((marketId, marketPositions) in positionsByMarket) {
val indexSets = marketPositions.map { it.second }
val isNegRisk = marketService.getNegRiskByConditionId(marketId) == true
val redeemResult = blockchainService.redeemPositions(
privateKey = decryptedPrivateKey,
proxyAddress = account.proxyAddress,
conditionId = marketId,
indexSets = indexSets,
isNegRisk = isNegRisk,
walletType = walletTypeEnum
)
redeemResult.fold(
onSuccess = { txHash ->
lastTxHash = txHash
},
onFailure = { e ->
logger.error("账户 $accountId 市场 $marketId 赎回失败: ${e.message}", e)
return Result.failure(Exception("赎回失败: 账户 $accountId 市场 $marketId - ${e.message}"))
}
)
}
}
// WCOL 解包由 WcolUnwrapJobService 每 20 秒轮询统一处理,赎回流程不再等待确认与解包
// 计算该账户的赎回总价值
val accountTotalValue = redeemedInfo.fold(BigDecimal.ZERO) { sum, info ->
@@ -1315,7 +1792,7 @@ class AccountService(
)
}
// 6. 发送赎回推送通知(异步,不阻塞)
// 7. 发送赎回推送通知(异步,不阻塞)
notificationScope.launch {
try {
// 获取当前语言设置
@@ -1329,16 +1806,42 @@ class AccountService(
for (transaction in accountTransactions) {
val account = accounts[transaction.accountId]
if (account != null) {
telegramNotificationService?.sendRedeemNotification(
accountName = account.accountName,
walletAddress = account.walletAddress,
transactionHash = transaction.transactionHash,
totalRedeemedValue = transaction.positions.fold(BigDecimal.ZERO) { sum, info ->
sum.add(info.value.toSafeBigDecimal())
}.toPlainString(),
positions = transaction.positions,
locale = locale
)
// 查询可用余额
val availableBalance = try {
blockchainService.getUsdcBalance(account.walletAddress, account.proxyAddress).getOrNull()
} catch (e: Exception) {
logger.warn("查询可用余额失败: accountId=${account.id}, ${e.message}")
null
}
// 计算该账户的赎回总价值
val accountTotalValue = transaction.positions.fold(BigDecimal.ZERO) { sum, info ->
sum.add(info.value.toSafeBigDecimal())
}
// 根据赎回价值选择不同的通知类型
if (accountTotalValue.gt(BigDecimal.ZERO)) {
// 有收益:发送赎回成功通知
telegramNotificationService?.sendRedeemNotification(
accountName = account.accountName,
walletAddress = account.walletAddress,
transactionHash = transaction.transactionHash,
totalRedeemedValue = accountTotalValue.toPlainString(),
positions = transaction.positions,
locale = locale,
availableBalance = availableBalance
)
} else {
// 无收益(输的仓位):发送已结算无收益通知
telegramNotificationService?.sendRedeemNoReturnNotification(
accountName = account.accountName,
walletAddress = account.walletAddress,
transactionHash = transaction.transactionHash,
positions = transaction.positions,
locale = locale,
availableBalance = availableBalance
)
}
}
}
} catch (e: Exception) {
@@ -1405,6 +1908,32 @@ class AccountService(
false
}
}
/**
* 将账户的 USDC.e wrap 为 pUSD
*/
suspend fun wrapUsdcToPusd(accountId: Long): Result<String?> {
val account = accountRepository.findById(accountId).orElse(null)
?: return Result.failure(IllegalArgumentException("账户不存在"))
if (account.proxyAddress.isBlank()) {
return Result.failure(IllegalStateException("账户代理地址不存在"))
}
val privateKey = cryptoUtils.decrypt(account.privateKey)
val walletType = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
return blockchainService.wrapUsdcToPusd(privateKey, account.proxyAddress, walletType)
}
/**
* 查询 USDC.e 余额(用于迁移提示)
*/
suspend fun getUsdceBalance(accountId: Long): Result<BigDecimal> {
val account = accountRepository.findById(accountId).orElse(null)
?: return Result.failure(IllegalArgumentException("账户不存在"))
if (account.proxyAddress.isBlank()) {
return Result.failure(IllegalStateException("账户代理地址不存在"))
}
return blockchainService.queryUsdceBalance(account.proxyAddress)
}
}
@@ -25,6 +25,7 @@ import com.wrbug.polymarketbot.service.common.MarketPriceService
import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
/**
* 仓位检查服务
@@ -77,7 +78,10 @@ class PositionCheckService(
// 同步锁,确保订阅任务的启动和停止是线程安全的
private val lock = Any()
// 防止 checkRedeemablePositions 重入:上一轮检查未完成时,新一轮轮询直接跳过
private val redeemCheckInProgress = AtomicBoolean(false)
/**
* 初始化服务(订阅 PositionPollingService 的事件,启动缓存清理任务)
*/
@@ -328,18 +332,23 @@ class PositionCheckService(
/**
* 逻辑1:处理待赎回仓位
https://clob.polymarket.com * 按照以下逻辑处理:
* 按照以下逻辑处理:
* 1. 无待赎回仓位:跳过
* 2. (未配置apikey || autoredeem==false) && 有待赎回的仓位:发送通知事件
* 3. (已配置) && 有待赎回的仓位:处理订单逻辑
* 防重入:上一轮检查未完成时,本轮直接跳过,避免并发赎回。
*/
private suspend fun checkRedeemablePositions(redeemablePositions: List<AccountPositionDto>) {
if (!redeemCheckInProgress.compareAndSet(false, true)) {
logger.debug("跳过本次待赎回仓位检查:上一次检查尚未完成")
return
}
try {
// 1. 无待赎回仓位:跳过
if (redeemablePositions.isEmpty()) {
return
}
// 检查系统级别的自动赎回配置
val autoRedeemEnabled = systemConfigService.isAutoRedeemEnabled()
val apiKeyConfigured = relayClientService.isBuilderApiKeyConfigured()
@@ -373,21 +382,24 @@ class PositionCheckService(
}
return // 未配置时直接返回,不进行后续处理
}
// Builder Relayer 配额冷却期内不再发起赎回(如 API 返回 quota exceeded, resets in N seconds
if (relayClientService.isBuilderRelayerQuotaBlocked()) {
val remaining = relayClientService.getBuilderRelayerQuotaBlockedRemainingSeconds()
logger.info("Builder Relayer 配额冷却中,跳过本次自动赎回,约 ${remaining} 秒后恢复")
return
}
// 3. (已配置) && 有待赎回的仓位:处理订单逻辑
// 自动赎回已开启且已配置 API Key,按账户分组进行赎回处理
// 先执行赎回,赎回成功后再查找订单并更新订单状态
val positionsByAccount = redeemablePositions.groupBy { it.accountId }
for ((accountId, positions) in positionsByAccount) {
// 查找该账户下所有启用的跟单配置
// 查找该账户下所有启用的跟单配置(仅用于赎回成功后更新跟单订单状态;无跟单配置的账户如加密价差策略账户也会执行赎回)
val copyTradings = copyTradingRepository.findByAccountId(accountId)
.filter { it.enabled }
if (copyTradings.isEmpty()) {
continue
}
// 过滤掉已经处理过的仓位(去重,避免重复赎回)
val now = System.currentTimeMillis()
val positionsToRedeem = positions.filter { position ->
@@ -455,9 +467,11 @@ class PositionCheckService(
}
} catch (e: Exception) {
logger.error("处理待赎回仓位异常: ${e.message}", e)
} finally {
redeemCheckInProgress.set(false)
}
}
/**
* 逻辑2:处理未卖出订单
* 检查所有未卖出的订单,匹配仓位
@@ -0,0 +1,51 @@
package com.wrbug.polymarketbot.service.accounts
import com.wrbug.polymarketbot.service.system.RelayClientService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.slf4j.LoggerFactory
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
/**
* WCOL 解包轮询任务
* 20 秒轮询一次遍历所有账户的代理地址 WCOL 余额 > 0 则执行解包
* 同一时间仅允许单次执行若上次执行未结束则本次忽略与现有轮询逻辑一致
* 若未配置 Builder API Key直接跳过本轮解包依赖 Relayer Gasless未配置则无法执行
*/
@Service
class WcolUnwrapJobService(
private val accountService: AccountService,
private val relayClientService: RelayClientService
) {
private val logger = LoggerFactory.getLogger(WcolUnwrapJobService::class.java)
private val scope = kotlinx.coroutines.CoroutineScope(Dispatchers.IO + SupervisorJob())
private var unwrapJob: Job? = null
/**
* 20 秒触发一次若未配置 Builder Key 或当前任务仍在执行则跳过本次
*/
@Scheduled(fixedRate = 20_000)
fun runWcolUnwrapPolling() {
if (!relayClientService.isBuilderApiKeyConfigured()) {
logger.debug("Builder API Key 未配置,跳过 WCOL 解包轮询")
return
}
if (unwrapJob?.isActive == true) {
logger.debug("上一轮 WCOL 解包任务仍在执行,跳过本次")
return
}
unwrapJob = scope.launch {
try {
accountService.runWcolUnwrapForAllAccounts()
} catch (e: Exception) {
logger.error("WCOL 解包轮询异常: ${e.message}", e)
} finally {
unwrapJob = null
}
}
}
}
@@ -73,6 +73,8 @@ class BacktestExecutionService(
minOrderDepth = null, // 回测无实时订单簿数据
maxSpread = null, // 回测无实时价差数据
maxPositionValue = task.maxPositionValue,
minPrice = task.minPrice, // 最低价格
maxPrice = task.maxPrice, // 最高价格
keywordFilterMode = task.keywordFilterMode,
keywords = task.keywords,
configName = null,
@@ -406,10 +408,25 @@ class BacktestExecutionService(
val cost = actualSellQuantity.multiply(position.avgPrice)
val profitLoss = netAmount.subtract(cost)
// 更新余额和持仓
// Bug #39 Fix: correctly reduce position quantity after sell
currentBalance += netAmount
if (position.quantity <= BigDecimal.ZERO) {
val remainingQuantity = position.quantity - actualSellQuantity
val remainingLeaderBuyQuantity = if (position.leaderBuyQuantity != null && position.leaderBuyQuantity > BigDecimal.ZERO) {
val totalQty = position.quantity
val leaderReduction = actualSellQuantity.divide(
totalQty, 8, java.math.RoundingMode.DOWN
)
(position.leaderBuyQuantity - leaderReduction).coerceAtLeast(BigDecimal.ZERO)
} else {
position.leaderBuyQuantity
}
if (remainingQuantity <= BigDecimal.ZERO) {
positions.remove(positionKey)
} else {
positions[positionKey] = position.copy(
quantity = remainingQuantity,
leaderBuyQuantity = remainingLeaderBuyQuantity
)
}
// 记录交易到当前页列表
@@ -630,7 +647,8 @@ class BacktestExecutionService(
val settlementPrice = avgPrice
val settlementValue = quantity.multiply(settlementPrice)
val profitLoss = settlementValue.negate()
// Bug #39 Fix: profitLoss for closed settlement at avgPrice should be ~0
val profitLoss = settlementValue.subtract(quantity.multiply(avgPrice))
balance += settlementValue
@@ -700,7 +718,8 @@ class BacktestExecutionService(
if (balance > peakBalance) {
peakBalance = balance
}
val drawdown = peakBalance - runningBalance
// Bug #39 Fix: use current balance, not runningBalance from previous iteration
val drawdown = peakBalance - balance
if (drawdown > maxDrawdown) {
maxDrawdown = drawdown
}
@@ -82,7 +82,9 @@ class BacktestService(
} else {
null
},
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal()
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
minPrice = request.minPrice?.toSafeBigDecimal(),
maxPrice = request.maxPrice?.toSafeBigDecimal()
)
backtestTaskRepository.save(task)
@@ -192,7 +194,9 @@ class BacktestService(
} else {
emptyList()
},
maxPositionValue = task.maxPositionValue?.toPlainString()
maxPositionValue = task.maxPositionValue?.toPlainString(),
minPrice = task.minPrice?.toPlainString(),
maxPrice = task.maxPrice?.toPlainString()
)
val statistics = BacktestStatisticsDto(
@@ -379,7 +383,9 @@ class BacktestService(
supportSell = source.supportSell,
keywordFilterMode = source.keywordFilterMode,
keywords = source.keywords,
maxPositionValue = source.maxPositionValue
maxPositionValue = source.maxPositionValue,
minPrice = source.minPrice,
maxPrice = source.maxPrice
)
backtestTaskRepository.save(newTask)
@@ -0,0 +1,134 @@
package com.wrbug.polymarketbot.service.binance
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.RoundingMode
import java.util.concurrent.ConcurrentHashMap
/**
* 自动最小价差按周期计算每个周期首次需要时拉取该周期前的 20 根已收盘 K 线按方向筛选IQR 剔除后求平均缓存 100% 基准值 (marketSlugPrefix, interval, period)
* 触发时由调用方按窗口进度计算动态系数100%50%后得到有效最小价差不在保存策略时计算
*/
@Service
class BinanceKlineAutoSpreadService(
private val retrofitFactory: RetrofitFactory
) {
private val logger = LoggerFactory.getLogger(BinanceKlineAutoSpreadService::class.java)
/** 市场 slug 前缀 -> Binance 交易对映射 */
private val marketToSymbol = mapOf(
"btc-updown" to "BTCUSDC",
"eth-updown" to "ETHUSDC",
"sol-updown" to "SOLUSDC",
"xrp-updown" to "XRPUSDC"
)
private val historyLimit = 20
private val minSamplesAfterIqr = 3
/** (marketSlugPrefix, intervalSeconds, periodStartUnix) -> (baseSpreadUp, baseSpreadDown)100% 基准价差 */
private val cache = ConcurrentHashMap<String, Pair<BigDecimal, BigDecimal>>()
/** 缓存保留时间(秒),超过则清理,防止无界增长 */
private val cacheExpireSeconds = 3600L
/** 从市场 slug 前缀获取 Binance 交易对;支持完整 slug(如 eth-updown-5m)或前缀(如 eth-updown */
private fun getSymbol(marketSlugPrefix: String): String? {
val base = marketSlugPrefix.lowercase().removeSuffix("-15m").removeSuffix("-5m")
return marketToSymbol[base]
}
private fun cacheKey(marketSlugPrefix: String, intervalSeconds: Int, periodStartUnix: Long): String {
return "$marketSlugPrefix-$intervalSeconds-$periodStartUnix"
}
/** 清理已过期的价差缓存,避免内存泄漏 */
private fun cleanExpiredCache() {
val nowSeconds = System.currentTimeMillis() / 1000
val expireThreshold = nowSeconds - cacheExpireSeconds
val keysToRemove = cache.keys.filter { key ->
// key 格式: marketSlugPrefix-intervalSeconds-periodStartUnix
val parts = key.split('-')
if (parts.size >= 3) {
parts.last().toLongOrNull()?.let { it < expireThreshold } ?: false
} else {
false
}
}
keysToRemove.forEach { cache.remove(it) }
}
/** 返回该周期、该方向的 100% 基准价差,供调用方按窗口进度应用动态系数。 */
fun getAutoMinSpreadBase(marketSlugPrefix: String, intervalSeconds: Int, periodStartUnix: Long, outcomeIndex: Int): BigDecimal? {
val key = cacheKey(marketSlugPrefix, intervalSeconds, periodStartUnix)
val (up, down) = cache[key] ?: run {
computeAndCache(marketSlugPrefix, intervalSeconds, periodStartUnix) ?: return null
}
return if (outcomeIndex == 0) up else down
}
/** 计算并缓存 100% 基准价差(IQR 平均,不乘系数)。预加载与触发时共用此缓存。 */
fun computeAndCache(marketSlugPrefix: String, intervalSeconds: Int, periodStartUnix: Long): Pair<BigDecimal, BigDecimal>? {
cleanExpiredCache()
val symbol = getSymbol(marketSlugPrefix) ?: run {
logger.warn("不支持的市场 slug 前缀: $marketSlugPrefix")
return null
}
val intervalStr = if (intervalSeconds == 300) "5m" else "15m"
val endTimeMs = periodStartUnix * 1000L
val klines = fetchKlines(symbol, intervalStr, historyLimit, endTime = endTimeMs) ?: return null
val spreadsUp = mutableListOf<BigDecimal>()
val spreadsDown = mutableListOf<BigDecimal>()
for (k in klines) {
if (k.size < 5) continue
val openP = k.getOrNull(1)?.toString()?.toSafeBigDecimal() ?: continue
val closeP = k.getOrNull(4)?.toString()?.toSafeBigDecimal() ?: continue
if (closeP > openP) spreadsUp.add(closeP.subtract(openP))
if (closeP < openP) spreadsDown.add(openP.subtract(closeP))
}
val baseUp = averageAfterIqr(spreadsUp).setScale(8, RoundingMode.HALF_UP)
val baseDown = averageAfterIqr(spreadsDown).setScale(8, RoundingMode.HALF_UP)
cache[cacheKey(marketSlugPrefix, intervalSeconds, periodStartUnix)] = baseUp to baseDown
logger.info(
"加密价差策略自动价差已计算并缓存(100%基准): market=$marketSlugPrefix symbol=$symbol interval=${intervalSeconds}s periodStartUnix=$periodStartUnix | " +
"Up方向: 样本数=${spreadsUp.size}, baseSpreadUp=${baseUp.toPlainString()} | " +
"Down方向: 样本数=${spreadsDown.size}, baseSpreadDown=${baseDown.toPlainString()}"
)
return baseUp to baseDown
}
private fun fetchKlines(symbol: String, interval: String, limit: Int, endTime: Long? = null): List<List<Any>>? {
return try {
val api = retrofitFactory.createBinanceApi()
val call = api.getKlines(symbol = symbol, interval = interval, limit = limit, endTime = endTime)
val response = call.execute()
if (response.isSuccessful && response.body() != null) response.body() else null
} catch (e: Exception) {
logger.warn("拉取币安 K 线失败: ${e.message}")
null
}
}
/**
* IQR 剔除异常值后求平均若剔除后样本数 < minSamplesAfterIqr 则不剔除用全量求平均
*/
private fun averageAfterIqr(list: List<BigDecimal>): BigDecimal {
if (list.isEmpty()) return BigDecimal.ZERO
val sorted = list.sorted()
val n = sorted.size
val q1Idx = (n * 0.25).toInt().coerceIn(0, n - 1)
val q3Idx = (n * 0.75).toInt().coerceIn(0, n - 1)
val q1 = sorted[q1Idx]
val q3 = sorted[q3Idx]
val iqr = q3.subtract(q1)
val lower = q1.subtract(iqr.multiply(BigDecimal("1.5")))
val upper = q3.add(iqr.multiply(BigDecimal("1.5")))
val filtered = sorted.filter { it >= lower && it <= upper }
val use = if (filtered.size < minSamplesAfterIqr) sorted else filtered
return use.fold(BigDecimal.ZERO) { a, b -> a.add(b) }.divide(BigDecimal(use.size), 18, RoundingMode.HALF_UP)
}
}
@@ -0,0 +1,199 @@
package com.wrbug.polymarketbot.service.binance
import com.wrbug.polymarketbot.util.createClient
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import okhttp3.Request
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.math.BigDecimal
import jakarta.annotation.PreDestroy
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicReference
/**
* 币安 K 线 WebSocket按需订阅加密价差策略使用的币种 5m/15m维护当前周期 (open, close)供价差校验使用
* 仅当存在启用策略且策略使用到某市场时才订阅对应币种无策略时不建立连接
*/
@Service
class BinanceKlineService {
private val logger = LoggerFactory.getLogger(BinanceKlineService::class.java)
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private val wsBase = "wss://stream.binance.com:9443"
private val client by lazy {
createClient().build()
}
/** (marketSlugPrefix, intervalSeconds, periodStartUnix) -> (open, close) */
private val openCloseByPeriod = ConcurrentHashMap<String, Pair<BigDecimal, BigDecimal>>()
/** 市场 slug 前缀(如 btc-updown-> Binance 交易对映射 */
private val marketToSymbol = mapOf(
"btc-updown" to "BTCUSDC",
"eth-updown" to "ETHUSDC",
"sol-updown" to "SOLUSDC",
"xrp-updown" to "XRPUSDC"
)
/** 已连接的 WebSocket: wsKey (symbol-interval) -> WebSocket */
private val connectedWebSockets = ConcurrentHashMap<String, WebSocket>()
/** 当前需要订阅的完整市场集合(如 btc-updown-5m、btc-updown-15m),由加密价差策略刷新时更新 */
private val requiredMarketPrefixes = AtomicReference<Set<String>>(emptySet())
private val subscriptionLock = Any()
private var reconnectJob: Job? = null
/** 解析完整市场 slug(如 btc-updown-5m)为 (basePrefix, interval),不支持则返回 null */
private fun parseMarketSlug(full: String): Pair<String, String>? {
val lower = full.lowercase()
return when {
lower.endsWith("-5m") -> Pair(lower.removeSuffix("-5m"), "5m")
lower.endsWith("-15m") -> Pair(lower.removeSuffix("-15m"), "15m")
else -> null
}
}
/** 从市场 base 前缀(如 btc-updown)获取 Binance 交易对 */
private fun getSymbol(basePrefix: String): String? = marketToSymbol[basePrefix]
private fun key(marketSlugPrefix: String, intervalSeconds: Int, periodStartUnix: Long): String {
return "$marketSlugPrefix-$intervalSeconds-$periodStartUnix"
}
fun getCurrentOpenClose(marketSlugPrefix: String, intervalSeconds: Int, periodStartUnix: Long): Pair<BigDecimal, BigDecimal>? {
return openCloseByPeriod[key(marketSlugPrefix, intervalSeconds, periodStartUnix)]
}
/** 供 API 健康检查使用:各币种各周期的连接状态 */
fun getConnectionStatuses(): Map<String, Boolean> {
return connectedWebSockets.keys.associateWith { connectedWebSockets[it] != null }
}
/**
* 按需更新订阅仅订阅策略用到的 (币种, 周期)例如只开 btc 5min 则只建 btc 5min K 线连接
* CryptoTailOrderbookWsService 在刷新订阅时根据启用策略的 marketSlugPrefix 调用
* @param marketPrefixes 当前启用策略用到的完整市场集合 ["btc-updown-5m"] ["btc-updown-5m", "eth-updown-15m"]空集合时关闭所有连接
*/
fun updateSubscriptions(marketPrefixes: Set<String>) {
val normalized = marketPrefixes.map { it.lowercase() }.toSet()
val parsed = normalized.mapNotNull { full ->
parseMarketSlug(full)?.let { (base, interval) ->
getSymbol(base)?.let { symbol -> Triple(full, symbol, interval) }
}
}.toSet()
val wsKeysNeeded = parsed.map { (_, symbol, interval) -> "$symbol-$interval" }.toSet()
// 检查是否有需要的 WebSocket 连接缺失(可能因网络问题断开)
val hasMissingConnection = wsKeysNeeded.any { it !in connectedWebSockets.keys }
// 只有当集合相同且所有需要的连接都存在时才跳过
if (normalized == requiredMarketPrefixes.get() && !hasMissingConnection) return
requiredMarketPrefixes.set(normalized)
synchronized(subscriptionLock) {
connectedWebSockets.keys.toList().forEach { wsKey ->
if (wsKey !in wsKeysNeeded) {
connectedWebSockets.remove(wsKey)?.close(1000, "subscription_update")
logger.info("币安 K 线 WS 已关闭(无策略使用): $wsKey")
}
}
parsed.forEach { (fullPrefix, symbol, interval) ->
connectStream(symbol, interval, fullPrefix) { marketPrefixParam, intervalSec, tMs, openP, closeP ->
val periodSec = tMs / 1000
openCloseByPeriod[key(marketPrefixParam, intervalSec, periodSec)] = openP to closeP
}
}
}
}
private fun connectStream(
symbol: String,
interval: String,
marketPrefix: String,
onKline: (marketPrefix: String, intervalSeconds: Int, openTimeMs: Long, open: BigDecimal, close: BigDecimal) -> Unit
) {
val streamName = "${symbol.lowercase()}@kline_$interval"
val wsKey = "$symbol-$interval"
if (connectedWebSockets[wsKey] != null) return
val url = "$wsBase/ws/$streamName"
val intervalSeconds = when (interval) {
"5m" -> 300
"15m" -> 900
else -> 300
}
val request = Request.Builder().url(url).build()
client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
connectedWebSockets[wsKey] = webSocket
logger.info("币安 K 线 WS 已连接: $streamName")
}
override fun onMessage(webSocket: WebSocket, text: String) {
parseKlineMessage(text)?.let { (tMs, o, c) ->
onKline(marketPrefix, intervalSeconds, tMs, o, c)
}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
connectedWebSockets.remove(wsKey)
logger.warn("币安 K 线 WS 异常 $streamName: ${t.message}")
scheduleReconnect()
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
connectedWebSockets.remove(wsKey)
if (code != 1000) scheduleReconnect()
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
connectedWebSockets.remove(wsKey)
}
})
}
private fun parseKlineMessage(text: String): Triple<Long, BigDecimal, BigDecimal>? {
return try {
val json = com.google.gson.JsonParser.parseString(text).asJsonObject
if (json.get("e")?.asString != "kline") return null
val k = json.getAsJsonObject("k") ?: return null
val tMs = k.get("t")?.asLong ?: return null
val o = k.get("o")?.asString?.toSafeBigDecimal() ?: return null
val c = k.get("c")?.asString?.toSafeBigDecimal() ?: return null
Triple(tMs, o, c)
} catch (e: Exception) {
logger.debug("解析币安 K 线消息失败: ${e.message}")
null
}
}
private fun scheduleReconnect() {
if (reconnectJob?.isActive == true) return
reconnectJob = scope.launch {
delay(3_000)
reconnectJob = null
val current = requiredMarketPrefixes.get()
connectedWebSockets.values.forEach { it.close(1000, "reconnect") }
connectedWebSockets.clear()
logger.info("币安 K 线 WS 尝试重连")
// 清空 requiredMarketPrefixes,否则 updateSubscriptions(current) 内会因 normalized == requiredMarketPrefixes.get() 直接 return,不会重新 connectStream
requiredMarketPrefixes.set(emptySet())
updateSubscriptions(current)
}
}
@PreDestroy
fun destroy() {
reconnectJob?.cancel()
connectedWebSockets.values.forEach { it.close(1000, "shutdown") }
connectedWebSockets.clear()
}
}
@@ -10,6 +10,7 @@ import com.wrbug.polymarketbot.api.ValueResponse
import com.wrbug.polymarketbot.constants.PolymarketConstants
import com.wrbug.polymarketbot.dto.PositionDto
import com.wrbug.polymarketbot.dto.WalletBalanceResponse
import com.wrbug.polymarketbot.enums.WalletType
import com.wrbug.polymarketbot.util.EthereumUtils
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.createClient
@@ -17,6 +18,7 @@ import com.wrbug.polymarketbot.util.toSafeBigDecimal
import org.slf4j.LoggerFactory
import com.wrbug.polymarketbot.service.system.RelayClientService
import com.wrbug.polymarketbot.service.system.RpcNodeService
import kotlinx.coroutines.delay
import org.springframework.stereotype.Service
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
@@ -37,8 +39,8 @@ class BlockchainService(
private val logger = LoggerFactory.getLogger(BlockchainService::class.java)
// USDC 合约地址(Polygon 主网,Polymarket 使用 Polygon
private val usdcContractAddress = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
// pUSD 合约地址(Polygon 主网,Polymarket 使用 Polygon
private val usdcContractAddress = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB"
// Polymarket Safe 代理工厂合约地址(Polygon 主网,用于 MetaMask 用户)
// 合约地址: 0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b
@@ -53,6 +55,9 @@ class BlockchainService(
// ConditionalTokens 合约地址(Polygon 主网)
private val conditionalTokensAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"
// Neg Risk WrappedCollateral 合约地址(Polygon
private val wcolContractAddress = "0x3A3BD7bb9528E159577F7C2e685CC81A765002E2"
// 空集合ID(用于计算collectionId
private val EMPTY_SET = "0x0000000000000000000000000000000000000000000000000000000000000000"
@@ -93,13 +98,13 @@ class BlockchainService(
* 2. Safe ProxyMetaMask 钱包用户- 通过合约调用获取地址
*
* @param walletAddress 用户的钱包地址EOA
* @param walletType 钱包类型"magic"默认 "safe"
* @param walletType 钱包类型MAGIC默认 SAFE
* @return 代理钱包地址
*/
suspend fun getProxyAddress(walletAddress: String, walletType: String = "magic"): Result<String> {
suspend fun getProxyAddress(walletAddress: String, walletType: WalletType = WalletType.MAGIC): Result<String> {
return try {
when (walletType.lowercase()) {
"safe" -> {
when (walletType) {
WalletType.SAFE -> {
// Safe ProxyMetaMask 用户)
val safeProxyResult = getSafeProxyAddress(walletAddress)
if (safeProxyResult.isSuccess) {
@@ -110,7 +115,7 @@ class BlockchainService(
Result.failure(safeProxyResult.exceptionOrNull() ?: Exception("获取 Safe Proxy 地址失败"))
}
}
else -> {
WalletType.MAGIC -> {
// Magic Proxy(邮箱/OAuth 登录用户)- 默认
val magicProxyAddress = calculateMagicProxyAddress(walletAddress)
logger.debug("使用 Magic Proxy 地址: $magicProxyAddress")
@@ -242,6 +247,62 @@ class BlockchainService(
false
}
}
/**
* 检查代理钱包是否已部署链上有合约代码
* @param proxyAddress 代理钱包地址
* @return 已部署返回 true
*/
suspend fun isProxyDeployed(proxyAddress: String): Boolean {
if (proxyAddress.isBlank() || !proxyAddress.startsWith("0x") || proxyAddress.length != 42) {
return false
}
return isContract(proxyAddress)
}
/**
* 查询 ERC20 USDC 授权额度 allowance(owner, spender)
* @param owner 代币持有者地址代理钱包地址
* @param spender 被授权方地址 CTF Exchange
* @return 授权额度原始值USDC 6 位小数需除以 1e6 为显示值
*/
suspend fun getUsdcAllowance(owner: String, spender: String): Result<BigInteger> {
return try {
if (owner.isBlank() || spender.isBlank()) {
return Result.failure(IllegalArgumentException("owner 或 spender 不能为空"))
}
val rpcApi = polygonRpcApi
// ERC20 allowance(address owner, address spender) 选择器
val functionSelector = "0xdd62ed3e"
val ownerEncoded = EthereumUtils.encodeAddress(owner)
val spenderEncoded = EthereumUtils.encodeAddress(spender)
val data = functionSelector + ownerEncoded + spenderEncoded
val rpcRequest = JsonRpcRequest(
method = "eth_call",
params = listOf(
mapOf(
"to" to usdcContractAddress,
"data" to data
),
"latest"
)
)
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}"))
}
val hexResult = rpcResponse.result?.asString ?: return Result.failure(Exception("RPC 响应 result 为空"))
val allowance = EthereumUtils.decodeUint256(hexResult)
Result.success(allowance)
} catch (e: Exception) {
logger.warn("查询 USDC 授权额度失败: ${e.message}")
Result.failure(e)
}
}
/**
* 查询账户 USDC 余额
@@ -580,45 +641,313 @@ class BlockchainService(
/**
* 赎回仓位
* 通过代理钱包的 execTransaction 调用 ConditionalTokens 合约的 redeemPositions 函数
*
* 使用 RelayClientService 实现完全参考 TypeScript 项目的实现方式
*
* Safe 账户通过代理 execTransaction 调用Magic 账户通过 Builder Relayer PROXYGasless执行
*
* @param privateKey 私钥原始钱包的私钥用于签名交易
* @param proxyAddress 代理地址Gnosis Safe 代理钱包地址
* @param proxyAddress 代理地址Safe Magic 代理钱包地址
* @param conditionId 市场条件IDbytes32必须是 0x 开头的 66 位十六进制字符串
* @param indexSets 要赎回的索引集合列表每个元素是 2^outcomeIndex例如 [1] 表示 outcome 0[2] 表示 outcome 1
* @param indexSets 要赎回的索引集合列表每个元素是 2^outcomeIndex
* @param isNegRisk 是否为 Neg Risk 市场true 时使用 WrappedCollateral 作为抵押品
* @param walletType 钱包类型MAGIC SAFE用于选择执行路径
* @return 交易哈希
*/
suspend fun redeemPositions(
privateKey: String,
proxyAddress: String,
conditionId: String,
indexSets: List<BigInteger>
indexSets: List<BigInteger>,
isNegRisk: Boolean = false,
walletType: WalletType = WalletType.SAFE
): Result<String> {
return try {
// 验证参数
if (indexSets.isEmpty()) {
return Result.failure(IllegalArgumentException("indexSets 不能为空"))
}
if (conditionId.isBlank() || !conditionId.startsWith("0x") || conditionId.length != 66) {
return Result.failure(IllegalArgumentException("conditionId 格式错误,必须是 0x 开头的 66 位十六进制字符串"))
}
if (proxyAddress.isBlank() || !proxyAddress.startsWith("0x") || proxyAddress.length != 42) {
return Result.failure(IllegalArgumentException("proxyAddress 格式错误,必须是有效的以太坊地址"))
}
// 使用 RelayClientService 创建赎回交易并执行
val redeemTx = relayClientService.createRedeemTx(conditionId, indexSets)
relayClientService.execute(privateKey, proxyAddress, redeemTx)
val redeemTx = relayClientService.createRedeemTx(conditionId, indexSets, isNegRisk)
relayClientService.execute(privateKey, proxyAddress, redeemTx, walletType)
} catch (e: Exception) {
logger.error("赎回仓位失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 批量赎回多个市场的仓位使用 MultiSend 合并为一笔交易
* 仅支持 Safe 钱包类型Magic 钱包不支持 MultiSend
*
* @param privateKey 私钥原始钱包的私钥用于签名交易
* @param proxyAddress 代理地址Safe 代理钱包地址
* @param redeemRequests 赎回请求列表每个元素是 (conditionId, indexSets, isNegRisk)
* @param walletType 钱包类型仅支持 SAFE
* @return 交易哈希
*/
suspend fun redeemPositionsBatch(
privateKey: String,
proxyAddress: String,
redeemRequests: List<Triple<String, List<BigInteger>, Boolean>>,
walletType: WalletType = WalletType.SAFE
): Result<String> {
return try {
if (redeemRequests.isEmpty()) {
return Result.failure(IllegalArgumentException("redeemRequests 不能为空"))
}
// Magic 钱包不支持 MultiSend
if (walletType == WalletType.MAGIC) {
return Result.failure(IllegalArgumentException("Magic 钱包不支持 MultiSend 批量赎回,请使用逐笔赎回"))
}
if (proxyAddress.isBlank() || !proxyAddress.startsWith("0x") || proxyAddress.length != 42) {
return Result.failure(IllegalArgumentException("proxyAddress 格式错误,必须是有效的以太坊地址"))
}
// 验证所有 conditionId 格式
for ((conditionId, _, _) in redeemRequests) {
if (conditionId.isBlank() || !conditionId.startsWith("0x") || conditionId.length != 66) {
return Result.failure(IllegalArgumentException("conditionId 格式错误: $conditionId"))
}
}
// 创建每个市场的赎回交易(Neg Risk 市场使用 WrappedCollateral
val redeemTxs = redeemRequests.map { (conditionId, indexSets, isNegRisk) ->
if (indexSets.isEmpty()) {
throw IllegalArgumentException("indexSets 不能为空: $conditionId")
}
relayClientService.createRedeemTx(conditionId, indexSets, isNegRisk)
}
// 使用 MultiSend 合并所有交易
val multiSendTx = relayClientService.createMultiSendTx(redeemTxs)
logger.info("批量赎回: 合并 ${redeemRequests.size} 个市场为一笔交易")
relayClientService.execute(privateKey, proxyAddress, multiSendTx, walletType)
} catch (e: Exception) {
logger.error("批量赎回仓位失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 轮询等待交易上链并确认成功
* @param txHash 交易 hash0x 开头
* @param maxWaitMs 最大等待毫秒数
* @param pollIntervalMs 轮询间隔毫秒数
* @return 成功返回 Unit超时或 revert 返回 Result.failure
*/
suspend fun waitForTransactionConfirmed(
txHash: String,
maxWaitMs: Long = 120_000,
pollIntervalMs: Long = 3_000
): Result<Unit> {
val rpcApi = polygonRpcApi
val start = System.currentTimeMillis()
while (System.currentTimeMillis() - start < maxWaitMs) {
val req = JsonRpcRequest(method = "eth_getTransactionReceipt", params = listOf(txHash))
val response = rpcApi.call(req)
if (!response.isSuccessful || response.body() == null) {
delay(pollIntervalMs)
continue
}
val body = response.body()!!
if (body.error != null) {
delay(pollIntervalMs)
continue
}
val result = body.result
if (result == null || result.isJsonNull) {
delay(pollIntervalMs)
continue
}
val status = result.asJsonObject?.get("status")?.asString
if (status == null) {
delay(pollIntervalMs)
continue
}
return when (status) {
"0x1" -> Result.success(Unit)
"0x0" -> Result.failure(Exception("交易已上链但执行失败 (revert)"))
else -> Result.failure(Exception("交易状态异常: $status"))
}
}
return Result.failure(Exception("等待交易确认超时 (${maxWaitMs}ms)"))
}
/**
* 查询代理地址的 WCOLWrapped Collateral余额raw6 位小数
*/
suspend fun getWcolBalance(proxyAddress: String): Result<BigInteger> {
val rpcApi = polygonRpcApi
val functionSelector = "0x70a08231" // balanceOf(address)
val paddedAddress = proxyAddress.removePrefix("0x").lowercase().padStart(64, '0')
val data = functionSelector + paddedAddress
val rpcRequest = JsonRpcRequest(
method = "eth_call",
params = listOf(
mapOf(
"to" to wcolContractAddress,
"data" to data
),
"latest"
)
)
val response = rpcApi.call(rpcRequest)
if (!response.isSuccessful || response.body() == null) {
return Result.failure(Exception("查询 WCOL 余额失败: ${response.code()} ${response.message()}"))
}
val rpcResponse = response.body()!!
if (rpcResponse.error != null) {
return Result.failure(Exception("查询 WCOL 余额失败: ${rpcResponse.error.message}"))
}
val hexBalance = rpcResponse.result?.asString ?: return Result.failure(Exception("WCOL 余额结果为空"))
val balance = EthereumUtils.decodeUint256(hexBalance)
return Result.success(balance)
}
/**
* 将代理钱包内的 WCOL 执行解包解包后转入代理地址
* 赎回 Neg Risk 仓位后到账为 WCOL调用此方法可执行解包后续资产处理
*
* Safe Magic 使用同一套逻辑同一 [createUnwrapWcolTx] + [RelayClientService.execute]
* Safe execTransactionMagic PROXY 编码最终均为代理合约调用 WCOL.unwrap(proxyAddress, amount)
*
* @param privateKey 主钱包私钥
* @param proxyAddress 代理地址Safe Magic 代理
* @param walletType 钱包类型SAFE / MAGIC用于选择 Relayer 执行路径
* @return 成功返回交易 hash余额为 0 返回 null失败返回 Result.failure
*/
suspend fun unwrapWcolForProxy(
privateKey: String,
proxyAddress: String,
walletType: WalletType
): Result<String?> {
return try {
val balanceResult = getWcolBalance(proxyAddress)
val balance = balanceResult.getOrElse {
logger.warn("查询 WCOL 余额失败,跳过解包: ${it.message}")
return Result.success(null)
}
if (balance == BigInteger.ZERO) {
return Result.success(null)
}
val unwrapTx = relayClientService.createUnwrapWcolTx(proxyAddress, balance)
val executeResult = relayClientService.execute(privateKey, proxyAddress, unwrapTx, walletType)
executeResult.fold(
onSuccess = { txHash ->
logger.info("WCOL 解包成功: proxy=${proxyAddress.take(10)}..., txHash=$txHash")
Result.success(txHash)
},
onFailure = { e ->
logger.error("WCOL 解包失败: ${e.message}", e)
Result.failure(e)
}
)
} catch (e: Exception) {
logger.error("WCOL 解包异常: ${e.message}", e)
Result.failure(e)
}
}
// USDC.e 合约地址(仅用于 wrap 查询)
private val usdceContractAddress = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
/**
* 查询 USDC.e 余额用于 wrap 前检查
*/
suspend fun queryUsdceBalance(walletAddress: String): Result<BigDecimal> {
return try {
val rpcApi = polygonRpcApi
val functionSelector = "0x70a08231"
val paddedAddress = walletAddress.removePrefix("0x").lowercase().padStart(64, '0')
val data = functionSelector + paddedAddress
val rpcRequest = JsonRpcRequest(
method = "eth_call",
params = listOf(mapOf("to" to usdceContractAddress, "data" to data), "latest")
)
val response = rpcApi.call(rpcRequest)
if (!response.isSuccessful || response.body() == null) {
return Result.failure(Exception("RPC 请求失败"))
}
val hexBalance = response.body()!!.result?.asString ?: return Result.failure(Exception("result 为空"))
val balanceWei = BigInteger(hexBalance.removePrefix("0x"), 16)
Result.success(BigDecimal(balanceWei).divide(BigDecimal("1000000")))
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* USDC.e wrap pUSD
* 步骤1) 检查 USDC.e 余额 2) approve CollateralOnramp 3) wrap
*/
suspend fun wrapUsdcToPusd(
privateKey: String,
proxyAddress: String,
walletType: WalletType
): Result<String?> {
return try {
val balanceResult = queryUsdceBalance(proxyAddress)
val balance = balanceResult.getOrElse {
logger.warn("查询 USDC.e 余额失败: ${it.message}")
return Result.failure(it)
}
if (balance <= BigDecimal.ZERO) {
return Result.success(null)
}
val wrapAmountWei = balance.movePointRight(6).toBigInteger()
logger.info("开始 wrap USDC.e → pUSD: proxy=${proxyAddress.take(10)}..., amount=$balance")
val unlimitedAllowance = BigInteger.valueOf(2).pow(256).minus(BigInteger.ONE)
val approveTx = relayClientService.createUsdceApproveForWrapTx(unlimitedAllowance)
val wrapTx = relayClientService.createWrapToPusdTx(proxyAddress, wrapAmountWei)
if (walletType == WalletType.MAGIC) {
// MAGIC 账户走 PROXY 时,不使用 Safe MultiSend(delegatecall)
// 改为顺序执行两笔 CALL,避免内层 delegatecall 回滚导致“外层成功但业务失败”。
val approveResult = relayClientService.execute(privateKey, proxyAddress, approveTx, walletType)
val approveHash = approveResult.getOrElse {
logger.error("USDC.e approve 失败: ${it.message}", it)
return Result.failure(it)
}
logger.info("USDC.e approve 成功: txHash=$approveHash")
val wrapResult = relayClientService.execute(privateKey, proxyAddress, wrapTx, walletType)
wrapResult.fold(
onSuccess = { txHash ->
logger.info("USDC.e → pUSD wrap 成功: txHash=$txHash")
Result.success(txHash)
},
onFailure = { e ->
logger.error("USDC.e → pUSD wrap 失败: ${e.message}", e)
Result.failure(e)
}
)
} else {
val safeTx = relayClientService.createMultiSendTx(listOf(approveTx, wrapTx))
val executeResult = relayClientService.execute(privateKey, proxyAddress, safeTx, walletType)
executeResult.fold(
onSuccess = { txHash ->
logger.info("USDC.e → pUSD wrap 成功: txHash=$txHash")
Result.success(txHash)
},
onFailure = { e ->
logger.error("USDC.e → pUSD wrap 失败: ${e.message}", e)
Result.failure(e)
}
)
}
} catch (e: Exception) {
logger.error("USDC.e → pUSD wrap 异常: ${e.message}", e)
Result.failure(e)
}
}
/**
* 获取代理钱包的 nonce用于构建 Safe 交易
*/
@@ -8,6 +8,7 @@ import com.wrbug.polymarketbot.entity.Market
import com.wrbug.polymarketbot.repository.MarketRepository
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.getEventSlug
import com.wrbug.polymarketbot.util.parseStringArray
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
@@ -207,6 +208,36 @@ class MarketService(
}
}
/**
* tokenId Gamma 解析市场信息conditionIdoutcomeIndex
* 用于链上解析时 Gamma 失败仅带 tokenId 的交易在 processBuyTrade 中补查市场
*/
suspend fun getMarketInfoByTokenId(tokenId: String): MarketInfoByTokenId? {
if (tokenId.isBlank()) return null
return try {
val gammaApi = retrofitFactory.createGammaApi()
val response = gammaApi.listMarkets(
conditionIds = null,
clobTokenIds = listOf(tokenId),
includeTag = null
)
if (!response.isSuccessful || response.body().isNullOrEmpty()) return null
val market = response.body()!!.first()
val conditionId = market.conditionId ?: return null
val clobTokenIdsRaw = market.clobTokenIds ?: market.clob_token_ids
val clobTokenIds = (clobTokenIdsRaw ?: "").parseStringArray()
val outcomeIndex = clobTokenIds.indexOfFirst { it.equals(tokenId, ignoreCase = true) }.takeIf { it >= 0 }
?: return null
val outcomes = market.outcomes.parseStringArray()
val outcome = if (outcomeIndex < outcomes.size) outcomes[outcomeIndex] else null
saveMarketFromResponse(conditionId, market)
MarketInfoByTokenId(conditionId = conditionId, outcomeIndex = outcomeIndex, outcome = outcome)
} catch (e: Exception) {
logger.warn("按 tokenId 查询市场失败: tokenId=$tokenId, error=${e.message}")
null
}
}
/**
* 清除缓存用于测试或手动刷新
*/
@@ -230,5 +261,33 @@ class MarketService(
null
}
}
/**
* 根据 conditionId 查询该市场是否为 Neg Risk需使用 Neg Risk Exchange 签约
* 用于跟单下单时选择正确的 exchange 合约避免 invalid signature
*/
suspend fun getNegRiskByConditionId(conditionId: String): Boolean? {
if (conditionId.isBlank()) return null
return try {
val gammaApi = retrofitFactory.createGammaApi()
val response = gammaApi.listMarkets(conditionIds = listOf(conditionId))
if (!response.isSuccessful || response.body().isNullOrEmpty()) return null
val marketResponse = response.body()!!.first()
val fromEvent = marketResponse.events?.firstOrNull()?.negRisk
val fromMarket = marketResponse.negRisk ?: marketResponse.negRiskOther
fromEvent ?: fromMarket
} catch (e: Exception) {
logger.warn("查询市场 negRisk 失败: conditionId=$conditionId, error=${e.message}")
null
}
}
}
/**
* tokenId 查询 Gamma 得到的市场信息用于补全 trade.market / outcomeIndex
*/
data class MarketInfoByTokenId(
val conditionId: String,
val outcomeIndex: Int,
val outcome: String? = null
)
@@ -98,6 +98,24 @@ class PolymarketApiKeyService(
creds.passphrase.isNotBlank()
}
/**
* CLOB /time 获取服务器时间戳失败时返回 null调用方使用本地时间
*/
private suspend fun fetchServerTimeOrNull(): Long? {
return try {
val timeApi = createUnauthenticatedApi()
val timeResponse = timeApi.getServerTime()
if (timeResponse.isSuccessful) {
timeResponse.body()?.string()?.trim()?.toLongOrNull()
} else {
null
}
} catch (e: Exception) {
logger.warn("获取服务器时间失败,使用本地时间", e)
null
}
}
/**
* 创建新的 API Key
*/
@@ -107,20 +125,7 @@ class PolymarketApiKeyService(
chainId: Long
): Result<ApiKeyCreds> {
return try {
// 获取服务器时间(可选,用于更准确的时间戳)
val serverTime = try {
val timeApi = createUnauthenticatedApi()
val timeResponse = timeApi.getServerTime()
if (timeResponse.isSuccessful && timeResponse.body() != null) {
timeResponse.body()!!.timestamp
} else {
null
}
} catch (e: Exception) {
logger.warn("获取服务器时间失败,使用本地时间", e)
null
}
val serverTime = fetchServerTimeOrNull()
// 创建带 L1 认证的 API 客户端
val api = createL1AuthenticatedApi(privateKey, walletAddress, chainId, serverTime)
@@ -158,20 +163,7 @@ class PolymarketApiKeyService(
chainId: Long
): Result<ApiKeyCreds> {
return try {
// 获取服务器时间(可选)
val serverTime = try {
val timeApi = createUnauthenticatedApi()
val timeResponse = timeApi.getServerTime()
if (timeResponse.isSuccessful && timeResponse.body() != null) {
timeResponse.body()!!.timestamp
} else {
null
}
} catch (e: Exception) {
logger.warn("获取服务器时间失败,使用本地时间", e)
null
}
val serverTime = fetchServerTimeOrNull()
// 创建带 L1 认证的 API 客户端
val api = createL1AuthenticatedApi(privateKey, walletAddress, chainId, serverTime)
@@ -223,15 +223,7 @@ class PolymarketClobService(
}
}
/**
* 创建订单已废弃使用 createSignedOrder 代替
* @deprecated 使用 createSignedOrder 代替需要签名的订单对象
*/
@Deprecated("使用 createSignedOrder 代替")
suspend fun createOrder(request: CreateOrderRequest): Result<OrderResponse> {
return Result.failure(UnsupportedOperationException("已废弃,请使用 createSignedOrder 方法"))
}
/**
* 创建签名的订单
* 注意此方法需要完整的订单签名逻辑当前为占位实现
@@ -401,7 +393,7 @@ class PolymarketClobService(
Result.failure(e)
}
}
/**
* 获取费率
* 文档: https://docs.polymarket.com/developers/market-makers/maker-rebates-program#1-fetch-the-fee-rate
@@ -1,11 +1,13 @@
package com.wrbug.polymarketbot.service.common
import com.wrbug.polymarketbot.dto.CryptoTailMonitorPushData
import com.wrbug.polymarketbot.dto.OrderPushMessage
import com.wrbug.polymarketbot.dto.PositionPushMessage
import com.wrbug.polymarketbot.dto.WebSocketMessage as WsMessage
import com.wrbug.polymarketbot.dto.WebSocketMessageType
import com.wrbug.polymarketbot.service.accounts.PositionPushService
import com.wrbug.polymarketbot.service.copytrading.orders.OrderPushService
import com.wrbug.polymarketbot.service.cryptotail.CryptoTailMonitorService
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
@@ -38,28 +40,47 @@ class WebSocketSubscriptionService(
// 存储 order 频道的订阅回调:sessionId -> callback(用于取消订阅)
private val orderChannelCallbacks = ConcurrentHashMap<String, (OrderPushMessage) -> Unit>()
// 存储加密价差策略监控频道的订阅回调:sessionId -> (strategyId -> callback)
private val monitorChannelCallbacks = ConcurrentHashMap<String, MutableMap<Long, (CryptoTailMonitorPushData) -> Unit>>()
// 加密价差策略监控服务(延迟注入,避免循环依赖)
private var cryptoTailMonitorService: CryptoTailMonitorService? = null
/**
* 设置加密价差策略监控服务 Spring 在初始化后调用
*/
fun setCryptoTailMonitorService(service: CryptoTailMonitorService) {
cryptoTailMonitorService = service
}
/**
* 注册会话
*/
fun registerSession(sessionId: String, callback: (WsMessage) -> Unit) {
sessionCallbacks[sessionId] = callback
sessionSubscriptions[sessionId] = mutableSetOf()
monitorChannelCallbacks[sessionId] = mutableMapOf()
}
/**
* 注销会话
*/
fun unregisterSession(sessionId: String) {
// 取消所有订阅
val channels = sessionSubscriptions.remove(sessionId) ?: emptySet()
channels.forEach { channel ->
unsubscribe(sessionId, channel)
}
// 清理 order 频道的回调
orderChannelCallbacks.remove(sessionId)
// 清理加密价差策略监控频道的回调
val monitorCallbacks = monitorChannelCallbacks.remove(sessionId)
monitorCallbacks?.keys?.forEach { strategyId ->
cryptoTailMonitorService?.unsubscribe(sessionId, strategyId)
}
sessionCallbacks.remove(sessionId)
}
@@ -83,8 +104,8 @@ class WebSocketSubscriptionService(
sendSubscribeAck(sessionId, channel, true)
// 根据频道类型启动推送服务
when (channel) {
"position" -> {
when {
channel == "position" -> {
positionPushService.subscribe(sessionId) { message ->
pushData(sessionId, channel, message)
}
@@ -97,7 +118,7 @@ class WebSocketSubscriptionService(
}
}
}
"order" -> {
channel == "order" -> {
// 订单推送:自动订阅所有启用的账户
val callback: (OrderPushMessage) -> Unit = { message ->
pushData(sessionId, channel, message)
@@ -105,6 +126,20 @@ class WebSocketSubscriptionService(
orderChannelCallbacks[sessionId] = callback
orderPushService.subscribeAllEnabled(callback)
}
channel.startsWith("crypto_tail_monitor_") -> {
// 加密价差策略监控频道
val strategyId = channel.removePrefix("crypto_tail_monitor_").toLongOrNull()
if (strategyId != null && cryptoTailMonitorService != null) {
val callback: (CryptoTailMonitorPushData) -> Unit = { message ->
pushData(sessionId, channel, message)
}
monitorChannelCallbacks.getOrPut(sessionId) { mutableMapOf() }[strategyId] = callback
cryptoTailMonitorService!!.subscribe(sessionId, strategyId, callback)
} else {
logger.warn("无效的加密价差策略监控频道或服务未初始化: $channel")
sendSubscribeAck(sessionId, channel, false, "无效的策略ID")
}
}
else -> {
logger.warn("未知的频道: $channel")
sendSubscribeAck(sessionId, channel, false, "未知的频道")
@@ -122,15 +157,58 @@ class WebSocketSubscriptionService(
channelSubscriptions[channel]?.remove(sessionId)
// 取消推送服务的订阅(推送服务内部会处理是否停止轮询)
when (channel) {
"position" -> positionPushService.unsubscribe(sessionId)
"order" -> {
when {
channel == "position" -> positionPushService.unsubscribe(sessionId)
channel == "order" -> {
// 取消订阅所有账户的订单推送
val callback = orderChannelCallbacks.remove(sessionId)
if (callback != null) {
orderPushService.unsubscribeAll(callback)
}
}
channel.startsWith("crypto_tail_monitor_") -> {
// 取消加密价差策略监控订阅
val strategyId = channel.removePrefix("crypto_tail_monitor_").toLongOrNull()
if (strategyId != null) {
monitorChannelCallbacks[sessionId]?.remove(strategyId)
cryptoTailMonitorService?.unsubscribe(sessionId, strategyId)
}
}
}
}
/**
* 注册加密价差策略监控回调 CryptoTailMonitorService 调用
*/
fun registerMonitorCallback(sessionId: String, strategyId: Long, callback: (CryptoTailMonitorPushData) -> Unit) {
monitorChannelCallbacks.getOrPut(sessionId) { mutableMapOf() }[strategyId] = callback
}
/**
* 注销加密价差策略监控回调 CryptoTailMonitorService 调用
*/
fun unregisterMonitorCallback(sessionId: String, strategyId: Long) {
monitorChannelCallbacks[sessionId]?.remove(strategyId)
}
/**
* 推送加密价差策略监控数据 CryptoTailMonitorService 调用
*/
fun pushMonitorData(strategyId: Long, data: CryptoTailMonitorPushData) {
val channel = "crypto_tail_monitor_$strategyId"
val sessionIds = channelSubscriptions[channel] ?: return
for (sessionId in sessionIds) {
val callback = sessionCallbacks[sessionId]
if (callback != null) {
val message = WsMessage(
type = WebSocketMessageType.DATA.value,
channel = channel,
payload = data,
timestamp = System.currentTimeMillis()
)
callback(message)
}
}
}
@@ -168,4 +246,3 @@ class WebSocketSubscriptionService(
}
}
}
@@ -45,7 +45,9 @@ object OnChainWsUtils {
}
// 合约地址
const val USDC_CONTRACT = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
const val PUSD_CONTRACT = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB" // V2 pUSD
const val USDC_CONTRACT = PUSD_CONTRACT // 默认使用 pUSD
private val COLLATERAL_CONTRACTS = setOf(PUSD_CONTRACT.lowercase())
const val ERC1155_CONTRACT = "0x4d97dcd97ec945f40cf65f87097ace5ea0476045"
const val ERC20_TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
const val ERC1155_TRANSFER_SINGLE_TOPIC = "0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62"
@@ -96,8 +98,8 @@ object OnChainWsUtils {
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) {
// 抵押品 ERC20 Transfer(当前仅匹配 pUSD
if (address in COLLATERAL_CONTRACTS && t0 == ERC20_TRANSFER_TOPIC && topics.size >= 3) {
val from = topicToAddress(topics[1])
val to = topicToAddress(topics[2])
val value = hexToBigInt(data)
@@ -149,6 +151,42 @@ object OnChainWsUtils {
return Pair(erc20, erc1155)
}
/**
* 通过 CLOB 交易历史获取 Leader 真实成交价用于无 USDC 转账的 ERC1155-only 场景
* 查询该 token 最近的成交记录匹配 side 方向的第一条作为成交价
* 返回 usdcRaw = price × sizeRaw
*/
private suspend fun fetchEstimatedUsdcRaw(
tokenId: String,
side: String,
sizeRaw: BigInteger,
retrofitFactory: RetrofitFactory
): BigInteger? {
return try {
val clobApi = retrofitFactory.createClobApiWithoutAuth()
val response = clobApi.getTrades(asset_id = tokenId)
if (!response.isSuccessful || response.body() == null) {
logger.warn("CLOB 交易历史查询失败: tokenId=$tokenId, code=${response.code()}")
return null
}
val trades = response.body()!!.data
val clobSide = side.lowercase()
val matchedTrade = trades.firstOrNull { it.side.equals(clobSide, ignoreCase = true) }
if (matchedTrade == null) {
logger.warn("CLOB 交易历史中未找到匹配成交: tokenId=$tokenId, side=$clobSide, totalTrades=${trades.size}")
return null
}
val price = matchedTrade.price.toBigDecimal()
val usdcRaw = price.multiply(sizeRaw.toBigDecimal())
.setScale(0, java.math.RoundingMode.DOWN).toBigInteger()
logger.debug("CLOB 交易历史估算: tokenId=$tokenId, side=$clobSide, tradePrice=${matchedTrade.price}, tradeSize=${matchedTrade.size}, sizeRaw=$sizeRaw, usdcRaw=$usdcRaw")
usdcRaw
} catch (e: Exception) {
logger.warn("获取 CLOB 交易历史失败: tokenId=$tokenId, side=$side, error=${e.message}")
null
}
}
/**
* Transfer 日志解析交易信息
*/
@@ -205,6 +243,32 @@ object OnChainWsUtils {
asset = bestOutId
sizeRaw = bestOutVal
usdcRaw = usdcIn
} else if (bestInId != null && bestInVal > BigInteger.ZERO && bestOutId == null
&& usdcOut == BigInteger.ZERO && usdcIn == BigInteger.ZERO
) {
// BUY(无 USDC: 只收到 ERC1155 token,无 USDC 流动(CLOB 内部结算等场景)
side = "BUY"
asset = bestInId
sizeRaw = bestInVal
usdcRaw = fetchEstimatedUsdcRaw(bestInId.toString(), "BUY", bestInVal, retrofitFactory)
?: run {
logger.warn("无法获取估算价格(ERC1155-only BUY: txHash=$txHash, tokenId=$bestInId")
return null
}
logger.debug("ERC1155-only BUY: txHash=$txHash, tokenId=$bestInId, sizeRaw=$sizeRaw, usdcRaw=$usdcRaw")
} else if (bestOutId != null && bestOutVal > BigInteger.ZERO && bestInId == null
&& usdcOut == BigInteger.ZERO && usdcIn == BigInteger.ZERO
) {
// SELL(无 USDC: 只发出 ERC1155 token,无 USDC 流动
side = "SELL"
asset = bestOutId
sizeRaw = bestOutVal
usdcRaw = fetchEstimatedUsdcRaw(bestOutId.toString(), "SELL", bestOutVal, retrofitFactory)
?: run {
logger.warn("无法获取估算价格(ERC1155-only SELL: txHash=$txHash, tokenId=$bestOutId")
return null
}
logger.debug("ERC1155-only SELL: txHash=$txHash, tokenId=$bestOutId, sizeRaw=$sizeRaw, usdcRaw=$usdcRaw")
} else {
// 无法判断交易方向
logger.debug("无法判断交易方向: txHash=$txHash, bestInId=$bestInId, bestInVal=$bestInVal, bestOutId=$bestOutId, bestOutVal=$bestOutVal, usdcOut=$usdcOut, usdcIn=$usdcIn")
@@ -220,10 +284,10 @@ object OnChainWsUtils {
return null
}
// 尝试通过 Gamma API 查询市场信息(通过 tokenId)
// 尝试通过 Gamma API 查询市场信息(通过 tokenId);失败时仍保留链上 tokenId 供后续按 tokenId 补查市场
val marketInfo = fetchMarketByTokenId(asset.toString(), retrofitFactory)
// 创建 TradeResponse
// 创建 TradeResponse:tokenId 始终写入链上解析得到的 asset(与 CLOB 一致),便于 Gamma 失败时在 processBuyTrade 中按 tokenId 再查
return TradeResponse(
id = txHash,
market = marketInfo?.conditionId ?: "",
@@ -233,7 +297,8 @@ object OnChainWsUtils {
timestamp = (timestamp ?: System.currentTimeMillis() / 1000).toString(),
user = walletAddress,
outcomeIndex = marketInfo?.outcomeIndex,
outcome = marketInfo?.outcome
outcome = marketInfo?.outcome,
tokenId = asset.toString()
)
}
@@ -463,6 +463,7 @@ class PolymarketActivityWsService(
// 使用 transactionHash 作为 trade ID,如果没有则生成 fallback ID
val tradeId = payload.transactionHash ?: "${leaderId}_${System.currentTimeMillis()}_${asset.take(10)}"
// asset 即 CLOB 的 tokenId,必须写入 TradeResponse,跟单下单时用此 tokenId 请求订单簿/下单,否则会用 conditionId+outcomeIndex 链上重算,可能得到与 CLOB 不一致的 tokenId
TradeResponse(
id = tradeId,
market = conditionId,
@@ -472,7 +473,8 @@ class PolymarketActivityWsService(
timestamp = timestamp,
user = null, // Activity WS 中不需要
outcomeIndex = outcomeIndex,
outcome = outcome
outcome = outcome,
tokenId = asset
)
} catch (e: Exception) {
logger.error("解析 Activity Trade 失败: ${e.message}", e)
@@ -20,7 +20,12 @@ import com.wrbug.polymarketbot.repository.CopyTradingRepository
import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.constants.PolymarketConstants
import com.wrbug.polymarketbot.service.common.MarketService
import com.wrbug.polymarketbot.util.div
import com.wrbug.polymarketbot.util.gt
import com.wrbug.polymarketbot.util.multi
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.util.concurrent.ConcurrentHashMap
/**
@@ -426,20 +431,29 @@ class OrderPushService(
// 获取市场信息(使用 MarketService,优先从数据库/缓存获取)
val market = marketService.getMarket(conditionId ?: openOrder.market)
// 转换为 DTO
// 有成交时按公式计算实际成交价:original_size * price / size_matched,数量用 size_matched
val sizeMatched = openOrder.sizeMatched.toSafeBigDecimal()
val avgFilledPrice = if (sizeMatched.gt(BigDecimal.ZERO)) {
openOrder.originalSize.toSafeBigDecimal()
.multi(openOrder.price)
.div(sizeMatched, 18)
} else null
// 转换为 DTO(展示数量用 size_matched
// 注意:createdAt 是 unix timestamp (Long),需要转换为字符串
OrderDetailDto(
id = openOrder.id,
market = openOrder.market,
side = openOrder.side,
price = openOrder.price,
size = openOrder.originalSize, // 使用 original_size
filled = openOrder.sizeMatched, // 使用 size_matched
size = openOrder.originalSize,
filled = openOrder.sizeMatched, // 已成交数量用 size_matched
status = openOrder.status,
createdAt = openOrder.createdAt.toString(), // unix timestamp 转换为字符串
marketName = market?.title,
marketSlug = market?.slug, // 显示用的 slug
marketIcon = market?.icon
marketIcon = market?.icon,
avgFilledPrice = avgFilledPrice?.toPlainString() // 实际成交价 = original_size*price/size_matched
)
},
onFailure = { e ->
@@ -8,6 +8,7 @@ import org.web3j.crypto.Credentials
import java.math.BigDecimal
import java.math.BigInteger
import java.math.RoundingMode
import java.util.concurrent.atomic.AtomicLong
/**
* 订单签名服务
@@ -19,11 +20,30 @@ import java.math.RoundingMode
*/
@Service
class OrderSigningService {
private val logger = LoggerFactory.getLogger(OrderSigningService::class.java)
// Polygon 主网合约地址
private val EXCHANGE_CONTRACT = "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E"
/**
* 根据是否为 Neg Risk 市场返回签约用 exchange 合约地址
* @param negRisk true 时使用 Neg Risk CTF Exchange否则使用标准 CTF Exchange
*/
fun getExchangeContract(negRisk: Boolean): String {
return if (negRisk) NEG_RISK_EXCHANGE_CONTRACT else EXCHANGE_CONTRACT
}
/**
* 根据钱包类型返回 CLOB 订单签名类型
* @param walletType Magic=邮箱/社交登录, Safe=Web3 钱包
* @return 1=POLY_PROXY(Magic), 2=POLY_GNOSIS_SAFE(Safe), 默认 2
*/
fun getSignatureTypeForWalletType(walletType: String?): Int {
val walletTypeEnum = com.wrbug.polymarketbot.enums.WalletType.fromStringOrDefault(walletType, com.wrbug.polymarketbot.enums.WalletType.SAFE)
return if (walletTypeEnum == com.wrbug.polymarketbot.enums.WalletType.MAGIC) 1 else 2
}
// V2 合约地址
private val EXCHANGE_CONTRACT = "0xE111180000d2663C0091e4f400237545B87B996B"
private val NEG_RISK_EXCHANGE_CONTRACT = "0xe2222d279d744050d28e00520010520000310F59"
private val CHAIN_ID = 137L
// USDC 有 6 位小数
@@ -135,8 +155,8 @@ class OrderSigningService {
}
/**
* 创建并签名订单
*
* 创建并签名订单 (V2)
*
* @param privateKey 私钥十六进制字符串
* @param makerAddress maker 地址funder通常是 proxyAddress
* @param tokenId token ID
@@ -144,9 +164,7 @@ class OrderSigningService {
* @param price 价格
* @param size 数量
* @param signatureType 签名类型1: Email/Magic, 2: Browser Wallet, 0: EOA
* @param nonce nonce默认 "0"
* @param feeRateBps 费率基点默认 "0"
* @param expiration 过期时间戳0 表示永不过期
* @param exchangeContract 签约用 exchange 合约地址null 时用标准 CTF Exchangeneg risk 市场需传 Neg Risk Exchange
* @return 签名的订单对象
*/
fun createAndSignOrder(
@@ -156,88 +174,83 @@ class OrderSigningService {
side: String,
price: String,
size: String,
signatureType: Int = 2, // 默认使用 Browser Wallet(与正确订单数据一致)
nonce: String = "0",
feeRateBps: String = "0",
expiration: String = "0"
signatureType: Int = 2,
exchangeContract: String? = null
): SignedOrderObject {
try {
// 1. 从私钥获取签名地址
val cleanPrivateKey = privateKey.removePrefix("0x")
val privateKeyBigInt = BigInteger(cleanPrivateKey, 16)
val credentials = Credentials.create(privateKeyBigInt.toString(16))
// 统一转换为小写,确保与 EIP-712 编码时使用的地址格式一致
// EIP-712 编码时地址会被转换为小写,所以订单对象中的地址也应该是小写
val signerAddress = credentials.address.lowercase()
// 2. 计算订单金额
val amounts = calculateOrderAmounts(side, size, price)
// 3. 生成 salt(使用时间戳,毫秒
// 3. 生成 salt 和 timestampV2: timestamp 替代 nonce 保证唯一性
val salt = generateSalt()
// 4. taker 地址(默认使用零地址)
val taker = "0x0000000000000000000000000000000000000000"
val timestamp = System.currentTimeMillis().toString()
// 4. V2 字段默认值
val metadata = "0x0000000000000000000000000000000000000000000000000000000000000000"
val builder = "0x0000000000000000000000000000000000000000000000000000000000000000"
// 5. 确保 maker 地址也是小写格式
val makerAddressLower = makerAddress.lowercase()
// 打印签名前的订单参数(DEBUG 级别,避免敏感信息泄露)
logger.debug("========== 订单签名前参数 ==========")
logger.debug("========== 订单签名前参数 (V2) ==========")
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("Salt: $salt, Timestamp: $timestamp")
logger.debug("Signature Type: $signatureType, Chain ID: $CHAIN_ID")
// 6. 构建订单数据并签名
val contract = exchangeContract?.takeIf { it.isNotBlank() } ?: EXCHANGE_CONTRACT
val signature = signOrder(
privateKey = privateKey,
exchangeContract = EXCHANGE_CONTRACT,
exchangeContract = contract,
chainId = CHAIN_ID,
salt = salt,
maker = makerAddressLower,
signer = signerAddress,
taker = taker,
tokenId = tokenId,
makerAmount = amounts.makerAmount,
takerAmount = amounts.takerAmount,
expiration = expiration,
nonce = nonce,
feeRateBps = feeRateBps,
side = side.uppercase(),
signatureType = signatureType
signatureType = signatureType,
timestamp = timestamp,
metadata = metadata,
builder = builder
)
// 7. 创建签名订单对象
// 注意:所有地址字段都使用小写格式,确保与签名时使用的地址一致
// 7. 创建 V2 签名订单对象
return SignedOrderObject(
salt = salt,
maker = makerAddressLower,
signer = signerAddress,
taker = taker,
taker = "0x0000000000000000000000000000000000000000",
tokenId = tokenId,
makerAmount = amounts.makerAmount,
takerAmount = amounts.takerAmount,
expiration = expiration,
nonce = nonce,
feeRateBps = feeRateBps,
side = side.uppercase(),
signatureType = signatureType,
timestamp = timestamp,
expiration = "0",
metadata = metadata,
builder = builder,
signature = signature
)
} catch (e: Exception) {
logger.error("创建并签名订单失败", e)
throw RuntimeException("创建并签名订单失败: ${e.message}", e)
logger.error("创建并签名订单失败 (V2)", e)
throw RuntimeException("创建并签名订单失败 (V2): ${e.message}", e)
}
}
/**
* 签名订单EIP-712
*
* 参考: @polymarket/order-utils ExchangeOrderBuilder
* 签名订单 V2EIP-712
*/
private fun signOrder(
privateKey: String,
@@ -246,79 +259,71 @@ class OrderSigningService {
salt: Long,
maker: String,
signer: String,
taker: String,
tokenId: String,
makerAmount: String,
takerAmount: String,
expiration: String,
nonce: String,
feeRateBps: String,
side: String,
signatureType: Int
signatureType: Int,
timestamp: String,
metadata: String,
builder: String
): String {
try {
// 1. 从私钥创建 BigInteger
val cleanPrivateKey = privateKey.removePrefix("0x")
val privateKeyBigInt = BigInteger(cleanPrivateKey, 16)
val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
// 2. 编码域分隔符
val credentials = Credentials.create(privateKeyBigInt.toString(16))
val ecKeyPair = credentials.ecKeyPair
val domainSeparator = com.wrbug.polymarketbot.util.Eip712Encoder.encodeExchangeDomain(
chainId = chainId,
verifyingContract = exchangeContract
verifyingContract = exchangeContract.lowercase()
)
// 3. 编码订单消息哈希
// signatureType 参数:1 = POLY_PROXY (代理钱包), 2 = POLY_GNOSIS_SAFE, 0 = EOA
// 使用传入的 signatureType 参数,而不是硬编码
val orderHash = com.wrbug.polymarketbot.util.Eip712Encoder.encodeExchangeOrder(
salt = salt,
maker = maker,
signer = signer,
taker = taker,
tokenId = tokenId,
makerAmount = makerAmount,
takerAmount = takerAmount,
expiration = expiration,
nonce = nonce,
feeRateBps = feeRateBps,
side = side,
signatureType = signatureType // 使用传入的参数
signatureType = signatureType,
timestamp = timestamp,
metadata = metadata,
builder = builder
)
// 4. 计算完整的结构化数据哈希
val structuredHash = com.wrbug.polymarketbot.util.Eip712Encoder.hashStructuredData(
domainSeparator = domainSeparator,
messageHash = orderHash
)
// 5. 使用私钥签名
val signature = org.web3j.crypto.Sign.signMessage(structuredHash, ecKeyPair, false)
// 6. 组合签名(r + s + v
val rHex = org.web3j.utils.Numeric.toHexString(signature.r).removePrefix("0x").padStart(64, '0')
val sHex = org.web3j.utils.Numeric.toHexString(signature.s).removePrefix("0x").padStart(64, '0')
val vBytes = signature.v as ByteArray
val vInt = if (vBytes.isNotEmpty()) {
vBytes[0].toInt() and 0xff
} else {
0
}
val vHex = String.format("%02x", vInt)
val vBytes = signature.v
val vInt = if (vBytes.isNotEmpty()) vBytes[0].toInt() and 0xff else 0
val vHex = "%02x".format(vInt)
return "0x$rHex$sHex$vHex"
} catch (e: Exception) {
logger.error("订单签名失败", e)
throw RuntimeException("订单签名失败: ${e.message}", e)
logger.error("订单签名失败 (V2)", e)
throw RuntimeException("订单签名失败 (V2): ${e.message}", e)
}
}
/** 并发安全:确保同一毫秒内多次调用生成唯一 salt,避免 FIXED 模式预签双单等场景的 salt 碰撞 */
private val saltSequence = AtomicLong(0)
/**
* 生成 salt使用时间戳毫秒
* TypeScript SDK 保持一致使用时间戳作为 salt
* 生成 salt时间戳 + 自增序列保证并发下唯一
* 兼容 Polymarketsalt Long时间戳主位 + 序列次位 TypeScript SDK 语义兼容
*/
private fun generateSalt(): Long {
return System.currentTimeMillis()
val now = System.currentTimeMillis()
val seq = saltSequence.incrementAndGet() and 0x3FF
return now * 1000 + seq
}
/**
@@ -258,19 +258,36 @@ open class CopyOrderTrackingService(
continue
}
// 直接使用outcomeIndex获取tokenId(支持多元市场)
if (trade.outcomeIndex == null) {
logger.warn("交易缺少outcomeIndex,无法确定tokenId: tradeId=${trade.id}, market=${trade.market}")
continue
// 获取 tokenId:优先使用链上解析得到的 tokenId(与 Gamma clobTokenIds 一致),否则用 conditionId+outcomeIndex 链上重算
val tokenId = if (!trade.tokenId.isNullOrBlank()) {
trade.tokenId
} else {
if (trade.outcomeIndex == null) {
logger.warn("交易缺少outcomeIndex且无tokenId,无法确定tokenId: tradeId=${trade.id}, market=${trade.market}")
continue
}
val tokenIdResult = blockchainService.getTokenId(trade.market, trade.outcomeIndex)
if (tokenIdResult.isFailure) {
logger.error("获取tokenId失败: market=${trade.market}, outcomeIndex=${trade.outcomeIndex}, error=${tokenIdResult.exceptionOrNull()?.message}")
continue
}
tokenIdResult.getOrNull() ?: continue
}
// 获取tokenId(直接使用outcomeIndex,不转换为YES/NO
val tokenIdResult = blockchainService.getTokenId(trade.market, trade.outcomeIndex)
if (tokenIdResult.isFailure) {
logger.error("获取tokenId失败: market=${trade.market}, outcomeIndex=${trade.outcomeIndex}, error=${tokenIdResult.exceptionOrNull()?.message}")
// 当链上解析时 Gamma 失败导致 market/outcomeIndex 为空时,按 tokenId 补查市场信息
var effectiveMarketId = trade.market
var effectiveOutcomeIndex = trade.outcomeIndex
if (effectiveMarketId.isBlank() && !trade.tokenId.isNullOrBlank()) {
val infoByToken = marketService.getMarketInfoByTokenId(trade.tokenId)
if (infoByToken != null) {
effectiveMarketId = infoByToken.conditionId
effectiveOutcomeIndex = infoByToken.outcomeIndex
}
}
if (effectiveMarketId.isBlank()) {
logger.warn("无法确定市场(conditionId),跳过: tradeId=${trade.id}, tokenId=${trade.tokenId}")
continue
}
val tokenId = tokenIdResult.getOrNull() ?: continue
// 先计算跟单金额(用于仓位检查)
// 注意:这里先计算金额,即使后续被过滤也会记录
@@ -293,7 +310,7 @@ open class CopyOrderTrackingService(
if (needMarketInfo) {
try {
val market = marketService.getMarket(trade.market)
val market = marketService.getMarket(effectiveMarketId)
marketTitle = market?.title
marketEndDate = market?.endDate
} catch (e: Exception) {
@@ -312,10 +329,10 @@ open class CopyOrderTrackingService(
tokenId,
tradePrice = tradePrice,
copyOrderAmount = copyOrderAmount,
marketId = trade.market,
marketId = effectiveMarketId,
marketTitle = marketTitle,
marketEndDate = marketEndDate,
outcomeIndex = trade.outcomeIndex
outcomeIndex = effectiveOutcomeIndex
)
val orderbook = filterResult.orderbook // 获取订单簿(如果需要)
if (!filterResult.isPassed) {
@@ -325,8 +342,8 @@ open class CopyOrderTrackingService(
notificationScope.launch {
try {
// 获取市场信息(标题和slug
val market = marketService.getMarket(trade.market)
val marketTitle = market?.title ?: trade.market
val market = marketService.getMarket(effectiveMarketId)
val marketTitle = market?.title ?: effectiveMarketId
val marketSlug = market?.slug // 显示用的 slug
// 从过滤结果中提取 filterType
@@ -346,11 +363,11 @@ open class CopyOrderTrackingService(
accountId = copyTrading.accountId,
leaderId = copyTrading.leaderId,
leaderTradeId = trade.id,
marketId = trade.market,
marketId = effectiveMarketId,
marketTitle = marketTitle,
marketSlug = marketSlug,
side = "BUY",
outcomeIndex = trade.outcomeIndex,
outcomeIndex = effectiveOutcomeIndex,
outcome = trade.outcome,
price = trade.price.toSafeBigDecimal(),
size = trade.size.toSafeBigDecimal(),
@@ -376,7 +393,7 @@ open class CopyOrderTrackingService(
telegramNotificationService?.sendOrderFilteredNotification(
marketTitle = marketTitle,
marketId = trade.market,
marketId = effectiveMarketId,
marketSlug = marketSlug,
side = "BUY",
outcome = trade.outcome,
@@ -545,16 +562,12 @@ open class CopyOrderTrackingService(
// 解密私钥
val decryptedPrivateKey = decryptPrivateKey(account)
// 获取费率(根据 Polymarket Maker Rebates Program 要求)
val feeRateResult = clobService.getFeeRate(tokenId)
val feeRateBps = if (feeRateResult.isSuccess) {
feeRateResult.getOrNull()?.toString() ?: "0"
} else {
logger.warn("获取费率失败,使用默认值 0: tokenId=$tokenId, error=${feeRateResult.exceptionOrNull()?.message}")
"0"
}
logger.info("准备创建买入订单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}, leaderPrice=${trade.price}, tolerance=${copyTrading.priceTolerance}, calculatedPrice=$buyPrice, quantity=$finalBuyQuantity")
logger.info("准备创建买入订单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}, leaderPrice=${trade.price}, tolerance=${copyTrading.priceTolerance}, calculatedPrice=$buyPrice, quantity=$finalBuyQuantity, baseFee=$feeRateBps")
// Neg Risk 市场需用 Neg Risk Exchange 签约,否则服务端返回 invalid signature
val negRisk = marketService.getNegRiskByConditionId(effectiveMarketId) == true
val exchangeContract = orderSigningService.getExchangeContract(negRisk)
if (negRisk) logger.debug("市场为 Neg Risk,使用 Neg Risk Exchange 签约: conditionId=$effectiveMarketId")
// 调用API创建订单(带重试机制)
// 重试策略:最多重试 MAX_RETRY_ATTEMPTS 次,每次重试前等待 RETRY_DELAY_MS 毫秒
@@ -563,6 +576,8 @@ open class CopyOrderTrackingService(
clobApi = clobApi,
privateKey = decryptedPrivateKey,
makerAddress = account.proxyAddress,
walletAddress = account.walletAddress,
exchangeContract = exchangeContract,
tokenId = tokenId,
side = "BUY",
price = buyPrice.toString(),
@@ -570,7 +585,7 @@ open class CopyOrderTrackingService(
owner = account.apiKey,
copyTradingId = copyTrading.id!!,
tradeId = trade.id,
feeRateBps = feeRateBps
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
)
// 处理订单创建失败
@@ -584,8 +599,8 @@ open class CopyOrderTrackingService(
notificationScope.launch {
try {
// 获取市场信息(标题和slug
val market = marketService.getMarket(trade.market)
val marketTitle = market?.title ?: trade.market
val market = marketService.getMarket(effectiveMarketId)
val marketTitle = market?.title ?: effectiveMarketId
val marketSlug = market?.eventSlug // 跳转用的 slug
// 获取当前语言设置(从 LocaleContextHolder
@@ -597,7 +612,7 @@ open class CopyOrderTrackingService(
telegramNotificationService?.sendOrderFailureNotification(
marketTitle = marketTitle,
marketId = trade.market,
marketId = effectiveMarketId,
marketSlug = marketSlug,
side = "BUY",
outcome = null, // 失败时可能没有 outcome
@@ -631,9 +646,9 @@ open class CopyOrderTrackingService(
copyTradingId = copyTrading.id,
accountId = copyTrading.accountId,
leaderId = copyTrading.leaderId,
marketId = trade.market,
side = trade.outcomeIndex.toString(), // 使用outcomeIndex作为side(兼容旧数据)
outcomeIndex = trade.outcomeIndex, // 新增字段
marketId = effectiveMarketId,
side = effectiveOutcomeIndex?.toString() ?: "", // 使用outcomeIndex作为side(兼容旧数据)
outcomeIndex = effectiveOutcomeIndex, // 新增字段
buyOrderId = realOrderId, // 使用真实订单ID
leaderBuyTradeId = trade.id,
leaderBuyQuantity = trade.size.toSafeBigDecimal(), // 存储 Leader 买入数量(用于固定金额模式计算卖出比例)
@@ -903,13 +918,21 @@ open class CopyOrderTrackingService(
finalNeedMatch = BigDecimal.ONE
}
// 4. 获取tokenId(直接使用outcomeIndex,支持多元市场)
val tokenIdResult = blockchainService.getTokenId(leaderSellTrade.market, leaderSellTrade.outcomeIndex)
if (tokenIdResult.isFailure) {
logger.error("获取tokenId失败: market=${leaderSellTrade.market}, outcomeIndex=${leaderSellTrade.outcomeIndex}, error=${tokenIdResult.exceptionOrNull()?.message}")
return
// 4. 获取 tokenId:优先使用链上解析得到的 tokenId,否则用 conditionId+outcomeIndex 链上重算
val tokenId = if (!leaderSellTrade.tokenId.isNullOrBlank()) {
leaderSellTrade.tokenId
} else {
if (leaderSellTrade.outcomeIndex == null) {
logger.error("卖出交易缺少outcomeIndex且无tokenId: market=${leaderSellTrade.market}")
return
}
val tokenIdResult = blockchainService.getTokenId(leaderSellTrade.market, leaderSellTrade.outcomeIndex)
if (tokenIdResult.isFailure) {
logger.error("获取tokenId失败: market=${leaderSellTrade.market}, outcomeIndex=${leaderSellTrade.outcomeIndex}, error=${tokenIdResult.exceptionOrNull()?.message}")
return
}
tokenIdResult.getOrNull() ?: return
}
val tokenId = tokenIdResult.getOrNull() ?: return
// 5. 计算卖出价格(优先使用订单簿 bestBid,失败则使用 Leader 价格,固定按90%计算)
// 注意:需要先计算卖出价格,因为后续创建 matchDetails 需要使用实际卖出价格
@@ -985,16 +1008,12 @@ open class CopyOrderTrackingService(
// 8. 解密私钥(在方法开始时解密一次,后续复用)
val decryptedPrivateKey = decryptPrivateKey(account)
// 获取费率(根据 Polymarket Maker Rebates Program 要求)
val feeRateResult = clobService.getFeeRate(tokenId)
val feeRateBps = if (feeRateResult.isSuccess) {
feeRateResult.getOrNull()?.toString() ?: "0"
} else {
logger.warn("获取费率失败,使用默认值 0: tokenId=$tokenId, error=${feeRateResult.exceptionOrNull()?.message}")
"0"
}
// 9. Neg Risk 市场需用 Neg Risk Exchange 签约
val negRiskSell = marketService.getNegRiskByConditionId(leaderSellTrade.market) == true
val exchangeContractSell = orderSigningService.getExchangeContract(negRiskSell)
if (negRiskSell) logger.debug("卖出市场为 Neg Risk,使用 Neg Risk Exchange 签约: conditionId=${leaderSellTrade.market}")
// 9. 创建并签名卖出订单
// 10. 创建并签名卖出订单(按账户钱包类型使用对应 signatureType
val signedOrder = try {
orderSigningService.createAndSignOrder(
privateKey = decryptedPrivateKey,
@@ -1003,27 +1022,24 @@ open class CopyOrderTrackingService(
side = "SELL",
price = sellPrice.toString(),
size = totalMatched.toString(),
signatureType = 2, // Browser Wallet
nonce = "0",
feeRateBps = feeRateBps, // 使用动态获取的费率
expiration = "0"
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType),
exchangeContract = exchangeContractSell
)
} catch (e: Exception) {
logger.error("创建并签名卖出订单失败: copyTradingId=${copyTrading.id}, tradeId=${leaderSellTrade.id}", e)
return
}
// 10. 构建订单请求
// 11. 构建订单请求
// 跟单订单使用 FAK (Fill-And-Kill),允许部分成交,未成交部分立即取消
// 这样可以快速响应 Leader 的交易,避免订单长期挂单导致价格不匹配
val orderRequest = NewOrderRequest(
order = signedOrder,
owner = account.apiKey,
orderType = "FAK", // Fill-And-Kill
deferExec = false
orderType = "FAK" // Fill-And-Kill
)
// 11. 创建带认证的CLOB API客户端(使用解密后的凭证)
// 12. 创建带认证的CLOB API客户端(使用解密后的凭证)
val clobApi = retrofitFactory.createClobApi(
account.apiKey,
apiSecret,
@@ -1031,12 +1047,13 @@ open class CopyOrderTrackingService(
account.walletAddress
)
// 12. 调用API创建卖出订单(带重试机制,重试时会重新生成salt并重新签名)
// 13. 调用API创建卖出订单(带重试机制,重试时会重新生成salt并重新签名)
val createOrderResult = createOrderWithRetry(
clobApi = clobApi,
privateKey = decryptedPrivateKey,
makerAddress = account.proxyAddress,
walletAddress = account.walletAddress,
exchangeContract = exchangeContractSell,
tokenId = tokenId,
side = "SELL",
price = sellPrice.toString(),
@@ -1044,7 +1061,7 @@ open class CopyOrderTrackingService(
owner = account.apiKey,
copyTradingId = copyTrading.id,
tradeId = leaderSellTrade.id,
feeRateBps = feeRateBps
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
)
if (createOrderResult.isFailure) {
@@ -1128,7 +1145,9 @@ open class CopyOrderTrackingService(
*
* @param clobApi CLOB API 客户端
* @param privateKey 私钥用于签名
* @param makerAddress 代理钱包地址
* @param makerAddress 代理钱包地址funder
* @param walletAddress 账户 EOA 地址须与私钥推导的 signer 一致用于校验及 POLY_ADDRESS
* @param exchangeContract 签约用 exchange 合约Neg Risk 市场需用 Neg Risk Exchange
* @param tokenId Token ID
* @param side 订单方向BUY/SELL
* @param price 价格
@@ -1136,13 +1155,15 @@ open class CopyOrderTrackingService(
* @param owner API Key用于owner字段
* @param copyTradingId 跟单配置ID用于日志
* @param tradeId Leader 交易ID用于日志
* @param feeRateBps 费率基点从API动态获取
* @param signatureType 签名类型1=Magic, 2=Safe
* @return 成功返回订单ID失败返回异常
*/
private suspend fun createOrderWithRetry(
clobApi: PolymarketClobApi,
privateKey: String,
makerAddress: String,
walletAddress: String,
exchangeContract: String,
tokenId: String,
side: String,
price: String,
@@ -1150,7 +1171,7 @@ open class CopyOrderTrackingService(
owner: String,
copyTradingId: Long,
tradeId: String,
feeRateBps: String
signatureType: Int
): Result<String> {
var lastError: Exception? = null
@@ -1165,20 +1186,24 @@ open class CopyOrderTrackingService(
side = side,
price = price,
size = size,
signatureType = 2, // Browser Wallet
nonce = "0",
feeRateBps = feeRateBps, // 使用动态获取的费率
expiration = "0"
signatureType = signatureType,
exchangeContract = exchangeContract
)
// 校验 signer 与账户 walletAddress 一致,否则服务端会返回 invalid signaturePOLY_ADDRESS 与 order.signer 需一致)
if (signedOrder.signer.lowercase() != walletAddress.lowercase()) {
val msg = "订单 signer 与账户 walletAddress 不一致,会导致 invalid signature。请确认该账户的私钥与 walletAddress 对应同一 EOA,且 API 密钥由该 EOA 创建。signer=${signedOrder.signer.take(10)}..., walletAddress=${walletAddress.take(10)}..."
logger.error(msg)
return Result.failure(IllegalStateException(msg))
}
// 构建订单请求
// 跟单订单使用 FAK (Fill-And-Kill),允许部分成交,未成交部分立即取消
// 这样可以快速响应 Leader 的交易,避免订单长期挂单导致价格不匹配
val orderRequest = NewOrderRequest(
order = signedOrder,
owner = owner,
orderType = "FAK", // Fill-And-Kill
deferExec = false
orderType = "FAK" // Fill-And-Kill
)
// 调用 API 创建订单
@@ -7,8 +7,10 @@ import com.wrbug.polymarketbot.service.common.MarketService
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.CryptoUtils
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import com.wrbug.polymarketbot.util.div
import com.wrbug.polymarketbot.util.gt
import com.wrbug.polymarketbot.util.multi
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import org.springframework.boot.context.event.ApplicationReadyEvent
@@ -38,7 +40,8 @@ class OrderStatusUpdateService(
private val cryptoUtils: CryptoUtils,
private val trackingService: CopyOrderTrackingService,
private val marketService: MarketService, // 市场信息服务
private val telegramNotificationService: TelegramNotificationService?
private val telegramNotificationService: TelegramNotificationService?,
private val blockchainService: com.wrbug.polymarketbot.service.common.BlockchainService
) : ApplicationContextAware {
private val logger = LoggerFactory.getLogger(OrderStatusUpdateService::class.java)
@@ -198,8 +201,6 @@ class OrderStatusUpdateService(
return
}
logger.debug("检查 ${ordersToCheck.size} 个30秒前创建的订单是否成交")
// 按账户分组,避免重复创建 API 客户端
val ordersByAccount = ordersToCheck.groupBy { it.accountId }
@@ -557,11 +558,13 @@ class OrderStatusUpdateService(
logger.info("更新卖出订单价格成功: orderId=${record.sellOrderId}, 原价格=${record.sellPrice}, 新价格=$actualSellPrice")
// 发送通知(使用实际价
// 发送通知(使用实际成交价)
sendSellOrderNotification(
record = updatedRecord,
actualPrice = actualSellPrice.toString(),
actualSize = record.totalMatchedQuantity.toString(),
avgFilledPrice = actualSellPrice.toString(),
filled = record.totalMatchedQuantity.toString(),
account = account,
copyTrading = copyTrading,
clobApi = clobApi,
@@ -590,11 +593,13 @@ class OrderStatusUpdateService(
logger.debug("卖出订单价格无需更新: orderId=${record.sellOrderId}, price=$actualSellPrice")
// 发送通知
// 发送通知(使用实际成交价)
sendSellOrderNotification(
record = updatedRecord,
actualPrice = actualSellPrice.toString(),
actualSize = record.totalMatchedQuantity.toString(),
avgFilledPrice = actualSellPrice.toString(),
filled = record.totalMatchedQuantity.toString(),
account = account,
copyTrading = copyTrading,
clobApi = clobApi,
@@ -810,9 +815,11 @@ class OrderStatusUpdateService(
val actualPrice = orderDetail.price?.toSafeBigDecimal() ?: order.price
val actualSize = orderDetail.originalSize?.toSafeBigDecimal() ?: order.quantity
val actualOutcome = orderDetail.outcome
// 使用交易所订单的实际创建时间(API返回秒级,转为毫秒)
val actualCreatedAt = if (orderDetail.createdAt > 0) orderDetail.createdAt * 1000 else order.createdAt
// 更新订单数据(如果实际数据与临时数据不同)
val needUpdate = actualPrice != order.price || actualSize != order.quantity
val needUpdate = actualPrice != order.price || actualSize != order.quantity || actualCreatedAt != order.createdAt
// 先保存更新后的订单,标记 notificationSent = true
// 这样可以防止其他并发任务重复发送通知
@@ -833,7 +840,7 @@ class OrderStatusUpdateService(
status = order.status,
notificationSent = true, // 标记为已发送通知
source = order.source, // 保留原始订单来源
createdAt = order.createdAt,
createdAt = actualCreatedAt,
updatedAt = System.currentTimeMillis()
)
@@ -846,12 +853,24 @@ class OrderStatusUpdateService(
logger.debug("买入订单数据无需更新: orderId=${order.buyOrderId}")
}
// 发送通知(使用实际数据)
// 有成交时按公式计算实际成交价:original_size * price / size_matched,数量用 size_matched
val sizeMatchedDec = orderDetail.sizeMatched.toSafeBigDecimal()
val avgFilledPriceStr = if (sizeMatchedDec.gt(BigDecimal.ZERO)) {
orderDetail.originalSize.toSafeBigDecimal()
.multi(orderDetail.price)
.div(sizeMatchedDec, 18)
.toPlainString()
} else null
val filledSize = orderDetail.sizeMatched
// 发送通知(使用实际数据,优先展示平均成交价)
sendBuyOrderNotification(
order = updatedOrder,
actualPrice = actualPrice.toString(),
actualSize = actualSize.toString(),
actualOutcome = actualOutcome,
avgFilledPrice = avgFilledPriceStr,
filled = filledSize,
account = account,
copyTrading = copyTrading,
clobApi = clobApi,
@@ -878,6 +897,8 @@ class OrderStatusUpdateService(
actualPrice: String? = null,
actualSize: String? = null,
actualOutcome: String? = null,
avgFilledPrice: String? = null, // 平均成交价(有成交时用于 TG 展示)
filled: String? = null, // 已成交数量(与 avgFilledPrice 一起用于金额计算)
account: Account? = null,
copyTrading: CopyTrading? = null,
clobApi: PolymarketClobApi? = null,
@@ -932,14 +953,24 @@ class OrderStatusUpdateService(
null
}
// 发送通知
// 查询可用余额
val availableBalance = try {
blockchainService.getUsdcBalance(finalAccount.walletAddress, finalAccount.proxyAddress).getOrNull()
} catch (e: Exception) {
logger.warn("查询可用余额失败: accountId=${finalAccount.id}, ${e.message}")
null
}
// 发送通知(优先使用平均成交价展示)
telegramNotificationService.sendOrderSuccessNotification(
orderId = order.buyOrderId,
marketTitle = marketTitle,
marketId = order.marketId,
marketSlug = market?.eventSlug, // 跳转用的 slug
side = "BUY",
price = actualPrice ?: order.price.toString(), // 使用实际价格或临时价格
price = actualPrice ?: order.price.toString(), // 限价,无 avgFilledPrice 时展示
avgFilledPrice = avgFilledPrice,
filled = filled,
size = actualSize ?: order.quantity.toString(), // 使用实际数量或临时数量
outcome = actualOutcome, // 使用实际 outcome
accountName = finalAccount.accountName,
@@ -952,7 +983,8 @@ class OrderStatusUpdateService(
locale = locale,
leaderName = leaderName,
configName = configName,
orderTime = orderCreatedAt // 使用订单创建时间
orderTime = orderCreatedAt, // 使用订单创建时间
availableBalance = availableBalance
)
logger.info("买入订单通知已发送: orderId=${order.buyOrderId}, copyTradingId=${order.copyTradingId}")
@@ -971,6 +1003,8 @@ class OrderStatusUpdateService(
actualPrice: String? = null,
actualSize: String? = null,
actualOutcome: String? = null,
avgFilledPrice: String? = null, // 平均成交价(有成交时用于 TG 展示)
filled: String? = null, // 已成交数量(与 avgFilledPrice 一起用于金额计算)
account: Account? = null,
copyTrading: CopyTrading? = null,
clobApi: PolymarketClobApi? = null,
@@ -1025,14 +1059,24 @@ class OrderStatusUpdateService(
null
}
// 发送通知
// 查询可用余额
val availableBalance = try {
blockchainService.getUsdcBalance(finalAccount.walletAddress, finalAccount.proxyAddress).getOrNull()
} catch (e: Exception) {
logger.warn("查询可用余额失败: accountId=${finalAccount.id}, ${e.message}")
null
}
// 发送通知(优先使用平均成交价展示)
telegramNotificationService.sendOrderSuccessNotification(
orderId = record.sellOrderId,
marketTitle = marketTitle,
marketId = record.marketId,
marketSlug = market?.eventSlug, // 跳转用的 slug
side = "SELL",
price = actualPrice ?: record.sellPrice.toString(), // 使用实际价格或临时价格
price = actualPrice ?: record.sellPrice.toString(), // 限价,无 avgFilledPrice 时展示
avgFilledPrice = avgFilledPrice,
filled = filled,
size = actualSize ?: record.totalMatchedQuantity.toString(), // 使用实际数量或临时数量
outcome = actualOutcome, // 使用实际 outcome
accountName = finalAccount.accountName,
@@ -1045,7 +1089,8 @@ class OrderStatusUpdateService(
locale = locale,
leaderName = leaderName,
configName = configName,
orderTime = orderCreatedAt // 使用订单创建时间
orderTime = orderCreatedAt, // 使用订单创建时间
availableBalance = availableBalance
)
logger.info("卖出订单通知已发送: orderId=${record.sellOrderId}, copyTradingId=${record.copyTradingId}")
@@ -0,0 +1,843 @@
package com.wrbug.polymarketbot.service.cryptotail
import com.wrbug.polymarketbot.api.GammaEventBySlugResponse
import com.wrbug.polymarketbot.constants.PolymarketConstants
import com.wrbug.polymarketbot.dto.CryptoTailMonitorInitRequest
import com.wrbug.polymarketbot.dto.CryptoTailMonitorInitResponse
import com.wrbug.polymarketbot.dto.CryptoTailMonitorPushData
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService
import com.wrbug.polymarketbot.service.binance.BinanceKlineService
import com.wrbug.polymarketbot.service.common.WebSocketSubscriptionService
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.createClient
import com.wrbug.polymarketbot.util.fromJson
import com.wrbug.polymarketbot.util.toJson
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import org.slf4j.LoggerFactory
import org.springframework.context.event.EventListener
import org.springframework.stereotype.Service
import jakarta.annotation.PostConstruct
import jakarta.annotation.PreDestroy
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.math.BigDecimal
import java.math.RoundingMode
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
/**
* 加密价差策略监控服务
* 负责实时推送监控数据到前端
*/
@Service
class CryptoTailMonitorService(
private val strategyRepository: CryptoTailStrategyRepository,
private val accountRepository: AccountRepository,
private val retrofitFactory: RetrofitFactory,
private val binanceKlineService: BinanceKlineService,
private val binanceKlineAutoSpreadService: BinanceKlineAutoSpreadService,
private val webSocketSubscriptionService: WebSocketSubscriptionService
) {
private val logger = LoggerFactory.getLogger(CryptoTailMonitorService::class.java)
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
/** 当前周期 token 映射 */
private val currentPeriodTokenToStrategy = AtomicReference<Map<String, List<MonitorEntry>>>(emptyMap())
/** 下一周期 token 映射 */
private val nextPeriodTokenToStrategy = AtomicReference<Map<String, List<MonitorEntry>>>(emptyMap())
/** strategyId -> 当前价格数据 */
private val strategyPriceData = ConcurrentHashMap<Long, StrategyPriceData>()
/** strategyId -> 订阅者数量 */
private val strategySubscribers = ConcurrentHashMap<Long, Int>()
private var currentPeriodWebSocket: WebSocket? = null
private var nextPeriodWebSocket: WebSocket? = null
private val wsUrl = PolymarketConstants.RTDS_WS_URL + "/ws/market"
private val client by lazy {
createClient().build()
}
private val reconnectDelayMs = 3_000L
private var reconnectJob: Job? = null
private val closedForNoSubscribers = AtomicBoolean(false)
private val connectLock = Any()
/** 防止 refreshSubscription 并发执行(周期结束时定时器与消息可能同时触发) */
private val refreshSubscriptionMutex = Mutex()
/** 周期结束倒计时 Job */
private var periodEndCountdownJob: Job? = null
/** 定时推送 Job(每 1.5 秒推送一次,保证 BTC 价格和分时图持续更新) */
private var periodicPushJob: Job? = null
private val pushIntervalMs = 1_500L
/** 策略推送历史(用于中途进入时补全分时图,最多保留 300 条) */
private val strategyPushHistory = ConcurrentHashMap<Long, MutableList<CryptoTailMonitorPushData>>()
private val strategyHistoryPeriod = ConcurrentHashMap<Long, Long>()
private val maxHistorySize = 300
/** price_change 推送节流:每策略最近一次推送时间,1s 内不重复推送 */
private val lastPriceChangePushTime = ConcurrentHashMap<Long, Long>()
private val priceChangePushThrottleMs = 1_000L
/** 当前周期/下一周期构建时缓存的市场标题,key = "strategyId-periodStartUnix",供推送携带 */
private val marketTitleByStrategyPeriod = ConcurrentHashMap<String, String>()
data class MonitorEntry(
val strategyId: Long,
val strategy: CryptoTailStrategy,
val periodStartUnix: Long,
val outcomeIndex: Int,
val tokenId: String,
/** 是否为下一个周期(用于预先订阅) */
val isNextPeriod: Boolean = false
)
data class StrategyPriceData(
val currentPriceUp: BigDecimal? = null,
val currentPriceDown: BigDecimal? = null,
/** BTC 开盘价 USDC(币安 K 线 open */
val openPriceBtc: BigDecimal? = null,
val spreadUp: BigDecimal? = null,
val spreadDown: BigDecimal? = null,
val minSpreadLineUp: BigDecimal? = null,
val minSpreadLineDown: BigDecimal? = null,
val triggered: Boolean = false,
val triggerDirection: String? = null,
val lastUpdateTime: Long = System.currentTimeMillis(),
/** 当前周期开始时间(用于双连接周期切换) */
val periodStartUnix: Long? = null
)
@PostConstruct
fun init() {
// 服务启动时不主动连接,等待前端订阅
}
/**
* 初始化监控数据
*/
fun initMonitor(request: CryptoTailMonitorInitRequest): Result<CryptoTailMonitorInitResponse> {
return try {
val strategy = strategyRepository.findById(request.strategyId).orElse(null)
if (strategy == null) {
return Result.failure(IllegalArgumentException("策略不存在"))
}
val account = accountRepository.findById(strategy.accountId).orElse(null)
val nowSeconds = System.currentTimeMillis() / 1000
val periodStartUnix = request.periodStartUnix
?: ((nowSeconds / strategy.intervalSeconds) * strategy.intervalSeconds)
// 获取市场信息
val slug = "${strategy.marketSlugPrefix}-$periodStartUnix"
val event = fetchEventBySlug(slug).getOrNull()
val market = event?.markets?.firstOrNull()
val tokenIds = parseClobTokenIds(market?.clobTokenIds)
// 获取开盘价(币安 K 线 open = BTC 价格 USDC
val openClose = binanceKlineService.getCurrentOpenClose(
strategy.marketSlugPrefix,
strategy.intervalSeconds,
periodStartUnix
)
val openPriceBtc = openClose?.first
// 获取自动计算的最小价差
var autoMinSpreadUp: BigDecimal? = null
var autoMinSpreadDown: BigDecimal? = null
if (strategy.spreadMode.name.uppercase() == "AUTO") {
val autoSpreads = binanceKlineAutoSpreadService.computeAndCache(
strategy.marketSlugPrefix,
strategy.intervalSeconds,
periodStartUnix
)
autoMinSpreadUp = autoSpreads?.first
autoMinSpreadDown = autoSpreads?.second
}
// 保存价格数据到缓存
val priceData = StrategyPriceData(
openPriceBtc = openPriceBtc,
minSpreadLineUp = autoMinSpreadUp ?: strategy.spreadValue?.toSafeBigDecimal(),
minSpreadLineDown = autoMinSpreadDown ?: strategy.spreadValue?.toSafeBigDecimal(),
periodStartUnix = periodStartUnix
)
strategyPriceData[strategy.id!!] = priceData
val response = CryptoTailMonitorInitResponse(
strategyId = strategy.id!!,
name = strategy.name ?: "",
accountId = strategy.accountId,
accountName = account?.accountName ?: "",
marketSlugPrefix = strategy.marketSlugPrefix,
marketTitle = event?.title ?: strategy.marketSlugPrefix,
intervalSeconds = strategy.intervalSeconds,
periodStartUnix = periodStartUnix,
windowStartSeconds = strategy.windowStartSeconds,
windowEndSeconds = strategy.windowEndSeconds,
minPrice = strategy.minPrice.toPlainString(),
maxPrice = strategy.maxPrice.toPlainString(),
minSpreadMode = strategy.spreadMode.name,
spreadDirection = strategy.spreadDirection.name,
minSpreadValue = strategy.spreadValue?.toPlainString(),
autoMinSpreadUp = autoMinSpreadUp?.toPlainString(),
autoMinSpreadDown = autoMinSpreadDown?.toPlainString(),
openPriceBtc = openPriceBtc?.setScale(2, RoundingMode.HALF_UP)?.toPlainString(),
tokenIdUp = tokenIds.getOrNull(0),
tokenIdDown = tokenIds.getOrNull(1),
currentTimestamp = System.currentTimeMillis(),
enabled = strategy.enabled,
amountMode = strategy.amountMode,
amountValue = strategy.amountValue.toPlainString()
)
Result.success(response)
} catch (e: Exception) {
logger.error("初始化监控失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 订阅策略监控
*/
fun subscribe(sessionId: String, strategyId: Long, callback: (CryptoTailMonitorPushData) -> Unit) {
// 增加订阅计数
val count = strategySubscribers.merge(strategyId, 1) { old, inc -> old + inc } ?: 1
// 注册推送回调
webSocketSubscriptionService.registerMonitorCallback(sessionId, strategyId, callback)
// 如果是第一个订阅者,启动 WebSocket 和定时推送
if (count == 1) {
scope.launch {
refreshSubscription()
}
startPeriodicPush()
}
// 立即发送当前数据
scope.launch {
try {
sendCurrentData(sessionId, strategyId, callback)
} catch (e: Exception) {
logger.error("发送当前监控数据失败: $sessionId, ${e.message}")
}
}
}
/**
* 取消订阅策略监控
*/
fun unsubscribe(sessionId: String, strategyId: Long) {
// 减少订阅计数
val currentCount = strategySubscribers[strategyId] ?: 0
val newCount = (currentCount - 1).coerceAtLeast(0)
if (newCount == 0) {
strategySubscribers.remove(strategyId)
} else {
strategySubscribers[strategyId] = newCount
}
// 移除回调
webSocketSubscriptionService.unregisterMonitorCallback(sessionId, strategyId)
// 如果没有订阅者,关闭 WebSocket 和定时推送
if (newCount == 0) {
scope.launch {
refreshSubscription()
}
stopPeriodicPush()
}
}
private fun startPeriodicPush() {
if (periodicPushJob?.isActive == true) return
periodicPushJob = scope.launch {
while (strategySubscribers.isNotEmpty() && strategySubscribers.values.any { (it ?: 0) > 0 }) {
delay(pushIntervalMs)
if (closedForNoSubscribers.get()) continue
val ids = strategySubscribers.filter { (it.value ?: 0) > 0 }.keys.toList()
for (strategyId in ids) {
try {
val strategy = strategyRepository.findById(strategyId).orElse(null) ?: continue
val priceData = strategyPriceData[strategyId] ?: continue
val pushData = buildPushData(strategy, priceData)
addToHistoryAndPush(strategyId, pushData)
} catch (e: Exception) {
logger.debug("定时推送失败 strategyId=$strategyId: ${e.message}")
}
}
}
}
}
private fun stopPeriodicPush() {
if (strategySubscribers.isEmpty() || strategySubscribers.values.all { (it ?: 0) <= 0 }) {
periodicPushJob?.cancel()
periodicPushJob = null
}
}
/**
* 发送当前数据含历史补全用于中途进入时填充分时图
*/
private suspend fun sendCurrentData(
sessionId: String,
strategyId: Long,
callback: (CryptoTailMonitorPushData) -> Unit
) {
val strategy = strategyRepository.findById(strategyId).orElse(null) ?: return
val priceData = strategyPriceData[strategyId] ?: StrategyPriceData()
val history = strategyPushHistory[strategyId]?.let { list ->
synchronized(list) { list.toList() }
} ?: emptyList()
for (item in history) {
callback(item)
}
val pushData = buildPushData(strategy, priceData)
callback(pushData)
}
/**
* 刷新订阅双连接模式当前周期连接 + 下一周期连接周期切换时关闭过期连接下一连接晋升为当前并新建下一周期连接
* 使用 Mutex 防止周期结束时 scheduleRefreshAtPeriodEnd maybeRefreshSubscriptionIfPeriodChanged 同时触发导致重复执行
*/
private suspend fun refreshSubscription() {
if (!refreshSubscriptionMutex.tryLock()) {
return
}
try {
refreshSubscriptionInternal()
} finally {
refreshSubscriptionMutex.unlock()
}
}
private suspend fun refreshSubscriptionInternal() {
periodEndCountdownJob?.cancel()
periodEndCountdownJob = null
val subscribedStrategyIds = strategySubscribers.keys.filter { (strategySubscribers[it] ?: 0) > 0 }
if (subscribedStrategyIds.isEmpty()) {
closeAllWebSockets()
return
}
val strategies = strategyRepository.findAllById(subscribedStrategyIds).filter { it.enabled && it.id != null }
if (strategies.isEmpty()) {
closeAllWebSockets()
return
}
val nowSeconds = System.currentTimeMillis() / 1000
val isSwitch = currentPeriodWebSocket != null
if (isSwitch) {
// 周期切换:关闭当前周期连接,下一晋升为当前,新建下一周期连接
closeCurrentPeriodWebSocket()
currentPeriodWebSocket = nextPeriodWebSocket
nextPeriodWebSocket = null
val nextMap = nextPeriodTokenToStrategy.get()
currentPeriodTokenToStrategy.set(nextMap)
val nextPeriodByStrategy =
nextMap.values.flatten().distinctBy { it.strategyId }.associate { it.strategyId to it.periodStartUnix }
logger.info("周期切换:下一周期连接晋升为当前")
for ((strategyId, periodStartUnix) in nextPeriodByStrategy) {
updateStrategyPriceDataForPeriod(listOf(strategyId), periodStartUnix, pushDefault = true)
}
val (newNextTokenIds, newNextMap) = buildSubscriptionMapForNextPeriod(subscribedStrategyIds)
nextPeriodTokenToStrategy.set(newNextMap)
if (newNextTokenIds.isNotEmpty()) {
connectNextPeriod(newNextTokenIds, newNextMap)
} else {
logger.info("下一周期市场尚未创建,仅建立空连接以便周期切换时复用")
connectNextPeriod(emptyList(), emptyMap())
}
scheduleRefreshAtPeriodEnd(if (newNextMap.isNotEmpty()) newNextMap else nextMap)
} else {
// 首次:建立当前周期连接 + 下一周期连接
val (currentTokenIds, currentMap) = buildSubscriptionMapForCurrentPeriod(subscribedStrategyIds)
currentPeriodTokenToStrategy.set(currentMap)
for (entry in currentMap.values.flatten().distinctBy { it.strategyId }) {
updateStrategyPriceDataForPeriod(listOf(entry.strategyId), entry.periodStartUnix, pushDefault = false)
}
if (currentTokenIds.isEmpty()) {
closeAllWebSockets()
return
}
connectCurrentPeriod(currentTokenIds, currentMap)
val (nextTokenIds, nextMap) = buildSubscriptionMapForNextPeriod(subscribedStrategyIds)
nextPeriodTokenToStrategy.set(nextMap)
if (nextTokenIds.isNotEmpty()) {
connectNextPeriod(nextTokenIds, nextMap)
} else {
logger.info("下一周期市场尚未创建,先建立空连接,周期切换时会重新订阅")
connectNextPeriod(emptyList(), emptyMap())
}
scheduleRefreshAtPeriodEnd(currentMap)
}
}
/** 构建当前周期订阅(每个策略按自己的 interval 算当前周期) */
private suspend fun buildSubscriptionMapForCurrentPeriod(strategyIds: List<Long>): Pair<List<String>, Map<String, List<MonitorEntry>>> {
val strategies = strategyRepository.findAllById(strategyIds)
val nowSeconds = System.currentTimeMillis() / 1000
val tokenIdSet = mutableSetOf<String>()
val map = mutableMapOf<String, MutableList<MonitorEntry>>()
for (strategy in strategies) {
if (!strategy.enabled || strategy.id == null) continue
val strategyPeriod = (nowSeconds / strategy.intervalSeconds) * strategy.intervalSeconds
val slug = "${strategy.marketSlugPrefix}-$strategyPeriod"
val event = fetchEventBySlug(slug).getOrNull() ?: continue
marketTitleByStrategyPeriod["${strategy.id!!}-$strategyPeriod"] = event.title ?: strategy.marketSlugPrefix
val market = event.markets?.firstOrNull() ?: continue
val tokenIds = parseClobTokenIds(market.clobTokenIds)
if (tokenIds.size < 2) continue
for (i in tokenIds.indices) {
tokenIdSet.add(tokenIds[i])
map.getOrPut(tokenIds[i]) { mutableListOf() }.add(
MonitorEntry(strategy.id!!, strategy, strategyPeriod, i, tokenIds[i], false)
)
}
}
return Pair(tokenIdSet.toList(), map)
}
/** 构建下一周期订阅(每个策略按自己的 interval 算下一周期) */
private suspend fun buildSubscriptionMapForNextPeriod(strategyIds: List<Long>): Pair<List<String>, Map<String, List<MonitorEntry>>> {
val strategies = strategyRepository.findAllById(strategyIds)
val nowSeconds = System.currentTimeMillis() / 1000
val tokenIdSet = mutableSetOf<String>()
val map = mutableMapOf<String, MutableList<MonitorEntry>>()
for (strategy in strategies) {
if (!strategy.enabled || strategy.id == null) {
continue
}
val currentPeriod = (nowSeconds / strategy.intervalSeconds) * strategy.intervalSeconds
val nextPeriod = currentPeriod + strategy.intervalSeconds
val slug = "${strategy.marketSlugPrefix}-$nextPeriod"
val event = fetchEventBySlug(slug).getOrNull()
if (event == null) {
continue
}
marketTitleByStrategyPeriod["${strategy.id!!}-$nextPeriod"] = event.title ?: strategy.marketSlugPrefix
val market = event.markets?.firstOrNull()
if (market == null) {
continue
}
val tokenIds = parseClobTokenIds(market.clobTokenIds)
if (tokenIds.size < 2) {
continue
}
for (i in tokenIds.indices) {
tokenIdSet.add(tokenIds[i])
map.getOrPut(tokenIds[i]) { mutableListOf() }.add(
MonitorEntry(strategy.id!!, strategy, nextPeriod, i, tokenIds[i], true)
)
}
}
return Pair(tokenIdSet.toList(), map)
}
/** 更新策略价格数据为指定周期(开盘价、价差线等),可选是否推送默认 0.5 */
private suspend fun updateStrategyPriceDataForPeriod(
strategyIds: List<Long>,
periodStartUnix: Long,
pushDefault: Boolean
) {
val strategies = strategyRepository.findAllById(strategyIds)
for (strategy in strategies) {
if (strategy.id == null) continue
val openClose = binanceKlineService.getCurrentOpenClose(
strategy.marketSlugPrefix,
strategy.intervalSeconds,
periodStartUnix
)
val openPriceBtc = openClose?.first
var minSpreadLineUp: BigDecimal? = null
var minSpreadLineDown: BigDecimal? = null
when (strategy.spreadMode.name.uppercase()) {
"FIXED" -> {
minSpreadLineUp = strategy.spreadValue?.toSafeBigDecimal()
minSpreadLineDown = strategy.spreadValue?.toSafeBigDecimal()
}
"AUTO" -> {
val autoSpreads = binanceKlineAutoSpreadService.computeAndCache(
strategy.marketSlugPrefix,
strategy.intervalSeconds,
periodStartUnix
)
minSpreadLineUp = autoSpreads?.first
minSpreadLineDown = autoSpreads?.second
}
}
val existingData = strategyPriceData[strategy.id] ?: StrategyPriceData()
val periodChanged = existingData.periodStartUnix != null && existingData.periodStartUnix != periodStartUnix
val newData = StrategyPriceData(
currentPriceUp = if (periodChanged && pushDefault) BigDecimal("0.5") else existingData.currentPriceUp,
currentPriceDown = if (periodChanged && pushDefault) BigDecimal("0.5") else existingData.currentPriceDown,
spreadUp = if (periodChanged && pushDefault) BigDecimal("0.5") else existingData.spreadUp,
spreadDown = if (periodChanged && pushDefault) BigDecimal("0.5") else existingData.spreadDown,
openPriceBtc = openPriceBtc,
minSpreadLineUp = minSpreadLineUp,
minSpreadLineDown = minSpreadLineDown,
periodStartUnix = periodStartUnix
)
strategyPriceData[strategy.id!!] = newData
if (periodChanged && pushDefault) {
val pushData = buildPushData(strategy, newData)
addToHistoryAndPush(strategy.id!!, pushData)
}
}
}
private fun connectCurrentPeriod(tokenIds: List<String>, map: Map<String, List<MonitorEntry>>) {
if (currentPeriodWebSocket != null) return
val request = Request.Builder().url(wsUrl).build()
currentPeriodWebSocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
closedForNoSubscribers.set(false)
val msg = """{"type":"MARKET","assets_ids":${tokenIds.toJson()}}"""
try {
webSocket.send(msg)
logger.info("加密价差策略监控 WebSocket(当前周期)已连接并订阅: ${tokenIds.size} 个 token")
} catch (e: Exception) {
logger.warn("发送当前周期订阅失败: ${e.message}")
}
}
override fun onMessage(webSocket: WebSocket, text: String) {
handleMessage(webSocket, text)
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
if (this@CryptoTailMonitorService.currentPeriodWebSocket == webSocket) {
this@CryptoTailMonitorService.currentPeriodWebSocket = null
if (!closedForNoSubscribers.get()) scheduleReconnect()
}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
if (this@CryptoTailMonitorService.currentPeriodWebSocket == webSocket) {
this@CryptoTailMonitorService.currentPeriodWebSocket = null
scheduleReconnect()
}
}
})
}
private fun connectNextPeriod(tokenIds: List<String>, map: Map<String, List<MonitorEntry>>) {
if (nextPeriodWebSocket != null) {
return
}
val request = Request.Builder().url(wsUrl).build()
nextPeriodWebSocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
val msg = """{"type":"MARKET","assets_ids":${tokenIds.toJson()}}"""
try {
webSocket.send(msg)
if (tokenIds.isEmpty()) {
logger.info("加密价差策略监控 WebSocket(下一周期)已连接,暂无 token 订阅,等待周期切换后更新")
} else {
logger.info("加密价差策略监控 WebSocket(下一周期)已连接并订阅: ${tokenIds.size} 个 token")
}
} catch (e: Exception) {
logger.warn("发送下一周期订阅失败: ${e.message}")
}
}
override fun onMessage(webSocket: WebSocket, text: String) {
handleMessage(webSocket, text)
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
if (this@CryptoTailMonitorService.nextPeriodWebSocket == webSocket) {
this@CryptoTailMonitorService.nextPeriodWebSocket = null
}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
if (this@CryptoTailMonitorService.nextPeriodWebSocket == webSocket) {
this@CryptoTailMonitorService.nextPeriodWebSocket = null
}
}
})
}
private fun closeCurrentPeriodWebSocket() {
currentPeriodWebSocket?.close(1000, "period_ended")
currentPeriodWebSocket = null
logger.info("加密价差策略监控 WebSocket(当前周期)已关闭")
}
private fun closeAllWebSockets() {
reconnectJob?.cancel()
reconnectJob = null
closedForNoSubscribers.set(true)
currentPeriodWebSocket?.close(1000, "no_subscribers")
currentPeriodWebSocket = null
nextPeriodWebSocket?.close(1000, "no_subscribers")
nextPeriodWebSocket = null
logger.info("加密价差策略监控 WebSocket 已全部关闭(无订阅者)")
}
private fun handleMessage(webSocket: WebSocket, text: String) {
if (text == "pong" || text.isEmpty()) return
if (closedForNoSubscribers.get()) return
maybeRefreshSubscriptionIfPeriodChanged()
val json = text.fromJson<com.google.gson.JsonObject>() ?: return
val eventType = (json.get("event_type") as? com.google.gson.JsonPrimitive)?.asString ?: return
val map = currentPeriodTokenToStrategy.get()
when (eventType) {
"price_change" -> {
val priceChanges = json.get("price_changes") as? com.google.gson.JsonArray ?: return
for (i in 0 until priceChanges.size()) {
val pc = priceChanges.get(i) as? com.google.gson.JsonObject ?: continue
val assetId = (pc.get("asset_id") as? com.google.gson.JsonPrimitive)?.asString ?: continue
val bestBidStr = (pc.get("best_bid") as? com.google.gson.JsonPrimitive)?.asString
val bestBid = bestBidStr?.toSafeBigDecimal()
if (bestBid != null) onPriceUpdate(assetId, bestBid, map)
}
}
}
}
private fun onPriceUpdate(tokenId: String, bestBid: BigDecimal, map: Map<String, List<MonitorEntry>>) {
if (closedForNoSubscribers.get()) return
val entries = map[tokenId] ?: return
for (entry in entries) {
val strategy = entry.strategy
val priceData = strategyPriceData[strategy.id!!] ?: StrategyPriceData()
// 根据方向更新价格
val newPriceData = if (entry.outcomeIndex == 0) {
// Up 方向
priceData.copy(
currentPriceUp = bestBid,
currentPriceDown = BigDecimal.ONE.subtract(bestBid),
spreadUp = BigDecimal.ONE.subtract(bestBid),
spreadDown = bestBid,
lastUpdateTime = System.currentTimeMillis()
)
} else {
// Down 方向
priceData.copy(
currentPriceDown = bestBid,
currentPriceUp = BigDecimal.ONE.subtract(bestBid),
spreadUp = bestBid,
spreadDown = BigDecimal.ONE.subtract(bestBid),
lastUpdateTime = System.currentTimeMillis()
)
}
strategyPriceData[strategy.id!!] = newPriceData
val now = System.currentTimeMillis()
val last = lastPriceChangePushTime[strategy.id!!] ?: 0L
if (now - last >= priceChangePushThrottleMs) {
lastPriceChangePushTime[strategy.id!!] = now
val pushData = buildPushData(strategy, newPriceData)
addToHistoryAndPush(strategy.id!!, pushData)
}
}
}
private fun addToHistoryAndPush(strategyId: Long, pushData: CryptoTailMonitorPushData) {
addToHistory(strategyId, pushData)
webSocketSubscriptionService.pushMonitorData(strategyId, pushData)
}
private fun addToHistory(strategyId: Long, pushData: CryptoTailMonitorPushData) {
val list = strategyPushHistory.getOrPut(strategyId) {
Collections.synchronizedList(mutableListOf<CryptoTailMonitorPushData>())
}
synchronized(list) {
val lastPeriod = strategyHistoryPeriod[strategyId]
if (lastPeriod != null && lastPeriod != pushData.periodStartUnix) {
list.clear()
}
strategyHistoryPeriod[strategyId] = pushData.periodStartUnix
list.add(pushData)
while (list.size > maxHistorySize) {
list.removeAt(0)
}
}
}
/**
* 构建推送数据
* 最新价价差使用币安 K 线的 BTC 价格open/close
*/
private fun buildPushData(strategy: CryptoTailStrategy, priceData: StrategyPriceData): CryptoTailMonitorPushData {
val nowSeconds = System.currentTimeMillis() / 1000
val periodStartUnix = (nowSeconds / strategy.intervalSeconds) * strategy.intervalSeconds
val periodEndUnix = periodStartUnix + strategy.intervalSeconds
val remainingSeconds = (periodEndUnix - nowSeconds).toInt().coerceAtLeast(0)
val windowStart = periodStartUnix + strategy.windowStartSeconds
val windowEnd = periodStartUnix + strategy.windowEndSeconds
val inTimeWindow = nowSeconds >= windowStart && nowSeconds < windowEnd
// 币安 K 线:open = 周期开盘价,close = 当前最新价(实时更新)
val openClose = binanceKlineService.getCurrentOpenClose(
strategy.marketSlugPrefix,
strategy.intervalSeconds,
periodStartUnix
)
val openPriceBtc = priceData.openPriceBtc ?: openClose?.first
val currentPriceBtc = openClose?.second
// K 线数据回来后更新缓存,供后续使用
if (openPriceBtc != null && priceData.openPriceBtc == null && strategy.id != null) {
strategyPriceData[strategy.id] = priceData.copy(openPriceBtc = openPriceBtc)
}
val spreadBtc = if (openPriceBtc != null && currentPriceBtc != null) {
currentPriceBtc.subtract(openPriceBtc)
} else null
// 判断价格区间(Polymarket 0-1
val currentUp = priceData.currentPriceUp
val currentDown = priceData.currentPriceDown
val inPriceRangeUp = currentUp != null &&
currentUp >= strategy.minPrice && currentUp <= strategy.maxPrice
val inPriceRangeDown = currentDown != null &&
currentDown >= strategy.minPrice && currentDown <= strategy.maxPrice
val marketTitle = marketTitleByStrategyPeriod["${strategy.id!!}-$periodStartUnix"] ?: strategy.marketSlugPrefix
return CryptoTailMonitorPushData(
strategyId = strategy.id!!,
timestamp = System.currentTimeMillis(),
periodStartUnix = periodStartUnix,
marketTitle = marketTitle,
currentPriceUp = priceData.currentPriceUp?.setScale(4, RoundingMode.HALF_UP)?.toPlainString(),
currentPriceDown = priceData.currentPriceDown?.setScale(4, RoundingMode.HALF_UP)?.toPlainString(),
spreadUp = priceData.spreadUp?.setScale(4, RoundingMode.HALF_UP)?.toPlainString(),
spreadDown = priceData.spreadDown?.setScale(4, RoundingMode.HALF_UP)?.toPlainString(),
minSpreadLineUp = priceData.minSpreadLineUp?.setScale(2, RoundingMode.HALF_UP)?.toPlainString(),
minSpreadLineDown = priceData.minSpreadLineDown?.setScale(2, RoundingMode.HALF_UP)?.toPlainString(),
openPriceBtc = openPriceBtc?.setScale(2, RoundingMode.HALF_UP)?.toPlainString(),
currentPriceBtc = currentPriceBtc?.setScale(2, RoundingMode.HALF_UP)?.toPlainString(),
spreadBtc = spreadBtc?.setScale(2, RoundingMode.HALF_UP)?.toPlainString(),
remainingSeconds = remainingSeconds,
inTimeWindow = inTimeWindow,
inPriceRangeUp = inPriceRangeUp,
inPriceRangeDown = inPriceRangeDown,
triggered = priceData.triggered,
triggerDirection = priceData.triggerDirection,
periodEnded = remainingSeconds <= 0
)
}
private fun maybeRefreshSubscriptionIfPeriodChanged() {
val subscribed = currentPeriodTokenToStrategy.get().values.flatten().distinctBy { it.strategyId }
.associate { it.strategyId to it.periodStartUnix }
if (subscribed.isEmpty()) return
val strategies = strategyRepository.findAllById(subscribed.keys)
val nowSeconds = System.currentTimeMillis() / 1000
for (s in strategies) {
if (s.id == null) continue
val currentPeriod = (nowSeconds / s.intervalSeconds) * s.intervalSeconds
val subPeriod = subscribed[s.id] ?: continue
if (currentPeriod != subPeriod) {
scope.launch { refreshSubscription() }
return
}
}
}
private fun scheduleRefreshAtPeriodEnd(newMap: Map<String, List<MonitorEntry>>) {
val entries = newMap.values.flatten()
if (entries.isEmpty()) return
val nextPeriodEndSeconds = entries.minOf { it.periodStartUnix + it.strategy.intervalSeconds }
val delayMs = (nextPeriodEndSeconds * 1000) - System.currentTimeMillis() + 2000
if (delayMs <= 0) return
periodEndCountdownJob = scope.launch {
delay(delayMs)
periodEndCountdownJob = null
refreshSubscription()
}
}
private fun closeWebSocketForNoSubscribers() {
closeAllWebSockets()
}
private fun scheduleReconnect() {
if (reconnectJob?.isActive == true) return
reconnectJob = scope.launch {
delay(reconnectDelayMs)
reconnectJob = null
if (strategySubscribers.isNotEmpty()) {
logger.info("加密价差策略监控 WebSocket 尝试重连")
refreshSubscription()
}
}
}
private fun fetchEventBySlug(slug: String): Result<GammaEventBySlugResponse> {
return try {
val api = retrofitFactory.createGammaApi()
val response = runBlocking { api.getEventBySlug(slug) }
if (response.isSuccessful && response.body() != null) {
Result.success(response.body()!!)
} else {
Result.failure(Exception("${response.code()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
private fun parseClobTokenIds(clobTokenIds: String?): List<String> {
if (clobTokenIds.isNullOrBlank()) return emptyList()
return clobTokenIds.fromJson<List<String>>() ?: emptyList()
}
@PreDestroy
fun destroy() {
reconnectJob?.cancel()
periodEndCountdownJob?.cancel()
periodicPushJob?.cancel()
currentPeriodWebSocket?.close(1000, "shutdown")
currentPeriodWebSocket = null
nextPeriodWebSocket?.close(1000, "shutdown")
nextPeriodWebSocket = null
}
}
@@ -0,0 +1,171 @@
package com.wrbug.polymarketbot.service.cryptotail
import com.wrbug.polymarketbot.entity.CryptoTailStrategyTrigger
import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository
import com.wrbug.polymarketbot.service.common.MarketService
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
import com.wrbug.polymarketbot.util.div
import com.wrbug.polymarketbot.util.gt
import com.wrbug.polymarketbot.util.multi
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import com.wrbug.polymarketbot.util.CryptoUtils
import com.wrbug.polymarketbot.util.RetrofitFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.slf4j.LoggerFactory
import org.springframework.context.ApplicationContext
import org.springframework.context.ApplicationContextAware
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import jakarta.annotation.PreDestroy
import java.math.BigDecimal
/**
* 加密价差策略订单 TG 通知轮询服务与跟单一致
* 定时查询下单成功且未发 TG的触发记录通过 CLOB getOrder 获取订单详情后发送 TG 并标记已发
*/
@Service
class CryptoTailOrderNotificationPollingService(
private val triggerRepository: CryptoTailStrategyTriggerRepository,
private val strategyRepository: CryptoTailStrategyRepository,
private val accountRepository: AccountRepository,
private val retrofitFactory: RetrofitFactory,
private val cryptoUtils: CryptoUtils,
private val marketService: MarketService,
private val telegramNotificationService: TelegramNotificationService
) : ApplicationContextAware {
private val logger = LoggerFactory.getLogger(CryptoTailOrderNotificationPollingService::class.java)
private val scopeJob = SupervisorJob()
private val scope = CoroutineScope(Dispatchers.IO + scopeJob)
private var applicationContext: ApplicationContext? = null
override fun setApplicationContext(applicationContext: ApplicationContext) {
this.applicationContext = applicationContext
}
private fun getSelf(): CryptoTailOrderNotificationPollingService {
return applicationContext?.getBean(CryptoTailOrderNotificationPollingService::class.java)
?: throw IllegalStateException("ApplicationContext not initialized")
}
@Volatile
private var notificationJob: Job? = null
@Scheduled(fixedDelay = 5000)
fun scheduledSendPendingNotifications() {
if (notificationJob != null && notificationJob!!.isActive) {
logger.debug("上一轮加密价差策略 TG 通知任务仍在执行,跳过本次")
return
}
notificationJob = scope.launch {
try {
getSelf().sendPendingNotifications()
} catch (e: Exception) {
logger.error("加密价差策略 TG 通知轮询异常: ${e.message}", e)
} finally {
notificationJob = null
}
}
}
@Transactional
suspend fun sendPendingNotifications() {
val pending = triggerRepository.findByStatusAndOrderIdIsNotNullAndNotificationSentFalseOrderByCreatedAtAsc("success")
if (pending.isEmpty()) return
for (trigger in pending) {
try {
if (trigger.resolved) {
trigger.notificationSent = true
triggerRepository.save(trigger)
logger.debug("触发已结算,跳过请求并标记已通知: triggerId=${trigger.id}, orderId=${trigger.orderId}")
continue
}
if (sendNotificationForTrigger(trigger)) {
trigger.notificationSent = true
triggerRepository.save(trigger)
}
} catch (e: Exception) {
logger.warn("加密价差策略 TG 通知单条失败: triggerId=${trigger.id}, orderId=${trigger.orderId}, ${e.message}", e)
}
}
}
private suspend fun sendNotificationForTrigger(trigger: CryptoTailStrategyTrigger): Boolean {
val strategy = strategyRepository.findById(trigger.strategyId).orElse(null) ?: return false
val account = accountRepository.findById(strategy.accountId).orElse(null) ?: return false
val orderId = trigger.orderId ?: return false
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
logger.debug("账户未配置 API 凭证,跳过 TG: accountId=${account.id}")
return false
}
val apiSecret = try {
cryptoUtils.decrypt(account.apiSecret)
} catch (e: Exception) {
logger.warn("解密 API Secret 失败: accountId=${account.id}", e)
return false
}
val apiPassphrase = try {
cryptoUtils.decrypt(account.apiPassphrase)
} catch (e: Exception) { "" }
val clobApi = retrofitFactory.createClobApi(
account.apiKey,
apiSecret,
apiPassphrase,
account.walletAddress
)
val orderResponse = clobApi.getOrder(orderId)
if (!orderResponse.isSuccessful) {
logger.debug("查询订单详情失败,等待下次轮询: orderId=$orderId, code=${orderResponse.code()}")
return false
}
val order = orderResponse.body() ?: run {
logger.debug("订单详情为空,等待下次轮询: orderId=$orderId")
return false
}
val market = marketService.getMarket(order.market)
val marketTitle = trigger.marketTitle?.takeIf { it.isNotBlank() } ?: market?.title ?: order.market
val orderTimeMs = if (order.createdAt < 1_000_000_000_000L) order.createdAt * 1000 else order.createdAt
// 实际成交价 = original_size * price / size_matched,数量用 size_matched
val sizeMatchedDec = order.sizeMatched.toSafeBigDecimal()
val avgFilledPriceStr = if (sizeMatchedDec.gt(BigDecimal.ZERO)) {
order.originalSize.toSafeBigDecimal()
.multi(order.price)
.div(sizeMatchedDec, 18)
.toPlainString()
} else null
val filledSize = order.sizeMatched
telegramNotificationService.sendCryptoTailOrderSuccessNotification(
orderId = orderId,
marketTitle = marketTitle,
marketId = order.market,
marketSlug = market?.eventSlug ?: market?.slug,
side = order.side,
outcome = order.outcome,
price = order.price,
size = order.originalSize,
avgFilledPrice = avgFilledPriceStr,
filled = filledSize,
strategyName = strategy.name,
accountName = account.accountName,
walletAddress = account.walletAddress,
orderTime = orderTimeMs
)
logger.info("加密价差策略订单 TG 通知已发送: orderId=$orderId, strategyId=${strategy.id}, triggerId=${trigger.id}")
return true
}
@PreDestroy
fun destroy() {
notificationJob?.cancel()
notificationJob = null
scopeJob.cancel()
}
}
@@ -0,0 +1,460 @@
package com.wrbug.polymarketbot.service.cryptotail
import com.wrbug.polymarketbot.api.GammaEventBySlugResponse
import com.wrbug.polymarketbot.constants.PolymarketConstants
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
import com.wrbug.polymarketbot.enums.SpreadMode
import com.wrbug.polymarketbot.event.CryptoTailStrategyChangedEvent
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService
import com.wrbug.polymarketbot.service.binance.BinanceKlineService
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.createClient
import com.wrbug.polymarketbot.util.fromJson
import com.wrbug.polymarketbot.util.gt
import com.wrbug.polymarketbot.util.toJson
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import org.slf4j.LoggerFactory
import org.springframework.context.event.EventListener
import org.springframework.stereotype.Service
import jakarta.annotation.PostConstruct
import jakarta.annotation.PreDestroy
import java.math.BigDecimal
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
/**
* 加密价差策略订单簿 WebSocket 监听订阅 CLOB Market 频道收到订单簿/价格变更时若满足条件立即触发下单
*/
@Service
class CryptoTailOrderbookWsService(
private val strategyRepository: CryptoTailStrategyRepository,
private val executionService: CryptoTailStrategyExecutionService,
private val retrofitFactory: RetrofitFactory,
private val binanceKlineAutoSpreadService: BinanceKlineAutoSpreadService,
private val binanceKlineService: BinanceKlineService
) {
private val logger = LoggerFactory.getLogger(CryptoTailOrderbookWsService::class.java)
private val scopeJob = SupervisorJob()
private val scope = CoroutineScope(Dispatchers.Default + scopeJob)
/** tokenId -> list of (strategy, periodStartUnix, marketTitle, tokenIds, outcomeIndex) */
private val tokenToEntries = AtomicReference<Map<String, List<WsBookEntry>>>(emptyMap())
private var webSocket: WebSocket? = null
private val wsUrl = PolymarketConstants.RTDS_WS_URL + "/ws/market"
private val client = createClient().build()
/** 订阅成功后设置的倒计时 Job,在周期结束时自动刷新订阅 */
private var periodEndCountdownJob: Job? = null
/** 重连延迟(毫秒) */
private val reconnectDelayMs = 3_000L
/** 因无启用策略而主动关闭 WS 时置为 true,onClosing 中不触发重连 */
private val closedForNoStrategies = AtomicBoolean(false)
/** 保护 connect() 的互斥锁,避免多线程并发创建连接 */
private val connectLock = Any()
/** 保护 refreshAndSubscribe() 的互斥锁,避免多线程并发刷新订阅 */
private val refreshLock = Any()
/** 标记是否正在刷新订阅,避免重复调用 */
private val isRefreshing = AtomicBoolean(false)
data class WsBookEntry(
val strategy: CryptoTailStrategy,
val periodStartUnix: Long,
val marketTitle: String?,
val tokenIds: List<String>,
val outcomeIndex: Int
)
@PostConstruct
fun init() {
if (strategyRepository.findAllByEnabledTrue().isNotEmpty()) connect()
}
@PreDestroy
fun destroy() {
periodEndCountdownJob?.cancel()
periodEndCountdownJob = null
reconnectJob?.cancel()
reconnectJob = null
synchronized(precomputeJobs) {
precomputeJobs.forEach { it.cancel() }
precomputeJobs.clear()
}
closedForNoStrategies.set(true)
try {
webSocket?.close(1000, "shutdown")
} catch (e: Exception) {
logger.debug("关闭加密价差策略 WebSocket 时异常: ${e.message}")
}
webSocket = null
scopeJob.cancel()
}
private fun connect() {
synchronized(connectLock) {
if (webSocket != null) return
try {
val request = Request.Builder().url(wsUrl).build()
webSocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
logger.info("加密价差策略订单簿 WebSocket 已连接")
refreshAndSubscribe(fromConnect = true)
}
override fun onMessage(webSocket: WebSocket, text: String) {
handleMessage(text)
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
this@CryptoTailOrderbookWsService.webSocket = null
if (!closedForNoStrategies.getAndSet(false)) scheduleReconnect()
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
logger.warn("加密价差策略订单簿 WebSocket 异常: ${t.message}")
this@CryptoTailOrderbookWsService.webSocket = null
scheduleReconnect()
}
})
} catch (e: Exception) {
logger.error("加密价差策略订单簿 WebSocket 连接失败: ${e.message}", e)
scheduleReconnect()
}
}
}
private var reconnectJob: Job? = null
private fun scheduleReconnect() {
if (reconnectJob?.isActive == true) return
reconnectJob = scope.launch {
delay(reconnectDelayMs)
reconnectJob = null
if (strategyRepository.findAllByEnabledTrue().isEmpty()) return@launch
logger.info("加密价差策略订单簿 WebSocket 尝试重连")
connect()
}
}
private fun handleMessage(text: String) {
if (text == "pong" || text.isEmpty()) return
if (closedForNoStrategies.get()) return
maybeRefreshSubscriptionIfPeriodChanged()
val json = text.fromJson<com.google.gson.JsonObject>() ?: return
val eventType = (json.get("event_type") as? com.google.gson.JsonPrimitive)?.asString ?: return
when (eventType) {
"book" -> {
val assetId = (json.get("asset_id") as? com.google.gson.JsonPrimitive)?.asString ?: return
val bids = json.get("bids") as? com.google.gson.JsonArray
if (bids == null || bids.isEmpty) return
// Polymarket book 的 bids 为价格升序,bids[0] 为最低买价;bestBid 应取最高买价
var bestBid: BigDecimal? = null
for (i in 0 until bids.size()) {
val level = bids.get(i) as? com.google.gson.JsonObject ?: continue
val p = (level.get("price") as? com.google.gson.JsonPrimitive)?.asString?.toSafeBigDecimal() ?: continue
if (bestBid == null || p.gt(bestBid)) bestBid = p
}
if (bestBid != null) onBestBid(assetId, bestBid)
}
"price_change" -> {
val priceChanges = json.get("price_changes") as? com.google.gson.JsonArray ?: return
for (i in 0 until priceChanges.size()) {
val pc = priceChanges.get(i) as? com.google.gson.JsonObject ?: continue
val assetId = (pc.get("asset_id") as? com.google.gson.JsonPrimitive)?.asString ?: continue
val bestBidStr = (pc.get("best_bid") as? com.google.gson.JsonPrimitive)?.asString
val bestBid = bestBidStr?.toSafeBigDecimal()
if (bestBid != null) onBestBid(assetId, bestBid)
}
}
}
}
private fun onBestBid(tokenId: String, bestBid: BigDecimal) {
if (closedForNoStrategies.get()) return
val entries = tokenToEntries.get()[tokenId]
if (entries == null) return
val nowSeconds = System.currentTimeMillis() / 1000
for (e in entries) {
val windowStart = e.periodStartUnix + e.strategy.windowStartSeconds
val windowEnd = e.periodStartUnix + e.strategy.windowEndSeconds
if (nowSeconds < windowStart || nowSeconds >= windowEnd) continue
scope.launch {
try {
executionService.tryTriggerWithPriceFromWs(
strategy = e.strategy,
periodStartUnix = e.periodStartUnix,
marketTitle = e.marketTitle,
tokenIds = e.tokenIds,
outcomeIndex = e.outcomeIndex,
bestBid = bestBid
)
} catch (ex: Exception) {
logger.error("WS 触发下单异常: strategyId=${e.strategy.id}, ${ex.message}", ex)
}
}
}
}
/**
* 事件驱动仅在收到 WS 消息时检查当前周期是否变化若变化则刷新订阅无需定时轮询
*/
private fun maybeRefreshSubscriptionIfPeriodChanged() {
val subscribed = tokenToEntries.get().values.flatten().distinctBy { it.strategy.id }
.associate { it.strategy.id!! to it.periodStartUnix }
if (subscribed.isEmpty()) return
val strategies = strategyRepository.findAllByEnabledTrue()
val nowSeconds = System.currentTimeMillis() / 1000
val currentStrategyIds = strategies.map { it.id!! }.toSet()
if (subscribed.keys != currentStrategyIds) {
refreshAndSubscribe()
return
}
for (s in strategies) {
val currentPeriod = (nowSeconds / s.intervalSeconds) * s.intervalSeconds
val subPeriod = subscribed[s.id!!] ?: continue
if (currentPeriod != subPeriod) {
refreshAndSubscribe()
return
}
}
}
private fun refreshAndSubscribe(fromConnect: Boolean = false) {
synchronized(refreshLock) {
// 如果正在刷新,直接返回,避免重复调用
if (isRefreshing.get()) {
logger.debug("加密价差策略订阅刷新已在进行中,跳过本次调用")
return
}
isRefreshing.set(true)
}
try {
val strategies = strategyRepository.findAllByEnabledTrue()
binanceKlineService.updateSubscriptions(strategies.map { it.marketSlugPrefix }.toSet())
periodEndCountdownJob?.cancel()
periodEndCountdownJob = null
val oldTokenIds = tokenToEntries.get().keys.toSet()
val (tokenIds, newMap) = buildSubscriptionMap()
tokenToEntries.set(newMap)
if (tokenIds.isEmpty()) {
closeWebSocketForNoStrategies()
return
}
if (!fromConnect) {
if (webSocket == null) {
connect()
return
}
if (oldTokenIds == tokenIds.toSet()) {
scheduleRefreshAtPeriodEnd(newMap)
precomputeAutoSpreadForCurrentPeriods(newMap)
return
}
closeWebSocketAndReconnect()
return
}
val marketSlugs = newMap.values.asSequence().flatten()
.distinctBy { "${it.strategy.marketSlugPrefix}-${it.periodStartUnix}" }
.map { "${it.strategy.marketSlugPrefix}-${it.periodStartUnix}" }
.toList()
val msg = """{"type":"MARKET","assets_ids":${tokenIds.toJson()}}"""
try {
webSocket?.send(msg)
logger.info("加密价差策略订单簿订阅: ${tokenIds.size} 个 token, 市场: $marketSlugs")
} catch (e: Exception) {
logger.warn("发送订阅失败: ${e.message}")
return
}
scheduleRefreshAtPeriodEnd(newMap)
precomputeAutoSpreadForCurrentPeriods(newMap)
} finally {
isRefreshing.set(false)
}
}
/**
* 订阅更新时关闭当前 WebSocket onClosing 触发重连重连后 onOpen 会重新订阅
*/
private fun closeWebSocketAndReconnect() {
val ws = webSocket
if (ws != null) {
webSocket = null
try {
ws.close(1000, "subscription_change")
} catch (e: Exception) {
logger.debug("关闭加密价差策略 WebSocket 时异常: ${e.message}")
}
logger.info("加密价差策略订单簿 WebSocket 已关闭(订阅更新,将重连)")
}
}
/** 跟踪预计算价差的协程 Job,用于在关闭时取消 */
private val precomputeJobs = mutableSetOf<Job>()
/**
* AUTO 模式在周期开始刷新订阅时预拉历史 30 K 线并计算该周期价差触发时直接用缓存
*/
private fun precomputeAutoSpreadForCurrentPeriods(newMap: Map<String, List<WsBookEntry>>) {
val autoPeriods = newMap.values.asSequence().flatten()
.filter { it.strategy.spreadMode == SpreadMode.AUTO }
.distinctBy { "${it.strategy.marketSlugPrefix}-${it.strategy.intervalSeconds}-${it.periodStartUnix}" }
.map { Triple(it.strategy.marketSlugPrefix, it.strategy.intervalSeconds, it.periodStartUnix) }
.toList()
if (autoPeriods.isEmpty()) return
val job = scope.launch {
for ((marketPrefix, intervalSeconds, periodStartUnix) in autoPeriods) {
try {
val pair = binanceKlineAutoSpreadService.computeAndCache(marketPrefix, intervalSeconds, periodStartUnix)
if (pair != null) {
logger.info(
"周期开始初始价差: market=$marketPrefix interval=${intervalSeconds}s periodStartUnix=$periodStartUnix " +
"baseSpreadUp=${pair.first.toPlainString()} baseSpreadDown=${pair.second.toPlainString()}"
)
}
} catch (e: Exception) {
logger.warn("周期开始预计算 AUTO 价差失败: market=$marketPrefix interval=$intervalSeconds periodStartUnix=$periodStartUnix ${e.message}")
}
}
}
synchronized(precomputeJobs) {
precomputeJobs.add(job)
// 清理已完成的 Job,避免集合无限增长
precomputeJobs.removeIf { !it.isActive }
}
}
/**
* 无启用策略或无需订阅时关闭 WebSocket并取消重连停用策略后刷新订阅会走到此处
*/
private fun closeWebSocketForNoStrategies() {
reconnectJob?.cancel()
reconnectJob = null
val ws = webSocket
if (ws != null) {
closedForNoStrategies.set(true)
webSocket = null
try {
ws.close(1000, "no_enabled_strategies")
} catch (e: Exception) {
logger.debug("关闭加密价差策略 WebSocket 时异常: ${e.message}")
}
logger.info("加密价差策略订单簿 WebSocket 已关闭(无启用策略)")
}
}
/**
* 订阅成功后设置倒计时在当前周期结束时自动刷新订阅无需等消息触发
*/
private fun scheduleRefreshAtPeriodEnd(newMap: Map<String, List<WsBookEntry>>) {
val entries = newMap.values.flatten()
if (entries.isEmpty()) return
val nextPeriodEndSeconds = entries.minOf { it.periodStartUnix + it.strategy.intervalSeconds }
val delayMs = (nextPeriodEndSeconds * 1000) - System.currentTimeMillis() + 2000
if (delayMs <= 0) return
periodEndCountdownJob = scope.launch {
delay(delayMs)
periodEndCountdownJob = null
refreshAndSubscribe()
}
logger.debug("加密价差策略订单簿订阅倒计时: ${delayMs / 1000}s 后刷新")
}
private fun buildSubscriptionMap(): Pair<List<String>, Map<String, List<WsBookEntry>>> {
val strategies = strategyRepository.findAllByEnabledTrue()
val nowSeconds = System.currentTimeMillis() / 1000
val tokenIdSet = mutableSetOf<String>()
val map = mutableMapOf<String, MutableList<WsBookEntry>>()
for (strategy in strategies) {
val interval = strategy.intervalSeconds
val periodStartUnix = (nowSeconds / interval) * interval
val windowEnd = periodStartUnix + strategy.windowEndSeconds
if (nowSeconds >= windowEnd) {
logger.debug("加密价差策略跳过(已过时间窗口): strategyId=${strategy.id}, slug=${strategy.marketSlugPrefix}, windowEnd=$windowEnd")
continue
}
val slug = "${strategy.marketSlugPrefix}-$periodStartUnix"
val event = runBlocking { fetchEventBySlugWithRetry(slug).getOrNull() }
if (event == null) {
logger.warn("加密价差策略跳过(拉取事件失败): strategyId=${strategy.id}, slug=$slug,请确认 Gamma 是否存在该 slug 或稍后重试")
continue
}
val market = event.markets?.firstOrNull()
if (market == null) {
logger.warn("加密价差策略跳过(事件无市场): strategyId=${strategy.id}, slug=$slug")
continue
}
val tokenIds = parseClobTokenIds(market.clobTokenIds)
if (tokenIds.size < 2) {
logger.warn("加密价差策略跳过(token 数量不足): strategyId=${strategy.id}, slug=$slug, tokenCount=${tokenIds.size}")
continue
}
tokenIdSet.addAll(tokenIds)
for (i in tokenIds.indices) {
map.getOrPut(tokenIds[i]) { mutableListOf() }.add(
WsBookEntry(strategy, periodStartUnix, event.title, tokenIds, i)
)
}
}
return Pair(tokenIdSet.toList(), map)
}
/** 拉取事件,失败时重试最多 2 次(间隔 1s),避免瞬时失败导致多策略只订阅到其中一个 */
private suspend fun fetchEventBySlugWithRetry(slug: String, maxAttempts: Int = 3): Result<GammaEventBySlugResponse> {
var lastFailure: Exception? = null
repeat(maxAttempts) { attempt ->
val result = fetchEventBySlug(slug)
if (result.isSuccess) return result
lastFailure = result.exceptionOrNull() as? Exception
if (attempt < maxAttempts - 1) delay(1000L)
}
return Result.failure(lastFailure ?: Exception("fetchEventBySlug failed"))
}
private suspend fun fetchEventBySlug(slug: String): Result<GammaEventBySlugResponse> {
return try {
val api = retrofitFactory.createGammaApi()
val response = api.getEventBySlug(slug)
if (response.isSuccessful && response.body() != null) {
Result.success(response.body()!!)
} else {
Result.failure(Exception("${response.code()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
private fun parseClobTokenIds(clobTokenIds: String?): List<String> {
if (clobTokenIds.isNullOrBlank()) return emptyList()
val parsed = clobTokenIds.fromJson<List<String>>()
return parsed ?: emptyList()
}
@EventListener
fun onStrategyChanged(event: CryptoTailStrategyChangedEvent) {
refreshAndSubscribe()
}
}
@@ -0,0 +1,285 @@
package com.wrbug.polymarketbot.service.cryptotail
import com.wrbug.polymarketbot.api.GammaEventBySlugResponse
import com.wrbug.polymarketbot.api.PolymarketDataApi
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
import com.wrbug.polymarketbot.entity.CryptoTailStrategyTrigger
import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository
import com.wrbug.polymarketbot.service.common.BlockchainService
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.gt
import com.wrbug.polymarketbot.util.multi
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import jakarta.annotation.PreDestroy
import java.math.BigDecimal
import java.math.RoundingMode
/**
* 加密价差策略结算轮询服务
* 定时扫描状态成功但未结算的触发记录通过 Gamma 获取 conditionId链上查询结算结果计算收益并回写
* 实际成交价与成交量使用 Data API activity 接口获取getUserActivity CLOB getOrder 更准确失败时回退为触发时的 amountUsdc + 固定价 0.99
*/
@Service
class CryptoTailSettlementService(
private val triggerRepository: CryptoTailStrategyTriggerRepository,
private val strategyRepository: CryptoTailStrategyRepository,
private val accountRepository: AccountRepository,
private val retrofitFactory: RetrofitFactory,
private val blockchainService: BlockchainService
) {
private val logger = LoggerFactory.getLogger(CryptoTailSettlementService::class.java)
private val triggerFixedPrice = BigDecimal("0.99")
private val pnlScale = 8
private val settlementScopeJob = SupervisorJob()
private val settlementScope = CoroutineScope(Dispatchers.IO + settlementScopeJob)
/** 跟踪上一轮结算任务的 Job,防止并发执行(与 OrderStatusUpdateService 一致) */
@Volatile
private var settlementJob: Job? = null
/**
* 定时轮询 10 秒执行一次
* 若上一轮任务仍在执行则跳过本次避免并发重叠
*/
@Scheduled(fixedDelay = 10_000)
fun scheduledPollAndSettle() {
val previousJob = settlementJob
if (previousJob != null && previousJob.isActive) {
logger.debug("上一轮加密价差策略结算任务仍在执行,跳过本次调度")
return
}
settlementJob = settlementScope.launch {
try {
doPollAndSettle()
} catch (e: Exception) {
logger.error("加密价差策略结算定时任务异常: ${e.message}", e)
} finally {
settlementJob = null
}
}
}
/**
* 轮询入口拉取所有 status=success resolved=false 的触发记录逐条尝试结算并更新
* Controller/定时任务调用此方法内部对 suspend 使用 runBlocking
*/
@Transactional
fun pollAndSettle(): Int = runBlocking {
doPollAndSettle()
}
private suspend fun doPollAndSettle(): Int {
val pending = triggerRepository.findByStatusAndResolvedAndOrderIdIsNotNullOrderByCreatedAtAsc("success", false)
if (pending.isEmpty()) return 0
var settledCount = 0
for (trigger in pending) {
try {
if (settleOne(trigger)) settledCount++
} catch (e: Exception) {
logger.warn("加密价差策略结算单条失败: triggerId=${trigger.id}, ${e.message}", e)
}
}
if (settledCount > 0) {
logger.info("加密价差策略结算轮询完成: 处理=${pending.size}, 新结算=$settledCount")
}
return settledCount
}
/**
* 处理单条触发记录解析 conditionId -> 查链上结算 -> 若已结算则计算 pnl 并更新
* 通过 copy() 生成新实体再 save不直接修改原实体实际成交价与投入金额从 Data API activity 获取并更新 triggerPriceamountUsdc
* @return true 表示本条已结算并更新
*/
private suspend fun settleOne(trigger: CryptoTailStrategyTrigger): Boolean {
if (trigger.resolved) return false
val strategy = strategyRepository.findById(trigger.strategyId).orElse(null) ?: return false
val conditionId = resolveConditionId(strategy, trigger) ?: return false
val fill = fetchActivityFill(trigger, strategy, conditionId)
val (newTriggerPrice, newAmountUsdc) = if (fill != null && fill.price.gt(BigDecimal.ZERO) && fill.size.gt(BigDecimal.ZERO)) {
val amountUsdc = fill.usdcSize?.takeIf { it.gt(BigDecimal.ZERO) }
?: fill.price.multi(fill.size).setScale(pnlScale, RoundingMode.HALF_UP)
Pair(fill.price, amountUsdc)
} else {
Pair(trigger.triggerPrice, trigger.amountUsdc)
}
val (_, payouts) = blockchainService.getCondition(conditionId).getOrNull() ?: run {
if (fill != null) {
val updated = trigger.copy(triggerPrice = newTriggerPrice, amountUsdc = newAmountUsdc)
triggerRepository.save(updated)
}
return false
}
if (payouts.isEmpty()) {
if (fill != null) {
val updated = trigger.copy(triggerPrice = newTriggerPrice, amountUsdc = newAmountUsdc)
triggerRepository.save(updated)
}
return false
}
val winnerIndex = payouts.indexOfFirst { it == java.math.BigInteger.ONE }
if (winnerIndex < 0) return false
val won = trigger.outcomeIndex == winnerIndex
val pnl = if (fill != null && fill.price.gt(BigDecimal.ZERO) && fill.size.gt(BigDecimal.ZERO)) {
if (won) newAmountUsdc.let { fill.size.subtract(it).setScale(pnlScale, RoundingMode.HALF_UP) }
else newAmountUsdc.negate().setScale(pnlScale, RoundingMode.HALF_UP)
} else {
computePnlFallback(trigger.amountUsdc, won)
}
val now = System.currentTimeMillis()
val updated = trigger.copy(
triggerPrice = newTriggerPrice,
amountUsdc = newAmountUsdc,
conditionId = conditionId,
resolved = true,
winnerOutcomeIndex = winnerIndex,
realizedPnl = pnl,
settledAt = now
)
triggerRepository.save(updated)
logger.debug("加密价差策略结算已更新: triggerId=${trigger.id}, winnerOutcomeIndex=$winnerIndex, won=$won, pnl=$pnl")
return true
}
private suspend fun resolveConditionId(strategy: CryptoTailStrategy, trigger: CryptoTailStrategyTrigger): String? {
if (!trigger.conditionId.isNullOrBlank()) return trigger.conditionId
val slug = "${strategy.marketSlugPrefix}-${trigger.periodStartUnix}"
val event = fetchEventBySlug(slug).getOrNull() ?: return null
val markets = event.markets ?: return null
val first = markets.firstOrNull() ?: return null
return first.conditionId?.takeIf { it.isNotBlank() }
}
private suspend fun fetchEventBySlug(slug: String): Result<GammaEventBySlugResponse> {
return try {
val gammaApi = retrofitFactory.createGammaApi()
val response = gammaApi.getEventBySlug(slug)
if (response.isSuccessful && response.body() != null) {
Result.success(response.body()!!)
} else {
val msg = if (response.code() == 404) "404" else "code=${response.code()}"
Result.failure(Exception(msg))
}
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Activity 匹配到的一条 TRADE 的成交数据价格数量实际投入 USDC接口 usdcSize
*/
private data class ActivityFill(
val price: BigDecimal,
val size: BigDecimal,
val usdcSize: BigDecimal?
)
/**
* 通过 Data API activity 接口获取该触发对应的实际成交价成交量与投入金额 CLOB getOrder 更准确
* 只有此接口返回匹配的 TRADE price/size 有效时结算才会更新 triggerPriceamountUsdc表现投入金额优先用 activity usdcSize
*/
private suspend fun fetchActivityFill(
trigger: CryptoTailStrategyTrigger,
strategy: CryptoTailStrategy,
conditionId: String
): ActivityFill? {
val account = accountRepository.findById(strategy.accountId).orElse(null) ?: run {
logger.warn("加密价差策略结算未拉取 activity: 账户不存在, triggerId=${trigger.id}, accountId=${strategy.accountId}")
return null
}
val user = account.proxyAddress
val triggerTimeSeconds = trigger.createdAt / 1000
val start = triggerTimeSeconds - 120
val end = triggerTimeSeconds + 600
return try {
val dataApi = retrofitFactory.createDataApi()
val response = dataApi.getUserActivity(
user = user,
type = listOf("TRADE"),
start = start,
end = end,
limit = 50,
sortBy = "TIMESTAMP",
sortDirection = "DESC"
)
if (!response.isSuccessful || response.body() == null) {
logger.warn("加密价差策略结算拉取 activity 失败: triggerId=${trigger.id}, code=${response.code()}")
return null
}
val activities = response.body()!!
// 只匹配 TRADE:返回里可能混有 REDEEMoutcomeIndex=999、price=0)等,需排除
val match = activities.firstOrNull { a ->
a.type == "TRADE" &&
a.conditionId == conditionId &&
a.outcomeIndex != null && a.outcomeIndex in 0..1 &&
a.outcomeIndex == trigger.outcomeIndex &&
a.side?.uppercase() == "BUY" &&
a.price != null && a.price > 0 &&
a.size != null && a.size > 0
} ?: run {
logger.debug("加密价差策略结算 activity 无匹配成交: triggerId=${trigger.id}, conditionId=$conditionId, outcomeIndex=${trigger.outcomeIndex}, 条数=${activities.size}")
return null
}
val price = match.price!!.toSafeBigDecimal()
val size = match.size!!.toSafeBigDecimal()
val usdcSize = match.usdcSize?.toSafeBigDecimal()?.takeIf { it.gt(BigDecimal.ZERO) }
if (price.gt(BigDecimal.ZERO) && size.gt(BigDecimal.ZERO)) {
ActivityFill(price = price, size = size, usdcSize = usdcSize)
} else {
logger.debug("加密价差策略结算 activity 成交数据无效: triggerId=${trigger.id}, price=$price, size=$size")
null
}
} catch (e: Exception) {
logger.warn("加密价差策略结算拉取 activity 异常,触发价/投入金额不会更新: triggerId=${trigger.id}, error=${e.message}")
null
}
}
/**
* 按实际成交价与成交量计算收益成本 = sizeMatched * price赢则赎回 sizeMatched * 1输则 0
*/
private fun computePnlFromFill(price: BigDecimal, sizeMatched: BigDecimal, won: Boolean): BigDecimal {
val cost = sizeMatched.multi(price).setScale(pnlScale, RoundingMode.HALF_UP)
return if (won) {
sizeMatched.subtract(cost).setScale(pnlScale, RoundingMode.HALF_UP)
} else {
cost.negate()
}
}
/**
* 回退收益计算 API 数据时用触发时的 amountUsdc 与固定价 0.99
* : pnl = amountUsdc/0.99 - amountUsdc: pnl = -amountUsdc
*/
private fun computePnlFallback(amountUsdc: BigDecimal, won: Boolean): BigDecimal {
return if (won) {
amountUsdc.divide(triggerFixedPrice, pnlScale, RoundingMode.HALF_UP).subtract(amountUsdc)
} else {
amountUsdc.negate()
}
}
@PreDestroy
fun destroy() {
settlementJob?.cancel()
settlementJob = null
settlementScopeJob.cancel()
}
}
@@ -0,0 +1,842 @@
package com.wrbug.polymarketbot.service.cryptotail
import com.wrbug.polymarketbot.api.GammaEventBySlugResponse
import com.wrbug.polymarketbot.api.NewOrderRequest
import com.wrbug.polymarketbot.api.PolymarketClobApi
import com.wrbug.polymarketbot.dto.CryptoTailManualOrderRequest
import com.wrbug.polymarketbot.dto.CryptoTailManualOrderResponse
import com.wrbug.polymarketbot.dto.ManualOrderDetails
import com.wrbug.polymarketbot.entity.Account
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
import com.wrbug.polymarketbot.entity.CryptoTailStrategyTrigger
import com.wrbug.polymarketbot.enums.SpreadMode
import com.wrbug.polymarketbot.enums.SpreadDirection
import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository
import com.wrbug.polymarketbot.service.accounts.AccountService
import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService
import com.wrbug.polymarketbot.service.binance.BinanceKlineService
import com.wrbug.polymarketbot.service.common.PolymarketClobService
import com.wrbug.polymarketbot.service.copytrading.orders.OrderSigningService
import com.wrbug.polymarketbot.util.CryptoUtils
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.div
import com.wrbug.polymarketbot.util.fromJson
import com.wrbug.polymarketbot.util.multi
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import com.github.benmanes.caffeine.cache.Cache
import com.github.benmanes.caffeine.cache.Caffeine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import jakarta.annotation.PreDestroy
import java.math.BigDecimal
import java.math.RoundingMode
import java.util.concurrent.ConcurrentHashMap
import java.util.regex.Pattern
/** 加密价差策略固定下单价格(最高价 0.99),不再在触发时拉取最优价 */
private const val TRIGGER_FIXED_PRICE = "0.99"
/** 最大价差模式(MAX)时,买入价格调整系数(加在触发价格上) */
private const val SPREAD_MAX_PRICE_ADJUSTMENT = "0.02"
/** 数量小数位数,与 OrderSigningService 的 roundConfig.size 一致 */
private const val SIZE_DECIMAL_SCALE = 2
/** 单笔下单最小 USDC 金额(平台限制),RATIO 模式计算值低于此值时按此值下单 */
private val MIN_ORDER_USDC = BigDecimal("1")
/**
* 周期内预置上下文账户解密凭证费率签名类型CLOB 客户端不含预签订单
* 触发时 FIXED/RATIO 均按 outcomeIndex 计算 size 并签名提交
*/
private data class PeriodContext(
val strategy: CryptoTailStrategy,
val periodStartUnix: Long,
val account: Account,
val decryptedPrivateKey: String,
val apiSecretDecrypted: String,
val apiPassphraseDecrypted: String,
val clobApi: PolymarketClobApi,
val signatureType: Int,
val tokenIds: List<String>,
val marketTitle: String?
)
/**
* 加密价差策略执行服务按周期与时间窗口检查价格并下单每周期最多触发一次
* 周期开始预置账户解密费率签名类型CLOB 客户端触发时按 outcomeIndex 计算 size 并签名提交
*/
@Service
class CryptoTailStrategyExecutionService(
private val strategyRepository: CryptoTailStrategyRepository,
private val triggerRepository: CryptoTailStrategyTriggerRepository,
private val accountRepository: AccountRepository,
private val accountService: AccountService,
private val retrofitFactory: RetrofitFactory,
private val clobService: PolymarketClobService,
private val orderSigningService: OrderSigningService,
private val cryptoUtils: CryptoUtils,
private val binanceKlineService: BinanceKlineService,
private val binanceKlineAutoSpreadService: BinanceKlineAutoSpreadService
) {
private val logger = LoggerFactory.getLogger(CryptoTailStrategyExecutionService::class.java)
/** 按 (strategyId, periodStartUnix) 加锁,避免同一周期被调度器与 WebSocket 等多路并发重复下单 */
private val triggerMutexMap = ConcurrentHashMap<String, Mutex>()
/** 过期锁 key 保留时间(秒),超过则清理,防止 map 无界增长 */
private val triggerMutexExpireSeconds = 3600L
private fun triggerLockKey(strategyId: Long, periodStartUnix: Long): String = "$strategyId-$periodStartUnix"
private fun getTriggerMutex(strategyId: Long, periodStartUnix: Long): Mutex {
cleanExpiredTriggerMutexKeys()
return triggerMutexMap.getOrPut(triggerLockKey(strategyId, periodStartUnix)) { Mutex() }
}
/** 清理已过期的 (strategyId, periodStartUnix) 锁,避免内存泄漏 */
private fun cleanExpiredTriggerMutexKeys() {
val nowSeconds = System.currentTimeMillis() / 1000
val expireThreshold = nowSeconds - triggerMutexExpireSeconds
val keysToRemove = triggerMutexMap.keys.filter { key ->
key.substringAfterLast('-').toLongOrNull()?.let { it < expireThreshold } ?: false
}
keysToRemove.forEach { triggerMutexMap.remove(it) }
}
/** 周期预置上下文缓存:(strategyId-periodStartUnix) -> PeriodContext,过期周期在读取时剔除 */
private val periodContextCache = ConcurrentHashMap<String, PeriodContext>()
/** 已打印「首次满足条件」日志的周期:LRU 容量 100,每周期只打一次 */
private val conditionLoggedCache: Cache<String, Long> = Caffeine.newBuilder()
.maximumSize(100)
.build()
/**
* 在周期内首次需要时构建并缓存预置上下文失败返回 null触发流程将走完整路径
* 预置账户解密费率签名类型CLOB 客户端不预签订单触发时再签名
*/
private suspend fun ensurePeriodContext(
strategy: CryptoTailStrategy,
periodStartUnix: Long,
tokenIds: List<String>,
marketTitle: String?
): PeriodContext? {
val key = triggerLockKey(strategy.id!!, periodStartUnix)
periodContextCache[key]?.let { return it }
val account = accountRepository.findById(strategy.accountId).orElse(null) ?: return null
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) return null
val decryptedKey = try {
cryptoUtils.decrypt(account.privateKey) ?: return null
} catch (e: Exception) {
logger.warn("加密价差策略周期上下文解密私钥失败: accountId=${account.id}", e)
return null
}
val apiSecret = try {
account.apiSecret.let { cryptoUtils.decrypt(it) }
} catch (e: Exception) {
""
}
val apiPassphrase = try {
account.apiPassphrase.let { cryptoUtils.decrypt(it) }
} catch (e: Exception) {
""
}
val clobApi = retrofitFactory.createClobApi(account.apiKey, apiSecret, apiPassphrase, account.walletAddress)
val signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
if (strategy.amountMode.uppercase() != "RATIO" && strategy.amountValue < MIN_ORDER_USDC) return null
val ctx = PeriodContext(
strategy = strategy,
periodStartUnix = periodStartUnix,
account = account,
decryptedPrivateKey = decryptedKey,
apiSecretDecrypted = apiSecret,
apiPassphraseDecrypted = apiPassphrase,
clobApi = clobApi,
signatureType = signatureType,
tokenIds = tokenIds,
marketTitle = marketTitle
)
periodContextCache[key] = ctx
return ctx
}
/**
* 按投入金额和价格计算可买张数size = ceil(amountUsdc/price)保留小数至少 1
* OrderSigningService 一致使用小数数量向上取整保证不超过投入金额
*/
private fun computeSize(amountUsdc: BigDecimal, price: BigDecimal): String {
val size = amountUsdc.divide(price, SIZE_DECIMAL_SCALE, RoundingMode.UP).max(BigDecimal.ONE)
return size.toPlainString()
}
private fun getOrInvalidatePeriodContext(strategy: CryptoTailStrategy, periodStartUnix: Long): PeriodContext? {
val key = triggerLockKey(strategy.id!!, periodStartUnix)
val nowSeconds = System.currentTimeMillis() / 1000
val ctx = periodContextCache[key] ?: return null
if (periodStartUnix + strategy.intervalSeconds <= nowSeconds) {
periodContextCache.remove(key)
cleanExpiredPeriodContextCache(nowSeconds)
return null
}
return ctx
}
/** 清理已过期的周期上下文缓存,避免内存泄漏 */
private fun cleanExpiredPeriodContextCache(nowSeconds: Long) {
val keysToRemove = periodContextCache.entries
.filter { (_, ctx) -> ctx.periodStartUnix + ctx.strategy.intervalSeconds <= nowSeconds }
.map { it.key }
keysToRemove.forEach { periodContextCache.remove(it) }
}
/**
* 由订单簿 WebSocket 触发当收到某 token bestBid 且满足区间时调用若本周期未触发则下单
*/
suspend fun tryTriggerWithPriceFromWs(
strategy: CryptoTailStrategy,
periodStartUnix: Long,
marketTitle: String?,
tokenIds: List<String>,
outcomeIndex: Int,
bestBid: BigDecimal
) {
if (outcomeIndex < 0 || outcomeIndex >= tokenIds.size) return
if (bestBid < strategy.minPrice || bestBid > strategy.maxPrice) return
val mutex = getTriggerMutex(strategy.id!!, periodStartUnix)
mutex.withLock {
if (triggerRepository.findByStrategyIdAndPeriodStartUnix(
strategy.id!!,
periodStartUnix
) != null
) return@withLock
val logKey = triggerLockKey(strategy.id!!, periodStartUnix)
if (conditionLoggedCache.getIfPresent(logKey) == null) {
conditionLoggedCache.put(logKey, periodStartUnix + strategy.intervalSeconds)
val oc = binanceKlineService.getCurrentOpenClose(
strategy.marketSlugPrefix,
strategy.intervalSeconds,
periodStartUnix
)
val openPrice = oc?.first?.toPlainString() ?: "-"
val closePrice = oc?.second?.toPlainString() ?: "-"
val strategyName = strategy.name?.takeIf { it.isNotBlank() } ?: "加密价差策略-${strategy.marketSlugPrefix}"
val direction = if (outcomeIndex == 0) "Up" else "Down"
val modeStr = if (strategy.spreadDirection == SpreadDirection.MAX) "最大价差" else "最小价差"
logger.info(
"加密价差策略首次满足条件: strategyName=$strategyName, strategyId=${strategy.id}, " +
"openPrice=$openPrice, closePrice=$closePrice, marketPrice=${bestBid.toPlainString()}, " +
"direction=$direction, outcomeIndex=$outcomeIndex, spreadMode=$modeStr"
)
}
if (!passSpreadCheck(strategy, periodStartUnix, outcomeIndex)) return@withLock
ensurePeriodContext(strategy, periodStartUnix, tokenIds, marketTitle)
placeOrderForTrigger(strategy, periodStartUnix, marketTitle, tokenIds, outcomeIndex, bestBid)
}
}
private fun passSpreadCheck(strategy: CryptoTailStrategy, periodStartUnix: Long, outcomeIndex: Int): Boolean {
if (strategy.spreadMode == SpreadMode.NONE) return true
val oc = binanceKlineService.getCurrentOpenClose(
strategy.marketSlugPrefix,
strategy.intervalSeconds,
periodStartUnix
)
?: return false
val (openP, closeP) = oc
val spreadAbs = closeP.subtract(openP).abs()
// 获取有效价差
val effectiveSpread = when (strategy.spreadMode) {
SpreadMode.FIXED -> {
strategy.spreadValue?.takeIf { it > BigDecimal.ZERO } ?: return true
}
SpreadMode.AUTO -> {
val result = computeAutoEffectiveSpread(strategy, periodStartUnix, outcomeIndex) ?: return true
result.effectiveSpread.takeIf { it > BigDecimal.ZERO } ?: return true
}
SpreadMode.NONE -> return true
}
// 根据价差方向判断
return if (strategy.spreadDirection == SpreadDirection.MAX) {
// 最大价差模式:价差 <= 配置值时触发
spreadAbs <= effectiveSpread
} else {
// 最小价差模式:价差 >= 配置值时触发
spreadAbs >= effectiveSpread
}
}
/**
* AUTO 模式 100% 基准价差按窗口内毫秒进度计算动态系数100%50%得到有效价差
*/
private data class AutoSpreadResult(
val baseSpread: BigDecimal,
val coefficient: BigDecimal,
val effectiveSpread: BigDecimal
)
private fun computeAutoEffectiveSpread(
strategy: CryptoTailStrategy,
periodStartUnix: Long,
outcomeIndex: Int
): AutoSpreadResult? {
val baseSpread = binanceKlineAutoSpreadService.getAutoMinSpreadBase(
strategy.marketSlugPrefix,
strategy.intervalSeconds,
periodStartUnix,
outcomeIndex
)
?: binanceKlineAutoSpreadService.computeAndCache(
strategy.marketSlugPrefix,
strategy.intervalSeconds,
periodStartUnix
)?.let { if (outcomeIndex == 0) it.first else it.second }
?: return null
if (baseSpread <= BigDecimal.ZERO) return null
val windowStartMs = (periodStartUnix + strategy.windowStartSeconds) * 1000L
val windowEndMs = (periodStartUnix + strategy.windowEndSeconds) * 1000L
val windowLenMs = windowEndMs - windowStartMs
val coefficient = if (windowLenMs <= 0) {
BigDecimal.ONE
} else {
val nowMs = System.currentTimeMillis()
val elapsedMs = (nowMs - windowStartMs).toBigDecimal()
val progress = elapsedMs.div(windowLenMs.toBigDecimal(), 18, RoundingMode.HALF_UP)
.let { p -> maxOf(BigDecimal.ZERO, minOf(BigDecimal.ONE, p)) }
BigDecimal.ONE.subtract(progress.multi("0.5"))
}
val effectiveSpread = baseSpread.multi(coefficient).setScale(8, RoundingMode.HALF_UP)
return AutoSpreadResult(baseSpread, coefficient, effectiveSpread)
}
private suspend fun placeOrderForTrigger(
strategy: CryptoTailStrategy,
periodStartUnix: Long,
marketTitle: String?,
tokenIds: List<String>,
outcomeIndex: Int,
triggerPrice: BigDecimal
) {
val ctx = getOrInvalidatePeriodContext(strategy, periodStartUnix)
if (ctx != null) {
var availableBalanceForRatio = BigDecimal.ZERO
var amountUsdc = when (strategy.amountMode.uppercase()) {
"RATIO" -> {
val balanceResult = accountService.getAccountBalance(ctx.account.id)
val availableBalance =
balanceResult.getOrNull()?.availableBalance?.toSafeBigDecimal() ?: BigDecimal.ZERO
availableBalanceForRatio = availableBalance
availableBalance.multiply(strategy.amountValue).divide(BigDecimal("100"), 18, RoundingMode.DOWN)
}
else -> strategy.amountValue
}
if (amountUsdc < MIN_ORDER_USDC) {
val amountMode = strategy.amountMode.uppercase()
if (amountMode == "RATIO" && availableBalanceForRatio >= MIN_ORDER_USDC) {
amountUsdc = MIN_ORDER_USDC
} else {
saveTriggerRecord(
strategy,
periodStartUnix,
marketTitle,
outcomeIndex,
triggerPrice,
amountUsdc,
null,
"fail",
"投入金额不足"
)
return
}
}
val tokenId = tokenIds.getOrNull(outcomeIndex) ?: run {
saveTriggerRecord(
strategy,
periodStartUnix,
marketTitle,
outcomeIndex,
triggerPrice,
amountUsdc,
null,
"fail",
"tokenIds 越界"
)
return
}
// 根据价差方向确定下单价格
val price = if (strategy.spreadDirection == SpreadDirection.MAX) {
// 最大价差模式:触发价格 + 0.02
triggerPrice.add(BigDecimal(SPREAD_MAX_PRICE_ADJUSTMENT)).setScale(8, RoundingMode.HALF_UP)
} else {
// 最小价差模式:固定价格 0.99
BigDecimal(TRIGGER_FIXED_PRICE)
}
val priceStr = price.toPlainString()
val size = computeSize(amountUsdc, price)
val signedOrder = orderSigningService.createAndSignOrder(
privateKey = ctx.decryptedPrivateKey,
makerAddress = ctx.account.proxyAddress,
tokenId = tokenId,
side = "BUY",
price = priceStr,
size = size,
signatureType = ctx.signatureType
)
val orderRequest = NewOrderRequest(
order = signedOrder,
owner = ctx.account.apiKey!!,
orderType = "FAK"
)
submitOrderAndSaveRecord(
ctx.clobApi,
strategy,
periodStartUnix,
marketTitle,
outcomeIndex,
triggerPrice,
amountUsdc,
orderRequest,
triggerType = "AUTO"
)
return
}
placeOrderForTriggerSlowPath(strategy, periodStartUnix, marketTitle, tokenIds, outcomeIndex, triggerPrice)
}
private suspend fun submitOrderAndSaveRecord(
clobApi: PolymarketClobApi,
strategy: CryptoTailStrategy,
periodStartUnix: Long,
marketTitle: String?,
outcomeIndex: Int,
triggerPrice: BigDecimal,
amountUsdc: BigDecimal,
orderRequest: NewOrderRequest,
triggerType: String = "AUTO"
) {
var failReason: String? = null
try {
val response = clobApi.createOrder(orderRequest)
if (response.isSuccessful && response.body() != null) {
val body = response.body()!!
if (body.success && body.orderId != null) {
saveTriggerRecord(
strategy,
periodStartUnix,
marketTitle,
outcomeIndex,
triggerPrice,
amountUsdc,
body.orderId,
"success",
null,
triggerType = triggerType
)
logger.info("加密价差策略下单成功: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, outcomeIndex=$outcomeIndex, orderId=${body.orderId}, triggerType=$triggerType")
return
}
failReason = body.errorMsg ?: "unknown"
} else {
val errorBody = response.errorBody()?.string().orEmpty()
failReason = errorBody.ifEmpty { "请求失败" }
}
} catch (e: Exception) {
failReason = e.message ?: e.toString()
logger.error("加密价差策略下单异常: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix", e)
}
saveTriggerRecord(
strategy,
periodStartUnix,
marketTitle,
outcomeIndex,
triggerPrice,
amountUsdc,
null,
"fail",
failReason,
triggerType = triggerType
)
logger.error("加密价差策略下单失败: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, reason=$failReason")
}
/** 无预置上下文时的完整流程:固定价格 0.99,账户/解密/费率/签名在触发时执行 */
private suspend fun placeOrderForTriggerSlowPath(
strategy: CryptoTailStrategy,
periodStartUnix: Long,
marketTitle: String?,
tokenIds: List<String>,
outcomeIndex: Int,
triggerPrice: BigDecimal
) {
val account = accountRepository.findById(strategy.accountId).orElse(null) ?: run {
logger.warn("账户不存在: accountId=${strategy.accountId}")
saveTriggerRecord(
strategy,
periodStartUnix,
marketTitle,
outcomeIndex,
triggerPrice,
BigDecimal.ZERO,
null,
"fail",
"账户不存在"
)
return
}
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
logger.warn("账户未配置 API 凭证: accountId=${account.id}")
saveTriggerRecord(
strategy,
periodStartUnix,
marketTitle,
outcomeIndex,
triggerPrice,
BigDecimal.ZERO,
null,
"fail",
"账户未配置API凭证"
)
return
}
val balanceResult = accountService.getAccountBalance(account.id)
val availableBalance = balanceResult.getOrNull()?.availableBalance?.toSafeBigDecimal() ?: BigDecimal.ZERO
var amountUsdc = when (strategy.amountMode.uppercase()) {
"RATIO" -> availableBalance.multiply(strategy.amountValue).divide(BigDecimal("100"), 18, RoundingMode.DOWN)
else -> strategy.amountValue
}
if (amountUsdc < MIN_ORDER_USDC) {
val amountMode = strategy.amountMode.uppercase()
if (amountMode == "RATIO" && availableBalance >= MIN_ORDER_USDC) {
amountUsdc = MIN_ORDER_USDC
} else {
saveTriggerRecord(
strategy,
periodStartUnix,
marketTitle,
outcomeIndex,
triggerPrice,
amountUsdc,
null,
"fail",
"投入金额不足"
)
return
}
}
val tokenId = tokenIds.getOrNull(outcomeIndex) ?: run {
saveTriggerRecord(
strategy,
periodStartUnix,
marketTitle,
outcomeIndex,
triggerPrice,
amountUsdc,
null,
"fail",
"tokenIds 越界"
)
return
}
// 根据价差方向确定下单价格
val price = if (strategy.spreadDirection == SpreadDirection.MAX) {
// 最大价差模式:触发价格 + 0.02
triggerPrice.add(BigDecimal(SPREAD_MAX_PRICE_ADJUSTMENT)).setScale(8, RoundingMode.HALF_UP)
} else {
// 最小价差模式:固定价格 0.99
BigDecimal(TRIGGER_FIXED_PRICE)
}
val priceStr = price.toPlainString()
val size = computeSize(amountUsdc, price)
val decryptedKey = try {
cryptoUtils.decrypt(account.privateKey) ?: ""
} catch (e: Exception) {
logger.error("解密私钥失败: accountId=${account.id}", e)
saveTriggerRecord(
strategy,
periodStartUnix,
marketTitle,
outcomeIndex,
triggerPrice,
amountUsdc,
null,
"fail",
"解密私钥失败"
)
return
}
val apiSecret = try {
account.apiSecret.let { cryptoUtils.decrypt(it) }
} catch (e: Exception) {
""
}
val apiPassphrase = try {
account.apiPassphrase.let { cryptoUtils.decrypt(it) }
} catch (e: Exception) {
""
}
val clobApi = retrofitFactory.createClobApi(account.apiKey, apiSecret, apiPassphrase, account.walletAddress)
val signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
val signedOrder = orderSigningService.createAndSignOrder(
privateKey = decryptedKey,
makerAddress = account.proxyAddress,
tokenId = tokenId,
side = "BUY",
price = priceStr,
size = size,
signatureType = signatureType
)
val orderRequest = NewOrderRequest(
order = signedOrder,
owner = account.apiKey!!,
orderType = "FAK"
)
submitOrderAndSaveRecord(
clobApi,
strategy,
periodStartUnix,
marketTitle,
outcomeIndex,
triggerPrice,
amountUsdc,
orderRequest
)
}
private suspend fun fetchEventBySlug(slug: String): Result<GammaEventBySlugResponse> {
return try {
val gammaApi = retrofitFactory.createGammaApi()
val response = gammaApi.getEventBySlug(slug)
if (response.isSuccessful && response.body() != null) {
Result.success(response.body()!!)
} else {
val msg = if (response.code() == 404) "404" else "code=${response.code()}"
Result.failure(Exception(msg))
}
} catch (e: Exception) {
Result.failure(e)
}
}
private fun parseClobTokenIds(clobTokenIds: String?): List<String> {
if (clobTokenIds.isNullOrBlank()) return emptyList()
val parsed = clobTokenIds.fromJson<List<String>>()
return parsed ?: emptyList()
}
private fun saveTriggerRecord(
strategy: CryptoTailStrategy,
periodStartUnix: Long,
marketTitle: String?,
outcomeIndex: Int,
triggerPrice: BigDecimal,
amountUsdc: BigDecimal,
orderId: String?,
status: String,
failReason: String?,
triggerType: String = "AUTO"
) {
val record = CryptoTailStrategyTrigger(
strategyId = strategy.id!!,
periodStartUnix = periodStartUnix,
marketTitle = marketTitle,
outcomeIndex = outcomeIndex,
triggerPrice = triggerPrice,
amountUsdc = amountUsdc,
orderId = orderId,
status = status,
failReason = failReason,
triggerType = triggerType
)
triggerRepository.save(record)
}
/**
* 手动下单用户主动触发下单不检查任何条件仅检查当前周期是否已下单
*/
suspend fun manualOrder(request: CryptoTailManualOrderRequest): Result<CryptoTailManualOrderResponse> {
return try {
val strategy = strategyRepository.findById(request.strategyId).orElse(null)
?: return Result.failure(IllegalArgumentException("策略不存在"))
val outcomeIndex = if (request.direction.uppercase() == "UP") 0 else 1
if (outcomeIndex < 0 || outcomeIndex >= request.tokenIds.size) {
return Result.failure(IllegalArgumentException("outcomeIndex 越界"))
}
val price = request.price.toSafeBigDecimal()
if (price <= BigDecimal.ZERO || price > BigDecimal.ONE) {
return Result.failure(IllegalArgumentException("价格必须在 0~1 之间"))
}
val priceRounded = price.setScale(4, RoundingMode.UP)
val size = request.size.toSafeBigDecimal()
if (size < BigDecimal.ONE) {
return Result.failure(IllegalArgumentException("数量不能少于 1"))
}
val amountUsdc = priceRounded.multi(size).setScale(2, RoundingMode.HALF_UP)
if (amountUsdc < BigDecimal.ONE) {
return Result.failure(IllegalArgumentException("总金额不能少于 \$1"))
}
val mutex = getTriggerMutex(strategy.id!!, request.periodStartUnix)
mutex.withLock {
if (triggerRepository.findByStrategyIdAndPeriodStartUnix(
strategy.id!!,
request.periodStartUnix
) != null
) {
return@withLock Result.failure(IllegalArgumentException("当前周期已下单"))
}
var ctx = getOrInvalidatePeriodContext(strategy, request.periodStartUnix)
if (ctx == null) {
ctx = ensurePeriodContext(
strategy,
request.periodStartUnix,
request.tokenIds,
request.marketTitle.ifBlank { null }
)
}
if (ctx != null) {
val tokenId = request.tokenIds.getOrNull(outcomeIndex)
?: return@withLock Result.failure(IllegalArgumentException("tokenIds 越界"))
val priceStr = priceRounded.toPlainString()
val sizeStr = size.toPlainString()
val signedOrder = orderSigningService.createAndSignOrder(
privateKey = ctx.decryptedPrivateKey,
makerAddress = ctx.account.proxyAddress,
tokenId = tokenId,
side = "BUY",
price = priceStr,
size = sizeStr,
signatureType = ctx.signatureType
)
val orderRequest = NewOrderRequest(
order = signedOrder,
owner = ctx.account.apiKey!!,
orderType = "FAK"
)
val orderResult = submitOrderForManualOrder(
ctx.clobApi,
strategy,
request.periodStartUnix,
request.marketTitle,
outcomeIndex,
priceRounded,
amountUsdc,
orderRequest
)
orderResult.fold(
onSuccess = { orderId ->
Result.success(
CryptoTailManualOrderResponse(
success = true,
orderId = orderId,
message = "下单成功",
orderDetails = ManualOrderDetails(
strategyId = strategy.id!!,
direction = request.direction,
price = priceStr,
size = sizeStr,
totalAmount = amountUsdc.toPlainString()
)
)
)
},
onFailure = { e ->
Result.failure(e)
}
)
} else {
Result.failure(IllegalArgumentException("账户未配置或凭证不足"))
}
}
} catch (e: Exception) {
logger.error("手动下单异常: strategyId=${request.strategyId}, ${e.message}", e)
Result.failure(e)
}
}
private suspend fun submitOrderForManualOrder(
clobApi: PolymarketClobApi,
strategy: CryptoTailStrategy,
periodStartUnix: Long,
marketTitle: String?,
outcomeIndex: Int,
price: BigDecimal,
amountUsdc: BigDecimal,
orderRequest: NewOrderRequest
): Result<String> {
return try {
val response = clobApi.createOrder(orderRequest)
if (response.isSuccessful && response.body() != null) {
val body = response.body()!!
if (body.success && body.orderId != null) {
saveTriggerRecord(
strategy,
periodStartUnix,
marketTitle,
outcomeIndex,
price,
amountUsdc,
body.orderId,
"success",
null,
triggerType = "MANUAL"
)
logger.info("手动下单成功: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, outcomeIndex=$outcomeIndex, orderId=${body.orderId}")
Result.success(body.orderId)
} else {
Result.failure(Exception(body.errorMsg ?: "unknown"))
}
} else {
val errorBody = response.errorBody()?.string().orEmpty()
Result.failure(Exception(errorBody.ifEmpty { "请求失败" }))
}
} catch (e: Exception) {
logger.error("手动下单异常: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix", e)
Result.failure(e)
}
}
@PreDestroy
fun destroy() {
// 清理所有周期上下文缓存,避免敏感信息(明文私钥、API Secret)在内存中保留
periodContextCache.clear()
// 清理所有锁,避免内存泄漏
triggerMutexMap.clear()
logger.debug("加密价差策略执行服务已清理缓存和锁")
}
}
@@ -0,0 +1,377 @@
package com.wrbug.polymarketbot.service.cryptotail
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
import com.wrbug.polymarketbot.entity.CryptoTailStrategyTrigger
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.enums.SpreadMode
import com.wrbug.polymarketbot.enums.SpreadDirection
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository
import com.wrbug.polymarketbot.event.CryptoTailStrategyChangedEvent
import com.wrbug.polymarketbot.util.gt
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import org.slf4j.LoggerFactory
import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Service
class CryptoTailStrategyService(
private val strategyRepository: CryptoTailStrategyRepository,
private val triggerRepository: CryptoTailStrategyTriggerRepository,
private val eventPublisher: ApplicationEventPublisher
) {
private val logger = LoggerFactory.getLogger(CryptoTailStrategyService::class.java)
private val maxWindowByInterval = mapOf(300 to 300, 900 to 900)
@Transactional
fun create(request: CryptoTailStrategyCreateRequest): Result<CryptoTailStrategyDto> {
return try {
if (request.accountId <= 0) {
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ACCOUNT_ID_INVALID.messageKey))
}
if (request.marketSlugPrefix.isBlank()) {
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
}
val interval = request.intervalSeconds
if (interval != 300 && interval != 900) {
return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_INTERVAL_INVALID.messageKey))
}
val maxWindow = maxWindowByInterval[interval] ?: 300
if (request.windowStartSeconds > request.windowEndSeconds) {
return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID.messageKey))
}
if (request.windowEndSeconds > maxWindow) {
return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED.messageKey))
}
val amountMode = request.amountMode.uppercase()
if (amountMode != "RATIO" && amountMode != "FIXED") {
return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID.messageKey))
}
val minPrice = request.minPrice.toSafeBigDecimal()
val maxPrice = (request.maxPrice ?: "1").toSafeBigDecimal()
if (minPrice > maxPrice) {
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
}
val amountValue = request.amountValue.toSafeBigDecimal()
if (amountValue <= BigDecimal.ZERO) {
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
}
val spreadMode = try {
SpreadMode.fromString(request.spreadMode)
} catch (e: Exception) {
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
}
val spreadValue = request.spreadValue?.toSafeBigDecimal()
if (spreadMode == SpreadMode.FIXED && (spreadValue == null || spreadValue < BigDecimal.ZERO)) {
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
}
val spreadDirection = try {
SpreadDirection.fromString(request.spreadDirection)
} catch (e: Exception) {
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
}
val nameToSave = request.name?.takeIf { it.isNotBlank() }
?: generateStrategyName(request.marketSlugPrefix.trim())
val entity = CryptoTailStrategy(
accountId = request.accountId,
name = nameToSave,
marketSlugPrefix = request.marketSlugPrefix.trim(),
intervalSeconds = interval,
windowStartSeconds = request.windowStartSeconds,
windowEndSeconds = request.windowEndSeconds,
minPrice = minPrice,
maxPrice = maxPrice,
amountMode = amountMode,
amountValue = amountValue,
spreadMode = spreadMode,
spreadValue = spreadValue,
spreadDirection = spreadDirection,
enabled = request.enabled
)
val saved = strategyRepository.save(entity)
eventPublisher.publishEvent(CryptoTailStrategyChangedEvent(this))
Result.success(entityToDto(saved, null))
} catch (e: IllegalArgumentException) {
Result.failure(e)
} catch (e: Exception) {
logger.error("创建加密价差策略失败: ${e.message}", e)
Result.failure(e)
}
}
@Transactional
fun update(request: CryptoTailStrategyUpdateRequest): Result<CryptoTailStrategyDto> {
return try {
val existing = strategyRepository.findById(request.strategyId).orElse(null)
?: return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND.messageKey))
val interval = existing.intervalSeconds
val maxWindow = maxWindowByInterval[interval] ?: 300
request.windowStartSeconds?.let { ws ->
request.windowEndSeconds?.let { we ->
if (ws > we) return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID.messageKey))
if (we > maxWindow) return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED.messageKey))
}
}
request.windowStartSeconds?.let { if (it > (request.windowEndSeconds ?: existing.windowEndSeconds)) return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID.messageKey)) }
request.windowEndSeconds?.let { if (it > maxWindow) return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED.messageKey)) }
val nameToSave = request.name?.takeIf { it.isNotBlank() }
?: existing.name?.takeIf { it.isNotBlank() }
?: generateStrategyName(existing.marketSlugPrefix)
val newSpreadMode = if (request.spreadMode != null) {
try {
SpreadMode.fromString(request.spreadMode)
} catch (e: Exception) {
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
}
} else {
existing.spreadMode
}
val newSpreadValue = request.spreadValue?.toSafeBigDecimal() ?: existing.spreadValue
if (newSpreadMode == SpreadMode.FIXED && (newSpreadValue == null || newSpreadValue < BigDecimal.ZERO)) {
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
}
val newSpreadDirection = if (request.spreadDirection != null) {
try {
SpreadDirection.fromString(request.spreadDirection)
} catch (e: Exception) {
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
}
} else {
existing.spreadDirection
}
val updated = existing.copy(
name = nameToSave,
windowStartSeconds = request.windowStartSeconds ?: existing.windowStartSeconds,
windowEndSeconds = request.windowEndSeconds ?: existing.windowEndSeconds,
minPrice = request.minPrice?.toSafeBigDecimal() ?: existing.minPrice,
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: existing.maxPrice,
amountMode = request.amountMode?.uppercase() ?: existing.amountMode,
amountValue = request.amountValue?.toSafeBigDecimal() ?: existing.amountValue,
spreadMode = newSpreadMode,
spreadValue = newSpreadValue,
spreadDirection = newSpreadDirection,
enabled = request.enabled ?: existing.enabled,
updatedAt = System.currentTimeMillis()
)
if (updated.minPrice > updated.maxPrice) {
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
}
request.amountMode?.uppercase()?.let { if (it != "RATIO" && it != "FIXED") return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID.messageKey)) }
val saved = strategyRepository.save(updated)
eventPublisher.publishEvent(CryptoTailStrategyChangedEvent(this))
val lastTrigger = triggerRepository.findAllByStrategyIdOrderByCreatedAtDesc(saved.id!!, PageRequest.of(0, 1))
.content.firstOrNull()?.createdAt
Result.success(entityToDto(saved, lastTrigger))
} catch (e: IllegalArgumentException) {
Result.failure(e)
} catch (e: Exception) {
logger.error("更新加密价差策略失败: ${e.message}", e)
Result.failure(e)
}
}
@Transactional
fun delete(strategyId: Long): Result<Unit> {
return try {
if (!strategyRepository.existsById(strategyId)) {
return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND.messageKey))
}
strategyRepository.deleteById(strategyId)
eventPublisher.publishEvent(CryptoTailStrategyChangedEvent(this))
Result.success(Unit)
} catch (e: Exception) {
logger.error("删除加密价差策略失败: ${e.message}", e)
Result.failure(e)
}
}
fun list(request: CryptoTailStrategyListRequest): Result<CryptoTailStrategyListResponse> {
return try {
val list = when {
request.accountId != null && request.enabled != null -> strategyRepository.findByAccountIdAndEnabled(request.accountId, request.enabled)
request.accountId != null -> strategyRepository.findAllByAccountId(request.accountId)
request.enabled == true -> strategyRepository.findAllByEnabledTrue()
request.enabled == false -> strategyRepository.findAll().filter { !it.enabled }
else -> strategyRepository.findAll()
}
val lastTriggerMap = list.map { it.id!! }.associateWith { id ->
triggerRepository.findAllByStrategyIdOrderByCreatedAtDesc(id, PageRequest.of(0, 1))
.content.firstOrNull()?.createdAt
}
val dtos = list.map { entityToDto(it, lastTriggerMap[it.id]) }
Result.success(CryptoTailStrategyListResponse(list = dtos))
} catch (e: Exception) {
logger.error("查询加密价差策略列表失败: ${e.message}", e)
Result.failure(e)
}
}
fun getPnlCurve(request: CryptoTailPnlCurveRequest): Result<CryptoTailPnlCurveResponse> {
return try {
val strategy = strategyRepository.findById(request.strategyId).orElse(null)
?: return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND.messageKey))
val start = request.startDate ?: 0L
val end = request.endDate ?: Long.MAX_VALUE
val triggers = triggerRepository.findResolvedByStrategyIdAndTimeRangeOrderBySettledAsc(
request.strategyId, start, end
)
var cumulative = BigDecimal.ZERO
var peak = BigDecimal.ZERO
var maxDrawdown = BigDecimal.ZERO
var winCountInRange = 0L
val curveData = triggers.map { t ->
val pnl = t.realizedPnl ?: BigDecimal.ZERO
cumulative = cumulative.add(pnl)
if (cumulative.gt(peak)) peak = cumulative
val drawdown = peak.subtract(cumulative)
if (drawdown.gt(maxDrawdown)) maxDrawdown = drawdown
if (t.winnerOutcomeIndex != null && t.outcomeIndex == t.winnerOutcomeIndex) winCountInRange++
val ts = t.settledAt ?: t.createdAt
CryptoTailPnlCurvePoint(
timestamp = ts,
cumulativePnl = cumulative.toPlainString(),
pointPnl = pnl.toPlainString(),
settledCount = 0L
)
}.mapIndexed { index, p ->
p.copy(settledCount = (index + 1).toLong())
}
val totalPnl = if (curveData.isEmpty()) BigDecimal.ZERO else curveData.last().cumulativePnl.toSafeBigDecimal()
val settledCountInRange = curveData.size.toLong()
val winRateStr = if (settledCountInRange > 0L) {
BigDecimal(winCountInRange).divide(BigDecimal(settledCountInRange), 4, java.math.RoundingMode.HALF_UP).toPlainString()
} else null
Result.success(
CryptoTailPnlCurveResponse(
strategyId = request.strategyId,
strategyName = strategy.name ?: strategy.marketSlugPrefix,
totalRealizedPnl = totalPnl.toPlainString(),
settledCount = settledCountInRange,
winCount = winCountInRange,
winRate = winRateStr,
maxDrawdown = if (maxDrawdown.compareTo(BigDecimal.ZERO) > 0) maxDrawdown.toPlainString() else null,
curveData = curveData
)
)
} catch (e: IllegalArgumentException) {
Result.failure(e)
} catch (e: Exception) {
logger.error("查询收益曲线失败: ${e.message}", e)
Result.failure(e)
}
}
fun getTriggerRecords(request: CryptoTailStrategyTriggerListRequest): Result<CryptoTailStrategyTriggerListResponse> {
return try {
val page = PageRequest.of((request.page - 1).coerceAtLeast(0), request.pageSize.coerceIn(1, 100))
val startTs = request.startDate ?: 0L
val endTs = request.endDate ?: Long.MAX_VALUE
val useTimeRange = request.startDate != null || request.endDate != null
val pageResult = when {
useTimeRange && request.status != null && request.status.isNotBlank() ->
triggerRepository.findAllByStrategyIdAndStatusAndCreatedAtBetweenOrderByCreatedAtDesc(
request.strategyId, request.status, startTs, endTs, page
)
useTimeRange ->
triggerRepository.findAllByStrategyIdAndCreatedAtBetweenOrderByCreatedAtDesc(
request.strategyId, startTs, endTs, page
)
request.status != null && request.status.isNotBlank() ->
triggerRepository.findAllByStrategyIdAndStatusOrderByCreatedAtDesc(request.strategyId, request.status, page)
else ->
triggerRepository.findAllByStrategyIdOrderByCreatedAtDesc(request.strategyId, page)
}
val list = pageResult.content.map { triggerToDto(it) }
val total = when {
useTimeRange && request.status != null && request.status.isNotBlank() ->
triggerRepository.countByStrategyIdAndStatusAndCreatedAtBetween(request.strategyId, request.status, startTs, endTs)
useTimeRange ->
triggerRepository.countByStrategyIdAndCreatedAtBetween(request.strategyId, startTs, endTs)
request.status != null && request.status.isNotBlank() ->
triggerRepository.countByStrategyIdAndStatus(request.strategyId, request.status)
else ->
pageResult.totalElements
}
Result.success(CryptoTailStrategyTriggerListResponse(list = list, total = total))
} catch (e: Exception) {
logger.error("查询触发记录失败: ${e.message}", e)
Result.failure(e)
}
}
fun getStrategy(strategyId: Long): CryptoTailStrategy? = strategyRepository.findById(strategyId).orElse(null)
private fun generateStrategyName(marketSlugPrefix: String): String {
val suffix = Instant.now().atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
return "加密价差策略-${marketSlugPrefix}-$suffix"
}
private fun entityToDto(e: CryptoTailStrategy, lastTriggerAt: Long?): CryptoTailStrategyDto {
val strategyId = e.id ?: 0L
val totalPnl = triggerRepository.sumRealizedPnlByStrategyId(strategyId)
val settledCount = triggerRepository.countResolvedByStrategyId(strategyId)
val winCount = triggerRepository.countWinsByStrategyId(strategyId)
val winRateStr = if (settledCount > 0L) {
BigDecimal(winCount).divide(BigDecimal(settledCount), 4, java.math.RoundingMode.HALF_UP).toPlainString()
} else null
return CryptoTailStrategyDto(
id = strategyId,
accountId = e.accountId,
name = e.name,
marketSlugPrefix = e.marketSlugPrefix,
marketTitle = null,
intervalSeconds = e.intervalSeconds,
windowStartSeconds = e.windowStartSeconds,
windowEndSeconds = e.windowEndSeconds,
minPrice = e.minPrice.toPlainString(),
maxPrice = e.maxPrice.toPlainString(),
amountMode = e.amountMode,
amountValue = e.amountValue.toPlainString(),
spreadMode = e.spreadMode.name,
spreadValue = e.spreadValue?.toPlainString(),
spreadDirection = e.spreadDirection.name,
enabled = e.enabled,
lastTriggerAt = lastTriggerAt,
totalRealizedPnl = totalPnl?.toPlainString(),
settledCount = settledCount,
winCount = winCount,
winRate = winRateStr,
createdAt = e.createdAt,
updatedAt = e.updatedAt
)
}
private fun triggerToDto(t: CryptoTailStrategyTrigger): CryptoTailStrategyTriggerDto = CryptoTailStrategyTriggerDto(
id = t.id ?: 0L,
strategyId = t.strategyId,
periodStartUnix = t.periodStartUnix,
marketTitle = t.marketTitle,
outcomeIndex = t.outcomeIndex,
triggerPrice = t.triggerPrice.toPlainString(),
amountUsdc = t.amountUsdc.toPlainString(),
orderId = t.orderId,
status = t.status,
failReason = t.failReason,
resolved = t.resolved,
realizedPnl = t.realizedPnl?.toPlainString(),
winnerOutcomeIndex = t.winnerOutcomeIndex,
settledAt = t.settledAt,
createdAt = t.createdAt
)
}
@@ -15,6 +15,7 @@ import org.springframework.context.ApplicationContextAware
import com.wrbug.polymarketbot.service.copytrading.orders.OrderPushService
import com.wrbug.polymarketbot.service.copytrading.monitor.PolymarketActivityWsService
import com.wrbug.polymarketbot.service.copytrading.monitor.UnifiedOnChainWsService
import com.wrbug.polymarketbot.service.binance.BinanceKlineService
import org.springframework.stereotype.Service
import java.util.concurrent.TimeUnit
@@ -76,6 +77,17 @@ class ApiHealthCheckService(
}
}
/**
* 获取 BinanceKlineService通过 ApplicationContext 避免循环依赖
*/
private fun getBinanceKlineService(): BinanceKlineService? {
return try {
applicationContext?.getBean(BinanceKlineService::class.java)
} catch (e: BeansException) {
null
}
}
private val logger = LoggerFactory.getLogger(ApiHealthCheckService::class.java)
/**
@@ -91,6 +103,8 @@ class ApiHealthCheckService(
async { checkDataApi() },
async { checkGammaApi() },
async { checkPolygonRpc() },
async { checkBinanceApi() },
async { checkBinanceWebSocket() },
async { checkPolymarketRtdsWebSocket() },
async { checkPolymarketActivityWebSocket() },
async { checkUnifiedOnChainWebSocket() },
@@ -197,6 +211,74 @@ class ApiHealthCheckService(
checkJsonRpcApi("Polygon RPC", rpcUrl)
}
/**
* 检查币安 API用于 K 线等
* 使用 /api/v3/ping 端点
*/
private suspend fun checkBinanceApi(): ApiHealthCheckDto = withContext(Dispatchers.IO) {
val url = "https://api.binance.com/api/v3/ping"
checkApi("币安 API", url)
}
/**
* 检查币安 K 线 WebSocket 连接状态5m / 15m
*/
private suspend fun checkBinanceWebSocket(): ApiHealthCheckDto = withContext(Dispatchers.Default) {
val binanceWsUrl = "wss://stream.binance.com:9443"
try {
val binanceKlineService = getBinanceKlineService()
if (binanceKlineService == null) {
return@withContext ApiHealthCheckDto(
name = "币安 WebSocket",
url = binanceWsUrl,
status = "error",
message = "服务未初始化"
)
}
val statuses = binanceKlineService.getConnectionStatuses()
val total = statuses.size
val connected = statuses.values.count { it }
if (connected == total && total > 0) {
ApiHealthCheckDto(
name = "币安 WebSocket",
url = binanceWsUrl,
status = "success",
message = "连接正常 (按策略订阅)"
)
} else if (total == 0) {
ApiHealthCheckDto(
name = "币安 WebSocket",
url = binanceWsUrl,
status = "success",
message = "无加密价差策略,未订阅"
)
} else if (connected > 0) {
val which = statuses.filter { it.value }.keys.joinToString("")
ApiHealthCheckDto(
name = "币安 WebSocket",
url = binanceWsUrl,
status = "error",
message = "部分连接正常 ($which)"
)
} else {
ApiHealthCheckDto(
name = "币安 WebSocket",
url = binanceWsUrl,
status = "error",
message = "连接断开"
)
}
} catch (e: Exception) {
logger.warn("检查币安 WebSocket 状态失败", e)
ApiHealthCheckDto(
name = "币安 WebSocket",
url = binanceWsUrl,
status = "error",
message = "检查失败:${e.message}"
)
}
}
/**
* 检查 Polymarket RTDS WebSocket 连接状态
* 用于订单推送服务
@@ -0,0 +1,67 @@
package com.wrbug.polymarketbot.service.system
import com.google.gson.Gson
import com.wrbug.polymarketbot.dto.GeoblockCheckDto
import com.wrbug.polymarketbot.dto.PolymarketGeoblockApiResponse
import com.wrbug.polymarketbot.util.createClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.util.concurrent.TimeUnit
@Service
class GeoblockService(
private val gson: Gson
) {
private val logger = LoggerFactory.getLogger(GeoblockService::class.java)
companion object {
private const val GEOBLOCK_URL = "https://polymarket.com/api/geoblock"
}
suspend fun checkGeoblock(): Result<GeoblockCheckDto> = withContext(Dispatchers.IO) {
val client = createClient()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.build()
checkGeoblockWithClient(client)
}
fun checkGeoblockWithClient(client: OkHttpClient): Result<GeoblockCheckDto> {
return try {
val request = Request.Builder()
.url(GEOBLOCK_URL)
.get()
.header("Accept", "application/json")
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
return Result.failure(
IllegalStateException("HTTP ${response.code}: ${response.message}")
)
}
val body = response.body?.string()
?: return Result.failure(IllegalStateException("Empty response body"))
val apiResponse = gson.fromJson(body, PolymarketGeoblockApiResponse::class.java)
Result.success(
GeoblockCheckDto(
blocked = apiResponse.blocked,
ip = apiResponse.ip,
country = apiResponse.country,
region = apiResponse.region,
checkedAt = System.currentTimeMillis()
)
)
}
} catch (e: Exception) {
logger.warn("Geoblock 检查失败", e)
Result.failure(e)
}
}
}
@@ -0,0 +1,457 @@
package com.wrbug.polymarketbot.service.system
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.entity.NotificationTemplate
import com.wrbug.polymarketbot.repository.NotificationTemplateRepository
import org.slf4j.LoggerFactory
import org.springframework.context.annotation.Lazy
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
/**
* 消息模板服务
* 负责管理消息模板渲染模板提供变量信息
*/
@Service
class NotificationTemplateService(
private val templateRepository: NotificationTemplateRepository,
@Lazy private val telegramNotificationService: TelegramNotificationService
) {
private val logger = LoggerFactory.getLogger(NotificationTemplateService::class.java)
companion object {
// 模板类型定义
val TEMPLATE_TYPES = mapOf(
"ORDER_SUCCESS" to TemplateTypeInfoDto(
type = "ORDER_SUCCESS",
name = "订单成功通知",
description = "订单创建成功时发送的通知"
),
"ORDER_FAILED" to TemplateTypeInfoDto(
type = "ORDER_FAILED",
name = "订单失败通知",
description = "订单创建失败时发送的通知"
),
"ORDER_FILTERED" to TemplateTypeInfoDto(
type = "ORDER_FILTERED",
name = "订单过滤通知",
description = "订单被风控过滤时发送的通知"
),
"CRYPTO_TAIL_SUCCESS" to TemplateTypeInfoDto(
type = "CRYPTO_TAIL_SUCCESS",
name = "加密价差策略成功通知",
description = "加密价差策略下单成功时发送的通知"
),
"REDEEM_SUCCESS" to TemplateTypeInfoDto(
type = "REDEEM_SUCCESS",
name = "仓位赎回成功通知",
description = "仓位赎回成功时发送的通知"
),
"REDEEM_NO_RETURN" to TemplateTypeInfoDto(
type = "REDEEM_NO_RETURN",
name = "仓位结算(无收益)通知",
description = "仓位结算但无收益时发送的通知"
)
)
// 变量分类
val VARIABLE_CATEGORIES = listOf(
TemplateVariableCategoryDto("common", 0),
TemplateVariableCategoryDto("order", 10),
TemplateVariableCategoryDto("copy_trading", 20),
TemplateVariableCategoryDto("redeem", 30),
TemplateVariableCategoryDto("error", 40),
TemplateVariableCategoryDto("filter", 50),
TemplateVariableCategoryDto("strategy", 60)
)
// 各模板类型可用的变量
val TEMPLATE_VARIABLES = mapOf(
"ORDER_SUCCESS" to listOf(
// 通用变量
TemplateVariableDto("account_name", "common", 1),
TemplateVariableDto("wallet_address", "common", 2),
TemplateVariableDto("time", "common", 3),
// 订单变量
TemplateVariableDto("order_id", "order", 10),
TemplateVariableDto("market_title", "order", 11),
TemplateVariableDto("market_link", "order", 12),
TemplateVariableDto("side", "order", 13),
TemplateVariableDto("outcome", "order", 14),
TemplateVariableDto("price", "order", 15),
TemplateVariableDto("quantity", "order", 16),
TemplateVariableDto("amount", "order", 17),
TemplateVariableDto("available_balance", "order", 18),
// 跟单变量
TemplateVariableDto("leader_name", "copy_trading", 21),
TemplateVariableDto("config_name", "copy_trading", 22)
),
"ORDER_FAILED" to listOf(
// 通用变量
TemplateVariableDto("account_name", "common", 1),
TemplateVariableDto("wallet_address", "common", 2),
TemplateVariableDto("time", "common", 3),
// 订单变量
TemplateVariableDto("market_title", "order", 10),
TemplateVariableDto("market_link", "order", 11),
TemplateVariableDto("side", "order", 12),
TemplateVariableDto("outcome", "order", 13),
TemplateVariableDto("price", "order", 14),
TemplateVariableDto("quantity", "order", 15),
TemplateVariableDto("amount", "order", 16),
// 错误变量
TemplateVariableDto("error_message", "error", 20)
),
"ORDER_FILTERED" to listOf(
// 通用变量
TemplateVariableDto("account_name", "common", 1),
TemplateVariableDto("wallet_address", "common", 2),
TemplateVariableDto("time", "common", 3),
// 订单变量
TemplateVariableDto("market_title", "order", 10),
TemplateVariableDto("market_link", "order", 11),
TemplateVariableDto("side", "order", 12),
TemplateVariableDto("outcome", "order", 13),
TemplateVariableDto("price", "order", 14),
TemplateVariableDto("quantity", "order", 15),
TemplateVariableDto("amount", "order", 16),
// 过滤变量
TemplateVariableDto("filter_type", "filter", 20),
TemplateVariableDto("filter_reason", "filter", 21)
),
"CRYPTO_TAIL_SUCCESS" to listOf(
// 通用变量
TemplateVariableDto("account_name", "common", 1),
TemplateVariableDto("wallet_address", "common", 2),
TemplateVariableDto("time", "common", 3),
// 订单变量
TemplateVariableDto("order_id", "order", 10),
TemplateVariableDto("market_title", "order", 11),
TemplateVariableDto("market_link", "order", 12),
TemplateVariableDto("side", "order", 13),
TemplateVariableDto("outcome", "order", 14),
TemplateVariableDto("price", "order", 15),
TemplateVariableDto("quantity", "order", 16),
TemplateVariableDto("amount", "order", 17),
// 策略变量
TemplateVariableDto("strategy_name", "strategy", 20)
),
"REDEEM_SUCCESS" to listOf(
// 通用变量
TemplateVariableDto("account_name", "common", 1),
TemplateVariableDto("wallet_address", "common", 2),
TemplateVariableDto("time", "common", 3),
// 赎回变量
TemplateVariableDto("transaction_hash", "redeem", 10),
TemplateVariableDto("total_value", "redeem", 11),
TemplateVariableDto("available_balance", "redeem", 12)
),
"REDEEM_NO_RETURN" to listOf(
// 通用变量
TemplateVariableDto("account_name", "common", 1),
TemplateVariableDto("wallet_address", "common", 2),
TemplateVariableDto("time", "common", 3),
// 赎回变量
TemplateVariableDto("transaction_hash", "redeem", 10),
TemplateVariableDto("available_balance", "redeem", 11)
)
)
// 默认模板
val DEFAULT_TEMPLATES = mapOf(
"ORDER_SUCCESS" to """
🚀 <b>订单创建成功</b>
📊 <b>订单信息</b>
订单ID: <code>{{order_id}}</code>
市场: <a href="{{market_link}}">{{market_title}}</a>
市场方向: <b>{{outcome}}</b>
方向: <b>{{side}}</b>
价格: <code>{{price}}</code>
数量: <code>{{quantity}}</code> shares
金额: <code>${'$'}{{amount}}</code>
账户: {{account_name}}
可用余额: <code>${'$'}{{available_balance}}</code>
时间: <code>{{time}}</code>
""".trimIndent(),
"ORDER_FAILED" to """
<b>订单创建失败</b>
📊 <b>订单信息</b>
市场: <a href="{{market_link}}">{{market_title}}</a>
市场方向: <b>{{outcome}}</b>
方向: <b>{{side}}</b>
价格: <code>{{price}}</code>
数量: <code>{{quantity}}</code> shares
金额: <code>${'$'}{{amount}}</code>
账户: {{account_name}}
<b>错误信息</b>
<code>{{error_message}}</code>
时间: <code>{{time}}</code>
""".trimIndent(),
"ORDER_FILTERED" to """
🚫 <b>订单被过滤</b>
📊 <b>订单信息</b>
市场: <a href="{{market_link}}">{{market_title}}</a>
市场方向: <b>{{outcome}}</b>
方向: <b>{{side}}</b>
价格: <code>{{price}}</code>
数量: <code>{{quantity}}</code> shares
金额: <code>${'$'}{{amount}}</code>
账户: {{account_name}}
<b>过滤类型</b> <code>{{filter_type}}</code>
📝 <b>过滤原因</b>
<code>{{filter_reason}}</code>
时间: <code>{{time}}</code>
""".trimIndent(),
"CRYPTO_TAIL_SUCCESS" to """
🚀 <b>加密价差策略下单成功</b>
📊 <b>订单信息</b>
订单ID: <code>{{order_id}}</code>
策略: {{strategy_name}}
市场: <a href="{{market_link}}">{{market_title}}</a>
市场方向: <b>{{outcome}}</b>
方向: <b>{{side}}</b>
价格: <code>{{price}}</code>
数量: <code>{{quantity}}</code> shares
金额: <code>${'$'}{{amount}}</code>
账户: {{account_name}}
时间: <code>{{time}}</code>
""".trimIndent(),
"REDEEM_SUCCESS" to """
💸 <b>仓位赎回成功</b>
📊 <b>赎回信息</b>
账户: {{account_name}}
交易哈希: <code>{{transaction_hash}}</code>
赎回总价值: <code>${'$'}{{total_value}}</code>
可用余额: <code>${'$'}{{available_balance}}</code>
时间: <code>{{time}}</code>
""".trimIndent(),
"REDEEM_NO_RETURN" to """
📋 <b>仓位已结算无收益</b>
📊 <b>结算信息</b>
<i>市场已结算您的预测未命中赎回价值为 0</i>
账户: {{account_name}}
交易哈希: <code>{{transaction_hash}}</code>
可用余额: <code>${'$'}{{available_balance}}</code>
时间: <code>{{time}}</code>
""".trimIndent()
)
}
/**
* 获取所有模板类型
*/
fun getTemplateTypes(): List<TemplateTypeInfoDto> {
return TEMPLATE_TYPES.values.toList()
}
/**
* 获取所有模板列表
*/
fun getAllTemplates(): List<NotificationTemplateDto> {
return templateRepository.findAll().map { it.toDto() }
}
/**
* 获取单个模板
*/
fun getTemplate(templateType: String): NotificationTemplateDto? {
return templateRepository.findByTemplateType(templateType)?.toDto()
?: DEFAULT_TEMPLATES[templateType]?.let {
NotificationTemplateDto(
templateType = templateType,
templateContent = it,
isDefault = true
)
}
}
/**
* 获取模板可用变量
*/
fun getTemplateVariables(templateType: String): TemplateVariablesResponse? {
if (!TEMPLATE_TYPES.containsKey(templateType)) return null
val variables = TEMPLATE_VARIABLES[templateType] ?: emptyList()
// 获取使用的分类
val usedCategories = variables.map { it.category }.toSet()
val categories = VARIABLE_CATEGORIES.filter { usedCategories.contains(it.key) }
return TemplateVariablesResponse(
templateType = templateType,
categories = categories,
variables = variables
)
}
/**
* 更新模板
*/
@Transactional
fun updateTemplate(templateType: String, content: String): NotificationTemplateDto {
val template = templateRepository.findByTemplateType(templateType)
val now = System.currentTimeMillis()
return if (template != null) {
template.templateContent = content
template.isDefault = false
template.updatedAt = now
templateRepository.save(template).toDto()
} else {
val newTemplate = NotificationTemplate(
templateType = templateType,
templateContent = content,
isDefault = false,
createdAt = now,
updatedAt = now
)
templateRepository.save(newTemplate).toDto()
}
}
/**
* 重置模板为默认
*/
@Transactional
fun resetTemplate(templateType: String): NotificationTemplateDto? {
val defaultContent = DEFAULT_TEMPLATES[templateType] ?: return null
val template = templateRepository.findByTemplateType(templateType)
val now = System.currentTimeMillis()
return if (template != null) {
template.templateContent = defaultContent
template.isDefault = true
template.updatedAt = now
templateRepository.save(template).toDto()
} else {
val newTemplate = NotificationTemplate(
templateType = templateType,
templateContent = defaultContent,
isDefault = true,
createdAt = now,
updatedAt = now
)
templateRepository.save(newTemplate).toDto()
}
}
/**
* 渲染模板按类型取模板内容后替换变量
* 优化先解析模版中需要的变量只替换这些变量未提供的变量使用 "-" 占位
*/
fun renderTemplate(templateType: String, variables: Map<String, String>): String {
val template = getTemplate(templateType)
val content = template?.templateContent ?: DEFAULT_TEMPLATES[templateType] ?: ""
return renderTemplateContent(content, variables)
}
/**
* 对给定模板内容做变量替换不查库
* 优化先解析模版中的变量占位符只替换这些变量未提供的变量使用 "-" 占位
*/
fun renderTemplateContent(content: String, variables: Map<String, String>): String {
// 先解析模版中需要的变量
val requiredVariables = extractTemplateVariables(content)
var result = content
// 只替换模版中实际使用的变量
requiredVariables.forEach { varName ->
val value = variables[varName]
result = result.replace("{{$varName}}", value ?: "-")
}
return result
}
/**
* 解析模版中使用的变量名
* @return 变量名列表去重
*/
private fun extractTemplateVariables(content: String): Set<String> {
val regex = Regex("\\{\\{([^}]+)}}")
return regex.findAll(content)
.map { it.groupValues[1].trim() }
.toSet()
}
/**
* 根据模版需要的变量过滤输入变量
* 只保留模版中实际使用的变量避免不必要的数据获取
*/
fun filterVariablesForTemplate(templateType: String, variables: Map<String, String>): Map<String, String> {
val template = getTemplate(templateType)
val content = template?.templateContent ?: DEFAULT_TEMPLATES[templateType] ?: return emptyMap()
val requiredVariables = extractTemplateVariables(content)
return variables.filterKeys { it in requiredVariables }
}
/**
* 发送测试消息
*/
suspend fun sendTestMessage(templateType: String, content: String? = null): Boolean {
val templateContent = content ?: getTemplate(templateType)?.templateContent ?: return false
val testVariables = generateTestVariables(templateType)
val message = renderTemplateContent(templateContent, testVariables)
return try {
telegramNotificationService.sendMessage(message)
true
} catch (e: Exception) {
logger.error("发送测试消息失败: ${e.message}", e)
false
}
}
/**
* 生成测试变量数据
*/
private fun generateTestVariables(templateType: String): Map<String, String> {
return mapOf(
"account_name" to "测试账户",
"wallet_address" to "0x1234...5678",
"time" to "2024-01-15 12:30:00",
"order_id" to "12345678",
"market_title" to "测试市场标题",
"market_link" to "https://polymarket.com/event/test",
"side" to "买入",
"outcome" to "YES",
"price" to "0.55",
"quantity" to "100",
"amount" to "55.00",
"available_balance" to "1000.00",
"leader_name" to "测试Leader",
"config_name" to "测试配置",
"error_message" to "余额不足",
"filter_type" to "价差过大",
"filter_reason" to "当前市场价差为 5%,超过设定的 3% 限制",
"strategy_name" to "BTC价差策略",
"transaction_hash" to "0xabcd...efgh",
"total_value" to "100.00"
)
}
/**
* Entity DTO
*/
private fun NotificationTemplate.toDto() = NotificationTemplateDto(
id = id,
templateType = templateType,
templateContent = templateContent,
isDefault = isDefault,
createdAt = createdAt,
updatedAt = updatedAt
)
}
@@ -24,7 +24,8 @@ import java.util.concurrent.TimeUnit
*/
@Service
class ProxyConfigService(
private val proxyConfigRepository: ProxyConfigRepository
private val proxyConfigRepository: ProxyConfigRepository,
private val geoblockService: GeoblockService
) : ApplicationContextAware {
private var applicationContext: ApplicationContext? = null
@@ -148,100 +149,153 @@ class ProxyConfigService(
}
/**
* 检查代理是否可用
* 使用配置的代理请求 Polymarket 健康检查接口
* 检查代理是否可用并检测经该代理访问 Polymarket 时的地域限制
*/
fun checkProxy(): ProxyCheckResponse {
return try {
val config = proxyConfigRepository.findByEnabledTrue()
?: return ProxyCheckResponse.create(
if (config == null) {
return ProxyCheckResponse.create(
success = false,
message = "未配置代理或代理未启用"
message = "未配置代理或代理未启用",
geoblock = checkGeoblockForProxy(null)
)
}
if (config.type != "HTTP") {
return ProxyCheckResponse.create(
success = false,
message = "当前仅支持检查 HTTP 代理(订阅代理检查功能待实现)"
message = "当前仅支持检查 HTTP 代理(订阅代理检查功能待实现)",
geoblock = checkGeoblockForProxy(null)
)
}
if (config.host == null || config.port == null) {
return ProxyCheckResponse.create(
success = false,
message = "代理配置不完整:缺少主机或端口"
message = "代理配置不完整:缺少主机或端口",
geoblock = checkGeoblockForProxy(null)
)
}
// 创建代理
val proxy = Proxy(Proxy.Type.HTTP, InetSocketAddress(config.host, config.port))
// 创建 OkHttpClient
val clientBuilder = OkHttpClient.Builder()
.proxy(proxy)
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
// 配置 SSL:信任所有证书(用于代理连接)
clientBuilder.createSSLSocketFactory()
clientBuilder.hostnameVerifier(TrustAllHostnameVerifier())
// 如果配置了用户名和密码,添加代理认证
if (config.username != null && config.password != null) {
clientBuilder.proxyAuthenticator { _, response ->
val credential = okhttp3.Credentials.basic(config.username, config.password)
response.request.newBuilder()
.header("Proxy-Authorization", credential)
.build()
}
}
val client = clientBuilder.build()
// 请求 Polymarket 健康检查接口
val client = buildProxyHttpClient(config)
val geoblock = checkGeoblockForProxy(client)
val request = Request.Builder()
.url("https://data-api.polymarket.com/")
.get()
.build()
val startTime = System.currentTimeMillis()
val response = client.newCall(request).execute()
val responseTime = System.currentTimeMillis() - startTime
val responseBody = response.body?.string()
if (response.isSuccessful && responseBody != null) {
// 检查响应内容是否为 {"data": "OK"}
if (responseBody.contains("\"data\"") && responseBody.contains("OK")) {
logger.info("代理检查成功:host=${config.host}, port=${config.port}, responseTime=${responseTime}ms")
val message = buildProxyCheckMessage("代理连接成功", geoblock)
ProxyCheckResponse.create(
success = true,
message = "代理连接成功",
responseTime = responseTime
message = message,
responseTime = responseTime,
geoblock = geoblock
)
} else {
ProxyCheckResponse.create(
success = false,
message = "代理连接成功,但响应格式不正确:$responseBody",
responseTime = responseTime
message = buildProxyCheckMessage(
"代理连接成功,但响应格式不正确:$responseBody",
geoblock
),
responseTime = responseTime,
geoblock = geoblock
)
}
} else {
ProxyCheckResponse.create(
success = false,
message = "代理连接失败:HTTP ${response.code} ${response.message}",
responseTime = responseTime
message = buildProxyCheckMessage(
"代理连接失败:HTTP ${response.code} ${response.message}",
geoblock
),
responseTime = responseTime,
geoblock = geoblock
)
}
} catch (e: Exception) {
logger.error("代理检查异常", e)
ProxyCheckResponse.create(
success = false,
message = "代理检查失败:${e.message}"
message = "代理检查失败:${e.message}",
geoblock = checkGeoblockForProxy(null)
)
}
}
private fun buildProxyHttpClient(config: ProxyConfig): OkHttpClient {
val host = requireNotNull(config.host) { "代理主机不能为空" }
val port = requireNotNull(config.port) { "代理端口不能为空" }
val proxy = Proxy(Proxy.Type.HTTP, InetSocketAddress(host, port))
val clientBuilder = OkHttpClient.Builder()
.proxy(proxy)
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
clientBuilder.createSSLSocketFactory()
clientBuilder.hostnameVerifier(TrustAllHostnameVerifier())
if (config.username != null && config.password != null) {
clientBuilder.proxyAuthenticator { _, response ->
val credential = Credentials.basic(config.username, config.password)
response.request.newBuilder()
.header("Proxy-Authorization", credential)
.build()
}
}
return clientBuilder.build()
}
private fun checkGeoblockForProxy(client: OkHttpClient?): ProxyCheckGeoblockResult {
val httpClient = client ?: com.wrbug.polymarketbot.util.createClient()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.build()
return geoblockService.checkGeoblockWithClient(httpClient).fold(
onSuccess = { dto ->
ProxyCheckGeoblockResult(
checked = true,
blocked = dto.blocked,
ip = dto.ip,
country = dto.country,
region = dto.region,
message = if (dto.blocked) {
"当前出口 IP${dto.country}/${dto.region})在 Polymarket 受限,无法下单"
} else {
"当前出口 IP${dto.country}/${dto.region})可向 Polymarket 下单"
}
)
},
onFailure = { e ->
ProxyCheckGeoblockResult(
checked = true,
message = "地域限制检测失败:${e.message}"
)
}
)
}
private fun buildProxyCheckMessage(baseMessage: String, geoblock: ProxyCheckGeoblockResult): String {
if (!geoblock.checked || geoblock.message.isNullOrBlank()) {
return baseMessage
}
return "$baseMessage${geoblock.message}"
}
/**
* 删除代理配置
@@ -4,12 +4,17 @@ import com.wrbug.polymarketbot.api.BuilderRelayerApi
import com.wrbug.polymarketbot.api.EthereumRpcApi
import com.wrbug.polymarketbot.api.JsonRpcRequest
import com.wrbug.polymarketbot.constants.PolymarketConstants
import com.wrbug.polymarketbot.enums.WalletType
import com.wrbug.polymarketbot.util.Eip712Encoder
import com.wrbug.polymarketbot.util.EthereumUtils
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.createClient
import kotlinx.coroutines.delay
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import retrofit2.Response
import java.math.BigInteger
import java.util.concurrent.atomic.AtomicLong
/**
* RelayClient 服务
@@ -19,8 +24,8 @@ import java.math.BigInteger
* 如果需要真正的 Gasless 功能需要集成 Builder Relayer API
*
* 参考
* - TypeScript: @polymarket/builder-relayer-client
* - TypeScript: utils/redeem.ts
* - TypeScript: https://github.com/Polymarket/builder-relayer-clientclient.execute、src/encode/safe.ts MultiSend
* - 赎回 calldata 由本服务构建官方仓库无 redeem 工具Neg Risk 逻辑见 docs/neg-risk-redeem.md
*/
@Service
class RelayClientService(
@@ -34,17 +39,97 @@ class RelayClientService(
// ConditionalTokens 合约地址
private val conditionalTokensAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"
// USDC.e 合约地址
private val usdcContractAddress = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
// pUSD 合约地址(普通市场抵押品)
private val usdcContractAddress = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB"
// USDC.e 合约地址(仅用于 wrap 到 pUSD)
private val usdceContractAddress = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
// CollateralOnramp 合约地址(USDC.e → pUSD
private val collateralOnrampAddress = "0x93070a847efEf7F70739046A929D47a521F5B8ee"
// Neg Risk 市场使用的 WrappedCollateral 合约地址(Polygonneg-risk-ctf-adapter
private val negRiskWrappedCollateralAddress = "0x3A3BD7bb9528E159577F7C2e685CC81A765002E2"
// 空集合ID
private val EMPTY_SET = "0x0000000000000000000000000000000000000000000000000000000000000000"
// Polygon PROXYMagic)合约地址,参考 builder-relayer-client config
private val proxyFactoryAddress = "0xaB45c5A4B0c941a2F231C04C3f49182e1A254052"
private val relayHubAddress = "0xD216153c06E857cD7f72665E0aF1d7D82172F494"
// PROXY relayCall 内层 gasLimit(签名参数)不能给过大值,否则 RelayHub 会因 gasleft 校验失败回滚。
private val defaultProxyGasLimit = "2400000"
private val maxProxyGasLimit = BigInteger.valueOf(2400000)
// Safe MultiSend 合约地址(Polygon 主网)
private val safeMultisendAddress = "0xA238CBeb142c10Ef7Ad8442C6D1f9E89e07e7761"
// Builder Relayer API 交易类型常量
private val RELAYER_TYPE_PROXY = "PROXY"
private val RELAYER_TYPE_SAFE = "SAFE"
private val RELAYER_TYPE_SAFE_CREATE = "SAFE-CREATE"
// Safe 代理工厂(用于 SAFE-CREATE 部署)
private val safeProxyFactoryAddress = PolymarketConstants.SAFE_PROXY_FACTORY_ADDRESS
private val polygonRpcApi: EthereumRpcApi by lazy {
val rpcUrl = rpcNodeService.getHttpUrl()
retrofitFactory.createEthereumRpcApi(rpcUrl)
}
/** 遇到 429 限流时的重试次数 */
private val builderRelayerRateLimitMaxAttempts = 3
/** 429 限流重试退避基数(毫秒),第 n 次重试等待 baseMs * 2^(n-1) */
private val builderRelayerRateLimitBackoffMs = 2000L
/** Builder Relayer 配额用尽后的冷却截止时间(毫秒时间戳),在此时间前不再发起赎回 */
private val builderRelayerQuotaBlockedUntilMs = AtomicLong(0)
/**
* 是否处于 Builder Relayer 配额冷却期配额用尽后在该时间内不再发起赎回
*/
fun isBuilderRelayerQuotaBlocked(): Boolean = System.currentTimeMillis() < builderRelayerQuotaBlockedUntilMs.get()
/**
* 配额冷却剩余秒数未在冷却期时返回 0
*/
fun getBuilderRelayerQuotaBlockedRemainingSeconds(): Long {
val remaining = (builderRelayerQuotaBlockedUntilMs.get() - System.currentTimeMillis()) / 1000
return maxOf(0, remaining)
}
/**
* API 错误响应中解析 "quota exceeded... resets in N seconds"并设置配额冷却截止时间
*/
private fun updateQuotaBlockedFromErrorBody(errorBody: String) {
if (!errorBody.contains("quota exceeded", ignoreCase = true)) return
val regex = Regex("resets\\s+in\\s+(\\d+)\\s+seconds", RegexOption.IGNORE_CASE)
regex.find(errorBody)?.groupValues?.getOrNull(1)?.toLongOrNull()?.let { seconds ->
val untilMs = System.currentTimeMillis() + seconds * 1000
builderRelayerQuotaBlockedUntilMs.set(untilMs)
logger.warn("Builder Relayer 配额已用尽,${seconds}秒内不再发起赎回")
}
}
/**
* Builder Relayer API 调用进行 429 限流重试指数退避
* HTTP 状态为 429Too Many Requests Cloudflare 1015时等待后重试避免瞬时限流导致赎回失败
*/
private suspend fun <T> withBuilderRelayerRateLimitRetry(block: suspend () -> Response<T>): Response<T> {
var lastResponse: Response<T>? = null
for (attempt in 1..builderRelayerRateLimitMaxAttempts) {
val response = block()
lastResponse = response
if (response.code() != 429) return response
if (attempt == builderRelayerRateLimitMaxAttempts) return response
val delayMs = builderRelayerRateLimitBackoffMs * (1L shl (attempt - 1))
logger.warn("Builder Relayer API 限流(429)${delayMs}ms 后重试 (${attempt}/${builderRelayerRateLimitMaxAttempts})")
delay(delayMs)
}
return lastResponse!!
}
/**
* 获取 Builder Relayer API 客户端动态获取因为配置可能更新
*/
@@ -115,6 +200,7 @@ class RelayClientService(
Result.success(responseTime)
} else {
val errorBody = response.errorBody()?.string() ?: "未知错误"
updateQuotaBlockedFromErrorBody(errorBody)
Result.failure(Exception("Builder Relayer API 调用失败: ${response.code()} - $errorBody"))
}
} catch (e: Exception) {
@@ -160,19 +246,22 @@ class RelayClientService(
/**
* 创建赎回交易支持多个 indexSets用于批量赎回
* 参考 TypeScript: utils/redeem.ts createRedeemTx
* Neg Risk 市场使用 WrappedCollateral 作为抵押品需传 isNegRisk=true
*
* @param conditionId 市场条件ID
* @param indexSets 索引集合列表每个元素是 2^outcomeIndex
* @param isNegRisk 是否为 Neg Risk 市场true 时使用 WrappedCollateral 地址
* @return Safe 交易对象
*/
fun createRedeemTx(conditionId: String, indexSets: List<BigInteger>): SafeTransaction {
fun createRedeemTx(conditionId: String, indexSets: List<BigInteger>, isNegRisk: Boolean = false): SafeTransaction {
// 编码 redeemPositions 函数调用
val functionSelector = EthereumUtils.getFunctionSelector(
"redeemPositions(address,bytes32,bytes32,uint256[])"
)
// 编码参数
val encodedCollateral = EthereumUtils.encodeAddress(usdcContractAddress)
// Neg Risk 市场仓位由 WrappedCollateral 抵押,普通市场由 USDC 抵押
val collateralAddress = if (isNegRisk) negRiskWrappedCollateralAddress else usdcContractAddress
val encodedCollateral = EthereumUtils.encodeAddress(collateralAddress)
val encodedParentCollection = EthereumUtils.encodeBytes32(EMPTY_SET)
val encodedConditionId = EthereumUtils.encodeBytes32(conditionId)
@@ -201,33 +290,189 @@ class RelayClientService(
}
/**
* 执行 Safe 交易通过 Proxy.execTransaction
* 参考 TypeScript: RelayClient.execute()
* 创建 WCOL 解包交易 Wrapped Collateral 执行解包
* 合约: Neg Risk WrappedCollateral 0x3A3BD7bb9528E159577F7C2e685CC81A765002E2
* 方法: unwrap(address _to, uint256 _amount)
*
* 优先使用 Builder RelayerGasless如果未配置则回退到手动发送交易
* Safe Magic 共用此交易对象Safe [executeViaBuilderRelayer] / [executeManually]execTransaction
* Magic [executeViaBuilderRelayerProxy]encodeProxyTransactionData语义一致
*
* @param toAddress 接收解包资产的地址通常为 proxy 自身使余额留在代理钱包
* @param amountWei WCOL 数量6 位小数对应的 raw balanceOf 返回一致
* @return Safe 交易对象
*/
fun createUnwrapWcolTx(toAddress: String, amountWei: BigInteger): SafeTransaction {
val functionSelector = EthereumUtils.getFunctionSelector("unwrap(address,uint256)")
val encodedTo = EthereumUtils.encodeAddress(toAddress)
val encodedAmount = EthereumUtils.encodeUint256(amountWei)
val callData = "0x" + functionSelector.removePrefix("0x") + encodedTo + encodedAmount
return SafeTransaction(
to = negRiskWrappedCollateralAddress,
operation = 0, // CALL
data = callData,
value = "0"
)
}
/**
* 创建 USDC approve 交易ERC20 approve(spender, amount)
* 用于 Polymarket 设置步骤3代币授权
*/
fun createUsdcApproveTx(spender: String, amount: BigInteger): SafeTransaction {
val functionSelector = EthereumUtils.getFunctionSelector("approve(address,uint256)")
val encodedSpender = EthereumUtils.encodeAddress(spender)
val encodedAmount = EthereumUtils.encodeUint256(amount)
val callData = "0x" + functionSelector.removePrefix("0x") + encodedSpender + encodedAmount
return SafeTransaction(
to = usdcContractAddress,
operation = 0, // CALL
data = callData,
value = "0"
)
}
/**
* 创建 USDC.e approve 交易用于 wrap pUSD
* 授权 CollateralOnramp 合约花费用户的 USDC.e
*/
fun createUsdceApproveForWrapTx(amount: BigInteger): SafeTransaction {
val functionSelector = EthereumUtils.getFunctionSelector("approve(address,uint256)")
val encodedSpender = EthereumUtils.encodeAddress(collateralOnrampAddress)
val encodedAmount = EthereumUtils.encodeUint256(amount)
val callData = "0x" + functionSelector.removePrefix("0x") + encodedSpender + encodedAmount
return SafeTransaction(
to = usdceContractAddress,
operation = 0,
data = callData,
value = "0"
)
}
/**
* 创建 USDC.e pUSD wrap 交易
* CollateralOnramp.wrap(address _asset, address _to, uint256 _amount)
*/
fun createWrapToPusdTx(recipientAddress: String, amount: BigInteger): SafeTransaction {
val functionSelector = EthereumUtils.getFunctionSelector("wrap(address,address,uint256)")
val asset = EthereumUtils.encodeAddress(usdceContractAddress)
val to = EthereumUtils.encodeAddress(recipientAddress)
val amt = EthereumUtils.encodeUint256(amount)
val callData = "0x" + functionSelector.removePrefix("0x") + asset + to + amt
return SafeTransaction(
to = collateralOnrampAddress,
operation = 0,
data = callData,
value = "0"
)
}
/**
* 创建 MultiSend 交易合并多个 SafeTransaction 为一笔交易
* 参考 TypeScript: builder-relayer-client/src/encode/safe.ts createSafeMultisendTransaction
*
* 使用 Gnosis Safe MultiSend 合约将多个交易合并为一笔 DelegateCall 交易
*
* @param safeTxs 多个 Safe 交易
* @return 合并后的 MultiSend 交易operation = 1 = DelegateCall
*/
fun createMultiSendTx(safeTxs: List<SafeTransaction>): SafeTransaction {
if (safeTxs.isEmpty()) {
throw IllegalArgumentException("safeTxs 不能为空")
}
// 单个交易直接返回,不需要 MultiSend
if (safeTxs.size == 1) {
logger.debug("单个交易,不使用 MultiSend")
return safeTxs.first()
}
logger.debug("创建 MultiSend 交易: ${safeTxs.size} 个交易待合并")
// MultiSend 函数选择器:multiSend(bytes)
val multiSendSelector = EthereumUtils.getFunctionSelector("multiSend(bytes)")
// 编码每个交易:encodePacked([uint8 operation, address to, uint256 value, uint256 dataLength, bytes data])
// 与 builder-relayer-client encode/safe.ts 完全一致
val encodedTransactions = safeTxs.map { tx ->
val operation = tx.operation.toByte()
// address: 20 字节,右对齐(取最后 40 个十六进制字符)
val toHex = tx.to.removePrefix("0x").lowercase().padStart(40, '0').takeLast(40)
val to = EthereumUtils.hexToBytes(toHex)
// value: 32 字节大端
val valueHex = BigInteger(tx.value).toString(16).padStart(64, '0')
val value = EthereumUtils.hexToBytes(valueHex)
val dataBytes = EthereumUtils.hexToBytes(tx.data.removePrefix("0x"))
// dataLength: 32 字节大端,表示 data 的字节数
val dataLengthHex = BigInteger.valueOf(dataBytes.size.toLong()).toString(16).padStart(64, '0')
val dataLength = EthereumUtils.hexToBytes(dataLengthHex)
// encodePacked: operation(1) + to(20) + value(32) + dataLength(32) + data(variable)
byteArrayOf(operation) + to + value + dataLength + dataBytes
}
// 拼接所有交易(无 padding,与 viem concatHex 一致)
val concatenatedTransactions = encodedTransactions.reduce { acc, bytes -> acc + bytes }
val totalDataLength = concatenatedTransactions.size
// multiSend(bytes) 的 ABI 编码:offset(32) + length(32) + data(按 32 字节对齐 padding)
val paddedLength = ((totalDataLength + 31) / 32) * 32
val paddedData = concatenatedTransactions + ByteArray(paddedLength - totalDataLength)
val encodedOffset = EthereumUtils.encodeUint256(BigInteger.valueOf(32))
val encodedLength = EthereumUtils.encodeUint256(BigInteger.valueOf(totalDataLength.toLong()))
val encodedData = paddedData.joinToString("") { "%02x".format(it) }
val callData = "0x" + multiSendSelector.removePrefix("0x") + encodedOffset + encodedLength + encodedData
return SafeTransaction(
to = safeMultisendAddress,
operation = 1, // DelegateCall
data = callData,
value = "0"
)
}
/**
* 执行代理交易Safe Magic PROXY
* 参考 TypeScript: RelayClient.execute()
*
* @param privateKey 私钥
* @param proxyAddress 代理钱包地址
* @param safeTx Safe 交易对象
* @param safeTx 交易对象to/data/value
* @param walletType 钱包类型MAGIC 使用 PROXY GaslessSAFE 使用 Safe 流程
* @return 交易哈希
*/
suspend fun execute(
privateKey: String,
proxyAddress: String,
safeTx: SafeTransaction
safeTx: SafeTransaction,
walletType: WalletType = WalletType.SAFE
): Result<String> {
return try {
// 验证参数
if (proxyAddress.isBlank() || !proxyAddress.startsWith("0x") || proxyAddress.length != 42) {
return Result.failure(IllegalArgumentException("proxyAddress 格式错误,必须是有效的以太坊地址"))
}
// 检查 Builder API Key 是否已配置
val builderApiKey = systemConfigService.getBuilderApiKey()
val builderSecret = systemConfigService.getBuilderSecret()
val builderPassphrase = systemConfigService.getBuilderPassphrase()
// 优先使用 Builder RelayerGasless
if (walletType == WalletType.MAGIC) {
if (!isBuilderRelayerEnabled(builderApiKey, builderSecret, builderPassphrase)) {
return Result.failure(IllegalStateException("Magic 账户赎回必须配置 Builder API KeyGasless"))
}
logger.info("使用 Builder Relayer PROXY 执行 Magic 赎回")
return executeViaBuilderRelayerProxy(
privateKey,
proxyAddress,
safeTx,
builderApiKey!!,
builderSecret!!,
builderPassphrase!!
)
}
if (isBuilderRelayerEnabled(builderApiKey, builderSecret, builderPassphrase)) {
logger.info("使用 Builder Relayer 执行 Gasless 交易")
return executeViaBuilderRelayer(
@@ -240,15 +485,251 @@ class RelayClientService(
)
}
// 回退到手动发送交易(需要用户支付 gas)
logger.info("Builder Relayer 未配置,使用手动发送交易(需要用户支付 gas)")
return executeManually(privateKey, proxyAddress, safeTx)
} catch (e: Exception) {
logger.error("执行 Safe 交易失败: ${e.message}", e)
logger.error("执行交易失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 通过 Builder Relayer 执行 PROXYMagic交易Gasless
* 参考: builder-relayer-client client.ts executeProxyTransactions, builder/proxy.ts
*/
private suspend fun executeViaBuilderRelayerProxy(
privateKey: String,
proxyAddress: String,
safeTx: SafeTransaction,
builderApiKey: String,
builderSecret: String,
builderPassphrase: String
): Result<String> {
val relayerApi = retrofitFactory.createBuilderRelayerApi(
relayerUrl = PolymarketConstants.BUILDER_RELAYER_URL,
apiKey = builderApiKey,
secret = builderSecret,
passphrase = builderPassphrase
)
val cleanPrivateKey = privateKey.removePrefix("0x")
val privateKeyBigInt = BigInteger(cleanPrivateKey, 16)
val credentials = org.web3j.crypto.Credentials.create(privateKeyBigInt.toString(16))
val fromAddress = credentials.address
val relayPayloadResponse = withBuilderRelayerRateLimitRetry { relayerApi.getRelayPayload(fromAddress, RELAYER_TYPE_PROXY) }
if (!relayPayloadResponse.isSuccessful || relayPayloadResponse.body() == null) {
val errorBody = relayPayloadResponse.errorBody()?.string() ?: "未知错误"
updateQuotaBlockedFromErrorBody(errorBody)
logger.error("获取 Relay Payload 失败: code=${relayPayloadResponse.code()}, body=$errorBody")
return Result.failure(Exception("获取 Relay Payload 失败: ${relayPayloadResponse.code()} - $errorBody"))
}
val relayPayload = relayPayloadResponse.body()!!
val relayAddress = relayPayload.address
val nonce = relayPayload.nonce
val proxyCallData = encodeProxyTransactionData(safeTx)
// 估算 gas limit(参考 builder-relayer-client builder/proxy.ts getGasLimit
val gasLimit = try {
val estimatedGasLimit = estimateProxyGasLimit(fromAddress, proxyFactoryAddress, proxyCallData)
val estimatedBigInt = BigInteger(estimatedGasLimit)
if (estimatedBigInt > maxProxyGasLimit) {
logger.warn(
"估算 PROXY gas limit 过大,进行截断: estimated=$estimatedGasLimit, capped=$maxProxyGasLimit"
)
maxProxyGasLimit.toString()
} else {
estimatedGasLimit
}
} catch (e: Exception) {
logger.warn("估算 PROXY gas limit 失败,使用默认值: ${e.message}", e)
defaultProxyGasLimit
}
val structHash = createProxyStructHash(
from = fromAddress,
to = proxyFactoryAddress,
data = proxyCallData,
txFee = "0",
gasPrice = "0",
gasLimit = gasLimit,
nonce = nonce,
relayHubAddress = relayHubAddress,
relayAddress = relayAddress
)
val prefix = "\u0019Ethereum Signed Message:\n32".toByteArray(Charsets.UTF_8)
val messageWithPrefix = ByteArray(prefix.size + structHash.size)
System.arraycopy(prefix, 0, messageWithPrefix, 0, prefix.size)
System.arraycopy(structHash, 0, messageWithPrefix, prefix.size, structHash.size)
val keccak256 = org.bouncycastle.crypto.digests.KeccakDigest(256)
keccak256.update(messageWithPrefix, 0, messageWithPrefix.size)
val hashWithPrefix = ByteArray(keccak256.digestSize)
keccak256.doFinal(hashWithPrefix, 0)
val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
val signature = org.web3j.crypto.Sign.signMessage(hashWithPrefix, ecKeyPair, false)
val sigHex = "0x" + org.web3j.utils.Numeric.toHexString(signature.r).removePrefix("0x").padStart(64, '0') +
org.web3j.utils.Numeric.toHexString(signature.s).removePrefix("0x").padStart(64, '0') +
String.format("%02x", (signature.v as ByteArray).getOrElse(0) { 0 }.toInt() and 0xff)
val request = BuilderRelayerApi.TransactionRequest(
type = RELAYER_TYPE_PROXY,
from = fromAddress,
to = proxyFactoryAddress,
proxyWallet = proxyAddress,
data = proxyCallData,
nonce = nonce,
signature = sigHex,
signatureParams = BuilderRelayerApi.SignatureParams(
gasPrice = "0",
gasLimit = gasLimit,
relayerFee = "0",
relayHub = relayHubAddress,
relay = relayAddress
),
metadata = "Redeem positions via Builder Relayer PROXY"
)
val response = withBuilderRelayerRateLimitRetry { relayerApi.submitTransaction(request) }
if (!response.isSuccessful || response.body() == null) {
val errorBody = response.errorBody()?.string() ?: "未知错误"
updateQuotaBlockedFromErrorBody(errorBody)
logger.error("Builder Relayer PROXY API 调用失败: code=${response.code()}, body=$errorBody")
return Result.failure(Exception("Builder Relayer PROXY 调用失败: ${response.code()} - $errorBody"))
}
val relayerResponse = response.body()!!
val txHash = relayerResponse.transactionHash ?: relayerResponse.hash
?: return Result.failure(Exception("Builder Relayer 返回的交易哈希为空"))
logger.info("Builder Relayer PROXY 执行成功: transactionID=${relayerResponse.transactionID}, txHash=$txHash")
return Result.success(txHash)
}
/**
* 编码 ProxyFactory.proxy(calls) 调用数据
* 参考: builder-relayer-client encode/proxy.ts, abis proxyFactory proxy((uint8,address,uint256,bytes)[])
*
* ABI 编码规则 tuple 数组中的 tuple 包含动态类型bytes需要先存储 tuple offset
* 结构
* - selector (4 bytes)
* - array offset (32 bytes) = 32
* - array length (32 bytes) = 1
* - tuple[0] offset (32 bytes) = 32 (指向 tuple 数据开始 array length 之后计算)
* - tuple[0] 数据
* - typeCode (32 bytes) = 1
* - to (32 bytes)
* - value (32 bytes) = 0
* - data offset (32 bytes) = 128 ( tuple 数据开始计算)
* - data length (32 bytes)
* - data (padded to 32-byte boundary)
*/
private fun encodeProxyTransactionData(safeTx: SafeTransaction): String {
val selector = EthereumUtils.getFunctionSelector("proxy((uint8,address,uint256,bytes)[])")
val callData = safeTx.data.removePrefix("0x")
val dataLen = callData.length / 2
val dataLenPadded = (dataLen + 31) / 32 * 32 * 2
val dataPadded = callData.padEnd(dataLenPadded, '0')
// ABI 编码:tuple 数组,tuple 包含动态类型 bytes
// 1. array offset: 32 (指向 array length)
val arrayOffset = EthereumUtils.encodeUint256(BigInteger.valueOf(32))
// 2. array length: 1
val arrayLength = EthereumUtils.encodeUint256(BigInteger.ONE)
// 3. tuple[0] offset: 32 (指向 tuple 数据开始,从 array length 之后计算)
val tupleOffset = EthereumUtils.encodeUint256(BigInteger.valueOf(32))
// 4. tuple[0] 数据:
// - typeCode: 1
val typeCode = EthereumUtils.encodeUint256(BigInteger.ONE)
// - to: address
val toEncoded = EthereumUtils.encodeAddress(safeTx.to)
// - value: 0
val valueEncoded = EthereumUtils.encodeUint256(BigInteger.ZERO)
// - data offset: 128 (从 tuple 数据开始计算,typeCode+to+value = 3*32 = 96,加上 offset 字段 = 128)
val dataOffsetInTuple = BigInteger.valueOf(128)
val dataOffsetEncoded = EthereumUtils.encodeUint256(dataOffsetInTuple)
// - data length
val dataLengthEncoded = EthereumUtils.encodeUint256(BigInteger.valueOf(dataLen.toLong()))
// - data (padded)
return "0x" + selector.removePrefix("0x") + arrayOffset + arrayLength +
tupleOffset + typeCode + toEncoded + valueEncoded + dataOffsetEncoded +
dataLengthEncoded + dataPadded
}
/**
* 估算 PROXY 交易的 gas limit
* 参考: builder-relayer-client builder/proxy.ts getGasLimit
*/
private suspend fun estimateProxyGasLimit(
from: String,
to: String,
data: String
): String {
val rpcApi = polygonRpcApi
val rpcRequest = JsonRpcRequest(
method = "eth_estimateGas",
params = listOf(
mapOf(
"from" to from,
"to" to to,
"data" to data
)
)
)
val response = rpcApi.call(rpcRequest)
if (!response.isSuccessful || response.body() == null) {
throw Exception("eth_estimateGas 调用失败: ${response.code()} ${response.message()}")
}
val rpcResponse = response.body()!!
if (rpcResponse.error != null) {
throw Exception("eth_estimateGas 返回错误: ${rpcResponse.error.message}")
}
val hexGasLimit = rpcResponse.result?.asString
?: throw Exception("eth_estimateGas 结果为空")
// 将十六进制转换为十进制字符串
val gasLimitBigInt = BigInteger(hexGasLimit.removePrefix("0x"), 16)
return gasLimitBigInt.toString()
}
/**
* 创建 PROXY 结构哈希参考 builder-relayer-client builder/proxy.ts createStructHash
* concat: "rlx:" + from + to + data + txFee + gasPrice + gasLimit + nonce + relayHub + relay, then keccak256
*/
private fun createProxyStructHash(
from: String,
to: String,
data: String,
txFee: String,
gasPrice: String,
gasLimit: String,
nonce: String,
relayHubAddress: String,
relayAddress: String
): ByteArray {
val rlxPrefix = "rlx:".toByteArray(Charsets.UTF_8)
val fromBytes = EthereumUtils.hexToBytes(from.lowercase().removePrefix("0x").padStart(40, '0'))
val toBytes = EthereumUtils.hexToBytes(to.lowercase().removePrefix("0x").padStart(40, '0'))
val dataBytes = EthereumUtils.hexToBytes(data.removePrefix("0x"))
val txFeeBytes = EthereumUtils.encodeUint256(BigInteger(txFee)).let { EthereumUtils.hexToBytes(it) }
val gasPriceBytes = EthereumUtils.encodeUint256(BigInteger(gasPrice)).let { EthereumUtils.hexToBytes(it) }
val gasLimitBytes = EthereumUtils.encodeUint256(BigInteger(gasLimit)).let { EthereumUtils.hexToBytes(it) }
val nonceBytes = EthereumUtils.encodeUint256(BigInteger(nonce)).let { EthereumUtils.hexToBytes(it) }
val relayHubBytes = EthereumUtils.hexToBytes(relayHubAddress.lowercase().removePrefix("0x").padStart(40, '0'))
val relayBytes = EthereumUtils.hexToBytes(relayAddress.lowercase().removePrefix("0x").padStart(40, '0'))
val concat = rlxPrefix + fromBytes + toBytes + dataBytes + txFeeBytes + gasPriceBytes +
gasLimitBytes + nonceBytes + relayHubBytes + relayBytes
return EthereumUtils.keccak256(concat)
}
/**
* 通过 Builder Relayer 执行交易Gasless
* 参考: builder-relayer-client/src/client.ts execute 方法
@@ -277,15 +758,27 @@ class RelayClientService(
// safeTx.data 已经是带 0x 前缀的完整调用数据
val redeemCallData = safeTx.data
// 获取 Proxy 的 nonce(通过 Builder Relayer API
val nonceResponse = relayerApi.getNonce(fromAddress, "SAFE")
// 获取 Proxy 的 nonce(通过 Builder Relayer API,遇 429 限流时重试
val nonceResponse = withBuilderRelayerRateLimitRetry { relayerApi.getNonce(fromAddress, RELAYER_TYPE_SAFE) }
if (!nonceResponse.isSuccessful || nonceResponse.body() == null) {
val errorBody = nonceResponse.errorBody()?.string() ?: "未知错误"
updateQuotaBlockedFromErrorBody(errorBody)
logger.error("获取 nonce 失败: code=${nonceResponse.code()}, body=$errorBody")
return Result.failure(Exception("获取 nonce 失败: ${nonceResponse.code()} - $errorBody"))
}
val proxyNonce = BigInteger(nonceResponse.body()!!.nonce)
// 调试 GS026:记录 nonce 与交易参数,便于与 relayer/链上对比
logger.debug(
"Safe exec 签名参数: nonce={}, to={}, value={}, dataLen={}, operation={}, proxyWallet={}",
proxyNonce,
safeTx.to,
safeTx.value,
redeemCallData.removePrefix("0x").length / 2,
safeTx.operation,
proxyAddress
)
// 构建 Safe 交易哈希并签名
// 注意:encodeSafeTx 需要 data 带 0x 前缀
val safeTxGas = BigInteger.ZERO
@@ -317,6 +810,12 @@ class RelayClientService(
messageHash = safeTxHash
)
// 调试 GS026:记录 EIP-712 structHash 与最终签名的 hash(可与 Safe.getTransactionHash 对比)
logger.debug(
"Safe exec 哈希: structHash=0x{}, hashToSign 将基于 prefix+structHash 的 keccak256",
safeTxStructuredHash.joinToString("") { "%02x".format(it) }
)
// 注意:ethers.js 的 signMessage 会添加 EIP-191 前缀
// 格式:\x19Ethereum Signed Message:\n<length><message>
// 我们需要模拟这个行为以匹配 TypeScript 实现
@@ -331,21 +830,21 @@ class RelayClientService(
val hashWithPrefix = ByteArray(keccak256.digestSize)
keccak256.doFinal(hashWithPrefix, 0)
logger.debug(
"Safe exec hashToSign=0x{} (personal_sign 后签名的 32 字节)",
hashWithPrefix.joinToString("") { "%02x".format(it) }
)
val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
val safeSignature = org.web3j.crypto.Sign.signMessage(hashWithPrefix, ecKeyPair, false)
// 打包签名(参考 builder-relayer-client/src/utils/index.ts 的 splitAndPackSig
val packedSignature = splitAndPackSig(safeSignature)
// 调试日志(地址已遮蔽)
logger.debug("=== Builder Relayer 签名调试 ===")
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 前缀
val request = BuilderRelayerApi.TransactionRequest(
type = "SAFE",
type = RELAYER_TYPE_SAFE,
from = fromAddress,
to = safeTx.to,
proxyWallet = proxyAddress,
@@ -360,16 +859,19 @@ class RelayClientService(
gasToken = gasToken,
refundReceiver = refundReceiver
),
metadata = "Redeem positions via Builder Relayer"
metadata = if (safeTx.operation == 1) {
"MultiSend redeem positions via Builder Relayer"
} else {
"Redeem positions via Builder Relayer"
}
)
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)
// 调用 Builder Relayer API(认证头通过拦截器添加,遇 429 限流时重试)
val response = withBuilderRelayerRateLimitRetry { relayerApi.submitTransaction(request) }
if (!response.isSuccessful || response.body() == null) {
val errorBody = response.errorBody()?.string() ?: "未知错误"
updateQuotaBlockedFromErrorBody(errorBody)
logger.error("Builder Relayer API 调用失败: code=${response.code()}, body=$errorBody")
return Result.failure(Exception("Builder Relayer API 调用失败: ${response.code()} - $errorBody"))
}
@@ -382,6 +884,104 @@ class RelayClientService(
return Result.success(txHash)
}
/**
* 通过 Builder Relayer 部署 Safe 代理SAFE-CREATE
* 参考: builder-relayer-client client.ts deploy()builder/create.ts buildSafeCreateTransactionRequest
*
* @param privateKey EOA 私钥
* @param proxyAddress 待部署的 Safe 代理地址 getProxyAddress 一致
* @param fromAddress EOA 地址from
* @return 交易哈希
*/
suspend fun deploySafeViaBuilderRelayer(
privateKey: String,
proxyAddress: String,
fromAddress: String
): Result<String> {
return try {
val builderApiKey = systemConfigService.getBuilderApiKey()
val builderSecret = systemConfigService.getBuilderSecret()
val builderPassphrase = systemConfigService.getBuilderPassphrase()
if (!isBuilderRelayerEnabled(builderApiKey, builderSecret, builderPassphrase)) {
return Result.failure(IllegalStateException("Builder API Key 未配置,无法执行 Safe 部署"))
}
val relayerApi = retrofitFactory.createBuilderRelayerApi(
relayerUrl = PolymarketConstants.BUILDER_RELAYER_URL,
apiKey = builderApiKey!!,
secret = builderSecret!!,
passphrase = builderPassphrase!!
)
val zeroAddress = "0x0000000000000000000000000000000000000000"
val paymentToken = zeroAddress
val payment = "0"
val paymentReceiver = zeroAddress
val domainSeparator = Eip712Encoder.encodeSafeCreateDomain(
name = PolymarketConstants.SAFE_FACTORY_EIP712_NAME,
chainId = 137L,
verifyingContract = safeProxyFactoryAddress
)
val createProxyHash = Eip712Encoder.encodeCreateProxyMessage(
paymentToken = paymentToken,
payment = BigInteger.ZERO,
paymentReceiver = paymentReceiver
)
val digest = Eip712Encoder.hashStructuredData(domainSeparator, createProxyHash)
val cleanPrivateKey = privateKey.removePrefix("0x")
val privateKeyBigInt = BigInteger(cleanPrivateKey, 16)
val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
val signature = org.web3j.crypto.Sign.signMessage(digest, ecKeyPair, false)
// SAFE-CREATE 使用标准 EIP-712 签名格式(0x + r + s + vv 为 27/28),与 signTypedData 一致
val signatureHex = signatureToStandardHex(signature)
val request = BuilderRelayerApi.TransactionRequest(
type = RELAYER_TYPE_SAFE_CREATE,
from = fromAddress,
to = safeProxyFactoryAddress,
proxyWallet = proxyAddress,
data = "0x",
nonce = null,
signature = signatureHex,
signatureParams = BuilderRelayerApi.SignatureParams(
paymentToken = paymentToken,
payment = payment,
paymentReceiver = paymentReceiver
),
metadata = null
)
val response = withBuilderRelayerRateLimitRetry { relayerApi.submitTransaction(request) }
if (!response.isSuccessful || response.body() == null) {
val errorBody = response.errorBody()?.string() ?: "未知错误"
updateQuotaBlockedFromErrorBody(errorBody)
logger.error("Builder Relayer SAFE-CREATE 失败: code=${response.code()}, body=$errorBody")
return Result.failure(Exception("部署 Safe 失败: ${response.code()} - $errorBody"))
}
val relayerResponse = response.body()!!
val txHash = relayerResponse.transactionHash ?: relayerResponse.hash
?: return Result.failure(Exception("Builder Relayer 返回的交易哈希为空"))
logger.info("Safe 部署成功: proxy=$proxyAddress, txHash=$txHash")
Result.success(txHash)
} catch (e: Exception) {
logger.error("部署 Safe 失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* SignatureData 转为标准 hex 签名0x + r(64) + s(64) + v(2)v 27/28
* 用于 SAFE-CREATE viem signTypedData 输出格式一致
*/
private fun signatureToStandardHex(signature: org.web3j.crypto.Sign.SignatureData): String {
val rHex = org.web3j.utils.Numeric.toHexString(signature.r).removePrefix("0x").padStart(64, '0')
val sHex = org.web3j.utils.Numeric.toHexString(signature.s).removePrefix("0x").padStart(64, '0')
val vBytes = signature.v
val v = if (vBytes != null && vBytes.isNotEmpty()) {
vBytes[0].toInt() and 0xff
} else {
27
}
val vHex = String.format("%02x", v)
return "0x$rHex$sHex$vHex"
}
/**
* 打包签名参考 builder-relayer-client/src/utils/index.ts splitAndPackSig
* 将签名打包成 Gnosis Safe 接受的格式encodePacked(["uint256", "uint256", "uint8"], [r, s, v])
@@ -25,19 +25,6 @@ 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)}"
}
}
}
/**
@@ -49,10 +36,10 @@ class SystemConfigService(
val builderPassphrase = getConfigValue(CONFIG_KEY_BUILDER_PASSPHRASE)
val autoRedeem = isAutoRedeemEnabled()
// 获取遮蔽后的显示值(仅显示部分字符,用于前端确认配置
// 获取完整显示值(用于前端展示与编辑
val builderApiKeyDisplay = builderApiKey?.let {
try {
maskSensitiveValue(cryptoUtils.decrypt(it))
cryptoUtils.decrypt(it)
} catch (e: Exception) {
null
}
@@ -60,7 +47,7 @@ class SystemConfigService(
val builderSecretDisplay = builderSecret?.let {
try {
maskSensitiveValue(cryptoUtils.decrypt(it))
cryptoUtils.decrypt(it)
} catch (e: Exception) {
null
}
@@ -68,7 +55,7 @@ class SystemConfigService(
val builderPassphraseDisplay = builderPassphrase?.let {
try {
maskSensitiveValue(cryptoUtils.decrypt(it))
cryptoUtils.decrypt(it)
} catch (e: Exception) {
null
}
@@ -26,6 +26,7 @@ import java.util.concurrent.TimeUnit
@Service
class TelegramNotificationService(
private val notificationConfigService: NotificationConfigService,
private val notificationTemplateService: NotificationTemplateService,
private val objectMapper: ObjectMapper,
private val messageSource: MessageSource
) {
@@ -85,7 +86,9 @@ class TelegramNotificationService(
marketId: String? = null,
marketSlug: String? = null,
side: String,
price: String? = null, // 订单价(可选,如果提供则直接使用
price: String? = null, // 订单价(可选)
avgFilledPrice: String? = null, // 平均成交价(可选,有成交时优先展示)
filled: String? = null, // 已成交数量(可选,与 avgFilledPrice 一起时用于金额计算)
size: String? = null, // 订单数量(可选,如果提供则直接使用)
outcome: String? = null, // 市场方向(可选,如果提供则直接使用)
accountName: String? = null,
@@ -98,7 +101,8 @@ class TelegramNotificationService(
locale: java.util.Locale? = null,
leaderName: String? = null, // Leader 名称(备注)
configName: String? = null, // 跟单配置名
orderTime: Long? = null // 订单创建时间(毫秒时间戳),用于通知中的时间显示
orderTime: Long? = null, // 订单创建时间(毫秒时间戳),用于通知中的时间显示
availableBalance: String? = null // 可用余额(可选)
) {
// 1. 如果提供了 orderId,检查是否已发送过通知(去重)
if (orderId != null) {
@@ -128,14 +132,21 @@ class TelegramNotificationService(
java.util.Locale("zh", "CN") // 默认简体中文
}
// 优先使用传入的价格和数量,如果没有提供则尝试从订单详情获取
var actualPrice: String? = price
// 优先使用平均成交价(实际成交价),其次传入的限价,若未提供则从订单详情获取
var actualPrice: String? = avgFilledPrice?.takeIf { it.isNotBlank() } ?: price
var actualSize: String? = size
var actualSide: String = side
var actualOutcome: String? = outcome
// 有平均成交价时,已成交数量优先用 filled,用于金额计算
val sizeForAmount: String? = if (avgFilledPrice != null && avgFilledPrice.isNotBlank() && filled != null && filled.isNotBlank()) {
filled
} else {
null
}
// 如果价格数量未提供,尝试从订单详情获取
if ((actualPrice == null || actualSize == null) && orderId != null && clobApi != null && apiKey != null && apiSecret != null && apiPassphrase != null && walletAddressForApi != null) {
// 如果价格数量或市场方向未提供,尝试从订单详情获取
if ((actualPrice == null || actualSize == null || actualOutcome == null) && orderId != null && clobApi != null && apiKey != null && apiSecret != null && apiPassphrase != null && walletAddressForApi != null) {
try {
val orderResponse = clobApi.getOrder(orderId)
if (orderResponse.isSuccessful) {
@@ -147,7 +158,8 @@ class TelegramNotificationService(
if (actualSize == null) {
actualSize = order.originalSize // 使用 originalSize 作为订单数量
}
actualSide = order.side // 使用订单详情中的 side
// 注意:不覆盖 side,因为传入的 side(BUY/SELL)是正确的
// actualSide = order.side // 不要使用订单详情中的 side,因为它可能不准确
if (actualOutcome == null) {
actualOutcome = order.outcome // 使用订单详情中的 outcome(市场方向)
}
@@ -165,19 +177,28 @@ class TelegramNotificationService(
// 如果仍然没有获取到实际值,使用默认值(这种情况不应该发生,但为了兼容性保留)
val finalPrice = actualPrice ?: "0"
val finalSize = actualSize ?: "0"
// 有实际成交价时展示数量用 size_matchedfilled),否则用订单数量(original_size
val finalSize = if (avgFilledPrice != null && avgFilledPrice.isNotBlank() && filled != null && filled.isNotBlank()) {
filled
} else {
actualSize ?: "0"
}
// 金额计算:有实际成交价和已成交数量时用二者乘积,否则用展示价格×订单数量
val sizeForCalc = sizeForAmount?.takeIf { it.isNotBlank() } ?: finalSize
// 计算订单金额 = price × sizeUSDC
val amount = try {
val priceDecimal = finalPrice.toSafeBigDecimal()
val sizeDecimal = finalSize.toSafeBigDecimal()
val sizeDecimal = sizeForCalc.toSafeBigDecimal()
priceDecimal.multiply(sizeDecimal).toString()
} catch (e: Exception) {
logger.warn("计算订单金额失败: ${e.message}", e)
null
}
val message = buildOrderSuccessMessage(
val unknownAccount = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", currentLocale).orEmpty().ifEmpty { "未知账户" }
val calculateFailed = messageSource.getMessage("notification.order.calculate_failed", null, "计算失败", currentLocale).orEmpty().ifEmpty { "计算失败" }
val vars = buildOrderSuccessVariables(
orderId = orderId,
marketTitle = marketTitle,
marketId = marketId,
@@ -192,8 +213,12 @@ class TelegramNotificationService(
locale = currentLocale,
leaderName = leaderName,
configName = configName,
orderTime = orderTime
orderTime = orderTime,
availableBalance = availableBalance,
unknownAccount = unknownAccount,
calculateFailed = calculateFailed
)
val message = notificationTemplateService.renderTemplate("ORDER_SUCCESS", vars)
sendMessage(message)
}
@@ -232,7 +257,9 @@ class TelegramNotificationService(
null
}
val message = buildOrderFailureMessage(
val unknownAccount = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", currentLocale).orEmpty().ifEmpty { "未知账户" }
val calculateFailed = messageSource.getMessage("notification.order.calculate_failed", null, "计算失败", currentLocale).orEmpty().ifEmpty { "计算失败" }
val vars = buildOrderFailureVariables(
marketTitle = marketTitle,
marketId = marketId,
marketSlug = marketSlug,
@@ -244,11 +271,65 @@ class TelegramNotificationService(
errorMessage = errorMessage,
accountName = accountName,
walletAddress = walletAddress,
locale = currentLocale
locale = currentLocale,
unknownAccount = unknownAccount,
calculateFailed = calculateFailed
)
val message = notificationTemplateService.renderTemplate("ORDER_FAILED", vars)
sendMessage(message)
}
/**
* 构建订单失败通知的变量 Map
*/
private fun buildOrderFailureVariables(
marketTitle: String,
marketId: String?,
marketSlug: String?,
side: String,
outcome: String?,
price: String,
size: String,
amount: String?,
errorMessage: String,
accountName: String?,
walletAddress: String?,
locale: java.util.Locale,
unknownAccount: String,
calculateFailed: String
): Map<String, String> {
val sideDisplay = when (side.uppercase()) {
"BUY" -> messageSource.getMessage("notification.order.side.buy", null, "买入", locale).orEmpty().ifEmpty { "买入" }
"SELL" -> messageSource.getMessage("notification.order.side.sell", null, "卖出", locale).orEmpty().ifEmpty { "卖出" }
else -> side
}
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
val marketLink = when {
!marketSlug.isNullOrBlank() -> "https://polymarket.com/event/$marketSlug"
!marketId.isNullOrBlank() && marketId.startsWith("0x") -> "https://polymarket.com/condition/$marketId"
else -> ""
}
val amountDisplay = amount?.let { am ->
try {
val amountDecimal = am.toSafeBigDecimal()
(if (amountDecimal.scale() > 4) amountDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else amountDecimal.stripTrailingZeros()).toPlainString()
} catch (e: Exception) { am }
} ?: calculateFailed
val shortError = if (errorMessage.length > 500) errorMessage.substring(0, 500) + "..." else errorMessage
return mapOf(
"market_title" to marketTitle.replace("<", "&lt;").replace(">", "&gt;"),
"market_link" to marketLink,
"side" to sideDisplay,
"outcome" to (outcome?.replace("<", "&lt;")?.replace(">", "&gt;") ?: ""),
"price" to formatPrice(price),
"quantity" to formatQuantity(size),
"amount" to amountDisplay,
"account_name" to accountInfo,
"error_message" to shortError.replace("<", "&lt;").replace(">", "&gt;"),
"time" to DateUtils.formatDateTime()
)
}
/**
* 发送订单被过滤通知
* @param locale 语言设置可选如果提供则使用否则使用 LocaleContextHolder 获取
@@ -285,7 +366,9 @@ class TelegramNotificationService(
null
}
val message = buildOrderFilteredMessage(
val unknownAccount = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", currentLocale).orEmpty().ifEmpty { "未知账户" }
val calculateFailed = messageSource.getMessage("notification.order.calculate_failed", null, "计算失败", currentLocale).orEmpty().ifEmpty { "计算失败" }
val vars = buildOrderFilteredVariables(
marketTitle = marketTitle,
marketId = marketId,
marketSlug = marketSlug,
@@ -298,11 +381,200 @@ class TelegramNotificationService(
filterType = filterType,
accountName = accountName,
walletAddress = walletAddress,
locale = currentLocale,
unknownAccount = unknownAccount,
calculateFailed = calculateFailed
)
val message = notificationTemplateService.renderTemplate("ORDER_FILTERED", vars)
sendMessage(message)
}
private fun buildOrderFilteredVariables(
marketTitle: String,
marketId: String?,
marketSlug: String?,
side: String,
outcome: String?,
price: String,
size: String,
amount: String?,
filterReason: String,
filterType: String,
accountName: String?,
walletAddress: String?,
locale: java.util.Locale,
unknownAccount: String,
calculateFailed: String
): Map<String, String> {
val sideDisplay = when (side.uppercase()) {
"BUY" -> messageSource.getMessage("notification.order.side.buy", null, "买入", locale).orEmpty().ifEmpty { "买入" }
"SELL" -> messageSource.getMessage("notification.order.side.sell", null, "卖出", locale).orEmpty().ifEmpty { "卖出" }
else -> side
}
val filterTypeDisplay = when (filterType.uppercase()) {
"ORDER_DEPTH" -> messageSource.getMessage("notification.filter.type.order_depth", null, "订单深度不足", locale).orEmpty().ifEmpty { "订单深度不足" }
"SPREAD" -> messageSource.getMessage("notification.filter.type.spread", null, "价差过大", locale).orEmpty().ifEmpty { "价差过大" }
"ORDERBOOK_DEPTH" -> messageSource.getMessage("notification.filter.type.orderbook_depth", null, "订单簿深度不足", locale).orEmpty().ifEmpty { "订单簿深度不足" }
"PRICE_VALIDITY" -> messageSource.getMessage("notification.filter.type.price_validity", null, "价格不合理", locale).orEmpty().ifEmpty { "价格不合理" }
"MARKET_STATUS" -> messageSource.getMessage("notification.filter.type.market_status", null, "市场状态不可交易", locale).orEmpty().ifEmpty { "市场状态不可交易" }
else -> filterType
}
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
val marketLink = when {
!marketSlug.isNullOrBlank() -> "https://polymarket.com/event/$marketSlug"
!marketId.isNullOrBlank() && marketId.startsWith("0x") -> "https://polymarket.com/condition/$marketId"
else -> ""
}
val amountDisplay = amount?.let { am ->
try {
(am.toSafeBigDecimal().let { if (it.scale() > 4) it.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else it.stripTrailingZeros() }.toPlainString())
} catch (e: Exception) { am }
} ?: calculateFailed
return mapOf(
"market_title" to marketTitle.replace("<", "&lt;").replace(">", "&gt;"),
"market_link" to marketLink,
"side" to sideDisplay,
"outcome" to (outcome?.replace("<", "&lt;")?.replace(">", "&gt;") ?: ""),
"price" to formatPrice(price),
"quantity" to formatQuantity(size),
"amount" to amountDisplay,
"account_name" to accountInfo,
"filter_type" to filterTypeDisplay,
"filter_reason" to filterReason.replace("<", "&lt;").replace(">", "&gt;"),
"time" to DateUtils.formatDateTime()
)
}
/**
* 发送加密价差策略下单成功通知与跟单一致在收到 WS 订单推送时匹配价差策略订单后调用
* @param price 订单限价
* @param avgFilledPrice 平均成交价可选有成交时优先展示
* @param filled 已成交数量可选 avgFilledPrice 一起时用于金额计算
*/
suspend fun sendCryptoTailOrderSuccessNotification(
orderId: String?,
marketTitle: String,
marketId: String? = null,
marketSlug: String? = null,
side: String,
outcome: String? = null,
price: String,
size: String,
avgFilledPrice: String? = null,
filled: String? = null,
strategyName: String? = null,
accountName: String? = null,
walletAddress: String? = null,
locale: java.util.Locale? = null,
orderTime: Long? = null
) {
if (orderId != null) {
val lastSentTime = sentOrderIds[orderId]
if (lastSentTime != null && System.currentTimeMillis() - lastSentTime < 5 * 60 * 1000) {
logger.info("加密价差策略订单通知已发送过(5分钟内),跳过: orderId=$orderId")
return
}
sentOrderIds[orderId] = System.currentTimeMillis()
if (sentOrderIds.size > 1000) {
val expiryTime = System.currentTimeMillis() - 5 * 60 * 1000
sentOrderIds.entries.removeIf { it.value < expiryTime }
}
}
val currentLocale = locale ?: try {
LocaleContextHolder.getLocale()
} catch (e: Exception) {
logger.warn("获取语言设置失败,使用默认语言: ${e.message}", e)
java.util.Locale("zh", "CN")
}
val displayPrice = avgFilledPrice?.takeIf { it.isNotBlank() } ?: price
val hasAvgFilled = avgFilledPrice != null && avgFilledPrice.isNotBlank() && filled != null && filled.isNotBlank()
val sizeForAmount = if (hasAvgFilled) filled else size
val quantityDisplay = if (hasAvgFilled) filled else size // 有实际成交价时展示数量用 size_matched
val amount = try {
val priceDecimal = displayPrice.toSafeBigDecimal()
val sizeDecimal = sizeForAmount.toSafeBigDecimal()
priceDecimal.multiply(sizeDecimal).toString()
} catch (e: Exception) {
logger.warn("计算订单金额失败: ${e.message}", e)
null
}
val unknown = messageSource.getMessage("common.unknown", null, "未知", currentLocale).orEmpty().ifEmpty { "未知" }
val unknownAccount = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", currentLocale).orEmpty().ifEmpty { "未知账户" }
val calculateFailed = messageSource.getMessage("notification.order.calculate_failed", null, "计算失败", currentLocale).orEmpty().ifEmpty { "计算失败" }
val vars = buildCryptoTailOrderSuccessVariables(
orderId = orderId,
marketTitle = marketTitle,
marketId = marketId,
marketSlug = marketSlug,
side = side,
outcome = outcome,
price = displayPrice,
size = quantityDisplay.orEmpty(),
amount = amount,
strategyName = strategyName,
accountName = accountName,
walletAddress = walletAddress,
orderTime = orderTime,
unknown = unknown,
unknownAccount = unknownAccount,
calculateFailed = calculateFailed,
locale = currentLocale
)
val message = notificationTemplateService.renderTemplate("CRYPTO_TAIL_SUCCESS", vars)
sendMessage(message)
}
private fun buildCryptoTailOrderSuccessVariables(
orderId: String?,
marketTitle: String,
marketId: String?,
marketSlug: String?,
side: String,
outcome: String?,
price: String,
size: String,
amount: String?,
strategyName: String?,
accountName: String?,
walletAddress: String?,
orderTime: Long?,
unknown: String,
unknownAccount: String,
calculateFailed: String,
locale: java.util.Locale
): Map<String, String> {
val sideDisplay = when (side.uppercase()) {
"BUY" -> messageSource.getMessage("notification.order.side.buy", null, "买入", locale).orEmpty().ifEmpty { "买入" }
"SELL" -> messageSource.getMessage("notification.order.side.sell", null, "卖出", locale).orEmpty().ifEmpty { "卖出" }
else -> side
}
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
val time = if (orderTime != null) DateUtils.formatDateTime(orderTime) else DateUtils.formatDateTime()
val marketLink = when {
!marketSlug.isNullOrBlank() -> "https://polymarket.com/event/$marketSlug"
!marketId.isNullOrBlank() && marketId.startsWith("0x") -> "https://polymarket.com/condition/$marketId"
else -> ""
}
val amountDisplay = amount?.let { am ->
try {
(am.toSafeBigDecimal().let { if (it.scale() > 4) it.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else it.stripTrailingZeros() }.toPlainString())
} catch (e: Exception) { am }
} ?: calculateFailed
return mapOf(
"order_id" to (orderId ?: unknown),
"market_title" to marketTitle.replace("<", "&lt;").replace(">", "&gt;"),
"market_link" to marketLink,
"side" to sideDisplay,
"outcome" to (outcome?.replace("<", "&lt;")?.replace(">", "&gt;") ?: ""),
"price" to formatPrice(price),
"quantity" to formatQuantity(size),
"amount" to amountDisplay,
"account_name" to accountInfo,
"strategy_name" to (strategyName?.takeIf { it.isNotBlank() } ?: unknown),
"time" to time
)
}
/**
* 构建订单被过滤消息
*/
@@ -419,7 +691,7 @@ class TelegramNotificationService(
$sideLabel: <b>$sideDisplay</b>
$priceLabel: <code>$priceDisplay</code>
$quantityLabel: <code>$sizeDisplay</code> shares
$amountLabel: <code>$amountDisplay</code> USDC
$amountLabel: <code>${'$'}$amountDisplay</code>
$accountLabel: $escapedAccountInfo
<b>$filterTypeLabel</b> <code>$filterTypeDisplay</code>
@@ -685,6 +957,76 @@ class TelegramNotificationService(
}
}
/**
* 构建订单成功通知的变量 Map供模板渲染
*/
private fun buildOrderSuccessVariables(
orderId: String?,
marketTitle: String,
marketId: String?,
marketSlug: String?,
side: String,
outcome: String?,
price: String,
size: String,
amount: String?,
accountName: String?,
walletAddress: String?,
locale: java.util.Locale,
leaderName: String?,
configName: String?,
orderTime: Long?,
availableBalance: String?,
unknownAccount: String,
calculateFailed: String
): Map<String, String> {
val sideDisplay = when (side.uppercase()) {
"BUY" -> messageSource.getMessage("notification.order.side.buy", null, "买入", locale).orEmpty().ifEmpty { "买入" }
"SELL" -> messageSource.getMessage("notification.order.side.sell", null, "卖出", locale).orEmpty().ifEmpty { "卖出" }
else -> side
}
val unknown = messageSource.getMessage("common.unknown", null, "未知", locale).orEmpty().ifEmpty { "未知" }
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
val time = if (orderTime != null) DateUtils.formatDateTime(orderTime) else DateUtils.formatDateTime()
val marketLink = when {
!marketSlug.isNullOrBlank() -> "https://polymarket.com/event/$marketSlug"
!marketId.isNullOrBlank() && marketId.startsWith("0x") -> "https://polymarket.com/condition/$marketId"
else -> ""
}
val amountDisplay = when {
amount != null -> try {
val amountDecimal = amount.toSafeBigDecimal()
val formatted = if (amountDecimal.scale() > 4) amountDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else amountDecimal.stripTrailingZeros()
formatted.toPlainString()
} catch (e: Exception) { amount ?: calculateFailed }
else -> calculateFailed
}
val availableBalanceDisplay = if (!availableBalance.isNullOrBlank()) {
try {
val balanceDecimal = availableBalance.toSafeBigDecimal()
val formatted = if (balanceDecimal.scale() > 4) balanceDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else balanceDecimal.stripTrailingZeros()
formatted.toPlainString()
} catch (e: Exception) { availableBalance ?: "" }
} else { "" }
val escapedMarketTitle = marketTitle.replace("<", "&lt;").replace(">", "&gt;")
val escapedOutcome = outcome?.replace("<", "&lt;")?.replace(">", "&gt;") ?: ""
return mapOf(
"order_id" to (orderId ?: unknown),
"market_title" to escapedMarketTitle,
"market_link" to marketLink,
"side" to sideDisplay,
"outcome" to escapedOutcome,
"price" to formatPrice(price),
"quantity" to formatQuantity(size),
"amount" to amountDisplay,
"account_name" to accountInfo,
"available_balance" to availableBalanceDisplay,
"leader_name" to (leaderName ?: ""),
"config_name" to (configName ?: ""),
"time" to time
)
}
/**
* 构建订单成功消息
*/
@@ -703,7 +1045,8 @@ class TelegramNotificationService(
locale: java.util.Locale,
leaderName: String? = null, // Leader 名称(备注)
configName: String? = null, // 跟单配置名
orderTime: Long? = null // 订单创建时间(毫秒时间戳)
orderTime: Long? = null, // 订单创建时间(毫秒时间戳)
availableBalance: String? = null // 可用余额
): String {
// 获取多语言文本
@@ -718,6 +1061,7 @@ class TelegramNotificationService(
val amountLabel = messageSource.getMessage("notification.order.amount", null, "金额", locale)
val accountLabel = messageSource.getMessage("notification.order.account", null, "账户", locale)
val timeLabel = messageSource.getMessage("notification.order.time", null, "时间", locale)
val availableBalanceLabel = messageSource.getMessage("notification.order.available_balance", null, "可用余额", locale)
val unknown = messageSource.getMessage("common.unknown", null, "未知", locale)
val unknownAccount: String = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", locale) ?: "未知账户"
val calculateFailed = messageSource.getMessage("notification.order.calculate_failed", null, "计算失败", locale)
@@ -816,6 +1160,23 @@ class TelegramNotificationService(
val priceDisplay = formatPrice(price)
val sizeDisplay = formatQuantity(size)
// 格式化可用余额
val availableBalanceDisplay = if (!availableBalance.isNullOrBlank()) {
try {
val balanceDecimal = availableBalance.toSafeBigDecimal()
val formatted = if (balanceDecimal.scale() > 4) {
balanceDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros()
} else {
balanceDecimal.stripTrailingZeros()
}
"\n$availableBalanceLabel: <code>${'$'}${formatted.toPlainString()}</code>"
} catch (e: Exception) {
"\n$availableBalanceLabel: <code>${'$'}$availableBalance</code>"
}
} else {
""
}
return """$icon <b>$orderCreatedSuccess</b>
📊 <b>$orderInfo</b>
@@ -824,8 +1185,87 @@ class TelegramNotificationService(
$sideLabel: <b>$sideDisplay</b>
$priceLabel: <code>$priceDisplay</code>
$quantityLabel: <code>$sizeDisplay</code> shares
$amountLabel: <code>$amountDisplay</code> USDC
$accountLabel: $escapedAccountInfo$escapedCopyTradingInfo
$amountLabel: <code>${'$'}$amountDisplay</code>
$accountLabel: $escapedAccountInfo$escapedCopyTradingInfo$availableBalanceDisplay
$timeLabel: <code>$time</code>"""
}
/**
* 构建加密价差策略下单成功消息与订单成功格式一致增加加密价差策略标题与策略名
*/
private fun buildCryptoTailOrderSuccessMessage(
orderId: String?,
marketTitle: String,
marketId: String?,
marketSlug: String?,
side: String,
outcome: String?,
price: String,
size: String,
amount: String?,
strategyName: String?,
accountName: String?,
walletAddress: String?,
locale: java.util.Locale,
orderTime: Long?
): String {
val tailOrderSuccess = messageSource.getMessage("notification.tail.order.success", null, "加密价差策略下单成功", locale)
val strategyLabel = messageSource.getMessage("notification.tail.strategy", null, "策略", locale)
val orderInfo = messageSource.getMessage("notification.order.info", null, "订单信息", locale)
val orderIdLabel = messageSource.getMessage("notification.order.id", null, "订单ID", locale)
val marketLabel = messageSource.getMessage("notification.order.market", null, "市场", locale)
val sideLabel = messageSource.getMessage("notification.order.side", null, "方向", locale)
val outcomeLabel = messageSource.getMessage("notification.order.outcome", null, "市场方向", locale)
val priceLabel = messageSource.getMessage("notification.order.price", null, "价格", locale)
val quantityLabel = messageSource.getMessage("notification.order.quantity", null, "数量", locale)
val amountLabel = messageSource.getMessage("notification.order.amount", null, "金额", locale)
val accountLabel = messageSource.getMessage("notification.order.account", null, "账户", locale)
val timeLabel = messageSource.getMessage("notification.order.time", null, "时间", locale)
val unknown: String = messageSource.getMessage("common.unknown", null, "未知", locale) ?: "未知"
val unknownAccount = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", locale) ?: "未知账户"
val calculateFailed = messageSource.getMessage("notification.order.calculate_failed", null, "计算失败", locale)
val sideDisplay = when (side.uppercase()) {
"BUY" -> messageSource.getMessage("notification.order.side.buy", null, "买入", locale)
"SELL" -> messageSource.getMessage("notification.order.side.sell", null, "卖出", locale)
else -> side
}
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
val time = if (orderTime != null) DateUtils.formatDateTime(orderTime) else DateUtils.formatDateTime()
val escapedMarketTitle = marketTitle.replace("<", "&lt;").replace(">", "&gt;")
val escapedAccountInfo = accountInfo.replace("<", "&lt;").replace(">", "&gt;")
val strategyDisplay = strategyName?.takeIf { it.isNotBlank() } ?: unknown
val escapedStrategyName = strategyDisplay.replace("<", "&lt;").replace(">", "&gt;")
val amountDisplay = if (amount != null) {
try {
val amountDecimal = amount.toSafeBigDecimal()
val formatted = if (amountDecimal.scale() > 4) amountDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else amountDecimal.stripTrailingZeros()
formatted.toPlainString()
} catch (e: Exception) { amount }
} else calculateFailed
val marketLink = when {
!marketSlug.isNullOrBlank() -> "https://polymarket.com/event/$marketSlug"
!marketId.isNullOrBlank() && marketId.startsWith("0x") -> "https://polymarket.com/condition/$marketId"
else -> null
}
val marketDisplay = if (marketLink != null) "<a href=\"$marketLink\">$escapedMarketTitle</a>" else escapedMarketTitle
val outcomeDisplay = if (!outcome.isNullOrBlank()) {
val escapedOutcome = outcome.replace("<", "&lt;").replace(">", "&gt;")
"\n$outcomeLabel: <b>$escapedOutcome</b>"
} else ""
val priceDisplay = formatPrice(price)
val sizeDisplay = formatQuantity(size)
return """🚀 <b>$tailOrderSuccess</b>
📊 <b>$orderInfo</b>
$orderIdLabel: <code>${orderId ?: unknown}</code>
$strategyLabel: $escapedStrategyName
$marketLabel: $marketDisplay$outcomeDisplay
$sideLabel: <b>$sideDisplay</b>
$priceLabel: <code>$priceDisplay</code>
$quantityLabel: <code>$sizeDisplay</code> shares
$amountLabel: <code>${'$'}$amountDisplay</code>
$accountLabel: $escapedAccountInfo
$timeLabel: <code>$time</code>"""
}
@@ -941,7 +1381,7 @@ class TelegramNotificationService(
$sideLabel: <b>$sideDisplay</b>
$priceLabel: <code>$priceDisplay</code>
$quantityLabel: <code>$sizeDisplay</code> shares
$amountLabel: <code>$amountDisplay</code> USDC
$amountLabel: <code>${'$'}$amountDisplay</code>
$accountLabel: $escapedAccountInfo
<b>$errorInfo</b>
@@ -953,6 +1393,7 @@ class TelegramNotificationService(
/**
* 发送仓位赎回通知
* @param locale 语言设置可选如果提供则使用否则使用 LocaleContextHolder 获取
* @param availableBalance 可用余额可选
*/
suspend fun sendRedeemNotification(
accountName: String?,
@@ -960,7 +1401,8 @@ class TelegramNotificationService(
transactionHash: String,
totalRedeemedValue: String,
positions: List<com.wrbug.polymarketbot.dto.RedeemedPositionInfo>,
locale: java.util.Locale? = null
locale: java.util.Locale? = null,
availableBalance: String? = null
) {
// 获取语言设置(优先使用传入的 locale,否则从 LocaleContextHolder 获取)
val currentLocale = locale ?: try {
@@ -970,16 +1412,46 @@ class TelegramNotificationService(
java.util.Locale("zh", "CN") // 默认简体中文
}
val message = buildRedeemMessage(
val unknownAccount = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", currentLocale) ?: "未知账户"
val vars = buildRedeemSuccessVariables(
accountName = accountName,
walletAddress = walletAddress,
transactionHash = transactionHash,
totalRedeemedValue = totalRedeemedValue,
positions = positions,
locale = currentLocale
availableBalance = availableBalance,
unknownAccount = unknownAccount
)
val message = notificationTemplateService.renderTemplate("REDEEM_SUCCESS", vars)
sendMessage(message)
}
private fun buildRedeemSuccessVariables(
accountName: String?,
walletAddress: String?,
transactionHash: String,
totalRedeemedValue: String,
availableBalance: String?,
unknownAccount: String
): Map<String, String> {
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
val totalValueDisplay = try {
val d = totalRedeemedValue.toSafeBigDecimal()
(if (d.scale() > 4) d.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else d.stripTrailingZeros()).toPlainString()
} catch (e: Exception) { totalRedeemedValue }
val availableBalanceDisplay = availableBalance?.let { ab ->
try {
val d = ab.toSafeBigDecimal()
(if (d.scale() > 4) d.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else d.stripTrailingZeros()).toPlainString()
} catch (e: Exception) { ab }
} ?: ""
return mapOf(
"account_name" to accountInfo,
"transaction_hash" to transactionHash.replace("<", "&lt;").replace(">", "&gt;"),
"total_value" to totalValueDisplay,
"available_balance" to availableBalanceDisplay,
"time" to DateUtils.formatDateTime()
)
}
/**
* 构建仓位赎回消息
@@ -990,7 +1462,8 @@ class TelegramNotificationService(
transactionHash: String,
totalRedeemedValue: String,
positions: List<com.wrbug.polymarketbot.dto.RedeemedPositionInfo>,
locale: java.util.Locale
locale: java.util.Locale,
availableBalance: String? = null
): String {
// 获取多语言文本
val redeemSuccess = messageSource.getMessage("notification.redeem.success", null, "仓位赎回成功", locale)
@@ -1003,6 +1476,7 @@ class TelegramNotificationService(
val quantityLabel = messageSource.getMessage("notification.order.quantity", null, "数量", locale)
val valueLabel = messageSource.getMessage("notification.order.amount", null, "金额", locale)
val timeLabel = messageSource.getMessage("notification.order.time", null, "时间", locale)
val availableBalanceLabel = messageSource.getMessage("notification.redeem.available_balance", null, "可用余额", locale)
val unknownAccount: String = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", locale) ?: "未知账户"
// 构建账户信息(格式:账户名(钱包地址))
@@ -1041,7 +1515,24 @@ class TelegramNotificationService(
} catch (e: Exception) {
position.value
}
"${position.marketId.substring(0, 8)}... (${position.side}): $quantityDisplay shares = $valueDisplay USDC"
"${position.marketId.substring(0, 8)}... (${position.side}): $quantityDisplay shares = ${'$'}$valueDisplay"
}
// 格式化可用余额
val availableBalanceDisplay = if (!availableBalance.isNullOrBlank()) {
try {
val balanceDecimal = availableBalance.toSafeBigDecimal()
val formatted = if (balanceDecimal.scale() > 4) {
balanceDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros()
} else {
balanceDecimal.stripTrailingZeros()
}
"\n$availableBalanceLabel: <code>${'$'}${formatted.toPlainString()}</code>"
} catch (e: Exception) {
"\n$availableBalanceLabel: <code>${'$'}$availableBalance</code>"
}
} else {
""
}
return """💸 <b>$redeemSuccess</b>
@@ -1049,14 +1540,130 @@ class TelegramNotificationService(
📊 <b>$redeemInfo</b>
$accountLabel: $escapedAccountInfo
$transactionHashLabel: <code>$escapedTxHash</code>
$totalValueLabel: <code>$totalValueDisplay</code> USDC
$totalValueLabel: <code>${'$'}$totalValueDisplay</code>$availableBalanceDisplay
📦 <b>$positionsLabel</b>
$positionsText
$timeLabel: <code>$time</code>"""
}
/**
* 发送仓位已结算无收益通知
* 用于输的仓位赎回价值为 0 的情况
*/
suspend fun sendRedeemNoReturnNotification(
accountName: String?,
walletAddress: String?,
transactionHash: String,
positions: List<com.wrbug.polymarketbot.dto.RedeemedPositionInfo>,
locale: java.util.Locale? = null,
availableBalance: String? = null
) {
val currentLocale = locale ?: try {
LocaleContextHolder.getLocale()
} catch (e: Exception) {
logger.warn("获取语言设置失败,使用默认语言: ${e.message}", e)
java.util.Locale("zh", "CN")
}
val unknownAccount = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", currentLocale) ?: "未知账户"
val vars = buildRedeemNoReturnVariables(
accountName = accountName,
walletAddress = walletAddress,
transactionHash = transactionHash,
availableBalance = availableBalance,
unknownAccount = unknownAccount
)
val message = notificationTemplateService.renderTemplate("REDEEM_NO_RETURN", vars)
sendMessage(message)
}
private fun buildRedeemNoReturnVariables(
accountName: String?,
walletAddress: String?,
transactionHash: String,
availableBalance: String?,
unknownAccount: String
): Map<String, String> {
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
val availableBalanceDisplay = availableBalance?.let { ab ->
try {
val d = ab.toSafeBigDecimal()
(if (d.scale() > 4) d.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else d.stripTrailingZeros()).toPlainString()
} catch (e: Exception) { ab }
} ?: ""
return mapOf(
"account_name" to accountInfo,
"transaction_hash" to transactionHash.replace("<", "&lt;").replace(">", "&gt;"),
"available_balance" to availableBalanceDisplay,
"time" to DateUtils.formatDateTime()
)
}
/**
* 构建仓位已结算无收益消息
*/
private fun buildRedeemNoReturnMessage(
accountName: String?,
walletAddress: String?,
transactionHash: String,
positions: List<com.wrbug.polymarketbot.dto.RedeemedPositionInfo>,
locale: java.util.Locale,
availableBalance: String? = null
): String {
val noReturnTitle = messageSource.getMessage("notification.redeem.no_return.title", null, "仓位已结算(无收益)", locale)
val noReturnInfo = messageSource.getMessage("notification.redeem.no_return.info", null, "结算信息", locale)
val noReturnMessage = messageSource.getMessage("notification.redeem.no_return.message", null, "市场已结算,您的预测未命中,赎回价值为 0。", locale)
val accountLabel = messageSource.getMessage("notification.order.account", null, "账户", locale)
val transactionHashLabel = messageSource.getMessage("notification.redeem.transaction_hash", null, "交易哈希", locale)
val positionsLabel = messageSource.getMessage("notification.redeem.no_return.positions", null, "结算仓位", locale)
val timeLabel = messageSource.getMessage("notification.order.time", null, "时间", locale)
val availableBalanceLabel = messageSource.getMessage("notification.redeem.available_balance", null, "可用余额", locale)
val unknownAccount: String = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", locale) ?: "未知账户"
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
val time = DateUtils.formatDateTime()
val escapedAccountInfo = accountInfo.replace("<", "&lt;").replace(">", "&gt;")
val escapedTxHash = transactionHash.replace("<", "&lt;").replace(">", "&gt;")
val positionsText = positions.joinToString("\n") { position ->
val quantityDisplay = formatQuantity(position.quantity)
"${position.marketId.substring(0, 8)}... (${position.side}): $quantityDisplay shares"
}
// 格式化可用余额
val availableBalanceDisplay = if (!availableBalance.isNullOrBlank()) {
try {
val balanceDecimal = availableBalance.toSafeBigDecimal()
val formatted = if (balanceDecimal.scale() > 4) {
balanceDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros()
} else {
balanceDecimal.stripTrailingZeros()
}
"\n$availableBalanceLabel: <code>${'$'}${formatted.toPlainString()}</code>"
} catch (e: Exception) {
"\n$availableBalanceLabel: <code>${'$'}$availableBalance</code>"
}
} else {
""
}
return """📋 <b>$noReturnTitle</b>
📊 <b>$noReturnInfo</b>
<i>$noReturnMessage</i>
$accountLabel: $escapedAccountInfo
$transactionHashLabel: <code>$escapedTxHash</code>$availableBalanceDisplay
📦 <b>$positionsLabel</b>
$positionsText
$timeLabel: <code>$time</code>"""
}
/**
* 脱敏显示地址只显示前6位和后4位
*/
@@ -167,9 +167,8 @@ object Eip712Encoder {
}
/**
* 编码 ExchangeOrder 域分隔符
* 参考: @polymarket/order-utils ExchangeOrderBuilder
* Domain: { name: "Polymarket CTF Exchange", version: "1", chainId: chainId, verifyingContract: exchangeContract }
* 编码 ExchangeOrder V2 域分隔符
* Domain: { name: "Polymarket CTF Exchange", version: "2", chainId: chainId, verifyingContract: exchangeContract }
*/
fun encodeExchangeDomain(
chainId: Long,
@@ -184,9 +183,9 @@ object Eip712Encoder {
"verifyingContract" to "address"
)
)
val nameHash = encodeString("Polymarket CTF Exchange")
val versionHash = encodeString("1")
val versionHash = encodeString("2")
val chainIdBytes = encodeUint256(BigInteger.valueOf(chainId))
val contractBytes = encodeAddress(verifyingContract)
@@ -201,23 +200,21 @@ object Eip712Encoder {
}
/**
* 编码 ExchangeOrder 消息哈希
* 参考: @polymarket/order-utils ExchangeOrderBuilder
* Order: { salt, maker, signer, taker, tokenId, makerAmount, takerAmount, expiration, nonce, feeRateBps, side, signatureType }
* 编码 ExchangeOrder V2 消息哈希
* V2 Order: { salt, maker, signer, tokenId, makerAmount, takerAmount, side, signatureType, timestamp, metadata, builder }
*/
fun encodeExchangeOrder(
salt: Long,
maker: String,
signer: String,
taker: String,
tokenId: String,
makerAmount: String,
takerAmount: String,
expiration: String,
nonce: String,
feeRateBps: String,
side: String,
signatureType: Int
signatureType: Int,
timestamp: String,
metadata: String,
builder: String
): ByteArray {
val orderTypeHash = encodeType(
"Order",
@@ -225,57 +222,51 @@ object Eip712Encoder {
"salt" to "uint256",
"maker" to "address",
"signer" to "address",
"taker" to "address",
"tokenId" to "uint256",
"makerAmount" to "uint256",
"takerAmount" to "uint256",
"expiration" to "uint256",
"nonce" to "uint256",
"feeRateBps" to "uint256",
"side" to "uint8",
"signatureType" to "uint8"
"signatureType" to "uint8",
"timestamp" to "uint256",
"metadata" to "bytes32",
"builder" to "bytes32"
)
)
// 编码订单字段
val saltBytes = encodeUint256(BigInteger.valueOf(salt))
val makerBytes = encodeAddress(maker)
val signerBytes = encodeAddress(signer)
val takerBytes = encodeAddress(taker)
val tokenIdBytes = encodeUint256(BigInteger(tokenId))
val makerAmountBytes = encodeUint256(BigInteger(makerAmount))
val takerAmountBytes = encodeUint256(BigInteger(takerAmount))
val expirationBytes = encodeUint256(BigInteger(expiration))
val nonceBytes = encodeUint256(BigInteger(nonce))
val feeRateBpsBytes = encodeUint256(BigInteger(feeRateBps))
// side: BUY = 0, SELL = 1 (uint8,但需要编码为 32 字节)
val sideValue = when (side.uppercase()) {
"BUY" -> 0
"SELL" -> 1
else -> throw IllegalArgumentException("side 必须是 BUY 或 SELL")
}
// uint8 类型,但 EIP-712 编码时仍需要 32 字节
val sideBytes = encodeUint256(BigInteger.valueOf(sideValue.toLong()))
val signatureTypeBytes = encodeUint256(BigInteger.valueOf(signatureType.toLong()))
// 组合所有字段
val encoded = ByteArray(32 * 13) // 13 个字段,每个 32 字节
val timestampBytes = encodeUint256(BigInteger(timestamp))
val metadataBytes = Numeric.hexStringToByteArray(metadata.removePrefix("0x").padStart(64, '0'))
val builderBytes = Numeric.hexStringToByteArray(builder.removePrefix("0x").padStart(64, '0'))
val encoded = ByteArray(32 * 12) // typeHash + 11 个字段
var offset = 0
System.arraycopy(orderTypeHash, 0, encoded, offset, 32); offset += 32
System.arraycopy(saltBytes, 0, encoded, offset, 32); offset += 32
System.arraycopy(makerBytes, 0, encoded, offset, 32); offset += 32
System.arraycopy(signerBytes, 0, encoded, offset, 32); offset += 32
System.arraycopy(takerBytes, 0, encoded, offset, 32); offset += 32
System.arraycopy(tokenIdBytes, 0, encoded, offset, 32); offset += 32
System.arraycopy(makerAmountBytes, 0, encoded, offset, 32); offset += 32
System.arraycopy(takerAmountBytes, 0, encoded, offset, 32); offset += 32
System.arraycopy(expirationBytes, 0, encoded, offset, 32); offset += 32
System.arraycopy(nonceBytes, 0, encoded, offset, 32); offset += 32
System.arraycopy(feeRateBpsBytes, 0, encoded, offset, 32); offset += 32
System.arraycopy(sideBytes, 0, encoded, offset, 32); offset += 32
System.arraycopy(signatureTypeBytes, 0, encoded, offset, 32)
System.arraycopy(signatureTypeBytes, 0, encoded, offset, 32); offset += 32
System.arraycopy(timestampBytes, 0, encoded, offset, 32); offset += 32
System.arraycopy(metadataBytes, 0, encoded, offset, 32); offset += 32
System.arraycopy(builderBytes, 0, encoded, offset, 32)
return keccak256(encoded)
}
@@ -377,5 +368,62 @@ object Eip712Encoder {
return keccak256(encoded)
}
/**
* SafeCreate EIP712 Polymarket Contract Proxy Factory
* Domain: EIP712Domain(string name, uint256 chainId, address verifyingContract)
* 参考: builder-relayer-client/src/builder/create.ts createSafeCreateSignature
*/
fun encodeSafeCreateDomain(
name: String,
chainId: Long,
verifyingContract: String
): ByteArray {
val domainTypeHash = encodeType(
"EIP712Domain",
listOf(
"name" to "string",
"chainId" to "uint256",
"verifyingContract" to "address"
)
)
val nameHash = encodeString(name)
val chainIdBytes = encodeUint256(BigInteger.valueOf(chainId))
val contractBytes = encodeAddress(verifyingContract)
val encoded = ByteArray(32 + 32 + 32 + 32)
System.arraycopy(domainTypeHash, 0, encoded, 0, 32)
System.arraycopy(nameHash, 0, encoded, 32, 32)
System.arraycopy(chainIdBytes, 0, encoded, 64, 32)
System.arraycopy(contractBytes, 0, encoded, 96, 32)
return keccak256(encoded)
}
/**
* CreateProxy 消息哈希SafeCreate 签名用
* CreateProxy(address paymentToken, uint256 payment, address paymentReceiver)
*/
fun encodeCreateProxyMessage(
paymentToken: String,
payment: BigInteger,
paymentReceiver: String
): ByteArray {
val typeHash = encodeType(
"CreateProxy",
listOf(
"paymentToken" to "address",
"payment" to "uint256",
"paymentReceiver" to "address"
)
)
val tokenBytes = encodeAddress(paymentToken)
val paymentBytes = encodeUint256(payment)
val receiverBytes = encodeAddress(paymentReceiver)
val encoded = ByteArray(32 + 32 + 32 + 32)
System.arraycopy(typeHash, 0, encoded, 0, 32)
System.arraycopy(tokenBytes, 0, encoded, 32, 32)
System.arraycopy(paymentBytes, 0, encoded, 64, 32)
System.arraycopy(receiverBytes, 0, encoded, 96, 32)
return keccak256(encoded)
}
}
@@ -10,7 +10,7 @@ import java.math.BigInteger
object EthereumUtils {
// Polymarket 合约地址(Polygon 主网)
private val COLLATERAL_TOKEN_ADDRESS = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174" // USDC
private val COLLATERAL_TOKEN_ADDRESS = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB" // pUSD
private val CONDITIONAL_TOKENS_ADDRESS = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045" // ConditionalTokens
/**
@@ -1,6 +1,7 @@
package com.wrbug.polymarketbot.util
import com.google.gson.Gson
import com.wrbug.polymarketbot.api.BinanceApi
import com.wrbug.polymarketbot.api.BuilderRelayerApi
import com.wrbug.polymarketbot.api.EthereumRpcApi
import com.wrbug.polymarketbot.api.GitHubApi
@@ -300,7 +301,18 @@ class RetrofitFactory(
fun createDataApi(): PolymarketDataApi {
return dataApi
}
private val binanceApi: BinanceApi by lazy {
Retrofit.Builder()
.baseUrl("https://api.binance.com/")
.client(sharedOkHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(BinanceApi::class.java)
}
fun createBinanceApi(): BinanceApi = binanceApi
/**
* 创建 Builder Relayer API 客户端
* relayerUrl 缓存避免重复创建
@@ -388,8 +400,6 @@ class RpcUrlReplaceInterceptor(
private val fixedBaseUrl: String,
private val actualRpcUrl: String
) : Interceptor {
private val logger = LoggerFactory.getLogger(RpcUrlReplaceInterceptor::class.java)
@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
@@ -403,8 +413,6 @@ class RpcUrlReplaceInterceptor(
val newUrl = newUrlString.toHttpUrlOrNull()
?: throw IllegalArgumentException("无效的 RPC URL: $newUrlString")
logger.debug("RPC URL 替换: $originalUrlString -> $newUrlString")
val newRequest = originalRequest.newBuilder()
.url(newUrl)
.build()
@@ -193,7 +193,7 @@ class UnifiedWebSocketHandler(
lastActivityTime.remove(sessionId)
sessionLocks.remove(sessionId) // 清理同步锁
subscriptionService.unregisterSession(sessionId)
if (session != null && session.isOpen) {
try {
session.close(CloseStatus.NORMAL)
@@ -201,7 +201,6 @@ class UnifiedWebSocketHandler(
// 忽略关闭时的异常
}
}
} catch (e: Exception) {
logger.error("清理 WebSocket 资源时发生错误: $sessionId, ${e.message}", e)
}
@@ -0,0 +1,9 @@
-- ============================================
-- V32: 添加回测价格区间过滤字段
-- 用于配置价格区间,仅在指定价格区间内的订单才会跟单
-- ============================================
-- 添加价格区间字段到回测任务表
ALTER TABLE backtest_task
ADD COLUMN min_price DECIMAL(20, 8) NULL COMMENT '最低价格(可选),NULL表示不限制最低价',
ADD COLUMN max_price DECIMAL(20, 8) NULL COMMENT '最高价格(可选),NULL表示不限制最高价';
@@ -0,0 +1,38 @@
-- ============================================
-- V33: 唯一约束从 wallet_address 改为 proxy_address
-- 允许同一 EOA 以不同代理类型(Magic/Safe)各导入一个账户,按代理地址去重
-- ============================================
-- 将已存在账户的 wallet_type 统一为 safe(历史数据兼容)
UPDATE wallet_accounts SET wallet_type = 'safe';
-- 删除 wallet_address 上的唯一约束(通过 KEY_COLUMN_USAGE 定位到该列的约束名)
SET @uk_name = (SELECT kcu.CONSTRAINT_NAME
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc
ON kcu.TABLE_SCHEMA = tc.TABLE_SCHEMA AND kcu.TABLE_NAME = tc.TABLE_NAME AND kcu.CONSTRAINT_NAME = tc.CONSTRAINT_NAME
WHERE kcu.TABLE_SCHEMA = DATABASE()
AND kcu.TABLE_NAME = 'wallet_accounts'
AND tc.CONSTRAINT_TYPE = 'UNIQUE'
AND kcu.COLUMN_NAME = 'wallet_address'
LIMIT 1);
SET @sql = IF(@uk_name IS NOT NULL,
CONCAT('ALTER TABLE wallet_accounts DROP INDEX ', @uk_name),
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 为 proxy_address 添加唯一约束(若已存在则跳过)
SET @uk_exists = (SELECT 1 FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'wallet_accounts'
AND CONSTRAINT_TYPE = 'UNIQUE'
AND CONSTRAINT_NAME = 'uk_wallet_accounts_proxy_address'
LIMIT 1);
SET @sql2 = IF(@uk_exists IS NULL,
'ALTER TABLE wallet_accounts ADD UNIQUE KEY uk_wallet_accounts_proxy_address (proxy_address)',
'SELECT 1');
PREPARE stmt2 FROM @sql2;
EXECUTE stmt2;
DEALLOCATE PREPARE stmt2;
@@ -0,0 +1,43 @@
-- ============================================
-- V34: 加密市场尾盘策略表
-- ============================================
CREATE TABLE IF NOT EXISTS crypto_tail_strategy (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '策略ID',
account_id BIGINT NOT NULL COMMENT '钱包账户ID',
name VARCHAR(255) DEFAULT NULL COMMENT '策略名称(可选,用于列表展示)',
market_slug_prefix VARCHAR(64) NOT NULL COMMENT '市场 slug 前缀,如 btc-updown-5m、btc-updown-15m',
interval_seconds INT NOT NULL COMMENT '周期长度秒数:300(5分钟) 或 900(15分钟)',
window_start_seconds INT NOT NULL COMMENT '时间窗口开始秒数(相对周期起点)',
window_end_seconds INT NOT NULL COMMENT '时间窗口结束秒数(相对周期起点)',
min_price DECIMAL(20, 8) NOT NULL COMMENT '最低触发价格 0~1',
max_price DECIMAL(20, 8) NOT NULL DEFAULT 1 COMMENT '最高触发价格 0~1,默认1',
amount_mode VARCHAR(10) NOT NULL DEFAULT 'RATIO' COMMENT '投入方式: RATIO=按比例, FIXED=固定金额',
amount_value DECIMAL(20, 8) NOT NULL COMMENT '比例(0~100)或固定USDC金额',
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用: 0=停用, 1=启用',
created_at BIGINT NOT NULL COMMENT '创建时间',
updated_at BIGINT NOT NULL COMMENT '更新时间',
INDEX idx_account_id (account_id),
INDEX idx_enabled (enabled),
FOREIGN KEY (account_id) REFERENCES wallet_accounts(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='加密市场尾盘策略表';
-- ============================================
-- 触发记录表
-- ============================================
CREATE TABLE IF NOT EXISTS crypto_tail_strategy_trigger (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '记录ID',
strategy_id BIGINT NOT NULL COMMENT '策略ID',
period_start_unix BIGINT NOT NULL COMMENT '周期起点 Unix 秒',
market_title VARCHAR(500) DEFAULT NULL COMMENT '市场标题',
outcome_index INT NOT NULL COMMENT '方向: 0=Up, 1=Down',
trigger_price DECIMAL(20, 8) NOT NULL COMMENT '触发时价格',
amount_usdc DECIMAL(20, 8) NOT NULL COMMENT '投入金额 USDC',
order_id VARCHAR(128) DEFAULT NULL COMMENT '订单ID(成功时有值)',
status VARCHAR(20) NOT NULL DEFAULT 'success' COMMENT '状态: success, fail',
fail_reason VARCHAR(500) DEFAULT NULL COMMENT '失败原因',
created_at BIGINT NOT NULL COMMENT '创建时间',
INDEX idx_strategy_id (strategy_id),
INDEX idx_period (strategy_id, period_start_unix),
INDEX idx_created_at (created_at),
FOREIGN KEY (strategy_id) REFERENCES crypto_tail_strategy(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='尾盘策略触发记录表';
@@ -0,0 +1,13 @@
-- ============================================
-- V35: 尾盘策略触发记录 - 结算与收益字段
-- 用于轮询服务:扫描 success 但未结算的订单,查链上结算结果并回写收益
-- ============================================
ALTER TABLE crypto_tail_strategy_trigger
ADD COLUMN condition_id VARCHAR(66) DEFAULT NULL COMMENT '市场 conditionId(用于查链上结算)' AFTER order_id,
ADD COLUMN resolved TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已结算: 0=未结算, 1=已结算',
ADD COLUMN winner_outcome_index INT DEFAULT NULL COMMENT '市场赢家 outcome 索引(结算后写入)',
ADD COLUMN realized_pnl DECIMAL(20, 8) DEFAULT NULL COMMENT '已实现盈亏 USDC(赢为正,输为负)',
ADD COLUMN settled_at BIGINT DEFAULT NULL COMMENT '结算时间(毫秒时间戳)';
CREATE INDEX idx_trigger_settlement ON crypto_tail_strategy_trigger (status, resolved);
@@ -0,0 +1,8 @@
-- ============================================
-- V36: 尾盘策略触发记录 - TG 通知已发标记(与跟单轮询发 TG 一致)
-- ============================================
ALTER TABLE crypto_tail_strategy_trigger
ADD COLUMN notification_sent TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已发送 TG 通知: 0=未发送, 1=已发送';
CREATE INDEX idx_trigger_notification ON crypto_tail_strategy_trigger (status, notification_sent);
@@ -0,0 +1,4 @@
-- 尾盘策略最小价差:NONE=不校验, FIXED=固定值, AUTO=历史计算
ALTER TABLE crypto_tail_strategy
ADD COLUMN min_spread_mode VARCHAR(16) NOT NULL DEFAULT 'NONE' COMMENT '最小价差模式: NONE, FIXED, AUTO',
ADD COLUMN min_spread_value DECIMAL(20, 8) NULL COMMENT '最小价差数值(FIXED 时必填;AUTO 时可存计算值)';
@@ -0,0 +1,28 @@
-- 尾盘策略价差字段重构:支持最小价差/最大价差方向,使用枚举数值存储
-- 1. 重命名 min_spread_mode -> spread_mode,并转换为 TINYINT (0=NONE, 1=FIXED, 2=AUTO)
-- 2. 重命名 min_spread_value -> spread_value
-- 3. 新增 spread_direction 字段,使用 TINYINT (0=MIN, 1=MAX)
-- 步骤1: 重命名并迁移 spread_mode 数据
ALTER TABLE crypto_tail_strategy
ADD COLUMN spread_mode_new TINYINT NOT NULL DEFAULT 0 COMMENT '价差模式: 0=NONE, 1=FIXED, 2=AUTO';
UPDATE crypto_tail_strategy
SET spread_mode_new = CASE
WHEN min_spread_mode = 'NONE' THEN 0
WHEN min_spread_mode = 'FIXED' THEN 1
WHEN min_spread_mode = 'AUTO' THEN 2
ELSE 0
END;
ALTER TABLE crypto_tail_strategy
DROP COLUMN min_spread_mode,
CHANGE COLUMN spread_mode_new spread_mode TINYINT NOT NULL DEFAULT 0 COMMENT '价差模式: 0=NONE, 1=FIXED, 2=AUTO';
-- 步骤2: 重命名 spread_value
ALTER TABLE crypto_tail_strategy
CHANGE COLUMN min_spread_value spread_value DECIMAL(20, 8) NULL COMMENT '价差数值(FIXED 时必填;AUTO 时可存计算值)';
-- 步骤3: 新增 spread_direction 字段
ALTER TABLE crypto_tail_strategy
ADD COLUMN spread_direction TINYINT NOT NULL DEFAULT 0 COMMENT '价差方向: 0=MIN(价差>=配置值触发), 1=MAX(价差<=配置值触发)';
@@ -0,0 +1,5 @@
-- 添加触发类型字段到加密价差策略触发记录表
-- AUTO: 自动下单触发
-- MANUAL: 手动下单触发
ALTER TABLE crypto_tail_strategy_trigger
ADD COLUMN trigger_type VARCHAR(20) DEFAULT 'AUTO' COMMENT '触发类型:AUTO(自动)或 MANUAL(手动)';
@@ -0,0 +1,97 @@
-- 消息模板表
CREATE TABLE notification_templates (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
template_type VARCHAR(50) NOT NULL COMMENT '模板类型',
template_content TEXT NOT NULL COMMENT '模板内容,支持 {{variable}} 变量',
is_default TINYINT(1) DEFAULT 0 COMMENT '是否使用默认模板(0=自定义,1=默认)',
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
UNIQUE KEY uk_template_type (template_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消息推送模板';
-- 插入默认模板
INSERT INTO notification_templates (template_type, template_content, is_default, created_at, updated_at) VALUES
('ORDER_SUCCESS', '🚀 <b>订单创建成功</b>
📊 <b></b>
ID: <code>{{order_id}}</code>
: <a href="{{market_link}}">{{market_title}}</a>
: <b>{{outcome}}</b>
: <b>{{side}}</b>
: <code>{{price}}</code>
: <code>{{quantity}}</code> shares
: <code>{{amount}}</code> USDC
: {{account_name}}
: <code>{{available_balance}}</code> USDC
: <code>{{time}}</code>', 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000),
('ORDER_FAILED', '❌ <b>订单创建失败</b>
📊 <b></b>
: <a href="{{market_link}}">{{market_title}}</a>
: <b>{{outcome}}</b>
: <b>{{side}}</b>
: <code>{{price}}</code>
: <code>{{quantity}}</code> shares
: <code>{{amount}}</code> USDC
: {{account_name}}
<b></b>
<code>{{error_message}}</code>
: <code>{{time}}</code>', 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000),
('ORDER_FILTERED', '🚫 <b>订单被过滤</b>
📊 <b></b>
: <a href="{{market_link}}">{{market_title}}</a>
: <b>{{outcome}}</b>
: <b>{{side}}</b>
: <code>{{price}}</code>
: <code>{{quantity}}</code> shares
: <code>{{amount}}</code> USDC
: {{account_name}}
<b></b> <code>{{filter_type}}</code>
📝 <b></b>
<code>{{filter_reason}}</code>
: <code>{{time}}</code>', 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000),
('CRYPTO_TAIL_SUCCESS', '🚀 <b>加密价差策略下单成功</b>
📊 <b></b>
ID: <code>{{order_id}}</code>
: {{strategy_name}}
: <a href="{{market_link}}">{{market_title}}</a>
: <b>{{outcome}}</b>
: <b>{{side}}</b>
: <code>{{price}}</code>
: <code>{{quantity}}</code> shares
: <code>{{amount}}</code> USDC
: {{account_name}}
: <code>{{time}}</code>', 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000),
('REDEEM_SUCCESS', '💸 <b>仓位赎回成功</b>
📊 <b></b>
: {{account_name}}
: <code>{{transaction_hash}}</code>
: <code>{{total_value}}</code> USDC
: <code>{{available_balance}}</code> USDC
: <code>{{time}}</code>', 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000),
('REDEEM_NO_RETURN', '📋 <b>仓位已结算(无收益)</b>
📊 <b></b>
<i> 0</i>
: {{account_name}}
: <code>{{transaction_hash}}</code>
: <code>{{available_balance}}</code> USDC
: <code>{{time}}</code>', 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000);
@@ -13,9 +13,20 @@ notification.order.quantity=Quantity
notification.order.amount=Amount
notification.order.account=Account
notification.order.time=Time
notification.order.available_balance=Available Balance
notification.order.error_info=Error Information
notification.order.unknown_account=Unknown Account
notification.order.calculate_failed=Calculation Failed
notification.order.filtered=Order Filtered
notification.order.filter_reason=Filter Reason
notification.order.filter_type=Filter Type
notification.filter.type.order_depth=Insufficient Order Depth
notification.filter.type.spread=Spread Too Large
notification.filter.type.orderbook_depth=Insufficient Orderbook Depth
notification.filter.type.price_validity=Invalid Price
notification.filter.type.market_status=Market Not Tradable
notification.tail.order.success=Crypto spread strategy order success
notification.tail.strategy=Strategy
notification.redeem.success=Position Redeemed Successfully
notification.redeem.info=Redeem Information
notification.redeem.transaction_hash=Transaction Hash
@@ -24,6 +35,13 @@ notification.redeem.position_count=Position Count
notification.redeem.positions=Redeemed Positions
notification.redeem.account=Account
notification.redeem.time=Time
notification.redeem.available_balance=Available Balance
# Position Settled (No Return)
notification.redeem.no_return.title=Position Settled (No Return)
notification.redeem.no_return.info=Settlement Information
notification.redeem.no_return.message=Market settled. Your prediction was incorrect. Redemption value is 0.
notification.redeem.no_return.positions=Settled Positions
# Auto Redeem related notifications
notification.auto_redeem.disabled.title=Auto Redeem Disabled
@@ -274,6 +292,18 @@ error.server.backtest_historical_data_fetch_failed=Failed to fetch historical da
error.server.backtest_stop_failed=Failed to stop backtest task
error.server.backtest_retry_failed=Failed to retry backtest task
error.server.backtest_rerun_failed=Failed to re-run backtest with same config
# Crypto spread strategy
error.crypto_tail_strategy_not_found=Crypto spread strategy not found
error.crypto_tail_strategy_window_invalid=Window start must not be greater than window end
error.crypto_tail_strategy_window_exceed=Time window must not exceed period length
error.crypto_tail_strategy_interval_invalid=Interval must be 300 or 900 seconds
error.crypto_tail_strategy_amount_mode_invalid=Amount mode must be RATIO or FIXED
error.server.crypto_tail_strategy_create_failed=Failed to create crypto spread strategy
error.server.crypto_tail_strategy_update_failed=Failed to update crypto spread strategy
error.server.crypto_tail_strategy_delete_failed=Failed to delete crypto spread strategy
error.server.crypto_tail_strategy_list_fetch_failed=Failed to fetch crypto spread strategy list
error.server.crypto_tail_strategy_triggers_fetch_failed=Failed to fetch trigger records
# Backtest Management
backtest.title=Backtest Management
backtest.create_task=Create Backtest
@@ -13,9 +13,20 @@ notification.order.quantity=数量
notification.order.amount=金额
notification.order.account=账户
notification.order.time=时间
notification.order.available_balance=可用余额
notification.order.error_info=错误信息
notification.order.unknown_account=未知账户
notification.order.calculate_failed=计算失败
notification.order.filtered=订单被过滤
notification.order.filter_reason=过滤原因
notification.order.filter_type=过滤类型
notification.filter.type.order_depth=订单深度不足
notification.filter.type.spread=价差过大
notification.filter.type.orderbook_depth=订单簿深度不足
notification.filter.type.price_validity=价格不合理
notification.filter.type.market_status=市场状态不可交易
notification.tail.order.success=加密价差策略下单成功
notification.tail.strategy=策略
notification.redeem.success=仓位赎回成功
notification.redeem.info=赎回信息
notification.redeem.transaction_hash=交易哈希
@@ -24,6 +35,13 @@ notification.redeem.position_count=仓位数量
notification.redeem.positions=赎回仓位
notification.redeem.account=账户
notification.redeem.time=时间
notification.redeem.available_balance=可用余额
# 仓位已结算(无收益)
notification.redeem.no_return.title=仓位已结算(无收益)
notification.redeem.no_return.info=结算信息
notification.redeem.no_return.message=市场已结算,您的预测未命中,赎回价值为 0。
notification.redeem.no_return.positions=结算仓位
# 自动赎回相关通知
notification.auto_redeem.disabled.title=自动赎回未开启
@@ -274,6 +292,18 @@ error.server.backtest_historical_data_fetch_failed=历史数据获取失败
error.server.backtest_stop_failed=停止回测任务失败
error.server.backtest_retry_failed=重试回测任务失败
error.server.backtest_rerun_failed=按配置重新测试失败
# 加密价差策略
error.crypto_tail_strategy_not_found=加密价差策略不存在
error.crypto_tail_strategy_window_invalid=时间区间开始不能大于结束
error.crypto_tail_strategy_window_exceed=时间区间不能超过周期长度
error.crypto_tail_strategy_interval_invalid=周期仅支持 300 或 900 秒
error.crypto_tail_strategy_amount_mode_invalid=投入方式仅支持 RATIO 或 FIXED
error.server.crypto_tail_strategy_create_failed=创建加密价差策略失败
error.server.crypto_tail_strategy_update_failed=更新加密价差策略失败
error.server.crypto_tail_strategy_delete_failed=删除加密价差策略失败
error.server.crypto_tail_strategy_list_fetch_failed=查询加密价差策略列表失败
error.server.crypto_tail_strategy_triggers_fetch_failed=查询触发记录失败
# 回测管理
backtest.title=回测管理
backtest.create_task=新增回测
@@ -13,9 +13,20 @@ notification.order.quantity=數量
notification.order.amount=金額
notification.order.account=賬戶
notification.order.time=時間
notification.order.available_balance=可用餘額
notification.order.error_info=錯誤信息
notification.order.unknown_account=未知賬戶
notification.order.calculate_failed=計算失敗
notification.order.filtered=訂單被過濾
notification.order.filter_reason=過濾原因
notification.order.filter_type=過濾類型
notification.filter.type.order_depth=訂單深度不足
notification.filter.type.spread=價差過大
notification.filter.type.orderbook_depth=訂單簿深度不足
notification.filter.type.price_validity=價格不合理
notification.filter.type.market_status=市場狀態不可交易
notification.tail.order.success=加密價差策略下單成功
notification.tail.strategy=策略
notification.redeem.success=倉位贖回成功
notification.redeem.info=贖回信息
notification.redeem.transaction_hash=交易哈希
@@ -24,6 +35,13 @@ notification.redeem.position_count=倉位數量
notification.redeem.positions=贖回倉位
notification.redeem.account=賬戶
notification.redeem.time=時間
notification.redeem.available_balance=可用餘額
# 倉位已結算(無收益)
notification.redeem.no_return.title=倉位已結算(無收益)
notification.redeem.no_return.info=結算信息
notification.redeem.no_return.message=市場已結算,您的預測未命中,贖回價值為 0。
notification.redeem.no_return.positions=結算倉位
# 自動贖回相關通知
notification.auto_redeem.disabled.title=自動贖回未開啟
@@ -274,6 +292,18 @@ error.server.backtest_historical_data_fetch_failed=歷史數據獲取失敗
error.server.backtest_stop_failed=停止回測任務失敗
error.server.backtest_retry_failed=重試回測任務失敗
error.server.backtest_rerun_failed=依配置重新測試失敗
# 加密價差策略
error.crypto_tail_strategy_not_found=加密價差策略不存在
error.crypto_tail_strategy_window_invalid=時間區間開始不能大於結束
error.crypto_tail_strategy_window_exceed=時間區間不能超過週期長度
error.crypto_tail_strategy_interval_invalid=週期僅支援 300 或 900 秒
error.crypto_tail_strategy_amount_mode_invalid=投入方式僅支援 RATIO 或 FIXED
error.server.crypto_tail_strategy_create_failed=創建加密價差策略失敗
error.server.crypto_tail_strategy_update_failed=更新加密價差策略失敗
error.server.crypto_tail_strategy_delete_failed=刪除加密價差策略失敗
error.server.crypto_tail_strategy_list_fetch_failed=查詢加密價差策略列表失敗
error.server.crypto_tail_strategy_triggers_fetch_failed=查詢觸發記錄失敗
# 回測管理
backtest.title=回測管理
backtest.create_task=新增回測
+206 -1
View File
@@ -1,4 +1,209 @@
# PolyHermes 一键部署脚本使用说明
# PolyHermes One-Click Deployment Script / PolyHermes 一键部署脚本使用说明
[English](#english) | [中文](#中文)
---
<a name="english"></a>
## English
## ✨ Core Features
- **Run from any directory** - No need to download source code
- **Online images only** - Pull official images from Docker Hub
- **Auto-download config** - Download the latest `docker-compose.prod.yml` from GitHub
- **Interactive configuration** - User-friendly Q&A style configuration wizard
- **Auto-generate secrets** - All sensitive configurations will auto-generate secure random values on Enter
## 🚀 Quick Start
### One-Click Installation (Recommended)
**Using curl (Recommended):**
```bash
mkdir -p ~/polyhermes && cd ~/polyhermes && curl -fsSL https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh -o deploy.sh && chmod +x deploy.sh && ./deploy.sh
```
**Using wget:**
```bash
mkdir -p ~/polyhermes && cd ~/polyhermes && wget -O deploy.sh https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh && chmod +x deploy.sh && ./deploy.sh
```
This command will automatically:
- 📁 Create a dedicated working directory `~/polyhermes`
- 📥 Download the deployment script
- ✅ Check Docker environment
- ⚙️ Configure all parameters interactively (press Enter for defaults)
- 🔐 Auto-generate secure random secrets
- 🚀 Download latest images and deploy
**Or run directly via pipe (without saving file):**
```bash
# curl method
mkdir -p ~/polyhermes && cd ~/polyhermes && curl -fsSL https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh | bash
# wget method
mkdir -p ~/polyhermes && cd ~/polyhermes && wget -qO- https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh | bash
```
### Method 1: Download and Run Script Directly
```bash
# Download script
curl -O https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh
# Add execute permission
chmod +x deploy-interactive.sh
# Run
./deploy-interactive.sh
```
### Method 2: Run in Project Directory
```bash
git clone https://github.com/WrBug/PolyHermes.git
cd PolyHermes
./deploy-interactive.sh
```
## 📝 Usage Flow
After running the script, you will be guided through the following steps:
```
Step 1: Environment Check → Check Docker/Docker Compose
Step 2: Configuration → Interactive input (press Enter for defaults)
Step 3: Get Deploy Config → Download docker-compose.prod.yml from GitHub
Step 4: Generate Env File → Auto-generate .env
Step 5: Pull Docker Images → Pull latest images from Docker Hub
Step 6: Deploy Services → Start containers
Step 7: Health Check → Verify services are running properly
```
## ⚡ Simplest Usage
**Press Enter for all configuration items to use default values**, the script will automatically:
- Use port 80 (application) and 3307 (MySQL)
- Generate 32-character database password
- Generate 128-character JWT secret
- Generate 64-character admin reset key
- Generate 64-character encryption key
- Configure reasonable log levels
### Interactive Example
The script will prompt you for configuration one by one, **press Enter to skip and use default values**:
```
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Step 2: Configuration
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
💡 All configurations are optional, press Enter to use default or auto-generated values
⚠ Secret config: Press Enter to auto-generate secure random secrets
⚠ Other config: Press Enter to use default values in parentheses
【Basic Configuration】
Will configure: Server port, MySQL port, Timezone
➤ Server port [Default: 80]: ⏎
➤ MySQL port (external access) [Default: 3307]: ⏎
➤ Timezone [Default: Asia/Shanghai]: ⏎
【Database Configuration】
Will configure: Database username, Database password
➤ Database username [Default: root]: ⏎
➤ Database password [Enter to auto-generate]: ⏎
[✓] Database password auto-generated (32 characters)
【Security Configuration】
Will configure: JWT secret, Admin password reset key, Data encryption key
➤ JWT secret [Enter to auto-generate]: ⏎
[✓] JWT secret auto-generated (128 characters)
➤ Admin password reset key [Enter to auto-generate]: ⏎
[✓] Admin reset key auto-generated (64 characters)
➤ Encryption key (for API Key encryption) [Enter to auto-generate]: ⏎
[✓] Encryption key auto-generated (64 characters)
【Log Configuration】
Will configure: Root log level, Application log level
Available levels: TRACE, DEBUG, INFO, WARN, ERROR, OFF
➤ Root log level (third-party libs) [Default: WARN]: ⏎
➤ Application log level [Default: INFO]: ⏎
【Other Configuration】
Will configure: Runtime environment, Auto-update policy, GitHub repo
➤ Spring Profile [Default: prod]: ⏎
➤ Allow prerelease updates (true/false) [Default: false]: ⏎
➤ GitHub repository [Default: WrBug/PolyHermes]: ⏎
```
## 🔧 Files Generated by Script
After running, the script will generate in the current directory:
1. **docker-compose.prod.yml** - Docker Compose config downloaded from GitHub (always latest)
2. **.env** - Environment variables file auto-generated based on your configuration
These two files contain all the configuration needed to run PolyHermes.
## 🌐 Post-Deployment Management
### Quick Update (Recommended)
If you already have configuration files, running the script again will detect and ask:
```bash
./deploy-interactive.sh
```
```
【Existing Configuration Detected】
Found existing .env configuration file
Use existing configuration to update images directly? [Y/n]: ⏎
```
- **Press Enter or input Y**: Use existing config, pull latest images and update
- **Input N**: Reconfigure (existing config will be backed up)
### Manual Management Commands
```bash
# View service status
docker compose -f docker-compose.prod.yml ps
# View logs
docker compose -f docker-compose.prod.yml logs -f
# Restart services
docker compose -f docker-compose.prod.yml restart
# Stop services
docker compose -f docker-compose.prod.yml down
# Update to latest version
docker pull wrbug/polyhermes:latest
docker compose -f docker-compose.prod.yml up -d
```
## 🔐 Security Recommendations
- **Protect .env file**: Contains sensitive information, never commit to version control
- **Backup database regularly**: Data is stored in Docker volume `mysql-data`
- **Configure HTTPS for production**: Recommend using Nginx or Caddy as reverse proxy
## 📞 Support
- [GitHub Repository](https://github.com/WrBug/PolyHermes)
- [Issue Feedback](https://github.com/WrBug/PolyHermes/issues)
- [Full Deployment Documentation](docs/zh/DEPLOYMENT_GUIDE.md)
---
<a name="中文"></a>
## 中文
## ✨ 核心特性
+175 -179
View File
@@ -1,18 +1,19 @@
#!/bin/bash
# ========================================
# PolyHermes Interactive Deploy Script
# PolyHermes 交互式一键部署脚本
# ========================================
# 功能:
# - 交互式配置环境变量
# - 自动生成安全密钥
# - 使用 Docker Hub 线上镜像部署
# - 支持配置预检和回滚
# Features / 功能:
# - Interactive env config / 交互式配置环境变量
# - Auto-generate secrets / 自动生成安全密钥
# - Deploy via Docker Hub images / 使用 Docker Hub 线上镜像部署
# - Config check and rollback / 支持配置预检和回滚
# ========================================
set -e
# 颜色输出
# Colors / 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
@@ -20,7 +21,14 @@ BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# 打印函数
# Language: LANG=zh* → prompts in Chinese only; else show "中文 / English"
# 语言:LANG 为 zh* 时仅中文,否则显示「中文 / English」
USE_ZH_ONLY=false
case "${LANG:-}" in
zh*) USE_ZH_ONLY=true ;;
esac
# Print functions / 打印函数
info() {
echo -e "${GREEN}[✓]${NC} $1"
}
@@ -37,6 +45,17 @@ title() {
echo -e "${CYAN}${1}${NC}"
}
# Bilingual: 中文 / English (or Chinese only when LANG=zh*)
bilingual() {
local zh="$1"
local en="$2"
if [ "$USE_ZH_ONLY" = true ]; then
echo "$zh"
else
echo "$zh / $en"
fi
}
# 生成随机密钥
generate_secret() {
local length=${1:-32}
@@ -47,54 +66,57 @@ generate_secret() {
fi
}
# 生成随机端口号(10000-60000之间)
# 生成随机端口号(10000-60000之间)/ Generate random port (10000-60000)
generate_random_port() {
echo $((10000 + RANDOM % 50001))
}
# 读取用户输入(支持默认值)
# 读取用户输入(支持默认值)/ Read user input (with default)
read_input() {
local prompt="$1"
local default="$2"
local is_secret="$3"
local value=""
# 构建提示信息(不使用颜色,因为 read -p 可能不支持)
local prompt_text=""
if [ -n "$default" ]; then
if [ "$is_secret" = "secret" ]; then
prompt_text="${prompt} [回车自动生成]: "
if [ "$USE_ZH_ONLY" = true ]; then
prompt_text="${prompt} [回车自动生成]: "
else
prompt_text="${prompt} [Enter to auto-generate]: "
fi
else
prompt_text="${prompt} [默认: ${default}]: "
if [ "$USE_ZH_ONLY" = true ]; then
prompt_text="${prompt} [默认: ${default}]: "
else
prompt_text="${prompt} [Default: ${default}]: "
fi
fi
else
prompt_text="${prompt}: "
fi
# 使用 read -p 确保提示正确显示
read -r -p "$prompt_text" value
# 如果用户没有输入,使用默认值
if [ -z "$value" ]; then
if [ "$is_secret" = "secret" ] && [ -z "$default" ]; then
# 自动生成密钥
case "$prompt" in
*JWT*)
*JWT*|*jwt*)
value=$(generate_secret 64)
# 输出到 stderr,避免被捕获到返回值中
info "已自动生成 JWT 密钥(128字符)" >&2
info "$(bilingual "已自动生成 JWT 密钥(128字符)" "JWT secret auto-generated (128 chars)")" >&2
;;
*管理员*|*ADMIN*)
*管理员*|*ADMIN*|*admin*|*reset*|*Reset*)
value=$(generate_secret 32)
info "已自动生成管理员重置密钥(64字符)" >&2
info "$(bilingual "已自动生成管理员重置密钥(64字符)" "Admin reset key auto-generated (64 chars)")" >&2
;;
*加密*|*CRYPTO*)
*加密*|*CRYPTO*|*crypto*|*Encryption*)
value=$(generate_secret 32)
info "已自动生成加密密钥(64字符)" >&2
info "$(bilingual "已自动生成加密密钥(64字符)" "Encryption key auto-generated (64 chars)")" >&2
;;
*数据库密码*|*DB_PASSWORD*)
*数据库密码*|*DB_PASSWORD*|*database*|*Database*)
value=$(generate_secret 16)
info "已自动生成数据库密码(32字符)" >&2
info "$(bilingual "已自动生成数据库密码(32字符)" "Database password auto-generated (32 chars)")" >&2
;;
*)
value="$default"
@@ -108,126 +130,115 @@ read_input() {
echo "$value"
}
# 检查 Docker 环境
# 检查 Docker 环境 / Check Docker environment
check_docker() {
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
title " 步骤 1: 环境检查"
title " $(bilingual "步骤 1: 环境检查" "Step 1: Environment Check")"
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# 检查 Docker
if ! command -v docker &> /dev/null; then
error "Docker 未安装"
error "$(bilingual "Docker 未安装" "Docker is not installed")"
echo ""
info "请先安装 Docker"
info "$(bilingual "请先安装 Docker" "Please install Docker first:")"
info " macOS: brew install docker"
info " Ubuntu/Debian: apt-get install docker.io"
info " CentOS/RHEL: yum install docker"
exit 1
fi
info "Docker 已安装: $(docker --version | head -1)"
info "$(bilingual "Docker 已安装" "Docker installed"): $(docker --version | head -1)"
# 检查 Docker Compose
if docker compose version &> /dev/null 2>&1; then
info "Docker Compose 已安装: $(docker compose version)"
info "$(bilingual "Docker Compose 已安装" "Docker Compose installed"): $(docker compose version)"
elif command -v docker-compose &> /dev/null; then
info "Docker Compose 已安装: $(docker-compose --version)"
info "$(bilingual "Docker Compose 已安装" "Docker Compose installed"): $(docker-compose --version)"
else
error "Docker Compose 未安装"
error "$(bilingual "Docker Compose 未安装" "Docker Compose is not installed")"
echo ""
info "请先安装 Docker Compose"
info "$(bilingual "请先安装 Docker Compose" "Please install Docker Compose:")"
info " https://docs.docker.com/compose/install/"
exit 1
fi
# 检查 Docker 守护进程
if ! docker info &> /dev/null; then
error "Docker 守护进程未运行"
info "请启动 Docker 服务:"
info " macOS: 打开 Docker Desktop"
error "$(bilingual "Docker 守护进程未运行" "Docker daemon is not running")"
info "$(bilingual "请启动 Docker 服务:" "Please start Docker:")"
info " $(bilingual "macOS: 打开 Docker Desktop" "macOS: Open Docker Desktop")"
info " Linux: systemctl start docker"
exit 1
fi
info "Docker 守护进程运行正常"
info "$(bilingual "Docker 守护进程运行正常" "Docker daemon is running")"
echo ""
}
# 交互式配置收集
# 交互式配置收集 / Interactive configuration
collect_configuration() {
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
title " 步骤 2: 配置收集"
title " $(bilingual "步骤 2: 配置收集" "Step 2: Configuration")"
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
info "💡 所有配置项均为可选,直接按回车即可使用默认值或自动生成"
info "$(bilingual "💡 所有配置项均为可选,直接按回车即可使用默认值或自动生成" "💡 All options are optional, press Enter for default or auto-generated values")"
echo ""
warn "密钥配置:回车将自动生成安全的随机密钥"
warn "其他配置:回车将使用括号中的默认值"
warn "$(bilingual "密钥配置:回车将自动生成安全的随机密钥" "Secrets: Enter to auto-generate secure random keys")"
warn "$(bilingual "其他配置:回车将使用括号中的默认值" "Other: Enter to use default value in brackets")"
echo ""
# 基础配置
title "【基础配置】"
echo -e "${CYAN}将配置:服务器端口、MySQL端口、时区${NC}"
# 生成随机端口作为默认值
title "$(bilingual "【基础配置】" "【Basic】")"
echo -e "${CYAN}$(bilingual "将配置:服务器端口、MySQL端口、时区" "Server port, MySQL port, Timezone")${NC}"
DEFAULT_PORT=$(generate_random_port)
SERVER_PORT=$(read_input "➤ 服务器端口" "$DEFAULT_PORT")
MYSQL_PORT=$(read_input "➤ MySQL 端口(外部访问)" "3307")
TZ=$(read_input "➤ 时区" "Asia/Shanghai")
SERVER_PORT=$(read_input "$(bilingual "➤ 服务器端口" "➤ Server port")" "$DEFAULT_PORT")
MYSQL_PORT=$(read_input "$(bilingual "➤ MySQL 端口(外部访问)" "➤ MySQL port (external)")" "3307")
TZ=$(read_input "$(bilingual "➤ 时区" "➤ Timezone")" "Asia/Shanghai")
echo ""
# 数据库配置
title "【数据库配置】"
echo -e "${CYAN}将配置:数据库用户名、数据库密码${NC}"
echo -e "${YELLOW}💡 提示:密码留空将自动生成 32 字符的安全随机密码${NC}"
DB_USERNAME=$(read_input "➤ 数据库用户名" "root")
DB_PASSWORD=$(read_input "➤ 数据库密码" "" "secret")
title "$(bilingual "【数据库配置】" "【Database】")"
echo -e "${CYAN}$(bilingual "将配置:数据库用户名、数据库密码" "Database username, password")${NC}"
echo -e "${YELLOW}$(bilingual "💡 提示:密码留空将自动生成 32 字符的安全随机密码" "💡 Leave password empty to auto-generate 32-char password")${NC}"
DB_USERNAME=$(read_input "$(bilingual "➤ 数据库用户名" "➤ Database username")" "root")
DB_PASSWORD=$(read_input "$(bilingual "➤ 数据库密码" "➤ Database password")" "" "secret")
echo ""
# 安全配置
title "【安全配置】"
echo -e "${CYAN}将配置:JWT密钥、管理员密码重置密钥、数据加密密钥${NC}"
echo -e "${YELLOW}💡 提示:留空将自动生成高强度随机密钥(推荐)${NC}"
JWT_SECRET=$(read_input "➤ JWT 密钥" "" "secret")
ADMIN_RESET_PASSWORD_KEY=$(read_input "➤ 管理员密码重置密钥" "" "secret")
CRYPTO_SECRET_KEY=$(read_input "➤ 加密密钥(用于加密 API Key" "" "secret")
title "$(bilingual "【安全配置】" "【Security】")"
echo -e "${CYAN}$(bilingual "将配置:JWT密钥、管理员密码重置密钥、数据加密密钥" "JWT secret, Admin reset key, Encryption key")${NC}"
echo -e "${YELLOW}$(bilingual "💡 提示:留空将自动生成高强度随机密钥(推荐)" "💡 Leave empty to auto-generate strong keys (recommended)")${NC}"
JWT_SECRET=$(read_input "$(bilingual "➤ JWT 密钥" "➤ JWT secret")" "" "secret")
ADMIN_RESET_PASSWORD_KEY=$(read_input "$(bilingual "➤ 管理员密码重置密钥" "➤ Admin password reset key")" "" "secret")
CRYPTO_SECRET_KEY=$(read_input "$(bilingual "➤ 加密密钥(用于加密 API Key" "➤ Encryption key (for API Key)")" "" "secret")
echo ""
# 日志配置
title "【日志配置】"
echo -e "${CYAN}将配置:Root日志级别、应用日志级别${NC}"
echo -e "${YELLOW}可选级别: TRACE, DEBUG, INFO, WARN, ERROR, OFF${NC}"
LOG_LEVEL_ROOT=$(read_input "➤ Root 日志级别(第三方库)" "WARN")
LOG_LEVEL_APP=$(read_input "➤ 应用日志级别" "INFO")
title "$(bilingual "【日志配置】" "【Logging】")"
echo -e "${CYAN}$(bilingual "将配置:Root日志级别、应用日志级别" "Root log level, App log level")${NC}"
echo -e "${YELLOW}$(bilingual "可选级别: TRACE, DEBUG, INFO, WARN, ERROR, OFF" "Levels: TRACE, DEBUG, INFO, WARN, ERROR, OFF")${NC}"
LOG_LEVEL_ROOT=$(read_input "$(bilingual "➤ Root 日志级别(第三方库)" "➤ Root log level (3rd party)")" "WARN")
LOG_LEVEL_APP=$(read_input "$(bilingual "➤ 应用日志级别" "➤ App log level")" "INFO")
echo ""
# 自动设置不需要用户输入的配置
SPRING_PROFILES_ACTIVE="prod"
ALLOW_PRERELEASE="false"
GITHUB_REPO="WrBug/PolyHermes"
}
# 下载 docker-compose.prod.yml(如果不存在)
# 下载 docker-compose.prod.yml(如果不存在)/ Download docker-compose.prod.yml if missing
download_docker_compose_file() {
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
title " 步骤 3: 获取部署配置"
title " $(bilingual "步骤 3: 获取部署配置" "Step 3: Get Deploy Config")"
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
if [ -f "docker-compose.prod.yml" ]; then
info "检测到现有 docker-compose.prod.yml,跳过下载"
info "$(bilingual "检测到现有 docker-compose.prod.yml,跳过下载" "Existing docker-compose.prod.yml found, skip download")"
echo ""
return 0
fi
info "正在从 GitHub 下载 docker-compose.prod.yml..."
info "$(bilingual "正在从 GitHub 下载 docker-compose.prod.yml..." "Downloading docker-compose.prod.yml from GitHub...")"
# GitHub raw 文件链接
local compose_url="https://raw.githubusercontent.com/WrBug/PolyHermes/main/docker-compose.prod.yml"
# 尝试下载
if curl -fsSL "$compose_url" -o docker-compose.prod.yml; then
info "docker-compose.prod.yml 下载成功"
info "$(bilingual "docker-compose.prod.yml 下载成功" "docker-compose.prod.yml downloaded")"
else
error "docker-compose.prod.yml 下载失败"
warn "请检查网络连接或手动下载:"
error "$(bilingual "docker-compose.prod.yml 下载失败" "Failed to download docker-compose.prod.yml")"
warn "$(bilingual "请检查网络连接或手动下载:" "Check network or download manually:")"
warn " $compose_url"
exit 1
fi
@@ -235,28 +246,26 @@ download_docker_compose_file() {
echo ""
}
# 生成 .env 文件
# 生成 .env 文件 / Generate .env file
generate_env_file() {
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
title " 步骤 4: 生成环境变量文件"
title " $(bilingual "步骤 4: 生成环境变量文件" "Step 4: Generate .env")"
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# 备份现有 .env 文件
if [ -f ".env" ]; then
BACKUP_FILE=".env.backup.$(date +%Y%m%d_%H%M%S)"
cp .env "$BACKUP_FILE"
warn "已备份现有配置文件到: $BACKUP_FILE"
warn "$(bilingual "已备份现有配置文件到" "Backed up existing config to"): $BACKUP_FILE"
fi
# 生成新的 .env 文件
cat > .env <<EOF
# ========================================
# PolyHermes 生产环境配置
# 生成时间: $(date '+%Y-%m-%d %H:%M:%S')
# PolyHermes Production Config / 生产环境配置
# Generated / 生成时间: $(date '+%Y-%m-%d %H:%M:%S')
# ========================================
# ============================================
# 基础配置
# Basic / 基础配置
# ============================================
TZ=${TZ}
SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE}
@@ -264,112 +273,107 @@ SERVER_PORT=${SERVER_PORT}
MYSQL_PORT=${MYSQL_PORT}
# ============================================
# 数据库配置
# Database / 数据库配置
# ============================================
DB_URL=jdbc:mysql://mysql:3306/polyhermes?useSSL=false&serverTimezone=UTC&characterEncoding=utf8&allowPublicKeyRetrieval=true
DB_USERNAME=${DB_USERNAME}
DB_PASSWORD=${DB_PASSWORD}
# ============================================
# 安全配置(请妥善保管)
# Security (keep safe) / 安全配置(请妥善保管)
# ============================================
JWT_SECRET=${JWT_SECRET}
ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY}
CRYPTO_SECRET_KEY=${CRYPTO_SECRET_KEY}
# ============================================
# 日志配置
# Logging / 日志配置
# ============================================
LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT}
LOG_LEVEL_APP=${LOG_LEVEL_APP}
# ============================================
# 其他配置
# Other / 其他配置
# ============================================
ALLOW_PRERELEASE=${ALLOW_PRERELEASE}
GITHUB_REPO=${GITHUB_REPO}
EOF
info "配置文件已生成: .env"
info "$(bilingual "配置文件已生成" "Config file generated"): .env"
echo ""
# 显示配置摘要
title "【配置摘要】"
echo " 服务器端口: ${SERVER_PORT}"
echo " MySQL 端口: ${MYSQL_PORT}"
echo " 时区: ${TZ}"
echo " 数据库用户: ${DB_USERNAME}"
echo " 数据库密码: ${DB_PASSWORD:0:8}... (已隐藏)"
echo " JWT 密钥: ${JWT_SECRET:0:16}... (已隐藏)"
echo " 管理员重置密钥: ${ADMIN_RESET_PASSWORD_KEY:0:16}... (已隐藏)"
echo " 加密密钥: ${CRYPTO_SECRET_KEY:0:16}... (已隐藏)"
echo " 日志级别: Root=${LOG_LEVEL_ROOT}, App=${LOG_LEVEL_APP}"
title "$(bilingual "【配置摘要】" "【Config Summary】")"
echo " $(bilingual "服务器端口" "Server port"): ${SERVER_PORT}"
echo " $(bilingual "MySQL 端口" "MySQL port"): ${MYSQL_PORT}"
echo " $(bilingual "时区" "Timezone"): ${TZ}"
echo " $(bilingual "数据库用户" "DB user"): ${DB_USERNAME}"
echo " $(bilingual "数据库密码" "DB password"): ${DB_PASSWORD:0:8}... $(bilingual "(已隐藏)" "(hidden)")"
echo " $(bilingual "JWT 密钥" "JWT secret"): ${JWT_SECRET:0:16}... $(bilingual "(已隐藏)" "(hidden)")"
echo " $(bilingual "管理员重置密钥" "Admin reset key"): ${ADMIN_RESET_PASSWORD_KEY:0:16}... $(bilingual "(已隐藏)" "(hidden)")"
echo " $(bilingual "加密密钥" "Encryption key"): ${CRYPTO_SECRET_KEY:0:16}... $(bilingual "(已隐藏)" "(hidden)")"
echo " $(bilingual "日志级别" "Log level"): Root=${LOG_LEVEL_ROOT}, App=${LOG_LEVEL_APP}"
echo ""
}
# 拉取镜像
# 拉取镜像 / Pull images
pull_images() {
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
title " 步骤 5: 拉取 Docker 镜像"
title " $(bilingual "步骤 5: 拉取 Docker 镜像" "Step 5: Pull Docker Images")"
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
info "正在从 Docker Hub 拉取最新镜像..."
info "$(bilingual "正在从 Docker Hub 拉取最新镜像..." "Pulling latest images from Docker Hub...")"
# 拉取应用镜像
if docker pull wrbug/polyhermes:latest; then
info "应用镜像拉取成功: wrbug/polyhermes:latest"
info "$(bilingual "应用镜像拉取成功" "App image pulled"): wrbug/polyhermes:latest"
else
error "应用镜像拉取失败"
warn "可能的原因:"
warn " 1. 网络连接问题"
warn " 2. Docker Hub 服务异常"
warn " 3. 镜像不存在"
error "$(bilingual "应用镜像拉取失败" "Failed to pull app image")"
warn "$(bilingual "可能的原因:" "Possible reasons:")"
warn " 1. $(bilingual "网络连接问题" "Network issue")"
warn " 2. $(bilingual "Docker Hub 服务异常" "Docker Hub unavailable")"
warn " 3. $(bilingual "镜像不存在" "Image not found")"
exit 1
fi
# 拉取 MySQL 镜像
if docker pull mysql:8.2; then
info "MySQL 镜像拉取成功: mysql:8.2"
info "$(bilingual "MySQL 镜像拉取成功" "MySQL image pulled"): mysql:8.2"
else
warn "MySQL 镜像拉取失败,将在启动时自动下载"
warn "$(bilingual "MySQL 镜像拉取失败,将在启动时自动下载" "MySQL pull failed, will download on start")"
fi
echo ""
}
# 部署服务
# 部署服务 / Deploy services
deploy_services() {
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
title " 步骤 6: 部署服务"
title " $(bilingual "步骤 6: 部署服务" "Step 6: Deploy Services")"
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# 停止现有服务
if docker compose -f docker-compose.prod.yml ps -q 2>/dev/null | grep -q .; then
warn "检测到正在运行的服务,正在停止..."
warn "$(bilingual "检测到正在运行的服务,正在停止..." "Stopping existing services...")"
docker compose -f docker-compose.prod.yml down
info "已停止现有服务"
info "$(bilingual "已停止现有服务" "Stopped existing services")"
fi
# 启动服务
info "正在启动服务..."
info "$(bilingual "正在启动服务..." "Starting services...")"
if docker compose -f docker-compose.prod.yml up -d; then
info "服务启动成功"
info "$(bilingual "服务启动成功" "Services started")"
else
error "服务启动失败"
error "请检查日志: docker compose -f docker-compose.prod.yml logs"
error "$(bilingual "服务启动失败" "Failed to start services")"
error "$(bilingual "请检查日志" "Check logs"): docker compose -f docker-compose.prod.yml logs"
exit 1
fi
echo ""
}
# 健康检查
# 健康检查 / Health check
health_check() {
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
title " 步骤 7: 健康检查"
title " $(bilingual "步骤 7: 健康检查" "Step 7: Health Check")"
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
info "等待服务启动(最多等待 60 秒)..."
info "$(bilingual "等待服务启动(最多等待 60 秒)..." "Waiting for services (up to 60s)...")"
local max_attempts=12
local attempt=0
@@ -377,13 +381,11 @@ health_check() {
while [ $attempt -lt $max_attempts ]; do
attempt=$((attempt + 1))
# 检查容器状态
if docker compose -f docker-compose.prod.yml ps | grep -q "Up"; then
info "容器运行正常"
info "$(bilingual "容器运行正常" "Containers are up")"
# 检查应用是否响应
if curl -s -o /dev/null -w "%{http_code}" http://localhost:${SERVER_PORT} | grep -q "200\|302\|401"; then
info "应用响应正常"
info "$(bilingual "应用响应正常" "App is responding")"
echo ""
return 0
fi
@@ -394,79 +396,76 @@ health_check() {
done
echo ""
warn "健康检查超时,请手动检查服务状态"
warn "查看日志: docker compose -f docker-compose.prod.yml logs -f"
warn "$(bilingual "健康检查超时,请手动检查服务状态" "Health check timeout, please check services manually")"
warn "$(bilingual "查看日志" "View logs"): docker compose -f docker-compose.prod.yml logs -f"
echo ""
}
# 显示部署信息
# 显示部署信息 / Show deployment info
show_deployment_info() {
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
title " 部署完成!"
title " $(bilingual "部署完成!" "Deployment Complete!")"
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
info "访问地址: ${GREEN}http://localhost:${SERVER_PORT}${NC}"
info "$(bilingual "访问地址" "Access URL"): ${GREEN}http://localhost:${SERVER_PORT}${NC}"
echo ""
title "【常用命令】"
echo -e " 查看服务状态: ${CYAN}docker compose -f docker-compose.prod.yml ps${NC}"
echo -e " 查看日志: ${CYAN}docker compose -f docker-compose.prod.yml logs -f${NC}"
echo -e " 停止服务: ${CYAN}docker compose -f docker-compose.prod.yml down${NC}"
echo -e " 重启服务: ${CYAN}docker compose -f docker-compose.prod.yml restart${NC}"
echo -e " 更新镜像: ${CYAN}docker pull wrbug/polyhermes:latest && docker compose -f docker-compose.prod.yml up -d${NC}"
title "$(bilingual "【常用命令】" "【Common Commands】")"
echo -e " $(bilingual "查看服务状态" "Status"): ${CYAN}docker compose -f docker-compose.prod.yml ps${NC}"
echo -e " $(bilingual "查看日志" "Logs"): ${CYAN}docker compose -f docker-compose.prod.yml logs -f${NC}"
echo -e " $(bilingual "停止服务" "Stop"): ${CYAN}docker compose -f docker-compose.prod.yml down${NC}"
echo -e " $(bilingual "重启服务" "Restart"): ${CYAN}docker compose -f docker-compose.prod.yml restart${NC}"
echo -e " $(bilingual "更新镜像" "Update"): ${CYAN}docker pull wrbug/polyhermes:latest && docker compose -f docker-compose.prod.yml up -d${NC}"
echo ""
title "【数据库连接信息】"
echo -e " 主机: ${CYAN}localhost${NC}"
echo -e " 端口: ${CYAN}${MYSQL_PORT}${NC}"
echo -e " 数据库: ${CYAN}polyhermes${NC}"
echo -e " 用户名: ${CYAN}${DB_USERNAME}${NC}"
echo -e " 密码: ${CYAN}${DB_PASSWORD}${NC}"
title "$(bilingual "【数据库连接信息】" "【Database Connection】")"
echo -e " $(bilingual "主机" "Host"): ${CYAN}localhost${NC}"
echo -e " $(bilingual "端口" "Port"): ${CYAN}${MYSQL_PORT}${NC}"
echo -e " $(bilingual "数据库" "Database"): ${CYAN}polyhermes${NC}"
echo -e " $(bilingual "用户名" "Username"): ${CYAN}${DB_USERNAME}${NC}"
echo -e " $(bilingual "密码" "Password"): ${CYAN}${DB_PASSWORD}${NC}"
echo ""
title "【管理员重置密钥】"
echo -e " 重置密钥: ${CYAN}${ADMIN_RESET_PASSWORD_KEY}${NC}"
echo -e " ${YELLOW}💡 此密钥用于重置管理员密码,请妥善保管${NC}"
title "$(bilingual "【管理员重置密钥】" "【Admin Reset Key】")"
echo -e " $(bilingual "重置密钥" "Reset key"): ${CYAN}${ADMIN_RESET_PASSWORD_KEY}${NC}"
echo -e " ${YELLOW}$(bilingual "💡 此密钥用于重置管理员密码,请妥善保管" "💡 Keep this key safe; it is used to reset admin password")${NC}"
echo ""
warn "重要提示:"
warn " 1. 请妥善保管 .env 文件,勿提交到版本控制系统"
warn " 2. 定期备份数据库数据(位于 Docker volume: polyhermes_mysql-data"
warn " 3. 生产环境建议配置反向代理(如 Nginx)并启用 HTTPS"
warn "$(bilingual "重要提示:" "Important:")"
warn " 1. $(bilingual "请妥善保管 .env 文件,勿提交到版本控制系统" "Keep .env secure; do not commit to version control")"
warn " 2. $(bilingual "定期备份数据库数据(位于 Docker volume: polyhermes_mysql-data" "Back up DB regularly (Docker volume: polyhermes_mysql-data)")"
warn " 3. $(bilingual "生产环境建议配置反向代理(如 Nginx)并启用 HTTPS" "Use a reverse proxy (e.g. Nginx) and HTTPS in production")"
echo ""
}
# 主函数
# 主函数 / Main
main() {
clear
echo ""
title "========================================="
title " PolyHermes 交互式一键部署脚本 "
title " $(bilingual "PolyHermes 交互式一键部署脚本" "PolyHermes Interactive Deploy") "
title "========================================="
echo ""
# 执行部署流程
check_docker
# 检查是否已存在 .env 文件
if [ -f ".env" ]; then
echo ""
title "【检测到现有配置】"
info "发现已存在的 .env 配置文件"
title "$(bilingual "【检测到现有配置】" "【Existing Config Found】")"
info "$(bilingual "发现已存在的 .env 配置文件" "Found existing .env file")"
echo ""
echo -ne "${YELLOW}是否使用现有配置直接更新镜像?[Y/n]: ${NC}"
echo -ne "${YELLOW}$(bilingual "是否使用现有配置直接更新镜像?[Y/n]" "Use existing config to update images? [Y/n]"): ${NC}"
read -r use_existing
use_existing=${use_existing:-Y}
if [[ "$use_existing" =~ ^[Yy]$ ]]; then
info "将使用现有配置,跳过配置步骤"
info "$(bilingual "将使用现有配置,跳过配置步骤" "Using existing config, skipping configuration")"
echo ""
# 从现有 .env 文件读取必要的变量
source .env 2>/dev/null || true
else
warn "将重新配置,现有配置将被备份"
warn "$(bilingual "将重新配置,现有配置将被备份" "Will reconfigure; existing config will be backed up")"
echo ""
collect_configuration
fi
@@ -476,21 +475,18 @@ main() {
download_docker_compose_file
# 只有在重新配置时才生成新的 .env 文件
if [[ ! "$use_existing" =~ ^[Yy]$ ]] || [ ! -f ".env" ]; then
generate_env_file
fi
# 确认部署
echo ""
title "【确认部署】"
echo -ne "${YELLOW}是否开始部署?[Y/n](回车默认为是): ${NC}"
title "$(bilingual "【确认部署】" "【Confirm Deploy】")"
echo -ne "${YELLOW}$(bilingual "是否开始部署?[Y/n](回车默认为是)" "Start deployment? [Y/n] (Enter = Yes)"): ${NC}"
read -r confirm
# 默认为 Y,只有明确输入 n/N 才取消
confirm=${confirm:-Y}
if [[ "$confirm" =~ ^[Nn]$ ]]; then
warn "部署已取消"
warn "$(bilingual "部署已取消" "Deployment cancelled")"
exit 0
fi
@@ -500,11 +496,11 @@ main() {
health_check
show_deployment_info
info "部署流程已完成!"
info "$(bilingual "部署流程已完成!" "Deployment finished!")"
}
# 捕获 Ctrl+C
trap 'echo ""; warn "部署已中断"; exit 1' INT
# 捕获 Ctrl+C / Handle Ctrl+C
trap 'echo ""; warn "$(bilingual "部署已中断" "Deployment interrupted")"; exit 1' INT
# 运行主函数
# 运行主函数 / Run main
main "$@"
+58 -12
View File
@@ -10,6 +10,7 @@ RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
COMPOSE_FILES=(-f docker-compose.yml)
# 打印信息
info() {
@@ -24,6 +25,40 @@ error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# 从环境变量或 .env 文件读取配置值,避免 source .env 时被特殊字符影响
get_config_value() {
local key="$1"
local value="${!key}"
if [ -n "$value" ]; then
echo "$value"
return
fi
if [ -f ".env" ]; then
grep "^${key}=" .env 2>/dev/null | cut -d'=' -f2- | sed 's/^["'\'']//;s/["'\'']$//' | tr -d '\r' || true
fi
}
# 根据数据库配置选择 compose 文件
configure_compose_files() {
local db_url
db_url=$(get_config_value "DB_URL")
COMPOSE_FILES=(-f docker-compose.yml)
if [[ "$db_url" == *"host.docker.internal"* ]]; then
COMPOSE_FILES=(-f docker-compose.host-mysql.yml)
info "检测到 DB_URL 指向宿主机 MySQL,将使用 docker-compose.host-mysql.yml"
else
info "使用默认 docker-compose.yml(包含内置 MySQL 服务)"
fi
}
compose() {
docker-compose "${COMPOSE_FILES[@]}" "$@"
}
# 检查 Docker 环境
check_docker() {
if ! command -v docker &> /dev/null; then
@@ -148,6 +183,7 @@ check_security_config() {
deploy() {
# 检查安全配置
check_security_config
configure_compose_files
# 检查是否使用 Docker Hub 镜像
USE_DOCKER_HUB="${USE_DOCKER_HUB:-false}"
@@ -161,10 +197,15 @@ deploy() {
# 注意:这里需要手动修改 docker-compose.yml,或者使用环境变量
warn "请确保 docker-compose.yml 中已配置使用 image: wrbug/polyhermes:latest"
else
# 获取当前分支名作为版本号
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "dev")
# 如果分支名包含 /,替换为 -(Docker tag 不支持 /
DOCKER_VERSION=$(echo "$CURRENT_BRANCH" | tr '/' '-')
# 版本号:优先环境变量 DOCKER_VERSION,其次 .env 中的 DOCKER_VERSION,否则用当前分支名
if [ -z "${DOCKER_VERSION}" ] && [ -f ".env" ]; then
DOCKER_VERSION=$(grep "^DOCKER_VERSION=" .env 2>/dev/null | cut -d'=' -f2- | sed 's/^["'\'']//;s/["'\'']$//' | tr -d '\r')
fi
if [ -z "${DOCKER_VERSION}" ]; then
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "dev")
DOCKER_VERSION=$(echo "$CURRENT_BRANCH" | tr '/' '-')
fi
export DOCKER_VERSION
info "构建 Docker 镜像(本地构建,版本号: ${DOCKER_VERSION}..."
@@ -177,20 +218,20 @@ deploy() {
export GIT_TAG=${DOCKER_VERSION}
export GITHUB_REPO_URL=https://github.com/WrBug/PolyHermes
docker-compose build
compose build
fi
info "启动服务..."
docker-compose up -d
compose up -d
info "等待服务启动..."
sleep 5
info "检查服务状态..."
docker-compose ps
compose ps
info "查看日志: docker-compose logs -f"
info "停止服务: docker-compose down"
info "查看日志: docker-compose ${COMPOSE_FILES[*]} logs -f"
info "停止服务: docker-compose ${COMPOSE_FILES[*]} down"
}
# 主函数
@@ -216,9 +257,14 @@ main() {
info "访问地址: http://localhost:${SERVER_PORT:-80}"
echo ""
if [ "$USE_DOCKER_HUB" != "true" ]; then
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "dev")
DOCKER_VERSION=$(echo "$CURRENT_BRANCH" | tr '/' '-')
info "提示:本地构建的版本号为当前分支名: ${DOCKER_VERSION}"
if [ -z "${DOCKER_VERSION}" ] && [ -f ".env" ]; then
DOCKER_VERSION=$(grep "^DOCKER_VERSION=" .env 2>/dev/null | cut -d'=' -f2- | sed 's/^["'\'']//;s/["'\'']$//' | tr -d '\r')
fi
if [ -z "${DOCKER_VERSION}" ]; then
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "dev")
DOCKER_VERSION=$(echo "$CURRENT_BRANCH" | tr '/' '-')
fi
info "提示:本地构建的版本号: ${DOCKER_VERSION}(可在 .env 或环境变量中设置 DOCKER_VERSION"
info "生产环境推荐使用 Docker Hub 镜像:"
info " ./deploy.sh --use-docker-hub"
info " 或修改 docker-compose.yml 使用 image: wrbug/polyhermes:latest"
+50
View File
@@ -0,0 +1,50 @@
version: '3.8'
# 使用宿主机 MySQL 的部署配置。
# 适用于 DB_URL 指向 host.docker.internal 的场景,不会启动内置 MySQL 容器。
services:
app:
# 使用 Docker Hub 镜像(推荐生产环境)
# 取消注释下面一行,并注释掉 build 部分,即可使用 Docker Hub 的镜像
# image: wrbug/polyhermes:latest
build:
context: .
dockerfile: Dockerfile
# 本地构建时可以传递版本号参数(自动使用当前分支名)
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"
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
- TZ=${TZ:-Asia/Shanghai}
- SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE:-prod}
- DB_URL=${DB_URL:?DB_URL is required when using host MySQL}
- DB_USERNAME=${DB_USERNAME:-root}
- DB_PASSWORD=${DB_PASSWORD:-}
- SERVER_PORT=8000
# ⚠️ 安全警告:以下两个环境变量不能使用默认值,否则容器启动会失败
# 请在 .env 文件中设置,或通过环境变量传入
# 生成随机密钥:openssl rand -hex 32 (ADMIN_RESET_PASSWORD_KEY) 或 openssl rand -hex 64 (JWT_SECRET)
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
- ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY:-change-me-in-production}
# 日志级别配置(可选,默认值:root=WARN, app=INFO
# 可选值:TRACE, DEBUG, INFO, WARN, ERROR, OFF
- LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT:-WARN}
- LOG_LEVEL_APP=${LOG_LEVEL_APP:-INFO}
# 动态更新配置
- ALLOW_PRERELEASE=${ALLOW_PRERELEASE:-false}
- GITHUB_REPO=${GITHUB_REPO:-WrBug/PolyHermes}
volumes:
- /etc/localtime:/etc/localtime:ro
restart: unless-stopped
networks:
- polyhermes-network
networks:
polyhermes-network:
driver: bridge
+32
View File
@@ -0,0 +1,32 @@
# 加密价差策略文档 (Crypto Spread Strategy)
本目录集中存放与 Polymarket 加密市场加密价差策略相关的文档。
## 目录结构
```
crypto-tail-strategy/
├── README.md # 本说明
├── crypto-tail-auto-spread-dynamic-coefficient.md # 自动价差动态系数(中英通用)
├── zh/ # 中文文档
│ ├── crypto-tail-strategy-user-guide.md # 用户配置指南
│ ├── crypto-tail-strategy-ui-spec.md # UI 规格
│ ├── crypto-tail-strategy-tasks.md # 任务与验收
│ ├── crypto-tail-strategy-flow.md # 流程说明
│ ├── crypto-tail-strategy-min-spread-flow.md # 最小/最大价差流程
│ └── crypto-tail-strategy-market-data.md # 市场数据与周期
└── en/ # 英文文档
└── crypto-tail-strategy-user-guide.md # User configuration guide
```
## 文档说明
| 文档 | 说明 |
|------|------|
| **user-guide** (zh/en) | 面向用户的策略配置指南与 FAQ |
| **ui-spec** (zh) | 前端列表、表单、时间窗口、触发记录等 UI 规格 |
| **tasks** (zh) | 开发任务与验收项 |
| **flow** (zh) | 策略整体流程 |
| **min-spread-flow** (zh) | 价差过滤(最小/最大价差)流程 |
| **market-data** (zh) | Gamma slug、周期、时间区间、价格判断等市场数据规则 |
| **auto-spread-dynamic-coefficient** | 自动价差模式下动态系数计算说明 |
@@ -0,0 +1,131 @@
# AUTO 最小价差:100%→50% 动态系数方案
## 现状
- **BinanceKlineAutoSpreadService**:拉取历史 K 线 → IQR 剔除异常值 → 求平均得到「基础价差」→ **固定 ×0.7** 后缓存。
- 预加载(周期开始时):`computeAndCache()` 计算并缓存的是 **已乘 0.7** 的值。
- 触发时:`getAutoMinSpread()` 直接返回缓存值,等价于始终用 **70%** 的系数。
问题:70% 固定,无法随周期内时间变化放宽或收紧。
---
## 目标
1. **预加载提供 100% 数值**:缓存里存「基础价差」(IQR 平均),不再乘 0.7,即预加载 = 100% 基准。
2. **系数随区间时间点动态递减**:从 **100%** 线性递减到 **50%**,根据「当前时间在区间内的进度」计算。
---
## 方案一:按「触发窗口」进度(推荐)
**区间**:策略的触发窗口 `[periodStartUnix + windowStartSeconds, periodStartUnix + windowEndSeconds]`
- 窗口起始:系数 = **100%**(最严,价差要求最高)。
- 窗口内时间越靠后,系数越小;窗口结束:系数 = **50%**(最松,更容易触发)。
公式(**progress 按毫秒计算**,保证精度):
```
windowStartMs = (periodStartUnix + windowStartSeconds) * 1000
windowEndMs = (periodStartUnix + windowEndSeconds) * 1000
windowLenMs = windowEndMs - windowStartMs
nowMs = System.currentTimeMillis()
progress = (nowMs - windowStartMs) / windowLenMs
progress = clamp(progress, 0, 1)
// 比例系数 = progress × (100% - 50%),即已「消耗」的系数降幅
// 真正系数 = 100% - 比例系数
coefficient = 1.0 - progress × (1.0 - 0.5) = 1.0 - 0.5 × progress
effectiveMinSpread = baseSpread × coefficient
```
**计算示例**(时间区间 14分0秒~15分0秒,窗口 60 秒 = 60000 ms):
| 时刻 | 进入窗口的毫秒数 | progress(按毫秒) | 比例系数 | 真正系数 |
|------------|------------------|--------------------|--------------------|------------|
| 14:00 | 0 | 0/60000 = 0% | 0% × 50% = 0% | 100% |
| 14:15 | 15000 | 15000/60000 = 25% | 25% × 50% = 12.5% | **87.5%** |
| 14:30 | 30000 | 30000/60000 = 50% | 50% × 50% = 25% | 75% |
| 15:00 | 60000 | 60000/60000 = 100% | 100% × 50% = 50% | 50% |
即:在 14分15秒 时,progress = 15000ms / 60000ms = 25%,比例系数 = 12.5%,真正系数 = **87.5%**。实现时统一用毫秒计算 progress,避免秒级舍入误差。
- 需要策略的 `windowStartSeconds``windowEndSeconds` 传入计算处;若窗口长度为 0,可退化为系数 = 1.0 或 0.5(需约定)。
**优点**:与「加密价差策略只在窗口内触发」一致,时间语义清晰;毫秒级 progress 更精确。
**缺点**`getAutoMinSpread` 需要增加当前时间(毫秒)和窗口参数(或传整个 strategy)。
---
## 方案二:按「整周期」进度
**区间**:整个周期 `[periodStartUnix, periodStartUnix + intervalSeconds]`。**progress 按毫秒计算**。
```
periodStartMs = periodStartUnix * 1000
periodEndMs = (periodStartUnix + intervalSeconds) * 1000
periodLenMs = intervalSeconds * 1000L
nowMs = System.currentTimeMillis()
progress = (nowMs - periodStartMs) / periodLenMs
progress = clamp(progress, 0, 1)
coefficient = 1.0 - 0.5 * progress
effectiveMinSpread = baseSpread × coefficient
```
**优点**:只依赖 `intervalSeconds``periodStartUnix``nowSeconds`,不依赖窗口配置。
**缺点**:若窗口只占周期后半段,周期前半段也会在算系数,语义上不如按窗口精确。
---
## 实现要点
### 1. 缓存 100% 基准值
- **BinanceKlineAutoSpreadService**
- `computeAndCache()`:缓存 **不乘 0.7** 的 (avgUp, avgDown),即 IQR 平均后的原始值(100% 基准)。
- 可保留方法名与入参不变,仅去掉 `autoSpreadCoefficient` 的乘法;或新增 `getBaseSpread()` 语义,内部仍用同一缓存。
### 2. 动态系数计算位置
- 系数依赖「当前时间」和「区间定义」,适合在 **触发校验处** 算,而不是在 AutoSpread 服务里写死。
- **CryptoTailStrategyExecutionService.passMinSpreadCheck()**
- 当前:`getAutoMinSpread(intervalSeconds, periodStartUnix, outcomeIndex)` 得到已乘系数的值。
- 改为:
- 取「基础价差」:`getAutoMinSpreadBase(intervalSeconds, periodStartUnix, outcomeIndex)` 或由现有缓存返回 100% 值。
- 在 `passMinSpreadCheck` 内根据 `strategy.windowStartSeconds/windowEndSeconds``System.currentTimeMillis()`(毫秒)算 `progress`(按毫秒)→ `coefficient``effectiveMinSpread = baseSpread × coefficient`
### 3. 接口形态建议
- **BinanceKlineAutoSpreadService**
- `computeAndCache(interval, periodStartUnix)`:只缓存 100% 基准 (baseUp, baseDown),不再乘 0.7。
- `getAutoMinSpreadBase(interval, periodStartUnix, outcomeIndex): BigDecimal?`:仅返回缓存的基础价差;若需兼容旧名,可保留 `getAutoMinSpread` 但增加可选参数 `coefficient`,默认 1.0。
- **CryptoTailStrategyExecutionService**
- 在 `passMinSpreadCheck(strategy, periodStartUnix, outcomeIndex)` 内:
- 取 `baseSpread = getAutoMinSpreadBase(...)`
- 计算 `progress`(按方案一用 windowStart/End,或方案二用 interval)。
- `coefficient = 1.0 - 0.5 * progress`,再 `effectiveMinSpread = baseSpread * coefficient` 做比较。
### 4. 边界与兼容
- 窗口长度为 0:可约定 `coefficient = 0.5` 或 1.0,避免除零。
- 已有策略未配置窗口(全 0):若用方案一,可退化为「整周期」或固定 0.5/1.0」。
- 预加载逻辑(如 CryptoTailOrderbookWsService 的 `precomputeAutoMinSpreadForCurrentPeriods`)无需改,仍调用 `computeAndCache`,只是缓存内容变为 100% 基准。
---
## 小结
| 项目 | 内容 |
|------------|------|
| 预加载 | 缓存 100% 基础价差(去掉固定 0.7) |
| 系数范围 | 100% → 50% 线性递减 |
| 推荐区间 | 按触发窗口 `windowStartSeconds``windowEndSeconds` 计算进度(方案一) |
| progress | **按毫秒计算**`(nowMs - windowStartMs) / windowLenMs`,避免秒级舍入误差 |
| 计算位置 | 触发时在 `passMinSpreadCheck` 中算 progress → coefficient → effectiveMinSpread |
按上述实现后,AUTO 模式即为「预加载提供 100% 数值 + 随区间时间点从 100% 递减到 50%」的动态方案。
@@ -0,0 +1,469 @@
# Crypto Spread Strategy Configuration Guide
## Part 1: What is Crypto Spread Strategy?
Crypto Spread Strategy is an automated trading strategy designed specifically for Polymarket crypto markets' **5-minute** or **15-minute** "Up or Down" markets.
**Core Logic**: Within a specified time window, when the market price enters your set price range, the system will automatically buy at a fixed price (0.99) without manual operation.
**Use Cases**:
- You want to capture price fluctuations at the end of market cycles
- You want to automate trading execution and avoid manual monitoring
- You have some judgment about market trends and want to set conditions for automatic triggering
---
## Part 2: How the Strategy Works
### 2.1 Basic Flow
```
Cycle Start → Within Time Window → Price Enters Range → Auto Order
```
1. **Cycle**: Each market runs on fixed cycles (5 minutes or 15 minutes)
- 5-minute market: Every 5 minutes is a cycle (e.g., 10:00, 10:05, 10:10...)
- 15-minute market: Every 15 minutes is a cycle (e.g., 10:00, 10:15, 10:30...)
2. **Time Window**: You can set a time period within the cycle
- Example: 15-minute market, set window to "3 minutes ~ 12 minutes"
- Meaning: Only triggers between the 3rd and 12th minute after cycle start
3. **Price Range**: Set the trigger price range
- Example: Minimum price 0.50, Maximum price 0.80
- Meaning: Only triggers when market price is between 0.50 ~ 0.80
4. **Auto Order**: After conditions are met, the system automatically buys at price 0.99
### 2.2 Important Limitations
- **Maximum one trigger per cycle**: Within the same cycle, even if conditions are met multiple times, only one order is placed
- **Fixed order price**: All orders are submitted at price 0.99
- **Requires separate wallet**: It's recommended to use a dedicated wallet for crypto spread strategies to avoid conflicts with other operations (manual trading, copy trading, etc.)
---
## Part 3: Parameter Details
### 3.1 Basic Parameters
| Parameter | Description | Required | Example |
|-----------|-------------|----------|---------|
| **Account** | Select the wallet account for trading | ✅ | Account A |
| **Strategy Name** | Name your strategy for easy identification | ❌ | "BTC 15min Crypto Spread Strategy" |
| **Market** | Select the market to trade (5-minute or 15-minute) | ✅ | btc-updown-15m |
### 3.2 Cycle Settings
| Parameter | Description | Required | Example |
|-----------|-------------|----------|---------|
| **Cycle Length** | Automatically determined by selected market | ✅ | 15 minutes (900 seconds) |
| **Time Window Start** | Minutes after cycle start to begin monitoring | ✅ | 3 min 0 sec |
| **Time Window End** | Minutes after cycle start to stop monitoring | ✅ | 12 min 0 sec |
**Time Window Explanation**:
- 5-minute market: Can choose any time period within 0 ~ 5 minutes
- 15-minute market: Can choose any time period within 0 ~ 15 minutes
- **Start time must ≤ End time**
- Times outside the window won't trigger even if price conditions are met
**Example**:
- 15-minute market, window "3 min 0 sec ~ 12 min 0 sec"
- 0 ~ 3 minutes after cycle start: Not monitoring
- 3 ~ 12 minutes after cycle start: Monitoring price, triggers when conditions met
- 12 ~ 15 minutes after cycle start: Not monitoring
### 3.3 Price Range
| Parameter | Description | Required | Range | Example |
|-----------|-------------|----------|-------|---------|
| **Minimum Price (minPrice)** | Minimum trigger price | ✅ | 0 ~ 1 | 0.50 |
| **Maximum Price (maxPrice)** | Maximum trigger price | ❌ | 0 ~ 1, default 1 | 0.80 |
**Price Range Explanation**:
- Price range is a decimal between 0 ~ 1
- Only triggers when market price is within [Minimum Price, Maximum Price]
- If maximum price is not filled, defaults to 1.0 (triggers as long as price ≥ minimum price)
**Example**:
- Minimum price 0.50, Maximum price 0.80
- Price 0.45: Not triggered (below minimum)
- Price 0.60: Triggered ✅ (within range)
- Price 0.85: Not triggered (above maximum)
### 3.4 Investment Amount
| Parameter | Description | Required | Example |
|-----------|-------------|----------|---------|
| **Investment Mode** | Choose ratio or fixed amount | ✅ | Ratio / Fixed Amount |
| **Ratio (%)** | Percentage of account balance to invest | Conditionally required | 10% (Account has 100 USDC, invest 10 USDC) |
| **Fixed Amount (USDC)** | Fixed amount to invest each time | Conditionally required | 50 USDC |
**Investment Mode Explanation**:
**Mode 1: By Ratio (RATIO)**
- Each trigger invests a percentage of current available balance
- Example: Account has 100 USDC, set ratio to 10%
- 1st trigger: Invest 10 USDC
- 2nd trigger: If balance becomes 90 USDC, invest 9 USDC
- **Advantages**: Automatically adapts to account balance changes
- **Disadvantages**: Investment amount may vary each time
**Mode 2: Fixed Amount (FIXED)**
- Each trigger invests a fixed specified amount
- Example: Set fixed amount to 50 USDC
- Every trigger invests 50 USDC
- **Advantages**: Stable investment amount, easy to manage
- **Disadvantages**: Need to ensure sufficient account balance
**Notes**:
- Minimum order amount: At least 1 USDC
- If account balance is insufficient, order will fail and record failure reason
### 3.5 Spread Filter (Advanced Feature)
The spread filter controls whether to trigger based on Binance BTC/USDC K-line volatility. It supports two directions: **Minimum spread** and **Maximum spread**.
| Parameter | Description | Required | Example |
|-----------|-------------|----------|---------|
| **Spread Mode** | Choose spread validation method | ✅ | None / Fixed / Auto |
| **Spread Direction** | Min spread (trigger when ≥) or Max spread (trigger when ≤) | ✅ | Min spread / Max spread |
| **Spread Value** | Fill when using Fixed mode (unit: USDC) | Conditionally required | 30 |
**Spread Direction**:
- **Min spread**: Triggers only when Binance K-line spread **≥** the set value
- Use when you want to trade only when volatility is "large enough" (avoid entering when volatility is too small).
- **Max spread**: Triggers only when Binance K-line spread **≤** the set value
- Use when you want to trade only when volatility is "small enough" (avoid entering when volatility is too high).
**Three Spread Modes**:
**Mode 1: None (NONE)**
- No spread validation
- Triggers as long as time window and price range conditions are met
- **Suitable for**: Not concerned about Binance price volatility, only watching Polymarket price
**Mode 2: Fixed (FIXED)**
- Set a fixed spread value (unit: USDC)
- **Min spread**: Triggers when K-line spread ≥ set value
- Example: Set 30, spread ≥ 30 → triggered ✅, spread < 30 → not triggered
- **Max spread**: Triggers when K-line spread ≤ set value
- Example: Set 50, spread ≤ 50 → triggered ✅, spread > 50 → not triggered
- **Suitable for**: You have a clear spread threshold in mind
**Mode 3: Auto (AUTO)**
- System automatically calculates an effective spread from the last 20 K-lines
- Calculation logic:
1. Get recent 20 K-lines (matching strategy cycle)
2. Filter by direction (Up direction only looks at rising K-lines, Down direction only looks at falling K-lines)
3. Remove outliers (using IQR method)
4. Calculate average spread × 0.8 as effective spread
- **Min spread**: Triggers when K-line spread ≥ effective spread
- **Max spread**: Triggers when K-line spread ≤ effective spread
- **Suitable for**: Want automatic adjustment based on historical data without setting a specific value
**Spread Explanation**:
- Spread = |close price - open price| (Binance BTC/USDC for that K-line)
- Example: Open price 50000, close price 50030, spread = 30
- Larger spread means greater price volatility in that cycle
---
## Part 4: Configuration Examples
### Example 1: Simple Strategy (5-minute Market)
**Scenario**: In the last 2 minutes of a 5-minute market, if price is below 0.60, automatically buy 10 USDC
**Configuration**:
```
Account: Account A
Strategy Name: BTC 5min Simple Strategy
Market: btc-updown-5m
Time Window: 3 min 0 sec ~ 5 min 0 sec
Minimum Price: 0.00
Maximum Price: 0.60
Investment Mode: Fixed Amount
Fixed Amount: 10 USDC
Spread Mode: None
Enabled: On
```
**Explanation**:
- 0 ~ 3 minutes after cycle start: Not monitoring
- 3 ~ 5 minutes after cycle start: If price ≤ 0.60, automatically buy 10 USDC
---
### Example 2: Ratio Investment Strategy (15-minute Market)
**Scenario**: In the middle segment (5 ~ 10 minutes) of a 15-minute market, if price is between 0.40 ~ 0.70, invest 15% of account balance
**Configuration**:
```
Account: Account B
Strategy Name: BTC 15min Ratio Strategy
Market: btc-updown-15m
Time Window: 5 min 0 sec ~ 10 min 0 sec
Minimum Price: 0.40
Maximum Price: 0.70
Investment Mode: By Ratio
Ratio: 15%
Spread Mode: None
Enabled: On
```
**Explanation**:
- Assuming account balance is 100 USDC
- 5 ~ 10 minutes after cycle start: If price is between 0.40 ~ 0.70, automatically buy about 15 USDC (100 × 15%)
---
### Example 3: Strategy with Spread Filter (15-minute Market)
**Scenario**: In the latter segment (10 ~ 14 minutes) of a 15-minute market, if price is between 0.50 ~ 0.80 and Binance spread ≥ 50, invest 20 USDC
**Configuration**:
```
Account: Account C
Strategy Name: BTC 15min Spread Strategy
Market: btc-updown-15m
Time Window: 10 min 0 sec ~ 14 min 0 sec
Minimum Price: 0.50
Maximum Price: 0.80
Investment Mode: Fixed Amount
Fixed Amount: 20 USDC
Spread Mode: Fixed
Spread Direction: Min spread
Spread Value: 50
Enabled: On
```
**Explanation**:
- 10 ~ 14 minutes after cycle start: Only triggers when both conditions are met:
1. Price is between 0.50 ~ 0.80 ✅
2. Spread direction is "Min spread" and Binance spread ≥ 50 ✅
- If spread is only 30, won't trigger even if price condition is met
---
### Example 4: Auto Spread Strategy (15-minute Market)
**Scenario**: In the early segment (2 ~ 8 minutes) of a 15-minute market, if price is between 0.30 ~ 0.90, invest 20% of account balance, spread calculated automatically by system
**Configuration**:
```
Account: Account D
Strategy Name: BTC 15min Auto Spread Strategy
Market: btc-updown-15m
Time Window: 2 min 0 sec ~ 8 min 0 sec
Minimum Price: 0.30
Maximum Price: 0.90
Investment Mode: By Ratio
Ratio: 20%
Spread Mode: Auto
Spread Direction: Min spread
Enabled: On
```
**Explanation**:
- System automatically calculates effective spread from the last 20 K-lines
- 2 ~ 8 minutes after cycle start: Only triggers when both conditions are met:
1. Price is between 0.30 ~ 0.90 ✅
2. Spread direction is "Min spread" and Binance spread ≥ system-calculated effective spread ✅
---
## Part 5: Frequently Asked Questions
### Q1: When will the strategy trigger?
**A**: All of the following conditions must be met simultaneously:
1. ✅ Current time is within the time window
2. ✅ Market price is within [Minimum Price, Maximum Price] range
3. ✅ This cycle hasn't triggered yet (maximum one trigger per cycle)
4. ✅ If spread filter is set, Binance spread and spread direction must both be satisfied
### Q2: Why didn't my strategy trigger?
**Possible reasons**:
1. **Time window incorrect**: Current time is not within the set time window
2. **Price not in range**: Market price is not within [Minimum Price, Maximum Price] range
3. **Already triggered this cycle**: This cycle has already triggered once, won't trigger again
4. **Spread not met**: If spread filter is set, Binance spread or spread direction requirement is not satisfied
5. **Insufficient account balance**: Account balance is less than the set investment amount
6. **Strategy not enabled**: Check if strategy's enabled status is "On"
### Q3: What does "maximum one trigger per cycle" mean?
**A**: Within each cycle (5 minutes or 15 minutes), even if conditions are met multiple times, only one order is placed.
**Example**:
- 15-minute market, cycle starts at 10:00
- At 10:05, price meets condition, triggers order ✅
- At 10:08, price meets condition again, but won't place another order (already triggered this cycle)
- At 10:15, new cycle starts, can trigger again
### Q4: What's the difference between fixed amount and ratio?
**Fixed Amount**:
- Invests the same amount each trigger
- Example: Set 50 USDC, every trigger is 50 USDC
- Need to ensure sufficient account balance
**By Ratio**:
- Invests a percentage of account balance each trigger
- Example: Set 10%, when account has 100 USDC, invest 10 USDC, after balance becomes 90 USDC, next trigger invests 9 USDC
- Automatically adapts to balance changes
### Q5: What's the use of the spread filter feature?
**A**: The spread filter decides whether to trigger based on Binance BTC/USDC K-line volatility. It supports two directions.
**Min spread** (trigger when spread **≥** set value):
- Avoids triggering when volatility is too small
- Example: Set 30, only triggers when spread ≥ 30
**Max spread** (trigger when spread **≤** set value):
- Avoids triggering when volatility is too high (lower risk)
- Example: Set 50, only triggers when spread ≤ 50
**Three mode selection suggestions**:
- **None**: Not concerned about Binance price volatility, only watching Polymarket price
- **Fixed**: You know the expected spread threshold (use with Min or Max spread direction)
- **Auto**: Want effective spread calculated from historical data without setting a specific value
### Q6: Why is it recommended to use a separate wallet?
**A**: To avoid the following issues:
1. **Balance changes**: If wallet is also used for manual trading, balance changes may affect strategy execution
2. **Position conflicts**: Manual trading and strategy trading may conflict
3. **Management confusion**: Difficult to distinguish which orders are from strategy vs manual
**Recommendation**: Create a dedicated wallet, only for crypto spread strategies.
### Q7: Why is the order price fixed at 0.99?
**A**: This is a design feature of the strategy:
- 0.99 is the highest price in the market (close to 1.0)
- Buying at the highest price ensures orders execute quickly
- Although buying price is higher, the strategy's core is capturing market volatility, not pursuing optimal price
### Q8: Does the strategy depend on auto-redeem functionality?
**A**: Yes, crypto spread strategy depends on auto-redeem functionality.
**Reasons**:
- Strategy orders create positions after execution
- These positions need to be automatically redeemed after market settlement
- If auto-redeem is not configured, positions may not be redeemed in time
**Configuration Requirements**:
- Configure Builder API Key in "System Settings"
- Enable auto-redeem functionality
---
## Part 6: Important Notes
### 6.1 Account Requirements
- ✅ Account must have API Key, API Secret, API Passphrase configured
- ✅ Account must have sufficient USDC balance
- ✅ Recommended to use a dedicated wallet to avoid conflicts with other operations
### 6.2 Time Window Settings
- ⚠️ Start time must ≤ End time
- ⚠️ Time window cannot exceed cycle length (5-minute market ≤ 5 minutes, 15-minute market ≤ 15 minutes)
- ⚠️ Recommended to set reasonable time windows, avoid triggering at cycle start or end
### 6.3 Price Range Settings
- ⚠️ Minimum price must ≤ Maximum price
- ⚠️ Price range is a decimal between 0 ~ 1
- ⚠️ Recommended to set reasonable price ranges based on market conditions
### 6.4 Investment Amount Settings
- ⚠️ Minimum order amount: At least 1 USDC
- ⚠️ Ensure sufficient account balance to avoid order failures
- ⚠️ Ratio mode: Note the impact of account balance changes on investment amount
### 6.5 Spread Filter Settings
- ⚠️ Spread direction: Min spread means "trigger when ≥"; Max spread means "trigger when ≤". Choose according to your need.
- ⚠️ Fixed mode: Need to fill a reasonable spread value (unit: USDC)
- ⚠️ Auto mode: System calculates effective spread within the window, no manual value needed
- ⚠️ Overly strict spread (min spread too high or max spread too low) may make the strategy rarely trigger
### 6.6 Other Notes
- ⚠️ Strategy is enabled by default after creation, can disable "Enabled Status" if need to pause
- ⚠️ Maximum one trigger per cycle, set trigger conditions reasonably
- ⚠️ Strategy depends on auto-redeem functionality, ensure Builder API Key is configured
- ⚠️ Recommended to regularly check trigger records to understand strategy execution
---
## Part 7: Strategy Management
### 7.1 View Strategy List
On the "Crypto Spread Strategy" page, you can view all strategies:
- Strategy name
- Market information
- Time window
- Price range
- Investment mode
- Enabled status
- Last trigger time
- Statistics like total profit, win rate
### 7.2 View Trigger Records
Click on a strategy to view detailed trigger records:
- Trigger time
- Market price
- Investment amount
- Order ID
- Order status (success/fail)
- Settlement information (profit/loss, win rate, etc.)
### 7.3 Edit Strategy
You can modify strategy parameters at any time:
- Time window
- Price range
- Investment mode
- Spread filter (mode, direction, value)
- Enabled status
**Note**: Modified strategies take effect in the next cycle.
### 7.4 Delete Strategy
After deleting a strategy:
- Strategy configuration is deleted
- Historical trigger records are retained
- Already placed orders are not affected
---
## Part 8: Summary
Crypto Spread Strategy is a powerful automated trading tool that can help you:
1. **Automated Trading**: No need for manual monitoring, system executes automatically
2. **Precise Control**: Precisely control trigger conditions through time windows and price ranges
3. **Flexible Configuration**: Supports both ratio and fixed amount investment modes
4. **Risk Filtering**: Control volatility conditions through spread filter (min spread / max spread)
**Usage Recommendations**:
- For first-time users, start with simple strategies (no spread filter)
- After familiarizing, try adding spread filter features
- Regularly check trigger records, adjust strategy parameters based on actual situation
- Use a dedicated wallet to avoid conflicts with other operations
**Happy Trading!** 🚀
@@ -0,0 +1,204 @@
# 加密价差策略 - 流程图
## 一、整体架构
```
┌─────────────────┐ POST 创建/更新 ┌──────────────────────────┐
│ 前端 / API │ ──────────────────────►│ CryptoTailStrategyController│
└─────────────────┘ └──────────────┬─────────────┘
┌──────────────────────────┐
│ CryptoTailStrategyService │
│ create / update │
│ save → publishEvent │
└──────────────┬─────────────┘
┌─────────────────────────────────────────┼─────────────────────────────────────────┐
│ CryptoTailStrategyChangedEvent │ │
▼ ▼ ▼
┌──────────────────────────────┐ ┌──────────────────────────────┐ ┌──────────────────────────────┐
│ CryptoTailStrategyScheduler │ │ CryptoTailOrderbookWsService │ │ (其他监听方,如有) │
@EventListener │ │ @EventListener │ └──────────────────────────────┘
│ → runCycle() 一次(补充) │ │ → refreshAndSubscribe() │
└──────────────┬───────────────┘ └──────────────┬───────────────┘
│ │
▼ │
┌──────────────────────────────┐ │
│ CryptoTailStrategyExecution │ │ 每 25 秒 + 事件时
│ runCycle() │ │ refreshAndSubscribe()
│ (HTTP 拉订单簿,满足则下单) │ ▼
└──────────────────────────────┘ ┌──────────────────────────────┐
│ CLOB Market WebSocket │
│ wss://.../ws/market │
│ subscribe assets_ids │
└──────────────┬───────────────┘
│ book / price_change
┌──────────────────────────────┐
│ onBestBid(tokenId, bestBid) │
│ → tryTriggerWithPriceFromWs │
└──────────────┬───────────────┘
┌──────────────────────────────┐
│ CryptoTailStrategyExecution │
│ placeOrderForTrigger │
│ → CLOB 下单 + 写触发记录 │
└──────────────────────────────┘
```
---
## 二、策略创建/更新流程(API → 事件)
```mermaid
sequenceDiagram
participant API as Controller
participant Svc as CryptoTailStrategyService
participant DB as DB
participant Event as ApplicationEventPublisher
API->>Svc: create(request) / update(request)
Svc->>Svc: 参数校验(账户、窗口、价格、金额模式等)
Svc->>DB: save(entity)
Svc->>Event: publishEvent(CryptoTailStrategyChangedEvent)
Svc->>API: Result.success(dto)
```
- **创建**:校验通过后落库,发布 `CryptoTailStrategyChangedEvent`,返回 DTO。
- **更新**:同上,更新实体后发布同一事件。
- **删除**:不发布事件(策略已移除,WS 下次刷新订阅时会自然不再包含该策略)。
---
## 三、策略变更后:双路响应
事件发出后,两个监听方并行执行,互不阻塞:
| 监听方 | 动作 | 说明 |
|--------|------|------|
| **CryptoTailStrategyScheduler** | `onStrategyChanged``runCycle()` 一次 | 用 HTTP 拉订单簿做一轮检查,作为 WS 未就绪时的补充。 |
| **CryptoTailOrderbookWsService** | `onStrategyChanged``refreshAndSubscribe()` | 按当前启用策略重新算 token 列表,向 WS 发送新的 `assets_ids` 订阅。 |
```mermaid
flowchart LR
subgraph 事件
E[CryptoTailStrategyChangedEvent]
end
subgraph 调度器
S[Scheduler.onStrategyChanged]
R[executionService.runCycle]
S --> R
end
subgraph WS服务
W[OrderbookWsService.onStrategyChanged]
Ref[refreshAndSubscribe]
W --> Ref
end
E --> S
E --> W
```
---
## 四、WebSocket 订单簿监听流程(主路径)
```mermaid
flowchart TB
subgraph 启动与连接
A[PostConstruct init] --> B[connect]
B --> C[OkHttp WebSocket 连接 wss://.../ws/market]
C --> D[onOpen: refreshAndSubscribe]
end
subgraph 订阅维护
D --> E[buildSubscriptionMap]
E --> F[遍历 enabled 策略]
F --> G[当前周期 periodStartUnix]
G --> H[slug = prefix-periodStartUnix]
H --> I[Gamma getEventBySlug]
I --> J[得到 tokenIds]
J --> K[tokenId → List of WsBookEntry]
K --> L[发送 type=MARKET, assets_ids=[...]]
T[每 25 秒 @Scheduled] --> E
EV[onStrategyChanged] --> E
end
subgraph 收消息与触发
M[onMessage: book / price_change]
M --> N[解析 asset_id, best_bid]
N --> O[onBestBid tokenId, bestBid]
O --> P[查 tokenToEntries 得到策略列表]
P --> Q[筛时间窗内]
Q --> R[scope.launch tryTriggerWithPriceFromWs]
R --> S[placeOrderForTrigger]
end
L --> M
```
- **buildSubscriptionMap**:只包含「当前时间仍在窗口内」的策略(`nowSeconds < windowEnd`),并只订阅这些策略对应周期的 token。
- **onBestBid**:再按当前时间过滤一次时间窗,对每个命中策略在协程里调用 `tryTriggerWithPriceFromWs`,内部会查「本周期是否已触发」和价格区间,通过则 `placeOrderForTrigger`
---
## 五、执行层:下单条件与顺序(ExecutionService
无论来自 **runCycleHTTP** 还是 **tryTriggerWithPriceFromWsWS**,最终都走同一套下单逻辑。
```mermaid
flowchart TB
subgraph runCycle 入口
A[runCycle] --> B[findAllByEnabledTrue]
B --> C[processStrategy 每个策略]
C --> D[在时间窗? 本周期已触发?]
D --> E[Gamma getEventBySlug]
E --> F[HTTP getOrderbook 两个 token]
F --> G[第一个 bestBid 在 minPrice~maxPrice?]
G --> H[placeOrderForTrigger]
end
subgraph tryTriggerWithPriceFromWs 入口
I[WS onBestBid] --> J[tryTriggerWithPriceFromWs]
J --> K[本周期已触发? bestBid 在区间?]
K --> H
end
subgraph placeOrderForTrigger 统一
H --> L[账户、API 凭证]
L --> M[余额、下单金额]
M --> N[最优价、数量]
N --> O[签名、CLOB 下单]
O --> P[保存 CryptoTailStrategyTrigger]
end
```
- **每周期最多触发一次**:由 `triggerRepository.findByStrategyIdAndPeriodStartUnix` 保证。
- **价格区间**`minPrice ≤ bestBid ≤ maxPrice` 才触发。
- **时间窗**:仅当 `windowStart ≤ now < windowEnd`(以当前周期的 `periodStartUnix` 为基准)才参与检查/下单。
---
## 六、关键数据流小结
| 阶段 | 输入 | 输出/动作 |
|------|------|-----------|
| 创建/更新策略 | API 请求体 | 落库 + 发布 `CryptoTailStrategyChangedEvent` |
| 事件 → 调度器 | 事件 | 执行一次 `runCycle()`HTTP 拉订单簿,满足则下单) |
| 事件 → WS 服务 | 事件 | `refreshAndSubscribe()`,更新订阅的 `assets_ids` |
| 定时刷新订阅 | 每 25 秒 | `refreshAndSubscribe()`,保证新周期、新策略被订阅 |
| WS 收 book/price_change | asset_id, best_bid | `onBestBid` → 时间窗内策略 → `tryTriggerWithPriceFromWs` → 未触发且价格在区间则 `placeOrderForTrigger` |
| placeOrderForTrigger | 策略、周期、token、outcome、价格 | 账户/余额/价格/签名 → CLOB 下单 → 写触发记录 |
---
## 七、涉及类与职责
| 类 | 职责 |
|----|------|
| **CryptoTailStrategyController** | 接收 list/create/update/delete/triggers/marketOptions 的 POST。 |
| **CryptoTailStrategyService** | 策略 CRUD、校验、发布 `CryptoTailStrategyChangedEvent`。 |
| **CryptoTailStrategyScheduler** | 监听策略变更事件,执行一次 `runCycle()`。 |
| **CryptoTailOrderbookWsService** | 连接 CLOB Market WS、维护订阅(事件 + 每 25 秒)、处理 book/price_change、调用 `tryTriggerWithPriceFromWs`。 |
| **CryptoTailStrategyExecutionService** | `runCycle()`HTTP 路径)、`tryTriggerWithPriceFromWs()`WS 路径)、`placeOrderForTrigger()`(统一下单与写触发记录)。 |

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