Compare commits

...

36 Commits

Author SHA1 Message Date
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 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
80 changed files with 7157 additions and 317 deletions
+1 -1
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
@@ -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>
/**
* 获取交易状态
@@ -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,19 @@ 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
)
/**
@@ -142,6 +166,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.*
@@ -164,10 +165,10 @@ interface PolymarketClobApi {
/**
* 获取服务器时间
* 端点: /time
* 端点: /time 返回纯数字(Unix 时间戳),非 JSON
*/
@GET("/time")
suspend fun getServerTime(): Response<ServerTimeResponse>
suspend fun getServerTime(): Response<ResponseBody>
}
// 请求和响应数据类
@@ -334,7 +335,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 +365,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 标记
)
@@ -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))
}
}
@@ -0,0 +1,183 @@
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.enums.ErrorCode
import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService
import com.wrbug.polymarketbot.service.cryptotail.CryptoTailStrategyService
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
@RestController
@RequestMapping("/api/crypto-tail-strategy")
class CryptoTailStrategyController(
private val cryptoTailStrategyService: CryptoTailStrategyService,
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("/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)
)
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
val pair = binanceKlineAutoSpreadService.computeAndCache(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))
}
}
}
@@ -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个)
)
/**
* 账户更新请求
*/
@@ -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,157 @@
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",
val minSpreadMode: String = "NONE",
val minSpreadValue: String? = null,
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,
val minSpreadMode: String? = null,
val minSpreadValue: 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",
val minSpreadMode: String = "NONE",
val minSpreadValue: String? = null,
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
)
@@ -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,62 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
import java.math.BigDecimal
import com.wrbug.polymarketbot.util.toSafeBigDecimal
/**
* 加密市场尾盘策略实体
* 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,
@Column(name = "min_spread_mode", nullable = false, length = 16)
val minSpreadMode: String = "NONE",
@Column(name = "min_spread_value", precision = 20, scale = 8)
val minSpreadValue: BigDecimal? = null,
@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,64 @@
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 = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@Column(name = "notification_sent", nullable = false)
var notificationSent: Boolean = false
)
@@ -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,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,43 @@
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
}
@@ -3,10 +3,12 @@ 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.getEventSlug
import com.wrbug.polymarketbot.service.common.PolymarketClobService
@@ -66,11 +68,6 @@ class AccountService(
return Result.failure(IllegalArgumentException("无效的钱包地址格式"))
}
// 2. 检查地址是否已存在
if (accountRepository.existsByWalletAddress(request.walletAddress)) {
return Result.failure(IllegalArgumentException("该钱包地址已存在"))
}
// 3. 验证私钥和地址的对应关系
// 注意:前端已经验证了私钥和地址的对应关系,这里只做格式验证
// 如果需要更严格的验证,可以使用以太坊库(如 web3j)进行验证
@@ -104,7 +101,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 +118,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) }
// 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 +175,192 @@ 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)
}
}
/**
* 更新账户信息
*/
@@ -827,7 +1017,7 @@ class AccountService(
"0"
}
// 11. 创建并签名订单(使用计算后的卖出数量)
// 11. 创建并签名订单(使用计算后的卖出数量,按账户钱包类型使用对应 signatureType
val signedOrder = try {
orderSigningService.createAndSignOrder(
privateKey = decryptedPrivateKey,
@@ -836,7 +1026,7 @@ class AccountService(
side = "SELL",
price = sellPrice,
size = sellQuantity.toPlainString(), // 使用计算后的卖出数量
signatureType = 2, // Browser Wallet(与正确订单数据一致)
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType),
nonce = "0",
feeRateBps = feeRateBps, // 使用动态获取的费率
expiration = expiration
@@ -1189,13 +1379,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 +1400,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 +1453,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
@@ -1280,11 +1473,13 @@ class AccountService(
val decryptedPrivateKey = decryptPrivateKey(account)
// 调用区块链服务赎回仓位
val walletTypeEnum = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
val redeemResult = blockchainService.redeemPositions(
privateKey = decryptedPrivateKey,
proxyAddress = account.proxyAddress,
conditionId = marketId,
indexSets = indexSets
indexSets = indexSets,
walletType = walletTypeEnum
)
redeemResult.fold(
@@ -1315,7 +1510,7 @@ class AccountService(
)
}
// 6. 发送赎回推送通知(异步,不阻塞)
// 7. 发送赎回推送通知(异步,不阻塞)
notificationScope.launch {
try {
// 获取当前语言设置
@@ -380,14 +380,10 @@ class PositionCheckService(
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 ->
@@ -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,
@@ -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,95 @@
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% 基准值 (interval, period)
* 触发时由调用方按窗口进度计算动态系数100%50%后得到有效最小价差不在保存策略时计算
*/
@Service
class BinanceKlineAutoSpreadService(
private val retrofitFactory: RetrofitFactory
) {
private val logger = LoggerFactory.getLogger(BinanceKlineAutoSpreadService::class.java)
private val symbol = "BTCUSDC"
private val historyLimit = 20
private val minSamplesAfterIqr = 3
/** (intervalSeconds, periodStartUnix) -> (baseSpreadUp, baseSpreadDown)100% 基准价差 */
private val cache = ConcurrentHashMap<String, Pair<BigDecimal, BigDecimal>>()
private fun cacheKey(intervalSeconds: Int, periodStartUnix: Long): String = "$intervalSeconds-$periodStartUnix"
/** 返回该周期、该方向的 100% 基准价差,供调用方按窗口进度应用动态系数。 */
fun getAutoMinSpreadBase(intervalSeconds: Int, periodStartUnix: Long, outcomeIndex: Int): BigDecimal? {
val key = cacheKey(intervalSeconds, periodStartUnix)
val (up, down) = cache[key] ?: run {
computeAndCache(intervalSeconds, periodStartUnix) ?: return null
}
return if (outcomeIndex == 0) up else down
}
/** 计算并缓存 100% 基准价差(IQR 平均,不乘系数)。预加载与触发时共用此缓存。 */
fun computeAndCache(intervalSeconds: Int, periodStartUnix: Long): Pair<BigDecimal, BigDecimal>? {
val intervalStr = if (intervalSeconds == 300) "5m" else "15m"
val endTimeMs = periodStartUnix * 1000L
val klines = fetchKlines(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(intervalSeconds, periodStartUnix)] = baseUp to baseDown
logger.info(
"尾盘自动价差已计算并缓存(100%基准): interval=${intervalSeconds}s periodStartUnix=$periodStartUnix | " +
"Up方向: 样本数=${spreadsUp.size}, baseSpreadUp=${baseUp.toPlainString()} | " +
"Down方向: 样本数=${spreadsDown.size}, baseSpreadDown=${baseDown.toPlainString()}"
)
return baseUp to baseDown
}
private fun fetchKlines(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,154 @@
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.AtomicBoolean
/**
* 币安 K 线 WebSocket订阅 BTCUSDC 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 = createClient().build()
/** (intervalSeconds, periodStartUnix) -> (open, close) */
private val openCloseByPeriod = ConcurrentHashMap<String, Pair<BigDecimal, BigDecimal>>()
private var ws5m: WebSocket? = null
private var ws15m: WebSocket? = null
private var reconnectJob: Job? = null
private val connected5m = AtomicBoolean(false)
private val connected15m = AtomicBoolean(false)
init {
connectAll()
}
private fun key(intervalSeconds: Int, periodStartUnix: Long): String = "$intervalSeconds-$periodStartUnix"
fun getCurrentOpenClose(intervalSeconds: Int, periodStartUnix: Long): Pair<BigDecimal, BigDecimal>? {
return openCloseByPeriod[key(intervalSeconds, periodStartUnix)]
}
/** 供 API 健康检查使用:5m / 15m 连接是否正常 */
fun getConnectionStatuses(): Map<String, Boolean> = mapOf(
"5m" to connected5m.get(),
"15m" to connected15m.get()
)
private fun connectAll() {
if (ws5m != null && ws15m != null) return
connectStream("btcusdc@kline_5m") { intervalSec, tMs, openP, closeP ->
val periodSec = tMs / 1000
openCloseByPeriod[key(intervalSec, periodSec)] = openP to closeP
}.also { ws5m = it }
connectStream("btcusdc@kline_15m") { intervalSec, tMs, openP, closeP ->
val periodSec = tMs / 1000
openCloseByPeriod[key(intervalSec, periodSec)] = openP to closeP
}.also { ws15m = it }
}
private fun connectStream(
streamName: String,
onKline: (intervalSeconds: Int, openTimeMs: Long, open: BigDecimal, close: BigDecimal) -> Unit
): WebSocket {
val url = "$wsBase/ws/$streamName"
val intervalSeconds = when {
streamName.contains("kline_5m") -> 300
streamName.contains("kline_15m") -> 900
else -> 300
}
val request = Request.Builder().url(url).build()
val connectedFlag = when {
streamName.contains("kline_5m") -> connected5m
streamName.contains("kline_15m") -> connected15m
else -> null
}
val ws = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
connectedFlag?.set(true)
}
override fun onMessage(webSocket: WebSocket, text: String) {
parseKlineMessage(text, intervalSeconds)?.let { (tMs, o, c) ->
onKline(intervalSeconds, tMs, o, c)
}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
connectedFlag?.set(false)
logger.warn("币安 K 线 WS 异常 $streamName: ${t.message}")
scheduleReconnect()
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
connectedFlag?.set(false)
if (code != 1000) scheduleReconnect()
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
connectedFlag?.set(false)
}
})
logger.info("币安 K 线 WS 已连接: $streamName")
return ws
}
private fun parseKlineMessage(text: String, intervalSeconds: Int): 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
ws5m?.close(1000, "reconnect")
ws15m?.close(1000, "reconnect")
ws5m = null
ws15m = null
connected5m.set(false)
connected15m.set(false)
logger.info("币安 K 线 WS 尝试重连")
connectAll()
}
}
@PreDestroy
fun destroy() {
reconnectJob?.cancel()
ws5m?.close(1000, "shutdown")
ws15m?.close(1000, "shutdown")
ws5m = null
ws15m = null
}
}
@@ -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
@@ -93,13 +94,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 +111,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")
@@ -580,39 +581,35 @@ 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 walletType 钱包类型MAGIC SAFE用于选择执行路径
* @return 交易哈希
*/
suspend fun redeemPositions(
privateKey: String,
proxyAddress: String,
conditionId: String,
indexSets: List<BigInteger>
indexSets: List<BigInteger>,
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)
relayClientService.execute(privateKey, proxyAddress, redeemTx, walletType)
} catch (e: Exception) {
logger.error("赎回仓位失败: ${e.message}", e)
Result.failure(e)
@@ -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)
@@ -220,10 +220,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 +233,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)
@@ -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,31 @@ import java.math.RoundingMode
*/
@Service
class OrderSigningService {
private val logger = LoggerFactory.getLogger(OrderSigningService::class.java)
// Polygon 主网合约地址
/**
* 根据是否为 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
}
// Polygon 主网合约地址(标准 CTF Exchange
private val EXCHANGE_CONTRACT = "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E"
// Neg Risk CTF Exchangeneg risk 市场需用此合约签约,否则服务端返回 invalid signature
private val NEG_RISK_EXCHANGE_CONTRACT = "0xC5d563A36AE78145C45a50134d48A1215220f80a"
private val CHAIN_ID = 137L
// USDC 有 6 位小数
@@ -147,6 +168,7 @@ class OrderSigningService {
* @param nonce nonce默认 "0"
* @param feeRateBps 费率基点默认 "0"
* @param expiration 过期时间戳0 表示永不过期
* @param exchangeContract 签约用 exchange 合约地址null 时用标准 CTF Exchangeneg risk 市场需传 Neg Risk Exchange
* @return 签名的订单对象
*/
fun createAndSignOrder(
@@ -159,7 +181,8 @@ class OrderSigningService {
signatureType: Int = 2, // 默认使用 Browser Wallet(与正确订单数据一致)
nonce: String = "0",
feeRateBps: String = "0",
expiration: String = "0"
expiration: String = "0",
exchangeContract: String? = null
): SignedOrderObject {
try {
// 1. 从私钥获取签名地址
@@ -192,10 +215,11 @@ class OrderSigningService {
logger.debug("Salt: $salt, Expiration: $expiration, Nonce: $nonce, FeeRateBPS: $feeRateBps")
logger.debug("Signature Type: $signatureType, Chain ID: $CHAIN_ID")
// 6. 构建订单数据并签名
// 6. 构建订单数据并签名neg risk 市场需用 NEG_RISK_EXCHANGE_CONTRACT
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,
@@ -257,20 +281,20 @@ class OrderSigningService {
signatureType: Int
): String {
try {
// 1. 私钥创建 BigInteger
// 1. 私钥与密钥对
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
// 2. 编码域分隔符(verifyingContract 显式小写,与 EIP-712 约定一致)
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 参数,而不是硬编码
// signatureType1 = POLY_PROXY (Magic), 2 = POLY_GNOSIS_SAFE (Safe), 0 = EOA
val orderHash = com.wrbug.polymarketbot.util.Eip712Encoder.encodeExchangeOrder(
salt = salt,
maker = maker,
@@ -283,29 +307,25 @@ class OrderSigningService {
nonce = nonce,
feeRateBps = feeRateBps,
side = side,
signatureType = signatureType // 使用传入的参数
signatureType = signatureType
)
// 4. 计算完整结构化数据哈希
// 4. 计算完整 EIP-712 结构化数据哈希
val structuredHash = com.wrbug.polymarketbot.util.Eip712Encoder.hashStructuredData(
domainSeparator = domainSeparator,
messageHash = orderHash
)
// 5. 使用私钥签名
// 5. 使用私钥签名needToHash=false,对 32 字节 hash 直接签名)
val signature = org.web3j.crypto.Sign.signMessage(structuredHash, ecKeyPair, false)
// 6. 组合签名(r + s + v
// 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)
@@ -313,12 +333,17 @@ class OrderSigningService {
}
}
/** 并发安全:确保同一毫秒内多次调用生成唯一 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,
@@ -556,6 +573,11 @@ open class CopyOrderTrackingService(
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 毫秒
// 每次重试都会重新生成salt并重新签名,确保签名唯一性
@@ -563,6 +585,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 +594,8 @@ open class CopyOrderTrackingService(
owner = account.apiKey,
copyTradingId = copyTrading.id!!,
tradeId = trade.id,
feeRateBps = feeRateBps
feeRateBps = feeRateBps,
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
)
// 处理订单创建失败
@@ -584,8 +609,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 +622,7 @@ open class CopyOrderTrackingService(
telegramNotificationService?.sendOrderFailureNotification(
marketTitle = marketTitle,
marketId = trade.market,
marketId = effectiveMarketId,
marketSlug = marketSlug,
side = "BUY",
outcome = null, // 失败时可能没有 outcome
@@ -631,9 +656,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 +928,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 需要使用实际卖出价格
@@ -994,7 +1027,12 @@ open class CopyOrderTrackingService(
"0"
}
// 9. 创建并签名卖出订单
// 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}")
// 10. 创建并签名卖出订单(按账户钱包类型使用对应 signatureType
val signedOrder = try {
orderSigningService.createAndSignOrder(
privateKey = decryptedPrivateKey,
@@ -1003,17 +1041,18 @@ open class CopyOrderTrackingService(
side = "SELL",
price = sellPrice.toString(),
size = totalMatched.toString(),
signatureType = 2, // Browser Wallet
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType),
nonce = "0",
feeRateBps = feeRateBps, // 使用动态获取的费率
expiration = "0"
expiration = "0",
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(
@@ -1023,7 +1062,7 @@ open class CopyOrderTrackingService(
deferExec = false
)
// 11. 创建带认证的CLOB API客户端(使用解密后的凭证)
// 12. 创建带认证的CLOB API客户端(使用解密后的凭证)
val clobApi = retrofitFactory.createClobApi(
account.apiKey,
apiSecret,
@@ -1031,12 +1070,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 +1084,8 @@ open class CopyOrderTrackingService(
owner = account.apiKey,
copyTradingId = copyTrading.id,
tradeId = leaderSellTrade.id,
feeRateBps = feeRateBps
feeRateBps = feeRateBps,
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
)
if (createOrderResult.isFailure) {
@@ -1128,7 +1169,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 价格
@@ -1137,12 +1180,15 @@ open class CopyOrderTrackingService(
* @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 +1196,8 @@ open class CopyOrderTrackingService(
owner: String,
copyTradingId: Long,
tradeId: String,
feeRateBps: String
feeRateBps: String,
signatureType: Int
): Result<String> {
var lastError: Exception? = null
@@ -1165,12 +1212,20 @@ open class CopyOrderTrackingService(
side = side,
price = price,
size = size,
signatureType = 2, // Browser Wallet
signatureType = signatureType,
nonce = "0",
feeRateBps = feeRateBps, // 使用动态获取的费率
expiration = "0"
expiration = "0",
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 的交易,避免订单长期挂单导致价格不匹配
@@ -198,8 +198,6 @@ class OrderStatusUpdateService(
return
}
logger.debug("检查 ${ordersToCheck.size} 个30秒前创建的订单是否成交")
// 按账户分组,避免重复创建 API 客户端
val ordersByAccount = ordersToCheck.groupBy { it.accountId }
@@ -0,0 +1,146 @@
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.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
/**
* 尾盘策略订单 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 scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
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) ?: return false
} 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
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,
strategyName = strategy.name,
accountName = account.accountName,
walletAddress = account.walletAddress,
orderTime = orderTimeMs
)
logger.info("尾盘订单 TG 通知已发送: orderId=$orderId, strategyId=${strategy.id}, triggerId=${trigger.id}")
return true
}
}
@@ -0,0 +1,406 @@
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.event.CryptoTailStrategyChangedEvent
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService
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.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 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 logger = LoggerFactory.getLogger(CryptoTailOrderbookWsService::class.java)
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
/** 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()
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()
}
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
val firstBid = bids.get(0) as? com.google.gson.JsonObject
val bestBid = (firstBid?.get("price") as? com.google.gson.JsonPrimitive)?.asString?.toSafeBigDecimal()
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) {
logger.debug("tokenToEntries null: $tokenId")
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 {
runBlocking {
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) {
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)
precomputeAutoMinSpreadForCurrentPeriods(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)
precomputeAutoMinSpreadForCurrentPeriods(newMap)
}
/**
* 订阅更新时关闭当前 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 已关闭(订阅更新,将重连)")
}
}
/**
* AUTO 模式在周期开始刷新订阅时预拉历史 30 K 线并计算该周期最小价差触发时直接用缓存
*/
private fun precomputeAutoMinSpreadForCurrentPeriods(newMap: Map<String, List<WsBookEntry>>) {
val autoPeriods = newMap.values.asSequence().flatten()
.filter { it.strategy.minSpreadMode.uppercase() == "AUTO" }
.distinctBy { "${it.strategy.intervalSeconds}-${it.periodStartUnix}" }
.map { it.strategy.intervalSeconds to it.periodStartUnix }
.toList()
if (autoPeriods.isEmpty()) return
scope.launch {
for ((intervalSeconds, periodStartUnix) in autoPeriods) {
try {
val pair = binanceKlineAutoSpreadService.computeAndCache(intervalSeconds, periodStartUnix)
if (pair != null) {
logger.info(
"周期开始初始价差: interval=${intervalSeconds}s periodStartUnix=$periodStartUnix " +
"baseSpreadUp=${pair.first.toPlainString()} baseSpreadDown=${pair.second.toPlainString()}"
)
}
} catch (e: Exception) {
logger.warn("周期开始预计算 AUTO 价差失败: interval=$intervalSeconds periodStartUnix=$periodStartUnix ${e.message}")
}
}
}
}
/**
* 无启用策略或无需订阅时关闭 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 = 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 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) runBlocking { delay(1000L) }
}
return Result.failure(lastFailure ?: Exception("fetchEventBySlug failed"))
}
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()
val parsed = clobTokenIds.fromJson<List<String>>()
return parsed ?: emptyList()
}
@EventListener
fun onStrategyChanged(event: CryptoTailStrategyChangedEvent) {
refreshAndSubscribe()
}
}
@@ -0,0 +1,276 @@
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 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 settlementScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
/** 跟踪上一轮结算任务的 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()
}
}
}
@@ -0,0 +1,469 @@
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.entity.Account
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.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 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"
/** 数量小数位数,与 OrderSigningService 的 roundConfig.size 一致 */
private const val SIZE_DECIMAL_SCALE = 2
/**
* 周期内预置上下文账户解密凭证费率签名类型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 feeRateByTokenId: Map<String, String>,
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>()
private fun triggerLockKey(strategyId: Long, periodStartUnix: Long): String = "$strategyId-$periodStartUnix"
private fun getTriggerMutex(strategyId: Long, periodStartUnix: Long): Mutex =
triggerMutexMap.getOrPut(triggerLockKey(strategyId, periodStartUnix)) { Mutex() }
/** 周期预置上下文缓存:(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 feeRateByTokenId = tokenIds.associate { tokenId ->
tokenId to (clobService.getFeeRate(tokenId).getOrNull()?.toString() ?: "0")
}
val signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
if (strategy.amountMode.uppercase() != "RATIO" && strategy.amountValue < BigDecimal("1")) return null
val ctx = PeriodContext(
strategy = strategy,
periodStartUnix = periodStartUnix,
account = account,
decryptedPrivateKey = decryptedKey,
apiSecretDecrypted = apiSecret,
apiPassphraseDecrypted = apiPassphrase,
clobApi = clobApi,
feeRateByTokenId = feeRateByTokenId,
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)
return null
}
return ctx
}
/**
* 由订单簿 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.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"
logger.info(
"尾盘策略首次满足条件: strategyName=$strategyName, strategyId=${strategy.id}, " +
"openPrice=$openPrice, closePrice=$closePrice, marketPrice=${bestBid.toPlainString()}, " +
"direction=$direction, outcomeIndex=$outcomeIndex"
)
}
if (!passMinSpreadCheck(strategy, periodStartUnix, outcomeIndex)) return@withLock
ensurePeriodContext(strategy, periodStartUnix, tokenIds, marketTitle)
placeOrderForTrigger(strategy, periodStartUnix, marketTitle, tokenIds, outcomeIndex, bestBid)
}
}
private fun passMinSpreadCheck(strategy: CryptoTailStrategy, periodStartUnix: Long, outcomeIndex: Int): Boolean {
val mode = strategy.minSpreadMode.uppercase()
if (mode == "NONE") return true
val oc = binanceKlineService.getCurrentOpenClose(strategy.intervalSeconds, periodStartUnix)
?: return false
val (openP, closeP) = oc
val spreadAbs = closeP.subtract(openP).abs()
when (mode) {
"FIXED" -> {
val effectiveMinSpread = strategy.minSpreadValue?.takeIf { it > BigDecimal.ZERO }
if (effectiveMinSpread == null || effectiveMinSpread <= BigDecimal.ZERO) return true
return spreadAbs >= effectiveMinSpread
}
"AUTO" -> {
val result = computeAutoEffectiveMinSpread(strategy, periodStartUnix, outcomeIndex) ?: return true
val effectiveMinSpread = result.effectiveMinSpread
if (effectiveMinSpread <= BigDecimal.ZERO) return true
return spreadAbs >= effectiveMinSpread
}
else -> return true
}
}
/**
* AUTO 模式 100% 基准价差按窗口内毫秒进度计算动态系数100%50%得到有效最小价差
*/
private data class AutoSpreadResult(
val baseSpread: BigDecimal,
val coefficient: BigDecimal,
val effectiveMinSpread: BigDecimal
)
private fun computeAutoEffectiveMinSpread(strategy: CryptoTailStrategy, periodStartUnix: Long, outcomeIndex: Int): AutoSpreadResult? {
val baseSpread = binanceKlineAutoSpreadService.getAutoMinSpreadBase(strategy.intervalSeconds, periodStartUnix, outcomeIndex)
?: binanceKlineAutoSpreadService.computeAndCache(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 effectiveMinSpread = baseSpread.multi(coefficient).setScale(8, RoundingMode.HALF_UP)
return AutoSpreadResult(baseSpread, coefficient, effectiveMinSpread)
}
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) {
val amountUsdc = when (strategy.amountMode.uppercase()) {
"RATIO" -> {
val balanceResult = accountService.getAccountBalance(ctx.account.id)
val availableBalance = balanceResult.getOrNull()?.availableBalance?.toSafeBigDecimal() ?: BigDecimal.ZERO
availableBalance.multiply(strategy.amountValue).divide(BigDecimal("100"), 18, RoundingMode.DOWN)
}
else -> strategy.amountValue
}
if (amountUsdc < BigDecimal("1")) {
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 = BigDecimal(TRIGGER_FIXED_PRICE)
val size = computeSize(amountUsdc, price)
val feeRateBps = ctx.feeRateByTokenId[tokenId] ?: "0"
val signedOrder = orderSigningService.createAndSignOrder(
privateKey = ctx.decryptedPrivateKey,
makerAddress = ctx.account.proxyAddress,
tokenId = tokenId,
side = "BUY",
price = TRIGGER_FIXED_PRICE,
size = size,
signatureType = ctx.signatureType,
nonce = "0",
feeRateBps = feeRateBps,
expiration = "0"
)
val orderRequest = NewOrderRequest(
order = signedOrder,
owner = ctx.account.apiKey!!,
orderType = "FAK",
deferExec = false
)
submitOrderAndSaveRecord(ctx.clobApi, strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, orderRequest)
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
) {
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)
logger.info("尾盘策略下单成功: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, outcomeIndex=$outcomeIndex, orderId=${body.orderId}")
return
}
failReason = body.errorMsg ?: "unknown"
} else {
val errorBody = response.errorBody()?.string().orEmpty()
failReason = "HTTP ${response.code()} $errorBody"
}
} 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)
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
val amountUsdc = when (strategy.amountMode.uppercase()) {
"RATIO" -> availableBalance.multiply(strategy.amountValue).divide(BigDecimal("100"), 18, RoundingMode.DOWN)
else -> strategy.amountValue
}
if (amountUsdc < BigDecimal("1")) {
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 = BigDecimal(TRIGGER_FIXED_PRICE)
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 feeRateBps = clobService.getFeeRate(tokenId).getOrNull()?.toString() ?: "0"
val signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
val signedOrder = orderSigningService.createAndSignOrder(
privateKey = decryptedKey,
makerAddress = account.proxyAddress,
tokenId = tokenId,
side = "BUY",
price = TRIGGER_FIXED_PRICE,
size = size,
signatureType = signatureType,
nonce = "0",
feeRateBps = feeRateBps,
expiration = "0"
)
val orderRequest = NewOrderRequest(
order = signedOrder,
owner = account.apiKey!!,
orderType = "FAK",
deferExec = false
)
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?
) {
val record = CryptoTailStrategyTrigger(
strategyId = strategy.id!!,
periodStartUnix = periodStartUnix,
marketTitle = marketTitle,
outcomeIndex = outcomeIndex,
triggerPrice = triggerPrice,
amountUsdc = amountUsdc,
orderId = orderId,
status = status,
failReason = failReason
)
triggerRepository.save(record)
}
}
@@ -0,0 +1,296 @@
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.repository.CryptoTailStrategyRepository
import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository
import com.wrbug.polymarketbot.event.CryptoTailStrategyChangedEvent
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 minSpreadMode = (request.minSpreadMode ?: "NONE").uppercase()
if (minSpreadMode != "NONE" && minSpreadMode != "FIXED" && minSpreadMode != "AUTO") {
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
}
val minSpreadValue = request.minSpreadValue?.toSafeBigDecimal()
if (minSpreadMode == "FIXED" && (minSpreadValue == null || minSpreadValue < BigDecimal.ZERO)) {
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,
minSpreadMode = minSpreadMode,
minSpreadValue = minSpreadValue,
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 newMinSpreadMode = request.minSpreadMode?.uppercase() ?: existing.minSpreadMode
if (newMinSpreadMode != "NONE" && newMinSpreadMode != "FIXED" && newMinSpreadMode != "AUTO") {
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
}
val newMinSpreadValue = request.minSpreadValue?.toSafeBigDecimal() ?: existing.minSpreadValue
if (newMinSpreadMode == "FIXED" && (newMinSpreadValue == null || newMinSpreadValue < BigDecimal.ZERO)) {
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
}
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,
minSpreadMode = newMinSpreadMode,
minSpreadValue = newMinSpreadValue,
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 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(),
minSpreadMode = e.minSpreadMode,
minSpreadValue = e.minSpreadValue?.toPlainString(),
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,67 @@ 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 = "连接正常 (5m、15m)"
)
} 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 连接状态
* 用于订单推送服务
@@ -4,6 +4,7 @@ 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.EthereumUtils
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.createClient
@@ -40,6 +41,15 @@ class RelayClientService(
// 空集合ID
private val EMPTY_SET = "0x0000000000000000000000000000000000000000000000000000000000000000"
// Polygon PROXYMagic)合约地址,参考 builder-relayer-client config
private val proxyFactoryAddress = "0xaB45c5A4B0c941a2F231C04C3f49182e1A254052"
private val relayHubAddress = "0xD216153c06E857cD7f72665E0aF1d7D82172F494"
private val defaultProxyGasLimit = "10000000"
// Builder Relayer API 交易类型常量
private val RELAYER_TYPE_PROXY = "PROXY"
private val RELAYER_TYPE_SAFE = "SAFE"
private val polygonRpcApi: EthereumRpcApi by lazy {
val rpcUrl = rpcNodeService.getHttpUrl()
retrofitFactory.createEthereumRpcApi(rpcUrl)
@@ -201,33 +211,45 @@ class RelayClientService(
}
/**
* 执行 Safe 交易通过 Proxy.execTransaction
* 执行代理交易Safe Magic PROXY
* 参考 TypeScript: RelayClient.execute()
*
* 优先使用 Builder RelayerGasless如果未配置则回退到手动发送交易
*
* @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 +262,240 @@ 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 = relayerApi.getRelayPayload(fromAddress, RELAYER_TYPE_PROXY)
if (!relayPayloadResponse.isSuccessful || relayPayloadResponse.body() == null) {
val errorBody = relayPayloadResponse.errorBody()?.string() ?: "未知错误"
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 {
estimateProxyGasLimit(fromAddress, proxyFactoryAddress, proxyCallData)
} 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 = relayerApi.submitTransaction(request)
if (!response.isSuccessful || response.body() == null) {
val errorBody = response.errorBody()?.string() ?: "未知错误"
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 方法
@@ -278,7 +525,7 @@ class RelayClientService(
val redeemCallData = safeTx.data
// 获取 Proxy 的 nonce(通过 Builder Relayer API
val nonceResponse = relayerApi.getNonce(fromAddress, "SAFE")
val nonceResponse = relayerApi.getNonce(fromAddress, RELAYER_TYPE_SAFE)
if (!nonceResponse.isSuccessful || nonceResponse.body() == null) {
val errorBody = nonceResponse.errorBody()?.string() ?: "未知错误"
logger.error("获取 nonce 失败: code=${nonceResponse.code()}, body=$errorBody")
@@ -345,7 +592,7 @@ class RelayClientService(
// 构建 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,
@@ -303,6 +303,69 @@ class TelegramNotificationService(
sendMessage(message)
}
/**
* 发送尾盘策略下单成功通知与跟单一致在收到 WS 订单推送时匹配尾盘订单后调用
*/
suspend fun sendCryptoTailOrderSuccessNotification(
orderId: String?,
marketTitle: String,
marketId: String? = null,
marketSlug: String? = null,
side: String,
outcome: String? = null,
price: String,
size: String,
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 amount = try {
val priceDecimal = price.toSafeBigDecimal()
val sizeDecimal = size.toSafeBigDecimal()
priceDecimal.multiply(sizeDecimal).toString()
} catch (e: Exception) {
logger.warn("计算订单金额失败: ${e.message}", e)
null
}
val message = buildCryptoTailOrderSuccessMessage(
orderId = orderId,
marketTitle = marketTitle,
marketId = marketId,
marketSlug = marketSlug,
side = side,
outcome = outcome,
price = price,
size = size,
amount = amount,
strategyName = strategyName,
accountName = accountName,
walletAddress = walletAddress,
locale = currentLocale,
orderTime = orderTime
)
sendMessage(message)
}
/**
* 构建订单被过滤消息
*/
@@ -827,6 +890,85 @@ class TelegramNotificationService(
$amountLabel: <code>$amountDisplay</code> USDC
$accountLabel: $escapedAccountInfo$escapedCopyTradingInfo
$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> USDC
$accountLabel: $escapedAccountInfo
$timeLabel: <code>$time</code>"""
}
@@ -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 缓存避免重复创建
@@ -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 时可存计算值)';
@@ -16,6 +16,8 @@ notification.order.time=Time
notification.order.error_info=Error Information
notification.order.unknown_account=Unknown Account
notification.order.calculate_failed=Calculation Failed
notification.tail.order.success=Tail Session Order Success
notification.tail.strategy=Strategy
notification.redeem.success=Position Redeemed Successfully
notification.redeem.info=Redeem Information
notification.redeem.transaction_hash=Transaction Hash
@@ -274,6 +276,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 tail strategy
error.crypto_tail_strategy_not_found=Crypto tail 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 tail strategy
error.server.crypto_tail_strategy_update_failed=Failed to update crypto tail strategy
error.server.crypto_tail_strategy_delete_failed=Failed to delete crypto tail strategy
error.server.crypto_tail_strategy_list_fetch_failed=Failed to fetch crypto tail 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
@@ -16,6 +16,8 @@ notification.order.time=时间
notification.order.error_info=错误信息
notification.order.unknown_account=未知账户
notification.order.calculate_failed=计算失败
notification.tail.order.success=尾盘策略下单成功
notification.tail.strategy=策略
notification.redeem.success=仓位赎回成功
notification.redeem.info=赎回信息
notification.redeem.transaction_hash=交易哈希
@@ -274,6 +276,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=新增回测
@@ -16,6 +16,8 @@ notification.order.time=時間
notification.order.error_info=錯誤信息
notification.order.unknown_account=未知賬戶
notification.order.calculate_failed=計算失敗
notification.tail.order.success=尾盤策略下單成功
notification.tail.strategy=策略
notification.redeem.success=倉位贖回成功
notification.redeem.info=贖回信息
notification.redeem.transaction_hash=交易哈希
@@ -274,6 +276,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=新增回測
+17 -7
View File
@@ -161,10 +161,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}..."
@@ -216,9 +221,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"
@@ -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%」的动态方案。
+204
View File
@@ -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()`(统一下单与写触发记录)。 |
+178
View File
@@ -0,0 +1,178 @@
# 加密市场尾盘策略 - 5/15 分钟市场数据获取说明
> 前端 UI 与交互详见 `crypto-tail-strategy-ui-spec.md`
## 1. 数据源
- **Gamma API**`https://gamma-api.polymarket.com`
- 用于获取市场元数据:conditionId、开始/结束时间、标题、clobTokenIds 等。
- 无需鉴权。
## 2. 市场类型与 Slug 规则
| 类型 | Event Slug 规则 | 周期长度 | 说明 |
|------|-----------------|----------|------|
| Bitcoin 5 分钟 | `btc-updown-5m-{periodStartUnix}` | 5 min | periodStartUnix 为 5 分钟边界的 Unix 时间戳(秒) |
| Bitcoin 15 分钟 | `btc-updown-15m-{periodStartUnix}` | 15 min | periodStartUnix 为 15 分钟边界:`(now // 900) * 900` |
| Ethereum 5 分钟 | `eth-updown-5m-{ts}` | 5 min | 暂未验证是否在平台上线;如有可按相同规则推导 |
| Ethereum 15 分钟 | `eth-updown-15m-{ts}` | 15 min | 已验证存在 |
- 5 分钟周期:按 **300 秒** 对齐;当前周期起点可用 `(nowUnix // 300) * 300`,下一周期为 `+300`
- 15 分钟周期:按 **900 秒** 对齐;当前周期起点可用 `(nowUnix // 900) * 900`。slug 中的时间戳即为周期起始 Unix 秒;周期结束以 API 的 endDate 为准。
## 3. 获取单个周期市场(开始时间、结束时间)
### 3.1 请求
```bash
# 5 分钟 - 当前周期(示例时间戳需替换为当前周期起点)
curl -s "https://gamma-api.polymarket.com/events/slug/btc-updown-5m-1771007100"
# 15 分钟 - 需使用实际存在的时间戳(可从前端或历史 slug 得知)
curl -s "https://gamma-api.polymarket.com/events/slug/btc-updown-15m-1770882300"
```
### 3.2 响应结构(与开始/结束时间相关)
- **Event 层**`startDate``endDate`ISO 8601)。
- **markets[]**:每个市场有 `conditionId``question``startDate``endDate``clobTokenIds` 等。
**周期本身**:例如 5 分钟市场 "1:30PM-1:35PM ET",理应是 **startDate = 1:30 PM**、**endDate = 1:35 PM**。
**API 返回值与周期起止的对应关系(已用脚本验证)**:
| 字段 | 是否等于周期起止 | 说明 |
|------|------------------|------|
| **endDate**Event / Market | **是**,等于周期结束时间(如 1:35 PM | API 的 endDate 即周期终点,可直接用。 |
| **startDate**Event / Market | **否**,不等于周期开始时间(1:30 PM | API 的 startDate 是市场创建/开放时间,不是周期起点,故**不能**当 1:30 PM 用。 |
**正确做法**:周期起点(1:30 PM)用 **slug 中的时间戳** 推导;周期终点(1:35 PM)用 API 的 **endDate**
- **5 分钟**:周期开始 = `slug_ts`(即 slug 中的 Unix 秒),周期结束 = `endDate`(或 `slug_ts + 300`)。
- **15 分钟**:周期开始 = `slug_ts`,周期结束 = `endDate`(或 `slug_ts + 900`)。
**示例(脚本输出解读)**:若 current 5m slug 为 `btc-updown-5m-1771007400`、title 为 "1:30PM-1:35PM ET"、endDate 为 `2026-02-13T18:35:00Z`,则 1771007400 = 18:30 UTC = 1:30 PM ET,即周期起点;endDate 18:35 UTC = 1:35 PM ET = 周期终点。next 5m slug 为 1771007700 = 1771007400 + 300,即下一周期起点。15m 同理:current slug 17710074001:301:45 PM ET),next 1771008300 = 1771007400 + 9001:452:00 PM ET)。
## 4. 如何列出“当前及未来”5/15 分钟市场
- Gamma 未提供按“5 分钟 / 15 分钟”或“Up or Down”的 tag 筛选;`tag_id=744`cryptocurrency)未返回这些短期市场。
- **可行方式**
1. **按周期时间戳生成 slug 并逐个请求**
- 5 分钟:当前周期 `ts = (nowUnix // 300) * 300`,下一周期 `ts + 300`,再下一周期 `ts + 600`
- 15 分钟:`ts = (nowUnix // 900) * 900`,然后 `ts + 900``ts + 1800`
- 请求 `GET /events/slug/btc-updown-5m-{ts}``btc-updown-15m-{ts}`;若返回 404 表示该周期尚未创建或已过期,可跳过。
2. **用户选择“市场”时**:若前端/后端已知“系列”(如 Bitcoin 5 minute),则只需约定 slug 前缀(`btc-updown-5m``btc-updown-15m`)与周期长度(300/900),按当前时间计算周期起点并请求对应 slug 即可得到当前周期的 conditionId、startDate、endDate;下一周期同理。
## 5. 周期边界与“每周期监听”
- **周期开始**:使用 **slug 中的时间戳** `periodStartUnix`(即请求 slug 时的 `btc-updown-5m-{ts}` 里的 `ts`),不要用 API 返回的 startDate。
- **周期结束**:使用 API 返回的 **event.endDate 或 market.endDate**(与 slug_ts + 300/900 一致)。
- 判断“当前是否在该周期内”:`periodStartUnix <= nowUnix < endDateUnix`,其中 `periodStartUnix` 从 slug 得到,`endDateUnix` 由 endDate 解析。
- 策略“每周期开始时开始监听”:当 `now` 跨过当前周期的 endDate(或下一周期的 periodStartUnix)时,视为新周期开始,重置“本周期是否已触发”等状态。
## 6. 如何保证每个周期的市场都能正确处理
### 6.1 用“当前时间”唯一确定当前周期
- 服务端只用**当前 Unix 时间**推导周期,不依赖 API 的 startDate。
- **5 分钟**`periodStartUnix = (nowUnix / 300) * 300`(整除)。
- **15 分钟**`periodStartUnix = (nowUnix / 900) * 900`
- 同一时刻算出的 `periodStartUnix` 唯一,对应唯一 slug(如 `btc-updown-5m-{periodStartUnix}`),从而对应唯一市场(conditionId、tokenIds、endDate)。
### 6.2 按周期拉取市场并切换
- **首次进入或策略启用**:用当前的 `periodStartUnix` 拼 slug,请求 Gamma `GET /events/slug/{slug}`,拿到该周期的 conditionId、endDate、clobTokenIds;用 endDate 解析得到 `endDateUnix`
- **每次需要判断“是否还在本周期”或“是否该下单”时**:先算当前 `currentPeriodStart = (nowUnix / interval) * interval`interval 为 300 或 900)。若 `currentPeriodStart` 大于上一笔使用的 `periodStartUnix`,说明已进入**下一周期**
- 用新的 `currentPeriodStart` 拼 slug,重新请求 Gamma,拿到**新周期**的 conditionId、endDate、clobTokenIds
- 用新周期的 tokenIds 订阅/拉取订单簿,用新 endDate 作为本周期结束时间;
- 重置本周期“是否已触发”等状态,避免把上一周期的状态带到新周期。
- **周期内**:始终用**本周期**的 conditionId、tokenIds、endDate 做价格监听与下单,不要混用上一周期的数据。
### 6.3 周期切换时机与 404 处理
- **切换时机**:以 `nowUnix >= endDateUnix``(nowUnix / interval) * interval > periodStartUnix` 作为“本周期已结束”,立刻按 6.2 用新 `periodStartUnix` 拉新周期市场。
- **新周期市场尚未创建(404**:Gamma 可能稍晚才创建下一周期 event。若请求 slug 返回 404,可短间隔重试(如 5–15 秒)或等到下一整点/对齐点再试;重试时仍用**同一** `periodStartUnix`,避免用错周期。若长时间 404,可记录日志并跳过该周期,下一周期再正常拉取。
### 6.4 下单失败重试规则(每周期最多下单一次)
- 市价单提交失败时,**最多重试 2 次**(即 1 次初始 + 2 次重试,共 3 次尝试)。
- 若 3 次均失败:
- 本周期**不再**对该 outcome 下单;
- 记录失败原因与状态(便于审计与前端展示触发记录)。
- 周期切换时(6.2)重置为“未下单”,仅对新周期做新的判断与尝试。
### 6.5 去重与幂等(每周期最多触发一次)
- 以「策略 + 周期」唯一标识一次执行,例如 `(strategyId, periodStartUnix)``(accountId, slugPrefix, periodStartUnix)`
- 在数据库或内存中记录:本周期是否已触发、是否已下单。若已触发,同一周期内不再根据价格区间下单。
- 周期切换时(6.2)清空或更新为“新周期未触发”,只对新周期的 conditionId/tokenIds 做监听与下单。
### 6.6 时间区间(窗口)内才触发
- 策略可配置**时间区间**:从周期起点起算的「开始秒数」与「结束秒数」,例如 5 分钟市场可选 0~300 秒内的一段,15 分钟市场可选 0~900 秒内的一段(对应前端“分+秒”下拉,如 3 分 0 秒~12 分 0 秒即 180~720 秒)。
- **执行规则**:仅当 `periodStartUnix + windowStartSeconds <= nowUnix < periodStartUnix + windowEndSeconds` 时,才根据 7.1 判断价格是否进入 [minPrice, maxPrice] 并执行下单;**区间外不进行价格判断与下单**。
- 存储:策略表(或配置)中保存 `windowStartSeconds``windowEndSeconds`(整数,单位秒);校验:`windowStartSeconds <= windowEndSeconds`,且不超过周期长度(5min 市场 ≤ 300,15min 市场 ≤ 900)。详见 [UI 规格 - 时间区间](crypto-tail-strategy-ui-spec.md)。
### 6.7 小结
| 要点 | 做法 |
|------|------|
| 周期唯一性 | 用 `(nowUnix / interval) * interval` 得到 periodStartUnix,再拼 slug,不依赖 API startDate。 |
| 周期数据 | 每周期用**该周期**的 slug 请求 Gamma,使用返回的 conditionId、endDate、clobTokenIds。 |
| 切换 | 当 `nowUnix >= endDateUnix` 或当前算出的 periodStartUnix 变化时,拉取新周期并重置状态。 |
| 404 | 同一 periodStartUnix 重试;长时间 404 可跳过该周期并打日志。 |
| 下单失败 | 失败后最多重试 2 次;仍失败则本周期不再下单并记录状态。 |
| 每周期只触发一次 | 用 (策略, periodStartUnix) 做去重,周期切换时重置“已触发”状态。 |
| 时间区间 | 仅当 periodStartUnix + windowStartSeconds ≤ now < periodStartUnix + windowEndSeconds 时做价格判断与下单;区间外不处理。 |
按上述方式,每个周期都会对应到正确的 slug、正确的市场与 endDate,并在周期结束时切换到下一周期;仅在配置的时间窗口内才根据价格触发下单,避免混周期或漏周期。
## 7. 与订单簿 / 价格的关系
- 价格由 **CLOB 订单簿**(或 WebSocket)获取,不依赖 Gamma;Gamma 仅提供市场元数据。
- 使用 market.conditionId 与 markets[].clobTokenIds 解析出 tokenId,再订阅或请求该 token 的订单簿即可得到实时价格,用于区间判断与市价下单。
### 7.1 价格区间与「反方向」判断(如 minPrice = 0.92
二元市场(Up or Down)有两个 outcome:通常 outcomeIndex 0 = Up1 = Down,各对应一个 tokenId 和订单簿。
- **配置含义**:用户配置 minPrice = 0.92(及可选 maxPrice,默认 1)表示「当**某个 outcome 的价格**落在 [0.92, 1] 时触发市价买入**该** outcome」。
- **不预先选方向**:不需要用户选「买 Up 还是买 Down」;谁的价格先进入区间就买谁。
- **订单簿取价方式(与现有市价单逻辑一致)**
- 对每个 outcome,取该 tokenId 订单簿的 **bestBid**(最高买入价)作为当前价格用于区间判断;若取价规则与现有市价买入逻辑不同,请以系统现有规则为准并在实现文档中写明。
- **判断方式**
- 同时取**两个 outcome** 的当前价格(按上述取价规则)。
- 对 **outcome 0**:若 `price0 >= minPrice && price0 <= maxPrice` → 满足触发条件,买入 outcome 0(Up)。
- 对 **outcome 1**:若 `price1 >= minPrice && price1 <= maxPrice` → 满足触发条件,买入 outcome 1(Down)。
- **反方向**:「反方向」即另一个 outcome。例如若本轮已因 outcome 0 进入 [0.92, 1] 而买入 Up,则本周期内**不再**检查 outcome 1 是否也进入区间、也不再买 Down;反之若先触发的是 outcome 1(Down),则本周期不再买 Up。实现上:一旦本周期已对**任意一个** outcome 触发并下单,即标记本周期已触发,不再对**另一个 outcome(反方向)**做区间判断与下单。
- **同一时刻两边都进区间**:若同一时刻 Up 和 Down 的价格都在 [0.92, 1](理论上二元市场 Up+Down≈1 时不会同时 ≥0.92,但若出现),可约定按 outcomeIndex 优先(如先判 0 再判 1)或先到先得,只执行一笔买入,本周期不再买反方向。
总结:配置 0.92 时,对**两个方向**都做同一区间判断;先满足区间的那一侧触发买入,另一侧即为反方向,本周期不再触发。
## 8. 验证方式
**startDate/endDate 验证结论**:已用脚本对比 slug 时间戳与 API 返回的 startDate/endDate。**endDate 等于当前周期结束时间****startDate 不等于周期起始点**(为市场创建/开放时间),周期起始点应以 slug 中的时间戳为准。详见上文 3.2、5 节。
### 8.1 脚本(推荐)
项目内脚本,会请求当前/下一 5 分钟与 15 分钟 BTC 市场并打印 conditionId、startDate、endDate、clobTokenIds
```bash
python3 scripts/fetch_crypto_minute_markets.py
```
### 8.2 curl 示例
```bash
# 5 分钟 - 当前或下一周期(时间戳需替换为实际周期起点)
curl -s "https://gamma-api.polymarket.com/events/slug/btc-updown-5m-1771007100"
# 15 分钟 - 当前周期(时间戳需替换为实际周期起点)
curl -s "https://gamma-api.polymarket.com/events/slug/btc-updown-15m-1771006500"
# 15 分钟 - 历史存在的事件
curl -s "https://gamma-api.polymarket.com/events/slug/btc-updown-15m-1770882300"
curl -s "https://gamma-api.polymarket.com/events/slug/eth-updown-15m-1770801300"
```
若返回 403,可加 User-Agent`curl -s -H "User-Agent: PolymarketBot/1.0" "https://gamma-api.polymarket.com/events/slug/btc-updown-5m-1771007100"`
@@ -0,0 +1,247 @@
# 尾盘策略 - 最小价差参数流程分析
## 一、需求摘要
在现有尾盘策略上增加**最小价差**参数:当策略条件(时间窗、价格区间)满足时,再判断**当前周期 Binance K 线的开盘价与收盘价价差**是否满足最小价差;满足才下单,不满足则等待,直到价差满足再下单。
- **后端**:需订阅币安对应币对(如 BTC/USDC)的 K 线,维护当前周期的**开盘价**与**实时收盘价**,并在触发时做价差校验。
- **前端**:可配置三种场景——无、固定、自动(见下)。
---
## 二、前端配置场景
| 场景 | 配置方式 | 校验逻辑 |
|------|----------|----------|
| **无** | 不进行价差校验 | 与现有一致:仅判断时间窗 + 价格区间,满足即下单。 |
| **固定** | 用户输入一个固定价差(如 30) | 当 \|收盘价 − 开盘价\| ≥ 该固定值时,校验通过,再下单。 |
| **自动** | 由系统根据历史数据计算最小价差 | 见下文「自动模式计算逻辑」;得到数值后,后续与固定模式一致:\|收盘价 − 开盘价\| ≥ 计算值 则通过。 |
### 自动模式计算逻辑
- 通过币安 API 获取**历史 20 根** K 线(与策略周期一致:5m 取 5m K 线,15m 取 15m K 线)。
- **下单方向 = Down**outcomeIndex = 1):只取「收盘价 < 开盘价」的 K 线,得到价差序列(开盘价 − 收盘价)。
- **下单方向 = Up**outcomeIndex = 0):只取「收盘价 > 开盘价」的 K 线,得到价差序列(收盘价 − 开盘价)。
- **异常值剔除**:对上述价差序列做异常值过滤(见下文「异常值剔除」),再用**剩余样本**求平均价差,乘以系数 **80%** 得到最小价差;后续用该值做 \|收盘价 − 开盘价\| ≥ 该值 的校验。
- **历史数据获取时机**:**在该周期开始时就拉取并计算**,不在保存策略时计算。订单簿 WS 在周期开始时刷新订阅(含每 25 秒或周期切换时的 refreshAndSubscribe),此时对当前周期内所有启用且为 AUTO 的策略,按 (intervalSeconds, periodStartUnix) 预拉该周期前 20 根已收盘 K 线并计算 minSpreadUp/minSpreadDown 写入缓存;该周期内触发时直接用缓存,无需在触发时再调 REST。
### 异常值剔除
- **目的**:避免少数极端 K 线(如 14 组价差在 50 以内、1 组价差 200)拉高平均价差,导致最小价差偏大、难以触发。
- **做法**:在按方向得到价差序列后,先**剔除异常值**,再对剩余价差求平均并 × 0.8。
- **推荐方法:IQR(四分位距)**
- 对价差序列排序,计算 Q1(25% 分位)、Q3(75% 分位)、IQR = Q3 Q1。
- 保留区间 **[Q1 1.5×IQR, Q3 + 1.5×IQR]** 内的价差,剔除该区间外的点。
- 示例:15 组价差,14 组在 50 以内、1 组为 200 → 200 会超出上界被剔除,只用 14 组参与平均。
- **边界与降级**
- 若剔除后剩余样本数过少(如 &lt; 3),则**不剔除**:用全部价差样本求平均 × 0.8。
- 若无满足方向的 K 线(如 20 根里没有 close &lt; open),仍按原文档降级处理(全量 \|close−open\| 或返回 0)。
---
## 三、整体流程(含价差校验)
```
┌─────────────────────────────────────────────────────────────────────────────────┐
│ 1. 数据源与订阅 │
├─────────────────────────────────────────────────────────────────────────────────┤
│ • CLOB 订单簿 WS(现有):Polymarket 订单簿 → bestBid。 │
│ • 币安 K 线 WS(新增):订阅 BTCUSDC 对应周期(5m/15m),维护「当前周期」的开盘价 │
│ open、实时收盘价 close(每根 K 线未收盘前 close 会持续更新)。 │
└─────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────┐
│ 2. 触发入口(与现有一致) │
├─────────────────────────────────────────────────────────────────────────────────┤
│ • 入口 ACryptoTailOrderbookWsService.onBestBid(tokenId, bestBid) │
│ • 入口 BCryptoTailStrategyExecutionService.runCycle()HTTP 拉订单簿) │
│ 两者在「时间窗 + 价格区间 + 本周期未触发」通过后,都会调用执行层「尝试下单」。 │
└─────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────┐
│ 3. 执行层增加「价差校验」 │
├─────────────────────────────────────────────────────────────────────────────────┤
│ 在现有 tryTriggerWithPriceFromWs / runCycle → placeOrderForTrigger 之前增加: │
│ │
│ if (策略.minSpreadMode == NONE) → 直接进入 placeOrderForTrigger。 │
│ else: │
│ • 从「币安 K 线服务」取当前周期(与 strategy.intervalSeconds 对齐)的 open、 │
│ close(实时)。 │
│ • 若取不到 open/close(例如该周期尚未有数据)→ 本轮不下单,等待下次 WS 更新。 │
│ • 计算 effectiveMinSpread
│ - FIXEDeffectiveMinSpread = 策略.minSpreadValue(用户填的固定值) │
│ - AUTOeffectiveMinSpread = 按当前下单方向(outcomeIndex)取「自动计算 │
│ 的最小价差」(见下节;若尚未计算则先拉 20 根历史 K 线并计算、缓存)。 │
│ • 若 |close open| < effectiveMinSpread → 本轮不下单,等待价差满足。 │
│ • 若 |close open| >= effectiveMinSpread → 通过价差校验,进入 │
│ placeOrderForTrigger(与现有逻辑一致:预签/签名、提交 CLOB 订单、写触发记录)。│
└─────────────────────────────────────────────────────────────────────────────────┘
```
- **「等待价差满足」**:不主动轮询;下次 CLOB 订单簿或币安 K 线有推送时,会再次进入上述判断,此时 close 可能已更新,价差可能已满足,再决定是否下单。
- **每周期最多触发一次**:仍由现有「本周期是否已触发」保证;价差不满足时**不写触发记录**,也不占「已触发」名额,直到某次检查同时满足价格区间与价差后才下单并标记已触发。
---
## 四、自动模式:何时拉历史、如何算、如何用
- **何时拉 20 根历史 K 线并计算**
- **在该周期开始时就预计算**,不在保存策略时计算。
- 订单簿 WS 在**周期开始时**会刷新订阅(`refreshAndSubscribe`:每 25 秒或检测到周期切换时),此时对当前周期内所有启用且 minSpreadMode=AUTO 的策略,按 `(intervalSeconds, periodStartUnix)` 异步拉取该周期前 20 根已收盘 K 线(REST `endTime = periodStartUnix * 1000`),按 Up/Down 分别算 avgSpread × 0.8(含 IQR 剔除)并写入缓存。该周期内后续触发时直接用缓存,**不在触发时再调 REST**。
- 若某周期未做预计算(如服务刚启动且尚未到刷新时机),触发时仍会按需调用 `computeAndCache` 并缓存,保证逻辑正确。
- 前端「自动最小价差」接口仅作**预览**,实际下单校验不依赖该接口。
- **计算细节**
- 历史 20 根:币安 REST `GET /api/v3/klines?symbol=BTCUSDC&interval=5m|15m&limit=20`(或 21 取前 20 根已收盘),每根格式为 [openTime, open, high, low, close, ...]。
- **DownoutcomeIndex=1**:筛选 close < open,价差 = open close,得到价差序列 → **异常值剔除(IQR** → 对剩余价差求平均,再 × 0.8 → minSpreadDown。
- **UpoutcomeIndex=0**:筛选 close > open,价差 = close open,得到价差序列 → **异常值剔除(IQR** → 对剩余价差求平均,再 × 0.8 → minSpreadUp。
- **异常值剔除**:见上文「异常值剔除」;剔除后再平均。若剔除后剩余样本 &lt; 3,则不剔除,用全部价差样本求平均。
- 若无满足方向的 K 线(例如 20 根里没有一根 close < open),可降级:用全部 20 根的 |close−open| 平均 × 0.8,或返回 0/不校验,具体产品可定。
- **触发时使用**
- 当前要下单的是 outcomeIndex0=Up, 1=Down),取对应的 minSpreadUp 或 minSpreadDown 作为 effectiveMinSpread,再与 |close open| 比较。
---
## 五、后端模块与数据流
| 模块 | 职责 |
|------|------|
| **BinanceKlineService(新)** | 1)订阅币安 WSBTCUSDC 的 5m、15m K 线流(可按需只订阅有策略使用的周期)。<br>2)维护「当前周期」数据:以 periodStartUnix(或 K 线 t 对齐)为 key,存 (open, close)K 线 WS 推送时更新 close,新周期首条推送时更新 open。<br>3)提供 getCurrentOpenClose(symbol, intervalSeconds, periodStartUnix) → (open, close)?,供执行层价差校验使用。 |
| **BinanceKlineAutoSpreadService 或合入上者(新)** | 1)按**周期**拉取:以 periodStartUnix 为界,REST 拉取该周期前的 20 根已收盘 K 线。<br>2)按 Up/Down 得到价差序列 → **IQR 异常值剔除** → 对剩余价差求平均 × 0.8,缓存 (intervalSeconds, periodStartUnix) → (minSpreadUp, minSpreadDown)。<br>3)提供 getAutoMinSpread(intervalSeconds, periodStartUnix, outcomeIndex) 与 computeAndCache(intervalSeconds, periodStartUnix)。**周期开始时**由 CryptoTailOrderbookWsService 在 refreshAndSubscribe 后对当前周期内 AUTO 策略预调 computeAndCache;触发时直接用缓存,未命中时再按需计算。 |
| **CryptoTailStrategy(实体)** | 新增字段建议:minSpreadModeNONE/FIXED/AUTO)、minSpreadValue(固定时使用;AUTO 时可为空或存上次计算值用于展示)。 |
| **CryptoTailStrategyExecutionService(现有)** | 在 tryTriggerWithPriceFromWs 与 runCycle 分支中,在调用 placeOrderForTrigger 前:若 minSpreadMode != NONE,则取 open/close 与 effectiveMinSpread,校验 \|closeopen\| >= effectiveMinSpread;不通过则 return,不调用 placeOrderForTrigger。 |
| **CryptoTailOrderbookWsService(现有)** | 仍只根据 CLOB bestBid 触发;价差校验在执行层统一做。**新增**refreshAndSubscribe 完成后,对当前周期内所有启用且 minSpreadMode=AUTO 的策略,异步调用 BinanceKlineAutoSpreadService.computeAndCache,在周期开始即预计算最小价差。 |
- **币安 K 线与周期对齐**
- 策略周期:periodStartUnix 为秒(如 5m 周期 = 300 的倍数,15m = 900 的倍数)。
- 币安 K 线:t 为毫秒,同一周期:t_ms = periodStartUnix * 1000。
- 用 (intervalSeconds, periodStartUnix) 或 (interval, t_ms) 对齐即可从 BinanceKlineService 取到「当前周期」的 open 和实时 close。
---
## 六、固定(FIXED)与自动(AUTO)时序图
### 6.1 固定(FIXED)时序图
固定模式:用户保存策略时写入 `minSpreadValue`(如 30);触发时直接用该值与当前周期 \|close−open\| 比较,不拉历史 K 线。
```mermaid
sequenceDiagram
participant User as 用户
participant API as Controller
participant Svc as CryptoTailStrategyService
participant DB as 数据库
participant CLOB_WS as CLOB 订单簿 WS
participant Orderbook as CryptoTailOrderbookWsService
participant Exec as CryptoTailStrategyExecutionService
participant BinanceWS as BinanceKlineService
participant CLOB as Polymarket CLOB
User->>API: 保存策略 minSpreadMode=FIXED, minSpreadValue=30
API->>Svc: create/update
Svc->>DB: 写入 min_spread_mode, min_spread_value
Svc-->>API: 成功
API-->>User: 成功
Note over BinanceWS: 后台持续:币安 K 线 WS 更新当前周期 (open, close)
CLOB_WS->>Orderbook: onMessage(book/price_change) → bestBid
Orderbook->>Orderbook: 时间窗内?价格在 [min,max]?本周期未触发?
Orderbook->>Exec: tryTriggerWithPriceFromWs(strategy, periodStartUnix, ..., bestBid)
Exec->>Exec: mutex 锁
Exec->>Exec: 本周期已触发?→ 是则 return
Exec->>Exec: passMinSpreadCheck(strategy, periodStartUnix, outcomeIndex)
Exec->>Exec: mode==FIXED → effectiveMinSpread = strategy.minSpreadValue (30)
Exec->>BinanceWS: getCurrentOpenClose(intervalSeconds, periodStartUnix)
BinanceWS-->>Exec: (open, close) 来自内存
Exec->>Exec: |closeopen| >= 30 ? 否 → return,不下单
Exec->>Exec: 是 → 通过价差校验
Exec->>Exec: ensurePeriodContext → placeOrderForTrigger
Exec->>CLOB: 提交订单
CLOB-->>Exec: orderId
Exec->>DB: 写入触发记录 (本周期已触发)
```
---
### 6.2 自动(AUTO)时序图
自动模式:不在保存策略时计算。**在该周期开始时就预计算**(订单簿 WS 刷新订阅时对该周期内 AUTO 策略异步拉 20 根历史 K 线并计算、缓存);触发时直接用缓存,同一周期内复用。
```mermaid
sequenceDiagram
participant User as 用户
participant API as Controller
participant Svc as CryptoTailStrategyService
participant DB as 数据库
participant CLOB_WS as CLOB 订单簿 WS
participant Orderbook as CryptoTailOrderbookWsService
participant Exec as CryptoTailStrategyExecutionService
participant BinanceWS as BinanceKlineService
participant AutoSpread as BinanceKlineAutoSpreadService
participant BinanceREST as 币安 REST API
participant CLOB as Polymarket CLOB
User->>API: 保存策略 minSpreadMode=AUTO(不填 minSpreadValue
API->>Svc: create/update
Svc->>DB: 写入 min_spread_mode=AUTO
Svc-->>API: 成功
API-->>User: 成功
Note over BinanceWS: 后台持续:币安 K 线 WS 更新当前周期 (open, close)
CLOB_WS->>Orderbook: onMessage → bestBid
Orderbook->>Orderbook: 时间窗 + 价格区间 + 本周期未触发 ✓
Orderbook->>Exec: tryTriggerWithPriceFromWs(strategy, periodStartUnix, ..., bestBid)
Exec->>Exec: mutex 锁
Exec->>Exec: passMinSpreadCheck(strategy, periodStartUnix, outcomeIndex)
Exec->>BinanceWS: getCurrentOpenClose(intervalSeconds, periodStartUnix)
BinanceWS-->>Exec: (open, close)
Note over Orderbook,AutoSpread: 周期开始时 refreshAndSubscribe 已对该周期预计算(见下)
Exec->>AutoSpread: getAutoMinSpread(intervalSeconds, periodStartUnix, outcomeIndex)
AutoSpread->>AutoSpread: 查缓存 (intervalSeconds, periodStartUnix) → 命中(周期开始已预计算)
AutoSpread-->>Exec: effectiveMinSpread
Exec->>Exec: |closeopen| >= effectiveMinSpread ? 否 → return
Exec->>Exec: 是 → 通过价差校验
Exec->>Exec: placeOrderForTrigger → CLOB 下单
Exec->>DB: 写入触发记录
Note over Orderbook,AutoSpread: 周期开始时(refreshAndSubscribe 或周期切换)
Orderbook->>Orderbook: refreshAndSubscribe() → buildSubscriptionMap() → newMap
Orderbook->>Orderbook: precomputeAutoMinSpreadForCurrentPeriods(newMap)
Orderbook->>AutoSpread: computeAndCache(intervalSeconds, periodStartUnix) [异步]
AutoSpread->>BinanceREST: GET /api/v3/klines?symbol=BTCUSDC&interval=15m&limit=20&endTime=periodStart*1000
BinanceREST-->>AutoSpread: 20 根已收盘 K 线
AutoSpread->>AutoSpread: 按 Up/Down 拆价差 → IQR 剔除 → 平均×0.8 → 缓存
Note over CLOB_WS,Exec: 同一周期内再次触发(如另一 outcome 或再次 bestBid
CLOB_WS->>Orderbook: onMessage → bestBid
Orderbook->>Exec: tryTriggerWithPriceFromWs(...)
Exec->>AutoSpread: getAutoMinSpread(intervalSeconds, periodStartUnix, outcomeIndex)
AutoSpread->>AutoSpread: 查缓存 → 命中
AutoSpread-->>Exec: effectiveMinSpread(不再调 REST
Exec->>Exec: 价差校验 → 通过则下单(或本周期已触发则跳过)
```
---
## 七、流程小结(按执行顺序)
1. **策略配置**
- 用户选择:无 / 固定(输入数值)/ 自动。
- 固定:必填 minSpreadValue,保存到 DB。
- 自动:不填 minSpreadValue,**不在保存时计算**;按周期在首次需要时计算并缓存。
2. **运行时**
- 币安 WS 持续更新当前周期的 (open, close)。
- CLOB 订单簿(或 HTTP)带来 bestBid;若时间窗 + 价格区间 + 本周期未触发 均满足:
- 若 minSpreadMode == NONE → 直接 placeOrderForTrigger。
- 否则取当前周期 open/close 与 effectiveMinSpread(固定值或自动缓存值),若 \|closeopen\| >= effectiveMinSpread → placeOrderForTrigger;否则本轮不下单,等后续推送再判。
3. **下单与去重**
- 仍保持「每周期最多触发一次」;价差不满足时不写触发记录,直到某次同时满足价格与价差后才下单并写记录。
按上述流程即可在现有尾盘策略上接入「最小价差」参数,并由后端订阅币安 K 线、在触发前做价差校验;固定与自动的时序差异见**第六节时序图**。
+150
View File
@@ -0,0 +1,150 @@
# 加密市场尾盘策略 - 任务梳理
> 需求与 UI 见 `crypto-tail-strategy-ui-spec.md`,市场数据与执行规则见 `crypto-tail-strategy-market-data.md`
以下按**文档 / 数据库 / 后端 / 前端**拆分为可执行任务,便于排期与验收。
---
## 一、文档(已完成)
| 任务 | 状态 | 说明 |
|------|------|------|
| PRD 与需求 | ✅ | 周期、价格区间、每周期最多触发一次、重试 2 次等 |
| 市场数据文档 | ✅ | `crypto-tail-strategy-market-data.md`:Gamma slug、周期、时间区间、价格判断 |
| UI 规格 | ✅ | `crypto-tail-strategy-ui-spec.md`:列表、表单、时间区间、触发记录、赎回前置检查 |
---
## 二、数据库
| 序号 | 任务 | 说明 |
|------|------|------|
| D1 | 策略表 migration | 新建表,字段建议:id, account_id, name, market_slug_prefix(如 btc-updown-5m), interval_seconds(300/900), window_start_seconds, window_end_seconds, min_price, max_price, amount_mode(ratio/fixed), amount_value(比例或 USDC 字符串), enabled, created_at, updated_at。唯一/外键按现有规范。 |
| D2 | 触发记录表 migration | 新建表,字段建议:id, strategy_id, period_start_unix, market_title, outcome_index(0=Up/1=Down), trigger_price, amount_usdc, order_id(可空), status(success/fail), fail_reason(可空), created_at。便于列表与筛选。 |
---
## 三、后端(Kotlin
### 3.1 实体与 Repository
| 序号 | 任务 | 说明 |
|------|------|------|
| B1 | 策略实体 Entity | 对应策略表;ID 用 Long?;时间 Long 时间戳;金额 BigDecimal;遵守 backend.mdc 实体规范。 |
| B2 | 触发记录实体 Entity | 对应触发记录表。 |
| B3 | JpaRepository | 策略、触发记录的 Repository;按 strategyId、时间等查记录。 |
### 3.2 外部依赖与领域
| 序号 | 任务 | 说明 |
|------|------|------|
| B4 | Gamma API 按 slug 拉市场 | 已有或扩展 PolymarketGammaApiGET /events/slug/{slug},返回 conditionId、endDate、clobTokenIds 等;与 market-data 文档 3、4 节一致。 |
| B5 | 周期与 slug 推导 | 工具或 Service:根据 interval(300/900)、当前时间算 periodStartUnix;拼 slug(如 btc-updown-5m-{ts});解析 endDate 得 endDateUnix。 |
| B6 | 订单簿价格 | 使用现有 CLOB/订单簿能力,按 conditionId、clobTokenIds 取各 outcome 的 bestBid;与 market-data 7.1 一致。 |
| B7 | 市价单与重试 | 按策略的 amount 计算下单金额;市价买入指定 outcome;失败时最多重试 2 次(共 3 次),仍失败则写触发记录状态为失败并记原因。 |
### 3.3 策略执行核心逻辑(按 market-data 第 6、7 节)
| 序号 | 任务 | 说明 |
|------|------|------|
| B8 | 周期内时间窗口判断 | 仅当 `periodStartUnix + windowStartSeconds <= nowUnix < periodStartUnix + windowEndSeconds` 时,才做价格区间判断与下单;区间外不处理。 |
| B9 | 价格区间与「先满足先买」 | 对两个 outcome 取价,若某 outcome 价格 ∈ [minPrice, maxPrice],则触发买该 outcome;另一 outcome 本周期不再触发(7.1)。 |
| B10 | 每周期只触发一次 | 以 (strategyId, periodStartUnix) 去重;周期切换时重置「本周期已触发」状态;结合 B8、B9 实现。 |
| B11 | 周期切换与 404 | 当 now >= endDateUnix 或新 periodStartUnix 时,用新 periodStartUnix 拉新 slug404 时同 periodStartUnix 短间隔重试,长时间 404 可跳过本周期并打日志。 |
### 3.4 API 与 DTO
| 序号 | 任务 | 说明 |
|------|------|------|
| B12 | 策略 CRUD API | 列表(分页/筛选)、创建、更新、删除、启用/停用;请求/响应为 DTO,不用 Map;统一 ApiResponse;错误码与 MessageSource。 |
| B13 | 策略 DTO | 创建/更新包含:accountId, name, marketSlugPrefix, intervalSeconds, windowStartSeconds, windowEndSeconds, minPrice, maxPrice(可选默认 1), amountMode, amountValue;校验 windowStart <= windowEnd,且不超过周期长度。 |
| B14 | 触发记录 API | 按 strategyId 分页查询触发记录;返回列表 DTO(时间、市场、方向、价格、金额、订单 ID、状态)。 |
| B15 | 5/15 分钟市场列表 API(可选) | 若前端需要「可选市场」列表:可按当前/下一周期拼 slug 调 Gamma 返回市场信息,供前端选择;或前端直接按 slug 规则+周期展示。 |
### 3.5 自动赎回与调度
| 序号 | 任务 | 说明 |
|------|------|------|
| B16 | 自动赎回包含尾盘策略仓位 | 尾盘策略产生的仓位与跟单/手动一视同仁,纳入现有自动赎回逻辑,不排除(见 UI 规格附录 A)。 |
| B17 | 调度/定时或常驻 | 对已启用策略按周期(如每 10–30 秒)检查:当前周期、是否在时间窗口内、是否已触发、价格是否进区间;满足则执行下单并写触发记录。 |
---
## 四、前端(React + TypeScript
### 4.1 路由与导航
| 序号 | 任务 | 说明 |
|------|------|------|
| F1 | 路由 | App.tsx 增加 `/crypto-tail-strategy`、可选 `/crypto-tail-strategy/records/:id`。 |
| F2 | 菜单 | Layout 中增加「尾盘策略」菜单项,与跟单同级或在其下;key 与路由一致。 |
### 4.2 列表页
| 序号 | 任务 | 说明 |
|------|------|------|
| F3 | 列表页组件 | 如 CryptoTailStrategyList.tsx;页面标题、钱包提示 Alert、新增按钮、筛选(账户、状态)。 |
| F4 | 列表展示 | 桌面 Table / 移动 Card:策略名、关联市场、时间区间、价格区间、投入方式、状态、最近触发、操作(编辑、启用/停用、删除、查看触发记录);删除 Popconfirm。 |
| F5 | 创建前检查 | 点击「新增策略」先调接口判断是否已配置自动赎回(如 builderApiKeyConfigured);未配置则弹出「请先配置自动赎回」Modal(去配置 → /system-settings,取消),不打开表单。 |
### 4.3 新增/编辑表单
| 序号 | 任务 | 说明 |
|------|------|------|
| F6 | 表单弹窗 | 策略名、选择账户、选择市场、时间区间、minPrice、maxPrice、投入方式(比例/固定)、启用状态。 |
| F7 | 时间区间控件 | 区间开始/结束:下拉选「分钟」+「秒」;5min 市场 0–5 分+059 秒(总≤5min),15min 市场 015 分+059 秒(总≤15min);校验**开始 ≤ 结束**;提交时转为 windowStartSeconds、windowEndSeconds。 |
| F8 | 市场选择器 | 仅展示 5/15 分钟加密市场;支持搜索;展示市场标题+周期;选后用于校验时间区间上界(5min 结束≤300s15min≤900s)。 |
| F9 | 表单校验与提交 | 市场类型、时间区间 start≤end 且不超周期、minPrice/maxPrice、比例或固定金额合法;提交后刷新列表、成功提示。 |
### 4.4 触发记录
| 序号 | 任务 | 说明 |
|------|------|------|
| F10 | 触发记录展示 | 弹窗或独立页:触发时间、市场、方向(Up/Down)、触发价格、投入金额、订单 ID、状态;支持按时间、状态筛选;formatUSDC;移动端 Card/折叠。 |
### 4.5 通用
| 序号 | 任务 | 说明 |
|------|------|------|
| F11 | 类型定义 | 策略、触发记录等 TypeScript 类型;无 any。 |
| F12 | API 封装 | apiService 中 cryptoTailStrategy.list/create/update/delete/toggle、records(strategyId) 等。 |
| F13 | 多语言 | locales 中 zh-CN、zh-TW、en 的 cryptoTailStrategy.*list.title、list.walletTip、form.walletTip、redeemRequiredModal.*、时间区间/价格区间等文案。 |
---
## 五、依赖关系简图
```
文档 ✅
D1,D2 数据库
B1B3 实体与 Repository
B4–B7 外部 API、周期、价格、下单
B8–B11 执行逻辑(时间窗口+价格+去重+周期切换)
B12B15 API 与 DTO
B16 自动赎回
B17 调度
F1F2 路由与菜单
F11F12 类型与 API 封装
F13 多语言
F3F5 列表与创建前检查
F6F9 表单(含时间区间)
F10 触发记录
```
---
## 六、验收要点
- **时间区间**:仅当周期内当前时间落在 [windowStartSeconds, windowEndSeconds] 时才判断价格并下单;前端区间开始 ≤ 结束,且不超出 5min/15min。
- **每周期一次**:同一策略同一周期只触发一次(先满足价格的 outcome 买入,反方向不买)。
- **重试**:下单失败最多重试 2 次,共 3 次;仍失败记入触发记录为失败。
- **自动赎回**:尾盘策略产生的仓位可被自动赎回,无排除逻辑。
- **创建前检查**:未配置自动赎回时点击新增策略弹出「去配置」弹窗,不打开表单。
+177
View File
@@ -0,0 +1,177 @@
# 加密市场尾盘策略 - 前端 UI 规格
> 周期推导与市场数据获取详见 `crypto-tail-strategy-market-data.md`
与现有跟单/回测保持同一风格(Ant Design、响应式、多语言),以下为页面结构及所含元素。
---
## 1. 导航与路由
| 项目 | 说明 |
|------|------|
| **菜单** | 在「跟单管理」同级或其下增加一项,如「尾盘策略」,key 建议 `/crypto-tail-strategy`。 |
| **路由** | 列表页 `/crypto-tail-strategy`;可选详情/触发记录 `/crypto-tail-strategy/records/:id`。 |
参考:`Layout.tsx``/copy-trading``/backtest` 的配置;`App.tsx` 中对应 `Route`
---
## 2. 列表页(主页面)
**路径**`/crypto-tail-strategy`
**组件**:如 `CryptoTailStrategyList.tsx`(或 `TailStrategyList.tsx`)。
### 2.1 顶部操作区
| 元素 | 类型 | 说明 |
|------|------|------|
| 页面标题 | 标题文案 | 如「加密尾盘策略」,用 `t('cryptoTailStrategy.list.title')`。 |
| **钱包使用提示** | **AlertWarning** | **必须**在页面顶部或标题下方展示:提示用户**使用单独/专用钱包**运行本策略,避免该钱包用于手动交易、跟单等其他操作,否则可能导致余额或仓位变化,进而造成策略执行异常(如余额不足、下单失败等)。文案走多语言 `t('cryptoTailStrategy.list.walletTip')`,可带 `showIcon`。 |
| 新增策略 | ButtonPrimary) | 点击时**先检查自动赎回相关配置**(见 2.4);若未配置则弹出「去配置」简易弹窗,若已配置则打开「新增策略」表单弹窗。图标可用 `PlusOutlined`。 |
| 筛选(可选) | Select / 筛选项 | 按账户、启用状态筛选;移动端可收起到抽屉或折叠。 |
### 2.2 列表内容(桌面端:Table,移动端:Card 列表)
| 列/卡片项 | 说明 |
|-----------|------|
| 策略名称 | 用户填的配置名或自动生成名。 |
| 关联市场 | 展示市场标题 + 周期,如「Bitcoin Up or Down - 5 minute」。 |
| 时间区间 | 如「3 分 0 秒 ~ 12 分 0 秒」(与周期类型一致:5min 为 0–5 分,15min 为 015 分)。 |
| 价格区间 | 如 `[0.92, 1]` 或「0.92 ~ 1」(maxPrice 为空时显示为 1)。 |
| 投入方式 | 「比例 10%」或「固定 100 USDC」,用 `formatUSDC` 格式化金额。 |
| 状态 | Tag 或 Switch:启用 / 停用。 |
| 最近触发 | 最近一次触发时间(若有);无则「-」。 |
| 操作 | 编辑、启用/停用、删除、查看触发记录。删除前 Popconfirm 二次确认。 |
### 2.3 与现有风格对齐
- 加载态:`Spin` 包裹列表。
- 空状态:无数据时展示空状态插画 + 引导「新增策略」。
- 响应式:`useMediaQuery({ maxWidth: 768 })`,桌面用 Table,移动用 Card + 操作折叠/抽屉。
参考:`CopyTradingList.tsx` 的 Table 列、Card 布局、筛选与 Modal 打开方式。
### 2.4 创建前检查:自动赎回配置(必须)
策略依赖**自动赎回**(需通过 Relayer/Builder API 提交链上赎回)。用户点击「新增策略」时:
1. **检查**:请求系统配置(如 `apiService.systemConfig.getConfig()` 或已有接口),判断是否已配置 Builder API Key(及可选:自动赎回已开启)。若 `builderApiKeyConfigured === false`(或后端约定之「未配置」状态),视为未配置。
2. **未配置时**:不打开新增策略表单,改为弹出**简易弹窗**(Modal),内容建议:
- **标题**:如「请先配置自动赎回」,`t('cryptoTailStrategy.redeemRequiredModal.title')`
- **正文**:简短说明尾盘策略依赖自动赎回,需要先在「系统设置」中配置 Builder API Key 及自动赎回。文案 `t('cryptoTailStrategy.redeemRequiredModal.description')`
- **操作**
- **去配置**:主按钮,点击后关闭弹窗并跳转到系统设置页(如 `/system-settings`,该页含 Relayer 配置与自动赎回开关)。
- **取消**:次按钮或关闭图标,仅关闭弹窗。
3. **已配置时**:正常打开新增策略表单弹窗。
弹窗保持简易,无需表单,仅提示 + 跳转;多语言键示例:`cryptoTailStrategy.redeemRequiredModal.title``cryptoTailStrategy.redeemRequiredModal.description``cryptoTailStrategy.redeemRequiredModal.goToSettings``cryptoTailStrategy.redeemRequiredModal.cancel`
---
## 3. 新增 / 编辑策略弹窗(Modal)
**组件**:如 `CryptoTailStrategyFormModal.tsx` 或内嵌在列表页的 Modal。
### 3.1 表单字段
| 表单项 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| **钱包提示(简短)** | **AlertWarning** | - | 在「选择账户」上方或表单单列顶部展示简短提示:建议使用**专用钱包**,避免手动操作等导致异常。文案如 `t('cryptoTailStrategy.form.walletTip')`。 |
| 策略名称 | Input | 否 | 用于列表展示,可占位「自动生成」。 |
| 选择账户 | Select | 是 | 下拉已导入账户(与跟单一致,来自 `useAccountStore()` 或接口)。 |
| 选择市场 | 市场选择器 | 是 | 仅展示 5/15 分钟加密市场;支持搜索;展示市场标题 + 周期(5min/15min);一个策略绑一个市场。 |
| **时间区间** | **开始 / 结束** | 是 | 仅在本周期内的该时间窗口内,价格满足时才下单;区间外不处理。见下方说明。 |
| 区间开始 | 下拉(分 + 秒) | 是 | 从周期起点起算的「开始」偏移。5 分钟市场可选 0~5 分 + 0~59 秒(总不超过 5 分钟);15 分钟市场可选 0~15 分 + 0~59 秒(总不超过 15 分钟)。 |
| 区间结束 | 下拉(分 + 秒) | 是 | 从周期起点起算的「结束」偏移。范围同上,且**区间开始不得大于区间结束**(前端校验)。 |
| 最低价 minPrice | InputNumber | 是 | 01,精度 24 位小数;校验 minPrice ≤ 1。 |
| 最高价 maxPrice | InputNumber | 否 | 0~1,占位「不填默认为 1」;若填则校验 minPrice ≤ maxPrice ≤ 1。 |
| 投入方式 | Radio.Group | 是 | 选项:「按比例」「固定金额」。 |
| 比例 % | InputNumber | 条件必填 | 选「按比例」时显示;0~100;可展示当前账户 USDC 余额与预估金额。 |
| 固定金额 (USDC) | InputNumber | 条件必填 | 选「固定金额」时显示;≥ 最小下单额,≤ 账户余额;用 `formatUSDC` 展示。 |
| 启用状态 | Switch | 否 | 新增默认开启;编辑可切换。 |
**时间区间说明**:例如 15 分钟市场配置「3 分 0 秒」~「12 分 0 秒」,表示从周期开始后第 3 分钟到第 12 分钟之间,若价格进入 [minPrice, maxPrice] 才下单;第 0~3 分钟、第 12~15 分钟即使价格满足也不下单。5 分钟市场同理,可选 0~5 分钟内的一段(如 0~2、2~5)。前端用下拉选择「分钟」+「秒」,后端存为相对周期起点的秒数(如 windowStartSeconds、windowEndSeconds)。
### 3.2 校验与提交
- 提交前:市场为 5/15 分钟、**时间区间开始 ≤ 时间区间结束**、时间区间不超出周期长度(5min 市场结束 ≤ 5 分 0 秒,15min 市场结束 ≤ 15 分 0 秒)、minPrice 合法、maxPrice 若填则 ≥ minPrice、余额/比例合法。
- 提交后:关闭弹窗、刷新列表、`message.success`;失败在表单上展示接口错误信息。
参考:`CopyTradingOrders/AddModal.tsx` 的 Form 布局、`Form.Item` + `rules`、条件显示(比例/固定金额)。
---
## 4. 触发记录
**入口**:列表行操作「查看触发记录」或单独 Tab/页。
### 4.1 展示方式(二选一或并存)
- **弹窗**Modal 内 Table,按策略 ID 拉取该策略的触发记录。
- **独立页**:路由如 `/crypto-tail-strategy/records/:strategyId`,页面内 Table 或 Card 列表。
### 4.2 记录列表字段
| 列/项 | 说明 |
|-------|------|
| 触发时间 | 时间戳格式化为本地时间。 |
| 市场 | 市场标题 + 周期。 |
| 方向 (outcome) | Up / Down。 |
| 触发价格 | 当时进入区间的价格。 |
| 投入金额 | USDC,用 `formatUSDC`。 |
| 订单 ID | 若有;可截断 + Tooltip 全量。 |
| 状态 | 成功 / 失败。 |
支持按时间范围、状态筛选;移动端用 Card 或折叠列表。
---
## 5. 组件与技术要点
| 要点 | 说明 |
|------|------|
| **钱包提示** | 列表页与新增/编辑表单**必须**包含「使用单独钱包」的 Alert 提示,避免用户用混用钱包导致异常;文案走多语言。 |
| **创建前检查** | 点击「新增策略」时先检查自动赎回/Builder API 是否已配置;未配置则弹出简易「去配置」弹窗,引导用户到系统设置配置 API Key 与自动赎回,不打开策略表单。 |
| 多语言 | 所有文案 `t('cryptoTailStrategy.xxx')`,在 `locales/zh-CN``zh-TW``en``common.json` 中增加键。需包含:`cryptoTailStrategy.list.walletTip``cryptoTailStrategy.form.walletTip`,以及 `cryptoTailStrategy.redeemRequiredModal.title``cryptoTailStrategy.redeemRequiredModal.description``cryptoTailStrategy.redeemRequiredModal.goToSettings``cryptoTailStrategy.redeemRequiredModal.cancel`。文案示例:列表页 `walletTip`:「请使用单独的钱包运行尾盘策略,避免该钱包用于手动交易、跟单等其他操作,否则可能导致余额或仓位变化,造成策略执行异常。」表单内 `walletTip`:「建议使用专用钱包,避免手动操作等导致余额或下单异常。」未配置赎回弹窗 `title`:「请先配置自动赎回」;`description`:「尾盘策略依赖自动赎回功能,请先在系统设置中配置 Builder API Key 并开启自动赎回。」;`goToSettings`:「去配置」;`cancel`:「取消」。 |
| 金额 | 统一 `formatUSDC`(见 frontend.mdc)。 |
| 响应式 | `useMediaQuery`;按钮触摸目标 ≥ 44px;移动端主操作突出。 |
| 类型 | 不用 `any`;为策略、触发记录定义 TypeScript 类型。 |
| API | 通过 `apiService` 封装(如 `apiService.cryptoTailStrategy.list/create/update/delete/records`)。 |
---
## 6. 页面与文件建议对应
| 功能 | 建议路径/文件 |
|------|----------------|
| 列表页 | `frontend/src/pages/CryptoTailStrategyList.tsx` |
| 未配置赎回时的简易弹窗 | 内嵌在列表页的 Modal,或 `CryptoTailStrategyList/RedeemRequiredModal.tsx` |
| 新增/编辑弹窗 | `frontend/src/pages/CryptoTailStrategyList/FormModal.tsx` 或内嵌 Modal |
| 触发记录 | `frontend/src/pages/CryptoTailStrategyList/TriggerRecordsModal.tsx``CryptoTailStrategyRecords.tsx` |
| 路由 | `App.tsx``/crypto-tail-strategy`、可选 `/crypto-tail-strategy/records/:id` |
| 菜单 | `Layout.tsx` 中增加「尾盘策略」菜单项 |
| 类型 | `frontend/src/types/index.ts``types/cryptoTailStrategy.ts` 中增加策略与触发记录类型 |
| 多语言 | `frontend/src/locales/{zh-CN,zh-TW,en}/common.json` 中增加 `cryptoTailStrategy.*` |
---
## 7. 小结:UI 包含的主要元素
- **导航**:主导航中「尾盘策略」入口。
- **列表页**:标题、钱包提示 Alert、新增按钮(点击前先检查赎回配置,未配置则弹「去配置」简易弹窗)、筛选、表格/卡片(策略名、市场、价格区间、投入方式、状态、最近触发、操作)、加载与空状态。
- **未配置赎回弹窗**:简易 Modal,提示依赖自动赎回、需先配置 Builder API Key 与自动赎回;按钮「去配置」(跳转 `/system-settings`)、「取消」。
- **表单弹窗**:策略名、账户、市场选择、minPrice/maxPrice、投入方式(比例/固定)、启用开关、提交/取消。
- **触发记录**:时间、市场、outcome、触发价格、金额、订单 ID、状态;支持弹窗或独立页。
- **通用**:Ant Design 组件、响应式、多语言、formatUSDC、TypeScript 类型。
---
## 附录 A 后端/产品要求:自动赎回须支持本策略仓位
自动赎回逻辑**必须支持赎回由尾盘策略产生的订单所对应的仓位**。即:本策略触发的市价买入会形成仓位,这些仓位在满足「可赎回」条件时,应被纳入现有自动赎回流程并正常发起赎回,不得因来源为「尾盘策略」而被排除。后端实现时需保证:
- 尾盘策略下单产生的仓位,与跟单/手动下单等来源的仓位一视同仁,参与可赎回查询与批量赎回;
- 若当前自动赎回按账户或仓位类型过滤,需将「尾盘策略订单产生的仓位」包含在内。
这样前端所依赖的「自动赎回」对该策略才完整有效。
+2
View File
@@ -34,6 +34,7 @@ import RpcNodeSettings from './pages/RpcNodeSettings'
import Announcements from './pages/Announcements'
import BacktestList from './pages/BacktestList'
import BacktestDetail from './pages/BacktestDetail'
import CryptoTailStrategyList from './pages/CryptoTailStrategyList'
import { wsManager } from './services/websocket'
import type { OrderPushMessage } from './types'
import { apiService } from './services/api'
@@ -250,6 +251,7 @@ function App() {
<Route path="/templates/add" element={<ProtectedRoute><TemplateAdd /></ProtectedRoute>} />
<Route path="/templates/edit/:id" element={<ProtectedRoute><TemplateEdit /></ProtectedRoute>} />
<Route path="/copy-trading" element={<ProtectedRoute><CopyTradingList /></ProtectedRoute>} />
<Route path="/crypto-tail-strategy" element={<ProtectedRoute><CryptoTailStrategyList /></ProtectedRoute>} />
<Route path="/copy-trading/statistics/:copyTradingId" element={<ProtectedRoute><CopyTradingStatistics /></ProtectedRoute>} />
{/* 保留旧路由以保持向后兼容 */}
<Route path="/copy-trading/orders/buy/:copyTradingId" element={<ProtectedRoute><CopyTradingBuyOrders /></ProtectedRoute>} />
+258 -72
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { Form, Input, Button, Radio, Space, Alert, Tooltip } from 'antd'
import { QuestionCircleOutlined } from '@ant-design/icons'
import { useState, useEffect } from 'react'
import { Form, Input, Button, Radio, Space, Card, Spin, message, Alert, Steps, Tag } from 'antd'
import { KeyOutlined, WalletOutlined, UserOutlined, CheckCircleOutlined, ExclamationCircleOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import { useAccountStore } from '../store/accountStore'
import {
@@ -9,18 +9,19 @@ import {
getPrivateKeyFromMnemonic,
isValidWalletAddress,
isValidPrivateKey,
isValidMnemonic
isValidMnemonic,
formatUSDC
} from '../utils'
import { useMediaQuery } from 'react-responsive'
import { apiService } from '../services/api'
import type { ProxyOption } from '../types'
type ImportType = 'privateKey' | 'mnemonic'
type WalletType = 'magic' | 'safe'
interface AccountImportFormProps {
form: any
onSuccess?: (accountId: number) => void
onCancel?: () => void
showAlert?: boolean
showCancelButton?: boolean
}
@@ -28,23 +29,33 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
form,
onSuccess,
onCancel,
showAlert = true,
showCancelButton = true
}) => {
const { t } = useTranslation()
const isMobile = useMediaQuery({ maxWidth: 768 })
const { importAccount, loading } = useAccountStore()
const [importType, setImportType] = useState<ImportType>('privateKey')
const [walletType, setWalletType] = useState<WalletType>('safe')
const [derivedAddress, setDerivedAddress] = useState<string>('')
const [addressError, setAddressError] = useState<string>('')
const [proxyOptions, setProxyOptions] = useState<ProxyOption[]>([])
const [selectedProxyType, setSelectedProxyType] = useState<string>('')
const [loadingProxyOptions, setLoadingProxyOptions] = useState<boolean>(false)
const [step, setStep] = useState<'input' | 'select'>('input') // 步骤:输入 -> 选择代理地址
// 当私钥输入时,自动推导地址
// 当私钥输入时,自动推导地址(不支持换行,自动去除换行符)
const handlePrivateKeyChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const privateKey = e.target.value.trim()
const raw = e.target.value
const normalized = raw.replace(/\r?\n/g, '')
if (normalized !== raw) {
form.setFieldsValue({ privateKey: normalized })
}
const privateKey = normalized.trim()
if (!privateKey) {
setDerivedAddress('')
setAddressError('')
setProxyOptions([])
setSelectedProxyType('')
setStep('input')
return
}
@@ -52,6 +63,9 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
if (!isValidPrivateKey(privateKey)) {
setAddressError(t('accountImport.privateKeyInvalid'))
setDerivedAddress('')
setProxyOptions([])
setSelectedProxyType('')
setStep('input')
return
}
@@ -62,18 +76,34 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
// 自动填充钱包地址字段
form.setFieldsValue({ walletAddress: address })
// 延迟获取代理选项(避免频繁请求)
setTimeout(() => {
fetchProxyOptions(address, privateKey, null)
}, 500)
} catch (error: any) {
setAddressError(error.message || t('accountImport.addressError'))
setDerivedAddress('')
setProxyOptions([])
setSelectedProxyType('')
setStep('input')
}
}
// 当助记词输入时,自动推导地址
// 当助记词输入时,自动推导地址(不支持换行,换行符转为空格)
const handleMnemonicChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const mnemonic = e.target.value.trim()
const raw = e.target.value
const normalized = raw.replace(/\r?\n/g, ' ').replace(/\s+/g, ' ').trimStart()
if (/\r?\n/.test(raw)) {
form.setFieldsValue({ mnemonic: normalized })
}
const mnemonic = normalized.trim()
if (!mnemonic) {
setDerivedAddress('')
setAddressError('')
setProxyOptions([])
setSelectedProxyType('')
setStep('input')
return
}
@@ -81,6 +111,9 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
if (!isValidMnemonic(mnemonic)) {
setAddressError(t('accountImport.mnemonicInvalid'))
setDerivedAddress('')
setProxyOptions([])
setSelectedProxyType('')
setStep('input')
return
}
@@ -91,14 +124,85 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
// 自动填充钱包地址字段
form.setFieldsValue({ walletAddress: address })
// 延迟获取代理选项(避免频繁请求)
setTimeout(() => {
fetchProxyOptions(address, null, mnemonic)
}, 500)
} catch (error: any) {
setAddressError(error.message || t('accountImport.addressErrorMnemonic'))
setDerivedAddress('')
setProxyOptions([])
setSelectedProxyType('')
setStep('input')
}
}
// 获取代理地址选项
const fetchProxyOptions = async (walletAddress: string, privateKey: string | null, mnemonic: string | null) => {
if (!walletAddress || (!privateKey && !mnemonic)) {
return
}
setLoadingProxyOptions(true)
try {
const response = await apiService.accounts.checkProxyOptions({
walletAddress,
privateKey: privateKey || undefined,
mnemonic: mnemonic || undefined
})
if (response.data.code === 0 && response.data.data) {
const options = response.data.data.options || []
setProxyOptions(options)
// 如果有选项,进入选择步骤
if (options.length > 0) {
setStep('select')
// 如果有资产,默认选择第一个有资产的选项
const hasAssetsOption = options.find((opt: ProxyOption) => opt.hasAssets)
if (hasAssetsOption) {
setSelectedProxyType(hasAssetsOption.walletType)
} else {
// 否则选择第一个选项
setSelectedProxyType(options[0].walletType)
}
} else {
setStep('input')
message.warning(t('accountImport.proxyOption.error') || '未获取到代理地址选项')
}
} else {
setProxyOptions([])
setStep('input')
message.error(response.data.msg || '获取代理地址选项失败')
}
} catch (error: any) {
setProxyOptions([])
setStep('input')
message.error(error.message || '获取代理地址选项失败')
} finally {
setLoadingProxyOptions(false)
}
}
// 切换导入方式时重置状态
useEffect(() => {
setDerivedAddress('')
setAddressError('')
setProxyOptions([])
setSelectedProxyType('')
setStep('input')
form.setFieldsValue({ walletAddress: '', privateKey: '', mnemonic: '' })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [importType])
const handleSubmit = async (values: any) => {
try {
// 如果还在输入步骤,需要先选择代理地址
if (step === 'input' || !selectedProxyType) {
return Promise.reject(new Error(t('accountImport.proxyOptionRequired')))
}
let privateKey: string
let walletAddress: string
@@ -124,14 +228,11 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
// 如果用户手动输入了地址,验证是否与推导的地址一致
if (values.walletAddress) {
if (values.walletAddress !== derivedAddressFromMnemonic) {
// 地址不匹配,使用推导的地址(因为私钥是从助记词导出的,必须使用对应的地址)
walletAddress = derivedAddressFromMnemonic
} else {
// 地址匹配,使用用户输入的地址
walletAddress = values.walletAddress
}
} else {
// 如果用户没有输入地址,使用推导的地址
walletAddress = derivedAddressFromMnemonic
}
}
@@ -145,14 +246,13 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
privateKey: privateKey,
walletAddress: walletAddress,
accountName: values.accountName,
walletType: walletType
walletType: selectedProxyType
})
// 等待store更新
await new Promise(resolve => setTimeout(resolve, 100))
// 获取新添加的账户ID(通过API获取,因为store可能还没更新)
const { apiService } = await import('../services/api')
const accountsResponse = await apiService.accounts.list()
if (accountsResponse.data.code === 0 && accountsResponse.data.data) {
const newAccounts = accountsResponse.data.data.list || []
@@ -160,76 +260,53 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
if (newAccount && onSuccess) {
onSuccess(newAccount.id)
} else if (onSuccess) {
// 如果找不到账户,仍然调用onSuccess(可能在其他地方处理)
onSuccess(0)
}
} else if (onSuccess) {
// API调用失败,仍然调用onSuccess
onSuccess(0)
}
return Promise.resolve()
} catch (error: any) {
} catch (error: unknown) {
const err = error as Error & { code?: number }
const isDuplicate = err?.code === 4601
message.error(isDuplicate ? t('accountImport.duplicateAccount') : (err?.message ?? t('accountImport.importFailed')))
return Promise.reject(error)
}
}
const currentStep = step === 'input' ? 0 : 1
return (
<>
{showAlert && (
<Alert
message={t('accountImport.securityTip')}
description={t('accountImport.securityTipDesc')}
type="warning"
showIcon
style={{ marginBottom: '24px' }}
/>
)}
<div style={{ padding: isMobile ? '0 4px' : '0 8px' }}>
<Steps
current={currentStep}
size="small"
style={{ marginBottom: 24 }}
items={[
{ title: t('accountImport.importMethod'), icon: <KeyOutlined /> },
{ title: t('accountImport.selectProxyOption'), icon: <WalletOutlined /> },
{ title: t('accountImport.accountName'), icon: <UserOutlined /> }
]}
/>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
size={isMobile ? 'middle' : 'large'}
>
<Form.Item label={t('accountImport.importMethod')}>
<Form.Item label={t('accountImport.importMethod')} style={{ marginBottom: 16 }}>
<Radio.Group
value={importType}
onChange={(e) => {
setImportType(e.target.value)
setDerivedAddress('')
setAddressError('')
form.setFieldsValue({ walletAddress: '' })
}}
optionType="button"
buttonStyle="solid"
size={isMobile ? 'middle' : 'large'}
>
<Radio value="privateKey">{t('accountImport.privateKey')}</Radio>
<Radio value="mnemonic">{t('accountImport.mnemonic')}</Radio>
</Radio.Group>
</Form.Item>
<Form.Item
label={
<span>
{t('accountImport.walletType')}{' '}
<Tooltip
title={t('accountImport.walletTypeHelp')}
overlayInnerStyle={{ whiteSpace: 'pre-line', maxWidth: '300px' }}
>
<QuestionCircleOutlined style={{ color: '#999' }} />
</Tooltip>
</span>
}
>
<Radio.Group
value={walletType}
onChange={(e) => setWalletType(e.target.value)}
>
<Radio value="safe">
{t('accountImport.walletTypeSafe')}
</Radio>
<Radio value="magic" disabled>
{t('accountImport.walletTypeMagic')} {t('accountImport.magicNotSupported')}
</Radio>
<Radio.Button value="privateKey">{t('accountImport.privateKey')}</Radio.Button>
<Radio.Button value="mnemonic">{t('accountImport.mnemonic')}</Radio.Button>
</Radio.Group>
</Form.Item>
@@ -250,13 +327,15 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
}
}
]}
help={addressError || (derivedAddress ? `${t('accountImport.derivedAddress')}: ${derivedAddress}` : '')}
help={addressError || ''}
validateStatus={addressError ? 'error' : derivedAddress ? 'success' : ''}
>
<Input.TextArea
rows={3}
rows={2}
placeholder={t('accountImport.privateKeyPlaceholder')}
onChange={handlePrivateKeyChange}
onKeyDown={(e) => e.key === 'Enter' && e.preventDefault()}
disabled={loadingProxyOptions}
/>
</Form.Item>
@@ -282,6 +361,7 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
<Input
placeholder={t('accountImport.walletAddressPlaceholder')}
readOnly={!!derivedAddress}
disabled={loadingProxyOptions}
/>
</Form.Item>
</>
@@ -302,13 +382,15 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
}
}
]}
help={addressError || (derivedAddress ? `${t('accountImport.derivedAddress')}: ${derivedAddress}` : '')}
help={addressError || ''}
validateStatus={addressError ? 'error' : derivedAddress ? 'success' : ''}
>
<Input.TextArea
rows={4}
rows={2}
placeholder={t('accountImport.mnemonicPlaceholder')}
onChange={handleMnemonicChange}
onKeyDown={(e) => e.key === 'Enter' && e.preventDefault()}
disabled={loadingProxyOptions}
/>
</Form.Item>
@@ -334,39 +416,143 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
<Input
placeholder={t('accountImport.walletAddressPlaceholder')}
readOnly={!!derivedAddress}
disabled={loadingProxyOptions}
/>
</Form.Item>
</>
)}
{/* 请求代理地址时的 loading 提示 */}
{loadingProxyOptions && step === 'input' && (
<Form.Item>
<Alert
message={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Spin size="small" />
<span>{t('accountImport.loadingProxyOptions')}</span>
</div>
}
type="info"
showIcon={false}
style={{ marginBottom: 16 }}
/>
</Form.Item>
)}
{/* 代理地址选项选择 */}
{step === 'select' && (
<Form.Item
label={t('accountImport.selectProxyOption')}
required
rules={[
{
validator: () => {
if (!selectedProxyType) {
return Promise.reject(new Error(t('accountImport.proxyOptionRequired')))
}
return Promise.resolve()
}
}
]}
style={{ marginBottom: 20 }}
>
{loadingProxyOptions ? (
<div style={{ padding: '32px 0', textAlign: 'center' }}>
<Spin tip={t('accountImport.loadingProxyOptions')} />
</div>
) : (
<Space direction="vertical" style={{ width: '100%' }} size={12}>
{proxyOptions.map((option) => {
const isSelected = selectedProxyType === option.walletType
const typeLabel = option.walletType.toLowerCase() === 'magic' ? 'Magic' : 'Safe'
return (
<Card
key={option.walletType}
hoverable
onClick={() => setSelectedProxyType(option.walletType)}
size="small"
style={{
cursor: 'pointer',
borderColor: isSelected ? 'var(--ant-color-primary)' : undefined,
borderWidth: isSelected ? 2 : 1,
backgroundColor: isSelected ? 'var(--ant-color-primary-bg)' : undefined,
transition: 'border-color 0.2s, background-color 0.2s'
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
<Space size="middle">
<Radio checked={isSelected} />
<Tag color={option.walletType.toLowerCase() === 'magic' ? 'purple' : 'blue'}>
{typeLabel}
</Tag>
{option.hasAssets && (
<span style={{ color: '#52c41a', fontSize: 12 }}>
<CheckCircleOutlined /> {t('accountImport.proxyOption.hasAssets')}
</span>
)}
{option.error && (
<span style={{ color: 'var(--ant-color-error)', fontSize: 12 }}>
<ExclamationCircleOutlined /> {t('accountImport.proxyOption.error')}
</span>
)}
</Space>
{!option.error && (
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--ant-color-primary)' }}>
{formatUSDC(option.totalBalance)} USDC
</span>
)}
</div>
<div style={{ marginTop: 8, marginLeft: 28, fontSize: 12, color: 'var(--ant-color-text-secondary)', wordBreak: 'break-all' }}>
{option.proxyAddress ? (
<span style={{ fontFamily: 'monospace' }}>{option.proxyAddress}</span>
) : (
'-'
)}
{option.error && (
<span style={{ color: 'var(--ant-color-error)', marginLeft: 8 }}>{option.error}</span>
)}
</div>
<div style={{ marginTop: 8, marginLeft: 28, fontSize: 12, color: 'var(--ant-color-text-secondary)', lineHeight: 1.5 }}>
{t('accountImport.proxyOption.proxyAddressHelp')}
</div>
</Card>
)
})}
</Space>
)}
</Form.Item>
)}
<Form.Item
label={t('accountImport.accountName')}
name="accountName"
style={{ marginBottom: 24 }}
>
<Input placeholder={t('accountImport.accountNamePlaceholder')} />
</Form.Item>
<Form.Item>
<Space>
<Form.Item style={{ marginBottom: 0 }}>
<Space size="middle">
<Button
type="primary"
htmlType="submit"
loading={loading}
disabled={step !== 'select' || !selectedProxyType || loadingProxyOptions}
size={isMobile ? 'middle' : 'large'}
style={isMobile ? { minHeight: 44 } : undefined}
>
{t('accountImport.importAccount')}
</Button>
{showCancelButton && onCancel && (
<Button onClick={onCancel}>
<Button onClick={onCancel} size={isMobile ? 'middle' : 'large'}>
{t('common.cancel')}
</Button>
)}
</Space>
</Form.Item>
</Form>
</>
</div>
)
}
export default AccountImportForm
+5
View File
@@ -157,6 +157,11 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
}
]
},
{
key: '/crypto-tail-strategy',
icon: <LineChartOutlined />,
label: t('menu.cryptoTailStrategy')
},
{
key: '/positions',
icon: <UnorderedListOutlined />,
+120 -5
View File
@@ -53,6 +53,7 @@
"accountId": "Account ID",
"accountName": "Account Name",
"walletAddress": "Wallet Address",
"walletType": "Wallet Type",
"balance": "Account Balance",
"refreshBalance": "Refresh Balance",
"apiCredentials": "API Credentials Configuration",
@@ -122,6 +123,7 @@
"importAccount": "Import Account",
"accountName": "Account Name",
"walletAddress": "Wallet Address",
"walletType": "Wallet Type",
"proxyAddress": "Proxy Wallet Address",
"apiCredentials": "API Credentials",
"balance": "Balance",
@@ -178,8 +180,6 @@
"accountImport": {
"title": "Import Account",
"back": "Back",
"securityTip": "Security Tip",
"securityTipDesc": "Private keys will be stored in the backend database. Please ensure database access is secure. HTTPS is recommended.",
"importMethod": "Import Method",
"privateKey": "Private Key",
"mnemonic": "Mnemonic",
@@ -213,9 +213,29 @@
"addressErrorMnemonic": "Cannot derive address from mnemonic",
"walletType": "Wallet Type",
"walletTypeHelp": "Web3 Wallet: Polymarket accounts connected via browser wallets like MetaMask\nMagic: Polymarket accounts logged in via email or social accounts (Google, Twitter, etc.)",
"walletTypeMagic": "Magic (Email/Social Login)",
"walletTypeSafe": "Web3 Wallet",
"magicNotSupported": "(Not Supported)"
"loadingProxyOptions": "Loading proxy addresses and asset information...",
"selectProxyOption": "Please select account type",
"proxyOptionRequired": "Please select a proxy address",
"proxyOption": {
"magic": {
"description": "Email/Social Login Account (Magic)",
"title": "Magic Proxy Address"
},
"safe": {
"description": "MetaMask Browser Wallet Account (Safe)",
"title": "Safe Proxy Address"
},
"proxyAddress": "Proxy Address",
"proxyAddressHelp": "The proxy address is the wallet address you actually use on Polymarket: Magic for email/social login accounts, Safe for browser wallet (e.g. MetaMask) accounts. Please select the one that matches how you log in to Polymarket.",
"availableBalance": "Available Balance",
"positionBalance": "Position Balance",
"totalBalance": "Total Balance",
"positionCount": "Position Count",
"noAssets": "No Assets",
"hasAssets": "Has Assets",
"error": "Failed to fetch",
"select": "Select this proxy address"
}
},
"leader": {
"title": "Leader Management",
@@ -246,6 +266,7 @@
"leaders": "Leader Management",
"templates": "Templates",
"copyTradingConfig": "Copy Trading Config",
"cryptoTailStrategy": "Tail Strategy",
"positions": "Position Management",
"backtest": "Backtest",
"statistics": "Statistics",
@@ -1363,6 +1384,10 @@
"fixedAmountRequired": "Please enter fixed amount",
"fixedAmountInvalid": "Fixed amount must be greater than 0",
"priceFilters": "Price Filters",
"priceRange": "Price Range",
"priceRangeTooltip": "Only copy Leader orders with price within the specified range. Leave empty to disable. Example: Enter 0.11 and 0.89 to only copy orders with price between 0.11 and 0.89; enter only max price 0.89 to only copy orders with price below 0.89; enter only min price 0.11 to only copy orders with price above 0.11.",
"minPricePlaceholder": "Min price (leave empty to disable)",
"maxPricePlaceholder": "Max price (leave empty to disable)",
"keywordsPlaceholder": "Please enter keywords, press Enter to add",
"maxPositionValuePlaceholder": "Leave empty to disable max position limit",
"delaySecondsHint": "Delay execution to simulate real copy trading delay",
@@ -1375,5 +1400,95 @@
"rerunTaskNamePlaceholder": "New task name (leave empty for \"Original name (copy)\")",
"rerunSuccess": "New backtest task created",
"rerunFailed": "Re-run failed"
},
"cryptoTailStrategy": {
"binanceApiAlert": {
"title": "Cannot connect to Binance API — strategy cannot run for now",
"description": "Tail strategy needs Binance market data to work. The connection failed; this may be a network issue or Binance outage. Try again later by clicking the button below.",
"recheck": "Re-check"
},
"list": {
"title": "Crypto Tail Strategy",
"walletTip": "Use a dedicated wallet for tail strategy. Do not use it for manual trading or copy trading to avoid balance/position issues.",
"addStrategy": "Add Strategy",
"strategyName": "Strategy Name",
"account": "Account",
"market": "Market",
"timeWindow": "Time Window",
"priceRange": "Price Range",
"amountMode": "Amount Mode",
"ratio": "Ratio",
"fixed": "Fixed",
"totalRealizedPnl": "Total PnL",
"winRate": "Win Rate",
"actions": "Actions",
"edit": "Edit",
"enable": "Enable",
"disable": "Disable",
"delete": "Delete",
"viewTriggers": "Orders",
"deleteConfirm": "Delete this strategy?",
"fetchFailed": "Failed to fetch list"
},
"form": {
"walletTip": "Use a dedicated wallet to avoid balance or order issues.",
"strategyName": "Strategy Name",
"strategyNamePlaceholder": "Optional, leave blank to auto-generate",
"selectAccount": "Select Account",
"selectMarket": "Select Market",
"timeWindowStart": "Window Start",
"timeWindowEnd": "Window End",
"minute": "min",
"second": "sec",
"minPrice": "Min Price",
"maxPrice": "Max Price",
"maxPricePlaceholder": "Default 1",
"amountMode": "Amount Mode",
"ratioPercent": "Ratio %",
"fixedUsdc": "Fixed (USDC)",
"enabled": "Enabled",
"create": "Create",
"update": "Update",
"timeWindowStartLEEnd": "Window start must not be greater than end",
"timeWindowExceed": "Time window must not exceed period length",
"minSpreadMode": "Min spread",
"minSpreadModeTip": "Whether to place an order is based on the spread between open and close in the current period. Auto: system computes a suggested spread from the last 20 klines (updated each period); Fixed: you enter a value (e.g. 30), order only when spread ≥ that value; None: no spread check, order when price is in range.",
"minSpreadModeNone": "None",
"minSpreadModeFixed": "Fixed",
"minSpreadModeAuto": "Auto",
"minSpreadValue": "Min spread value (USDC)",
"minSpreadValuePlaceholder": "e.g. 30"
},
"redeemRequiredModal": {
"title": "Configure Auto Redeem First",
"description": "Tail strategy requires auto redeem. Please configure Builder API Key and enable auto redeem in System Settings.",
"goToSettings": "Go to Settings",
"cancel": "Cancel"
},
"triggerRecords": {
"title": "Trigger Records",
"timeRange": "Time Range",
"startDate": "Start Date",
"endDate": "End Date",
"successTab": "Success",
"failTab": "Failed",
"triggerTime": "Time",
"market": "Market",
"direction": "Direction",
"up": "Up",
"down": "Down",
"triggerPrice": "Trigger Price",
"amount": "Amount",
"orderId": "Order ID",
"status": "Status",
"success": "Success",
"fail": "Fail",
"failReason": "Failure Reason",
"realizedPnl": "PnL",
"resolved": "Settled",
"emptySuccess": "No success records",
"emptyFail": "No failed records",
"totalCount": "{count} record(s)"
}
}
}
+119 -5
View File
@@ -121,6 +121,7 @@
"importAccount": "导入账户",
"accountName": "账户名称",
"walletAddress": "钱包地址",
"walletType": "钱包类型",
"proxyAddress": "代理钱包地址",
"apiCredentials": "API 凭证",
"balance": "余额",
@@ -178,8 +179,6 @@
"accountImport": {
"title": "导入账户",
"back": "返回",
"securityTip": "安全提示",
"securityTipDesc": "私钥将存储在后端数据库中,请确保数据库访问安全。建议使用 HTTPS 连接。",
"importMethod": "导入方式",
"privateKey": "私钥",
"mnemonic": "助记词",
@@ -213,9 +212,29 @@
"addressErrorMnemonic": "无法从助记词推导地址",
"walletType": "钱包类型",
"walletTypeHelp": "Web3钱包:使用 MetaMask 等浏览器钱包连接的 Polymarket 账户\nMagic:通过邮箱或社交账号(如 Google、Twitter)登录的 Polymarket 账户",
"walletTypeMagic": "Magic(邮箱/社交账号登录)",
"walletTypeSafe": "Web3钱包",
"magicNotSupported": "(暂不支持)"
"loadingProxyOptions": "正在获取代理地址和资产信息...",
"selectProxyOption": "请选择账户类型",
"proxyOptionRequired": "请选择一个代理地址",
"proxyOption": {
"magic": {
"description": "邮箱/社交账号登录账户(Magic",
"title": "Magic 代理地址"
},
"safe": {
"description": "MetaMask 等浏览器钱包账户(Safe",
"title": "Safe 代理地址"
},
"proxyAddress": "代理地址",
"proxyAddressHelp": "代理地址是您在 Polymarket 上实际使用的钱包地址:Magic 为邮箱/社交登录账户,Safe 为浏览器钱包(如 MetaMask)账户。请选择与您 Polymarket 登录方式一致的一项。",
"availableBalance": "可用余额",
"positionBalance": "仓位余额",
"totalBalance": "总余额",
"positionCount": "持仓数量",
"noAssets": "无资产",
"hasAssets": "有资产",
"error": "获取失败",
"select": "选择此代理地址"
}
},
"leader": {
"title": "Leader 管理",
@@ -246,6 +265,7 @@
"leaders": "Leader 管理",
"templates": "跟单模板",
"copyTradingConfig": "跟单配置",
"cryptoTailStrategy": "尾盘策略",
"positions": "仓位管理",
"backtest": "回测",
"statistics": "统计信息",
@@ -1363,6 +1383,10 @@
"fixedAmountRequired": "请输入固定金额",
"fixedAmountInvalid": "固定金额必须大于 0",
"priceFilters": "价格过滤",
"priceRange": "价格区间",
"priceRangeTooltip": "仅跟单 Leader 交易价格在指定区间内的订单。不填写表示不限制。示例:填写 0.11 和 0.89 表示仅跟单价格在 0.11 到 0.89 之间的订单;只填写最高价 0.89 表示仅跟单价格在 0.89 以下的订单;只填写最低价 0.11 表示仅跟单价格在 0.11 以上的订单。",
"minPricePlaceholder": "最低价(留空不限制)",
"maxPricePlaceholder": "最高价(留空不限制)",
"keywordsPlaceholder": "请输入关键字,按回车添加",
"maxPositionValuePlaceholder": "留空表示不启用最大仓位限制",
"delaySecondsHint": "延迟执行模拟真实跟单延迟",
@@ -1375,5 +1399,95 @@
"rerunTaskNamePlaceholder": "新任务名称(留空使用「原名称 (副本)」)",
"rerunSuccess": "已创建新回测任务",
"rerunFailed": "重新测试失败"
},
"cryptoTailStrategy": {
"binanceApiAlert": {
"title": "无法连接币安 API,策略暂时不能运行",
"description": "尾盘策略需要从币安获取行情数据才能工作。当前连接失败,可能是网络问题或币安服务异常,请稍后点击下方按钮重新检测。",
"recheck": "重新检测"
},
"list": {
"title": "加密尾盘策略",
"walletTip": "请使用单独的钱包运行尾盘策略,避免该钱包用于手动交易、跟单等其他操作,否则可能导致余额或仓位变化,造成策略执行异常。",
"addStrategy": "新增策略",
"strategyName": "策略名称",
"account": "账户",
"market": "关联市场",
"timeWindow": "时间区间",
"priceRange": "价格区间",
"amountMode": "投入方式",
"ratio": "比例",
"fixed": "固定金额",
"totalRealizedPnl": "总收益",
"winRate": "胜率",
"actions": "操作",
"edit": "编辑",
"enable": "启用",
"disable": "停用",
"delete": "删除",
"viewTriggers": "订单",
"deleteConfirm": "确定删除该策略?",
"fetchFailed": "获取列表失败"
},
"form": {
"walletTip": "建议使用专用钱包,避免手动操作等导致余额或下单异常。",
"strategyName": "策略名称",
"strategyNamePlaceholder": "选填,留空将自动生成",
"selectAccount": "选择账户",
"selectMarket": "选择市场",
"timeWindowStart": "区间开始",
"timeWindowEnd": "区间结束",
"minute": "分",
"second": "秒",
"minPrice": "最低价",
"maxPrice": "最高价",
"maxPricePlaceholder": "不填默认为 1",
"amountMode": "投入方式",
"ratioPercent": "比例 %",
"fixedUsdc": "固定金额 (USDC)",
"enabled": "启用",
"create": "创建",
"update": "更新",
"timeWindowStartLEEnd": "时间区间开始不能大于结束",
"timeWindowExceed": "时间区间不能超过周期长度",
"minSpreadMode": "最小价差",
"minSpreadModeTip": "根据当前周期开盘价与收盘价的价差决定是否下单。自动:系统按历史 20 根 K 线计算建议价差(每周期更新);固定:您输入一个数值(如 30),仅当价差 ≥ 该值时才下单;无:不校验价差,满足价格区间即下单。",
"minSpreadModeNone": "无",
"minSpreadModeFixed": "固定",
"minSpreadModeAuto": "自动",
"minSpreadValue": "最小价差数值 (USDC)",
"minSpreadValuePlaceholder": "如 30"
},
"redeemRequiredModal": {
"title": "请先配置自动赎回",
"description": "尾盘策略依赖自动赎回功能,请先在系统设置中配置 Builder API Key 并开启自动赎回。",
"goToSettings": "去配置",
"cancel": "取消"
},
"triggerRecords": {
"title": "触发记录",
"timeRange": "时间范围",
"startDate": "开始日期",
"endDate": "结束日期",
"successTab": "成功",
"failTab": "失败",
"triggerTime": "触发时间",
"market": "市场",
"direction": "方向",
"up": "Up",
"down": "Down",
"triggerPrice": "触发价格",
"amount": "投入金额",
"orderId": "订单 ID",
"status": "状态",
"success": "成功",
"fail": "失败",
"failReason": "失败原因",
"realizedPnl": "收益",
"resolved": "已结算",
"emptySuccess": "暂无成功记录",
"emptyFail": "暂无失败记录",
"totalCount": "共 {count} 条"
}
}
}
+120 -5
View File
@@ -53,6 +53,7 @@
"accountId": "賬戶ID",
"accountName": "賬戶名稱",
"walletAddress": "錢包地址",
"walletType": "錢包類型",
"balance": "賬戶餘額",
"refreshBalance": "刷新餘額",
"apiCredentials": "API 憑證配置",
@@ -122,6 +123,7 @@
"importAccount": "導入賬戶",
"accountName": "賬戶名稱",
"walletAddress": "錢包地址",
"walletType": "錢包類型",
"proxyAddress": "代理錢包地址",
"apiCredentials": "API 憑證",
"balance": "餘額",
@@ -178,8 +180,6 @@
"accountImport": {
"title": "導入賬戶",
"back": "返回",
"securityTip": "安全提示",
"securityTipDesc": "私鑰將存儲在後端數據庫中,請確保數據庫訪問安全。建議使用 HTTPS 連接。",
"importMethod": "導入方式",
"privateKey": "私鑰",
"mnemonic": "助記詞",
@@ -213,9 +213,29 @@
"addressErrorMnemonic": "無法從助記詞推導地址",
"walletType": "錢包類型",
"walletTypeHelp": "Web3錢包:使用 MetaMask 等瀏覽器錢包連接的 Polymarket 帳戶\nMagic:透過郵箱或社群帳號(如 Google、Twitter)登入的 Polymarket 帳戶",
"walletTypeMagic": "Magic(郵箱/社群帳號登入)",
"walletTypeSafe": "Web3錢包",
"magicNotSupported": "(暫不支持)"
"loadingProxyOptions": "正在獲取代理地址和資產信息...",
"selectProxyOption": "請選擇帳戶類型",
"proxyOptionRequired": "請選擇一個代理地址",
"proxyOption": {
"magic": {
"description": "郵箱/社群帳號登入帳戶(Magic",
"title": "Magic 代理地址"
},
"safe": {
"description": "MetaMask 等瀏覽器錢包帳戶(Safe",
"title": "Safe 代理地址"
},
"proxyAddress": "代理地址",
"proxyAddressHelp": "代理地址是您在 Polymarket 上實際使用的錢包地址:Magic 為郵箱/社群登入帳戶,Safe 為瀏覽器錢包(如 MetaMask)帳戶。請選擇與您 Polymarket 登入方式一致的一項。",
"availableBalance": "可用餘額",
"positionBalance": "倉位餘額",
"totalBalance": "總餘額",
"positionCount": "持倉數量",
"noAssets": "無資產",
"hasAssets": "有資產",
"error": "獲取失敗",
"select": "選擇此代理地址"
}
},
"leader": {
"title": "Leader 管理",
@@ -246,6 +266,7 @@
"leaders": "Leader 管理",
"templates": "跟單模板",
"copyTradingConfig": "跟單配置",
"cryptoTailStrategy": "尾盤策略",
"positions": "倉位管理",
"backtest": "回測",
"statistics": "統計信息",
@@ -1363,6 +1384,10 @@
"fixedAmountRequired": "請輸入固定金額",
"fixedAmountInvalid": "固定金額必須大於 0",
"priceFilters": "價格過濾",
"priceRange": "價格區間",
"priceRangeTooltip": "僅跟單 Leader 交易價格在指定區間內的訂單。不填寫表示不限制。示例:填寫 0.11 和 0.89 表示僅跟單價格在 0.11 到 0.89 之間的訂單;只填寫最高價 0.89 表示僅跟單價格在 0.89 以下的訂單;只填寫最低價 0.11 表示僅跟單價格在 0.11 以上的訂單。",
"minPricePlaceholder": "最低價(留空不限制)",
"maxPricePlaceholder": "最高價(留空不限制)",
"keywordsPlaceholder": "請輸入關鍵字,按回車添加",
"maxPositionValuePlaceholder": "留空表示不啟用最大倉位限制",
"delaySecondsHint": "延遲執行模擬真實跟單延遲",
@@ -1375,5 +1400,95 @@
"rerunTaskNamePlaceholder": "新任務名稱(留空使用「原名稱 (副本)」)",
"rerunSuccess": "已創建新回測任務",
"rerunFailed": "重新測試失敗"
},
"cryptoTailStrategy": {
"binanceApiAlert": {
"title": "無法連接幣安 API,策略暫時不能運行",
"description": "尾盤策略需要從幣安取得行情資料才能運作。目前連線失敗,可能是網路問題或幣安服務異常,請稍後點擊下方按鈕重新檢測。",
"recheck": "重新檢測"
},
"list": {
"title": "加密尾盤策略",
"walletTip": "請使用單獨的錢包運行尾盤策略,避免該錢包用於手動交易、跟單等其他操作,否則可能導致餘額或倉位變化,造成策略執行異常。",
"addStrategy": "新增策略",
"strategyName": "策略名稱",
"account": "賬戶",
"market": "關聯市場",
"timeWindow": "時間區間",
"priceRange": "價格區間",
"amountMode": "投入方式",
"ratio": "比例",
"fixed": "固定金額",
"totalRealizedPnl": "總收益",
"winRate": "勝率",
"actions": "操作",
"edit": "編輯",
"enable": "啟用",
"disable": "停用",
"delete": "刪除",
"viewTriggers": "訂單",
"deleteConfirm": "確定刪除該策略?",
"fetchFailed": "獲取列表失敗"
},
"form": {
"walletTip": "建議使用專用錢包,避免手動操作等導致餘額或下單異常。",
"strategyName": "策略名稱",
"strategyNamePlaceholder": "選填,留空將自動生成",
"selectAccount": "選擇賬戶",
"selectMarket": "選擇市場",
"timeWindowStart": "區間開始",
"timeWindowEnd": "區間結束",
"minute": "分",
"second": "秒",
"minPrice": "最低價",
"maxPrice": "最高價",
"maxPricePlaceholder": "不填默認為 1",
"amountMode": "投入方式",
"ratioPercent": "比例 %",
"fixedUsdc": "固定金額 (USDC)",
"enabled": "啟用",
"create": "創建",
"update": "更新",
"timeWindowStartLEEnd": "時間區間開始不能大於結束",
"timeWindowExceed": "時間區間不能超過週期長度",
"minSpreadMode": "最小價差",
"minSpreadModeTip": "依當前週期開盤價與收盤價的價差決定是否下單。自動:系統依歷史 20 根 K 線計算建議價差(每週期更新);固定:您輸入一個數值(如 30),僅當價差 ≥ 該值時才下單;無:不校驗價差,滿足價格區間即下單。",
"minSpreadModeNone": "無",
"minSpreadModeFixed": "固定",
"minSpreadModeAuto": "自動",
"minSpreadValue": "最小價差數值 (USDC)",
"minSpreadValuePlaceholder": "如 30"
},
"redeemRequiredModal": {
"title": "請先配置自動贖回",
"description": "尾盤策略依賴自動贖回功能,請先在系統設置中配置 Builder API Key 並開啟自動贖回。",
"goToSettings": "去配置",
"cancel": "取消"
},
"triggerRecords": {
"title": "觸發記錄",
"timeRange": "時間範圍",
"startDate": "開始日期",
"endDate": "結束日期",
"successTab": "成功",
"failTab": "失敗",
"triggerTime": "觸發時間",
"market": "市場",
"direction": "方向",
"up": "Up",
"down": "Down",
"triggerPrice": "觸發價格",
"amount": "投入金額",
"orderId": "訂單 ID",
"status": "狀態",
"success": "成功",
"fail": "失敗",
"failReason": "失敗原因",
"realizedPnl": "收益",
"resolved": "已結算",
"emptySuccess": "暫無成功記錄",
"emptyFail": "暫無失敗記錄",
"totalCount": "共 {count} 條"
}
}
}
+7
View File
@@ -181,6 +181,13 @@ const AccountDetail: React.FC = () => {
<Descriptions.Item label={t('account.accountName')}>
{account.accountName || '-'}
</Descriptions.Item>
{account.walletType && (
<Descriptions.Item label={t('account.walletType')}>
<Tag color={account.walletType.toLowerCase() === 'magic' ? 'purple' : 'blue'}>
{account.walletType.toLowerCase() === 'magic' ? 'Magic' : 'Safe'}
</Tag>
</Descriptions.Item>
)}
<Descriptions.Item label={t('account.walletAddress')} span={isMobile ? 1 : 2}>
<span style={{
fontFamily: 'monospace',
-2
View File
@@ -35,8 +35,6 @@ const AccountImport: React.FC = () => {
form={form}
onSuccess={handleSuccess}
onCancel={() => navigate('/accounts')}
showAlert={true}
showCancelButton={true}
/>
</Card>
</div>
+32 -5
View File
@@ -303,6 +303,20 @@ const AccountList: React.FC = () => {
)
}
},
{
title: t('accountList.walletType'),
dataIndex: 'walletType',
key: 'walletType',
render: (walletType: string) => {
if (!walletType) return '-'
const type = walletType.toLowerCase()
return (
<Tag color={type === 'magic' ? 'purple' : 'blue'}>
{type === 'magic' ? 'Magic' : 'Safe'}
</Tag>
)
}
},
{
title: t('accountList.balance'),
dataIndex: 'balance',
@@ -392,7 +406,7 @@ const AccountList: React.FC = () => {
style={{ marginLeft: '4px', padding: '0 4px' }}
/>
</div>
<div>
<div style={{ marginBottom: '4px' }}>
<strong>{t('accountList.proxyAddress')}:</strong> {record.proxyAddress ? `${record.proxyAddress.slice(0, 6)}...${record.proxyAddress.slice(-4)}` : '-'}
<Button
type="text"
@@ -405,6 +419,14 @@ const AccountList: React.FC = () => {
style={{ marginLeft: '4px', padding: '0 4px' }}
/>
</div>
{record.walletType && (
<div style={{ marginBottom: '4px' }}>
<strong>{t('accountList.walletType')}:</strong>{' '}
<Tag color={record.walletType.toLowerCase() === 'magic' ? 'purple' : 'blue'} style={{ marginLeft: '4px' }}>
{record.walletType.toLowerCase() === 'magic' ? 'Magic' : 'Safe'}
</Tag>
</div>
)}
</div>
<div style={{
fontSize: '14px',
@@ -654,6 +676,13 @@ const AccountList: React.FC = () => {
/>
</Space>
</Descriptions.Item>
{detailAccount.walletType && (
<Descriptions.Item label={t('accountList.walletType')}>
<Tag color={detailAccount.walletType.toLowerCase() === 'magic' ? 'purple' : 'blue'}>
{detailAccount.walletType.toLowerCase() === 'magic' ? 'Magic' : 'Safe'}
</Tag>
</Descriptions.Item>
)}
<Descriptions.Item label={t('accountList.totalBalance')} span={isMobile ? 1 : 2}>
{detailBalanceLoading ? (
<Spin size="small" />
@@ -854,9 +883,9 @@ const AccountList: React.FC = () => {
accountImportForm.resetFields()
}}
footer={null}
width={isMobile ? '95%' : 600}
width={isMobile ? '95%' : 640}
style={{ top: isMobile ? 20 : 50 }}
bodyStyle={{ padding: '24px', maxHeight: 'calc(100vh - 150px)', overflow: 'auto' }}
bodyStyle={{ padding: isMobile ? '16px 20px' : '24px 28px', maxHeight: 'calc(100vh - 140px)', overflow: 'auto' }}
destroyOnClose
maskClosable
closable
@@ -868,8 +897,6 @@ const AccountList: React.FC = () => {
setAccountImportModalVisible(false)
accountImportForm.resetFields()
}}
showAlert={true}
showCancelButton={true}
/>
</Modal>
</div>
+65 -1
View File
@@ -272,7 +272,9 @@ const BacktestList: React.FC = () => {
supportSell: values.supportSell,
keywordFilterMode: values.keywordFilterMode,
keywords: values.keywords,
maxPositionValue: values.maxPositionValue
maxPositionValue: values.maxPositionValue,
minPrice: values.minPrice,
maxPrice: values.maxPrice
}
const response = await backtestService.create(request)
@@ -324,6 +326,8 @@ const BacktestList: React.FC = () => {
keywordFilterMode: taskConfig.keywordFilterMode || 'DISABLED',
keywords: taskConfig.keywords || [],
maxPositionValue: taskConfig.maxPositionValue,
minPrice: taskConfig.minPrice,
maxPrice: taskConfig.maxPrice,
configName: `回测任务-${taskDetail.taskName}`
}
@@ -992,6 +996,50 @@ const BacktestList: React.FC = () => {
/>
</Form.Item>
<Form.Item
label={t('backtest.priceRange')}
tooltip={t('backtest.priceRangeTooltip')}
>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="minPrice" noStyle>
<InputNumber
style={{ width: '100%' }}
placeholder={t('backtest.minPricePlaceholder') || '最低价(留空不限制)'}
min={0.01}
max={0.99}
step={0.0001}
precision={4}
formatter={(value) => {
if (!value && value !== 0) return ''
const num = parseFloat(value.toString())
if (isNaN(num)) return ''
return num.toString().replace(/\.0+$/, '')
}}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="maxPrice" noStyle>
<InputNumber
style={{ width: '100%' }}
placeholder={t('backtest.maxPricePlaceholder') || '最高价(留空不限制)'}
min={0.01}
max={0.99}
step={0.0001}
precision={4}
formatter={(value) => {
if (!value && value !== 0) return ''
const num = parseFloat(value.toString())
if (isNaN(num)) return ''
return num.toString().replace(/\.0+$/, '')
}}
/>
</Form.Item>
</Col>
</Row>
</Form.Item>
<Form.Item
label={t('backtest.supportSell')}
name="supportSell"
@@ -1078,6 +1126,8 @@ const BacktestList: React.FC = () => {
keywordFilterMode: detailConfig.keywordFilterMode,
keywords: detailConfig.keywords || [],
maxPositionValue: detailConfig.maxPositionValue,
minPrice: detailConfig.minPrice,
maxPrice: detailConfig.maxPrice,
configName: `回测任务-${detailTask.taskName}`
}
setPreFilledConfig(preFilled)
@@ -1268,6 +1318,20 @@ const BacktestList: React.FC = () => {
{formatUSDC(detailConfig.maxPositionValue)} USDC
</Descriptions.Item>
)}
{(detailConfig.minPrice || detailConfig.maxPrice) && (
<Descriptions.Item label={t('backtest.priceRange')}>
{detailConfig.minPrice !== undefined && detailConfig.minPrice !== null && detailConfig.minPrice !== ''
? `${parseFloat(detailConfig.minPrice).toFixed(4)}`
: ''}
{(detailConfig.minPrice !== undefined && detailConfig.minPrice !== null && detailConfig.minPrice !== '') &&
(detailConfig.maxPrice !== undefined && detailConfig.maxPrice !== null && detailConfig.maxPrice !== '')
? ' ~ '
: ''}
{detailConfig.maxPrice !== undefined && detailConfig.maxPrice !== null && detailConfig.maxPrice !== ''
? `${parseFloat(detailConfig.maxPrice).toFixed(4)}`
: ''}
</Descriptions.Item>
)}
</Descriptions>
</Card>
)}
@@ -1106,9 +1106,9 @@ const AddModal: React.FC<AddModalProps> = ({
accountImportForm.resetFields()
}}
footer={null}
width={isMobile ? '95%' : 600}
width={isMobile ? '95%' : 640}
style={{ top: isMobile ? 20 : 50 }}
bodyStyle={{ padding: '24px', maxHeight: 'calc(100vh - 150px)', overflow: 'auto' }}
bodyStyle={{ padding: isMobile ? '16px 20px' : '24px 28px', maxHeight: 'calc(100vh - 140px)', overflow: 'auto' }}
destroyOnClose
maskClosable
closable
@@ -1120,8 +1120,6 @@ const AddModal: React.FC<AddModalProps> = ({
setAccountImportModalVisible(false)
accountImportForm.resetFields()
}}
showAlert={true}
showCancelButton={true}
/>
</Modal>
File diff suppressed because it is too large Load Diff
+17 -4
View File
@@ -62,10 +62,10 @@ const PositionList: React.FC = () => {
}
}, [])
// 当仓位数据变化时,更新可赎回统计
// 当仓位数据变化时,静默更新可赎回统计(不显示loading状态)
useEffect(() => {
if (currentPositions.length > 0) {
fetchRedeemableSummary()
fetchRedeemableSummarySilently()
}
}, [currentPositions, selectedAccountId])
@@ -74,7 +74,19 @@ const PositionList: React.FC = () => {
setCurrentPage(1)
}, [positionFilter, selectedAccountId, searchKeyword])
// 获取可赎回仓位统计
// 静默获取可赎回仓位统计(不显示loading状态)
const fetchRedeemableSummarySilently = async () => {
try {
const response = await apiService.accounts.getRedeemableSummary({ accountId: selectedAccountId })
if (response.data.code === 0 && response.data.data) {
setRedeemableSummary(response.data.data)
}
} catch (error: any) {
console.error('获取可赎回统计失败:', error)
}
}
// 获取可赎回仓位统计(带loading状态,用于用户主动操作)
const fetchRedeemableSummary = async () => {
setLoadingRedeemableSummary(true)
try {
@@ -91,8 +103,9 @@ const PositionList: React.FC = () => {
// 处理赎回按钮点击
const handleRedeemClick = async () => {
await fetchRedeemableSummary()
setRedeemModalVisible(true)
// 打开模态框时重新获取最新数据
fetchRedeemableSummary()
}
// 提交赎回
+59
View File
@@ -206,6 +206,12 @@ export const apiService = {
* API
*/
accounts: {
/**
*
*/
checkProxyOptions: (data: any) =>
apiClient.post<ApiResponse<any>>('/accounts/check-proxy-options', data),
/**
*
*/
@@ -423,6 +429,59 @@ export const apiService = {
}) =>
apiClient.post<ApiResponse<any>>('/copy-trading/configs/filtered-orders', data)
},
/**
* API
*/
cryptoTailStrategy: {
list: (data: { accountId?: number; enabled?: boolean } = {}) =>
apiClient.post<ApiResponse<{ list: import('../types').CryptoTailStrategyDto[] }>>('/crypto-tail-strategy/list', data),
create: (data: {
accountId: number
name?: string
marketSlugPrefix: string
intervalSeconds: number
windowStartSeconds: number
windowEndSeconds: number
minPrice: string
maxPrice?: string
amountMode: string
amountValue: string
minSpreadMode?: string
minSpreadValue?: string | null
enabled?: boolean
}) =>
apiClient.post<ApiResponse<import('../types').CryptoTailStrategyDto>>('/crypto-tail-strategy/create', data),
update: (data: {
strategyId: number
name?: string
windowStartSeconds?: number
windowEndSeconds?: number
minPrice?: string
maxPrice?: string
amountMode?: string
amountValue?: string
minSpreadMode?: string
minSpreadValue?: string | null
enabled?: boolean
}) =>
apiClient.post<ApiResponse<import('../types').CryptoTailStrategyDto>>('/crypto-tail-strategy/update', data),
delete: (data: { strategyId: number }) =>
apiClient.post<ApiResponse<void>>('/crypto-tail-strategy/delete', data),
triggers: (data: {
strategyId: number
page?: number
pageSize?: number
status?: string
startDate?: number
endDate?: number
}) =>
apiClient.post<ApiResponse<{ list: import('../types').CryptoTailStrategyTriggerDto[]; total: number }>>('/crypto-tail-strategy/triggers', data),
marketOptions: () =>
apiClient.post<ApiResponse<import('../types').CryptoTailMarketOptionDto[]>>('/crypto-tail-strategy/market-options', {}),
autoMinSpread: (data: { intervalSeconds: number }) =>
apiClient.post<ApiResponse<import('../types').CryptoTailAutoMinSpreadResponse>>('/crypto-tail-strategy/auto-min-spread', data)
},
/**
* API
+3 -1
View File
@@ -60,8 +60,10 @@ export const useAccountStore = create<AccountStore>((set, get) => ({
if (response.data.code === 0) {
await get().fetchAccounts()
} else {
const err = new Error(response.data.msg || '导入账户失败')
;(err as Error & { code?: number }).code = response.data.code
set({ error: response.data.msg || '导入账户失败', loading: false })
throw new Error(response.data.msg || '导入账户失败')
throw err
}
} catch (error: any) {
set({ error: error.message || '导入账户失败', loading: false })
+102
View File
@@ -46,6 +46,37 @@ export interface AccountImportRequest {
walletType?: string // 钱包类型:magic(邮箱/OAuth登录)或 safeMetaMask浏览器钱包)
}
/**
*
*/
export interface CheckProxyOptionsRequest {
walletAddress: string // EOA 地址
privateKey?: string // 私钥(加密,私钥导入时提供)
mnemonic?: string // 助记词(加密,助记词导入时提供)
}
/**
*
*/
export interface ProxyOption {
walletType: string // "magic" 或 "safe"
proxyAddress: string // 代理地址
descriptionKey: string // 说明文案的多语言 key
availableBalance: string // 可用余额
positionBalance: string // 仓位余额
totalBalance: string // 总余额
positionCount: number // 持仓数量
hasAssets: boolean // 是否有资产
error?: string // 获取失败时的错误信息
}
/**
*
*/
export interface CheckProxyOptionsResponse {
options: ProxyOption[] // 代理地址选项列表
}
/**
*
*/
@@ -1003,3 +1034,74 @@ export interface BacktestTaskDto {
executionStartedAt?: number
executionFinishedAt?: number
}
/**
*
*/
export interface CryptoTailStrategyDto {
id: number
accountId: number
name?: string
marketSlugPrefix: string
marketTitle?: string
intervalSeconds: number
windowStartSeconds: number
windowEndSeconds: number
minPrice: string
maxPrice: string
amountMode: string
amountValue: string
/** 最小价差模式: NONE, FIXED, AUTO */
minSpreadMode?: string
/** 最小价差数值(FIXED 时必填;AUTO 时可为计算值) */
minSpreadValue?: string | null
enabled: boolean
lastTriggerAt?: number
/** 已实现总收益 USDC */
totalRealizedPnl?: string
settledCount?: number
winCount?: number
/** 胜率 0~1 */
winRate?: string
createdAt: number
updatedAt: number
}
/** 自动最小价差计算响应 */
export interface CryptoTailAutoMinSpreadResponse {
minSpreadUp: string
minSpreadDown: string
}
/**
*
*/
export interface CryptoTailStrategyTriggerDto {
id: number
strategyId: number
periodStartUnix: number
marketTitle?: string
outcomeIndex: number
triggerPrice: string
amountUsdc: string
orderId?: string
status: string
failReason?: string
resolved?: boolean
/** 已实现盈亏 USDC(结算后有值) */
realizedPnl?: string
winnerOutcomeIndex?: number
settledAt?: number
createdAt: number
}
/**
*
*/
export interface CryptoTailMarketOptionDto {
slug: string
title: string
intervalSeconds: number
periodStartUnix: number
endDate?: string
}
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""
从币安现货 API 获取 BTC/USDC 15 分钟 K 线数据
API: https://api.binance.com/api/v3/klines
无需 API Key公开行情接口
使用方法:
python3 scripts/fetch_binance_btc_usdc_klines.py
python3 scripts/fetch_binance_btc_usdc_klines.py --limit 96
python3 scripts/fetch_binance_btc_usdc_klines.py --limit 10 --interval 15m
"""
import argparse
import json
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
BINANCE_BASE = "https://api.binance.com"
def fetch_klines(
symbol: str = "BTCUSDC",
interval: str = "15m",
limit: int = 500,
start_time: int | None = None,
end_time: int | None = None,
) -> list[list] | None:
"""
获取 K 线数据
返回每根 K 线: [ openTime, open, high, low, close, volume, closeTime, ... ]
"""
params = {"symbol": symbol, "interval": interval, "limit": limit}
if start_time is not None:
params["startTime"] = start_time
if end_time is not None:
params["endTime"] = end_time
qs = urllib.parse.urlencode(params)
url = f"{BINANCE_BASE}/api/v3/klines?{qs}"
req = urllib.request.Request(url, headers={"User-Agent": "PolymarketBot/1.0 (script)"})
try:
with urllib.request.urlopen(req, timeout=15) as resp:
return json.load(resp)
except urllib.error.HTTPError as e:
body = e.read().decode() if e.fp else ""
try:
err = json.loads(body)
except json.JSONDecodeError:
err = {"msg": body}
print(f"Request failed: {e.code} - {err}", file=sys.stderr)
return None
except Exception as e:
print(f"Request error: {e}", file=sys.stderr)
return None
def main():
parser = argparse.ArgumentParser(description="Fetch Binance BTC/USDC 15m klines")
parser.add_argument("--symbol", default="BTCUSDC", help="Trading pair (default: BTCUSDC)")
parser.add_argument("--interval", default="15m", help="Kline interval (default: 15m)")
parser.add_argument("--limit", type=int, default=20, help="Number of klines (default: 20, max 1000)")
parser.add_argument("--start", type=int, default=None, help="Start time (ms)")
parser.add_argument("--end", type=int, default=None, help="End time (ms)")
args = parser.parse_args()
limit = max(1, min(1000, args.limit))
print("=== Binance BTC/USDC K-line (15m) ===\n")
print(f"Symbol: {args.symbol} Interval: {args.interval} Limit: {limit}")
if args.start:
print(f"Start: {args.start} ({time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(args.start // 1000))})")
if args.end:
print(f"End: {args.end} ({time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(args.end // 1000))})")
print()
klines = fetch_klines(
symbol=args.symbol,
interval=args.interval,
limit=limit,
start_time=args.start,
end_time=args.end,
)
if not klines:
print("No kline data returned")
sys.exit(1)
print(f"Got {len(klines)} kline(s)\n")
print("Columns: openTime, open, high, low, close, volume, closeTime, ...")
print("-" * 72)
for k in klines:
open_ts_ms = k[0]
open_ts = open_ts_ms // 1000
ts_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(open_ts))
o, h, l, c, v = k[1], k[2], k[3], k[4], k[5]
print(f" {ts_str} O:{o} H:{h} L:{l} C:{c} V:{v}")
print("-" * 72)
last = klines[-1]
print(f"Latest: open={last[1]}, high={last[2]}, low={last[3]}, close={last[4]}, volume={last[5]}")
if __name__ == "__main__":
main()
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""
获取 Polymarket 5/15 分钟加密市场数据开始时间结束时间conditionId
使用 Gamma API: https://gamma-api.polymarket.com
验证方式: python3 scripts/fetch_crypto_minute_markets.py
"""
import json
import time
import urllib.request
from datetime import datetime, timezone
GAMMA_BASE = "https://gamma-api.polymarket.com"
def fetch_event_by_slug(slug: str) -> dict | None:
url = f"{GAMMA_BASE}/events/slug/{slug}"
req = urllib.request.Request(url, headers={"User-Agent": "PolymarketBot/1.0 (script)"})
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return json.load(resp)
except urllib.error.HTTPError as e:
if e.code == 404:
return None
raise
except Exception as e:
print(f"Request error {url}: {e}")
return None
def parse_iso_to_ms(iso: str | None) -> int | None:
if not iso:
return None
try:
# ISO 可能带 Z 或 +00:00
if iso.endswith("Z"):
iso = iso.replace("Z", "+00:00")
dt = datetime.fromisoformat(iso.replace("Z", "+00:00"))
return int(dt.timestamp() * 1000)
except Exception:
return None
def main():
now = int(time.time())
# 5 分钟周期边界 (300s)
period_5m = (now // 300) * 300
next_5m = period_5m + 300
# 15 分钟周期边界 (900s);slug 可能用结束时间,这里试起点
period_15m = (now // 900) * 900
next_15m = period_15m + 900
print("=== 5 minute markets (BTC) ===")
for ts, label in [(period_5m, "current"), (next_5m, "next")]:
slug = f"btc-updown-5m-{ts}"
ev = fetch_event_by_slug(slug)
if ev and ev.get("slug"):
start = ev.get("startDate")
end = ev.get("endDate")
print(f" [{label}] slug={slug}")
print(f" title: {ev.get('title', '')[:70]}")
print(f" startDate: {start} endDate: {end}")
markets = ev.get("markets") or []
for m in markets[:1]:
cid = m.get("conditionId")
print(f" conditionId: {cid}")
print(f" question: {(m.get('question') or '')[:60]}")
# clobTokenIds 用于订单簿
tokens = m.get("clobTokenIds")
if tokens:
try:
ids = json.loads(tokens) if isinstance(tokens, str) else tokens
print(f" clobTokenIds: {ids[:2]}..." if len(ids) > 2 else f" clobTokenIds: {ids}")
except Exception:
print(f" clobTokenIds: {tokens[:80]}...")
else:
print(f" [{label}] slug={slug} -> not found (404 or empty)")
print("\n=== 15 minute markets (BTC) ===")
for ts, label in [(period_15m, "current"), (next_15m, "next")]:
slug = f"btc-updown-15m-{ts}"
ev = fetch_event_by_slug(slug)
if ev and ev.get("slug"):
print(f" [{label}] slug={slug}")
print(f" title: {ev.get('title', '')[:70]}")
print(f" startDate: {ev.get('startDate')} endDate: {ev.get('endDate')}")
for m in (ev.get("markets") or [])[:1]:
print(f" conditionId: {m.get('conditionId')}")
else:
print(f" [{label}] slug={slug} -> not found")
print("\n=== Summary ===")
print("5m: slug btc-updown-5m-{periodStartUnix}, periodStartUnix = (now // 300) * 300; period end = endDate.")
print("15m: slug btc-updown-15m-{periodStartUnix}, periodStartUnix = (now // 900) * 900; period end = endDate.")
print("Period start = slug timestamp; period end = API endDate (do not use startDate as period start).")
if __name__ == "__main__":
main()
+2 -1
View File
@@ -9,7 +9,8 @@
"version": "1.0.0",
"dependencies": {
"@ethersproject/wallet": "^5.7.0",
"@polymarket/clob-client": "^5.2.1"
"@polymarket/clob-client": "^5.2.1",
"ws": "^8.18.0"
}
},
"node_modules/@adraffy/ens-normalize": {
+4 -2
View File
@@ -5,10 +5,12 @@
"type": "module",
"scripts": {
"get-order-detail": "node get-order-detail.js",
"verify-backtest-data": "node verify-backtest-data.js"
"verify-backtest-data": "node verify-backtest-data.js",
"ws-binance-klines": "node ws_binance_btc_usdc_klines.js"
},
"dependencies": {
"@ethersproject/wallet": "^5.7.0",
"@polymarket/clob-client": "^5.2.1"
"@polymarket/clob-client": "^5.2.1",
"ws": "^8.18.0"
}
}
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env node
/**
* 通过币安 WebSocket 订阅 BTC/USDC 15 分钟 K 线推送
*
* 文档: https://developers.binance.com/docs/binance-spot-api-docs/web-socket-streams
* - 现货 K 线流: wss://stream.binance.com:9443/ws/btcusdc@kline_15m
* - 服务端约每 20 秒发 pingws 库会自动回 pong
* - K 线推送频率: 15m 约每 2 秒更新一次x=true 表示该根 K 线已收盘
*
* 依赖: npm install ws scripts 目录下执行
*
* 使用方法:
* node scripts/ws_binance_btc_usdc_klines.js
* node scripts/ws_binance_btc_usdc_klines.js --interval 1m
* node scripts/ws_binance_btc_usdc_klines.js --url "wss://stream.binance.com:9443/ws/btcusdc@kline_15m"
* Ctrl+C 退出
*/
import WebSocket from 'ws';
const BINANCE_WS_BASE = 'wss://stream.binance.com:9443';
function parseArgs() {
const args = process.argv.slice(2);
const out = { symbol: 'btcusdc', interval: '15m', url: null };
for (let i = 0; i < args.length; i++) {
if (args[i] === '--symbol' && args[i + 1]) {
out.symbol = String(args[i + 1]).toLowerCase();
i++;
} else if (args[i] === '--interval' && args[i + 1]) {
out.interval = args[i + 1];
i++;
} else if (args[i] === '--url' && args[i + 1]) {
out.url = args[i + 1];
i++;
}
}
return out;
}
function formatKline(msg) {
if (msg.e !== 'kline') {
return JSON.stringify(msg);
}
const k = msg.k || {};
const tMs = Number(k.t) || 0;
const tsStr = new Date(tMs).toISOString().replace('T', ' ').slice(0, 19);
const closed = k.x ? ' [CLOSED]' : '';
return ` ${tsStr} O:${k.o} H:${k.h} L:${k.l} C:${k.c} V:${k.v}${closed}`;
}
function run(wsUrl) {
console.log(`Connecting: ${wsUrl}`);
console.log('(Ctrl+C to exit)\n');
const ws = new WebSocket(wsUrl);
ws.on('open', () => {
// ws 库收到 ping 会自动回 pong,无需手动处理
});
ws.on('message', (data) => {
try {
const msg = JSON.parse(data.toString());
if (msg.result !== undefined && msg.id !== undefined) return;
if (msg.code !== undefined && msg.code !== 0) {
console.error('Error:', msg);
return;
}
console.log(formatKline(msg));
} catch {
console.log(data.toString());
}
});
ws.on('error', (err) => {
console.error('WebSocket error:', err.message);
process.exit(1);
});
ws.on('close', (code, reason) => {
if (code !== 1000) {
console.error(`Connection closed: ${code} ${reason?.toString() || ''}`);
process.exit(1);
}
});
}
const args = parseArgs();
const wsUrl = args.url || `${BINANCE_WS_BASE}/ws/${args.symbol}@kline_${args.interval}`;
run(wsUrl);