Compare commits

..

105 Commits

Author SHA1 Message Date
WrBug 5bb46ebb97 docs: 尾盘策略文档归集至 docs/crypto-tail-strategy/,前端链接同步
- 将尾盘策略相关文档移至 docs/crypto-tail-strategy/{zh,en}/
- 新增 docs/crypto-tail-strategy/README.md 目录说明
- 前端配置指南链接改为 docs/crypto-tail-strategy/${lang}/crypto-tail-strategy-user-guide.md

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

CryptoTailStrategyService:
- delete() 发布 CryptoTailStrategyChangedEvent

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-11 23:54:49 +08:00
WrBug 99c4328830 feat(backtest): 失败与已停止的回测任务支持在列表中删除
操作列删除按钮对 STOPPED、FAILED 状态也显示,与详情页一致。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 02:57:37 +08:00
WrBug 1df438d18d fix(frontend): 修复 BacktestList 与 AddModal 的 TypeScript 类型错误
- BacktestList: Option 改为 Select.Option,避免与原生 HTML Option 冲突
- AddModal: onSelectChange 仅在 value 有值时调用 fetchLeaderAssetInfo

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 02:46:27 +08:00
WrBug 366d3d1b25 fix(positions): 修复仓位管理点击市场链接打开两个标签页的问题
在市场列链接的 onClick 中增加 e.preventDefault(),阻止 <a> 默认行为,
避免与 window.open() 重复打开同一页面。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 02:41:59 +08:00
WrBug 17a6a4f314 fix: 创建跟单时带入最大仓位金额参数
修复从回测任务创建跟单时,maxPositionValue 参数没有带入的问题。

修改内容:
- BacktestList.tsx:两处创建跟单配置时添加 maxPositionValue 字段
  1) handleCreateCopyTrading 函数(预填充配置)
  2) 复制按钮的预填充配置
- CopyTradingOrders/AddModal.tsx:
  1) AddModalProps 接口添加 maxPositionValue 字段
  2) fillPreFilledConfig 函数添加 maxPositionValue 处理

类型定义已存在:
- types/index.ts 的 CopyTradingCreateRequest 已包含 maxPositionValue 字段
- CopyTradingOrders/AddModal.tsx 的 handleSubmit 已处理该字段(第 355 行)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 02:40:12 +08:00
WrBug 8600449f69 fix(copytrading): 赎回后按 copyTrading 分别生成自动卖出记录
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 02:38:47 +08:00
WrBug 3d47bd50d9 chore(backtest): 移除回测轮询服务的冗余日志
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 02:31:26 +08:00
WrBug 20df471683 feat: 回测任务添加最大仓位参数支持
参考跟单配置的实现,为回测任务添加最大仓位金额限制功能。

后端改动:
- 添加数据库迁移文件 V31__add_backtest_max_position_value.sql
- BacktestTask 实体添加 maxPositionValue 字段(BigDecimal?,NULL表示不启用)
- BacktestCreateRequest 添加 maxPositionValue 字段
- BacktestConfigDto 添加 maxPositionValue 字段
- BacktestService 处理 maxPositionValue 的创建、详情和复制
- BacktestExecutionService.taskToCopyTrading 映射 maxPositionValue 参数
- BacktestExecutionService.executeBacktest 在买入逻辑中添加仓位检查
  * 检查条件:当前仓位 + 买入金额 <= 最大仓位
  * 仓位价值计算:quantity * avgPrice
  * 超过限制时跳过该笔买入并记录详细日志
  * 按市场+方向(marketId + outcomeIndex)分别检查

前端改动:
- BacktestList 表单添加最大仓位金额输入框(可选字段)
- 创建回测任务时包含 maxPositionValue
- 回测任务详情中显示 maxPositionValue(仅在配置了时显示)
- 默认最大每日订单数从 50 改为 100
- 多语言翻译新增:
  * backtest.maxPositionValue: 最大仓位金额 / 最大倉位金額 / Max Position Value
  * maxPositionValuePlaceholder: 留空表示不启用最大仓位限制

功能特点:
- 可选参数:留空或为 null 时表示不启用该限制,保持向后兼容
- 单市场单方向限制:按 marketId 和 outcomeIndex 分别计算和限制
- 精确计算:使用 BigDecimal 进行数值比较
- 详细日志:超过限制时记录当前仓位、买入金额、总计等详细信息

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 02:25:24 +08:00
WrBug 5487c7c862 feat(leader): Leader 管理展示跟单/回测数量并支持跳转筛选
- 后端:LeaderDto 增加 backtestCount,LeaderService 统计回测数并返回
- Leader 列表:展示跟单数、回测数,点击跳转跟单配置/回测页并带 leaderId 筛选
- 跟单配置页:从 URL 读取 leaderId 并应用筛选,Leader 下拉改用 LeaderSelect
- 回测页:支持 Leader 筛选,页面加载时请求 Leader 列表,筛选与详情统一显示规则
- LeaderSelect:支持 style,回测/跟单筛选统一使用
- 多语言:补充 leaderList 相关 key
- 移动端:Leader 管理卡片内按钮增加内边距 padding 8px 16px

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 02:03:17 +08:00
WrBug c1829fe21b feat(backtest): 结算显示市场名称,列表非终态任务 3s 轮询进度
- 结算记录: WIN/LOSE/UNKNOWN/CLOSED 通过 marketService.getMarket 写入 marketTitle
- 回测列表: 存在 PENDING/RUNNING 时每 3 秒静默刷新列表更新进度,全部终态后停止轮询

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 01:12:31 +08:00
WrBug b1ad6f02d2 feat(backtest): 回测结算持久化、按配置重新测试、执行时以当前时间为窗口基准
- 持久化: BUY/SELL 与 SETTLEMENT(WIN/LOSE/UNKNOWN/CLOSED) 均写入 backtest_trade
- 重新测试: 已完成任务支持「按当前配置重新测试」,新任务名称可编辑,后端 POST /tasks/rerun + 前端按钮与确认弹窗
- 回测窗口: 首次执行以当前时间为终点、startTime = endTime - backtestDays(局部变量,不修改实体)
- 新增错误码 BACKTEST_TASK_NOT_COMPLETED、SERVER_BACKTEST_RERUN_FAILED 及多语言

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 01:08:40 +08:00
WrBug 01a1836659 docs: 清理根目录临时文档
- 删除过时的版本发布说明(RELEASE.md 和 RELEASE_NOTES_v2.0.*.md)
- 删除临时检查报告(CHECK_AND_FIX_REPORT.md、IMPLEMENTATION_COMPLETE.md、MISSING_ITEMS_CHECK.md)
- 所有版本发布信息统一在 RELEASE_NOTES.md 中管理
- 添加新的部署指南文档和 Docker 构建配置

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 23:34:46 +08:00
WrBug b616e10ceb feat(skills): 添加 check-i18n-keys 技能并补全多语言 key
- 按 Cursor Agent Skills 规范:技能目录含 SKILL.md 与 scripts/
- 多语言检查脚本:扫描 t() 引用,对比 zh-CN/zh-TW/en common.json
- 补全缺失 key:common.create/copy/copySuccess/copyFailed、
  leaderList.openDetailFailed、leaderDetail.market、
  filteredOrdersList.orderDetails、filteredOrdersList.filterTypes.keywordFilter

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 23:17:27 +08:00
WrBug d07c4c2c5e fix: improve deployment info display
- Add admin reset password key to deployment completion info
- Fix color code display by adding -e flag to echo commands
- Display admin reset key in plaintext for easy reference
2026-02-01 03:17:58 +08:00
WrBug 7485e2ce24 feat: add interactive deployment script with one-liner installation
- Add deploy-interactive.sh: Interactive deployment script with automatic configuration
  - Auto-generate secure random keys (JWT, admin reset key, crypto key, DB password)
  - Smart .env detection for quick updates without reconfiguration
  - Random port generation (10000-60000) for SERVER_PORT default
  - Interactive prompts with clear defaults and auto-generation hints
  - Automatic docker-compose.prod.yml download from GitHub
  - Health check and deployment verification

- Add deploy-interactive-README.md: Comprehensive deployment guide
  - One-liner installation commands (curl/wget)
  - Quick update workflow for existing deployments
  - Detailed feature explanations and usage examples

- Update README.md & README_EN.md: Add one-liner installation section
  - Automatic working directory creation (~polyhermes)
  - Simplified deployment process for new users
  - Clear benefits and features list

Features:
- 📁 Auto-creates ~/polyhermes working directory
- 🔐 Generates secure random secrets automatically
-  Quick update mode: skip config if .env exists
- 🚀 One command from zero to running
- 🌐 Downloads latest docker-compose config from GitHub
-  Full health check and verification
2026-02-01 03:12:27 +08:00
WrBug 8f52b5016a feat: 将回测任务详情改为 Modal 方式展示
- 将任务详情从页面跳转改为 Modal 弹窗展示
- 在详情 Modal 中添加完整的统计信息、配置信息、图表和交易记录
- 修复统计数据显示:添加 totalTrades 字段,修复空值处理
- 将'创建跟单'按钮固定在 Modal 底部 footer
- 修复 Modal 滚动问题,确保内容可以正常滚动
- 修复编译错误:删除未使用的 navigate 导入
- 添加调试日志便于问题排查
2026-01-31 22:57:07 +08:00
WrBug 5c0808c2cb feat: 添加从回测任务创建跟单配置功能
- 在回测任务列表页和详情页添加'创建跟单'按钮
- 实现从回测任务配置自动填充到跟单配置表单
- 复用 AddModal 组件,支持 preFilledConfig 参数预填充
- 修复配置字段从 BacktestConfigDto 获取的问题
- 列表页点击按钮时先获取任务详情以获取配置信息
- 添加调试日志便于问题排查
- 更新多语言文案:'一键创建跟单配置' -> '创建跟单'
2026-01-31 22:23:44 +08:00
WrBug 9d01c120e5 fix: 修复回测任务恢复逻辑和分页问题
- 修复新建任务被误判为恢复任务的问题:将 lastProcessedTradeIndex 默认值从 0 改为 null
- 修复页码计算错误:统一页码从 0 开始,确保新任务的 offset 为 0
- 修复恢复任务时跳过已处理条目的逻辑
- 添加数据库迁移文件 V30,将现有新任务的索引值改为 NULL
2026-01-31 21:45:02 +08:00
WrBug ec8cfeac77 fix: 修复回测分页起始位置错误
- 修复新回测任务从第1页开始,导致 offset=100
- 应该从第0页开始,offset=0
- 修复后不会跳过前100条交易数据

Bug原因:
- BacktestExecutionService startPage 初始化为 1
- offset = page * size = 1 * 100 = 100
- 导致跳过前100条交易
2026-01-31 21:25:56 +08:00
WrBug 7c833f1e9b fix: 资金变化图始终显示所有交易数据
- 添加 allTrades 状态存储所有交易数据
- 添加 fetchAllTrades 方法获取所有数据(size=10000)
- 图表使用 allTrades 而不是分页的 trades
- 修复图表因分页只显示部分数据的问题
2026-01-31 21:15:09 +08:00
WrBug 08cc30a90f feat: 移除回测交易记录的盈亏列
- 移除盈亏列,进一步简化交易记录表格
- 盈亏信息已在统计卡片和图表中展示
2026-01-31 21:13:39 +08:00
WrBug 8288cd7579 feat: 移除回测交易记录的手续费列
- 移除手续费列,简化交易记录表格
- 手续费已包含在盈亏计算中,无需单独显示
2026-01-31 21:12:49 +08:00
WrBug 55da551971 fix: 修复回测统计计算空列表异常
- 修复 calculateStatistics 在 trades 为空时的 IndexOutOfBoundsException
- 添加空列表检查,避免访问 trades[0]
- 确保回测任务即使没有交易记录也能正常完成
2026-01-31 21:10:10 +08:00
WrBug ff3b24b50e fix: 优化回测交易记录显示
- 盈亏颜色逻辑:仅盈利(>0)显示绿色,亏损(<0)显示红色,持平(=0)无颜色
- 手续费显示:确保null值显示为 $0.00
- 遵守绿涨红跌原则
2026-01-31 20:58:13 +08:00
WrBug 88cae4018a fix: 修复 V29 migration SQL 语法兼容性
- 移除 DROP COLUMN IF EXISTS(仅 MySQL 8.0.29+ 支持)
- 使用标准 DROP COLUMN 语法(MySQL 5.7+ 兼容)
- 添加 Flyway 清理脚本
2026-01-31 20:51:26 +08:00
WrBug 96fbc3f720 feat: 回测系统优化 - 清理字段、移动端适配、缓存优化
## 主要改动

### 1. 回测字段清理
- 删除 BacktestCreate.tsx(未使用,创建使用 modal)
- 移除不适用于回测的字段:
  - priceTolerance, delaySeconds(回测使用历史数据)
  - minOrderDepth, maxSpread(无历史订单簿数据)
  - minPrice, maxPrice, maxPositionValue(回测中无实际意义)
  - maxMarketEndDate(意义不大)
- 更新相关 Entity、DTO、Service
- 添加数据库 migration(V29)

### 2. 移动端响应式适配
- BacktestList:筛选器、表格、Modal、表单响应式布局
- BacktestDetail:详情卡片、统计信息、按钮响应式
- 使用 useMediaQuery hook,断点 768px
- 支持手机、平板、桌面多种设备

### 3. 已结算市场缓存优化
- MarketPriceService 添加 Caffeine 缓存
- 缓存已结算市场价格,避免重复 RPC 调用
- 预计 RPC 请求减少 ~78.6%,性能提升显著
- 添加缓存统计和管理方法

### 4. UI 修复
- 修复 BacktestDetail 停止按钮文本
- 更新多语言翻译(supportSell → 跟单卖出)

## 性能收益
- 回测场景 RPC 调用减少 78.6%
- 回测执行时间预计减少 11秒(14秒 → 3秒)
- 移动端用户体验显著提升
2026-01-31 20:45:40 +08:00
WrBug fd25821e39 fix: 优化回测功能UI和修复问题
## 主要修改

### UI 优化
- 将回测功能移到跟单交易菜单下作为子菜单
- 创建回测改为使用 Modal 方式(移除独立页面)
- 优化导航路径,使用 useNavigate 替代 window.location.href

### 功能修复
- 修复 API 路径问题:移除重复的 /api 前缀(/api/backtest -> /backtest)
- 修复 copyRatio 输入框单位:改为百分比输入(100 表示 100%),与跟单配置保持一致
- 修复回测列表中 leader 列显示为空的问题:优先显示 leaderName,为空时显示 leaderAddress

### 多语言完善
- 补充缺失的多语言 key:
  - common.ascending / common.descending / common.day
  - backtest.sortBy / backtest.sortOrder / backtest.createdAt
  - backtest.copyRatioTooltip / backtest.copyRatioPlaceholder
  - 以及其他表单验证相关的 key
- 为中文简体、中文繁体、英文三种语言都添加了完整的翻译

## 修改的文件
- frontend/src/App.tsx - 移除 BacktestCreate 路由
- frontend/src/components/Layout.tsx - 调整菜单结构
- frontend/src/pages/BacktestList.tsx - Modal 创建、修复 leader 显示
- frontend/src/pages/BacktestDetail.tsx - 优化导航
- frontend/src/pages/BacktestCreate.tsx - 同步 copyRatio 修改
- frontend/src/services/api.ts - 修复 API 路径
- frontend/src/locales/* - 补充多语言配置
2026-01-31 07:46:16 +08:00
WrBug cdd02e9f3d feat: 实现回测功能
## 功能概述
实现完整的回测功能,支持基于历史数据模拟跟单策略的执行效果。

## 后端实现
- 数据库:新增 backtest_task 和 backtest_trade 表(V27迁移脚本)
- 实体类:BacktestTask、BacktestTrade
- Repository:BacktestTaskRepository、BacktestTradeRepository
- Service:
  - BacktestService:回测任务管理(CRUD)
  - BacktestDataService:从 Polymarket Data API 获取历史交易数据
  - BacktestExecutionService:回测算法核心实现
  - BacktestPollingService:定时轮询执行回测任务
- Controller:BacktestController(6个API接口)
- DTO:BacktestDto、TradeData
- 错误码:新增回测相关错误码和国际化消息

## 前端实现
- 页面组件:
  - BacktestList:回测任务列表
  - BacktestCreate:创建回测任务
  - BacktestDetail:回测详情(含图表)
  - BacktestChart:资金曲线图表(使用 ECharts)
- 类型定义:backtest.ts
- API 服务:集成所有回测接口
- 国际化:支持中英文

## 核心特性
- 回测天数限制:1-15 天
- 数据获取:直接从 Polymarket Data API 获取历史交易(不使用缓存表)
- 任务执行:同一时刻只执行一个任务,按创建时间顺序执行最早创建的任务
- 回测算法:完整实现市场结算、卖出匹配、价格容忍度、每日订单限制等规则
- 实时进度:支持任务进度更新和实时轮询

## 文档更新
- BACKTEST_PRD.md:产品需求文档
- BACKTEST_TECHNICAL_DESIGN.md:技术设计文档
- BACKTEST_REVIEW_CHECKLIST.md:设计评审检查清单

## 其他修改
- 移除 max_position_count 配置(V26迁移脚本)
- 移除 BacktestSyncService(不再需要实时同步)
- 修复前后端编译错误
2026-01-31 07:27:36 +08:00
WrBug fabbd81f22 Merge branch 'main' into backtest 2026-01-31 01:06:54 +08:00
WrBug ace32b37cf Merge pull request #26 from WrBug/remove-telegram-notify-workflow
chore: 移除 Telegram PR 合并通知工作流
2026-01-31 01:01:23 +08:00
WrBug f1eae8a40b chore: 移除 Telegram PR 合并通知工作流 2026-01-31 00:59:45 +08:00
WrBug ca655f351d docs: 添加 v2.0.3 发布说明 2026-01-31 00:57:45 +08:00
WrBug 40081c2464 feat: 为所有数值显示添加千分位分隔符
- 重构 formatNumber 和 formatUSDC 函数,默认添加千分位分隔符
- 更新 Statistics.tsx、CopyTradingStatistics.tsx 等统计页面
- 更新 PositionList.tsx 持仓列表的数值显示
- 更新 AccountList.tsx 账户列表的余额显示
- 所有数值(金额、数量、价格等)现在默认显示千分位
- 示例:1234567.89 显示为 1,234,567.89

影响范围:
- 工具函数:utils/index.ts
- 统计页面:Statistics.tsx, CopyTradingStatistics.tsx
- 业务页面:PositionList.tsx, AccountList.tsx

Changes:
- Refactored formatNumber and formatUSDC to include thousand separators by default
- Updated statistics pages to display numbers with commas
- Updated position list and account list for better readability
- Example: 1234567.89 now displays as 1,234,567.89
2026-01-31 00:52:42 +08:00
WrBug f1f809f54b docs: 新增跟单回测功能设计文档
- 新增产品需求文档 (BACKTEST_PRD.md)
- 新增技术设计文档 (BACKTEST_TECHNICAL_DESIGN.md)
- 新增设计审查清单 (BACKTEST_REVIEW_CHECKLIST.md)
- 新增文档总览 (README.md)

关键设计:
- 实时市场结算机制(按endDate检查)
- 严格余额检查(不计入未实现持仓)
- 停止条件优化(余额不足且无持仓时停止)
- 不计算手续费(简化逻辑)
2026-01-31 00:26:00 +08:00
WrBug e8fd1b503b 移除跟单最大仓位数量(maxPositionCount)配置
- 数据库:创建迁移文件 V26 删除 max_position_count 字段
- 后端:移除实体类、DTO、服务中的 maxPositionCount 相关代码
- 后端:移除 FilterResult 中的 FAILED_MAX_POSITION_COUNT 状态
- 后端:移除 CopyTradingFilterService 中的最大仓位数量检查逻辑
- 前端:移除类型定义、表单字段和国际化翻译
- 前端:移除过滤订单列表中的 MAX_POSITION_COUNT 类型
- 前端:修复编译错误(Spin 导入、Table fixed 类型、size 类型)
2026-01-31 00:24:25 +08:00
WrBug 390b3ee876 feat: 在创建跟单配置时显示Leader资产信息
- 选择Leader后自动获取并显示资产信息(总资产、可用余额、仓位资产)
- 在AddModal和EditModal中都实现该功能
- 使用Card和Statistic组件美观展示
- 添加中英文多语言支持
- 使用formatUSDC格式化显示金额
2026-01-30 23:58:18 +08:00
WrBug 80976609c7 fix: 修复TypeScript类型错误
- 移除未使用的PositionDto导入
- 修复pnl参数重复声明
2026-01-30 23:29:42 +08:00
WrBug 17eea0183f refactor: 列表只显示可用余额
- 桌面和移动端都只显示可用余额
- 移除总余额显示
2026-01-30 23:13:26 +08:00
WrBug 6980781f89 refactor: 移除仓位资产列
- 列表只显示总余额和跟单数
- 移除仓位资产列
- 移动端卡片移除仓位资产Tag
2026-01-30 22:42:44 +08:00
WrBug 3350039f05 feat: Leader列表显示仓位资产
- 列表新增仓位资产列
- 移动端卡片新增仓位资产Tag
- 文案'仓位余额'改为'仓位资产'
- 仓位资产使用紫色Tag显示
2026-01-30 22:24:59 +08:00
WrBug 0bdc0c74d1 feat: Leader列表优化
- 后端过滤价值为0的仓位
- 持仓列表显示市场名称而非ID
- 列表移除分类和创建时间列
- 文案'跟单关系数'改为'跟单数'
- 持仓DTO添加title字段
2026-01-30 22:03:50 +08:00
WrBug cccc829cef Merge pull request #25 from WrBug/dev
feat: 优化构建流程和通知机制
2026-01-29 04:09:44 +08:00
WrBug fa1a915e9c docs: 添加 v2.0.2 release notes 2026-01-29 04:00:11 +08:00
WrBug e5992b5145 feat(workflow): 优化构建流程和通知机制
- 添加 workflow_dispatch 支持手动触发构建
- Release 事件默认只打包产物(在线升级),不构建 Docker
- 手动触发可选择构建类型:在线升级或 Docker 升级
- 优化 Telegram 通知文案,区分构建类型和升级路径
- 在线升级任务不发送开始通知,只发送完成通知
- 手动触发时产物上传为 Artifact,Release 事件上传到 Release Assets
2026-01-29 03:59:07 +08:00
WrBug 42472f6b03 fix: 修复买入订单金额精度问题
- 修复市场买入订单 makerAmount 和 takerAmount 的精度限制
- makerAmount (USDC) 限制为最多 2 位小数
- takerAmount (shares) 限制为最多 4 位小数
- 符合 Polymarket API 的要求,解决 'invalid amounts' 错误
2026-01-29 03:45:17 +08:00
WrBug c56e08e681 docs: 添加 v2.0.1 release notes 2026-01-28 15:30:20 +08:00
WrBug 7e87965418 fix: 修复订单状态检查中缓存清除导致计时重置的问题
- 修复订单详情为 null 时缓存清除时机问题,避免计时被重置导致订单无法删除
- 统一部分卖出和未成交订单的删除逻辑,都使用60秒重试窗口(之前部分卖出订单等待1小时)
- 删除未使用的常量 PARTIAL_SOLD_CLEANUP_WINDOW_MS
- 优化日志输出,区分部分卖出和未成交订单的日志信息

修复问题:
- 订单超过60秒不会删除的问题
- 部分卖出订单等待时间过长的问题
2026-01-28 15:28:36 +08:00
WrBug e115d459f7 refactor: 优化过滤原因文案的数值显示格式
优化内容:
1. 新增 DateUtils.formatDuration() 函数:将毫秒转换为可读格式(X天X小时X分钟)
2. 优化市场截止时间过滤原因:使用格式化后的时间显示,避免显示过长的毫秒数
3. 优化所有过滤原因中的 BigDecimal 数值显示:
   - 价格区间检查:去除尾随零
   - 价差检查:去除尾随零
   - 订单深度检查:去除尾随零
   - 仓位金额检查:去除尾随零

优化效果:
- 时间显示:1009163461ms (280小时) -> 11天16小时26分钟
- 数值显示:0.50000000000000000000 -> 0.5

影响范围:
- DateUtils.kt
- CopyTradingFilterService.kt
2026-01-28 15:16:12 +08:00
WrBug dd39e59304 fix: 修复禁用RPC节点后仍被使用的问题
问题描述:
- 用户在前端禁用节点后,系统仍然使用禁用的节点
- 原因:RetrofitFactory 的 rpcApiCache 未被清理,导致禁用的节点 URL 继续在拦截器中被使用

修复内容:
1. updateNode 方法:添加禁用节点时清理缓存的逻辑
2. deleteNode 方法:添加删除节点时清理缓存的逻辑
3. getAvailableNode 方法:优化逻辑,确保始终返回可用节点
   - 没有启用节点时返回默认节点
   - 所有节点不可用时返回默认节点
   - 异常时返回默认节点作为兜底
4. 新增 createDefaultNodeConfig 辅助方法:创建默认节点配置
5. 简化 getHttpUrl 和 getWsUrl 方法:移除冗余逻辑

影响范围:
- RpcNodeService.kt
2026-01-28 15:13:07 +08:00
WrBug 2efc04a3c3 fix: 补充缺失的多语言key
修复内容:
1. copyTradingOrders 部分:添加 allFullyMatched 和 partiallyMatched(zh-CN、zh-TW)
2. filteredOrdersList 部分:添加 noFilteredOrders(所有语言文件)

影响范围:
- frontend/src/locales/en/common.json
- frontend/src/locales/zh-CN/common.json
- frontend/src/locales/zh-TW/common.json
2026-01-28 15:11:19 +08:00
WrBug 2b20f4b2e2 fix: 修复订单金额计算和价格范围验证问题
修复了两个关键问题:

1. 订单金额精度错误
   - 原因:总是强制将金额舍入到 4 位小数,导致精度丢失
   - 问题案例:25.09488 被错误地舍入为 25.0948
   - 修复:只在小数位数超过 roundConfig.amount 时才进行舍入,采用与 clob-client 一致的条件舍入逻辑

2. 价格超出范围错误
   - 原因:价格 0.999 舍入到 2 位小数后变成 1.00,违反了 price < 1 的限制
   - 修复:在舍入后验证价格范围,超出时自动调整到最接近的有效值 (0.01-0.99)

主要变更:
- 对价格进行 roundNormal 舍入(参考 clob-client)
- 实现条件舍入逻辑:先 roundUp(amount + 4),必要时再 roundDown(amount)
- 添加价格范围验证 (MIN_PRICE=0.01, MAX_PRICE=0.99)
- 添加 decimalPlaces() 辅助函数
- 修改 roundNormal/roundDown/roundUp 函数,只有必要时才舍入

参考:clob-client/src/order-builder/helpers.ts 的 getOrderRawAmounts 函数
2026-01-23 12:54:48 +08:00
WrBug 419c68c024 fix: 修复本地 Docker 构建时 frontend/dist 和 backend/build 不存在的问题
- 移除 Dockerfile 中无效的 COPY frontend/dist 指令
- 改进前端构建逻辑,添加外部产物检查
- 改进后端构建逻辑,支持复制外部构建的 JAR 文件
- 在 deploy.sh 中添加创建占位符目录步骤,避免 COPY 失败
- 保持与 GitHub Actions 构建流程的兼容性(BUILD_IN_DOCKER=false)

修复了本地使用 ./deploy.sh 部署时的构建错误:
"failed to calculate checksum: /frontend/dist: not found"
2026-01-21 21:04:25 +08:00
WrBug 8889803dd7 fix: 修复 Dockerfile 中前端产物复制逻辑
- 修复 BUILD_IN_DOCKER=true 时 frontend/dist 不存在的问题
- 当 BUILD_IN_DOCKER=true 时,RUN 已经创建了 dist 目录
- COPY frontend/dist ./dist 现在应该能正常工作
2026-01-21 20:56:57 +08:00
WrBug 9c5517768f Merge pull request #24 from WrBug/dynamic_load
feat: 合并 v2.0.0 系统动态更新功能到 main 分支
2026-01-21 20:46:49 +08:00
WrBug 4aa85a9c2d fix: 修复系统更新 API 路由和改进健康检查逻辑
- 修复 /api/update/execute 404 错误,改为 /api/update/update
- 增加健康检查等待时间从30秒到90秒
- 后端日志输出到文件,便于诊断问题
- 每5秒检查一次后端进程状态
- 改进错误诊断信息,区分进程退出和健康检查失败
- 记录后端启动日志的最后100行用于问题排查
2026-01-21 11:53:30 +08:00
WrBug fabfe601c6 Merge pull request #23 from WrBug/dev
Release v1.1.16: 订单列表优化和多语言修复
2026-01-20 23:32:31 +08:00
161 changed files with 22962 additions and 3050 deletions
+39
View File
@@ -0,0 +1,39 @@
# Cursor Skills 目录结构
本目录遵循 [Cursor Agent Skills 规范](https://cursor.com/cn/docs/context/skills):每个技能为一个**文件夹**,内含 `SKILL.md` 及可选的 `scripts/``references/``assets/`
## 目录结构
```
.cursor/skills/
├── frontend/ # 前端相关 skill 分组
│ └── check-i18n-keys/ # 单个 skill(文件夹名 = name
│ ├── SKILL.md # 必填,技能定义与指令
│ └── scripts/
│ ├── check-i18n-keys.ts
│ └── package.json
├── backend/ # 后端相关 skill
└── common/ # 通用 skill
```
- **SKILL.md**YAML frontmatter`name``description` 必填,`name` 须与父文件夹名一致、小写连字符)+ 给 Agent 的详细指令。
- **scripts/**Agent 可执行的脚本,在 SKILL.md 中用相对路径引用。
## 添加新 Skill
1. **确定分组**:在 `frontend/``backend/``common/` 下新建**以技能名命名的文件夹**(仅小写、数字、连字符,如 `check-i18n-keys`)。
2. **创建 SKILL.md**:在该文件夹内创建 `SKILL.md`(大写),frontmatter 中 `name` 必须与文件夹名一致。
3. **可选 scripts/**:在技能文件夹内建 `scripts/`,放入可执行脚本;在 SKILL.md 正文中写明运行命令(如 `cd .cursor/skills/.../scripts && npm run xxx`)。
## 示例
- `frontend/check-i18n-keys/SKILL.md` + `frontend/check-i18n-keys/scripts/` — 检查前端多语言 key。
## 运行 check-i18n-keys
```bash
cd .cursor/skills/frontend/check-i18n-keys/scripts
npm install
npm run check-i18n
```
@@ -0,0 +1,36 @@
---
name: check-i18n-keys
description: 检查前端多语言 key 完整性。当用户要求检查 i18n、多语言缺失、翻译 key 或运行 check-i18n 时使用。
---
# Check I18n Keys
检查前端代码中使用的 i18n key 是否在所有语言文件(zh-CN、zh-TW、en)中存在,并报告缺失或语言间不一致的 key。
## 使用时机
- 用户要求「检查多语言」「检查 i18n」「扫一下 key」「多语言缺失」时
- 用户要求运行多语言检查或执行 check-i18n 时
- 在修改或新增前端文案后,需要确认三语言 key 一致时
## 指令
1. **运行检查脚本**(在项目仓库根目录下执行):
```bash
cd .cursor/skills/frontend/check-i18n-keys/scripts && npm install && npm run check-i18n
```
首次运行需先 `npm install`,之后可直接 `npm run check-i18n`。
2. **脚本行为**
- 扫描 `frontend/` 下所有 `.ts`、`.tsx`、`.js`、`.jsx`(排除 node_modules、dist、build、*.d.ts
- 提取代码中 `t('key')` / `t("key")` 的 key
- 与 `frontend/src/locales/{zh-CN,zh-TW,en}/common.json` 对比
- 报告:代码中使用但某语言 JSON 缺失的 key;某语言有而另一语言没有的 key(不一致)
3. **结果处理**
- 若有缺失或不一致,退出码为 1;可根据报告在对应 `common.json` 中补全 key
- 脚本路径:`scripts/check-i18n-keys.ts`(相对本 skill 根目录)
## 可选目录说明
- `scripts/`:可执行检查脚本,Agent 按上述命令调用。
@@ -0,0 +1,3 @@
node_modules/
*.log
.DS_Store
@@ -0,0 +1,302 @@
#!/usr/bin/env node
/**
* 检查前端多语言 key 完整性
*
* 扫描范围:frontend 下所有 .ts / .tsx / .js / .jsx(排除 node_modules、dist、build、*.d.ts
*
* 支持的检查:
* 1. 代码引用但 JSON 没有:某 ts/js 引用了 t("common.test"),但任意语言 common.json 里没有该 key → 报缺失
* 2. 语言间不一致:zh-CN/zh-TW 的 JSON 有 common.xxx,但 en 的没有 → 报不一致(某些语言有、某些没有)
*/
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
import { glob } from 'glob';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
interface CheckResult {
usedKeys: Set<string>;
localeKeys: {
'zh-CN': Set<string>;
'zh-TW': Set<string>;
'en': Set<string>;
};
missingKeys: {
'zh-CN': string[];
'zh-TW': string[];
'en': string[];
};
inconsistentKeys: Array<{
key: string;
existsIn: string[];
missingIn: string[];
}>;
}
const LOCALES = ['zh-CN', 'zh-TW', 'en'] as const;
type Locale = typeof LOCALES[number];
// 颜色输出(如果支持)
const colors = {
reset: '\x1b[0m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m',
};
function log(message: string, color: keyof typeof colors = 'reset') {
console.log(`${colors[color]}${message}${colors.reset}`);
}
/**
* 从代码文件中提取所有 t() 调用中的 key
*/
function extractKeysFromCode(frontendDir: string): Set<string> {
const keys = new Set<string>();
// 匹配 t('key') 或 t("key") 或 t(`key`)
const tPattern = /t\(['"`]([^'"`]+)['"`]\)/g;
// 扫描所有 TS/JS 文件(含 .ts .tsx .js .jsx),排除 node_modules、dist、build、*.d.ts
const files = glob.sync('**/*.{ts,tsx,js,jsx}', {
cwd: frontendDir,
ignore: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/*.d.ts'],
absolute: true,
});
for (const file of files) {
try {
const content = fs.readFileSync(file, 'utf-8');
let match;
while ((match = tPattern.exec(content)) !== null) {
const key = match[1];
// 过滤掉模板字符串中的变量(如 t(`key.${variable}`)
if (!key.includes('${') && !key.includes('${')) {
keys.add(key);
}
}
} catch (error) {
console.error(`Error reading file ${file}:`, error);
}
}
return keys;
}
/**
* 加载语言文件
*/
function loadLocaleFile(localeDir: string, locale: Locale): Record<string, any> {
const filePath = path.join(localeDir, locale, 'common.json');
if (!fs.existsSync(filePath)) {
log(`⚠️ 语言文件不存在: ${filePath}`, 'yellow');
return {};
}
try {
const content = fs.readFileSync(filePath, 'utf-8');
return JSON.parse(content);
} catch (error) {
log(`❌ 解析语言文件失败 ${filePath}: ${error}`, 'red');
return {};
}
}
/**
* 展平嵌套对象为点分隔的 key 路径
*/
function flattenKeys(obj: Record<string, any>, prefix = ''): Set<string> {
const keys = new Set<string>();
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
// 递归处理嵌套对象
const nestedKeys = flattenKeys(value, fullKey);
nestedKeys.forEach(k => keys.add(k));
} else {
// 叶子节点
keys.add(fullKey);
}
}
return keys;
}
/**
* 检查 key 完整性
*/
function checkKeys(usedKeys: Set<string>, localeKeys: Record<Locale, Set<string>>): CheckResult {
const result: CheckResult = {
usedKeys,
localeKeys,
missingKeys: {
'zh-CN': [],
'zh-TW': [],
'en': [],
},
inconsistentKeys: [],
};
// 检查每个语言文件中缺失的 key
for (const locale of LOCALES) {
for (const key of usedKeys) {
if (!localeKeys[locale].has(key)) {
result.missingKeys[locale].push(key);
}
}
}
// 检查不一致的 key(某些语言有,某些没有)
const allLocaleKeys = new Set<string>();
LOCALES.forEach(locale => {
localeKeys[locale].forEach(key => allLocaleKeys.add(key));
});
for (const key of allLocaleKeys) {
const existsIn: Locale[] = [];
const missingIn: Locale[] = [];
for (const locale of LOCALES) {
if (localeKeys[locale].has(key)) {
existsIn.push(locale);
} else {
missingIn.push(locale);
}
}
// 如果某些语言有,某些没有,则不一致
if (existsIn.length > 0 && existsIn.length < LOCALES.length) {
result.inconsistentKeys.push({
key,
existsIn,
missingIn,
});
}
}
return result;
}
/**
* 打印检查报告
*/
function printReport(result: CheckResult): void {
log('\n=== 多语言 Key 检查报告 ===\n', 'cyan');
// 统计信息
log('📊 统计信息:', 'blue');
log(` - 代码中使用的 key 数量: ${result.usedKeys.size}`);
log(` - zh-CN 语言文件 key 数量: ${result.localeKeys['zh-CN'].size}`);
log(` - zh-TW 语言文件 key 数量: ${result.localeKeys['zh-TW'].size}`);
log(` - en 语言文件 key 数量: ${result.localeKeys['en'].size}`);
log('');
// 缺失的 key
let hasMissing = false;
for (const locale of LOCALES) {
if (result.missingKeys[locale].length > 0) {
hasMissing = true;
log(`❌ 缺失的 Key (代码中使用但 ${locale} 语言文件中不存在):`, 'red');
result.missingKeys[locale].forEach(key => {
log(` - ${key}`, 'red');
});
log('');
}
}
// 不一致的 key
if (result.inconsistentKeys.length > 0) {
log('⚠️ 不一致的 Key (某些语言文件有,某些没有):', 'yellow');
result.inconsistentKeys.forEach(({ key, existsIn, missingIn }) => {
log(` - ${key}`, 'yellow');
log(` 存在于: ${existsIn.join(', ')}`, 'yellow');
log(` 缺失于: ${missingIn.join(', ')}`, 'yellow');
});
log('');
}
// 总结
const hasErrors = hasMissing || result.inconsistentKeys.length > 0;
if (hasErrors) {
log('❌ 检查失败:发现缺失或不一致的 key', 'red');
} else {
log('✅ 检查通过:所有 key 都完整且一致', 'green');
}
}
/**
* 主函数
*/
function main(): void {
// 脚本位于 .cursor/skills/frontend/check-i18n-keys/scripts/,向上到项目根
const scriptDir = __dirname;
const skillDir = path.resolve(scriptDir, '..');
const frontendSkillDir = path.resolve(skillDir, '..');
const repoRoot = path.resolve(frontendSkillDir, '../../..');
const frontendDir = path.join(repoRoot, 'frontend');
const localesDir = path.join(frontendDir, 'src', 'locales');
// 检查目录是否存在
if (!fs.existsSync(frontendDir)) {
log(`❌ 前端目录不存在: ${frontendDir}`, 'red');
process.exit(1);
}
if (!fs.existsSync(localesDir)) {
log(`❌ 语言文件目录不存在: ${localesDir}`, 'red');
process.exit(1);
}
log('🔍 开始检查多语言 key...\n', 'cyan');
// 1. 提取代码中使用的 key
log('📝 扫描代码文件...', 'blue');
const usedKeys = extractKeysFromCode(frontendDir);
log(` 找到 ${usedKeys.size} 个使用的 key\n`, 'green');
// 2. 加载语言文件
log('📚 加载语言文件...', 'blue');
const localeKeys: Record<Locale, Set<string>> = {
'zh-CN': new Set(),
'zh-TW': new Set(),
'en': new Set(),
};
for (const locale of LOCALES) {
const localeObj = loadLocaleFile(localesDir, locale);
localeKeys[locale] = flattenKeys(localeObj);
log(` ${locale}: ${localeKeys[locale].size} 个 key`, 'green');
}
log('');
// 3. 检查
log('🔎 检查 key 完整性...', 'blue');
const result = checkKeys(usedKeys, localeKeys);
// 4. 输出报告
printReport(result);
// 5. 退出码
const hasErrors =
result.missingKeys['zh-CN'].length > 0 ||
result.missingKeys['zh-TW'].length > 0 ||
result.missingKeys['en'].length > 0 ||
result.inconsistentKeys.length > 0;
process.exit(hasErrors ? 1 : 0);
}
// 运行
main();
@@ -0,0 +1,680 @@
{
"name": "cursor-skill-frontend-scripts",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cursor-skill-frontend-scripts",
"version": "1.0.0",
"dependencies": {
"glob": "^13.0.1"
},
"devDependencies": {
"@types/node": "^20.10.0",
"tsx": "^4.7.0",
"typescript": "^5.3.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@isaacs/balanced-match": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
"license": "MIT",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@isaacs/brace-expansion": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz",
"integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==",
"license": "MIT",
"dependencies": {
"@isaacs/balanced-match": "^4.0.1"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@types/node": {
"version": "20.19.33",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz",
"integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/esbuild": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.3",
"@esbuild/android-arm": "0.27.3",
"@esbuild/android-arm64": "0.27.3",
"@esbuild/android-x64": "0.27.3",
"@esbuild/darwin-arm64": "0.27.3",
"@esbuild/darwin-x64": "0.27.3",
"@esbuild/freebsd-arm64": "0.27.3",
"@esbuild/freebsd-x64": "0.27.3",
"@esbuild/linux-arm": "0.27.3",
"@esbuild/linux-arm64": "0.27.3",
"@esbuild/linux-ia32": "0.27.3",
"@esbuild/linux-loong64": "0.27.3",
"@esbuild/linux-mips64el": "0.27.3",
"@esbuild/linux-ppc64": "0.27.3",
"@esbuild/linux-riscv64": "0.27.3",
"@esbuild/linux-s390x": "0.27.3",
"@esbuild/linux-x64": "0.27.3",
"@esbuild/netbsd-arm64": "0.27.3",
"@esbuild/netbsd-x64": "0.27.3",
"@esbuild/openbsd-arm64": "0.27.3",
"@esbuild/openbsd-x64": "0.27.3",
"@esbuild/openharmony-arm64": "0.27.3",
"@esbuild/sunos-x64": "0.27.3",
"@esbuild/win32-arm64": "0.27.3",
"@esbuild/win32-ia32": "0.27.3",
"@esbuild/win32-x64": "0.27.3"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/get-tsconfig": {
"version": "4.13.6",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/glob": {
"version": "13.0.1",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.1.tgz",
"integrity": "sha512-B7U/vJpE3DkJ5WXTgTpTRN63uV42DseiXXKMwG14LQBXmsdeIoHAPbU/MEo6II0k5ED74uc2ZGTC6MwHFQhF6w==",
"license": "BlueOak-1.0.0",
"dependencies": {
"minimatch": "^10.1.2",
"minipass": "^7.1.2",
"path-scurry": "^2.0.0"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/lru-cache": {
"version": "11.2.5",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz",
"integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/minimatch": {
"version": "10.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz",
"integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/brace-expansion": "^5.0.1"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"license": "ISC",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/path-scurry": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz",
"integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==",
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}
@@ -0,0 +1,16 @@
{
"name": "cursor-skill-frontend-scripts",
"version": "1.0.0",
"type": "module",
"scripts": {
"check-i18n": "tsx check-i18n-keys.ts"
},
"dependencies": {
"glob": "^13.0.1"
},
"devDependencies": {
"@types/node": "^20.10.0",
"tsx": "^4.7.0",
"typescript": "^5.3.0"
}
}
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "node",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"downlevelIteration": true,
"types": ["node"]
},
"include": ["*.ts"],
"exclude": ["node_modules", "dist"]
}
+119 -21
View File
@@ -4,6 +4,24 @@ on:
release:
types:
- published # 当通过 GitHub Releases 页面创建 release 时触发
workflow_dispatch:
inputs:
build_type:
description: '构建类型'
required: true
type: choice
options:
- package-only # 只打包产物
- package-and-docker # 打包产物 + Docker 镜像
default: 'package-and-docker'
version:
description: '版本号(例如: v1.0.0'
required: false
type: string
tag_name:
description: 'Git Tag 名称(留空则使用 version'
required: false
type: string
jobs:
build-and-push:
@@ -16,25 +34,61 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ github.event.release.tag_name }} # 使用 release 对应的 tag
ref: ${{ github.event.release.tag_name || github.event.inputs.tag_name || github.event.inputs.version || github.ref }}
- name: Determine build type
id: build_config
run: |
# 确定构建类型
if [ "${{ github.event_name }}" = "release" ]; then
# Release 事件:默认只打包产物(不构建 Docker)
BUILD_TYPE="package-only"
echo "📦 Release 事件:将只打包产物(不构建 Docker)"
else
# workflow_dispatch 事件:使用用户输入
BUILD_TYPE="${{ github.event.inputs.build_type }}"
echo "🔧 手动触发:构建类型 = ${BUILD_TYPE}"
fi
echo "BUILD_TYPE=${BUILD_TYPE}" >> $GITHUB_OUTPUT
- name: Extract version and check if pre-release
id: extract_version
run: |
# 从 release tag 中提取版本号(例如 v1.0.0 -> 1.0.0
TAG_NAME="${{ github.event.release.tag_name }}"
if [ -z "$TAG_NAME" ]; then
TAG_NAME=${GITHUB_REF#refs/tags/}
# 从不同事件源提取版本号
if [ "${{ github.event_name }}" = "release" ]; then
# Release 事件:从 release tag 中提取
TAG_NAME="${{ github.event.release.tag_name }}"
IS_PRERELEASE="${{ github.event.release.prerelease }}"
else
# workflow_dispatch 事件:从输入参数中提取
TAG_NAME="${{ github.event.inputs.tag_name }}"
if [ -z "$TAG_NAME" ]; then
TAG_NAME="${{ github.event.inputs.version }}"
fi
# 如果仍然为空,尝试从 git ref 中提取
if [ -z "$TAG_NAME" ]; then
TAG_NAME=${GITHUB_REF#refs/tags/}
if [ "$TAG_NAME" = "$GITHUB_REF" ]; then
# 不是 tag,尝试从分支名或 commit SHA 获取
TAG_NAME=${GITHUB_REF#refs/heads/}
if [ "$TAG_NAME" = "$GITHUB_REF" ]; then
TAG_NAME="dev-$(date +%Y%m%d-%H%M%S)"
echo "⚠️ 未指定版本号,使用临时版本: $TAG_NAME"
fi
fi
fi
IS_PRERELEASE="false"
fi
# 验证版本号格式:v数字.数字.数字[-后缀](例如 v1.0.0, v2.10.102, v1.0.0-beta
if [[ ! "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then
echo "错误: 版本号格式不正确,应为 v数字.数字.数字 或 v数字.数字.数字-后缀 (例如: v1.0.0, v1.0.0-beta)"
exit 1
if [[ ! "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]] && [[ ! "$TAG_NAME" =~ ^dev- ]]; then
echo "⚠️ 警告: 版本号格式不符合标准,但仍将继续构建"
echo " 当前版本号: $TAG_NAME"
echo " 标准格式应为: v数字.数字.数字 或 v数字.数字.数字-后缀 (例如: v1.0.0, v1.0.0-beta)"
fi
VERSION=${TAG_NAME#v} # 移除 v 前缀
IS_PRERELEASE="${{ github.event.release.prerelease }}"
VERSION=${TAG_NAME#v} # 移除 v 前缀(如果存在)
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
echo "TAG=$TAG_NAME" >> $GITHUB_OUTPUT
@@ -47,7 +101,7 @@ jobs:
fi
- name: Send Telegram notification (build started)
if: steps.extract_version.outputs.IS_PRERELEASE == 'false'
if: steps.extract_version.outputs.IS_PRERELEASE == 'false' && steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
@@ -59,12 +113,15 @@ jobs:
fi
# 获取构建信息
VERSION="${{ steps.extract_version.outputs.VERSION }}"
TAG="${{ steps.extract_version.outputs.TAG }}"
RELEASE_URL="${{ github.event.release.html_url }}"
# 构建消息内容(仅包含关键信息)
MESSAGE="🔨 <b>Docker 镜像构建中</b>"$'\n'$'\n'"📦 <b>版本:</b> ${VERSION}"$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"
if [ "${{ github.event_name }}" = "release" ]; then
RELEASE_URL="${{ github.event.release.html_url }}"
MESSAGE="🔨 <b>Release 构建中</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"
else
WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
MESSAGE="🔨 <b>构建中</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">查看 Workflow</a>"
fi
# 发送 Telegram 消息(使用 jq 转义 JSON
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
@@ -141,12 +198,18 @@ jobs:
echo "✅ 前端文件已复制"
# 创建版本信息文件
if [ "${{ github.event_name }}" = "release" ]; then
RELEASE_NOTES=$(echo '${{ github.event.release.body }}' | jq -Rs .)
else
RELEASE_NOTES="\"手动构建 - workflow_dispatch\""
fi
cat > update-package/version.json <<EOF
{
"version": "${{ steps.extract_version.outputs.VERSION }}",
"tag": "${{ steps.extract_version.outputs.TAG }}",
"buildTime": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"releaseNotes": $(echo '${{ github.event.release.body }}' | jq -Rs .)
"releaseNotes": ${RELEASE_NOTES}
}
EOF
echo "✅ 版本信息已创建"
@@ -169,6 +232,7 @@ jobs:
echo "$CHECKSUM $FILE" > checksums.txt
- name: Upload Update Package to Release
if: github.event_name == 'release'
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -178,7 +242,8 @@ jobs:
asset_name: polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz
asset_content_type: application/gzip
- name: Upload Checksums
- name: Upload Checksums to Release
if: github.event_name == 'release'
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -188,19 +253,32 @@ jobs:
asset_name: checksums.txt
asset_content_type: text/plain
- name: Upload Update Package as Artifact
if: github.event_name == 'workflow_dispatch'
uses: actions/upload-artifact@v4
with:
name: polyhermes-${{ steps.extract_version.outputs.TAG }}-update
path: |
polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz
checksums.txt
retention-days: 30
- name: Set up Docker Buildx
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
uses: docker/setup-buildx-action@v3
with:
# 启用多架构构建支持
platforms: linux/amd64,linux/arm64
- name: Log in to Docker Hub
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Prepare Docker build context
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
run: |
echo "📦 准备 Docker 构建上下文..."
# 确保构建产物存在且可访问
@@ -217,6 +295,7 @@ jobs:
ls -lh backend/build/libs/*.jar
- name: Build and push Docker image
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
uses: docker/build-push-action@v5
with:
context: .
@@ -234,6 +313,12 @@ jobs:
GITHUB_REPO_URL=https://github.com/WrBug/PolyHermes
cache-from: type=registry,ref=wrbug/polyhermes:latest
cache-to: type=inline
- name: Skip Docker build notice
if: steps.build_config.outputs.BUILD_TYPE == 'package-only'
run: |
echo "⏭️ 跳过 Docker 镜像构建(构建类型:package-only"
echo "✅ 仅打包产物已完成"
- name: Send Telegram notification
if: steps.extract_version.outputs.IS_PRERELEASE == 'false'
@@ -250,13 +335,26 @@ jobs:
# 获取构建信息
VERSION="${{ steps.extract_version.outputs.VERSION }}"
TAG="${{ steps.extract_version.outputs.TAG }}"
RELEASE_NAME="${{ github.event.release.name }}"
RELEASE_URL="${{ github.event.release.html_url }}"
REPO_NAME="${{ github.repository }}"
BUILD_TYPE="${{ steps.build_config.outputs.BUILD_TYPE }}"
# 构建消息内容(仅包含关键信息)
DEPLOY_DOC_URL="https://github.com/WrBug/PolyHermes/blob/main/docs/zh/DEPLOYMENT.md"
MESSAGE="✅ <b>Docker 镜像构建成功</b>"$'\n'$'\n'"📦 <b>版本:</b> ${VERSION}"$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker 部署文档</a>"
if [ "${{ github.event_name }}" = "release" ]; then
RELEASE_URL="${{ github.event.release.html_url }}"
if [ "$BUILD_TYPE" = "package-and-docker" ]; then
MESSAGE="✅ <b>Release 构建成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker 部署文档</a>"
else
MESSAGE="✅ <b>Release 打包成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> 在线升级"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"$'\n'"📍 <b>升级路径:</b> 系统管理 → 概览 → 检查更新"
fi
else
WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
if [ "$BUILD_TYPE" = "package-and-docker" ]; then
MESSAGE="✅ <b>构建成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">查看 Workflow</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker 部署文档</a>"
else
MESSAGE="✅ <b>打包成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> 在线升级"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">查看 Workflow</a>"$'\n'"📍 <b>升级路径:</b> 系统管理 → 概览 → 检查更新"
fi
fi
# 发送 Telegram 消息(使用 jq 转义 JSON
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-103
View File
@@ -1,103 +0,0 @@
name: Telegram Notification on PR Merge
on:
pull_request:
types:
- closed # 当 PR 被关闭(合并或关闭)时触发
jobs:
notify:
runs-on: ubuntu-latest
# 只在 PR 被合并到 main 分支时执行
if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Get PR details
id: pr_details
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUMBER="${{ github.event.pull_request.number }}"
REPO="${{ github.repository }}"
# 获取 PR 详细信息
PR_RESPONSE=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/${REPO}/pulls/${PR_NUMBER}")
# 获取 PR 变更的文件列表
FILES_RESPONSE=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/${REPO}/pulls/${PR_NUMBER}/files")
# 提取 PR 描述(body),保留换行,限制长度
PR_BODY=$(echo "$PR_RESPONSE" | jq -r '.body // ""')
if [ ${#PR_BODY} -gt 500 ]; then
PR_BODY="${PR_BODY:0:500}..."
fi
# 保存到输出变量(使用 base64 编码避免特殊字符问题)
echo "pr_body<<EOF" >> $GITHUB_OUTPUT
echo "$PR_BODY" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Send Telegram notification
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
run: |
# 检查 PR 是否被合并(而不是仅关闭)
PR_MERGED="${{ github.event.pull_request.merged }}"
if [ "$PR_MERGED" != "true" ]; then
echo "ℹ️ PR 仅关闭,未合并,跳过通知"
exit 0
fi
# 检查必要的环境变量
# 注意:TELEGRAM_CHAT_ID 可以是个人聊天 ID(正数)或群组 ID(负数,如 -1001234567890
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
echo "⚠️ Telegram Bot Token 或 Chat ID 未配置,跳过通知"
exit 0
fi
# 获取 PR 基本信息
PR_NUMBER="${{ github.event.pull_request.number }}"
PR_TITLE="${{ github.event.pull_request.title }}"
PR_URL="${{ github.event.pull_request.html_url }}"
PR_MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}"
# 获取 PR 详细信息
PR_BODY="${{ steps.pr_details.outputs.pr_body }}"
# 转义 PR 标题中的 HTML 特殊字符
PR_TITLE_ESCAPED=$(echo "$PR_TITLE" | sed 's/&/\&amp;/g' | sed 's/</\&lt;/g' | sed 's/>/\&gt;/g')
# 构建消息内容(仅包含关键信息)
MESSAGE="🚀 <b>main 分支代码更新</b>"$'\n'$'\n'"📝 <b>PR #${PR_NUMBER}:</b> ${PR_TITLE_ESCAPED}"$'\n'"🔗 <a href=\"${PR_URL}\">查看 PR</a>"
# 发送 Telegram 消息(使用 jq 转义 JSON
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg chat_id "$TELEGRAM_CHAT_ID" \
--arg text "$MESSAGE" \
'{chat_id: $chat_id, text: $text, parse_mode: "HTML", disable_web_page_preview: false}')" > /tmp/telegram_response.json
# 检查发送结果
if [ $? -eq 0 ]; then
RESPONSE=$(cat /tmp/telegram_response.json)
if echo "$RESPONSE" | grep -q '"ok":true'; then
echo "✅ Telegram 通知发送成功"
else
echo "❌ Telegram 通知发送失败: $RESPONSE"
exit 1
fi
else
echo "❌ 发送 Telegram 消息时发生错误"
exit 1
fi
+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
-37
View File
@@ -1,37 +0,0 @@
# PolyHermes 动态更新功能 - 遗漏检查与修复报告
## 检查时间
2026-01-21 03:00
## ✅ 已发现并修复的遗漏
### 1. docker-compose.prod.yml 环境变量
- **问题**: 生产环境部署文件缺少 `ALLOW_PRERELEASE``GITHUB_REPO`
- **修复**: 已添加到 `docker-compose.prod.yml`
### 2. 后端权限验证端点
- **问题**: `/api/auth/verify` 端点缺失,导致 Python 更新服务无法验证管理员权限。
- **修复**: 已在 `AuthController` 中添加 `/verify` 接口,仅允许 ADMIN 角色访问。
### 3. README.md 文档
- **问题**: 未提及新功能。
- **修复**: 已在 README 中添加"动态更新"功能说明及文档链接。
### 4. Docker Python 依赖优化
- **问题**: 使用 `pip install` 可能导致依赖冲突或安装缓慢。
- **修复**: 替换为 `apt-get install python3-flask python3-requests`,使用系统包更稳定、快速,且减小镜像体积。
---
## 🏁 最终状态
所有已知的遗漏都已检查并修复。系统现已准备好进行集成测试。
### 建议测试步骤
1. **本地构建测试**: `./deploy.sh` 验证 Dockerfile 更改(系统包安装)。
2. **后端测试**: 验证 `/api/auth/verify` 接口(需登录并在 Header 带上 Token)。
3. **流程测试**: 按计划进行 Pre-release 测试。
---
**状态**: ✅ **全功能就绪,已加固**
+32 -13
View File
@@ -31,21 +31,22 @@ RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
COPY frontend/ ./
# 条件:仅在 Docker 内部编译时执行构建
# 如果 BUILD_IN_DOCKER=false,需要构建上下文复制外部编译的 dist
# 如果 BUILD_IN_DOCKER=false,需要确保构建上下文中存在 frontend/dist
# 注意:COPY frontend/ ./ 已经复制了整个 frontend 目录(包括 dist,如果存在)
RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
echo "🔨 Docker 内部编译前端..."; \
npm run build; \
else \
echo "⏭️ 使用外部产物,将在下一步复制"; \
mkdir -p dist; \
echo "⏭️ 使用外部产物..."; \
if [ ! -d "dist" ] || [ -z "$(ls -A dist 2>/dev/null)" ]; then \
echo "❌ 错误:BUILD_IN_DOCKER=false 但找不到外部产物 frontend/dist"; \
echo " 请先执行: cd frontend && npm install && npm run build"; \
exit 1; \
else \
echo "✅ 找到外部构建的前端产物"; \
fi; \
fi
# 如果使用外部产物,从构建上下文复制外部编译的 dist
# 注意:这个 COPY 在 BUILD_IN_DOCKER=false 时必需
# 在 BUILD_IN_DOCKER=true 时,如果前端已编译,这个 COPY 会尝试覆盖,但结果相同
# 如果本地没有 distBUILD_IN_DOCKER=true 且未编译),这个 COPY 会失败,但上面的 RUN 已经编译了
COPY frontend/dist ./dist
# ==================== 阶段2:构建后端 ====================
FROM gradle:8.5-jdk17 AS backend-build
ARG BUILD_IN_DOCKER
@@ -64,9 +65,25 @@ RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
# 复制源代码
COPY backend/src ./src
# 如果使用外部产物,先从构建上下文复制外部编译的 JAR
# 注意:如果 BUILD_IN_DOCKER=true 且本地没有 JAR,这个 COPY 会失败,但会在下面编译生成
COPY backend/build/libs/*.jar build/libs/
# 尝试复制外部构建的 JAR(如果存在)
# 注意:COPY 指令如果源不存在会失败
# GitHub Actions 使用 BUILD_IN_DOCKER=false,会先构建产物,所以 backend/build 应该存在
# 本地开发使用 BUILD_IN_DOCKER=true,会在 Docker 内编译,所以 backend/build 可能不存在
# 解决方案:先复制整个 backend 目录(包括 build,如果存在),然后只使用需要的部分
# 使用 .dockerignore 确保不会复制不需要的文件(如 .gradle、out、bin 等)
COPY backend/build ./build-external
# 处理外部构建的 JAR(如果存在)
RUN if [ -d "build-external/libs" ] && [ -n "$(ls -A build-external/libs/*.jar 2>/dev/null)" ]; then \
echo "📦 找到外部构建的后端产物,复制到 build/libs..."; \
mkdir -p build/libs; \
cp build-external/libs/*.jar build/libs/; \
rm -rf build-external; \
else \
echo "⏭️ 未找到外部构建的 JAR,将在 Docker 内编译"; \
rm -rf build-external; \
mkdir -p build/libs; \
fi
# 条件:仅在 Docker 内部编译时执行构建(会覆盖外部产物)
RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
@@ -74,10 +91,12 @@ RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
gradle bootJar --no-daemon; \
else \
echo "⏭️ 使用外部产物"; \
mkdir -p build/libs; \
if [ -z "$(ls -A build/libs/*.jar 2>/dev/null)" ]; then \
echo "❌ 错误:BUILD_IN_DOCKER=false 但找不到外部产物 backend/build/libs/*.jar"; \
echo " 请先执行: cd backend && ./gradlew bootJar"; \
exit 1; \
else \
echo "✅ 使用外部构建的后端产物"; \
fi; \
fi
-301
View File
@@ -1,301 +0,0 @@
# PolyHermes 动态更新功能实施完成总结
## ✅ 全部完成!
**实施时间**: 2026-01-21
**总文件修改**: 12个
**前端新增**: 1个组件
**后端新增**: 1个服务
**总代码行数**: 约2000行
---
## 📂 文件清单
### 后端实施(已完成)
1.`Dockerfile` - 混合编译方案
2.`docker/update-service.py` - P Python Flask 更新服务(573行)
3.`docker/start.sh` - 启动3个进程
4.`docker/nginx.conf` - Nginx 代理配置
5.`docker-compose.yml` - 环境变量
6.`docker-compose.test.yml` - 测试环境
7.`.github/workflows/docker-build.yml` - CI/CD
### 前端实施(已完成)
8.`frontend/src/pages/SystemUpdate.tsx` - 系统更新组件(334行)
9.`frontend/src/pages/SystemSettings.tsx` - 集成到系统设置
### 文档
10.`docs/zh/DYNAMIC_UPDATE.md` - 完整技术文档
11.`docs/zh/IMPLEMENTATION_SUMMARY.md` - 实施总结
12.`verify-implementation.sh` - 验证脚本
---
## 🎯 核心功能
### 1. 混合编译策略
- **GitHub Actions**: 编译1次,8分钟完成
- **本地 deploy.sh**: 完全兼容,Docker内编译
- **构建参数**: `BUILD_IN_DOCKER` 控制编译位置
### 2. Pre-release 测试
- 测试版本不推送 `latest` 标签
- 测试版本不触发 Telegram 通知
- 环境变量 `ALLOW_PRERELEASE=true` 启用检测
### 3. 更新流程
```
检查版本 → 下载更新包 → 备份 → 替换文件 → 重启服务 → 健康检查 → 回滚(失败时)
```
### 4. 架构特点
- **Nginx 直接代理**: `/api/update/*` → Python:9090
- **权限验证**: Python 调用后端 `/api/auth/verify`
- **独立服务**: 更新服务与主应用分离
- **版本追踪**: `/app/version.json`
### 5. 前端UI
- 实时进度显示
- 版本对比
- Release Notes 展示
- 一键升级
- 自动刷新
---
## 🚀 使用流程
### 开发测试(Pre-release
```bash
# 1. 提交代码
git add .
git commit -m "feat: 动态更新功能"
git push origin dynamic_load
# 2. 创建测试 tag
git tag v1.3.0-beta
git push origin v1.3.0-beta
# 3. GitHub 创建 Pre-release
# - Tag: v1.3.0-beta
# - ✅ 勾选 "This is a pre-release"
# - 发布
# 4. GitHub Actions 自动执行
# - 编译前后端
# - 打包更新包
# - 上传到 Release Assets
# - 构建 Docker 镜像(仅 v1.3.0-beta 标签)
# -❌不推送 latest
# - ❌ 不发送 Telegram
# 5. 测试环境部署
docker pull wrbug/polyhermes:v1.3.0-beta
docker-compose -f docker-compose.test.yml up -d
# 6. 测试更新功能
# - 访问系统设置 → 系统更新
# - 点击"检查更新"(应该检测到 v1.3.0-beta
# - 点击"立即升级"
# - 验证更新流程
```
### 生产发布
```bash
# 测试通过后,创建正式版本
git tag v1.3.0
git push origin v1.3.0
# GitHub 创建 Release
# - Tag: v1.3.0
# - ❌ 不勾选 "pre-release"
# - 发布
# GitHub Actions 自动执行
# - 编译前后端
# - 打包更新包
# - 上传到 Release Assets
# - 构建 Docker 镜像(v1.3.0 + latest
# - ✅ 推送 latest
# - ✅ 发送 Telegram 通知
# 生产环境更新
# 1. 用户访问系统设置 → 系统更新
# 2. 点击"检查更新"
# 3. 点击"立即升级"
# 4. 等待30-60秒
# 5. 页面自动刷新
```
---
## 📋 验证清单
运行验证脚本:
```bash
./verify-implementation.sh
```
**预期输出**
```
========================================
PolyHermes 动态更新功能验证
========================================
📋 检查文件...
✅ Dockerfile
✅ docker/update-service.py
✅ docker/start.sh
✅ docker/nginx.conf
✅ docker-compose.yml
✅ docker-compose.test.yml
✅ .github/workflows/docker-build.yml
✅ docs/zh/DYNAMIC_UPDATE.md
📋 检查关键配置...
✅ Dockerfile 包含 BUILD_IN_DOCKER 参数
✅ Dockerfile 安装 Python
✅ Nginx 配置包含更新服务代理
✅ docker-compose.yml 包含 ALLOW_PRERELEASE
✅ GitHub Actions 包含 Pre-release 检测
✅ GitHub Actions 包含后端编译步骤
📋 检查 Python 语法...
✅ update-service.py 语法正确
========================================
✅ 验证通过!所有检查项正常
========================================
```
---
## ⚠️ 注意事项
### 必须检查的端点
1. **健康检查端点**: `/api/system/health`
- 用于检查后端服务是否正常
- 如果不存在,需要修改 `Dockerfile``start.sh` 中的健康检查URL
2. **权限验证端点**: `/api/auth/verify`
- 用于验证管理员权限
- 如果不存在,有两个选择:
- 在后端创建此端点
- 或修改 `update-service.py` 中的权限验证逻辑
---
## 🎨 前端UI特性
- ✅ 当前版本显示
- ✅ 检查更新按钮
- ✅ 更新信息展示(版本、发布时间、Release Notes
- ✅ 实时进度条(0-100%
- ✅ 状态消息显示
- ✅ 一键升级按钮
- ✅ 错误处理和显示
- ✅ 更新成功后自动刷新
- ✅ 使用说明提示
---
## 📚 API 文档
### 前端调用的API
| 端点 | 方法 | 说明 | 权限 |
|------|------|------|------|
| `/api/update/version` | GET | 获取当前版本 | 无 |
| `/api/update/check` | GET | 检查更新 | 无 |
| `/api/update/execute` | POST | 执行更新 | Admin |
| `/api/update/status` | GET | 获取更新状态 | 无 |
| `/api/update/logs` | GET | 获取更新日志 | Admin |
### 响应格式
```json
{
"code": 0,
"data": {
...
},
"message": "success"
}
```
---
## 🐛 故障排查
### 问题1:健康检查失败
**错误信息**: `后端服务启动超时`
**解决方案**:
```bash
# 检查健康检查端点
curl http://localhost:8000/api/system/health
# 如果404,修改 Dockerfile 和 start.sh
# 将 /api/system/health 改为实际存在的端点
```
### 问题2:权限验证失败
**错误信息**: `需要管理员权限`
**解决方案**:
1. 确保前端已登录且有 Admin Token
2. 检查 `/api/auth/verify` 端点是否存在
3. 或修改 `update-service.py` 的权限验证逻辑
### 问题3:更新包下载失败
**错误信息**: `下载更新包失败`
**可能原因**:
- GitHub Release 未发布
- 更新包文件名不符合规范
- 网络连接问题
**解决方案**:
```bash
# 检查 Release Assets
curl https://api.github.com/repos/WrBug/PolyHermes/releases/latest
# 确保文件名格式:polyhermes-{tag}-update.tar.gz
```
---
## 📊 性能指标
| 指标 | 数值 |
|------|------|
| **GitHub Actions 构建时间** | ~8分钟 |
| **更新包大小** | ~50MB |
| **更新总时长** | 30-60秒 |
| **下载时间** | 5-15秒(依网络)|
| **备份时间** | 2-5秒 |
| **解压时间** | 2-3秒 |
| **重启时间** | 10-15秒 |
| **健康检查** | 最多30秒 |
---
##✅ 实施完成状态
**后端**: ✅ 100% 完成
**前端**: ✅ 100% 完成
**文档**: ✅ 100% 完成
**测试**: ⏳ 待验证
---
**状态**: 🎉 **实施完成,准备测试!**
**下一步**: 创建 Pre-release 进行测试验证
-31
View File
@@ -1,31 +0,0 @@
# PolyHermes 动态更新功能 - 遗漏检查报告
## 检查时间
2026-01-21 01:46
## 已发现并修复的遗漏
### 1. ✅ docker-compose.prod.yml 缺少环境变量
**问题**: 生产环境配置文件缺少动态更新相关的环境变量
**修复**: 已添加 `ALLOW_PRERELEASE``GITHUB_REPO` 环境变量
### 2. ✅ 后端缺少权限验证端点
**问题**: Python 更新服务需要调用 `/api/auth/verify` 验证管理员权限,但该端点不存在
**修复**: 已在 `AuthController.kt` 中添加 `verify` 端点
## 继续检查项目
### 3. 备份文件检查
检查是否有遗留的备份文件需要清理...
### 4. .gitignore 文件
检查是否需要添加临时文件到 .gitignore...
### 5. 前端国际化
检查是否需要为系统更新添加多语言支持...
### 6. README 文档
检查是否需要更新 README 说明新功能...
### 7. 依赖检查
检查 Python 依赖是否完整(Flask, requests...
+23
View File
@@ -165,6 +165,29 @@
**部署步骤**
#### ⚡ 一键安装(最快,推荐新用户)
**使用 curl(推荐):**
```bash
mkdir -p ~/polyhermes && cd ~/polyhermes && curl -fsSL https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh -o deploy.sh && chmod +x deploy.sh && ./deploy.sh
```
**使用 wget**
```bash
mkdir -p ~/polyhermes && cd ~/polyhermes && wget -O deploy.sh https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh && chmod +x deploy.sh && ./deploy.sh
```
这个命令会自动:
- 📁 创建专用工作目录 `~/polyhermes`
- ✅ 自动检查 Docker 环境
- ⚙️ 交互式配置所有参数(支持回车使用默认值)
- 🔐 自动生成安全的随机密钥
- 🚀 自动下载最新镜像并部署
---
#### 📋 其他部署方式
1. **使用 Docker Hub 镜像(推荐,生产环境首选)**
**方式 1:独立部署(无需 clone 代码,推荐)**
+23
View File
@@ -164,6 +164,29 @@ Deploy both frontend and backend together in a single Docker container, using Ng
**Deployment Steps**:
#### ⚡ One-Liner Installation (Fastest, Recommended for New Users)
**Using curl (Recommended):**
```bash
mkdir -p ~/polyhermes && cd ~/polyhermes && curl -fsSL https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh -o deploy.sh && chmod +x deploy.sh && ./deploy.sh
```
**Using wget:**
```bash
mkdir -p ~/polyhermes && cd ~/polyhermes && wget -O deploy.sh https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh && chmod +x deploy.sh && ./deploy.sh
```
This command will automatically:
- 📁 Create dedicated working directory `~/polyhermes`
- ✅ Automatically check Docker environment
- ⚙️ Interactive configuration for all parameters (supports pressing Enter for defaults)
- 🔐 Automatically generate secure random keys
- 🚀 Automatically download latest images and deploy
---
#### 📋 Alternative Deployment Methods
1. **Using Docker Hub Images (Recommended, Production First Choice)**
**Method 1: Standalone Deployment (No code clone required, Recommended)**
-971
View File
@@ -1,971 +0,0 @@
# v1.1.10
## 🚀 主要功能
### 📢 推送已过滤订单功能
- **新增推送已过滤订单功能(pushFilteredOrders**,默认关闭
- 支持在模板和跟单配置中配置是否推送被过滤的订单通知
- 开启后,当订单因过滤条件(价格区间、订单深度、价差、仓位限制等)被过滤时,会发送 Telegram 通知
- 帮助用户了解哪些订单被过滤以及过滤原因
- **数据库迁移**
- 添加 `push_filtered_orders` 字段到 `copy_trading_templates`
- 添加 `push_filtered_orders` 字段到 `copy_trading`
- 迁移脚本:`V24__add_push_filtered_orders_to_templates.sql`
- **后端实现**
- 在实体类(`CopyTradingTemplate``CopyTrading`)中添加 `pushFilteredOrders` 字段
- 在 DTO 中添加字段支持(创建、更新、查询)
- 在 Service 中处理字段的创建、更新和传递
- 在发送过滤订单通知时检查 `pushFilteredOrders` 字段,只有为 `true` 时才发送
- **前端实现**
- 在模板新增、编辑、推送页面添加"推送已过滤订单"开关
- 在跟单配置新增、编辑页面添加"推送已过滤订单"开关
- 从模板创建跟单配置时,自动填充 `pushFilteredOrders` 字段
- 支持多语言(中文、繁体中文、英文)
### 🔍 优化订单列表筛选功能
- **支持按市场标题搜索**
- 买入订单列表:添加市场标题筛选,移除方向筛选
- 卖出订单列表:添加市场标题筛选,将方向筛选改为状态筛选
- 已成交订单列表:添加市场标题筛选和市场列显示
- **改进分组体验**
- 记录用户的分组偏好到 localStorage,跨会话持久化
- 所有搜索输入框添加 0.5 秒防抖优化,提升性能
- **后端优化**
- `MarketGroupedOrdersRequest` 添加 `marketId``marketTitle` 字段
- 买入/卖出订单分组接口支持市场 ID 模糊匹配和市场标题关键字筛选
### ⚡ 优化跟单关系统计性能
- **移除未实现盈亏计算**
- 移除未实现盈亏和持仓价值的计算逻辑
- 总盈亏现在仅包含已实现盈亏,计算更准确
- 简化盈亏百分比计算,仅基于已实现盈亏
- **性能提升**
- 从 1+N 次网络请求减少到 0 次
- 删除不再使用的方法和依赖注入
- 统计查询速度显著提升
### 🐳 Docker 容器时区配置
- **支持通过 .env 自定义时区**:
-`docker-compose.yml``docker-compose.prod.yml` 中添加时区环境变量配置
- 支持通过 `TZ` 环境变量自定义容器时区
- 默认使用系统时区
## 🐛 Bug 修复
### 修复跟单配置更新时清空可选字段无法保存的问题
- **问题**:修改跟单配置时,清空价格区间、最大仓位数量、截止时间等可选字段后,无法保存到数据库
- **修复**
- 修复价格区间(`minPrice`/`maxPrice`)清空后无法保存的问题
- 修复最大仓位数量(`maxPositionCount`)清空后无法保存的问题
- 修复市场截止时间(`maxMarketEndDate`)清空后无法保存的问题
- 修复其他可选字段(`minOrderDepth`/`maxSpread`/`maxPositionValue`)清空后无法保存的问题
- **实现方案**
- 前端:清空字段时传空字符串或 `-1` 标记,让后端识别为清空操作
- 后端:处理空字符串和 `-1` 标记,正确设置为 `null` 以清空字段
### 修复新建和编辑页面的截止时间输入框交互问题
- **问题**:删除截止时间输入框内容后,失去焦点会自动填充 1
- **修复**
-`min``1` 改为 `0`,允许空值
- 优化 `onChange` 处理,当值为 `0``null``undefined` 时设置为 `undefined`(清空)
- 添加 `onBlur` 处理,确保失去焦点时如果值为空或 `0`,设置为 `undefined`
- 修改 `parser`,空值时返回空字符串而不是 `0`
### 修复订单通知重复发送和时间显示问题
- **修复并发导致的重复通知问题**:
-`OrderStatusUpdateService` 中实现双重检查机制
- 先保存订单标记为已发送,再重新查询数据库检查
- 防止定时任务并发时重复发送同一订单的通知
- 同时修复买入订单和卖出订单的通知逻辑
- **修复 Telegram 通知时间显示**
- `TelegramNotificationService.sendOrderSuccessNotification` 添加 `orderTime` 参数
- 使用订单的 `createdAt` 时间戳作为通知显示时间
- 而不是使用当前通知发送时间
- 更准确反映订单的实际创建时间
### 修复新建跟单配置时 pushFilteredOrders 字段未生效的问题
- **问题**:新建跟单配置时,即使设置了 `pushFilteredOrders: true`,也没有生效
- **修复**
- 修复手动输入模式下 `pushFilteredOrders` 被硬编码为 `false` 的问题
- 修复从模板填充表单时未加载 `pushFilteredOrders` 的问题
- 添加 `CopyTradingTemplate` 接口的 `pushFilteredOrders` 字段定义
## 📝 技术细节
### 数据库变更
- **迁移脚本**`V24__add_push_filtered_orders_to_templates.sql`
- **变更内容**
- `copy_trading_templates.push_filtered_orders`: BOOLEAN NOT NULL DEFAULT FALSE
- `copy_trading.push_filtered_orders`: BOOLEAN NOT NULL DEFAULT FALSE
- **自动执行**:升级时会自动执行迁移脚本
### API 变更
- **无新增接口**
- **无移除接口**
- **请求/响应变更**
- `CopyTradingCreateRequest` 添加 `pushFilteredOrders` 字段
- `CopyTradingUpdateRequest` 添加 `pushFilteredOrders` 字段
- `TemplateCreateRequest` 添加 `pushFilteredOrders` 字段
- `TemplateUpdateRequest` 添加 `pushFilteredOrders` 字段
- `MarketGroupedOrdersRequest` 添加 `marketId``marketTitle` 字段
### 前端变更
- **新增字段**
- `CopyTradingTemplate` 接口添加 `pushFilteredOrders` 字段
- **组件更新**
- 模板新增、编辑、推送页面添加"推送已过滤订单"开关
- 跟单配置新增、编辑页面添加"推送已过滤订单"开关
- 优化截止时间输入框交互逻辑
- **多语言支持**
- 添加中文、繁体中文、英文翻译
## 📊 变更统计
- **提交数量**6 个提交
- **文件变更**30 个文件
- **代码变更**+651 行 / -555 行(净增加 96 行)
### 详细文件变更
**后端变更**
- `CopyTrading.kt` - 添加 `pushFilteredOrders` 字段(+3 行)
- `CopyTradingTemplate.kt` - 添加 `pushFilteredOrders` 字段(+3 行)
- `CopyTradingDto.kt` - 添加 `pushFilteredOrders` 字段支持(+3 行)
- `CopyTradingTemplateDto.kt` - 添加 `pushFilteredOrders` 字段支持(+10 行)
- `CopyTradingService.kt` - 处理 `pushFilteredOrders` 字段和清空字段逻辑(+93 行)
- `CopyTradingTemplateService.kt` - 处理 `pushFilteredOrders` 字段(+8 行)
- `CopyOrderTrackingService.kt` - 检查 `pushFilteredOrders` 字段发送通知(+42 行)
- `CopyTradingStatisticsService.kt` - 优化统计性能(-328 行)
- `OrderStatusUpdateService.kt` - 修复重复通知问题(+142 行)
- `TelegramNotificationService.kt` - 添加订单时间参数(+17 行)
- `V24__add_push_filtered_orders_to_templates.sql` - 数据库迁移脚本(+13 行)
**前端变更**
- `AddModal.tsx` - 添加 `pushFilteredOrders` 字段和优化截止时间输入框(+40 行)
- `EditModal.tsx` - 添加 `pushFilteredOrders` 字段和优化截止时间输入框(+59 行)
- `TemplateAdd.tsx` - 添加 `pushFilteredOrders` 字段(+15 行)
- `TemplateEdit.tsx` - 添加 `pushFilteredOrders` 字段(+15 行)
- `TemplateList.tsx` - 添加 `pushFilteredOrders` 字段(+13 行)
- `BuyOrdersTab.tsx` - 优化筛选功能(+94 行)
- `SellOrdersTab.tsx` - 优化筛选功能(+77 行)
- `MatchedOrdersTab.tsx` - 优化筛选功能(+26 行)
- `types/index.ts` - 添加 `pushFilteredOrders` 字段定义(+6 行)
- `locales/*/common.json` - 添加多语言翻译(+31 行)
**配置文件变更**
- `docker-compose.yml` - 添加时区配置(+5 行)
- `docker-compose.prod.yml` - 添加时区配置(+5 行)
- `Dockerfile` - 优化构建配置(+4 行)
## 🔄 主要提交
```
9c303e0 feat: 添加推送已过滤订单功能并修复相关问题
cb8e469 fix: 修复跟单配置更新时清空可选字段无法保存的问题
279806d feat: 优化订单列表筛选功能,支持按市场标题搜索并改进分组体验
90fa487 refactor: 移除未实现盈亏计算以优化跟单关系统计性能
b58bb26 feat: 添加 Docker 容器时区配置,支持通过 .env 自定义
6af76c4 fix: 修复订单通知重复发送和时间显示问题
```
## 🎯 升级建议
1. **数据库迁移**:本次版本包含数据库迁移脚本,升级时会自动执行
- 自动添加 `push_filtered_orders` 字段到模板表和跟单配置表
- 现有数据不受影响,新字段默认值为 `false`
2. **配置更新**
- 可选:在 `.env` 文件中添加 `TZ` 环境变量自定义容器时区
- 无需其他配置变更
3. **兼容性**
- 完全向后兼容,不影响现有功能
- API 变更都是新增字段,不影响现有调用
## 📦 Docker 镜像
Docker 镜像会自动构建并推送到 Docker Hub:
- `wrbug/polyhermes:v1.1.10`
- `wrbug/polyhermes:latest`(如果这是最新版本)
## 🔗 相关链接
- [GitHub Release](https://github.com/WrBug/PolyHermes/releases/tag/v1.1.10)
- [完整更新日志](https://github.com/WrBug/PolyHermes/compare/v1.1.9...v1.1.10)
---
**发布日期**2026-01-12
---
# v1.1.9
## 🐛 Bug 修复
### 修复盈亏统计计算错误问题
- **问题**:在仓位检查时,刚创建的订单可能因仓位更新延迟被误判为已卖出,导致盈亏统计计算错误
- **修复**
- 优化自动卖出订单筛选逻辑,在 SQL 层直接过滤创建时间超过阈值的订单
- 新增 `findUnmatchedBuyOrdersByOutcomeIndexOlderThan` Repository 方法,提高查询效率
- 统一使用 2 分钟阈值保护刚创建的订单,避免误判
- 两个场景都使用 SQL 过滤:
* 场景1:仓位不存在时,延迟检测使用 SQL 过滤
* 场景2:仓位部分存在时,FIFO 匹配使用 SQL 过滤
- **优化效果**
- SQL 层面直接过滤,减少数据传输,提高查询效率
- 代码更简洁,逻辑更清晰
- 刚创建的订单(< 2 分钟)不会被误判为已卖出
- 确保盈亏统计计算的准确性
## 📝 技术细节
- **Repository 变更**
- 新增 `findUnmatchedBuyOrdersByOutcomeIndexOlderThan` 方法
- 在 SQL 查询中添加 `createdAt < :thresholdTime` 条件过滤
- **Service 变更**
- `PositionCheckService` 中两个场景都使用 SQL 过滤替代内存过滤
- 统一使用 2 分钟(120000 毫秒)作为时间阈值
## 📊 变更统计
- **提交数量**1 个提交
- **文件变更**2 个文件
- **代码变更**+57 行 / -19 行(净增加 38 行)
### 详细文件变更
**后端变更**
- `CopyOrderTrackingRepository.kt` - 新增 SQL 查询方法(+18 行)
- `PositionCheckService.kt` - 优化订单筛选逻辑(+39 行 / -19 行)
## 🔄 主要提交
```
6ad4024 fix: 优化自动卖出订单筛选逻辑,避免刚创建的订单被误判
```
## 🎯 升级建议
1. **数据库迁移**:无需数据库迁移,可直接升级
2. **配置更新**:无需配置变更
3. **兼容性**:完全向后兼容,不影响现有功能
## 📦 Docker 镜像
Docker 镜像会自动构建并推送到 Docker Hub:
- `wrbug/polyhermes:v1.1.9`
- `wrbug/polyhermes:latest`(如果这是最新版本)
## 🔗 相关链接
- [GitHub Release](https://github.com/WrBug/PolyHermes/releases/tag/v1.1.9)
---
# v1.1.8
## 🚀 主要功能
### ⚡ Polymarket Activity WebSocket 双重监听方案
- **新增 PolymarketActivityWsService**:通过 Activity WebSocket 实时监听 Leader 交易
- 延迟 < 100ms,适合快速跟单场景
- 订阅全局 activity 交易流,客户端过滤 Leader 地址
- 支持动态添加/移除 Leader 监听
- 地址筛选忽略大小写,提高匹配准确性
- **双重监听机制**
- Activity WebSocket(低延迟,< 100ms):作为主要监听方式
- On-Chain WebSocket(高可靠性,~2-3s):作为兜底监听方式
- 自动去重,避免重复处理同一笔交易
- **协议支持**
- 支持 `connection_id` 字段
- 优先使用消息中的 `outcomeIndex` 字段,fallback 到从 `outcome` 解析
- **参考文档**
- [双重监听方案文档](docs/zh/copy-trading-dual-monitoring-plan.md)
- [Activity WebSocket API 文档](docs/zh/polymarket-activity-websocket-api.md)
### 🔧 支持同一用户创建多个相同 Leader 的跟单配置
- **移除唯一约束**:允许同一用户创建多个跟单同一个 Leader 的配置
- **应用场景**:支持不同参数(比例、过滤条件等)的多配置跟单
- **数据库迁移**`V23__remove_unique_constraint_from_copy_trading.sql`
### 🎯 市场截止时间筛选功能
- **新增市场截止时间过滤**:支持设置跟单配置的最大市场截止时间
- **自动过滤过期市场**:系统会自动跳过超过设定截止时间的市场
- **数据库字段**`max_market_end_date`(时间戳,单位:秒)
- **数据库迁移**`V22__add_max_market_end_date_to_copy_trading.sql`
### 🔍 关键字过滤功能
- **新增关键字过滤**:支持在黑名单或白名单模式下过滤市场标题关键字
- **过滤模式**
- `DISABLED`:禁用关键字过滤
- `BLACKLIST`:黑名单模式(包含关键字的市场会被过滤)
- `WHITELIST`:白名单模式(只允许包含关键字的市场)
- **多关键字支持**:支持多个关键字,以 JSON 数组格式存储
- **数据库字段**`keyword_filter_mode``keywords`JSON 数组)
- **数据库迁移**`V20__add_keyword_filter.sql`
### 📊 订单列表功能重构
- **按市场分组显示**:订单列表按市场分组,便于查看和管理
- **市场信息展示**
- 显示市场标题和 slug
- 支持跳转到 Polymarket 市场页面
- 显示市场基本信息(标题、slug、截止时间等)
- **订单 ID 复制功能**:一键复制订单 ID,方便查询和调试
- **UI/UX 优化**
- 优化订单列表展示样式
- 改进移动端适配
### 📈 市场信息管理和缓存优化
- **新增 MarketService**:统一管理市场信息查询和缓存
- 使用 LRU 缓存提高查询性能
- 支持从 Gamma API 和 CLOB API 查询市场信息
- 自动缓存市场信息,减少 API 调用
- **新增 Market 实体和表**:持久化存储市场信息
- 存储市场标题、slug、eventSlug、endDate 等基本信息
- 支持通过 marketId 快速查询市场信息
- **数据库迁移**
- `V19__create_markets_table.sql`:创建市场信息表
- `V21__add_event_slug_to_markets.sql`:添加 eventSlug 字段
- **市场信息轮询服务**:定期更新市场信息,保持数据新鲜度
## 🐛 Bug 修复
### 前端 TypeScript 类型错误修复
- **修复 InputNumber parser 类型错误**`parser` 函数应返回 `number` 而不是 `string`
- **修复文件**
- `src/pages/CopyTradingOrders/AddModal.tsx`
- `src/pages/CopyTradingOrders/EditModal.tsx`
## 📝 文档更新
- **新增双重监听方案文档**:详细说明 Activity WebSocket 和 On-Chain WebSocket 的双重监听机制
- **新增 Activity WebSocket API 文档**:详细的 API 格式和消息结构说明
- **更新开发规范**JSON 解析规范和 Data Class 规范
## 📊 统计信息
- **61 个文件被修改**
- **+5125 行新增代码**
- **-1942 行删除代码**
- **净增加 3183 行代码**
## 🔄 主要提交
```
d376a82 feat: 添加市场信息管理和订单ID复制功能
2af2c0e feat: 订单列表按市场分组并支持跳转到Polymarket
9ed5190 feat: 添加关键字过滤功能并优化市场 slug 处理
0327eaf feat: 添加市场截止时间筛选功能
a16b6fc feat: 支持同一用户创建多个相同 leader 的跟单配置
19508dc feat: 实现 Polymarket Activity WebSocket 双重监听方案
```
## 🎯 升级建议
1. **数据库迁移**:确保执行所有数据库迁移脚本(V19-V23)
2. **配置更新**:新版本的 `application.properties` 中添加了 Activity WebSocket URL 配置
3. **环境变量**:如果使用 Docker 部署,建议更新 `docker-compose.yml` 中的配置
## 📦 Docker 镜像
Docker 镜像会自动构建并推送到 Docker Hub:
- `wrbug/polyhermes:v1.1.8`
- `wrbug/polyhermes:latest`(如果这是最新版本)
## 🔗 相关链接
- [GitHub Release](https://github.com/WrBug/PolyHermes/releases/tag/v1.1.8)
- [双重监听方案文档](docs/zh/copy-trading-dual-monitoring-plan.md)
- [Activity WebSocket API 文档](docs/zh/polymarket-activity-websocket-api.md)
---
# v1.1.7
## 🚀 主要功能
### 💰 Polymarket Maker Rebates Program 费率支持
- **新增费率查询 API 接口** (`getFeeRate`)
- 支持动态查询 Maker Rebates Program 费率
- 修正 API 返回字段名:使用 `base_fee` 而非 `fee_rate_bps`(与 TypeScript clob-client 一致)
- **动态费率获取**
- 在所有订单创建处动态获取费率:
* 跟单买入订单 (`processBuyTrade`)
* 跟单卖出订单 (`matchSellOrder`)
* 账户卖出订单 (`sellPosition`)
- 费率获取失败时降级到默认值 "0",确保系统可用性
- 添加详细的日志记录,便于监控和调试
- **参考文档**: https://docs.polymarket.com/developers/market-makers/maker-rebates-program
### 🔧 Docker 部署优化
- **日志级别环境变量支持**
-`application.properties` 中支持通过 `LOG_LEVEL_ROOT``LOG_LEVEL_APP` 环境变量配置日志级别
-`docker-compose.yml``docker-compose.prod.yml` 中添加日志级别环境变量配置
-`deploy.sh``.env` 模板中添加日志级别配置说明
- 支持通过环境变量动态配置日志级别,无需修改配置文件
- 默认值:`root=INFO`, `app=DEBUG`
## 🐛 Bug 修复
### 修复市场条件查询的 RPC 调用错误
- **问题**:使用错误的函数签名 `conditions(bytes32)` 导致 RPC 调用失败(execution reverted
- **修复**
- 将错误的 `conditions(bytes32)` 函数调用改为正确的 `getOutcomeSlotCount(bytes32)``payoutDenominator(bytes32)` 函数调用
- 修复 `BlockchainService.getCondition` 方法,使用正确的 ConditionalTokens 合约函数签名
- 改进 `MarketPriceService` 的错误处理:当链上查询出现 RPC 错误时,降级到 CLOB API 或 Gamma API 查询,而不是直接抛出异常,提高容错性
### 修复 RPC 错误时误创建自动卖出记录的问题
- **问题**:当链上查询市场条件出现 RPC 错误(execution reverted)时,系统会误判为市场已卖出,创建错误的自动卖出记录
- **修复**
- 修改 `getPriceFromChainCondition` 返回 `Pair<BigDecimal?, Boolean>`,第二个值表示是否发生 RPC 错误
-`getCurrentMarketPrice` 中检测到 RPC 错误时抛出异常,`PositionCheckService` 会捕获并跳过该市场的处理
- 避免在市场不存在或尚未创建时误判为已卖出
## 📝 文档更新
### 更新 Telegram 群链接
- 将所有 Telegram 群链接统一更新为 `t.me/polyhermes`
- 更新了以下文件:
- `frontend/src/components/Layout.tsx` - 桌面端和移动端导航链接
- `RELEASE.md` - 相关链接
- `README.md``README_EN.md` - 相关链接部分
### 添加 Docker 版本徽章
- 在 README 和 README_EN.md 中添加动态 Docker 版本徽章
- 使用 shields.io 自动显示 Docker Hub 上 `wrbug/polyhermes` 镜像的最新版本
- 版本信息自动更新,无需手动维护
## 📊 变更统计
- **提交数量**5 个提交
- **文件变更**16 个文件
- **代码变更**+205 行 / -886 行(净减少 681 行)
### 详细文件变更
**后端变更**
- `PolymarketClobApi.kt` - 添加费率查询接口(+25 行)
- `AccountService.kt` - 在订单创建处添加动态费率获取(+11 行)
- `BlockchainService.kt` - 修复市场条件查询的 RPC 调用错误(+84 行)
- `MarketPriceService.kt` - 改进错误处理,支持降级到其他数据源(+36 行)
- `PolymarketClobService.kt` - 添加费率查询服务(+32 行)
- `CopyOrderTrackingService.kt` - 在跟单订单创建处添加费率获取(+34 行)
- `PositionCheckService.kt` - 修复 RPC 错误处理逻辑(+2 行)
- `application.properties` - 添加日志级别环境变量支持(+6 行)
**前端变更**
- `Layout.tsx` - 更新 Telegram 群链接(+4 行)
**配置文件变更**
- `docker-compose.yml` - 添加日志级别环境变量(+4 行)
- `docker-compose.prod.yml` - 添加日志级别环境变量(+4 行)
- `deploy.sh` - 添加日志级别配置说明(+5 行)
**文档变更**
- `README.md` - 更新 Telegram 链接,添加 Docker 版本徽章(+2 行)
- `README_EN.md` - 更新 Telegram 链接,添加 Docker 版本徽章(+2 行)
- `RELEASE.md` - 更新 Telegram 链接(+4 行)
- `docs/zh/smart-money-analysis.md` - 删除文档(-836 行)
## 🔧 技术细节
### API 变更
- **新增接口**
- `POST /api/clob/fee-rate` - 获取 Maker Rebates Program 费率(内部使用)
- **无移除接口**
### 环境变量变更
- **新增环境变量**
- `LOG_LEVEL_ROOT` - Root 日志级别(默认:INFO
- `LOG_LEVEL_APP` - 应用日志级别(默认:DEBUG)
### 合约调用修复
- **修复的函数调用**
-`conditions(bytes32)` 改为 `getOutcomeSlotCount(bytes32)``payoutDenominator(bytes32)`
- 使用正确的 ConditionalTokens 合约函数签名
- 参考:https://polygonscan.com/address/0x4d97dcd97ec945f40cf65f87097ace5ea0476045#code
## 📝 升级说明
### 数据库升级
- **无需数据库迁移**:本次更新不涉及数据库结构变更
### 配置更新
- **可选配置**:新增日志级别环境变量,如不配置将使用默认值
- `LOG_LEVEL_ROOT=INFO`(默认)
- `LOG_LEVEL_APP=DEBUG`(默认)
### Docker 部署
- **推荐更新**:使用 Docker Hub 镜像部署的用户,建议更新到最新版本
```bash
docker pull wrbug/polyhermes:latest
docker-compose -f docker-compose.prod.yml up -d
```
## 🔗 相关链接
- **GitHub 仓库**https://github.com/WrBug/PolyHermes
- **Twitter**@polyhermes
- **Telegram 群组**https://t.me/polyhermes
---
**发布日期**2026-01-07
---
# v1.1.5
## 🔧 功能优化与改进
### 前端优化
#### 优化 InputNumber 输入框格式化
- 优化数值输入框的格式化逻辑,修正正则表达式以正确处理整数显示
- 更新所有相关 InputNumber 组件的 formatter 函数,确保显示准确性
- 影响的组件:CopyTradingAdd、CopyTradingEdit、EditModal、TemplateAdd、TemplateEdit、TemplateList
- 影响范围:跟单配置、模板配置中的所有数值输入框
#### 优化数字显示格式
- 添加 `formatNumber` 工具函数,自动去除小数尾随零(如 100.00 → 100
- 统一所有数值输入框的显示格式,提升用户体验
### 后端优化
#### 优化按比例跟单金额计算逻辑
- 优化按比例计算的订单金额处理,使用向上取整确保满足最小限制要求
- 对订单金额进行向上取整处理(保留 2 位小数精度)
- 自动调整订单数量以满足最小限制要求
- 使用 `RoundingMode.CEILING` 确保金额满足最小限制
- 影响范围:按比例跟单的订单创建逻辑
- 技术细节:
- 扩展 `BigDecimal.div()` 扩展函数,支持指定精度和舍入模式
- 在 `CopyOrderTrackingService` 中优化金额计算和验证逻辑
#### 增强 copyRatio 精度支持
- 将 copyRatio 字段精度从 DECIMAL(10,2) 增加到 DECIMAL(20,8)
- 支持更精确的跟单比例设置(最小 0.01%,最大 10000%
- 影响的实体:CopyTrading、CopyTradingTemplate
- 数据库迁移:新增 V18 迁移脚本,自动升级数据库字段精度
## 🔧 功能优化
### 移除刷新代理钱包接口
- **移除接口**
- `POST /api/accounts/refresh-proxy` - 刷新单个账户的代理地址
- `POST /api/accounts/refresh-all-proxies` - 刷新所有账户的代理地址
- **原因**:代理地址应在账户导入时自动计算,无需手动刷新
- **影响范围**AccountController、AccountService
- **向后兼容性**:这些接口已不再使用,移除不影响现有功能
### 前端跟单比例配置优化
- **最小比例**:从 10% 降低到 0.01%,支持更灵活的跟单比例设置
- **最大比例**:增加到 10000%,满足大比例跟单需求
- **显示格式**:比例模式显示为百分比(如 "100%" 而不是 "1x"
- **输入验证**:增强输入验证,确保比例在合理范围内
## 📊 变更统计
- **提交数量**3 个提交
- **文件变更**15 个文件
- **代码变更**+575 行 / -194 行(净增加 381 行)
### 详细文件变更
**后端变更**
- `AccountController.kt` - 移除刷新代理钱包接口(-59 行)
- `AccountService.kt` - 移除刷新代理钱包方法(-79 行)
- `CopyTrading.kt` - 增加 copyRatio 精度
- `CopyTradingTemplate.kt` - 增加 copyRatio 精度
- `CopyOrderTrackingService.kt` - 优化按比例跟单金额计算逻辑(+45 行)
- `MathExt.kt` - 扩展 div 函数支持精度和舍入模式(+20 行)
- `V18__increase_copy_ratio_precision.sql` - 数据库迁移脚本(+14 行)
**前端变更**
- `CopyTradingAdd.tsx` - 优化 formatter、优化比例配置(+106 行)
- `CopyTradingEdit.tsx` - 优化 formatter、优化比例配置(+106 行)
- `CopyTradingList.tsx` - 优化比例显示格式
- `CopyTradingOrders/EditModal.tsx` - 优化 formatter、优化比例配置(+106 行)
- `TemplateAdd.tsx` - 优化 formatter、优化比例配置(+65 行)
- `TemplateEdit.tsx` - 优化 formatter、优化比例配置(+65 行)
- `TemplateList.tsx` - 优化 formatter、优化比例配置(+65 行)
- `utils/index.ts` - 添加 formatNumber 工具函数(+31 行)
## 🔧 技术细节
### 数据库变更
- **迁移脚本**`V18__increase_copy_ratio_precision.sql`
- **变更内容**
- `copy_trading.copy_ratio`: DECIMAL(10,2) → DECIMAL(20,8)
- `copy_trading_templates.copy_ratio`: DECIMAL(10,2) → DECIMAL(20,8)
- **自动执行**:升级时会自动执行迁移脚本
### API 变更
- **移除接口**
- `POST /api/accounts/refresh-proxy`
- `POST /api/accounts/refresh-all-proxies`
- **无新增接口**
### 前端变更
- **工具函数**:新增 `formatNumber()` 函数,用于格式化数字显示
- **组件更新**:所有数值输入框统一使用新的 formatter 函数
- **显示优化**:跟单模式的比例显示为百分比格式
## 📝 升级说明
### 数据库升级
本次版本包含数据库迁移脚本,升级时会自动执行:
- 自动增加 `copy_ratio` 字段的精度
- 现有数据不受影响,精度升级是向后兼容的
### 配置变更
无需额外配置变更。
### 兼容性
- **向后兼容**:所有变更都是向后兼容的
- **API 兼容**:移除的接口不影响现有功能(这些接口已不再使用)
- **数据兼容**:数据库字段精度升级不会影响现有数据
## 🎯 主要改进
1. **优化输入框格式化**:优化数值输入框的显示逻辑
2. **优化跟单金额计算**:确保按比例跟单的金额满足最小限制要求
3. **提升精度支持**:支持更精确的跟单比例设置(0.01% - 10000%
4. **代码清理**:移除不再使用的刷新代理钱包接口
## 🔗 相关链接
- [GitHub Tag](https://github.com/WrBug/PolyHermes/releases/tag/v1.1.5)
- [变更日志](https://github.com/WrBug/PolyHermes/compare/v1.1.4...v1.1.5)
## 🙏 致谢
感谢所有贡献者和测试用户的反馈与支持!
---
# v1.1.2
## 🚀 主要功能
### 🐛 修复内存泄漏问题
- 修复 Retrofit/OkHttpClient 实例重复创建导致的内存泄漏问题
- 为不需要认证的 API 创建共享的 OkHttpClient 实例(Gamma API、Data API、GitHub API 等)
- 带认证的 CLOB API 按钱包地址缓存(每个账户一个客户端)
- RPC API 按 RPC URL 缓存,Builder Relayer API 按 relayerUrl 缓存
- 添加 `@PreDestroy` 方法清理缓存,确保资源正确释放
- **效果**:内存占用从运行几小时后从 400MB 涨到 1GB+ 变为保持稳定,大幅减少内存占用
### 📊 市场价格服务优化
- 移除降级查询逻辑,仅保留链上 RPC 查询和 CLOB 订单簿查询
- 移除 CLOB Trades、Gamma Market Status、Gamma Market Price 查询逻辑
- 如果所有数据源都失败,抛出明确的异常信息
- 价格截位到 4 位小数(向下截断,不四舍五入)
- 简化代码逻辑,提高查询效率和准确性
### 🔧 代码架构优化
- 统一 Gson 使用,改为依赖注入方式
- 在 `GsonConfig` 中统一配置 Gson Beanlenient 模式)
- 所有 Service 类通过构造函数注入 Gson 实例
- 移除所有 `GsonConverterFactory.create()` 无参调用,统一使用注入的 Gson
- 提高代码一致性和可维护性
### 🗑️ 功能清理
- 移除下单失败存储数据库的功能
- 删除 `FailedTrade` 实体类和 `FailedTradeRepository`
- 从 `CopyOrderTrackingService` 中移除失败交易存储逻辑
- 创建 Flyway migration V16 删除 `failed_trade` 表
- 下单失败时仅记录日志,不再存储到数据库,简化数据模型
### 🚀 部署优化
- 自动使用当前分支名作为 Docker 版本号
- 分支名中的 `/` 自动替换为 `-`Docker tag 不支持 `/
- `docker-compose.yml` 启用 build args,从环境变量读取版本号
- 前端页面将显示当前分支名作为版本号
- 如果没有 Git 仓库或获取失败,使用默认值 `dev`
## 🐛 Bug 修复
### 修复 Flyway Migration 问题
- 恢复 V1 migration 文件,避免 checksum 不匹配
- 保持 `V1__init_database.sql` 的原有内容不变
- `failed_trade` 表的删除通过 V16 migration 处理
- 确保已有数据库的 migration checksum 保持一致
### 修复前端编译错误
- 修复 `PositionList.tsx` 中引用不存在的 `bestBid` 属性导致的编译错误
- 使用 `currentPrice` 替代 `bestBid`,确保前端代码可以正常编译
## 📚 文档更新
- 新增智能资金分析文档(`docs/zh/smart-money-analysis.md`
- 详细说明智能资金分析功能的使用方法和策略
## 🔧 技术改进
- 优化 `RetrofitFactory`,实现客户端实例缓存和复用
- 优化 `CopyOrderTrackingService`,移除失败交易相关逻辑
- 优化 `OrderStatusUpdateService`,增强订单状态更新功能
- 优化 `TelegramNotificationService`,改进通知逻辑
- 优化 `PositionCheckService`,简化代码结构
- 优化 `PolymarketClobService`,改进 API 调用逻辑
## 📦 数据库变更
- 删除 `failed_trade` 表(Migration: V16
## 🔗 相关链接
- **GitHub Release**: https://github.com/WrBug/PolyHermes/releases/tag/v1.1.2
- **完整更新日志**: https://github.com/WrBug/PolyHermes/compare/v1.1.1...v1.1.2
- **Docker Hub**: https://hub.docker.com/r/wrbug/polyhermes
## 📊 统计信息
- **文件变更**: 29 个文件
- **代码变更**: +1597 行 / -678 行
- **主要提交**: 8 个提交
## ⚠️ 重要提醒
**请务必使用官方 Docker 镜像源,避免财产损失!**
### ✅ 官方 Docker Hub 镜像
**官方镜像地址**`wrbug/polyhermes`
```bash
# ✅ 正确:使用官方镜像
docker pull wrbug/polyhermes:v1.1.2
# ❌ 错误:不要使用其他来源的镜像
# 任何非官方来源的镜像都可能包含恶意代码,导致您的私钥和资产被盗
```
### 🔗 官方渠道
请通过以下**唯一官方渠道**获取 PolyHermes
* **GitHub 仓库**https://github.com/WrBug/PolyHermes
* **Twitter**@polyhermes
* **Telegram 群组**https://t.me/polyhermes
---
**⭐ 如果这个项目对您有帮助,请给个 Star 支持一下!**
---
# v1.1.1
## 🚀 主要功能
### 🔗 链上 WebSocket 监听优化
- 创建 `UnifiedOnChainWsService` 统一管理 WebSocket 连接,所有服务共享同一个连接
- 创建 `OnChainWsUtils` 工具类,提取公共的链上 WebSocket 相关功能
- 创建 `AccountOnChainMonitorService` 监听账户链上卖出和赎回事件
- 优化 `OnChainWsService`,复用公共代码,减少代码重复
- 支持通过链上 WebSocket 实时监听账户的卖出和赎回交易,自动更新订单状态
### 📊 市场状态查询优化
- 优化市场结算状态查询,优先使用链上查询 `ConditionalTokens.getCondition`
- 如果链上查询失败,自动降级到 Gamma API 查询
- 提供更实时和准确的市场结算结果
### 🔕 自动订单通知优化
- 自动生成的订单(AUTO_、AUTO_FIFO_、AUTO_WS_ 前缀)不再发送 Telegram 通知
- 优化 `OrderStatusUpdateService`,跳过自动生成订单的通知处理
- 减少不必要的通知,提升用户体验
## 🐛 Bug 修复
### 修复移动端 API 健康页面缺少数据显示
- 移动端添加 URL 地址显示
- 移动端添加状态文本显示(正常/异常/未配置)
- 移动端添加消息/状态信息显示
- 移动端和桌面端显示信息保持一致
## 🔧 功能优化
### 优化 Telegram 推送消息格式
- 添加价格和数量截位处理:
* 价格保留最多4位小数(截断,不四舍五入)
* 数量保留最多2位小数(截断,不四舍五入)
- 优化账户信息显示格式:
* 有账户名和钱包地址时显示:账户名(0x123...123)
* 只有账户名时显示账户名
* 只有钱包地址时显示脱敏后的地址
* 都没有时显示未知账户
### 配置优化
- 移除 `polygon.rpc.url` 配置,使用 RpcNodeService 统一管理 RPC 节点
- 删除无用的 `position.push.polling-interval``position.push.heartbeat-timeout` 配置项
- 修正日志配置中的包名(polyhermes -> polymarketbot
- 更新 `ApiHealthCheckService` 直接使用 `RpcNodeService.getHttpUrl()`
## 📚 文档更新
- 统一发布说明文件,使用 RELEASE.md 替代版本化文件(RELEASE_v1.0.1.md、RELEASE_v1.1.0.md
- 更新所有部署文档,移除 POLYGON_RPC_URL 相关说明
- 更新所有 Docker Compose 配置文件,移除 POLYGON_RPC_URL 环境变量
- 更新所有部署脚本,移除 POLYGON_RPC_URL 环境变量定义
## 🔧 技术改进
- 重构链上 WebSocket 服务,提取公共代码到 `OnChainWsUtils`
- 创建统一的 WebSocket 连接管理服务 `UnifiedOnChainWsService`
- 添加链上查询市场结算结果的功能(`BlockchainService.getCondition`
- 添加 ABI 编码/解码工具方法(`EthereumUtils.decodeConditionResult`
- 优化代码结构,减少代码重复,提高可维护性
---
# v1.1.0
## 🚀 主要功能
### 🔗 链上 WebSocket 实时监听
- 实现通过 Polygon RPC `eth_subscribe` 实时监听链上交易
- 支持监听 USDC Transfer 和 ERC1155 Transfer 事件
- 实现并行监控策略:链上 WebSocket 和轮询同时运行,哪个数据先返回用哪个
- 支持通过 `eth_unsubscribe` 取消单个 Leader 的订阅,无需重新连接
- 优化 WebSocket 连接管理:只创建一个连接,没有跟单配置时自动取消
- 跟单配置生效/失效时及时更新 WebSocket 订阅
- 使用 Gson 替换所有 JSON 解析,提高解析稳定性
- 添加 Mutex 保证线程安全,防止并发处理导致的数据重复
### 📊 RPC 节点管理
- 实现 RPC 节点管理功能,支持添加、编辑、删除自定义 RPC 节点
- 支持 RPC 节点启用/禁用功能,禁用的节点会被自动忽略
- 前端添加启用/禁用开关,支持实时切换节点状态
- 健康检查只检查启用的节点,提高检查效率
- 节点选择时自动过滤禁用的节点
### 💰 卖出订单价格轮询更新
- 添加 `price_updated` 字段到 `sell_match_record` 表,用于标记价格是否已更新
- 创建 `OrderStatusUpdateService` 定时任务服务,每 5 秒轮询一次:
- 更新卖出订单的实际成交价(通过 orderId 查询订单详情)
- 清理已删除账户的订单记录
- 支持加权平均价格计算,处理部分成交的订单
- 添加 orderId 格式验证:非 0x 开头的直接标记为已更新,0x 开头的等待定时任务更新
- 下单完成后不再立即查询价格,直接保存,等待定时任务更新
## 🐛 Bug 修复
### 修复跟单卖出订单的 API 凭证解密问题
- 修复 `processSellTrade` 中 API 凭证未解密的问题,与 `processBuyTrade` 保持一致
- 确保卖出订单能够正常使用 API 凭证进行认证
### 修复 SELL 订单精度问题
- 修复 SELL 订单的 `makerAmount``takerAmount` 精度问题:
- `makerAmount` (shares) 最多 2 位小数(符合 API 要求)
- `takerAmount` (USDC) 最多 4 位小数(符合 API 要求)
- 确保订单能够正常提交到 Polymarket API
## 📚 文档更新
- 添加 Docker 版本更新说明(中英文)
- 添加链上 WebSocket 监听策略文档
- 添加跟单逻辑总结文档
- 更新部署文档,包含详细的版本更新步骤
## 🔧 技术改进
- 使用 Gson 替换 ObjectMapper,提高 JSON 解析稳定性
- `JsonRpcResponse.result` 使用 `JsonElement` 类型,支持灵活的 JSON 结构
- 优化 WebSocket 连接管理,减少不必要的连接
- 添加线程安全机制,使用 Kotlin Coroutines Mutex
- 启用 Spring 定时任务功能(`@EnableScheduling`
## 📦 数据库变更
- 新增 `price_updated` 字段到 `sell_match_record` 表(Migration: V13
## 🔗 相关链接
- **GitHub Release**: https://github.com/WrBug/PolyHermes/releases/tag/v1.1.1
- **完整更新日志**: https://github.com/WrBug/PolyHermes/compare/v1.1.0...v1.1.1
- **Docker Hub**: https://hub.docker.com/r/wrbug/polyhermes
## 📊 统计信息
- **文件变更**: 32 个文件
- **代码变更**: +1872 行 / -1503 行
- **主要提交**: 7 个提交
## ⚠️ 重要提醒
**请务必使用官方 Docker 镜像源,避免财产损失!**
### ✅ 官方 Docker Hub 镜像
**官方镜像地址**`wrbug/polyhermes`
```bash
# ✅ 正确:使用官方镜像
docker pull wrbug/polyhermes:v1.1.1
# ❌ 错误:不要使用其他来源的镜像
# 任何非官方来源的镜像都可能包含恶意代码,导致您的私钥和资产被盗
```
### 🔗 官方渠道
请通过以下**唯一官方渠道**获取 PolyHermes
* **GitHub 仓库**https://github.com/WrBug/PolyHermes
* **Twitter**@polyhermes
* **Telegram 群组**https://t.me/polyhermes
---
**⭐ 如果这个项目对您有帮助,请给个 Star 支持一下!**
+1
View File
@@ -74,3 +74,4 @@
- b658270 - 添加订单详情查询脚本
- 07b4d65 - 清理 MarketPollingService 调试日志
+59
View File
@@ -0,0 +1,59 @@
#!/bin/bash
# 清理 Flyway V29 失败记录的脚本
echo "=== 清理 Flyway V29 失败记录 ==="
echo ""
echo "请确保 MySQL 正在运行,然后输入数据库密码"
echo ""
# 数据库配置
DB_HOST="localhost"
DB_PORT="3306"
DB_NAME="polymarket_bot"
DB_USER="root"
# 检查 MySQL 命令是否可用
if ! command -v mysql &> /dev/null; then
echo "❌ 错误: 未找到 mysql 命令"
echo ""
echo "请使用数据库客户端(如 Navicat、DataGrip 等)执行以下 SQL"
echo ""
echo "-- 1. 查看 Flyway 历史记录"
echo "SELECT version, description, installed_on, success "
echo "FROM flyway_schema_history "
echo "WHERE version >= 28"
echo "ORDER BY installed_rank;"
echo ""
echo "-- 2. 删除 V29 的失败记录"
echo "DELETE FROM flyway_schema_history WHERE version = '29';"
echo ""
exit 1
fi
# 执行清理
echo "正在连接数据库..."
mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p "$DB_NAME" << 'EOF'
-- 查看当前状态
SELECT '=== 当前 Flyway 历史记录 ===' as '';
SELECT version, description, installed_on, success
FROM flyway_schema_history
WHERE version >= 28
ORDER BY installed_rank;
-- 删除 V29 失败记录
SELECT '=== 删除 V29 记录 ===' as '';
DELETE FROM flyway_schema_history WHERE version = '29';
-- 确认删除结果
SELECT CONCAT('已删除 ', ROW_COUNT(), ' 条记录') as result;
-- 再次查看状态
SELECT '=== 清理后的 Flyway 历史记录 ===' as '';
SELECT version, description, installed_on, success
FROM flyway_schema_history
WHERE version >= 28
ORDER BY installed_rank;
EOF
echo ""
echo "✅ 清理完成!现在可以重启应用了"
@@ -0,0 +1,26 @@
package com.wrbug.polymarketbot.api
import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Query
/**
* 币安现货公开 API(K 线等)
* Base URL: https://api.binance.com
* 文档: https://developers.binance.com/docs/binance-spot-api-docs/rest-api
*/
interface BinanceApi {
/**
* K 线数据
* 返回每根 K 线: [openTime, open, high, low, close, volume, closeTime, ...]
*/
@GET("/api/v3/klines")
fun getKlines(
@Query("symbol") symbol: String,
@Query("interval") interval: String,
@Query("limit") limit: Int = 30,
@Query("startTime") startTime: Long? = null,
@Query("endTime") endTime: Long? = null
): Call<List<List<Any>>>
}
@@ -41,6 +41,17 @@ interface BuilderRelayerApi {
@Query("address") address: String,
@Query("type") type: String
): Response<NoncePayload>
/**
* 获取 Relay PayloadPROXY 类型执行时使用)
* GET /relay-payload?address={address}&type=PROXY
* 参考: builder-relayer-client endpoints GET_RELAY_PAYLOAD
*/
@GET("/relay-payload")
suspend fun getRelayPayload(
@Query("address") address: String,
@Query("type") type: String
): Response<RelayPayload>
/**
* 获取交易状态
@@ -81,7 +92,7 @@ interface BuilderRelayerApi {
val data: String, // 调用数据(十六进制字符串,带 0x 前缀)
@SerializedName("nonce")
val nonce: String, // Safe nonce字符串
val nonce: String? = null, // Safe nonceSAFE 必填,SAFE-CREATE 不传
@SerializedName("signature")
val signature: String, // Safe 签名(packed signature,十六进制字符串,带 0x 前缀)
@@ -96,6 +107,7 @@ interface BuilderRelayerApi {
/**
* 签名参数
* 参考: builder-relayer-client/src/types.ts 的 SignatureParams
* Safe 使用 operation/safeTxnGas/baseGas 等,PROXY 使用 relayHub/relay/relayerFee 等
*/
data class SignatureParams(
@SerializedName("gasPrice")
@@ -114,7 +126,29 @@ interface BuilderRelayerApi {
val gasToken: String? = null,
@SerializedName("refundReceiver")
val refundReceiver: String? = null
val refundReceiver: String? = null,
@SerializedName("relayerFee")
val relayerFee: String? = null,
@SerializedName("gasLimit")
val gasLimit: String? = null,
@SerializedName("relayHub")
val relayHub: String? = null,
@SerializedName("relay")
val relay: String? = null,
/** SAFE-CREATE 签名参数 */
@SerializedName("paymentToken")
val paymentToken: String? = null,
@SerializedName("payment")
val payment: String? = null,
@SerializedName("paymentReceiver")
val paymentReceiver: String? = null
)
/**
@@ -142,6 +176,17 @@ interface BuilderRelayerApi {
@SerializedName("nonce")
val nonce: String
)
/**
* Relay PayloadPROXY 执行时获取 relay 地址与 nonce
* 参考: builder-relayer-client types RelayPayload
*/
data class RelayPayload(
@SerializedName("address")
val address: String,
@SerializedName("nonce")
val nonce: String
)
/**
* Relayer 交易详情
@@ -1,6 +1,7 @@
package com.wrbug.polymarketbot.api
import com.google.gson.annotations.SerializedName
import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.http.*
@@ -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 标记
)
@@ -44,5 +44,14 @@ object PolymarketConstants {
* 用于 Gasless 交易
*/
const val BUILDER_RELAYER_URL = "https://relayer-v2.polymarket.com/"
/**
* Polymarket Safe 代理工厂合约地址(Polygon 主网)
* 用于 Safe 类型账户的代理部署(SAFE-CREATE
*/
const val SAFE_PROXY_FACTORY_ADDRESS = "0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b"
/** SafeCreate 用 EIP-712 domain name,与 builder-relayer-client 一致 */
const val SAFE_FACTORY_EIP712_NAME = "Polymarket Contract Proxy Factory"
}
@@ -23,6 +23,50 @@ class AccountController(
private val logger = LoggerFactory.getLogger(AccountController::class.java)
/**
* 检查代理地址选项(用于导入前选择代理类型)
*/
@PostMapping("/check-proxy-options")
fun checkProxyOptions(@RequestBody request: CheckProxyOptionsRequest): ResponseEntity<ApiResponse<CheckProxyOptionsResponse>> {
return try {
if (request.walletAddress.isBlank()) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_WALLET_ADDRESS_EMPTY, messageSource = messageSource))
}
if (request.privateKey.isNullOrBlank() && request.mnemonic.isNullOrBlank()) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, "必须提供私钥或助记词", messageSource))
}
val result = runBlocking { accountService.checkProxyOptions(request) }
result.fold(
onSuccess = { response ->
ResponseEntity.ok(ApiResponse.success(response))
},
onFailure = { e ->
logger.error("检查代理地址选项失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> ResponseEntity.ok(
ApiResponse.error(
ErrorCode.PARAM_ERROR,
e.message,
messageSource
)
)
else -> ResponseEntity.ok(
ApiResponse.error(
ErrorCode.SERVER_ERROR,
e.message,
messageSource
)
)
}
}
)
} catch (e: Exception) {
logger.error("检查代理地址选项异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
/**
* 通过私钥导入账户
*/
@@ -45,14 +89,17 @@ class AccountController(
onFailure = { e ->
logger.error("导入账户失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> ResponseEntity.ok(
ApiResponse.error(
ErrorCode.PARAM_ERROR,
e.message,
messageSource
is IllegalArgumentException -> if (e.message == "ACCOUNT_ALREADY_EXISTS") {
ResponseEntity.ok(ApiResponse.error(ErrorCode.ACCOUNT_ALREADY_EXISTS, messageSource = messageSource))
} else {
ResponseEntity.ok(
ApiResponse.error(
ErrorCode.PARAM_ERROR,
e.message,
messageSource
)
)
)
}
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ACCOUNT_IMPORT_FAILED, e.message, messageSource))
}
}
@@ -157,6 +204,82 @@ class AccountController(
}
}
/**
* 检查账户设置状态(代理部署、交易启用、代币批准)
*/
@PostMapping("/check-setup-status")
fun checkSetupStatus(@RequestBody request: AccountDetailRequest): ResponseEntity<ApiResponse<AccountSetupStatusDto>> {
return try {
if (request.accountId == null || request.accountId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ACCOUNT_ID_INVALID, messageSource = messageSource))
}
val result = runBlocking { accountService.checkAccountSetupStatus(request.accountId) }
result.fold(
onSuccess = { status ->
ResponseEntity.ok(ApiResponse.success(status))
},
onFailure = { e ->
logger.error("检查账户设置状态失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> ResponseEntity.ok(
ApiResponse.error(
ErrorCode.PARAM_ERROR,
e.message,
messageSource
)
)
else -> ResponseEntity.ok(
ApiResponse.error(
ErrorCode.SERVER_ERROR,
e.message,
messageSource
)
)
}
}
)
} catch (e: Exception) {
logger.error("检查账户设置状态异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
/**
* 执行设置步骤(步骤1 返回跳转 URL,步骤2/3 由后端执行)
*/
@PostMapping("/execute-setup-step")
fun executeSetupStep(@RequestBody request: ExecuteSetupStepRequest): ResponseEntity<ApiResponse<ExecuteSetupStepResponse>> {
return try {
if (request.accountId == null || request.accountId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ACCOUNT_ID_INVALID, messageSource = messageSource))
}
val step = request.step ?: 0
if (step !in 1..3) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, "步骤必须为 1、2 或 3", messageSource))
}
val result = runBlocking { accountService.executeSetupStep(request.accountId, step) }
result.fold(
onSuccess = { response ->
ResponseEntity.ok(ApiResponse.success(response))
},
onFailure = { e ->
logger.error("执行设置步骤失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> ResponseEntity.ok(
ApiResponse.error(ErrorCode.PARAM_ERROR, e.message, messageSource)
)
else -> ResponseEntity.ok(
ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource)
)
}
}
)
} catch (e: Exception) {
logger.error("执行设置步骤异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
/**
* 查询账户详情
*/
@@ -0,0 +1,256 @@
package com.wrbug.polymarketbot.controller.backtest
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.backtest.BacktestService
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
/**
* 回测管理控制器
*/
@RestController
@RequestMapping("/api/backtest")
class BacktestController(
private val backtestService: BacktestService,
private val messageSource: MessageSource
) {
private val logger = LoggerFactory.getLogger(BacktestController::class.java)
/**
* 创建回测任务
*/
@PostMapping("/tasks")
fun createBacktestTask(@RequestBody request: BacktestCreateRequest): ResponseEntity<ApiResponse<BacktestTaskDto>> {
return try {
logger.info("创建回测任务: taskName=${request.taskName}, leaderId=${request.leaderId}")
val result = runBlocking {
backtestService.createBacktestTask(request)
}
result.fold(
onSuccess = { dto ->
logger.info("回测任务创建成功: taskId=${dto.id}")
ResponseEntity.ok(ApiResponse.success(dto))
},
onFailure = { e ->
logger.error("创建回测任务失败", e)
val errorCode = when (e) {
is IllegalArgumentException -> ErrorCode.PARAM_ERROR
else -> ErrorCode.SERVER_BACKTEST_CREATE_FAILED
}
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("创建回测任务异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_CREATE_FAILED, e.message, messageSource))
}
}
/**
* 查询回测任务列表
*/
@PostMapping("/tasks/list")
fun getBacktestTaskList(@RequestBody request: BacktestListRequest): ResponseEntity<ApiResponse<BacktestListResponse>> {
return try {
val result = backtestService.getBacktestTaskList(request)
result.fold(
onSuccess = { response ->
logger.info("查询回测任务列表成功: total=${response.total}")
ResponseEntity.ok(ApiResponse.success(response))
},
onFailure = { e ->
logger.error("查询回测任务列表失败", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_LIST_FETCH_FAILED, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("查询回测任务列表异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_LIST_FETCH_FAILED, e.message, messageSource))
}
}
/**
* 查询回测任务详情
*/
@PostMapping("/tasks/detail")
fun getBacktestTaskDetail(@RequestBody request: BacktestDetailRequest): ResponseEntity<ApiResponse<BacktestDetailResponse>> {
return try {
val result = backtestService.getBacktestTaskDetail(request)
result.fold(
onSuccess = { response ->
logger.info("查询回测任务详情成功: taskId=${request.id}")
ResponseEntity.ok(ApiResponse.success(response))
},
onFailure = { e ->
logger.error("查询回测任务详情失败", e)
val errorCode = when (e) {
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
else -> ErrorCode.SERVER_BACKTEST_DETAIL_FETCH_FAILED
}
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("查询回测任务详情异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_DETAIL_FETCH_FAILED, e.message, messageSource))
}
}
/**
* 查询回测交易记录
*/
@PostMapping("/tasks/trades")
fun getBacktestTrades(@RequestBody request: BacktestTradeListRequest): ResponseEntity<ApiResponse<BacktestTradeListResponse>> {
return try {
val result = backtestService.getBacktestTrades(request)
result.fold(
onSuccess = { response ->
logger.info("查询回测交易记录成功: taskId=${request.taskId}")
ResponseEntity.ok(ApiResponse.success(response))
},
onFailure = { e ->
logger.error("查询回测交易记录失败", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_TRADES_FETCH_FAILED, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("查询回测交易记录异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_TRADES_FETCH_FAILED, e.message, messageSource))
}
}
/**
* 删除回测任务
*/
@PostMapping("/tasks/delete")
fun deleteBacktestTask(@RequestBody request: BacktestDeleteRequest): ResponseEntity<ApiResponse<Unit>> {
return try {
logger.info("删除回测任务: taskId=${request.id}")
val result = backtestService.deleteBacktestTask(request)
result.fold(
onSuccess = {
logger.info("回测任务删除成功: taskId=${request.id}")
ResponseEntity.ok(ApiResponse.success(Unit))
},
onFailure = { e ->
logger.error("删除回测任务失败", e)
val errorCode = when (e) {
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
is IllegalStateException -> ErrorCode.BACKTEST_TASK_RUNNING
else -> ErrorCode.SERVER_BACKTEST_DELETE_FAILED
}
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("删除回测任务异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_DELETE_FAILED, e.message, messageSource))
}
}
/**
* 停止回测任务
*/
@PostMapping("/tasks/stop")
fun stopBacktestTask(@RequestBody request: BacktestStopRequest): ResponseEntity<ApiResponse<Unit>> {
return try {
logger.info("停止回测任务: taskId=${request.id}")
val result = backtestService.stopBacktestTask(request)
result.fold(
onSuccess = {
logger.info("回测任务停止成功: taskId=${request.id}")
ResponseEntity.ok(ApiResponse.success(Unit))
},
onFailure = { e ->
logger.error("停止回测任务失败", e)
val errorCode = when (e) {
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
is IllegalStateException -> ErrorCode.BACKTEST_TASK_RUNNING
else -> ErrorCode.SERVER_BACKTEST_STOP_FAILED
}
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("停止回测任务异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_STOP_FAILED, e.message, messageSource))
}
}
/**
* 重试回测任务
*/
@PostMapping("/tasks/retry")
fun retryBacktestTask(@RequestBody request: BacktestRetryRequest): ResponseEntity<ApiResponse<Unit>> {
return try {
logger.info("重试回测任务: taskId=${request.id}")
val result = backtestService.retryBacktestTask(request)
result.fold(
onSuccess = {
logger.info("回测任务重试成功: taskId=${request.id}")
ResponseEntity.ok(ApiResponse.success(Unit))
},
onFailure = { e ->
logger.error("重试回测任务失败", e)
val errorCode = when (e) {
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
is IllegalStateException -> ErrorCode.BACKTEST_TASK_RUNNING
else -> ErrorCode.SERVER_BACKTEST_RETRY_FAILED
}
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("重试回测任务异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_RETRY_FAILED, e.message, messageSource))
}
}
/**
* 按当前配置重新测试:基于已完成的回测任务创建相同配置的新任务(仅支持已完成任务)
*/
@PostMapping("/tasks/rerun")
fun rerunBacktestTask(@RequestBody request: BacktestRerunRequest): ResponseEntity<ApiResponse<BacktestTaskDto>> {
return try {
logger.info("按配置重新测试: sourceTaskId=${request.id}, newTaskName=${request.taskName}")
val result = backtestService.rerunBacktestTask(request)
result.fold(
onSuccess = { dto ->
logger.info("重新测试任务创建成功: newTaskId=${dto.id}")
ResponseEntity.ok(ApiResponse.success(dto))
},
onFailure = { e ->
logger.error("按配置重新测试失败", e)
val errorCode = when (e) {
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
is IllegalStateException -> ErrorCode.BACKTEST_TASK_NOT_COMPLETED
else -> ErrorCode.SERVER_BACKTEST_RERUN_FAILED
}
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("按配置重新测试异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_RERUN_FAILED, e.message, messageSource))
}
}
}
@@ -140,7 +140,7 @@ class LeaderController(
if (request.leaderId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_LEADER_ID_INVALID, messageSource = messageSource))
}
val result = leaderService.getLeaderDetail(request.leaderId)
result.fold(
onSuccess = { leader ->
@@ -159,6 +159,36 @@ class LeaderController(
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_LEADER_DETAIL_FETCH_FAILED, e.message, messageSource))
}
}
/**
* 查询被跟单者余额
*/
@PostMapping("/balance")
fun getLeaderBalance(@RequestBody request: LeaderBalanceRequest): ResponseEntity<ApiResponse<LeaderBalanceResponse>> {
return try {
if (request.leaderId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_LEADER_ID_INVALID, messageSource = messageSource))
}
val result = leaderService.getLeaderBalance(request.leaderId)
result.fold(
onSuccess = { balance ->
ResponseEntity.ok(ApiResponse.success(balance))
},
onFailure = { e ->
logger.error("查询 Leader 余额失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, e.message, messageSource))
is IllegalStateException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.BUSINESS_ERROR, e.message, messageSource))
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
)
} catch (e: Exception) {
logger.error("查询 Leader 余额异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, 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个)
)
/**
* 账户更新请求
*/
@@ -37,9 +68,9 @@ data class SystemConfigDto(
val builderApiKeyConfigured: Boolean, // Builder API Key 是否已配置
val builderSecretConfigured: Boolean, // Builder Secret 是否已配置
val builderPassphraseConfigured: Boolean, // Builder Passphrase 是否已配置
val builderApiKeyDisplay: String? = null, // Builder API Key 显示值(部分显示,用于前端展示)
val builderSecretDisplay: String? = null, // Builder Secret 显示值(部分显示,用于前端展示)
val builderPassphraseDisplay: String? = null, // Builder Passphrase 显示值(部分显示,用于前端展示)
val builderApiKeyDisplay: String? = null, // Builder API Key 显示值(完整,用于前端展示)
val builderSecretDisplay: String? = null, // Builder Secret 显示值(完整,用于前端展示)
val builderPassphraseDisplay: String? = null, // Builder Passphrase 显示值(完整,用于前端展示)
val autoRedeemEnabled: Boolean = true // 自动赎回(系统级别配置,默认开启)
)
@@ -93,6 +124,16 @@ data class AccountListResponse(
val total: Long
)
/**
* 钱包余额响应(通用类,用于 Account 和 Leader
*/
data class WalletBalanceResponse(
val availableBalance: String, // 可用余额(RPC 查询的 USDC 余额)
val positionBalance: String, // 仓位余额(持仓总价值)
val totalBalance: String, // 总余额 = 可用余额 + 仓位余额
val positions: List<PositionDto> = emptyList()
)
/**
* 账户余额响应
*/
@@ -108,6 +149,7 @@ data class AccountBalanceResponse(
*/
data class PositionDto(
val marketId: String,
val title: String?, // 市场名称
val side: String, // YES 或 NO
val quantity: String,
val avgPrice: String,
@@ -0,0 +1,63 @@
package com.wrbug.polymarketbot.dto
/**
* 账户设置状态检查结果
*/
data class AccountSetupStatusDto(
/**
* 步骤1:代理钱包是否已部署
*/
val proxyDeployed: Boolean,
/**
* 步骤2:交易是否已启用(API Key 是否已配置)
*/
val tradingEnabled: Boolean,
/**
* 步骤3:代币是否已批准
*/
val tokensApproved: Boolean,
/**
* 代币批准详情(各合约的授权额度)
* Key: 合约名称(CTF_CONTRACT, CTF_EXCHANGE, NEG_RISK_EXCHANGE, NEG_RISK_ADAPTER
* Value: 授权额度(USDC6位小数)
*/
val approvalDetails: Map<String, String>? = null,
/**
* 检查错误信息(如果有)
*/
val error: String? = null
)
/**
* 执行设置步骤请求
*/
data class ExecuteSetupStepRequest(
/** 账户 ID */
val accountId: Long? = null,
/** 步骤:1=部署代理, 2=启用交易, 3=批准代币 */
val step: Int? = null
)
/**
* 执行设置步骤响应
*/
data class ExecuteSetupStepResponse(
/** 是否由后端执行成功(步骤1 仅返回跳转链接,为 false) */
val success: Boolean = false,
/** 需跳转时由后端提供的 URL(步骤1 使用) */
val redirectUrl: String? = null,
/** 链上交易哈希(步骤3 批准代币成功时返回) */
val transactionHash: String? = null
)
/**
* 账户导入响应(扩展,包含设置状态)
*/
data class AccountImportResponse(
val account: AccountDto,
val setupStatus: AccountSetupStatusDto? = null // 设置状态检查结果(可选)
)
@@ -0,0 +1,211 @@
package com.wrbug.polymarketbot.dto
import java.math.BigDecimal
/**
* 回测任务创建请求
*/
data class BacktestCreateRequest(
val taskName: String, // 回测任务名称
val leaderId: Long, // Leader ID
val initialBalance: String, // 初始资金
val backtestDays: Int, // 回测天数 (1-30)
// 跟单配置(与 CopyTrading 一致,但不包含 max_position_count
val copyMode: String? = null, // "RATIO" 或 "FIXED"
val copyRatio: String? = null, // 仅在 copyMode="RATIO" 时生效
val fixedAmount: String? = null, // 仅在 copyMode="FIXED" 时生效
val maxOrderSize: String? = null,
val minOrderSize: String? = null,
val maxDailyLoss: String? = null,
val maxDailyOrders: Int? = null,
val supportSell: Boolean? = null,
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开始)
)
/**
* 回测任务列表请求
*/
data class BacktestListRequest(
val leaderId: Long? = null, // Leader ID(可选)
val status: String? = null, // PENDING/RUNNING/COMPLETED/STOPPED/FAILED
val sortBy: String? = null, // profitAmount / profitRate / createdAt
val sortOrder: String? = null, // asc / desc
val page: Int = 1, // 页码,从1开始
val size: Int = 20 // 每页数量
)
/**
* 回测任务详情请求
*/
data class BacktestDetailRequest(
val id: Long // 回测任务ID
)
/**
* 回测交易记录请求
*/
data class BacktestTradeListRequest(
val taskId: Long, // 回测任务ID
val page: Int = 1, // 页码,从1开始
val size: Int = 20 // 每页数量
)
/**
* 回测进度查询请求
*/
data class BacktestProgressRequest(
val id: Long // 回测任务ID
)
/**
* 回测任务停止请求
*/
data class BacktestStopRequest(
val id: Long // 回测任务ID
)
/**
* 回测任务删除请求
*/
data class BacktestDeleteRequest(
val id: Long // 回测任务ID
)
/**
* 回测任务重试请求
*/
data class BacktestRetryRequest(
val id: Long // 回测任务ID
)
/**
* 按当前配置重新测试请求(仅支持已完成任务)
*/
data class BacktestRerunRequest(
val id: Long, // 源回测任务ID
val taskName: String? = null // 新任务名称,为空时使用「原名称 (副本)」
)
/**
* 回测任务列表响应
*/
data class BacktestListResponse(
val list: List<BacktestTaskDto>,
val total: Long,
val page: Int,
val size: Int
)
/**
* 回测任务详情响应
*/
data class BacktestDetailResponse(
val task: BacktestTaskDto,
val config: BacktestConfigDto,
val statistics: BacktestStatisticsDto
)
/**
* 回测交易记录列表响应
*/
data class BacktestTradeListResponse(
val list: List<BacktestTradeDto>,
val total: Long,
val page: Int,
val size: Int
)
/**
* 回测进度响应
*/
data class BacktestProgressResponse(
val progress: Int, // 执行进度 (0-100)
val currentBalance: String, // 当前余额
val totalTrades: Int, // 总交易笔数
val status: String // 任务状态
)
/**
* 回测任务 DTO
*/
data class BacktestTaskDto(
val id: Long,
val taskName: String,
val leaderId: Long,
val leaderName: String?,
val leaderAddress: String?,
val initialBalance: String,
val finalBalance: String?,
val profitAmount: String?,
val profitRate: String?,
val backtestDays: Int,
val startTime: Long,
val endTime: Long?,
val status: String, // PENDING/RUNNING/COMPLETED/STOPPED/FAILED
val progress: Int,
val totalTrades: Int,
val createdAt: Long,
val executionStartedAt: Long?,
val executionFinishedAt: Long?
)
/**
* 回测配置 DTO
*/
data class BacktestConfigDto(
val copyMode: String,
val copyRatio: String,
val fixedAmount: String?,
val maxOrderSize: String,
val minOrderSize: String,
val maxDailyLoss: String,
val maxDailyOrders: Int,
val supportSell: Boolean,
val keywordFilterMode: String?,
val keywords: List<String>?,
val maxPositionValue: String?,
val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价
val maxPrice: String? // 最高价格(可选),NULL表示不限制最高价
)
/**
* 回测统计信息 DTO
*/
data class BacktestStatisticsDto(
val totalTrades: Int, // 总交易笔数
val buyTrades: Int, // 买入笔数
val sellTrades: Int, // 卖出笔数
val winTrades: Int, // 盈利交易笔数
val lossTrades: Int, // 亏损交易笔数
val winRate: String, // 胜率(%)
val maxProfit: String, // 最大单笔盈利
val maxLoss: String, // 最大单笔亏损
val maxDrawdown: String, // 最大回撤
val avgHoldingTime: Long? // 平均持仓时间(毫秒)
)
/**
* 回测交易记录 DTO
*/
data class BacktestTradeDto(
val id: Long,
val tradeTime: Long,
val marketId: String,
val marketTitle: String?,
val side: String, // BUY/SELL/SETTLEMENT
val outcome: String,
val outcomeIndex: Int?,
val quantity: String,
val price: String,
val amount: String,
val fee: String,
val profitLoss: String?,
val balanceAfter: String,
val leaderTradeId: String?
)
@@ -36,7 +36,6 @@ data class CopyTradingCreateRequest(
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
// 最大仓位配置
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
// 关键字过滤配置
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
@@ -75,7 +74,6 @@ data class CopyTradingUpdateRequest(
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
// 最大仓位配置
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
// 关键字过滤配置
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
@@ -151,7 +149,6 @@ data class CopyTradingDto(
val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价
// 最大仓位配置
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
// 关键字过滤配置
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
@@ -0,0 +1,169 @@
package com.wrbug.polymarketbot.dto
/**
* 尾盘策略创建请求
* 金额与价格使用 String,后端转为 BigDecimal
*/
data class CryptoTailStrategyCreateRequest(
val accountId: Long = 0L,
val name: String? = null,
val marketSlugPrefix: String = "",
val intervalSeconds: Int = 300,
val windowStartSeconds: Int = 0,
val windowEndSeconds: Int = 0,
val minPrice: String = "0",
val maxPrice: String? = null,
val amountMode: String = "RATIO",
val amountValue: String = "0",
/** 价差模式: NONE, FIXED, AUTO */
val spreadMode: String = "NONE",
/** 价差数值 */
val spreadValue: String? = null,
/** 价差方向: MIN=最小价差, MAX=最大价差 */
val spreadDirection: String = "MIN",
val enabled: Boolean = true
)
/**
* 尾盘策略更新请求
*/
data class CryptoTailStrategyUpdateRequest(
val strategyId: Long = 0L,
val name: String? = null,
val windowStartSeconds: Int? = null,
val windowEndSeconds: Int? = null,
val minPrice: String? = null,
val maxPrice: String? = null,
val amountMode: String? = null,
val amountValue: String? = null,
/** 价差模式: NONE, FIXED, AUTO */
val spreadMode: String? = null,
/** 价差数值 */
val spreadValue: String? = null,
/** 价差方向: MIN=最小价差, MAX=最大价差 */
val spreadDirection: String? = null,
val enabled: Boolean? = null
)
/**
* 尾盘策略列表请求
*/
data class CryptoTailStrategyListRequest(
val accountId: Long? = null,
val enabled: Boolean? = null
)
/**
* 尾盘策略 DTO(列表与详情)
*/
data class CryptoTailStrategyDto(
val id: Long = 0L,
val accountId: Long = 0L,
val name: String? = null,
val marketSlugPrefix: String = "",
val marketTitle: String? = null,
val intervalSeconds: Int = 0,
val windowStartSeconds: Int = 0,
val windowEndSeconds: Int = 0,
val minPrice: String = "0",
val maxPrice: String = "1",
val amountMode: String = "RATIO",
val amountValue: String = "0",
/** 价差模式: NONE, FIXED, AUTO */
val spreadMode: String = "NONE",
/** 价差数值 */
val spreadValue: String? = null,
/** 价差方向: MIN=最小价差(价差>=配置值触发), MAX=最大价差(价差<=配置值触发) */
val spreadDirection: String = "MIN",
val enabled: Boolean = true,
val lastTriggerAt: Long? = null,
/** 已实现总收益 USDC(已结算订单的 realizedPnl 之和) */
val totalRealizedPnl: String? = null,
/** 已结算笔数(用于胜率分母) */
val settledCount: Long = 0L,
/** 已结算中赢的笔数(用于胜率分子) */
val winCount: Long = 0L,
/** 胜率 0~1(已结算时 = winCount/settledCount,无结算为 null */
val winRate: String? = null,
val createdAt: Long = 0L,
val updatedAt: Long = 0L
)
/**
* 尾盘策略列表响应
*/
data class CryptoTailStrategyListResponse(
val list: List<CryptoTailStrategyDto> = emptyList()
)
/**
* 尾盘策略删除请求
*/
data class CryptoTailStrategyDeleteRequest(
val strategyId: Long = 0L
)
/**
* 触发记录列表请求
* @param startDate 开始日期(当天 00:00:00.000 的时间戳毫秒),为 null 表示不限制
* @param endDate 结束日期(当天 23:59:59.999 的时间戳毫秒),为 null 表示不限制
*/
data class CryptoTailStrategyTriggerListRequest(
val strategyId: Long = 0L,
val page: Int = 1,
val pageSize: Int = 20,
val status: String? = null,
val startDate: Long? = null,
val endDate: Long? = null
)
/**
* 触发记录 DTO
*/
data class CryptoTailStrategyTriggerDto(
val id: Long = 0L,
val strategyId: Long = 0L,
val periodStartUnix: Long = 0L,
val marketTitle: String? = null,
val outcomeIndex: Int = 0,
val triggerPrice: String = "0",
val amountUsdc: String = "0",
val orderId: String? = null,
val status: String = "success",
val failReason: String? = null,
/** 是否已结算 */
val resolved: Boolean = false,
/** 已实现盈亏 USDC(结算后有值) */
val realizedPnl: String? = null,
/** 市场赢家 outcome 索引(结算后有值) */
val winnerOutcomeIndex: Int? = null,
val settledAt: Long? = null,
val createdAt: Long = 0L
)
/**
* 触发记录分页响应
*/
data class CryptoTailStrategyTriggerListResponse(
val list: List<CryptoTailStrategyTriggerDto> = emptyList(),
val total: Long = 0L
)
/**
* 自动价差计算响应(按 30 根历史 K 线 + IQR 剔除后 × 0.7
*/
data class CryptoTailAutoMinSpreadResponse(
val minSpreadUp: String = "0",
val minSpreadDown: String = "0"
)
/**
* 5/15 分钟市场项(供前端选择市场)
*/
data class CryptoTailMarketOptionDto(
val slug: String = "",
val title: String = "",
val intervalSeconds: Int = 0,
val periodStartUnix: Long = 0L,
val endDate: String? = null
)
@@ -36,6 +36,13 @@ data class LeaderListRequest(
val category: String? = null // sports 或 crypto
)
/**
* Leader 余额请求
*/
data class LeaderBalanceRequest(
val leaderId: Long // LeaderID(必需)
)
/**
* Leader 信息响应
*/
@@ -47,6 +54,7 @@ data class LeaderDto(
val remark: String? = null, // Leader 备注(可选)
val website: String? = null, // Leader 网站(可选)
val copyTradingCount: Long = 0, // 跟单关系数量
val backtestCount: Long = 0, // 回测数量
val totalOrders: Long? = null, // 总订单数(可选)
val totalPnl: String? = null, // 总盈亏(可选)
val createdAt: Long,
@@ -61,3 +69,16 @@ data class LeaderListResponse(
val total: Long
)
/**
* Leader 余额响应
*/
data class LeaderBalanceResponse(
val leaderId: Long,
val leaderAddress: String,
val leaderName: String?,
val availableBalance: String, // 可用余额(RPC 查询的 USDC 余额)
val positionBalance: String, // 仓位余额(持仓总价值)
val totalBalance: String, // 总余额 = 可用余额 + 仓位余额
val positions: List<PositionDto> = emptyList()
)
@@ -0,0 +1,32 @@
package com.wrbug.polymarketbot.dto
import java.math.BigDecimal
/**
* 用户交易数据
* 用于回测功能,从 Polymarket API 获取的用户交易历史
*/
data class TradeData(
val tradeId: String, // 交易 ID
val marketId: String, // 市场 ID
val marketTitle: String?, // 市场标题
val marketSlug: String?, // 市场 Slug
val side: String, // 交易方向: BUY/SELL
val outcome: String, // 结果: YES/NO 或 outcomeIndex
val outcomeIndex: Int?, // 结果索引
val price: BigDecimal, // 成交价格
val size: BigDecimal, // 成交数量
val amount: BigDecimal, // 成交金额
val timestamp: Long // 交易时间戳
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is TradeData) return false
return tradeId == other.tradeId
}
override fun hashCode(): Int {
return tradeId.hashCode()
}
}
@@ -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(可选,明文存储)
@@ -0,0 +1,151 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
import java.math.BigDecimal
import com.wrbug.polymarketbot.util.toSafeBigDecimal
/**
* 回测任务实体
*/
@Entity
@Table(name = "backtest_task")
data class BacktestTask(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "task_name", nullable = false, length = 100)
val taskName: String,
@Column(name = "leader_id", nullable = false)
val leaderId: Long,
// 回测参数
@Column(name = "initial_balance", nullable = false, precision = 20, scale = 8)
val initialBalance: BigDecimal,
@Column(name = "final_balance", precision = 20, scale = 8)
var finalBalance: BigDecimal? = null,
@Column(name = "profit_amount", precision = 20, scale = 8)
var profitAmount: BigDecimal? = null,
@Column(name = "profit_rate", precision = 10, scale = 4)
var profitRate: BigDecimal? = null, // 收益率(%)
@Column(name = "backtest_days", nullable = false)
val backtestDays: Int,
@Column(name = "start_time", nullable = false)
val startTime: Long, // 回测开始时间(历史时间),创建时计算;执行时以当前时间为基准用局部变量重算窗口
@Column(name = "end_time")
var endTime: Long? = null, // 回测结束时间(历史时间)
// 跟单配置 (复制CopyTrading表结构,但不包含 max_position_count)
@Column(name = "copy_mode", nullable = false, length = 10)
val copyMode: String = "RATIO", // "RATIO" 或 "FIXED"
@Column(name = "copy_ratio", nullable = false, precision = 20, scale = 8)
val copyRatio: BigDecimal = BigDecimal.ONE,
@Column(name = "fixed_amount", precision = 20, scale = 8)
val fixedAmount: BigDecimal? = null,
@Column(name = "max_order_size", nullable = false, precision = 20, scale = 8)
val maxOrderSize: BigDecimal = "1000".toSafeBigDecimal(),
@Column(name = "min_order_size", nullable = false, precision = 20, scale = 8)
val minOrderSize: BigDecimal = "1".toSafeBigDecimal(),
@Column(name = "max_daily_loss", nullable = false, precision = 20, scale = 8)
val maxDailyLoss: BigDecimal = "10000".toSafeBigDecimal(),
@Column(name = "max_daily_orders", nullable = false)
val maxDailyOrders: Int = 100,
@Column(name = "support_sell", nullable = false)
val supportSell: Boolean = true,
@Column(name = "keyword_filter_mode", nullable = false, length = 20)
val keywordFilterMode: String = "DISABLED", // DISABLED/WHITELIST/BLACKLIST
@Column(name = "keywords", columnDefinition = "JSON")
val keywords: String? = null,
@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, // 平均持仓时间(毫秒)
@Column(name = "data_source", length = 50)
var dataSource: String = "MIXED", // INTERNAL/API/MIXED
// 执行状态
@Column(name = "status", nullable = false, length = 20)
var status: String = "PENDING", // PENDING/RUNNING/COMPLETED/STOPPED/FAILED
@Column(name = "progress", nullable = false)
var progress: Int = 0, // 执行进度(0-100)
@Column(name = "total_trades", nullable = false)
var totalTrades: Int = 0,
@Column(name = "buy_trades", nullable = false)
var buyTrades: Int = 0,
@Column(name = "sell_trades", nullable = false)
var sellTrades: Int = 0,
@Column(name = "win_trades", nullable = false)
var winTrades: Int = 0,
@Column(name = "loss_trades", nullable = false)
var lossTrades: Int = 0,
@Column(name = "win_rate", precision = 5, scale = 2)
var winRate: BigDecimal? = null, // 胜率(%)
@Column(name = "max_profit", precision = 20, scale = 8)
var maxProfit: BigDecimal? = null, // 最大单笔盈利
@Column(name = "max_loss", precision = 20, scale = 8)
var maxLoss: BigDecimal? = null, // 最大单笔亏损
@Column(name = "max_drawdown", precision = 20, scale = 8)
var maxDrawdown: BigDecimal? = null, // 最大回撤
@Column(name = "error_message", columnDefinition = "TEXT")
var errorMessage: String? = null,
// 时间字段
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@Column(name = "execution_started_at")
var executionStartedAt: Long? = null,
@Column(name = "execution_finished_at")
var executionFinishedAt: Long? = null,
@Column(name = "updated_at", nullable = false)
var updatedAt: Long = System.currentTimeMillis(),
@Column(name = "last_processed_trade_time")
var lastProcessedTradeTime: Long? = null,
@Column(name = "last_processed_trade_index")
var lastProcessedTradeIndex: Int? = null,
@Column(name = "processed_trade_count")
var processedTradeCount: Int = 0
)
@@ -0,0 +1,62 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
import java.math.BigDecimal
/**
* 回测交易记录实体
* 用于记录回测过程中的每笔模拟交易
*/
@Entity
@Table(name = "backtest_trade")
data class BacktestTrade(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "backtest_task_id", nullable = false)
val backtestTaskId: Long,
@Column(name = "trade_time", nullable = false)
val tradeTime: Long,
@Column(name = "market_id", nullable = false, length = 100)
val marketId: String,
@Column(name = "market_title", length = 500)
val marketTitle: String? = null,
@Column(name = "side", nullable = false, length = 20)
val side: String, // BUY/SELL/SETTLEMENT
@Column(name = "outcome", nullable = false, length = 50)
val outcome: String, // YES/NO 或 outcomeIndex
@Column(name = "outcome_index")
val outcomeIndex: Int? = null, // 结果索引(0, 1, 2, ...),支持多元市场
@Column(name = "quantity", nullable = false, precision = 20, scale = 8)
val quantity: BigDecimal,
@Column(name = "price", nullable = false, precision = 20, scale = 8)
val price: BigDecimal,
@Column(name = "amount", nullable = false, precision = 20, scale = 8)
val amount: BigDecimal,
@Column(name = "fee", nullable = false, precision = 20, scale = 8)
val fee: BigDecimal = BigDecimal.ZERO, // 手续费(回测不计算,默认为0
@Column(name = "profit_loss", precision = 20, scale = 8)
val profitLoss: BigDecimal? = null, // 盈亏(仅卖出时)
@Column(name = "balance_after", nullable = false, precision = 20, scale = 8)
val balanceAfter: BigDecimal, // 交易后余额
@Column(name = "leader_trade_id", length = 100)
val leaderTradeId: String? = null, // Leader 原始交易ID
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis()
)
@@ -83,9 +83,6 @@ data class CopyTrading(
@Column(name = "max_position_value", precision = 20, scale = 8)
val maxPositionValue: BigDecimal? = null, // 最大仓位金额(USDC),NULL表示不启用
@Column(name = "max_position_count")
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
// 关键字过滤配置
@Column(name = "keyword_filter_mode", nullable = false, length = 20)
val keywordFilterMode: String = "DISABLED", // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
@@ -0,0 +1,73 @@
package com.wrbug.polymarketbot.entity
import com.wrbug.polymarketbot.enums.SpreadDirection
import com.wrbug.polymarketbot.enums.SpreadDirectionConverter
import com.wrbug.polymarketbot.enums.SpreadMode
import com.wrbug.polymarketbot.enums.SpreadModeConverter
import jakarta.persistence.*
import java.math.BigDecimal
/**
* 加密市场尾盘策略实体
* 5/15 分钟 Up or Down 市场,在周期内时间窗口、价格进入区间时市价买入
*/
@Entity
@Table(name = "crypto_tail_strategy")
data class CryptoTailStrategy(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "account_id", nullable = false)
val accountId: Long = 0L,
@Column(name = "name", length = 255)
val name: String? = null,
@Column(name = "market_slug_prefix", nullable = false, length = 64)
val marketSlugPrefix: String = "",
@Column(name = "interval_seconds", nullable = false)
val intervalSeconds: Int = 300,
@Column(name = "window_start_seconds", nullable = false)
val windowStartSeconds: Int = 0,
@Column(name = "window_end_seconds", nullable = false)
val windowEndSeconds: Int = 0,
@Column(name = "min_price", nullable = false, precision = 20, scale = 8)
val minPrice: BigDecimal = BigDecimal.ONE,
@Column(name = "max_price", nullable = false, precision = 20, scale = 8)
val maxPrice: BigDecimal = BigDecimal.ONE,
@Column(name = "amount_mode", nullable = false, length = 10)
val amountMode: String = "RATIO",
@Column(name = "amount_value", nullable = false, precision = 20, scale = 8)
val amountValue: BigDecimal = BigDecimal.ZERO,
/** 价差模式: NONE=不校验, FIXED=固定值, AUTO=历史计算 */
@Convert(converter = SpreadModeConverter::class)
@Column(name = "spread_mode", nullable = false, columnDefinition = "TINYINT")
val spreadMode: SpreadMode = SpreadMode.NONE,
/** 价差数值(FIXED 时必填;AUTO 时可存计算值) */
@Column(name = "spread_value", precision = 20, scale = 8)
val spreadValue: BigDecimal? = null,
/** 价差方向: MIN=最小价差(价差>=配置值触发),MAX=最大价差(价差<=配置值触发) */
@Convert(converter = SpreadDirectionConverter::class)
@Column(name = "spread_direction", nullable = false, columnDefinition = "TINYINT")
val spreadDirection: SpreadDirection = SpreadDirection.MIN,
@Column(name = "enabled", nullable = false)
val enabled: Boolean = true,
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@Column(name = "updated_at", nullable = false)
var updatedAt: Long = System.currentTimeMillis()
)
@@ -0,0 +1,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"),
@@ -231,7 +238,33 @@ enum class ErrorCode(
SERVER_ORDER_TRACKING_PROCESS_FAILED(5901, "处理订单跟踪失败", "error.server.order_tracking_process_failed"),
SERVER_ORDER_TRACKING_BUY_FAILED(5902, "处理买入订单失败", "error.server.order_tracking_buy_failed"),
SERVER_ORDER_TRACKING_SELL_FAILED(5903, "处理卖出订单失败", "error.server.order_tracking_sell_failed"),
SERVER_ORDER_TRACKING_MATCH_FAILED(5904, "订单匹配失败", "error.server.order_tracking_match_failed");
SERVER_ORDER_TRACKING_MATCH_FAILED(5904, "订单匹配失败", "error.server.order_tracking_match_failed"),
// 回测服务错误 (4601-4699)
BACKTEST_TASK_NOT_FOUND(4601, "回测任务不存在", "error.backtest.task_not_found"),
BACKTEST_LEADER_NOT_FOUND(4602, "Leader不存在", "error.backtest.leader_not_found"),
BACKTEST_DAYS_INVALID(4603, "回测天数超出限制", "error.backtest.days_invalid"),
BACKTEST_INITIAL_BALANCE_INVALID(4604, "初始金额无效", "error.backtest.initial_balance_invalid"),
BACKTEST_TASK_RUNNING(4605, "回测任务正在运行,无法删除", "error.backtest.task_running"),
BACKTEST_TASK_NOT_COMPLETED(4606, "仅支持对已完成的回测任务重新测试", "error.backtest.task_not_completed"),
SERVER_BACKTEST_CREATE_FAILED(5603, "创建回测任务失败", "error.server.backtest_create_failed"),
SERVER_BACKTEST_UPDATE_FAILED(5604, "更新回测任务失败", "error.server.backtest_update_failed"),
SERVER_BACKTEST_DELETE_FAILED(5605, "删除回测任务失败", "error.server.backtest_delete_failed"),
SERVER_BACKTEST_LIST_FETCH_FAILED(5606, "查询回测列表失败", "error.server.backtest_list_fetch_failed"),
SERVER_BACKTEST_DETAIL_FETCH_FAILED(5607, "查询回测详情失败", "error.server.backtest_detail_fetch_failed"),
SERVER_BACKTEST_TRADES_FETCH_FAILED(5608, "查询回测交易记录失败", "error.server.backtest_trades_fetch_failed"),
SERVER_BACKTEST_EXECUTE_FAILED(5609, "回测执行失败", "error.server.backtest_execute_failed"),
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"),
// 尾盘策略服务 (5620-5629)
SERVER_CRYPTO_TAIL_STRATEGY_CREATE_FAILED(5620, "创建尾盘策略失败", "error.server.crypto_tail_strategy_create_failed"),
SERVER_CRYPTO_TAIL_STRATEGY_UPDATE_FAILED(5621, "更新尾盘策略失败", "error.server.crypto_tail_strategy_update_failed"),
SERVER_CRYPTO_TAIL_STRATEGY_DELETE_FAILED(5622, "删除尾盘策略失败", "error.server.crypto_tail_strategy_delete_failed"),
SERVER_CRYPTO_TAIL_STRATEGY_LIST_FETCH_FAILED(5623, "查询尾盘策略列表失败", "error.server.crypto_tail_strategy_list_fetch_failed"),
SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED(5624, "查询触发记录失败", "error.server.crypto_tail_strategy_triggers_fetch_failed");
companion object {
/**
@@ -0,0 +1,50 @@
package com.wrbug.polymarketbot.enums
/**
* 价差方向枚举
*/
enum class SpreadDirection(val value: Int, val description: String) {
/**
* 最小价差:价差 >= 配置值时触发,买入价固定 0.99
*/
MIN(0, "最小价差"),
/**
* 最大价差:价差 <= 配置值时触发,买入价 = 触发价 + 0.02
*/
MAX(1, "最大价差");
companion object {
/**
* 从数值解析价差方向
*/
fun fromValue(value: Int?): SpreadDirection {
if (value == null) {
return MIN // 默认返回 MIN
}
return values().find { it.value == value }
?: throw IllegalArgumentException("未知的价差方向: $value")
}
/**
* 安全地从数值解析价差方向,解析失败返回默认值
*/
fun fromValueOrDefault(value: Int?, default: SpreadDirection = MIN): SpreadDirection {
if (value == null) {
return default
}
return values().find { it.value == value } ?: default
}
/**
* 从字符串解析价差方向(兼容旧逻辑)
*/
fun fromString(value: String?): SpreadDirection {
if (value.isNullOrBlank()) {
return MIN
}
return values().find { it.name.equals(value, ignoreCase = true) }
?: throw IllegalArgumentException("未知的价差方向: $value")
}
}
}
@@ -0,0 +1,20 @@
package com.wrbug.polymarketbot.enums
import jakarta.persistence.AttributeConverter
import jakarta.persistence.Converter
/**
* SpreadDirection 枚举的 JPA 转换器
* 数据库存储为 TINYINT (0 = MIN, 1 = MAX)
*/
@Converter(autoApply = false)
class SpreadDirectionConverter : AttributeConverter<SpreadDirection, Int> {
override fun convertToDatabaseColumn(attribute: SpreadDirection?): Int {
return attribute?.value ?: SpreadDirection.MIN.value
}
override fun convertToEntityAttribute(dbData: Int?): SpreadDirection {
return SpreadDirection.fromValueOrDefault(dbData)
}
}
@@ -0,0 +1,55 @@
package com.wrbug.polymarketbot.enums
/**
* 价差模式枚举
*/
enum class SpreadMode(val value: Int, val description: String) {
/**
* 不校验价差
*/
NONE(0, ""),
/**
* 固定值:用户输入一个数值
*/
FIXED(1, "固定"),
/**
* 自动:系统按历史 K 线计算建议价差
*/
AUTO(2, "自动");
companion object {
/**
* 从数值解析价差模式
*/
fun fromValue(value: Int?): SpreadMode {
if (value == null) {
return NONE // 默认返回 NONE
}
return values().find { it.value == value }
?: throw IllegalArgumentException("未知的价差模式: $value")
}
/**
* 安全地从数值解析价差模式,解析失败返回默认值
*/
fun fromValueOrDefault(value: Int?, default: SpreadMode = NONE): SpreadMode {
if (value == null) {
return default
}
return values().find { it.value == value } ?: default
}
/**
* 从字符串解析价差模式(兼容旧逻辑)
*/
fun fromString(value: String?): SpreadMode {
if (value.isNullOrBlank()) {
return NONE
}
return values().find { it.name.equals(value, ignoreCase = true) }
?: throw IllegalArgumentException("未知的价差模式: $value")
}
}
}
@@ -0,0 +1,20 @@
package com.wrbug.polymarketbot.enums
import jakarta.persistence.AttributeConverter
import jakarta.persistence.Converter
/**
* SpreadMode 枚举的 JPA 转换器
* 数据库存储为 TINYINT (0 = NONE, 1 = FIXED, 2 = AUTO)
*/
@Converter(autoApply = false)
class SpreadModeConverter : AttributeConverter<SpreadMode, Int> {
override fun convertToDatabaseColumn(attribute: SpreadMode?): Int {
return attribute?.value ?: SpreadMode.NONE.value
}
override fun convertToEntityAttribute(dbData: Int?): SpreadMode {
return SpreadMode.fromValueOrDefault(dbData)
}
}
@@ -0,0 +1,51 @@
package com.wrbug.polymarketbot.enums
/**
* 钱包类型枚举
*/
enum class WalletType(val value: String, val description: String) {
/**
* Magic 钱包(邮箱/OAuth 登录)
* 使用 PROXY 代理合约,通过 Builder Relayer 执行 Gasless 交易
*/
MAGIC("magic", "Magic(邮箱/OAuth登录)"),
/**
* Safe 钱包(MetaMask 等 Web3 钱包)
* 使用 Gnosis Safe 代理合约,支持 Builder Relayer Gasless 或手动交易
*/
SAFE("safe", "SafeWeb3钱包)");
companion object {
/**
* 从字符串值解析钱包类型(不区分大小写)
*/
fun fromString(value: String?): WalletType {
if (value.isNullOrBlank()) {
return SAFE // 默认返回 SAFE
}
return values().find { it.value.equals(value, ignoreCase = true) }
?: throw IllegalArgumentException("未知的钱包类型: $value")
}
/**
* 安全地从字符串值解析钱包类型(不区分大小写),解析失败返回默认值
*/
fun fromStringOrDefault(value: String?, default: WalletType = SAFE): WalletType {
if (value.isNullOrBlank()) {
return default
}
return values().find { it.value.equals(value, ignoreCase = true) } ?: default
}
/**
* 检查字符串是否为有效的钱包类型
*/
fun isValid(value: String?): Boolean {
if (value.isNullOrBlank()) {
return false
}
return values().any { it.value.equals(value, ignoreCase = true) }
}
}
}
@@ -0,0 +1,8 @@
package com.wrbug.polymarketbot.event
import org.springframework.context.ApplicationEvent
/**
* 尾盘策略创建/更新/启用状态变更后发布,用于立即触发一轮执行检查。
*/
class CryptoTailStrategyChangedEvent(source: Any) : ApplicationEvent(source)
@@ -29,5 +29,10 @@ interface AccountRepository : JpaRepository<Account, Long> {
* 检查钱包地址是否存在
*/
fun existsByWalletAddress(walletAddress: String): Boolean
/**
* 检查代理地址是否存在
*/
fun existsByProxyAddress(proxyAddress: String): Boolean
}
@@ -0,0 +1,63 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.BacktestTask
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.stereotype.Repository
/**
* 回测任务Repository
*/
@Repository
interface BacktestTaskRepository : JpaRepository<BacktestTask, Long> {
/**
* 根据 Leader ID 查询回测任务
*/
fun findByLeaderId(leaderId: Long): List<BacktestTask>
/**
* 根据状态查询回测任务
*/
fun findByStatus(status: String): List<BacktestTask>
/**
* 根据 Leader ID 和状态查询回测任务
*/
fun findByLeaderIdAndStatus(leaderId: Long, status: String): List<BacktestTask>
/**
* 根据 Leader ID、收益率排序查询
*/
@Query("SELECT t FROM BacktestTask t WHERE t.leaderId = :leaderId AND t.status = :status ORDER BY t.profitRate DESC")
fun findByLeaderIdAndStatusOrderByProfitRateDesc(leaderId: Long, status: String): List<BacktestTask>
/**
* 根据状态和创建时间倒序查询
*/
@Query("SELECT t FROM BacktestTask t WHERE t.status = :status ORDER BY t.createdAt DESC")
fun findByStatusOrderByCreatedAtDesc(status: String): List<BacktestTask>
/**
* 更新回测任务状态
*/
@Modifying
@Query("UPDATE BacktestTask t SET t.status = :status, t.updatedAt = :updatedAt WHERE t.id = :id")
fun updateStatus(id: Long, status: String, updatedAt: Long = System.currentTimeMillis())
/**
* 更新回测任务状态和错误信息
*/
@Modifying
@Query("UPDATE BacktestTask t SET t.status = :status, t.errorMessage = :errorMessage, t.updatedAt = :updatedAt WHERE t.id = :id")
fun updateStatusAndError(id: Long, status: String, errorMessage: String?, updatedAt: Long = System.currentTimeMillis())
/**
* 更新回测任务进度
*/
@Modifying
@Query("UPDATE BacktestTask t SET t.progress = :progress, t.updatedAt = :updatedAt WHERE t.id = :id")
fun updateProgress(id: Long, progress: Int, updatedAt: Long = System.currentTimeMillis())
}
@@ -0,0 +1,38 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.BacktestTrade
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.stereotype.Repository
/**
* 回测交易记录Repository
*/
@Repository
interface BacktestTradeRepository : JpaRepository<BacktestTrade, Long> {
/**
* 根据回测任务ID查询所有交易记录
*/
fun findByBacktestTaskIdOrderByTradeTime(backtestTaskId: Long): List<BacktestTrade>
/**
* 根据回测任务ID分页查询交易记录
*/
@Query("SELECT t FROM BacktestTrade t WHERE t.backtestTaskId = :backtestTaskId ORDER BY t.tradeTime")
fun findByBacktestTaskId(
backtestTaskId: Long,
pageable: org.springframework.data.domain.Pageable
): org.springframework.data.domain.Page<BacktestTrade>
/**
* 根据回测任务ID统计交易数量
*/
fun countByBacktestTaskId(backtestTaskId: Long): Long
/**
* 删除回测任务的所有交易记录(由级联删除处理)
*/
fun deleteByBacktestTaskId(backtestTaskId: Long)
}
@@ -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,401 @@ class AccountService(
}
}
/**
* 检查代理地址选项(用于账户导入前选择代理类型)
* 私钥导入:返回 Magic 和 Safe 两个选项
* 助记词导入:仅返回 Safe 选项
*/
suspend fun checkProxyOptions(request: CheckProxyOptionsRequest): Result<CheckProxyOptionsResponse> {
return try {
// 1. 验证钱包地址格式
if (!isValidWalletAddress(request.walletAddress)) {
return Result.failure(IllegalArgumentException("无效的钱包地址格式"))
}
// 2. 验证至少提供了私钥或助记词之一
if (request.privateKey.isNullOrBlank() && request.mnemonic.isNullOrBlank()) {
return Result.failure(IllegalArgumentException("必须提供私钥或助记词"))
}
val options = mutableListOf<ProxyOptionDto>()
// 3. 判断导入类型
val isPrivateKeyImport = !request.privateKey.isNullOrBlank()
if (isPrivateKeyImport) {
// 私钥导入:并行获取 Magic 和 Safe 代理地址及资产
coroutineScope {
val magicDeferred = async {
try {
val proxyAddress = blockchainService.getProxyAddress(request.walletAddress, WalletType.MAGIC).getOrNull()
if (proxyAddress != null) {
val balance = blockchainService.getWalletBalance(proxyAddress).getOrNull()
ProxyOptionDto(
walletType = WalletType.MAGIC.value,
proxyAddress = proxyAddress,
descriptionKey = "accountImport.proxyOption.magic.description",
availableBalance = balance?.availableBalance ?: "0",
positionBalance = balance?.positionBalance ?: "0",
totalBalance = balance?.totalBalance ?: "0",
positionCount = balance?.positions?.size ?: 0,
hasAssets = (balance?.availableBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
(balance?.positionBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
(balance?.positions?.isNotEmpty() == true),
error = null
)
} else {
ProxyOptionDto(
walletType = "magic",
proxyAddress = "",
descriptionKey = "accountImport.proxyOption.magic.description",
availableBalance = "0",
positionBalance = "0",
totalBalance = "0",
positionCount = 0,
hasAssets = false,
error = "获取 Magic 代理地址失败"
)
}
} catch (e: Exception) {
logger.warn("获取 Magic 代理地址或资产失败: ${e.message}", e)
ProxyOptionDto(
walletType = "magic",
proxyAddress = blockchainService.calculateMagicProxyAddress(request.walletAddress),
descriptionKey = "accountImport.proxyOption.magic.description",
availableBalance = "0",
positionBalance = "0",
totalBalance = "0",
positionCount = 0,
hasAssets = false,
error = "获取资产信息失败: ${e.message}"
)
}
}
val safeDeferred = async {
try {
val proxyAddress = blockchainService.getProxyAddress(request.walletAddress, WalletType.SAFE).getOrNull()
if (proxyAddress != null) {
val balance = blockchainService.getWalletBalance(proxyAddress).getOrNull()
ProxyOptionDto(
walletType = WalletType.SAFE.value,
proxyAddress = proxyAddress,
descriptionKey = "accountImport.proxyOption.safe.description",
availableBalance = balance?.availableBalance ?: "0",
positionBalance = balance?.positionBalance ?: "0",
totalBalance = balance?.totalBalance ?: "0",
positionCount = balance?.positions?.size ?: 0,
hasAssets = (balance?.availableBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
(balance?.positionBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
(balance?.positions?.isNotEmpty() == true),
error = null
)
} else {
ProxyOptionDto(
walletType = "safe",
proxyAddress = "",
descriptionKey = "accountImport.proxyOption.safe.description",
availableBalance = "0",
positionBalance = "0",
totalBalance = "0",
positionCount = 0,
hasAssets = false,
error = "获取 Safe 代理地址失败"
)
}
} catch (e: Exception) {
logger.warn("获取 Safe 代理地址或资产失败: ${e.message}", e)
ProxyOptionDto(
walletType = "safe",
proxyAddress = "",
descriptionKey = "accountImport.proxyOption.safe.description",
availableBalance = "0",
positionBalance = "0",
totalBalance = "0",
positionCount = 0,
hasAssets = false,
error = "获取资产信息失败: ${e.message}"
)
}
}
val magicOption = magicDeferred.await()
val safeOption = safeDeferred.await()
// Safe 在前,Magic 在后
options.add(safeOption)
options.add(magicOption)
}
} else {
// 助记词导入:仅获取 Safe 代理地址及资产
try {
val proxyAddress = blockchainService.getProxyAddress(request.walletAddress, WalletType.SAFE).getOrNull()
if (proxyAddress != null) {
val balance = blockchainService.getWalletBalance(proxyAddress).getOrNull()
options.add(
ProxyOptionDto(
walletType = "safe",
proxyAddress = proxyAddress,
descriptionKey = "accountImport.proxyOption.safe.description",
availableBalance = balance?.availableBalance ?: "0",
positionBalance = balance?.positionBalance ?: "0",
totalBalance = balance?.totalBalance ?: "0",
positionCount = balance?.positions?.size ?: 0,
hasAssets = (balance?.availableBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
(balance?.positionBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
(balance?.positions?.isNotEmpty() == true),
error = null
)
)
} else {
options.add(
ProxyOptionDto(
walletType = "safe",
proxyAddress = "",
descriptionKey = "accountImport.proxyOption.safe.description",
availableBalance = "0",
positionBalance = "0",
totalBalance = "0",
positionCount = 0,
hasAssets = false,
error = "获取 Safe 代理地址失败"
)
)
}
} catch (e: Exception) {
logger.warn("获取 Safe 代理地址或资产失败: ${e.message}", e)
options.add(
ProxyOptionDto(
walletType = "safe",
proxyAddress = "",
descriptionKey = "accountImport.proxyOption.safe.description",
availableBalance = "0",
positionBalance = "0",
totalBalance = "0",
positionCount = 0,
hasAssets = false,
error = "获取资产信息失败: ${e.message}"
)
)
}
}
Result.success(CheckProxyOptionsResponse(options = options))
} catch (e: Exception) {
logger.error("检查代理地址选项失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* Polymarket 代币批准检查:USDC.e 需授权的 spender 合约地址(Polygon 主网)
* 来源:Polymarket/magic-safe-builder-example README §6 Token Approvals
* 及 neg-risk-ctf-adapter 仓库 addresses.json (chainId 137)
*/
private val setupApprovalSpenders = mapOf(
"CTF_CONTRACT" to "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045", // Conditional Tokens
"CTF_EXCHANGE" to "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E", // 普通市场交易所
"NEG_RISK_EXCHANGE" to "0xC5d563A36AE78145C45a50134d48A1215220f80a", // 负风险市场交易所
"NEG_RISK_ADAPTER" to "0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296" // 负风险适配器(非 WCOL 地址)
)
/** USDC 精度(6 位小数) */
private val usdcDecimals = java.math.BigDecimal("1000000")
/** ERC20 无限授权额度(type(uint256).max),Polymarket 默认使用无限授权 */
private val unlimitedAllowance = BigInteger("115792089237316195423570985008687907853269984665640564039457584007913129639935")
/**
* 检查账户设置状态(代理部署、交易启用、代币批准)
* @param accountId 账户 ID
* @return AccountSetupStatusDto
*/
suspend fun checkAccountSetupStatus(accountId: Long): Result<AccountSetupStatusDto> {
return try {
if (accountId <= 0) {
return Result.failure(IllegalArgumentException("账户 ID 无效"))
}
val account = accountRepository.findById(accountId).orElse(null)
?: return Result.failure(IllegalArgumentException("账户不存在"))
val proxyAddress = account.proxyAddress
if (proxyAddress.isBlank()) {
return Result.success(
AccountSetupStatusDto(
proxyDeployed = false,
tradingEnabled = account.apiKey != null && account.apiSecret != null && account.apiPassphrase != null,
tokensApproved = false,
approvalDetails = null,
error = "代理地址为空"
)
)
}
// 步骤1:代理钱包是否已部署
val proxyDeployed = blockchainService.isProxyDeployed(proxyAddress)
// 步骤2:交易是否已启用(API 凭证是否已配置)
val tradingEnabled = account.apiKey != null &&
account.apiSecret != null &&
account.apiPassphrase != null
// 步骤3:代币是否已批准(USDC 对各 spender 的 allowance,默认无限授权)
val approvalDetails = mutableMapOf<String, String>()
var tokensApproved = true
for ((name, spender) in setupApprovalSpenders) {
val allowanceResult = blockchainService.getUsdcAllowance(proxyAddress, spender)
val allowance = allowanceResult.getOrNull() ?: BigInteger.ZERO
val displayAmount = if (allowance >= unlimitedAllowance) {
"unlimited"
} else {
java.math.BigDecimal(allowance).divide(usdcDecimals, 6, java.math.RoundingMode.DOWN).toPlainString()
}
approvalDetails[name] = displayAmount
if (allowance <= BigInteger.ZERO) {
tokensApproved = false
}
}
Result.success(
AccountSetupStatusDto(
proxyDeployed = proxyDeployed,
tradingEnabled = tradingEnabled,
tokensApproved = tokensApproved,
approvalDetails = approvalDetails,
error = null
)
)
} catch (e: Exception) {
logger.error("检查账户设置状态失败: accountId=$accountId, ${e.message}", e)
Result.failure(e)
}
}
/** 步骤1 跳转 URL(代理部署需在 Polymarket 完成) */
private val setupStep1RedirectUrl = "https://polymarket.com/settings/wallet"
/**
* 执行设置步骤(由后端实现或返回跳转)
* 步骤1:仅返回跳转 URL,由用户前往 Polymarket 完成部署
* 步骤2:创建/派生 API Key 并更新账户
* 步骤3:通过代理钱包批量执行 USDC 授权
*/
suspend fun executeSetupStep(accountId: Long, step: Int): Result<ExecuteSetupStepResponse> {
return try {
if (accountId <= 0) {
return Result.failure(IllegalArgumentException("账户 ID 无效"))
}
val account = accountRepository.findById(accountId).orElse(null)
?: return Result.failure(IllegalArgumentException("账户不存在"))
when (step) {
1 -> {
val walletType = WalletType.fromStringOrDefault(account.walletType, WalletType.MAGIC)
if (walletType == WalletType.MAGIC) {
Result.success(
ExecuteSetupStepResponse(
success = false,
redirectUrl = setupStep1RedirectUrl
)
)
} else {
val proxyAddress = account.proxyAddress
if (proxyAddress.isBlank()) {
return Result.failure(IllegalArgumentException("代理地址为空"))
}
val alreadyDeployed = blockchainService.isProxyDeployed(proxyAddress)
if (alreadyDeployed) {
Result.success(ExecuteSetupStepResponse(success = true))
} else {
val privateKey = decryptPrivateKey(account)
val deployResult = relayClientService.deploySafeViaBuilderRelayer(
privateKey = privateKey,
proxyAddress = proxyAddress,
fromAddress = account.walletAddress
)
deployResult.fold(
onSuccess = { txHash ->
Result.success(
ExecuteSetupStepResponse(
success = true,
transactionHash = txHash
)
)
},
onFailure = { e ->
logger.error("Safe 部署失败: accountId=$accountId, ${e.message}", e)
Result.failure(e)
}
)
}
}
}
2 -> {
val privateKey = decryptPrivateKey(account)
val result = apiKeyService.createOrDeriveApiKey(
privateKey = privateKey,
walletAddress = account.walletAddress,
chainId = 137L
)
if (result.isFailure) {
val e = result.exceptionOrNull()
logger.error("启用交易(API Key)失败: accountId=$accountId, ${e?.message}", e)
return Result.failure(e ?: IllegalStateException("获取 API Key 失败"))
}
val creds = result.getOrNull()
?: return Result.failure(IllegalStateException("API Key 返回为空"))
val encryptedSecret = creds.secret?.let { cryptoUtils.encrypt(it) }
val encryptedPassphrase = creds.passphrase?.let { cryptoUtils.encrypt(it) }
val updated = account.copy(
apiKey = creds.apiKey,
apiSecret = encryptedSecret,
apiPassphrase = encryptedPassphrase,
updatedAt = System.currentTimeMillis()
)
accountRepository.save(updated)
orderPushService.refreshSubscriptions()
Result.success(ExecuteSetupStepResponse(success = true))
}
3 -> {
val proxyAddress = account.proxyAddress
if (proxyAddress.isBlank()) {
return Result.failure(IllegalArgumentException("代理地址为空,请先完成步骤1"))
}
val privateKey = decryptPrivateKey(account)
val walletType = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
val approveTxs = setupApprovalSpenders.values.map { spender ->
relayClientService.createUsdcApproveTx(spender, unlimitedAllowance)
}
val multiSendTx = relayClientService.createMultiSendTx(approveTxs)
val executeResult = relayClientService.execute(
privateKey = privateKey,
proxyAddress = proxyAddress,
safeTx = multiSendTx,
walletType = walletType
)
executeResult.fold(
onSuccess = { txHash ->
Result.success(
ExecuteSetupStepResponse(
success = true,
transactionHash = txHash
)
)
},
onFailure = { e ->
logger.error("代币授权执行失败: accountId=$accountId, ${e.message}", e)
Result.failure(e)
}
)
}
else -> Result.failure(IllegalArgumentException("无效的步骤: $step,应为 1、2 或 3"))
}
} catch (e: Exception) {
logger.error("执行设置步骤失败: accountId=$accountId, step=$step, ${e.message}", e)
Result.failure(e)
}
}
/**
* 更新账户信息
*/
@@ -278,7 +677,7 @@ class AccountService(
if (accountId == null) {
return Result.failure(IllegalArgumentException("账户ID不能为空"))
}
val account = accountRepository.findById(accountId).orElse(null)
?: return Result.failure(IllegalArgumentException("账户不存在"))
@@ -288,68 +687,19 @@ class AccountService(
return Result.failure(IllegalStateException("账户代理地址不存在,无法查询余额。请重新导入账户以获取代理地址"))
}
// 查询 USDC 余额和持仓信息
// 使用通用方法查询余额
val balanceResult = runBlocking {
try {
// 查询持仓信息(用于返回持仓列表)
// 使用代理地址查询持仓(Polymarket 使用代理地址存储持仓)
val positionsResult = blockchainService.getPositions(account.proxyAddress)
val positions = if (positionsResult.isSuccess) {
positionsResult.getOrNull()?.map { pos ->
PositionDto(
marketId = pos.conditionId ?: "",
side = pos.outcome ?: "",
quantity = pos.size?.toString() ?: "0",
avgPrice = pos.avgPrice?.toString() ?: "0",
currentValue = pos.currentValue?.toString() ?: "0",
pnl = pos.cashPnl?.toString()
)
} ?: emptyList()
} else {
logger.warn("持仓信息查询失败: ${positionsResult.exceptionOrNull()?.message}")
emptyList()
}
// 使用 /value 接口获取仓位总价值(而不是累加)
val positionBalanceResult = blockchainService.getTotalValue(account.proxyAddress)
val positionBalance = if (positionBalanceResult.isSuccess) {
positionBalanceResult.getOrNull() ?: "0"
} else {
logger.warn("仓位总价值查询失败: ${positionBalanceResult.exceptionOrNull()?.message}")
"0"
}
// 查询可用余额(通过 RPC 查询 USDC 余额)
// 必须使用代理地址查询
val availableBalanceResult = blockchainService.getUsdcBalance(
walletAddress = account.walletAddress,
proxyAddress = account.proxyAddress
)
val availableBalance = if (availableBalanceResult.isSuccess) {
availableBalanceResult.getOrNull() ?: throw Exception("USDC 余额查询返回空值")
} else {
// 如果 RPC 查询失败,返回错误(不返回 mock 数据)
val error = availableBalanceResult.exceptionOrNull()
logger.error("USDC 可用余额 RPC 查询失败: ${error?.message}")
throw Exception("USDC 可用余额查询失败: ${error?.message}。请确保已配置 Ethereum RPC URL")
}
// 计算总余额 = 可用余额 + 仓位余额
val totalBalance = availableBalance.toSafeBigDecimal().add(positionBalance.toSafeBigDecimal())
AccountBalanceResponse(
availableBalance = availableBalance,
positionBalance = positionBalance,
totalBalance = totalBalance.toPlainString(),
positions = positions
)
} catch (e: Exception) {
logger.error("查询余额失败: ${e.message}", e)
throw e
}
blockchainService.getWalletBalance(account.proxyAddress)
}
Result.success(balanceResult)
balanceResult.map { walletBalance: WalletBalanceResponse ->
AccountBalanceResponse(
availableBalance = walletBalance.availableBalance,
positionBalance = walletBalance.positionBalance,
totalBalance = walletBalance.totalBalance,
positions = walletBalance.positions
)
}
} catch (e: Exception) {
logger.error("查询账户余额失败", e)
Result.failure(e)
@@ -586,7 +936,38 @@ class AccountService(
throw RuntimeException("解密私钥失败: ${e.message}", e)
}
}
/**
* 轮询用:遍历所有账户,对代理地址 WCOL 余额 > 0 的执行解包为 USDC.e。
* 由 WcolUnwrapJobService 每 20 秒调用,赎回后无需在赎回流程内等待确认与解包。
*/
suspend fun runWcolUnwrapForAllAccounts() {
val accounts = accountRepository.findAllByOrderByCreatedAtAsc()
if (accounts.isEmpty()) return
for (account in accounts) {
try {
val privateKey = decryptPrivateKey(account)
val walletType = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
blockchainService.unwrapWcolForProxy(
privateKey = privateKey,
proxyAddress = account.proxyAddress,
walletType = walletType
).fold(
onSuccess = { txHash ->
if (txHash != null) {
logger.info("轮询解包 WCOL: accountId=${account.id}, proxy=${account.proxyAddress.take(10)}..., txHash=$txHash")
}
},
onFailure = { e ->
logger.warn("轮询解包 WCOL 失败 accountId=${account.id}: ${e.message}")
}
)
} catch (e: Exception) {
logger.warn("轮询解包 WCOL 跳过 accountId=${account.id}: ${e.message}")
}
}
}
/**
* 解密账户 API Secret
*/
@@ -876,7 +1257,7 @@ class AccountService(
"0"
}
// 11. 创建并签名订单(使用计算后的卖出数量)
// 11. 创建并签名订单(使用计算后的卖出数量,按账户钱包类型使用对应 signatureType
val signedOrder = try {
orderSigningService.createAndSignOrder(
privateKey = decryptedPrivateKey,
@@ -885,7 +1266,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
@@ -1238,13 +1619,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("赎回仓位列表不能为空"))
}
@@ -1266,7 +1640,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>>()
@@ -1309,7 +1693,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
@@ -1320,20 +1704,31 @@ class AccountService(
// 按市场分组(同一市场的仓位可以批量赎回)
val positionsByMarket = positions.groupBy { it.first.marketId }
// 对每个市场执行赎回
// 获取钱包类型
val walletTypeEnum = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
// 解密私钥(只需解密一次)
val decryptedPrivateKey = decryptPrivateKey(account)
// 执行赎回
var lastTxHash: String? = null
for ((marketId, marketPositions) in positionsByMarket) {
val indexSets = marketPositions.map { it.second }
// 解密私钥
val decryptedPrivateKey = decryptPrivateKey(account)
// Safe 钱包且有多个市场:使用 MultiSend 批量赎回
if (walletTypeEnum == WalletType.SAFE && positionsByMarket.size > 1) {
val redeemRequests = mutableListOf<Triple<String, List<BigInteger>, Boolean>>()
for ((marketId, marketPositions) in positionsByMarket) {
val indexSets = marketPositions.map { it.second }
val isNegRisk = marketService.getNegRiskByConditionId(marketId) == true
redeemRequests.add(Triple(marketId, indexSets, isNegRisk))
}
// 调用区块链服务赎回仓位
val redeemResult = blockchainService.redeemPositions(
logger.info("账户 $accountId: 使用 MultiSend 批量赎回 ${redeemRequests.size} 个市场")
val redeemResult = blockchainService.redeemPositionsBatch(
privateKey = decryptedPrivateKey,
proxyAddress = account.proxyAddress,
conditionId = marketId,
indexSets = indexSets
redeemRequests = redeemRequests,
walletType = walletTypeEnum
)
redeemResult.fold(
@@ -1341,11 +1736,38 @@ class AccountService(
lastTxHash = txHash
},
onFailure = { e ->
logger.error("账户 $accountId 市场 $marketId 赎回失败: ${e.message}", e)
return Result.failure(Exception("赎回失败: 账户 $accountId 市场 $marketId - ${e.message}"))
logger.error("账户 $accountId MultiSend 批量赎回失败: ${e.message}", e)
return Result.failure(Exception("赎回失败: 账户 $accountId - ${e.message}"))
}
)
} else {
// Magic 钱包或单个市场:逐笔赎回
for ((marketId, marketPositions) in positionsByMarket) {
val indexSets = marketPositions.map { it.second }
val isNegRisk = marketService.getNegRiskByConditionId(marketId) == true
val redeemResult = blockchainService.redeemPositions(
privateKey = decryptedPrivateKey,
proxyAddress = account.proxyAddress,
conditionId = marketId,
indexSets = indexSets,
isNegRisk = isNegRisk,
walletType = walletTypeEnum
)
redeemResult.fold(
onSuccess = { txHash ->
lastTxHash = txHash
},
onFailure = { e ->
logger.error("账户 $accountId 市场 $marketId 赎回失败: ${e.message}", e)
return Result.failure(Exception("赎回失败: 账户 $accountId 市场 $marketId - ${e.message}"))
}
)
}
}
// WCOL 解包由 WcolUnwrapJobService 每 20 秒轮询统一处理,赎回流程不再等待确认与解包
// 计算该账户的赎回总价值
val accountTotalValue = redeemedInfo.fold(BigDecimal.ZERO) { sum, info ->
@@ -1364,7 +1786,7 @@ class AccountService(
)
}
// 6. 发送赎回推送通知(异步,不阻塞)
// 7. 发送赎回推送通知(异步,不阻塞)
notificationScope.launch {
try {
// 获取当前语言设置
@@ -25,6 +25,7 @@ import com.wrbug.polymarketbot.service.common.MarketPriceService
import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
/**
* 仓位检查服务
@@ -77,7 +78,10 @@ class PositionCheckService(
// 同步锁,确保订阅任务的启动和停止是线程安全的
private val lock = Any()
// 防止 checkRedeemablePositions 重入:上一轮检查未完成时,新一轮轮询直接跳过
private val redeemCheckInProgress = AtomicBoolean(false)
/**
* 初始化服务订阅 PositionPollingService 的事件启动缓存清理任务
*/
@@ -328,18 +332,23 @@ class PositionCheckService(
/**
* 逻辑1处理待赎回仓位
https://clob.polymarket.com * 按照以下逻辑处理
* 按照以下逻辑处理
* 1. 无待赎回仓位跳过
* 2. (未配置apikey || autoredeem==false) && 有待赎回的仓位发送通知事件
* 3. (已配置) && 有待赎回的仓位处理订单逻辑
* 防重入上一轮检查未完成时本轮直接跳过避免并发赎回
*/
private suspend fun checkRedeemablePositions(redeemablePositions: List<AccountPositionDto>) {
if (!redeemCheckInProgress.compareAndSet(false, true)) {
logger.debug("跳过本次待赎回仓位检查:上一次检查尚未完成")
return
}
try {
// 1. 无待赎回仓位:跳过
if (redeemablePositions.isEmpty()) {
return
}
// 检查系统级别的自动赎回配置
val autoRedeemEnabled = systemConfigService.isAutoRedeemEnabled()
val apiKeyConfigured = relayClientService.isBuilderApiKeyConfigured()
@@ -373,21 +382,24 @@ class PositionCheckService(
}
return // 未配置时直接返回,不进行后续处理
}
// Builder Relayer 配额冷却期内不再发起赎回(如 API 返回 quota exceeded, resets in N seconds
if (relayClientService.isBuilderRelayerQuotaBlocked()) {
val remaining = relayClientService.getBuilderRelayerQuotaBlockedRemainingSeconds()
logger.info("Builder Relayer 配额冷却中,跳过本次自动赎回,约 ${remaining} 秒后恢复")
return
}
// 3. (已配置) && 有待赎回的仓位:处理订单逻辑
// 自动赎回已开启且已配置 API Key,按账户分组进行赎回处理
// 先执行赎回,赎回成功后再查找订单并更新订单状态
val positionsByAccount = redeemablePositions.groupBy { it.accountId }
for ((accountId, positions) in positionsByAccount) {
// 查找该账户下所有启用的跟单配置
// 查找该账户下所有启用的跟单配置(仅用于赎回成功后更新跟单订单状态;无跟单配置的账户如尾盘策略账户也会执行赎回)
val copyTradings = copyTradingRepository.findByAccountId(accountId)
.filter { it.enabled }
if (copyTradings.isEmpty()) {
continue
}
// 过滤掉已经处理过的仓位(去重,避免重复赎回)
val now = System.currentTimeMillis()
val positionsToRedeem = positions.filter { position ->
@@ -430,27 +442,20 @@ class PositionCheckService(
processedRedeemablePositions[positionKey] = now
}
// 赎回成功后,再查找订单并更新订单状态
// 赎回成功后,按每个跟单配置分别查找未卖出订单并更新状态
// 同一账户同一市场可能同时跟多个 Leader,需按 copyTradingId 分别生成自动卖出记录(如 leader1 对应 20 shareleader2 对应 16 share
for (position in positionsToRedeem) {
// 查找相同仓位的未卖出订单(remaining_quantity > 0
val unmatchedOrders = mutableListOf<CopyOrderTracking>()
for (copyTrading in copyTradings) {
if (position.outcomeIndex != null) {
val orders = copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndex(
copyTrading.id!!,
position.marketId,
position.outcomeIndex
)
unmatchedOrders.addAll(orders)
}
if (position.outcomeIndex == null) {
continue
}
// 如果有未卖出订单,更新订单状态
if (unmatchedOrders.isNotEmpty()) {
// 从订单中获取 copyTradingId(所有订单应该有相同的 copyTradingId
val copyTradingId = unmatchedOrders.firstOrNull()?.copyTradingId
if (copyTradingId != null) {
updateOrdersAsSoldAfterRedeem(unmatchedOrders, position, copyTradingId)
for (copyTrading in copyTradings) {
val orders = copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndex(
copyTrading.id!!,
position.marketId,
position.outcomeIndex
)
if (orders.isNotEmpty()) {
updateOrdersAsSoldAfterRedeem(orders, position, copyTrading.id!!)
}
}
}
@@ -462,9 +467,11 @@ class PositionCheckService(
}
} catch (e: Exception) {
logger.error("处理待赎回仓位异常: ${e.message}", e)
} finally {
redeemCheckInProgress.set(false)
}
}
/**
* 逻辑2处理未卖出订单
* 检查所有未卖出的订单匹配仓位
@@ -0,0 +1,51 @@
package com.wrbug.polymarketbot.service.accounts
import com.wrbug.polymarketbot.service.system.RelayClientService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.slf4j.LoggerFactory
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
/**
* WCOL 解包轮询任务
* 20 秒轮询一次遍历所有账户的代理地址 WCOL 余额 > 0 则解包为 USDC.e
* 同一时间仅允许单次执行若上次执行未结束则本次忽略与现有轮询逻辑一致
* 若未配置 Builder API Key直接跳过本轮解包依赖 Relayer Gasless未配置则无法执行
*/
@Service
class WcolUnwrapJobService(
private val accountService: AccountService,
private val relayClientService: RelayClientService
) {
private val logger = LoggerFactory.getLogger(WcolUnwrapJobService::class.java)
private val scope = kotlinx.coroutines.CoroutineScope(Dispatchers.IO + SupervisorJob())
private var unwrapJob: Job? = null
/**
* 20 秒触发一次若未配置 Builder Key 或当前任务仍在执行则跳过本次
*/
@Scheduled(fixedRate = 20_000)
fun runWcolUnwrapPolling() {
if (!relayClientService.isBuilderApiKeyConfigured()) {
logger.debug("Builder API Key 未配置,跳过 WCOL 解包轮询")
return
}
if (unwrapJob?.isActive == true) {
logger.debug("上一轮 WCOL 解包任务仍在执行,跳过本次")
return
}
unwrapJob = scope.launch {
try {
accountService.runWcolUnwrapForAllAccounts()
} catch (e: Exception) {
logger.error("WCOL 解包轮询异常: ${e.message}", e)
} finally {
unwrapJob = null
}
}
}
}
@@ -0,0 +1,137 @@
package com.wrbug.polymarketbot.service.backtest
import com.wrbug.polymarketbot.api.PolymarketDataApi
import com.wrbug.polymarketbot.api.UserActivityResponse
import com.wrbug.polymarketbot.dto.TradeData
import com.wrbug.polymarketbot.entity.Leader
import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import kotlinx.coroutines.delay
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.math.BigDecimal
/**
* 基于 start 游标的一批历史交易结果
* @param trades 本批交易列表已按时间升序
* @param nextCursorSeconds 下一页游标API start 参数秒级若本批不足 limit 条则为 null 表示最后一页
*/
data class LeaderTradesBatchResult(
val trades: List<TradeData>,
val nextCursorSeconds: Long?
)
/**
* 回测数据服务
* 直接从 Polymarket Data API 获取 Leader 历史交易使用 start 游标分页避免 offset 过大报错
*/
@Service
class BacktestDataService(
private val leaderRepository: LeaderRepository,
private val retrofitFactory: RetrofitFactory
) {
private val logger = LoggerFactory.getLogger(BacktestDataService::class.java)
/**
* start 游标获取一批 Leader 历史交易
* 规则limit 固定为 500若返回 500 条则取本批最大时间戳作为下一页 start不加 1同一秒可能多笔订单由下游按 tradeId 去重不足 500 则为最后一页
*
* @param leaderId Leader ID
* @param startTime 回测开始时间毫秒
* @param endTime 回测结束时间毫秒
* @param cursorStartSeconds 本页游标API start首次传 startTime/1000
* @param limit 每批条数建议 500
* @return 本批交易与下一页游标null 表示没有下一页
*/
suspend fun getLeaderHistoricalTradesBatch(
leaderId: Long,
startTime: Long,
endTime: Long,
cursorStartSeconds: Long,
limit: Int
): LeaderTradesBatchResult {
logger.info("获取 Leader 历史交易批次: leaderId=$leaderId, cursorStart=$cursorStartSeconds, limit=$limit")
val leader = leaderRepository.findById(leaderId).orElse(null)
?: throw IllegalArgumentException("Leader 不存在: $leaderId")
val dataApi = retrofitFactory.createDataApi()
val endSeconds = endTime / 1000
val maxRetries = 5
val retryDelay = 1000L
var lastException: Exception? = null
for (attempt in 1..maxRetries) {
try {
val response = dataApi.getUserActivity(
user = leader.leaderAddress,
type = listOf("TRADE"),
start = cursorStartSeconds,
end = endSeconds,
limit = limit,
offset = null,
sortBy = "TIMESTAMP",
sortDirection = "ASC"
)
if (!response.isSuccessful || response.body() == null) {
throw Exception("从 Data API 获取用户活动失败: code=${response.code()}, message=${response.message()}")
}
val activities = response.body()!!
logger.info("本批获取 ${activities.size} 条活动(第 $attempt 次尝试)")
val trades = activities.mapNotNull { activity ->
try {
if (activity.type != "TRADE") return@mapNotNull null
if (activity.side == null || activity.price == null || activity.size == null || activity.usdcSize == null) {
logger.warn("活动数据缺少必要字段,跳过: activity=$activity")
return@mapNotNull null
}
val tradeTimestamp = activity.timestamp * 1000
if (tradeTimestamp < startTime || tradeTimestamp > endTime) {
logger.debug("交易时间超出范围,跳过: timestamp=$tradeTimestamp")
return@mapNotNull null
}
TradeData(
tradeId = activity.transactionHash ?: "${activity.timestamp}_${activity.conditionId}_${activity.side}",
marketId = activity.conditionId,
marketTitle = activity.title,
marketSlug = activity.slug,
side = activity.side.uppercase(),
outcome = activity.outcome ?: activity.outcomeIndex?.toString() ?: "",
outcomeIndex = activity.outcomeIndex,
price = activity.price.toSafeBigDecimal(),
size = activity.size.toSafeBigDecimal(),
amount = activity.usdcSize.toSafeBigDecimal(),
timestamp = tradeTimestamp
)
} catch (e: Exception) {
logger.warn("转换活动数据失败: activity=$activity, error=${e.message}", e)
null
}
}
// 下一页 start 用本批最大 timestamp(秒),不加 1:同一秒可能有多笔订单,依赖下游按 tradeId 去重
val nextCursorSeconds: Long? = if (trades.size < limit) {
null
} else {
val maxTs = trades.maxOf { it.timestamp }
maxTs / 1000
}
return LeaderTradesBatchResult(trades = trades, nextCursorSeconds = nextCursorSeconds)
} catch (e: Exception) {
lastException = e
logger.warn("$attempt/$maxRetries 次获取批次失败: ${e.message}")
if (attempt < maxRetries) {
logger.info("等待 $retryDelay 毫秒后重试...")
delay(retryDelay)
}
}
}
val errorMsg = "重试 $maxRetries 次后仍然失败,cursorStart=$cursorStartSeconds"
logger.error(errorMsg, lastException)
throw Exception(errorMsg, lastException)
}
}
@@ -0,0 +1,792 @@
package com.wrbug.polymarketbot.service.backtest
import com.wrbug.polymarketbot.dto.TradeData
import com.wrbug.polymarketbot.dto.BacktestStatisticsDto
import com.wrbug.polymarketbot.entity.BacktestTask
import com.wrbug.polymarketbot.entity.BacktestTrade
import com.wrbug.polymarketbot.entity.CopyTrading
import com.wrbug.polymarketbot.repository.BacktestTradeRepository
import com.wrbug.polymarketbot.repository.BacktestTaskRepository
import com.wrbug.polymarketbot.service.common.MarketPriceService
import com.wrbug.polymarketbot.service.common.MarketService
import com.wrbug.polymarketbot.service.copytrading.configs.CopyTradingFilterService
import com.wrbug.polymarketbot.util.gt
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
import java.text.SimpleDateFormat
import java.util.*
import kotlin.math.max
import kotlin.math.min
@Service
class BacktestExecutionService(
private val backtestTaskRepository: BacktestTaskRepository,
private val backtestTradeRepository: BacktestTradeRepository,
private val backtestDataService: BacktestDataService,
private val marketPriceService: MarketPriceService,
private val marketService: MarketService,
private val copyTradingFilterService: CopyTradingFilterService
) {
private val logger = LoggerFactory.getLogger(BacktestExecutionService::class.java)
/**
* 持仓数据结构
* @param marketEndDate 市场结束时间毫秒用于到期结算判断null 表示未知
*/
data class Position(
val marketId: String,
val outcome: String,
val outcomeIndex: Int?,
var quantity: BigDecimal,
val avgPrice: BigDecimal,
val leaderBuyQuantity: BigDecimal?,
val marketEndDate: Long? = null
)
/**
* 将回测任务转换为虚拟的 CopyTrading 配置用于执行
* 注意回测场景使用历史数据不需要实时跟单的相关配置
*/
private fun taskToCopyTrading(task: BacktestTask): CopyTrading {
return CopyTrading(
id = task.id,
accountId = 0L,
leaderId = task.leaderId,
enabled = true,
copyMode = task.copyMode,
copyRatio = task.copyRatio,
fixedAmount = null,
maxOrderSize = task.maxOrderSize,
minOrderSize = task.minOrderSize,
maxDailyLoss = task.maxDailyLoss,
maxDailyOrders = task.maxDailyOrders,
priceTolerance = BigDecimal.ZERO, // 回测使用历史价格,不需要容忍度
delaySeconds = 0, // 回测按时间线执行,无需延迟
pollIntervalSeconds = 5,
useWebSocket = false,
websocketReconnectInterval = 5000,
websocketMaxRetries = 10,
supportSell = task.supportSell,
minOrderDepth = null, // 回测无实时订单簿数据
maxSpread = null, // 回测无实时价差数据
maxPositionValue = task.maxPositionValue,
minPrice = task.minPrice, // 最低价格
maxPrice = task.maxPrice, // 最高价格
keywordFilterMode = task.keywordFilterMode,
keywords = task.keywords,
configName = null,
pushFailedOrders = false,
pushFilteredOrders = false,
createdAt = task.createdAt,
updatedAt = task.updatedAt
)
}
/**
* 执行回测任务支持分页和恢复
* 自动处理所有页面的数据支持中断恢复
*/
/** 每批请求 API 的条数(基于 start 游标分页,避免 offset 过大) */
private val backtestBatchLimit = 500
@Transactional
suspend fun executeBacktest(task: BacktestTask, page: Int = 1, size: Int = 100) {
try {
logger.info("开始执行回测任务: taskId=${task.id}, taskName=${task.taskName}, batchLimit=$backtestBatchLimit")
// 1. 更新任务状态为 RUNNING
task.status = "RUNNING"
task.executionStartedAt = System.currentTimeMillis()
task.updatedAt = System.currentTimeMillis()
backtestTaskRepository.save(task)
// 2. 初始化
var currentBalance = task.initialBalance
val positions = mutableMapOf<String, Position>()
val trades = mutableListOf<BacktestTrade>()
val dailyOrderCountCache = mutableMapOf<String, Int>()
val dailyLossCache = mutableMapOf<String, BigDecimal>()
val seenTradeIds = mutableSetOf<String>()
// 3. 回测时间范围:首次执行以当前时间为基准取最近 backtestDays 天;断点续跑保留原 startTime,仅 endTime 延到当前
val endTime = System.currentTimeMillis()
val startTime = if (task.lastProcessedTradeTime == null) {
endTime - (task.backtestDays * 24L * 3600 * 1000)
} else {
task.startTime
}
logger.info("回测时间范围: ${formatTimestamp(startTime)} - ${formatTimestamp(endTime)} (${task.backtestDays} 天), " +
"初始余额: ${task.initialBalance.toPlainString()}")
// 4. 游标分页:恢复时也从 lastProcessedTradeTime 所在秒开始拉(不加 1),与分页规则一致;已处理的通过 timestamp 跳过,不依赖内存 seenTradeIds
var cursorSeconds = if (task.lastProcessedTradeTime != null) {
task.lastProcessedTradeTime!! / 1000
} else {
startTime / 1000
}
val endSeconds = endTime / 1000
val resumeThresholdMs = task.lastProcessedTradeTime ?: 0L
logger.info("开始游标分页:cursorStart=$cursorSeconds(恢复则跳过 timestamp<=${resumeThresholdMs}ms")
var terminateBacktest = false
while (true) {
if (terminateBacktest) {
logger.info("余额已为负或不足,终止回测循环")
break
}
val currentTaskStatus = backtestTaskRepository.findById(task.id!!).orElse(null)
if (currentTaskStatus == null || currentTaskStatus.status != "RUNNING") {
logger.info("回测任务状态已变更: ${currentTaskStatus?.status},停止执行")
break
}
logger.info("正在获取批次数据 cursorStart=$cursorSeconds (${formatTimestamp(cursorSeconds * 1000)}) ...")
val currentPageTrades = mutableListOf<BacktestTrade>()
try {
val batch = backtestDataService.getLeaderHistoricalTradesBatch(
task.leaderId,
startTime,
endTime,
cursorSeconds,
backtestBatchLimit
)
val pageTrades = batch.trades
if (pageTrades.isEmpty()) {
logger.info("本批无数据,所有数据处理完成")
break
}
logger.info("本批获取 ${pageTrades.size} 条交易,是否有下一页: ${batch.nextCursorSeconds != null}")
val countAtBatchStart = task.processedTradeCount
var lastProcessedIndexInPage: Int? = null
var processedInBatch = 0
for (localIndex in pageTrades.indices) {
val leaderTrade = pageTrades[localIndex]
if (leaderTrade.tradeId in seenTradeIds) {
logger.debug("跳过重复交易: ${leaderTrade.tradeId}")
continue
}
if (resumeThresholdMs > 0 && leaderTrade.timestamp <= resumeThresholdMs) {
logger.debug("恢复时跳过已处理时间戳: tradeId=${leaderTrade.tradeId}, timestamp=${leaderTrade.timestamp}")
continue
}
seenTradeIds.add(leaderTrade.tradeId)
val index = countAtBatchStart + processedInBatch
lastProcessedIndexInPage = index
processedInBatch++
// 进度按时间比例:(当前订单时间 - 开始时间) / (结束时间 - 开始时间) * 100,运行中上限 99
val timeRange = endTime - startTime
val progress = if (timeRange > 0) {
val elapsed = (leaderTrade.timestamp - startTime).coerceIn(0L, timeRange)
min(99, ((elapsed * 100) / timeRange).toInt())
} else {
0
}
if (progress > task.progress) {
task.progress = progress
task.processedTradeCount = index + 1
backtestTaskRepository.save(task)
}
try {
// 5.1 实时检查并结算已到期的市场
currentBalance = settleExpiredPositions(task, positions, currentBalance, trades, leaderTrade.timestamp, currentPageTrades)
// 5.2 检查余额和持仓状态
if (currentBalance <= BigDecimal.ONE) {
logger.info(
if (currentBalance < BigDecimal.ZERO) "余额已为负,直接终止回测: $currentBalance"
else "余额<=1,停止回测: $currentBalance"
)
terminateBacktest = true
break
}
// 5.3 应用过滤规则
val copyTrading = taskToCopyTrading(task)
val filterResult = copyTradingFilterService.checkFilters(
copyTrading,
tokenId = "",
tradePrice = leaderTrade.price,
copyOrderAmount = null,
marketId = leaderTrade.marketId,
marketTitle = leaderTrade.marketTitle,
marketEndDate = null,
outcomeIndex = leaderTrade.outcomeIndex
)
if (!filterResult.isPassed) {
logger.debug("交易被过滤: ${leaderTrade.tradeId}")
continue
}
// 5.4 每日订单数检查 - 使用缓存,只统计 BUY 订单
val tradeDate = formatDate(leaderTrade.timestamp)
val dailyOrderCount = dailyOrderCountCache.getOrDefault(tradeDate, 0)
if (dailyOrderCount >= task.maxDailyOrders) {
logger.info("已达到每日最大 BUY 订单数限制: $dailyOrderCount / ${task.maxDailyOrders}")
continue
}
// 5.6 计算跟单金额
val followAmount = calculateFollowAmount(task, leaderTrade)
// 5.6.1 检查订单大小限制
val finalFollowAmount = if (followAmount > task.maxOrderSize) {
logger.info("跟单金额超过最大限制: $followAmount > ${task.maxOrderSize},调整为最大值")
task.maxOrderSize
} else if (followAmount < task.minOrderSize) {
logger.info("跟单金额低于最小限制: $followAmount < ${task.minOrderSize},调整为最小值")
task.minOrderSize
} else {
followAmount
}
// 5.6.2 检查每日最大亏损(买入订单)- 使用缓存
val dailyLoss = dailyLossCache.getOrDefault(tradeDate, BigDecimal.ZERO)
if (dailyLoss > task.maxDailyLoss) {
logger.info("已达到每日最大亏损限制: $dailyLoss / ${task.maxDailyLoss},跳过买入订单")
continue
}
// 5.7 处理买卖逻辑
if (leaderTrade.side == "BUY") {
// 余额不足时按最大可用余额交易,仍须满足最小订单金额
val actualBuyAmount = if (currentBalance < finalFollowAmount) {
logger.debug("余额不足,按最大余额买入: balance=$currentBalance, 原需=$finalFollowAmount, marketId=${leaderTrade.marketId}")
currentBalance
} else {
finalFollowAmount
}
if (actualBuyAmount < task.minOrderSize) {
logger.debug("可用金额低于最小订单限制跳过: actual=$actualBuyAmount, minOrderSize=${task.minOrderSize}")
continue
}
val quantity = actualBuyAmount.divide(leaderTrade.price, 8, java.math.RoundingMode.DOWN)
if (quantity <= BigDecimal.ZERO) {
logger.debug("计算数量为0跳过: actualBuyAmount=$actualBuyAmount, price=${leaderTrade.price}")
continue
}
val totalCost = actualBuyAmount
// 5.6.3 检查最大仓位限制(如果配置了)
if (task.maxPositionValue != null) {
val positionKey = "${leaderTrade.marketId}:${leaderTrade.outcomeIndex ?: 0}"
val currentPosition = positions[positionKey]
val currentPositionValue = if (currentPosition != null) {
currentPosition.quantity.multiply(currentPosition.avgPrice)
} else {
BigDecimal.ZERO
}
val totalValueAfterOrder = currentPositionValue.add(actualBuyAmount)
if (totalValueAfterOrder.gt(task.maxPositionValue)) {
val currentPositionValueStr = currentPositionValue.stripTrailingZeros().toPlainString()
val totalValueStr = totalValueAfterOrder.stripTrailingZeros().toPlainString()
val maxValueStr = task.maxPositionValue.stripTrailingZeros().toPlainString()
logger.info("超过最大仓位金额限制: 市场=${leaderTrade.marketId}, 方向=${leaderTrade.outcomeIndex}, 当前仓位=${currentPositionValueStr} USDC, 买入金额=${actualBuyAmount} USDC, 总计=${totalValueStr} USDC > 最大限制=${maxValueStr} USDC")
continue
}
}
// 更新余额和持仓(同市场同 outcome 多次买入合并:数量相加、加权均价、leaderBuyQuantity 相加)
currentBalance -= totalCost
val positionKey = "${leaderTrade.marketId}:${leaderTrade.outcomeIndex ?: 0}"
val price = leaderTrade.price.toSafeBigDecimal()
val leaderSize = leaderTrade.size.toSafeBigDecimal()
val existing = positions[positionKey]
positions[positionKey] = if (existing != null) {
val newQuantity = existing.quantity.add(quantity)
val newAvgPrice = if (newQuantity > BigDecimal.ZERO) {
existing.quantity.multiply(existing.avgPrice).add(quantity.multiply(price))
.divide(newQuantity, 8, java.math.RoundingMode.HALF_UP)
} else {
price
}
val newLeaderBuyQuantity = (existing.leaderBuyQuantity ?: BigDecimal.ZERO).add(leaderSize)
Position(
marketId = leaderTrade.marketId,
outcome = leaderTrade.outcome ?: "",
outcomeIndex = leaderTrade.outcomeIndex,
quantity = newQuantity,
avgPrice = newAvgPrice,
leaderBuyQuantity = newLeaderBuyQuantity,
marketEndDate = existing.marketEndDate
)
} else {
val market = marketService.getMarket(leaderTrade.marketId)
Position(
marketId = leaderTrade.marketId,
outcome = leaderTrade.outcome ?: "",
outcomeIndex = leaderTrade.outcomeIndex,
quantity = quantity,
avgPrice = price,
leaderBuyQuantity = leaderSize,
marketEndDate = market?.endDate
)
}
// 记录交易到当前页列表
currentPageTrades.add(BacktestTrade(
backtestTaskId = task.id!!,
tradeTime = leaderTrade.timestamp,
marketId = leaderTrade.marketId,
marketTitle = leaderTrade.marketTitle,
side = "BUY",
outcome = leaderTrade.outcome ?: leaderTrade.outcomeIndex.toString(),
outcomeIndex = leaderTrade.outcomeIndex,
quantity = quantity,
price = leaderTrade.price.toSafeBigDecimal(),
amount = actualBuyAmount,
fee = BigDecimal.ZERO,
profitLoss = null,
balanceAfter = currentBalance,
leaderTradeId = leaderTrade.tradeId
))
// 更新每日订单数缓存
dailyOrderCountCache[tradeDate] = dailyOrderCount + 1
} else {
// SELL 逻辑
if (!task.supportSell) {
continue
}
val positionKey = "${leaderTrade.marketId}:${leaderTrade.outcomeIndex ?: 0}"
val position = positions[positionKey] ?: continue
// 计算卖出数量
val sellQuantity = if (task.copyMode == "RATIO") {
if (position.leaderBuyQuantity != null && position.leaderBuyQuantity > BigDecimal.ZERO) {
position.quantity.multiply(
leaderTrade.size.divide(position.leaderBuyQuantity, 8, java.math.RoundingMode.DOWN)
)
} else {
position.quantity
}
} else {
position.quantity
}
val actualSellQuantity = if (sellQuantity > position.quantity) {
position.quantity
} else {
sellQuantity
}
// 计算卖出金额
val sellAmount = actualSellQuantity.multiply(leaderTrade.price.toSafeBigDecimal())
// 5.6.2 检查卖出金额限制
val finalSellAmount = if (sellAmount > task.maxOrderSize) {
logger.info("卖出金额超过最大限制: $sellAmount > ${task.maxOrderSize},调整为最大值")
task.maxOrderSize
} else if (sellAmount < task.minOrderSize) {
logger.info("卖出金额低于最小限制: $sellAmount < ${task.minOrderSize},调整为最小值")
task.minOrderSize
} else {
sellAmount
}
val netAmount = finalSellAmount
// 计算盈亏
val cost = actualSellQuantity.multiply(position.avgPrice)
val profitLoss = netAmount.subtract(cost)
// 更新余额和持仓
currentBalance += netAmount
if (position.quantity <= BigDecimal.ZERO) {
positions.remove(positionKey)
}
// 记录交易到当前页列表
currentPageTrades.add(BacktestTrade(
backtestTaskId = task.id!!,
tradeTime = leaderTrade.timestamp,
marketId = leaderTrade.marketId,
marketTitle = leaderTrade.marketTitle,
side = "SELL",
outcome = leaderTrade.outcome ?: leaderTrade.outcomeIndex.toString(),
outcomeIndex = leaderTrade.outcomeIndex,
quantity = actualSellQuantity,
price = leaderTrade.price.toSafeBigDecimal(),
amount = finalSellAmount,
fee = BigDecimal.ZERO,
profitLoss = profitLoss,
balanceAfter = currentBalance,
leaderTradeId = leaderTrade.tradeId
))
// SELL 订单不计入每日订单数限制
// 更新每日亏损缓存(只累加亏损,不累加盈利)
if (profitLoss < BigDecimal.ZERO) {
val currentDailyLoss = dailyLossCache.getOrDefault(tradeDate, BigDecimal.ZERO)
dailyLossCache[tradeDate] = currentDailyLoss + profitLoss.negate()
}
}
} catch (e: Exception) {
logger.error("处理交易失败: tradeId=${leaderTrade.tradeId}", e)
}
}
// 保存本批交易
if (currentPageTrades.isNotEmpty()) {
logger.info("保存本批交易,共 ${currentPageTrades.size}")
backtestTradeRepository.saveAll(currentPageTrades)
val lastTradeInPage = currentPageTrades.lastOrNull()
if (lastTradeInPage != null && lastProcessedIndexInPage != null) {
task.lastProcessedTradeTime = lastTradeInPage.tradeTime
task.lastProcessedTradeIndex = lastProcessedIndexInPage
task.processedTradeCount = lastProcessedIndexInPage + 1
task.finalBalance = currentBalance
backtestTaskRepository.save(task)
logger.info("本批处理完成,lastProcessedTradeIndex=${task.lastProcessedTradeIndex}, 总处理数=${task.processedTradeCount}")
}
} else {
logger.info("本批没有交易需要保存")
}
trades.addAll(currentPageTrades)
if (batch.nextCursorSeconds == null) {
logger.info("本批不足 $backtestBatchLimit 条,已是最后一页")
break
}
cursorSeconds = batch.nextCursorSeconds!!
} catch (e: Exception) {
logger.error("获取或处理本批数据失败: ${e.message}", e)
// 重试失败,标记任务为 FAILED
throw e
}
}
// 6. 处理回测结束时仍未到期的持仓
val remainingSettlements = mutableListOf<BacktestTrade>()
currentBalance = settleRemainingPositions(task, positions, currentBalance, trades, endTime, remainingSettlements)
if (remainingSettlements.isNotEmpty()) {
backtestTradeRepository.saveAll(remainingSettlements)
logger.info("回测结束结算剩余持仓,持久化 ${remainingSettlements.size} 笔 SETTLEMENT(CLOSED)")
}
// 7. 计算最终统计数据
val statistics = calculateStatistics(trades)
// 8. 更新任务状态
val profitAmount = currentBalance.subtract(task.initialBalance)
val profitRate = if (task.initialBalance > BigDecimal.ZERO) {
profitAmount.divide(task.initialBalance, 4, java.math.RoundingMode.HALF_UP).multiply(BigDecimal("100"))
} else {
BigDecimal.ZERO
}
val finalStatus = if (task.status == "STOPPED") "STOPPED" else "COMPLETED"
task.finalBalance = currentBalance
task.profitAmount = profitAmount
task.profitRate = profitRate
task.endTime = endTime
task.status = finalStatus
task.progress = 100
task.totalTrades = trades.size
task.buyTrades = trades.count { it.side == "BUY" }
task.sellTrades = trades.count { it.side == "SELL" }
task.winTrades = statistics.winTrades
task.lossTrades = statistics.lossTrades
task.winRate = statistics.winRate.toSafeBigDecimal()
task.maxProfit = statistics.maxProfit.toSafeBigDecimal()
task.maxLoss = statistics.maxLoss.toSafeBigDecimal()
task.maxDrawdown = statistics.maxDrawdown.toSafeBigDecimal()
task.avgHoldingTime = statistics.avgHoldingTime
task.executionFinishedAt = System.currentTimeMillis()
task.updatedAt = System.currentTimeMillis()
backtestTaskRepository.save(task)
logger.info("回测任务执行完成: taskId=${task.id}, " +
"最终余额=${currentBalance.toPlainString()}, " +
"收益额=${task.profitAmount?.toPlainString()}, " +
"收益率=${task.profitRate?.toPlainString()}%, " +
"总交易数=${trades.size}, " +
"盈利率=${task.winRate?.toPlainString()}%")
} catch (e: Exception) {
logger.error("回测任务执行失败: taskId=${task.id}", e)
task.status = "FAILED"
task.errorMessage = e.message
task.executionFinishedAt = System.currentTimeMillis()
task.updatedAt = System.currentTimeMillis()
backtestTaskRepository.save(task)
throw e
}
}
/**
* 结算已到期的市场
* @param batchTradesToSave 本批要持久化的交易列表到期结算赎回/会追加到此列表并随本批一起落库
*/
private suspend fun settleExpiredPositions(
task: BacktestTask,
positions: MutableMap<String, Position>,
currentBalance: BigDecimal,
trades: MutableList<BacktestTrade>,
currentTime: Long,
batchTradesToSave: MutableList<BacktestTrade>
): BigDecimal {
var balance = currentBalance
for ((positionKey, position) in positions.toList()) {
try {
// 仅当市场已到期(结束时间 <= 当前回测时间)时才结算,避免未到期持仓被误结算
if (position.marketEndDate == null || position.marketEndDate!! > currentTime) {
logger.debug("持仓未到期跳过结算: marketId=${position.marketId}, endDate=${position.marketEndDate}, currentTime=$currentTime")
continue
}
// 获取市场当前价格
val marketPrice = marketPriceService.getCurrentMarketPrice(
position.marketId,
position.outcomeIndex ?: 0
)
val price = marketPrice.toSafeBigDecimal()
// 通过市场价格判断结算价格
val settlementPrice = when {
price >= BigDecimal("0.95") -> BigDecimal.ONE
price <= BigDecimal("0.05") -> BigDecimal.ZERO
else -> position.avgPrice
}
val settlementValue = position.quantity.multiply(settlementPrice)
val profitLoss = settlementValue.subtract(position.quantity.multiply(position.avgPrice))
balance += settlementValue
val marketTitle = marketService.getMarket(position.marketId)?.title ?: ""
val settlementTrade = BacktestTrade(
backtestTaskId = task.id!!,
tradeTime = currentTime,
marketId = position.marketId,
marketTitle = marketTitle,
side = "SETTLEMENT",
outcome = when {
settlementPrice == BigDecimal.ONE -> "WIN"
settlementPrice == BigDecimal.ZERO -> "LOSE"
else -> "UNKNOWN"
},
outcomeIndex = position.outcomeIndex,
quantity = position.quantity,
price = settlementPrice,
amount = settlementValue,
fee = BigDecimal.ZERO,
profitLoss = profitLoss,
balanceAfter = balance,
leaderTradeId = null
)
trades.add(settlementTrade)
batchTradesToSave.add(settlementTrade)
// 移除已结算的持仓
positions.remove(positionKey)
} catch (e: Exception) {
logger.error("结算市场失败: marketId=${position.marketId}, outcomeIndex=${position.outcomeIndex}", e)
}
}
return balance
}
/**
* 结算未到期持仓回测结束时剩余持仓按均价平仓
* @param settlementsToSave 本批结算记录会追加到此列表调用方需落库
*/
private suspend fun settleRemainingPositions(
task: BacktestTask,
positions: MutableMap<String, Position>,
currentBalance: BigDecimal,
trades: MutableList<BacktestTrade>,
currentTime: Long,
settlementsToSave: MutableList<BacktestTrade>
): BigDecimal {
var balance = currentBalance
for ((positionKey, position) in positions.toList()) {
val quantity = position.quantity
val avgPrice = position.avgPrice
val settlementPrice = avgPrice
val settlementValue = quantity.multiply(settlementPrice)
val profitLoss = settlementValue.negate()
balance += settlementValue
val marketTitle = marketService.getMarket(position.marketId)?.title ?: ""
val closedTrade = BacktestTrade(
backtestTaskId = task.id!!,
tradeTime = currentTime,
marketId = position.marketId,
marketTitle = marketTitle,
side = "SETTLEMENT",
outcome = "CLOSED",
outcomeIndex = position.outcomeIndex,
quantity = quantity,
price = avgPrice,
amount = settlementValue,
fee = BigDecimal.ZERO,
profitLoss = profitLoss,
balanceAfter = balance,
leaderTradeId = null
)
trades.add(closedTrade)
settlementsToSave.add(closedTrade)
}
positions.clear()
return balance
}
/**
* 计算统计数据
*/
private fun calculateStatistics(trades: List<BacktestTrade>): BacktestStatisticsDto {
val buyTrades = trades.count { it.side == "BUY" }
val sellTrades = trades.count { it.side == "SELL" }
val winTrades = trades.count { it.profitLoss != null && it.profitLoss > BigDecimal.ZERO }
val lossTrades = trades.count { it.profitLoss != null && it.profitLoss < BigDecimal.ZERO }
var totalProfit = BigDecimal.ZERO
var totalLoss = BigDecimal.ZERO
var maxProfit = BigDecimal.ZERO
var maxLoss = BigDecimal.ZERO
// 计算最大回撤
var runningBalance = if (trades.isNotEmpty()) {
trades[0].balanceAfter?.toSafeBigDecimal() ?: BigDecimal.ZERO
} else {
BigDecimal.ZERO
}
var peakBalance = runningBalance
var maxDrawdown = BigDecimal.ZERO
for (i in trades.indices) {
val trade = trades[i]
val balance = trade.balanceAfter?.toSafeBigDecimal() ?: continue
if (trade.profitLoss != null) {
val pnl = trade.profitLoss.toSafeBigDecimal()
if (pnl > BigDecimal.ZERO) {
totalProfit += pnl
if (pnl > maxProfit) maxProfit = pnl
} else {
totalLoss += pnl
if (pnl < maxLoss) maxLoss = pnl
}
}
if (balance > peakBalance) {
peakBalance = balance
}
val drawdown = peakBalance - runningBalance
if (drawdown > maxDrawdown) {
maxDrawdown = drawdown
}
runningBalance = balance
}
// 计算平均持仓时间
var avgHoldingTime: Long? = null
if (trades.size > 1) {
var totalHoldingTime = 0L
var count = 0
for (i in 0 until trades.size - 1) {
val currentTrade = trades[i]
val nextTrade = trades[i + 1]
if (currentTrade.side == "BUY" && nextTrade.side == "SELL") {
val holdingTime = nextTrade.tradeTime - currentTrade.tradeTime
totalHoldingTime += holdingTime
count++
}
}
if (count > 0) {
avgHoldingTime = totalHoldingTime / count
}
}
return BacktestStatisticsDto(
totalTrades = trades.size,
buyTrades = buyTrades,
sellTrades = sellTrades,
winTrades = winTrades,
lossTrades = lossTrades,
winRate = if (buyTrades + sellTrades > 0) {
(winTrades.toBigDecimal().divide((buyTrades + sellTrades).toBigDecimal(), 4, java.math.RoundingMode.HALF_UP))
.multiply(BigDecimal("100"))
.toPlainString()
} else {
BigDecimal.ZERO.toPlainString()
},
maxProfit = maxProfit.toPlainString(),
maxLoss = maxLoss.toPlainString(),
maxDrawdown = maxDrawdown.toPlainString(),
avgHoldingTime = avgHoldingTime
)
}
/**
* 计算跟单金额
*/
private fun calculateFollowAmount(task: BacktestTask, leaderTrade: TradeData): BigDecimal {
return if (task.copyMode == "RATIO") {
// 比例模式:Leader 成交金额 × 跟单比例
leaderTrade.amount.toSafeBigDecimal().multiply(task.copyRatio)
} else {
// 固定金额模式:使用配置的固定金额
task.fixedAmount ?: leaderTrade.amount.toSafeBigDecimal()
}
}
/**
* 判断是否同一天
*/
private fun isSameDay(timestamp1: Long, timestamp2: Long): Boolean {
val cal1 = Calendar.getInstance().apply { timeInMillis = timestamp1 }
val cal2 = Calendar.getInstance().apply { timeInMillis = timestamp2 }
return cal1.get(Calendar.YEAR) == cal2.get(Calendar.YEAR) &&
cal1.get(Calendar.DAY_OF_YEAR) == cal2.get(Calendar.DAY_OF_YEAR)
}
/**
* 格式化时间戳
*/
private fun formatTimestamp(timestamp: Long): String {
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
return sdf.format(Date(timestamp))
}
/**
* 格式化日期用于缓存key
*/
private fun formatDate(timestamp: Long): String {
val sdf = SimpleDateFormat("yyyy-MM-dd")
return sdf.format(Date(timestamp))
}
}
@@ -0,0 +1,113 @@
package com.wrbug.polymarketbot.service.backtest
import com.wrbug.polymarketbot.entity.BacktestTask
import com.wrbug.polymarketbot.repository.BacktestTaskRepository
import org.slf4j.LoggerFactory
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.ThreadPoolExecutor
import kotlinx.coroutines.runBlocking
/**
* 回测轮询服务
* 定时获取待执行的回测任务并执行
*/
@Service
class BacktestPollingService(
private val backtestTaskRepository: BacktestTaskRepository,
private val executionService: BacktestExecutionService
) {
private val logger = LoggerFactory.getLogger(BacktestPollingService::class.java)
// 线程池:同一时刻只执行一个任务
private val executor: ExecutorService = Executors.newFixedThreadPool(1) as ThreadPoolExecutor
/**
* 轮询待执行的回测任务
* 10 秒执行一次
* 规则同一时刻只执行一个任务如果有多个待执行任务按创建时间先后执行最早创建的
*/
@Scheduled(fixedDelay = 10000) // 10 秒
fun pollPendingTasks() {
try {
// 1. 检查是否有长时间处于 RUNNING 状态的任务(可能是应用重启导致的)
val runningTasks = backtestTaskRepository.findByStatus("RUNNING")
if (runningTasks.isNotEmpty()) {
val activeQueueSize = (executor as ThreadPoolExecutor).queue.size
val activeCount = (executor as ThreadPoolExecutor).activeCount
// 如果有线程池中没有活跃任务但有 RUNNING 状态的任务,说明是应用重启导致的
// 重置这些任务的状态为 PENDING,以便恢复执行
if (activeCount == 0 && runningTasks.isNotEmpty()) {
logger.info("检测到应用重启导致的异常 RUNNING 任务,重置为 PENDING 以便恢复")
runningTasks.forEach { task ->
val now = System.currentTimeMillis()
val executionStartedAt = task.executionStartedAt
val executionDuration = if (executionStartedAt != null) {
now - executionStartedAt
} else {
0L
}
// 如果任务执行时间超过 1 分钟,认为是异常状态
if (executionDuration > 60000) {
logger.info("重置异常 RUNNING 任务: taskId=${task.id}, executionStartedAt=$executionStartedAt, duration=${executionDuration}ms")
task.status = "PENDING"
task.updatedAt = now
backtestTaskRepository.save(task)
}
}
} else {
logger.debug("${runningTasks.size} 个任务正在执行,跳过本次轮询")
return
}
}
// 2. 查询所有 PENDING 状态的任务,按创建时间升序排序
val pendingTasks = backtestTaskRepository.findByStatus("PENDING")
.sortedBy { it.createdAt }
if (pendingTasks.isEmpty()) {
return
}
// 3. 只执行最早创建的任务
val taskToExecute = pendingTasks.first()
logger.info("找到 ${pendingTasks.size} 个待执行的回测任务,执行最早创建的任务: taskId=${taskToExecute.id}, createdAt=${taskToExecute.createdAt}")
// 4. 提交任务到线程池执行
executor.submit {
try {
// 执行前再次检查任务状态(防止并发执行)
val currentTask = backtestTaskRepository.findById(taskToExecute.id!!).orElse(null)
if (currentTask == null || currentTask.status != "PENDING") {
logger.debug("任务状态已变更,跳过执行: taskId=${taskToExecute.id}, currentStatus=${currentTask?.status}")
return@submit
}
runBlocking {
// 使用 start 游标分页,恢复时由 lastProcessedTradeTime 决定从何时开始拉取
logger.info("执行回测任务: taskId=${currentTask.id}(游标分页,limit=500")
executionService.executeBacktest(currentTask, page = 0, size = 500)
}
} catch (e: Exception) {
logger.error("回测任务执行失败: taskId=${taskToExecute.id}", e)
// 更新任务状态为 FAILED
val failedTask = backtestTaskRepository.findById(taskToExecute.id!!).orElse(null)
if (failedTask != null) {
failedTask.status = "FAILED"
failedTask.errorMessage = e.message
failedTask.updatedAt = System.currentTimeMillis()
backtestTaskRepository.save(failedTask)
}
}
}
} catch (e: Exception) {
logger.error("轮询回测任务失败", e)
}
}
}
@@ -0,0 +1,426 @@
package com.wrbug.polymarketbot.service.backtest
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.entity.BacktestTask
import com.wrbug.polymarketbot.entity.BacktestTrade
import com.wrbug.polymarketbot.entity.Leader
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.repository.BacktestTaskRepository
import com.wrbug.polymarketbot.repository.BacktestTradeRepository
import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import com.wrbug.polymarketbot.util.toJson
import com.wrbug.polymarketbot.util.fromJson
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
/**
* 回测任务服务
*/
@Service
class BacktestService(
private val backtestTaskRepository: BacktestTaskRepository,
private val backtestTradeRepository: BacktestTradeRepository,
private val leaderRepository: LeaderRepository,
private val messageSource: MessageSource
) {
private val logger = LoggerFactory.getLogger(BacktestService::class.java)
/**
* 创建回测任务
*/
@Transactional
fun createBacktestTask(request: BacktestCreateRequest): Result<BacktestTaskDto> {
return try {
// 1. 验证 Leader 是否存在
val leader = leaderRepository.findById(request.leaderId).orElse(null)
?: return Result.failure(IllegalArgumentException("Leader 不存在"))
// 2. 验证回测天数
if (request.backtestDays < 1 || request.backtestDays > 15) {
return Result.failure(IllegalArgumentException("回测天数必须在 1-15 之间"))
}
// 3. 验证恢复页码(如果提供)
if (request.pageForResume != null && request.pageForResume < 1) {
return Result.failure(IllegalArgumentException("恢复页码必须大于 0"))
}
// 4. 验证初始金额
val initialBalance = request.initialBalance.toSafeBigDecimal()
if (initialBalance <= BigDecimal.ZERO) {
return Result.failure(IllegalArgumentException("初始金额必须大于 0"))
}
// 4. 创建回测任务
val task = BacktestTask(
taskName = request.taskName.trim(),
leaderId = request.leaderId,
initialBalance = initialBalance,
backtestDays = request.backtestDays,
startTime = System.currentTimeMillis() - (request.backtestDays * 24 * 3600 * 1000),
status = "PENDING",
// 跟单配置(不包含 max_position_count
copyMode = request.copyMode ?: "RATIO",
copyRatio = request.copyRatio?.toSafeBigDecimal() ?: BigDecimal.ONE,
fixedAmount = request.fixedAmount?.toSafeBigDecimal(),
maxOrderSize = request.maxOrderSize?.toSafeBigDecimal() ?: "1000".toSafeBigDecimal(),
minOrderSize = request.minOrderSize?.toSafeBigDecimal() ?: "1".toSafeBigDecimal(),
maxDailyLoss = request.maxDailyLoss?.toSafeBigDecimal() ?: "10000".toSafeBigDecimal(),
maxDailyOrders = request.maxDailyOrders ?: 100,
supportSell = request.supportSell ?: true,
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
keywords = if (request.keywords != null && request.keywords.isNotEmpty()) {
request.keywords.toJson()
} else {
null
},
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
minPrice = request.minPrice?.toSafeBigDecimal(),
maxPrice = request.maxPrice?.toSafeBigDecimal()
)
backtestTaskRepository.save(task)
// 5. 转换为 DTO 返回
Result.success(task.toDto(leader))
} catch (e: Exception) {
logger.error("创建回测任务失败", e)
Result.failure(e)
}
}
/**
* 查询回测任务列表
*/
fun getBacktestTaskList(request: BacktestListRequest): Result<BacktestListResponse> {
return try {
// 获取所有符合条件的任务
val allTasks = when {
request.leaderId != null && request.status != null -> {
backtestTaskRepository.findByLeaderIdAndStatus(request.leaderId, request.status)
}
request.leaderId != null -> {
backtestTaskRepository.findByLeaderId(request.leaderId)
.filter { request.status == null || it.status == request.status }
}
request.status != null -> {
backtestTaskRepository.findByStatus(request.status)
}
else -> {
backtestTaskRepository.findAll()
}
}
// 排序
val sortedTasks = when (request.sortBy) {
"profitAmount" -> {
if (request.sortOrder == "asc") {
allTasks.sortedBy { it.profitAmount }
} else {
allTasks.sortedByDescending { it.profitAmount }
}
}
"profitRate" -> {
if (request.sortOrder == "asc") {
allTasks.sortedBy { it.profitRate }
} else {
allTasks.sortedByDescending { it.profitRate }
}
}
else -> {
if (request.sortOrder == "asc") {
allTasks.sortedBy { it.createdAt }
} else {
allTasks.sortedByDescending { it.createdAt }
}
}
}
// 分页
val total = sortedTasks.size
val pagedTasks = sortedTasks
.drop((request.page - 1) * request.size)
.take(request.size)
val list = pagedTasks.map { task ->
val leader = leaderRepository.findById(task.leaderId).orElse(null)
task.toDto(leader)
}
Result.success(
BacktestListResponse(
list = list,
total = total.toLong(),
page = request.page,
size = request.size
)
)
} catch (e: Exception) {
logger.error("查询回测任务列表失败", e)
Result.failure(e)
}
}
/**
* 查询回测任务详情
*/
fun getBacktestTaskDetail(request: BacktestDetailRequest): Result<BacktestDetailResponse> {
return try {
val task = backtestTaskRepository.findById(request.id).orElse(null)
?: return Result.failure(IllegalArgumentException("回测任务不存在"))
val leader = leaderRepository.findById(task.leaderId).orElse(null)
val config = BacktestConfigDto(
copyMode = task.copyMode,
copyRatio = task.copyRatio.toPlainString(),
fixedAmount = task.fixedAmount?.toPlainString(),
maxOrderSize = task.maxOrderSize.toPlainString(),
minOrderSize = task.minOrderSize.toPlainString(),
maxDailyLoss = task.maxDailyLoss.toPlainString(),
maxDailyOrders = task.maxDailyOrders,
supportSell = task.supportSell,
keywordFilterMode = task.keywordFilterMode,
keywords = if (task.keywords != null) {
task.keywords.fromJson<List<String>>()
} else {
emptyList()
},
maxPositionValue = task.maxPositionValue?.toPlainString(),
minPrice = task.minPrice?.toPlainString(),
maxPrice = task.maxPrice?.toPlainString()
)
val statistics = BacktestStatisticsDto(
totalTrades = task.totalTrades,
buyTrades = task.buyTrades,
sellTrades = task.sellTrades,
winTrades = task.winTrades,
lossTrades = task.lossTrades,
winRate = task.winRate?.toPlainString() ?: "0.00",
maxProfit = task.maxProfit?.toPlainString() ?: "0.00",
maxLoss = task.maxLoss?.toPlainString() ?: "0.00",
maxDrawdown = task.maxDrawdown?.toPlainString() ?: "0.00",
avgHoldingTime = task.avgHoldingTime
)
val taskDto = task.toDto(leader)
Result.success(
BacktestDetailResponse(
task = taskDto,
config = config,
statistics = statistics
)
)
} catch (e: Exception) {
logger.error("查询回测任务详情失败", e)
Result.failure(e)
}
}
/**
* 查询回测交易记录
*/
fun getBacktestTrades(request: BacktestTradeListRequest): Result<BacktestTradeListResponse> {
return try {
val pageRequest = PageRequest.of(
request.page - 1,
request.size,
Sort.by(Sort.Order.asc("tradeTime"))
)
val tradesPage = backtestTradeRepository.findByBacktestTaskId(
request.taskId,
pageRequest
)
val list = tradesPage.content.map { trade ->
BacktestTradeDto(
id = trade.id!!,
tradeTime = trade.tradeTime,
marketId = trade.marketId,
marketTitle = trade.marketTitle,
side = trade.side,
outcome = trade.outcome,
outcomeIndex = trade.outcomeIndex,
quantity = trade.quantity.toPlainString(),
price = trade.price.toPlainString(),
amount = trade.amount.toPlainString(),
fee = trade.fee.toPlainString(),
profitLoss = trade.profitLoss?.toPlainString(),
balanceAfter = trade.balanceAfter.toPlainString(),
leaderTradeId = trade.leaderTradeId
)
}
Result.success(
BacktestTradeListResponse(
list = list,
total = tradesPage.totalElements,
page = request.page,
size = request.size
)
)
} catch (e: Exception) {
logger.error("查询回测交易记录失败", e)
Result.failure(e)
}
}
/**
* 删除回测任务
*/
@Transactional
fun deleteBacktestTask(request: BacktestDeleteRequest): Result<Unit> {
return try {
val task = backtestTaskRepository.findById(request.id).orElse(null)
?: return Result.failure(IllegalArgumentException("回测任务不存在"))
if (task.status == "RUNNING") {
return Result.failure(IllegalStateException("回测任务正在运行,无法删除"))
}
backtestTaskRepository.deleteById(request.id)
Result.success(Unit)
} catch (e: Exception) {
logger.error("删除回测任务失败", e)
Result.failure(e)
}
}
/**
* 停止回测任务
*/
@Transactional
fun stopBacktestTask(request: BacktestStopRequest): Result<Unit> {
return try {
val task = backtestTaskRepository.findById(request.id).orElse(null)
?: return Result.failure(IllegalArgumentException("回测任务不存在"))
if (task.status != "RUNNING") {
return Result.failure(IllegalArgumentException("回测任务未在运行中"))
}
task.status = "STOPPED"
task.updatedAt = System.currentTimeMillis()
backtestTaskRepository.save(task)
Result.success(Unit)
} catch (e: Exception) {
logger.error("停止回测任务失败", e)
Result.failure(e)
}
}
/**
* 重试回测任务
* 从断点继续执行保留已处理的交易记录
*/
@Transactional
fun retryBacktestTask(request: BacktestRetryRequest): Result<Unit> {
return try {
val task = backtestTaskRepository.findById(request.id).orElse(null)
?: return Result.failure(IllegalArgumentException("回测任务不存在"))
if (task.status == "RUNNING") {
return Result.failure(IllegalArgumentException("回测任务正在运行中,无需重试"))
}
// 重置任务状态为 PENDING,进度保持不变
task.status = "PENDING"
task.errorMessage = null
task.updatedAt = System.currentTimeMillis()
// 不清理已处理的交易记录,保留恢复点
backtestTaskRepository.save(task)
Result.success(Unit)
} catch (e: Exception) {
logger.error("重试回测任务失败", e)
Result.failure(e)
}
}
/**
* 按当前配置重新测试基于已完成的回测任务创建一份相同配置的新任务名称可修改
*/
@Transactional
fun rerunBacktestTask(request: BacktestRerunRequest): Result<BacktestTaskDto> {
return try {
val source = backtestTaskRepository.findById(request.id).orElse(null)
?: return Result.failure(IllegalArgumentException("回测任务不存在"))
if (source.status != "COMPLETED") {
return Result.failure(IllegalStateException("仅支持对已完成的回测任务重新测试"))
}
val newTaskName = request.taskName?.trim()?.takeIf { it.isNotEmpty() }
?: "${source.taskName} (副本)"
val newTask = BacktestTask(
taskName = newTaskName,
leaderId = source.leaderId,
initialBalance = source.initialBalance,
backtestDays = source.backtestDays,
startTime = source.startTime,
status = "PENDING",
copyMode = source.copyMode,
copyRatio = source.copyRatio,
fixedAmount = source.fixedAmount,
maxOrderSize = source.maxOrderSize,
minOrderSize = source.minOrderSize,
maxDailyLoss = source.maxDailyLoss,
maxDailyOrders = source.maxDailyOrders,
supportSell = source.supportSell,
keywordFilterMode = source.keywordFilterMode,
keywords = source.keywords,
maxPositionValue = source.maxPositionValue,
minPrice = source.minPrice,
maxPrice = source.maxPrice
)
backtestTaskRepository.save(newTask)
val leader = leaderRepository.findById(newTask.leaderId).orElse(null)
Result.success(newTask.toDto(leader))
} catch (e: Exception) {
logger.error("按配置重新测试失败", e)
Result.failure(e)
}
}
}
/**
* 扩展函数BacktestTask DTO
*/
private fun BacktestTask.toDto(leader: Leader?): BacktestTaskDto {
return BacktestTaskDto(
id = this.id!!,
taskName = this.taskName,
leaderId = this.leaderId,
leaderName = leader?.leaderName,
leaderAddress = leader?.leaderAddress,
initialBalance = this.initialBalance.toPlainString(),
finalBalance = this.finalBalance?.toPlainString(),
profitAmount = this.profitAmount?.toPlainString(),
profitRate = this.profitRate?.toPlainString(),
backtestDays = this.backtestDays,
startTime = this.startTime,
endTime = this.endTime,
status = this.status,
progress = this.progress,
totalTrades = this.totalTrades,
createdAt = this.createdAt,
executionStartedAt = this.executionStartedAt,
executionFinishedAt = this.executionFinishedAt
)
}
@@ -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
}
}
@@ -8,12 +8,17 @@ import com.wrbug.polymarketbot.api.PolymarketDataApi
import com.wrbug.polymarketbot.api.PositionResponse
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
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import org.slf4j.LoggerFactory
import com.wrbug.polymarketbot.service.system.RelayClientService
import com.wrbug.polymarketbot.service.system.RpcNodeService
import kotlinx.coroutines.delay
import org.springframework.stereotype.Service
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
@@ -50,6 +55,9 @@ class BlockchainService(
// ConditionalTokens 合约地址(Polygon 主网)
private val conditionalTokensAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"
// Neg Risk WrappedCollateral 合约地址(Polygon,解包后得 USDC.e
private val wcolContractAddress = "0x3A3BD7bb9528E159577F7C2e685CC81A765002E2"
// 空集合ID(用于计算collectionId
private val EMPTY_SET = "0x0000000000000000000000000000000000000000000000000000000000000000"
@@ -90,13 +98,13 @@ class BlockchainService(
* 2. Safe ProxyMetaMask 钱包用户- 通过合约调用获取地址
*
* @param walletAddress 用户的钱包地址EOA
* @param walletType 钱包类型"magic"默认 "safe"
* @param walletType 钱包类型MAGIC默认 SAFE
* @return 代理钱包地址
*/
suspend fun getProxyAddress(walletAddress: String, walletType: String = "magic"): Result<String> {
suspend fun getProxyAddress(walletAddress: String, walletType: WalletType = WalletType.MAGIC): Result<String> {
return try {
when (walletType.lowercase()) {
"safe" -> {
when (walletType) {
WalletType.SAFE -> {
// Safe ProxyMetaMask 用户)
val safeProxyResult = getSafeProxyAddress(walletAddress)
if (safeProxyResult.isSuccess) {
@@ -107,7 +115,7 @@ class BlockchainService(
Result.failure(safeProxyResult.exceptionOrNull() ?: Exception("获取 Safe Proxy 地址失败"))
}
}
else -> {
WalletType.MAGIC -> {
// Magic Proxy(邮箱/OAuth 登录用户)- 默认
val magicProxyAddress = calculateMagicProxyAddress(walletAddress)
logger.debug("使用 Magic Proxy 地址: $magicProxyAddress")
@@ -239,6 +247,62 @@ class BlockchainService(
false
}
}
/**
* 检查代理钱包是否已部署链上有合约代码
* @param proxyAddress 代理钱包地址
* @return 已部署返回 true
*/
suspend fun isProxyDeployed(proxyAddress: String): Boolean {
if (proxyAddress.isBlank() || !proxyAddress.startsWith("0x") || proxyAddress.length != 42) {
return false
}
return isContract(proxyAddress)
}
/**
* 查询 ERC20 USDC 授权额度 allowance(owner, spender)
* @param owner 代币持有者地址代理钱包地址
* @param spender 被授权方地址 CTF Exchange
* @return 授权额度原始值USDC 6 位小数需除以 1e6 为显示值
*/
suspend fun getUsdcAllowance(owner: String, spender: String): Result<BigInteger> {
return try {
if (owner.isBlank() || spender.isBlank()) {
return Result.failure(IllegalArgumentException("owner 或 spender 不能为空"))
}
val rpcApi = polygonRpcApi
// ERC20 allowance(address owner, address spender) 选择器
val functionSelector = "0xdd62ed3e"
val ownerEncoded = EthereumUtils.encodeAddress(owner)
val spenderEncoded = EthereumUtils.encodeAddress(spender)
val data = functionSelector + ownerEncoded + spenderEncoded
val rpcRequest = JsonRpcRequest(
method = "eth_call",
params = listOf(
mapOf(
"to" to usdcContractAddress,
"data" to data
),
"latest"
)
)
val response = rpcApi.call(rpcRequest)
if (!response.isSuccessful || response.body() == null) {
return Result.failure(Exception("RPC 请求失败: ${response.code()} ${response.message()}"))
}
val rpcResponse = response.body()!!
if (rpcResponse.error != null) {
return Result.failure(Exception("RPC 错误: ${rpcResponse.error.message}"))
}
val hexResult = rpcResponse.result?.asString ?: return Result.failure(Exception("RPC 响应 result 为空"))
val allowance = EthereumUtils.decodeUint256(hexResult)
Result.success(allowance)
} catch (e: Exception) {
logger.warn("查询 USDC 授权额度失败: ${e.message}")
Result.failure(e)
}
}
/**
* 查询账户 USDC 余额
@@ -255,7 +319,6 @@ class BlockchainService(
return Result.failure(IllegalArgumentException("代理地址不能为空"))
}
// 使用 RPC 查询 USDC 余额(使用代理地址)
val balance = queryUsdcBalanceViaRpc(proxyAddress)
Result.success(balance)
@@ -264,6 +327,82 @@ class BlockchainService(
Result.failure(e)
}
}
/**
* 查询钱包余额通用方法
* 用于 Account Leader 的余额查询
* @param walletAddress 钱包地址代理地址Polymarket 使用代理地址存储资产
* @return WalletBalanceResponse 包含可用余额仓位余额总余额和持仓列表
*/
suspend fun getWalletBalance(walletAddress: String): Result<WalletBalanceResponse> {
return try {
if (walletAddress.isBlank()) {
logger.error("钱包地址为空,无法查询余额")
return Result.failure(IllegalArgumentException("钱包地址不能为空"))
}
// 1. 查询持仓信息(用于返回持仓列表)
val positionsResult = getPositions(walletAddress)
val positions = if (positionsResult.isSuccess) {
// 过滤掉价值为0的仓位
positionsResult.getOrNull()?.filter { pos ->
val currentValue = pos.currentValue ?: 0.0
currentValue > 0
}?.map { pos ->
PositionDto(
marketId = pos.conditionId ?: "",
title = pos.title,
side = pos.outcome ?: "",
quantity = pos.size?.toString() ?: "0",
avgPrice = pos.avgPrice?.toString() ?: "0",
currentValue = pos.currentValue?.toString() ?: "0",
pnl = pos.cashPnl?.toString()
)
} ?: emptyList()
} else {
logger.warn("持仓信息查询失败: ${positionsResult.exceptionOrNull()?.message}")
emptyList()
}
// 2. 使用 /value 接口获取仓位总价值
val positionBalanceResult = getTotalValue(walletAddress)
val positionBalance = if (positionBalanceResult.isSuccess) {
positionBalanceResult.getOrNull() ?: "0"
} else {
logger.warn("仓位总价值查询失败: ${positionBalanceResult.exceptionOrNull()?.message}")
"0"
}
// 3. 查询可用余额(通过 RPC 查询 USDC 余额)
val availableBalanceResult = getUsdcBalance(
walletAddress = walletAddress,
proxyAddress = walletAddress
)
val availableBalance = if (availableBalanceResult.isSuccess) {
availableBalanceResult.getOrNull() ?: throw Exception("USDC 余额查询返回空值")
} else {
// 如果 RPC 查询失败,返回错误(不返回 mock 数据)
val error = availableBalanceResult.exceptionOrNull()
logger.error("USDC 可用余额 RPC 查询失败: ${error?.message}")
throw Exception("USDC 可用余额查询失败: ${error?.message}。请确保已配置 Ethereum RPC URL")
}
// 4. 计算总余额 = 可用余额 + 仓位余额
val totalBalance = availableBalance.toSafeBigDecimal().add(positionBalance.toSafeBigDecimal())
Result.success(
WalletBalanceResponse(
availableBalance = availableBalance,
positionBalance = positionBalance,
totalBalance = totalBalance.toPlainString(),
positions = positions
)
)
} catch (e: Exception) {
logger.error("查询钱包余额失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 通过 RPC 查询 USDC 余额
@@ -502,45 +641,220 @@ class BlockchainService(
/**
* 赎回仓位
* 通过代理钱包的 execTransaction 调用 ConditionalTokens 合约的 redeemPositions 函数
*
* 使用 RelayClientService 实现完全参考 TypeScript 项目的实现方式
*
* Safe 账户通过代理 execTransaction 调用Magic 账户通过 Builder Relayer PROXYGasless执行
*
* @param privateKey 私钥原始钱包的私钥用于签名交易
* @param proxyAddress 代理地址Gnosis Safe 代理钱包地址
* @param proxyAddress 代理地址Safe Magic 代理钱包地址
* @param conditionId 市场条件IDbytes32必须是 0x 开头的 66 位十六进制字符串
* @param indexSets 要赎回的索引集合列表每个元素是 2^outcomeIndex例如 [1] 表示 outcome 0[2] 表示 outcome 1
* @param indexSets 要赎回的索引集合列表每个元素是 2^outcomeIndex
* @param isNegRisk 是否为 Neg Risk 市场true 时使用 WrappedCollateral 作为抵押品
* @param walletType 钱包类型MAGIC SAFE用于选择执行路径
* @return 交易哈希
*/
suspend fun redeemPositions(
privateKey: String,
proxyAddress: String,
conditionId: String,
indexSets: List<BigInteger>
indexSets: List<BigInteger>,
isNegRisk: Boolean = false,
walletType: WalletType = WalletType.SAFE
): Result<String> {
return try {
// 验证参数
if (indexSets.isEmpty()) {
return Result.failure(IllegalArgumentException("indexSets 不能为空"))
}
if (conditionId.isBlank() || !conditionId.startsWith("0x") || conditionId.length != 66) {
return Result.failure(IllegalArgumentException("conditionId 格式错误,必须是 0x 开头的 66 位十六进制字符串"))
}
if (proxyAddress.isBlank() || !proxyAddress.startsWith("0x") || proxyAddress.length != 42) {
return Result.failure(IllegalArgumentException("proxyAddress 格式错误,必须是有效的以太坊地址"))
}
// 使用 RelayClientService 创建赎回交易并执行
val redeemTx = relayClientService.createRedeemTx(conditionId, indexSets)
relayClientService.execute(privateKey, proxyAddress, redeemTx)
val redeemTx = relayClientService.createRedeemTx(conditionId, indexSets, isNegRisk)
relayClientService.execute(privateKey, proxyAddress, redeemTx, walletType)
} catch (e: Exception) {
logger.error("赎回仓位失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 批量赎回多个市场的仓位使用 MultiSend 合并为一笔交易
* 仅支持 Safe 钱包类型Magic 钱包不支持 MultiSend
*
* @param privateKey 私钥原始钱包的私钥用于签名交易
* @param proxyAddress 代理地址Safe 代理钱包地址
* @param redeemRequests 赎回请求列表每个元素是 (conditionId, indexSets, isNegRisk)
* @param walletType 钱包类型仅支持 SAFE
* @return 交易哈希
*/
suspend fun redeemPositionsBatch(
privateKey: String,
proxyAddress: String,
redeemRequests: List<Triple<String, List<BigInteger>, Boolean>>,
walletType: WalletType = WalletType.SAFE
): Result<String> {
return try {
if (redeemRequests.isEmpty()) {
return Result.failure(IllegalArgumentException("redeemRequests 不能为空"))
}
// Magic 钱包不支持 MultiSend
if (walletType == WalletType.MAGIC) {
return Result.failure(IllegalArgumentException("Magic 钱包不支持 MultiSend 批量赎回,请使用逐笔赎回"))
}
if (proxyAddress.isBlank() || !proxyAddress.startsWith("0x") || proxyAddress.length != 42) {
return Result.failure(IllegalArgumentException("proxyAddress 格式错误,必须是有效的以太坊地址"))
}
// 验证所有 conditionId 格式
for ((conditionId, _, _) in redeemRequests) {
if (conditionId.isBlank() || !conditionId.startsWith("0x") || conditionId.length != 66) {
return Result.failure(IllegalArgumentException("conditionId 格式错误: $conditionId"))
}
}
// 创建每个市场的赎回交易(Neg Risk 市场使用 WrappedCollateral
val redeemTxs = redeemRequests.map { (conditionId, indexSets, isNegRisk) ->
if (indexSets.isEmpty()) {
throw IllegalArgumentException("indexSets 不能为空: $conditionId")
}
relayClientService.createRedeemTx(conditionId, indexSets, isNegRisk)
}
// 使用 MultiSend 合并所有交易
val multiSendTx = relayClientService.createMultiSendTx(redeemTxs)
logger.info("批量赎回: 合并 ${redeemRequests.size} 个市场为一笔交易")
relayClientService.execute(privateKey, proxyAddress, multiSendTx, walletType)
} catch (e: Exception) {
logger.error("批量赎回仓位失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 轮询等待交易上链并确认成功
* @param txHash 交易 hash0x 开头
* @param maxWaitMs 最大等待毫秒数
* @param pollIntervalMs 轮询间隔毫秒数
* @return 成功返回 Unit超时或 revert 返回 Result.failure
*/
suspend fun waitForTransactionConfirmed(
txHash: String,
maxWaitMs: Long = 120_000,
pollIntervalMs: Long = 3_000
): Result<Unit> {
val rpcApi = polygonRpcApi
val start = System.currentTimeMillis()
while (System.currentTimeMillis() - start < maxWaitMs) {
val req = JsonRpcRequest(method = "eth_getTransactionReceipt", params = listOf(txHash))
val response = rpcApi.call(req)
if (!response.isSuccessful || response.body() == null) {
delay(pollIntervalMs)
continue
}
val body = response.body()!!
if (body.error != null) {
delay(pollIntervalMs)
continue
}
val result = body.result
if (result == null || result.isJsonNull) {
delay(pollIntervalMs)
continue
}
val status = result.asJsonObject?.get("status")?.asString
if (status == null) {
delay(pollIntervalMs)
continue
}
return when (status) {
"0x1" -> Result.success(Unit)
"0x0" -> Result.failure(Exception("交易已上链但执行失败 (revert)"))
else -> Result.failure(Exception("交易状态异常: $status"))
}
}
return Result.failure(Exception("等待交易确认超时 (${maxWaitMs}ms)"))
}
/**
* 查询代理地址的 WCOLWrapped Collateral余额raw6 位小数
*/
suspend fun getWcolBalance(proxyAddress: String): Result<BigInteger> {
val rpcApi = polygonRpcApi
val functionSelector = "0x70a08231" // balanceOf(address)
val paddedAddress = proxyAddress.removePrefix("0x").lowercase().padStart(64, '0')
val data = functionSelector + paddedAddress
val rpcRequest = JsonRpcRequest(
method = "eth_call",
params = listOf(
mapOf(
"to" to wcolContractAddress,
"data" to data
),
"latest"
)
)
val response = rpcApi.call(rpcRequest)
if (!response.isSuccessful || response.body() == null) {
return Result.failure(Exception("查询 WCOL 余额失败: ${response.code()} ${response.message()}"))
}
val rpcResponse = response.body()!!
if (rpcResponse.error != null) {
return Result.failure(Exception("查询 WCOL 余额失败: ${rpcResponse.error.message}"))
}
val hexBalance = rpcResponse.result?.asString ?: return Result.failure(Exception("WCOL 余额结果为空"))
val balance = EthereumUtils.decodeUint256(hexBalance)
return Result.success(balance)
}
/**
* 将代理钱包内的 WCOL 解包为 USDC.e解包后转入代理地址
* 赎回 Neg Risk 仓位后到账为 WCOL调用此方法可转为 USDC.e 以便显示/使用
*
* Safe Magic 使用同一套逻辑同一 [createUnwrapWcolTx] + [RelayClientService.execute]
* Safe execTransactionMagic PROXY 编码最终均为代理合约调用 WCOL.unwrap(proxyAddress, amount)USDC.e 转入 proxyAddress
*
* @param privateKey 主钱包私钥
* @param proxyAddress 代理地址Safe Magic 代理
* @param walletType 钱包类型SAFE / MAGIC用于选择 Relayer 执行路径
* @return 成功返回交易 hash余额为 0 返回 null失败返回 Result.failure
*/
suspend fun unwrapWcolForProxy(
privateKey: String,
proxyAddress: String,
walletType: WalletType
): Result<String?> {
return try {
val balanceResult = getWcolBalance(proxyAddress)
val balance = balanceResult.getOrElse {
logger.warn("查询 WCOL 余额失败,跳过解包: ${it.message}")
return Result.success(null)
}
if (balance == BigInteger.ZERO) {
return Result.success(null)
}
val unwrapTx = relayClientService.createUnwrapWcolTx(proxyAddress, balance)
val executeResult = relayClientService.execute(privateKey, proxyAddress, unwrapTx, walletType)
executeResult.fold(
onSuccess = { txHash ->
logger.info("WCOL 解包成功: proxy=${proxyAddress.take(10)}..., txHash=$txHash")
Result.success(txHash)
},
onFailure = { e ->
logger.error("WCOL 解包失败: ${e.message}", e)
Result.failure(e)
}
)
} catch (e: Exception) {
logger.error("WCOL 解包异常: ${e.message}", e)
Result.failure(e)
}
}
/**
* 获取代理钱包的 nonce用于构建 Safe 交易
*/
@@ -9,6 +9,8 @@ import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.math.BigInteger
import com.github.benmanes.caffeine.cache.Cache
import com.github.benmanes.caffeine.cache.Caffeine
/**
* 市场价格服务
@@ -27,6 +29,21 @@ class MarketPriceService(
private val logger = LoggerFactory.getLogger(MarketPriceService::class.java)
/**
* 已结算市场的价格缓存
* Key: "marketId:outcomeIndex"
* Value: BigDecimal (1.0 0.0)
*
* 缓存策略
* - 最大缓存 10,000 个已结算市场
* - 永不过期已结算的市场状态永不改变
* - 内存占用约: 10,000 * ~100 bytes = ~1MB
*/
private val settledMarketCache: Cache<String, BigDecimal> = Caffeine.newBuilder()
.maximumSize(10_000)
.recordStats() // 启用统计信息
.build()
/**
* 获取当前市场最新价
* 优先级
@@ -83,9 +100,20 @@ class MarketPriceService(
* - payout == 0输了 返回 0.0
* 如果市场未结算或查询失败返回 null
*
* 使用缓存优化已结算的市场结果会被缓存避免重复 RPC 调用
*
* @return Pair<BigDecimal?, Boolean> 第一个值是价格如果已结算第二个值表示是否发生了 RPC 错误execution reverted
*/
private suspend fun getPriceFromChainCondition(marketId: String, outcomeIndex: Int): Pair<BigDecimal?, Boolean> {
// 1. 先检查缓存
val cacheKey = "$marketId:$outcomeIndex"
val cachedPrice = settledMarketCache.getIfPresent(cacheKey)
if (cachedPrice != null) {
logger.debug("从缓存获取已结算市场价格: marketId=$marketId, outcomeIndex=$outcomeIndex, price=$cachedPrice")
return Pair(cachedPrice, false)
}
// 2. 缓存未命中,发起 RPC 查询
return try {
val chainResult = blockchainService.getCondition(marketId)
chainResult.fold(
@@ -96,11 +124,17 @@ class MarketPriceService(
when {
payout > BigInteger.ZERO -> {
logger.info("从链上查询到市场已结算,该 outcome 赢了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
return Pair(BigDecimal.ONE, false)
val price = BigDecimal.ONE
// 缓存已结算的结果
settledMarketCache.put(cacheKey, price)
return Pair(price, false)
}
payout == BigInteger.ZERO -> {
logger.info("从链上查询到市场已结算,该 outcome 输了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
return Pair(BigDecimal.ZERO, false)
val price = BigDecimal.ZERO
// 缓存已结算的结果
settledMarketCache.put(cacheKey, price)
return Pair(price, false)
}
else -> {
logger.warn("从链上查询到异常的 payout 值: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
@@ -109,7 +143,7 @@ class MarketPriceService(
}
} else {
logger.debug("从链上查询到市场尚未结算: marketId=$marketId, payouts=${payouts.size}")
Pair(null, false)
Pair(null, false) // 未结算的市场不缓存
}
},
onFailure = { e ->
@@ -290,5 +324,29 @@ class MarketPriceService(
}
}
/**
* 获取缓存统计信息
* 用于监控缓存命中率和性能
*/
fun getCacheStats(): String {
val stats = settledMarketCache.stats()
return """
已结算市场缓存统计:
- 缓存条目数: ${settledMarketCache.estimatedSize()}
- 命中次数: ${stats.hitCount()}
- 未命中次数: ${stats.missCount()}
- 命中率: ${"%.2f".format(stats.hitRate() * 100)}%
- 总请求次数: ${stats.requestCount()}
""".trimIndent()
}
/**
* 清空缓存测试或管理用
*/
fun clearSettledMarketCache() {
settledMarketCache.invalidateAll()
logger.info("已清空已结算市场缓存")
}
}
@@ -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)
@@ -7,6 +7,7 @@ import com.wrbug.polymarketbot.util.lt
import com.wrbug.polymarketbot.util.multi
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import com.wrbug.polymarketbot.util.JsonUtils
import com.wrbug.polymarketbot.util.DateUtils
import org.slf4j.LoggerFactory
import com.wrbug.polymarketbot.service.common.PolymarketClobService
import com.wrbug.polymarketbot.service.accounts.AccountService
@@ -202,12 +203,16 @@ class CopyTradingFilterService(
// 检查最低价格
if (copyTrading.minPrice != null && tradePrice.lt(copyTrading.minPrice)) {
return FilterResult.priceRangeFailed("价格低于最低限制: $tradePrice < ${copyTrading.minPrice}")
val priceStr = tradePrice.stripTrailingZeros().toPlainString()
val minPriceStr = copyTrading.minPrice.stripTrailingZeros().toPlainString()
return FilterResult.priceRangeFailed("价格低于最低限制: $priceStr < $minPriceStr")
}
// 检查最高价格
if (copyTrading.maxPrice != null && tradePrice.gt(copyTrading.maxPrice)) {
return FilterResult.priceRangeFailed("价格高于最高限制: $tradePrice > ${copyTrading.maxPrice}")
val priceStr = tradePrice.stripTrailingZeros().toPlainString()
val maxPriceStr = copyTrading.maxPrice.stripTrailingZeros().toPlainString()
return FilterResult.priceRangeFailed("价格高于最高限制: $priceStr > $maxPriceStr")
}
return FilterResult.passed()
@@ -245,7 +250,9 @@ class CopyTradingFilterService(
val spread = bestAsk.subtract(bestBid)
if (spread.gt(copyTrading.maxSpread)) {
return FilterResult.spreadFailed("价差过大: $spread > ${copyTrading.maxSpread}", orderbook)
val spreadStr = spread.stripTrailingZeros().toPlainString()
val maxSpreadStr = copyTrading.maxSpread.stripTrailingZeros().toPlainString()
return FilterResult.spreadFailed("价差过大: $spreadStr > $maxSpreadStr", orderbook)
}
return FilterResult.passed()
@@ -285,7 +292,9 @@ class CopyTradingFilterService(
val totalDepth = bidsDepth.add(asksDepth)
if (totalDepth.lt(copyTrading.minOrderDepth)) {
return FilterResult.orderDepthFailed("订单深度不足: $totalDepth < ${copyTrading.minOrderDepth}", orderbook)
val totalDepthStr = totalDepth.stripTrailingZeros().toPlainString()
val minDepthStr = copyTrading.minOrderDepth.stripTrailingZeros().toPlainString()
return FilterResult.orderDepthFailed("订单深度不足: $totalDepthStr < $minDepthStr", orderbook)
}
return FilterResult.passed()
@@ -306,7 +315,7 @@ class CopyTradingFilterService(
outcomeIndex: Int?
): FilterResult {
// 如果未配置仓位限制,直接通过
if (copyTrading.maxPositionValue == null && copyTrading.maxPositionCount == null) {
if (copyTrading.maxPositionValue == null) {
return FilterResult.passed()
}
@@ -348,41 +357,14 @@ class CopyTradingFilterService(
val totalValueAfterOrder = currentPositionValue.add(copyOrderAmount)
if (totalValueAfterOrder.gt(copyTrading.maxPositionValue)) {
val currentValueStr = currentPositionValue.stripTrailingZeros().toPlainString()
val dbValueStr = dbValue.stripTrailingZeros().toPlainString()
val extValueStr = extValue.stripTrailingZeros().toPlainString()
val orderAmountStr = copyOrderAmount.stripTrailingZeros().toPlainString()
val totalValueStr = totalValueAfterOrder.stripTrailingZeros().toPlainString()
val maxValueStr = copyTrading.maxPositionValue.stripTrailingZeros().toPlainString()
return FilterResult.maxPositionValueFailed(
"超过最大仓位金额限制: 市场=$marketId, 方向=$outcomeIndex, 当前仓位(取最大值)=${currentPositionValue} USDC (DB=${dbValue}, Ext=${extValue}), 跟单金额=${copyOrderAmount} USDC, 总计=${totalValueAfterOrder} USDC > 最大限制=${copyTrading.maxPositionValue} USDC"
)
}
}
// 检查最大仓位数量(如果配置了)
if (copyTrading.maxPositionCount != null) {
// 使用数据库中的订单记录计算活跃仓位数量(解决延迟问题)
val dbCount = copyOrderTrackingRepository.countActivePositions(copyTrading.id!!)
// 计算外部持仓中的唯一市场数量(防止遗漏非本项目创建的仓位)
val extCount = positions.currentPositions
.filter { it.accountId == copyTrading.accountId }
.map { it.marketId }
.distinct()
.size
val currentPositionCount = maxOf(dbCount, extCount)
// 检查:如果当前没有该市场该方向的活跃仓位,且总仓位数量已达到限制,则不允许开新仓
// 判断当前市场该方向是否已有活跃仓位(数据库)
val hasDbPosition = if (outcomeIndex != null) {
copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndex(
copyTrading.id, marketId, outcomeIndex
).isNotEmpty()
} else {
false
}
val hasExtPosition = marketPositions.isNotEmpty()
val hasCurrentMarketPosition = hasDbPosition || hasExtPosition
if (!hasCurrentMarketPosition && currentPositionCount >= copyTrading.maxPositionCount) {
return FilterResult.maxPositionCountFailed(
"超过最大仓位数量限制: 当前活跃仓位总数(取最大值)=${currentPositionCount} (DB=${dbCount}, Ext=${extCount}) >= 最大限制=${copyTrading.maxPositionCount}"
"超过最大仓位金额限制: 市场=$marketId, 方向=$outcomeIndex, 当前仓位(取最大值)=${currentValueStr} USDC (DB=${dbValueStr}, Ext=${extValueStr}), 跟单金额=${orderAmountStr} USDC, 总计=${totalValueStr} USDC > 最大限制=${maxValueStr} USDC"
)
}
}
@@ -420,8 +402,10 @@ class CopyTradingFilterService(
val remainingTime = marketEndDate - currentTime
if (remainingTime > copyTrading.maxMarketEndDate) {
val remainingTimeFormatted = DateUtils.formatDuration(remainingTime)
val maxLimitFormatted = DateUtils.formatDuration(copyTrading.maxMarketEndDate)
return FilterResult.marketEndDateFailed(
"市场截止时间超出限制: 剩余时间=${remainingTime}ms (${remainingTime / (1000 * 60 * 60)}小时) > 最大限制=${copyTrading.maxMarketEndDate}ms (${copyTrading.maxMarketEndDate / (1000 * 60 * 60)}小时)"
"市场截止时间超出限制: 剩余时间=${remainingTimeFormatted} > 最大限制=${maxLimitFormatted}"
)
}
@@ -100,7 +100,6 @@ class CopyTradingService(
minPrice = request.minPrice?.toSafeBigDecimal() ?: template.minPrice,
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice,
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
maxPositionCount = request.maxPositionCount,
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
keywords = convertKeywordsToJson(request.keywords),
maxMarketEndDate = request.maxMarketEndDate,
@@ -132,7 +131,6 @@ class CopyTradingService(
minPrice = request.minPrice?.toSafeBigDecimal(),
maxPrice = request.maxPrice?.toSafeBigDecimal(),
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
maxPositionCount = request.maxPositionCount,
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
keywords = convertKeywordsToJson(request.keywords),
maxMarketEndDate = request.maxMarketEndDate,
@@ -164,7 +162,6 @@ class CopyTradingService(
minPrice = config.minPrice,
maxPrice = config.maxPrice,
maxPositionValue = config.maxPositionValue,
maxPositionCount = config.maxPositionCount,
keywordFilterMode = config.keywordFilterMode,
keywords = config.keywords,
configName = configName,
@@ -282,16 +279,6 @@ class CopyTradingService(
} else {
copyTrading.maxPositionValue
},
// 处理 maxPositionCount-1 表示要清空(设置为 null),null 表示不更新
maxPositionCount = if (request.maxPositionCount != null) {
if (request.maxPositionCount == -1) {
null
} else {
request.maxPositionCount
}
} else {
copyTrading.maxPositionCount
},
keywordFilterMode = request.keywordFilterMode ?: copyTrading.keywordFilterMode,
keywords = if (request.keywords != null) {
convertKeywordsToJson(request.keywords)
@@ -520,7 +507,6 @@ class CopyTradingService(
minPrice = copyTrading.minPrice?.toPlainString(),
maxPrice = copyTrading.maxPrice?.toPlainString(),
maxPositionValue = copyTrading.maxPositionValue?.toPlainString(),
maxPositionCount = copyTrading.maxPositionCount,
keywordFilterMode = copyTrading.keywordFilterMode,
keywords = convertJsonToKeywords(copyTrading.keywords),
configName = copyTrading.configName,
@@ -585,7 +571,6 @@ class CopyTradingService(
val minPrice: BigDecimal?,
val maxPrice: BigDecimal?,
val maxPositionValue: BigDecimal?,
val maxPositionCount: Int?,
val keywordFilterMode: String,
val keywords: String?, // JSON 字符串
val maxMarketEndDate: Long?, // 市场截止时间限制(毫秒时间戳)
@@ -20,8 +20,6 @@ enum class FilterStatus {
FAILED_ORDER_DEPTH,
/** 失败:超过最大仓位金额 */
FAILED_MAX_POSITION_VALUE,
/** 失败:超过最大仓位数量 */
FAILED_MAX_POSITION_COUNT,
/** 失败:关键字过滤 */
FAILED_KEYWORD_FILTER,
/** 失败:市场截止时间超出限制 */
@@ -87,12 +85,6 @@ data class FilterResult(
status = FilterStatus.FAILED_MAX_POSITION_VALUE,
reason = reason
)
/** 超过最大仓位数量 */
fun maxPositionCountFailed(reason: String) = FilterResult(
status = FilterStatus.FAILED_MAX_POSITION_COUNT,
reason = reason
)
/** 关键字过滤失败 */
fun keywordFilterFailed(reason: String) = FilterResult(
@@ -3,12 +3,15 @@ package com.wrbug.polymarketbot.service.copytrading.leaders
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.entity.Leader
import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.repository.BacktestTaskRepository
import com.wrbug.polymarketbot.repository.CopyTradingRepository
import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.service.common.BlockchainService
import com.wrbug.polymarketbot.util.CategoryValidator
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import kotlinx.coroutines.runBlocking
/**
* Leader 管理服务
@@ -17,9 +20,11 @@ import org.springframework.transaction.annotation.Transactional
class LeaderService(
private val leaderRepository: LeaderRepository,
private val accountRepository: AccountRepository,
private val copyTradingRepository: CopyTradingRepository
private val copyTradingRepository: CopyTradingRepository,
private val backtestTaskRepository: BacktestTaskRepository,
private val blockchainService: BlockchainService
) {
private val logger = LoggerFactory.getLogger(LeaderService::class.java)
/**
@@ -154,7 +159,8 @@ class LeaderService(
val leaderDtos = leaders.map { leader ->
val copyTradingCount = copyTradingRepository.countByLeaderId(leader.id!!)
toDto(leader, copyTradingCount)
val backtestCount = backtestTaskRepository.findByLeaderId(leader.id).size.toLong()
toDto(leader, copyTradingCount, backtestCount)
}
Result.success(
@@ -176,19 +182,54 @@ class LeaderService(
return try {
val leader = leaderRepository.findById(leaderId).orElse(null)
?: return Result.failure(IllegalArgumentException("Leader 不存在"))
val copyTradingCount = copyTradingRepository.countByLeaderId(leaderId)
Result.success(toDto(leader, copyTradingCount))
val backtestCount = backtestTaskRepository.findByLeaderId(leaderId).size.toLong()
Result.success(toDto(leader, copyTradingCount, backtestCount))
} catch (e: Exception) {
logger.error("查询 Leader 详情失败", e)
Result.failure(e)
}
}
/**
* 查询 Leader 余额
* 使用代理地址查询 USDC 余额和持仓信息
*/
fun getLeaderBalance(leaderId: Long): Result<LeaderBalanceResponse> {
return try {
val leader = leaderRepository.findById(leaderId).orElse(null)
?: return Result.failure(IllegalArgumentException("Leader 不存在"))
// Leader 的 leaderAddress 就是代理地址
val walletAddress = leader.leaderAddress
// 使用通用方法查询余额
val balanceResult = runBlocking {
blockchainService.getWalletBalance(walletAddress)
}
balanceResult.map { walletBalance: WalletBalanceResponse ->
LeaderBalanceResponse(
leaderId = leader.id!!,
leaderAddress = leader.leaderAddress,
leaderName = leader.leaderName,
availableBalance = walletBalance.availableBalance,
positionBalance = walletBalance.positionBalance,
totalBalance = walletBalance.totalBalance,
positions = walletBalance.positions
)
}
} catch (e: Exception) {
logger.error("查询 Leader 余额失败", e)
Result.failure(e)
}
}
/**
* 转换为 DTO
*/
private fun toDto(leader: Leader, copyTradingCount: Long = 0): LeaderDto {
private fun toDto(leader: Leader, copyTradingCount: Long = 0, backtestCount: Long = 0): LeaderDto {
return LeaderDto(
id = leader.id!!,
leaderAddress = leader.leaderAddress,
@@ -197,6 +238,7 @@ class LeaderService(
remark = leader.remark,
website = leader.website,
copyTradingCount = copyTradingCount,
backtestCount = backtestCount,
createdAt = leader.createdAt,
updatedAt = leader.updatedAt
)
@@ -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 位小数
@@ -36,13 +57,11 @@ class OrderSigningService {
size = 2,
amount = 4
)
// 金额精度限制(根据 Polymarket API 要求)
// makerAmount (USDC) 最多 2 位小数
// takerAmount (shares) 最多 4 位小数
private val MAKER_AMOUNT_DECIMALS = 2 // USDC 金额精度
private val TAKER_AMOUNT_DECIMALS = 4 // shares 数量精度
// 价格有效范围(Polymarket API 要求)
private val MIN_PRICE = BigDecimal("0.01")
private val MAX_PRICE = BigDecimal("0.99")
/**
* 订单金额计算结果
*/
@@ -62,7 +81,9 @@ class OrderSigningService {
/**
* 计算订单金额makerAmount takerAmount
*
*
* 参考 clob-client/src/order-builder/helpers.ts getOrderRawAmounts 函数
*
* @param side BUY SELL
* @param size 数量shares
* @param price 价格0-1 之间
@@ -77,49 +98,59 @@ class OrderSigningService {
): OrderAmounts {
val sizeDecimal = size.toSafeBigDecimal()
val priceDecimal = price.toSafeBigDecimal()
// 对价格进行 roundNormal 处理(与 clob-client 保持一致)
var rawPrice = roundNormal(priceDecimal, roundConfig.price)
// 验证价格范围,如果超出则调整到最接近的有效值
// Polymarket API 要求: 0.01 <= price <= 0.99
if (rawPrice > MAX_PRICE) {
logger.warn("价格超出最大限制,已调整: $priceDecimal -> $MAX_PRICE")
rawPrice = MAX_PRICE
} else if (rawPrice < MIN_PRICE) {
logger.warn("价格低于最小限制,已调整: $priceDecimal -> $MIN_PRICE")
rawPrice = MIN_PRICE
}
if (side.uppercase() == "BUY") {
// BUY: makerAmount = price * size (USDC), takerAmount = size (shares)
// makerAmount 是 USDC 金额,最多 2 位小数
// takerAmount 是 shares 数量,最多 4 位小数
val rawTakerAmt = roundDown(sizeDecimal, roundConfig.size)
// makerAmount = price * size,使用原始价格计算(与SDK保持一致)
// 先使用原始价格计算,然后再进行舍入,确保精度一致
var rawMakerAmt = rawTakerAmt.multiply(priceDecimal)
// 确保 makerAmount 精度(USDC,最多 2 位小数)
rawMakerAmt = roundDown(rawMakerAmt, MAKER_AMOUNT_DECIMALS)
// 确保 takerAmount 精度(shares,最多 4 位小数)
val finalTakerAmt = roundDown(rawTakerAmt, TAKER_AMOUNT_DECIMALS)
// 参考 clob-client/src/order-builder/helpers.ts 第 73-89 行
// 注意:Polymarket API 要求市场买入订单的 makerAmount 最多 2 位小数,takerAmount 最多 4 位小数
// takerAmount (shares) 使用 4 位小数
val rawTakerAmt = roundDown(sizeDecimal, 4)
var rawMakerAmt = rawTakerAmt.multiply(rawPrice)
// makerAmount (USDC) 使用 2 位小数
if (decimalPlaces(rawMakerAmt) > 2) {
rawMakerAmt = roundUp(rawMakerAmt, 2 + 4)
if (decimalPlaces(rawMakerAmt) > 2) {
rawMakerAmt = roundDown(rawMakerAmt, 2)
}
}
// 转换为 wei6 位小数)
val makerAmount = parseUnits(rawMakerAmt, COLLATERAL_TOKEN_DECIMALS)
val takerAmount = parseUnits(finalTakerAmt, COLLATERAL_TOKEN_DECIMALS)
val takerAmount = parseUnits(rawTakerAmt, COLLATERAL_TOKEN_DECIMALS)
return OrderAmounts(makerAmount.toString(), takerAmount.toString())
} else {
// SELL: makerAmount = size (shares), takerAmount = price * size (USDC)
// 根据 Polymarket API 要求:
// - makerAmount (shares) 最多 2 位小数
// - takerAmount (USDC) 最多 4 位小数
// 参考 clob-client/src/order-builder/helpers.ts 第 90-105 行
val rawMakerAmt = roundDown(sizeDecimal, roundConfig.size)
// takerAmount = price * size,使用原始价格计算(不使用舍入后的价格)
// SDK期望使用原始价格进行计算,以保留足够的精度
// 例如:0.9596 * 16.09 = 15.439964,而不是 0.96 * 16.09 = 15.4464
val rawTakerAmt = rawMakerAmt.multiply(priceDecimal)
// 确保 makerAmount 精度(shares,最多 2 位小数,符合 API 要求)
val finalMakerAmt = roundDown(rawMakerAmt, MAKER_AMOUNT_DECIMALS)
// 确保 takerAmount 精度(USDC,最多 4 位小数,符合 API 要求)
val finalTakerAmt = roundDown(rawTakerAmt, TAKER_AMOUNT_DECIMALS)
var rawTakerAmt = rawMakerAmt.multiply(rawPrice)
// 如果 takerAmount 的小数位数超过 roundConfig.amount,进行特殊舍入处理
if (decimalPlaces(rawTakerAmt) > roundConfig.amount) {
rawTakerAmt = roundUp(rawTakerAmt, roundConfig.amount + 4)
if (decimalPlaces(rawTakerAmt) > roundConfig.amount) {
rawTakerAmt = roundDown(rawTakerAmt, roundConfig.amount)
}
}
// 转换为 wei6 位小数)
val makerAmount = parseUnits(finalMakerAmt, COLLATERAL_TOKEN_DECIMALS)
val takerAmount = parseUnits(finalTakerAmt, COLLATERAL_TOKEN_DECIMALS)
val makerAmount = parseUnits(rawMakerAmt, COLLATERAL_TOKEN_DECIMALS)
val takerAmount = parseUnits(rawTakerAmt, COLLATERAL_TOKEN_DECIMALS)
return OrderAmounts(makerAmount.toString(), takerAmount.toString())
}
}
@@ -137,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(
@@ -149,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. 从私钥获取签名地址
@@ -182,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,
@@ -247,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,
@@ -273,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)
@@ -303,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
}
/**
@@ -324,23 +359,65 @@ class OrderSigningService {
/**
* 正常舍入四舍五入
* 参考 clob-client/src/utilities.ts roundNormal 函数
* 只有当小数位数超过 decimals 时才进行舍入
*
* @param value 要舍入的数值
* @param decimals 目标小数位数
* @return 舍入后的数值
*/
private fun roundNormal(value: BigDecimal, decimals: Int): BigDecimal {
if (decimalPlaces(value) <= decimals) {
return value
}
return value.setScale(decimals, RoundingMode.HALF_UP)
}
/**
* 向下舍入
* 参考 clob-client/src/utilities.ts roundDown 函数
* 只有当小数位数超过 decimals 时才进行舍入
*
* @param value 要舍入的数值
* @param decimals 目标小数位数
* @return 舍入后的数值
*/
private fun roundDown(value: BigDecimal, decimals: Int): BigDecimal {
if (decimalPlaces(value) <= decimals) {
return value
}
return value.setScale(decimals, RoundingMode.DOWN)
}
/**
* 向上舍入
* 参考 clob-client/src/utilities.ts roundUp 函数
* 只有当小数位数超过 decimals 时才进行舍入
*
* @param value 要舍入的数值
* @param decimals 目标小数位数
* @return 舍入后的数值
*/
private fun roundUp(value: BigDecimal, decimals: Int): BigDecimal {
if (decimalPlaces(value) <= decimals) {
return value
}
return value.setScale(decimals, RoundingMode.UP)
}
/**
* 计算 BigDecimal 的小数位数
* 参考 clob-client/src/utilities.ts decimalPlaces 函数
*
* @param value 要计算的数值
* @return 小数位数
*/
private fun decimalPlaces(value: BigDecimal): Int {
if (value.scale() <= 0) {
return 0
}
// 去除尾部的零,获取真实的小数位数
return value.stripTrailingZeros().scale()
}
}
@@ -185,6 +185,7 @@ open class CopyOrderTrackingService(
processedAt = System.currentTimeMillis()
)
processedTradeRepository.save(processed)
} catch (e: Exception) {
// 检查是否是唯一键冲突异常(理论上不会发生,但保留作为兜底)
if (isUniqueConstraintViolation(e)) {
@@ -257,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
// 先计算跟单金额(用于仓位检查)
// 注意:这里先计算金额,即使后续被过滤也会记录
@@ -292,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) {
@@ -311,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) {
@@ -324,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
@@ -345,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(),
@@ -375,7 +393,7 @@ open class CopyOrderTrackingService(
telegramNotificationService?.sendOrderFilteredNotification(
marketTitle = marketTitle,
marketId = trade.market,
marketId = effectiveMarketId,
marketSlug = marketSlug,
side = "BUY",
outcome = trade.outcome,
@@ -555,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并重新签名,确保签名唯一性
@@ -562,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(),
@@ -569,7 +594,8 @@ open class CopyOrderTrackingService(
owner = account.apiKey,
copyTradingId = copyTrading.id!!,
tradeId = trade.id,
feeRateBps = feeRateBps
feeRateBps = feeRateBps,
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
)
// 处理订单创建失败
@@ -583,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
@@ -596,7 +622,7 @@ open class CopyOrderTrackingService(
telegramNotificationService?.sendOrderFailureNotification(
marketTitle = marketTitle,
marketId = trade.market,
marketId = effectiveMarketId,
marketSlug = marketSlug,
side = "BUY",
outcome = null, // 失败时可能没有 outcome
@@ -630,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 买入数量(用于固定金额模式计算卖出比例)
@@ -902,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 需要使用实际卖出价格
@@ -993,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,
@@ -1002,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(
@@ -1022,7 +1062,7 @@ open class CopyOrderTrackingService(
deferExec = false
)
// 11. 创建带认证的CLOB API客户端(使用解密后的凭证)
// 12. 创建带认证的CLOB API客户端(使用解密后的凭证)
val clobApi = retrofitFactory.createClobApi(
account.apiKey,
apiSecret,
@@ -1030,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(),
@@ -1043,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) {
@@ -1127,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 价格
@@ -1136,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,
@@ -1149,7 +1196,8 @@ open class CopyOrderTrackingService(
owner: String,
copyTradingId: Long,
tradeId: String,
feeRateBps: String
feeRateBps: String,
signatureType: Int
): Result<String> {
var lastError: Exception? = null
@@ -1164,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 的交易,避免订单长期挂单导致价格不匹配
@@ -1430,7 +1486,6 @@ open class CopyOrderTrackingService(
FilterStatus.FAILED_SPREAD -> "SPREAD"
FilterStatus.FAILED_ORDER_DEPTH -> "ORDER_DEPTH"
FilterStatus.FAILED_MAX_POSITION_VALUE -> "MAX_POSITION_VALUE"
FilterStatus.FAILED_MAX_POSITION_COUNT -> "MAX_POSITION_COUNT"
FilterStatus.FAILED_KEYWORD_FILTER -> "KEYWORD_FILTER"
FilterStatus.FAILED_MARKET_END_DATE -> "MARKET_END_DATE"
}
@@ -69,9 +69,6 @@ class OrderStatusUpdateService(
// 订单详情为 null 的重试时间窗口(1分钟)
private val ORDER_NULL_RETRY_WINDOW_MS = 60000L
// 订单详情为 null 但已部分卖出的清理时间窗口(1小时)
private val PARTIAL_SOLD_CLEANUP_WINDOW_MS = 3600000L
@EventListener(ApplicationReadyEvent::class)
fun onApplicationReady() {
logger.info("订单状态更新服务已启动,将每5秒轮询一次")
@@ -201,8 +198,6 @@ class OrderStatusUpdateService(
return
}
logger.debug("检查 ${ordersToCheck.size} 个30秒前创建的订单是否成交")
// 按账户分组,避免重复创建 API 客户端
val ordersByAccount = ordersToCheck.groupBy { it.accountId }
@@ -297,53 +292,38 @@ class OrderStatusUpdateService(
updatedAt = System.currentTimeMillis()
)
copyOrderTrackingRepository.save(updatedOrder)
// 清除缓存(仅在处理完成后清除)
orderNullDetectionTime.remove(order.buyOrderId)
} catch (e: Exception) {
logger.error("更新订单状态失败: orderId=${order.buyOrderId}, error=${e.message}", e)
}
}
// 清除缓存,下次重新检测
orderNullDetectionTime.remove(order.buyOrderId)
// 未超过60秒,继续等待,不清除缓存
continue
}
// 检查订单是否已部分卖出,如果已部分卖出则保留订单用于统计
val hasMatchedDetails =
sellMatchDetailRepository.findByTrackingId(order.id!!).isNotEmpty()
if (hasMatchedDetails || order.matchedQuantity > BigDecimal.ZERO) {
// 检查是否超过清理时间窗口(1小时)
val orderAge = currentTime - order.createdAt
if (orderAge >= PARTIAL_SOLD_CLEANUP_WINDOW_MS) {
logger.warn("订单详情为 null 且已部分卖出,但超过清理时间窗口,删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}, 已等待=${orderAge / 1000}s")
try {
copyOrderTrackingRepository.deleteById(order.id!!)
logger.info("已删除本地订单(超时清理): orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
// 清除缓存
orderNullDetectionTime.remove(order.buyOrderId)
} catch (e: Exception) {
logger.error(
"删除本地订单失败: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, error=${e.message}",
e
)
}
continue
} else {
logger.debug("订单详情为 null 但已部分卖出,保留订单用于统计: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}, 已等待=${orderAge / 1000}s")
// 清除缓存,下次重新检测
orderNullDetectionTime.remove(order.buyOrderId)
continue
}
}
// 检查是否超过重试时间窗口
// 检查是否超过重试时间窗口(统一使用60秒,无论是否已部分卖出)
if (currentTime - firstDetectionTime < ORDER_NULL_RETRY_WINDOW_MS) {
// 未超过重试窗口,记录日志并等待下次轮询
val elapsedSeconds = ((currentTime - firstDetectionTime) / 1000).toInt()
logger.debug("订单详情为 null(可能是网络异常),等待重试: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, 已等待=${elapsedSeconds}s, 重试窗口=${ORDER_NULL_RETRY_WINDOW_MS / 1000}s")
val hasMatchedDetails = sellMatchDetailRepository.findByTrackingId(order.id!!).isNotEmpty()
val hasPartialSold = hasMatchedDetails || order.matchedQuantity > BigDecimal.ZERO
if (hasPartialSold) {
logger.debug("订单详情为 null 且已部分卖出,等待重试: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}, 已等待=${elapsedSeconds}s, 重试窗口=${ORDER_NULL_RETRY_WINDOW_MS / 1000}s")
} else {
logger.debug("订单详情为 null(可能是网络异常),等待重试: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, 已等待=${elapsedSeconds}s, 重试窗口=${ORDER_NULL_RETRY_WINDOW_MS / 1000}s")
}
continue
}
// 超过重试窗口,删除本地订单
logger.warn("订单详情为 null 超过重试窗口,删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, 已等待=$((currentTime - firstDetectionTime) / 1000}s")
// 超过重试窗口,删除本地订单(无论是否已部分卖出)
val hasMatchedDetails = sellMatchDetailRepository.findByTrackingId(order.id!!).isNotEmpty()
val hasPartialSold = hasMatchedDetails || order.matchedQuantity > BigDecimal.ZERO
if (hasPartialSold) {
logger.warn("订单详情为 null 且已部分卖出,超过重试窗口,删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}, 已等待=$((currentTime - firstDetectionTime) / 1000}s")
} else {
logger.warn("订单详情为 null 超过重试窗口,删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, 已等待=$((currentTime - firstDetectionTime) / 1000}s")
}
try {
copyOrderTrackingRepository.deleteById(order.id!!)
logger.info("已删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
@@ -775,52 +755,38 @@ class OrderStatusUpdateService(
updatedAt = System.currentTimeMillis()
)
copyOrderTrackingRepository.save(updatedOrder)
// 清除缓存(仅在处理完成后清除)
orderNullDetectionTime.remove(order.buyOrderId)
} catch (e: Exception) {
logger.error("更新订单状态失败: orderId=${order.buyOrderId}, error=${e.message}", e)
}
}
// 清除缓存,下次重新检测
orderNullDetectionTime.remove(order.buyOrderId)
// 未超过60秒,继续等待,不清除缓存
continue
}
// 检查订单是否已部分卖出,如果已部分卖出则保留订单用于统计
val hasMatchedDetails = sellMatchDetailRepository.findByTrackingId(order.id!!).isNotEmpty()
if (hasMatchedDetails || order.matchedQuantity > BigDecimal.ZERO) {
// 检查是否超过清理时间窗口(1小时)
val orderAge = currentTime - order.createdAt
if (orderAge >= PARTIAL_SOLD_CLEANUP_WINDOW_MS) {
logger.warn("订单详情为 null 且已部分卖出,但超过清理时间窗口,删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}, 已等待=${orderAge / 1000}s")
try {
copyOrderTrackingRepository.deleteById(order.id!!)
logger.info("已删除本地订单(超时清理): orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
// 清除缓存
orderNullDetectionTime.remove(order.buyOrderId)
} catch (e: Exception) {
logger.error(
"删除本地订单失败: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, error=${e.message}",
e
)
}
continue
} else {
logger.debug("订单详情为 null 但已部分卖出,保留订单用于统计: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}, 已等待=${orderAge / 1000}s")
// 清除缓存,下次重新检测
orderNullDetectionTime.remove(order.buyOrderId)
continue
}
}
// 检查是否超过重试时间窗口
// 检查是否超过重试时间窗口(统一使用60秒,无论是否已部分卖出)
if (currentTime - firstDetectionTime < ORDER_NULL_RETRY_WINDOW_MS) {
// 未超过重试窗口,记录日志并等待下次轮询
val elapsedSeconds = ((currentTime - firstDetectionTime) / 1000).toInt()
logger.debug("订单详情为 null(可能是网络异常),等待重试: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, 已等待=${elapsedSeconds}s, 重试窗口=${ORDER_NULL_RETRY_WINDOW_MS / 1000}s")
val hasMatchedDetails = sellMatchDetailRepository.findByTrackingId(order.id!!).isNotEmpty()
val hasPartialSold = hasMatchedDetails || order.matchedQuantity > BigDecimal.ZERO
if (hasPartialSold) {
logger.debug("订单详情为 null 且已部分卖出,等待重试: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}, 已等待=${elapsedSeconds}s, 重试窗口=${ORDER_NULL_RETRY_WINDOW_MS / 1000}s")
} else {
logger.debug("订单详情为 null(可能是网络异常),等待重试: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, 已等待=${elapsedSeconds}s, 重试窗口=${ORDER_NULL_RETRY_WINDOW_MS / 1000}s")
}
continue
}
// 超过重试窗口,删除本地订单
logger.warn("订单详情为 null 超过重试窗口,删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, 已等待=$((currentTime - firstDetectionTime) / 1000}s")
// 超过重试窗口,删除本地订单(无论是否已部分卖出)
val hasMatchedDetails = sellMatchDetailRepository.findByTrackingId(order.id!!).isNotEmpty()
val hasPartialSold = hasMatchedDetails || order.matchedQuantity > BigDecimal.ZERO
if (hasPartialSold) {
logger.warn("订单详情为 null 且已部分卖出,超过重试窗口,删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, matchedQuantity=${order.matchedQuantity}, 已等待=$((currentTime - firstDetectionTime) / 1000}s")
} else {
logger.warn("订单详情为 null 超过重试窗口,删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}, 已等待=$((currentTime - firstDetectionTime) / 1000}s")
}
try {
copyOrderTrackingRepository.deleteById(order.id!!)
logger.info("已删除本地订单: orderId=${order.buyOrderId}, copyOrderTrackingId=${order.id}")
@@ -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,410 @@
package com.wrbug.polymarketbot.service.cryptotail
import com.wrbug.polymarketbot.api.GammaEventBySlugResponse
import com.wrbug.polymarketbot.constants.PolymarketConstants
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
import com.wrbug.polymarketbot.enums.SpreadMode
import com.wrbug.polymarketbot.event.CryptoTailStrategyChangedEvent
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.createClient
import com.wrbug.polymarketbot.util.fromJson
import com.wrbug.polymarketbot.util.gt
import com.wrbug.polymarketbot.util.toJson
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import org.slf4j.LoggerFactory
import org.springframework.context.event.EventListener
import org.springframework.stereotype.Service
import jakarta.annotation.PostConstruct
import 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
// Polymarket book 的 bids 为价格升序,bids[0] 为最低买价;bestBid 应取最高买价
var bestBid: BigDecimal? = null
for (i in 0 until bids.size()) {
val level = bids.get(i) as? com.google.gson.JsonObject ?: continue
val p = (level.get("price") as? com.google.gson.JsonPrimitive)?.asString?.toSafeBigDecimal() ?: continue
if (bestBid == null || p.gt(bestBid)) bestBid = p
}
if (bestBid != null) onBestBid(assetId, bestBid)
}
"price_change" -> {
val priceChanges = json.get("price_changes") as? com.google.gson.JsonArray ?: return
for (i in 0 until priceChanges.size()) {
val pc = priceChanges.get(i) as? com.google.gson.JsonObject ?: continue
val assetId = (pc.get("asset_id") as? com.google.gson.JsonPrimitive)?.asString ?: continue
val bestBidStr = (pc.get("best_bid") as? com.google.gson.JsonPrimitive)?.asString
val bestBid = bestBidStr?.toSafeBigDecimal()
if (bestBid != null) onBestBid(assetId, bestBid)
}
}
}
}
private fun onBestBid(tokenId: String, bestBid: BigDecimal) {
if (closedForNoStrategies.get()) return
val entries = tokenToEntries.get()[tokenId]
if (entries == null) return
val nowSeconds = System.currentTimeMillis() / 1000
for (e in entries) {
val windowStart = e.periodStartUnix + e.strategy.windowStartSeconds
val windowEnd = e.periodStartUnix + e.strategy.windowEndSeconds
if (nowSeconds < windowStart || nowSeconds >= windowEnd) continue
scope.launch {
try {
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)
precomputeAutoSpreadForCurrentPeriods(newMap)
return
}
closeWebSocketAndReconnect()
return
}
val marketSlugs = newMap.values.asSequence().flatten()
.distinctBy { "${it.strategy.marketSlugPrefix}-${it.periodStartUnix}" }
.map { "${it.strategy.marketSlugPrefix}-${it.periodStartUnix}" }
.toList()
val msg = """{"type":"MARKET","assets_ids":${tokenIds.toJson()}}"""
try {
webSocket?.send(msg)
logger.info("尾盘策略订单簿订阅: ${tokenIds.size} 个 token, 市场: $marketSlugs")
} catch (e: Exception) {
logger.warn("发送订阅失败: ${e.message}")
return
}
scheduleRefreshAtPeriodEnd(newMap)
precomputeAutoSpreadForCurrentPeriods(newMap)
}
/**
* 订阅更新时关闭当前 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 precomputeAutoSpreadForCurrentPeriods(newMap: Map<String, List<WsBookEntry>>) {
val autoPeriods = newMap.values.asSequence().flatten()
.filter { it.strategy.spreadMode == SpreadMode.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,498 @@
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.enums.SpreadMode
import com.wrbug.polymarketbot.enums.SpreadDirection
import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository
import com.wrbug.polymarketbot.service.accounts.AccountService
import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService
import com.wrbug.polymarketbot.service.binance.BinanceKlineService
import com.wrbug.polymarketbot.service.common.PolymarketClobService
import com.wrbug.polymarketbot.service.copytrading.orders.OrderSigningService
import com.wrbug.polymarketbot.util.CryptoUtils
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.div
import com.wrbug.polymarketbot.util.fromJson
import com.wrbug.polymarketbot.util.multi
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import com.github.benmanes.caffeine.cache.Cache
import com.github.benmanes.caffeine.cache.Caffeine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.math.RoundingMode
import java.util.concurrent.ConcurrentHashMap
import java.util.regex.Pattern
/** 尾盘策略固定下单价格(最高价 0.99),不再在触发时拉取最优价 */
private const val TRIGGER_FIXED_PRICE = "0.99"
/** 最大价差模式(MAX)时,买入价格调整系数(加在触发价格上) */
private const val SPREAD_MAX_PRICE_ADJUSTMENT = "0.02"
/** 数量小数位数,与 OrderSigningService 的 roundConfig.size 一致 */
private const val SIZE_DECIMAL_SCALE = 2
/**
* 周期内预置上下文账户解密凭证费率签名类型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"
val modeStr = if (strategy.spreadDirection == SpreadDirection.MAX) "最大价差" else "最小价差"
logger.info(
"尾盘策略首次满足条件: strategyName=$strategyName, strategyId=${strategy.id}, " +
"openPrice=$openPrice, closePrice=$closePrice, marketPrice=${bestBid.toPlainString()}, " +
"direction=$direction, outcomeIndex=$outcomeIndex, spreadMode=$modeStr"
)
}
if (!passSpreadCheck(strategy, periodStartUnix, outcomeIndex)) return@withLock
ensurePeriodContext(strategy, periodStartUnix, tokenIds, marketTitle)
placeOrderForTrigger(strategy, periodStartUnix, marketTitle, tokenIds, outcomeIndex, bestBid)
}
}
private fun passSpreadCheck(strategy: CryptoTailStrategy, periodStartUnix: Long, outcomeIndex: Int): Boolean {
if (strategy.spreadMode == SpreadMode.NONE) return true
val oc = binanceKlineService.getCurrentOpenClose(strategy.intervalSeconds, periodStartUnix)
?: return false
val (openP, closeP) = oc
val spreadAbs = closeP.subtract(openP).abs()
// 获取有效价差
val effectiveSpread = when (strategy.spreadMode) {
SpreadMode.FIXED -> {
strategy.spreadValue?.takeIf { it > BigDecimal.ZERO } ?: return true
}
SpreadMode.AUTO -> {
val result = computeAutoEffectiveSpread(strategy, periodStartUnix, outcomeIndex) ?: return true
result.effectiveSpread.takeIf { it > BigDecimal.ZERO } ?: return true
}
SpreadMode.NONE -> return true
}
// 根据价差方向判断
return if (strategy.spreadDirection == SpreadDirection.MAX) {
// 最大价差模式:价差 <= 配置值时触发
spreadAbs <= effectiveSpread
} else {
// 最小价差模式:价差 >= 配置值时触发
spreadAbs >= effectiveSpread
}
}
/**
* AUTO 模式 100% 基准价差按窗口内毫秒进度计算动态系数100%50%得到有效价差
*/
private data class AutoSpreadResult(
val baseSpread: BigDecimal,
val coefficient: BigDecimal,
val effectiveSpread: BigDecimal
)
private fun computeAutoEffectiveSpread(strategy: CryptoTailStrategy, periodStartUnix: Long, outcomeIndex: Int): AutoSpreadResult? {
val baseSpread = binanceKlineAutoSpreadService.getAutoMinSpreadBase(strategy.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 effectiveSpread = baseSpread.multi(coefficient).setScale(8, RoundingMode.HALF_UP)
return AutoSpreadResult(baseSpread, coefficient, effectiveSpread)
}
private suspend fun placeOrderForTrigger(
strategy: CryptoTailStrategy,
periodStartUnix: Long,
marketTitle: String?,
tokenIds: List<String>,
outcomeIndex: Int,
triggerPrice: BigDecimal
) {
val ctx = getOrInvalidatePeriodContext(strategy, periodStartUnix)
if (ctx != null) {
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 = if (strategy.spreadDirection == SpreadDirection.MAX) {
// 最大价差模式:触发价格 + 0.02
triggerPrice.add(BigDecimal(SPREAD_MAX_PRICE_ADJUSTMENT)).setScale(8, RoundingMode.HALF_UP)
} else {
// 最小价差模式:固定价格 0.99
BigDecimal(TRIGGER_FIXED_PRICE)
}
val priceStr = price.toPlainString()
val size = computeSize(amountUsdc, price)
val feeRateBps = ctx.feeRateByTokenId[tokenId] ?: "0"
val signedOrder = orderSigningService.createAndSignOrder(
privateKey = ctx.decryptedPrivateKey,
makerAddress = ctx.account.proxyAddress,
tokenId = tokenId,
side = "BUY",
price = priceStr,
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 = errorBody.ifEmpty { "请求失败" }
}
} catch (e: Exception) {
failReason = e.message ?: e.toString()
logger.error("尾盘策略下单异常: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix", e)
}
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", failReason)
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 = if (strategy.spreadDirection == SpreadDirection.MAX) {
// 最大价差模式:触发价格 + 0.02
triggerPrice.add(BigDecimal(SPREAD_MAX_PRICE_ADJUSTMENT)).setScale(8, RoundingMode.HALF_UP)
} else {
// 最小价差模式:固定价格 0.99
BigDecimal(TRIGGER_FIXED_PRICE)
}
val priceStr = price.toPlainString()
val size = computeSize(amountUsdc, price)
val decryptedKey = try {
cryptoUtils.decrypt(account.privateKey) ?: ""
} catch (e: Exception) {
logger.error("解密私钥失败: accountId=${account.id}", e)
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", "解密私钥失败")
return
}
val apiSecret = try {
account.apiSecret?.let { cryptoUtils.decrypt(it) } ?: ""
} catch (e: Exception) { "" }
val apiPassphrase = try {
account.apiPassphrase?.let { cryptoUtils.decrypt(it) } ?: ""
} catch (e: Exception) { "" }
val clobApi = retrofitFactory.createClobApi(account.apiKey!!, apiSecret, apiPassphrase, account.walletAddress)
val 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 = priceStr,
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,321 @@
package com.wrbug.polymarketbot.service.cryptotail
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
import com.wrbug.polymarketbot.entity.CryptoTailStrategyTrigger
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.enums.SpreadMode
import com.wrbug.polymarketbot.enums.SpreadDirection
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository
import com.wrbug.polymarketbot.event.CryptoTailStrategyChangedEvent
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import org.slf4j.LoggerFactory
import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Service
class CryptoTailStrategyService(
private val strategyRepository: CryptoTailStrategyRepository,
private val triggerRepository: CryptoTailStrategyTriggerRepository,
private val eventPublisher: ApplicationEventPublisher
) {
private val logger = LoggerFactory.getLogger(CryptoTailStrategyService::class.java)
private val maxWindowByInterval = mapOf(300 to 300, 900 to 900)
@Transactional
fun create(request: CryptoTailStrategyCreateRequest): Result<CryptoTailStrategyDto> {
return try {
if (request.accountId <= 0) {
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ACCOUNT_ID_INVALID.messageKey))
}
if (request.marketSlugPrefix.isBlank()) {
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
}
val interval = request.intervalSeconds
if (interval != 300 && interval != 900) {
return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_INTERVAL_INVALID.messageKey))
}
val maxWindow = maxWindowByInterval[interval] ?: 300
if (request.windowStartSeconds > request.windowEndSeconds) {
return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID.messageKey))
}
if (request.windowEndSeconds > maxWindow) {
return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED.messageKey))
}
val amountMode = request.amountMode.uppercase()
if (amountMode != "RATIO" && amountMode != "FIXED") {
return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID.messageKey))
}
val minPrice = request.minPrice.toSafeBigDecimal()
val maxPrice = (request.maxPrice ?: "1").toSafeBigDecimal()
if (minPrice > maxPrice) {
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
}
val amountValue = request.amountValue.toSafeBigDecimal()
if (amountValue <= BigDecimal.ZERO) {
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
}
val spreadMode = try {
SpreadMode.fromString(request.spreadMode)
} catch (e: Exception) {
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
}
val spreadValue = request.spreadValue?.toSafeBigDecimal()
if (spreadMode == SpreadMode.FIXED && (spreadValue == null || spreadValue < BigDecimal.ZERO)) {
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
}
val spreadDirection = try {
SpreadDirection.fromString(request.spreadDirection)
} catch (e: Exception) {
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
}
val nameToSave = request.name?.takeIf { it.isNotBlank() }
?: generateStrategyName(request.marketSlugPrefix.trim())
val entity = CryptoTailStrategy(
accountId = request.accountId,
name = nameToSave,
marketSlugPrefix = request.marketSlugPrefix.trim(),
intervalSeconds = interval,
windowStartSeconds = request.windowStartSeconds,
windowEndSeconds = request.windowEndSeconds,
minPrice = minPrice,
maxPrice = maxPrice,
amountMode = amountMode,
amountValue = amountValue,
spreadMode = spreadMode,
spreadValue = spreadValue,
spreadDirection = spreadDirection,
enabled = request.enabled
)
val saved = strategyRepository.save(entity)
eventPublisher.publishEvent(CryptoTailStrategyChangedEvent(this))
Result.success(entityToDto(saved, null))
} catch (e: IllegalArgumentException) {
Result.failure(e)
} catch (e: Exception) {
logger.error("创建尾盘策略失败: ${e.message}", e)
Result.failure(e)
}
}
@Transactional
fun update(request: CryptoTailStrategyUpdateRequest): Result<CryptoTailStrategyDto> {
return try {
val existing = strategyRepository.findById(request.strategyId).orElse(null)
?: return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND.messageKey))
val interval = existing.intervalSeconds
val maxWindow = maxWindowByInterval[interval] ?: 300
request.windowStartSeconds?.let { ws ->
request.windowEndSeconds?.let { we ->
if (ws > we) return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID.messageKey))
if (we > maxWindow) return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED.messageKey))
}
}
request.windowStartSeconds?.let { if (it > (request.windowEndSeconds ?: existing.windowEndSeconds)) return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID.messageKey)) }
request.windowEndSeconds?.let { if (it > maxWindow) return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED.messageKey)) }
val nameToSave = request.name?.takeIf { it.isNotBlank() }
?: existing.name?.takeIf { it.isNotBlank() }
?: generateStrategyName(existing.marketSlugPrefix)
val newSpreadMode = if (request.spreadMode != null) {
try {
SpreadMode.fromString(request.spreadMode)
} catch (e: Exception) {
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
}
} else {
existing.spreadMode
}
val newSpreadValue = request.spreadValue?.toSafeBigDecimal() ?: existing.spreadValue
if (newSpreadMode == SpreadMode.FIXED && (newSpreadValue == null || newSpreadValue < BigDecimal.ZERO)) {
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
}
val newSpreadDirection = if (request.spreadDirection != null) {
try {
SpreadDirection.fromString(request.spreadDirection)
} catch (e: Exception) {
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
}
} else {
existing.spreadDirection
}
val updated = existing.copy(
name = nameToSave,
windowStartSeconds = request.windowStartSeconds ?: existing.windowStartSeconds,
windowEndSeconds = request.windowEndSeconds ?: existing.windowEndSeconds,
minPrice = request.minPrice?.toSafeBigDecimal() ?: existing.minPrice,
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: existing.maxPrice,
amountMode = request.amountMode?.uppercase() ?: existing.amountMode,
amountValue = request.amountValue?.toSafeBigDecimal() ?: existing.amountValue,
spreadMode = newSpreadMode,
spreadValue = newSpreadValue,
spreadDirection = newSpreadDirection,
enabled = request.enabled ?: existing.enabled,
updatedAt = System.currentTimeMillis()
)
if (updated.minPrice > updated.maxPrice) {
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
}
request.amountMode?.uppercase()?.let { if (it != "RATIO" && it != "FIXED") return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID.messageKey)) }
val saved = strategyRepository.save(updated)
eventPublisher.publishEvent(CryptoTailStrategyChangedEvent(this))
val lastTrigger = triggerRepository.findAllByStrategyIdOrderByCreatedAtDesc(saved.id!!, PageRequest.of(0, 1))
.content.firstOrNull()?.createdAt
Result.success(entityToDto(saved, lastTrigger))
} catch (e: IllegalArgumentException) {
Result.failure(e)
} catch (e: Exception) {
logger.error("更新尾盘策略失败: ${e.message}", e)
Result.failure(e)
}
}
@Transactional
fun delete(strategyId: Long): Result<Unit> {
return try {
if (!strategyRepository.existsById(strategyId)) {
return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND.messageKey))
}
strategyRepository.deleteById(strategyId)
eventPublisher.publishEvent(CryptoTailStrategyChangedEvent(this))
Result.success(Unit)
} catch (e: Exception) {
logger.error("删除尾盘策略失败: ${e.message}", e)
Result.failure(e)
}
}
fun list(request: CryptoTailStrategyListRequest): Result<CryptoTailStrategyListResponse> {
return try {
val list = when {
request.accountId != null && request.enabled != null -> strategyRepository.findByAccountIdAndEnabled(request.accountId, request.enabled)
request.accountId != null -> strategyRepository.findAllByAccountId(request.accountId)
request.enabled == true -> strategyRepository.findAllByEnabledTrue()
request.enabled == false -> strategyRepository.findAll().filter { !it.enabled }
else -> strategyRepository.findAll()
}
val lastTriggerMap = list.map { it.id!! }.associateWith { id ->
triggerRepository.findAllByStrategyIdOrderByCreatedAtDesc(id, PageRequest.of(0, 1))
.content.firstOrNull()?.createdAt
}
val dtos = list.map { entityToDto(it, lastTriggerMap[it.id]) }
Result.success(CryptoTailStrategyListResponse(list = dtos))
} catch (e: Exception) {
logger.error("查询尾盘策略列表失败: ${e.message}", e)
Result.failure(e)
}
}
fun getTriggerRecords(request: CryptoTailStrategyTriggerListRequest): Result<CryptoTailStrategyTriggerListResponse> {
return try {
val page = PageRequest.of((request.page - 1).coerceAtLeast(0), request.pageSize.coerceIn(1, 100))
val startTs = request.startDate ?: 0L
val endTs = request.endDate ?: Long.MAX_VALUE
val useTimeRange = request.startDate != null || request.endDate != null
val pageResult = when {
useTimeRange && request.status != null && request.status.isNotBlank() ->
triggerRepository.findAllByStrategyIdAndStatusAndCreatedAtBetweenOrderByCreatedAtDesc(
request.strategyId, request.status, startTs, endTs, page
)
useTimeRange ->
triggerRepository.findAllByStrategyIdAndCreatedAtBetweenOrderByCreatedAtDesc(
request.strategyId, startTs, endTs, page
)
request.status != null && request.status.isNotBlank() ->
triggerRepository.findAllByStrategyIdAndStatusOrderByCreatedAtDesc(request.strategyId, request.status, page)
else ->
triggerRepository.findAllByStrategyIdOrderByCreatedAtDesc(request.strategyId, page)
}
val list = pageResult.content.map { triggerToDto(it) }
val total = when {
useTimeRange && request.status != null && request.status.isNotBlank() ->
triggerRepository.countByStrategyIdAndStatusAndCreatedAtBetween(request.strategyId, request.status, startTs, endTs)
useTimeRange ->
triggerRepository.countByStrategyIdAndCreatedAtBetween(request.strategyId, startTs, endTs)
request.status != null && request.status.isNotBlank() ->
triggerRepository.countByStrategyIdAndStatus(request.strategyId, request.status)
else ->
pageResult.totalElements
}
Result.success(CryptoTailStrategyTriggerListResponse(list = list, total = total))
} catch (e: Exception) {
logger.error("查询触发记录失败: ${e.message}", e)
Result.failure(e)
}
}
fun getStrategy(strategyId: Long): CryptoTailStrategy? = strategyRepository.findById(strategyId).orElse(null)
private fun generateStrategyName(marketSlugPrefix: String): String {
val suffix = Instant.now().atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
return "尾盘策略-${marketSlugPrefix}-$suffix"
}
private fun entityToDto(e: CryptoTailStrategy, lastTriggerAt: Long?): CryptoTailStrategyDto {
val strategyId = e.id ?: 0L
val totalPnl = triggerRepository.sumRealizedPnlByStrategyId(strategyId)
val settledCount = triggerRepository.countResolvedByStrategyId(strategyId)
val winCount = triggerRepository.countWinsByStrategyId(strategyId)
val winRateStr = if (settledCount > 0L) {
BigDecimal(winCount).divide(BigDecimal(settledCount), 4, java.math.RoundingMode.HALF_UP).toPlainString()
} else null
return CryptoTailStrategyDto(
id = strategyId,
accountId = e.accountId,
name = e.name,
marketSlugPrefix = e.marketSlugPrefix,
marketTitle = null,
intervalSeconds = e.intervalSeconds,
windowStartSeconds = e.windowStartSeconds,
windowEndSeconds = e.windowEndSeconds,
minPrice = e.minPrice.toPlainString(),
maxPrice = e.maxPrice.toPlainString(),
amountMode = e.amountMode,
amountValue = e.amountValue.toPlainString(),
spreadMode = e.spreadMode.name,
spreadValue = e.spreadValue?.toPlainString(),
spreadDirection = e.spreadDirection.name,
enabled = e.enabled,
lastTriggerAt = lastTriggerAt,
totalRealizedPnl = totalPnl?.toPlainString(),
settledCount = settledCount,
winCount = winCount,
winRate = winRateStr,
createdAt = e.createdAt,
updatedAt = e.updatedAt
)
}
private fun triggerToDto(t: CryptoTailStrategyTrigger): CryptoTailStrategyTriggerDto = CryptoTailStrategyTriggerDto(
id = t.id ?: 0L,
strategyId = t.strategyId,
periodStartUnix = t.periodStartUnix,
marketTitle = t.marketTitle,
outcomeIndex = t.outcomeIndex,
triggerPrice = t.triggerPrice.toPlainString(),
amountUsdc = t.amountUsdc.toPlainString(),
orderId = t.orderId,
status = t.status,
failReason = t.failReason,
resolved = t.resolved,
realizedPnl = t.realizedPnl?.toPlainString(),
winnerOutcomeIndex = t.winnerOutcomeIndex,
settledAt = t.settledAt,
createdAt = t.createdAt
)
}
@@ -15,6 +15,7 @@ import org.springframework.context.ApplicationContextAware
import com.wrbug.polymarketbot.service.copytrading.orders.OrderPushService
import com.wrbug.polymarketbot.service.copytrading.monitor.PolymarketActivityWsService
import com.wrbug.polymarketbot.service.copytrading.monitor.UnifiedOnChainWsService
import com.wrbug.polymarketbot.service.binance.BinanceKlineService
import org.springframework.stereotype.Service
import java.util.concurrent.TimeUnit
@@ -76,6 +77,17 @@ class ApiHealthCheckService(
}
}
/**
* 获取 BinanceKlineService通过 ApplicationContext 避免循环依赖
*/
private fun getBinanceKlineService(): BinanceKlineService? {
return try {
applicationContext?.getBean(BinanceKlineService::class.java)
} catch (e: BeansException) {
null
}
}
private val logger = LoggerFactory.getLogger(ApiHealthCheckService::class.java)
/**
@@ -91,6 +103,8 @@ class ApiHealthCheckService(
async { checkDataApi() },
async { checkGammaApi() },
async { checkPolygonRpc() },
async { checkBinanceApi() },
async { checkBinanceWebSocket() },
async { checkPolymarketRtdsWebSocket() },
async { checkPolymarketActivityWebSocket() },
async { checkUnifiedOnChainWebSocket() },
@@ -197,6 +211,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,12 +4,17 @@ import com.wrbug.polymarketbot.api.BuilderRelayerApi
import com.wrbug.polymarketbot.api.EthereumRpcApi
import com.wrbug.polymarketbot.api.JsonRpcRequest
import com.wrbug.polymarketbot.constants.PolymarketConstants
import com.wrbug.polymarketbot.enums.WalletType
import com.wrbug.polymarketbot.util.Eip712Encoder
import com.wrbug.polymarketbot.util.EthereumUtils
import com.wrbug.polymarketbot.util.RetrofitFactory
import com.wrbug.polymarketbot.util.createClient
import kotlinx.coroutines.delay
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import retrofit2.Response
import java.math.BigInteger
import java.util.concurrent.atomic.AtomicLong
/**
* RelayClient 服务
@@ -19,8 +24,8 @@ import java.math.BigInteger
* 如果需要真正的 Gasless 功能需要集成 Builder Relayer API
*
* 参考
* - TypeScript: @polymarket/builder-relayer-client
* - TypeScript: utils/redeem.ts
* - TypeScript: https://github.com/Polymarket/builder-relayer-clientclient.execute、src/encode/safe.ts MultiSend
* - 赎回 calldata 由本服务构建官方仓库无 redeem 工具Neg Risk 逻辑见 docs/neg-risk-redeem.md
*/
@Service
class RelayClientService(
@@ -34,17 +39,89 @@ class RelayClientService(
// ConditionalTokens 合约地址
private val conditionalTokensAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"
// USDC.e 合约地址
// USDC.e 合约地址(普通市场抵押品)
private val usdcContractAddress = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
// Neg Risk 市场使用的 WrappedCollateral 合约地址(Polygonneg-risk-ctf-adapter
private val negRiskWrappedCollateralAddress = "0x3A3BD7bb9528E159577F7C2e685CC81A765002E2"
// 空集合ID
private val EMPTY_SET = "0x0000000000000000000000000000000000000000000000000000000000000000"
// Polygon PROXYMagic)合约地址,参考 builder-relayer-client config
private val proxyFactoryAddress = "0xaB45c5A4B0c941a2F231C04C3f49182e1A254052"
private val relayHubAddress = "0xD216153c06E857cD7f72665E0aF1d7D82172F494"
private val defaultProxyGasLimit = "10000000"
// Safe MultiSend 合约地址(Polygon 主网)
private val safeMultisendAddress = "0xA238CBeb142c10Ef7Ad8442C6D1f9E89e07e7761"
// Builder Relayer API 交易类型常量
private val RELAYER_TYPE_PROXY = "PROXY"
private val RELAYER_TYPE_SAFE = "SAFE"
private val RELAYER_TYPE_SAFE_CREATE = "SAFE-CREATE"
// Safe 代理工厂(用于 SAFE-CREATE 部署)
private val safeProxyFactoryAddress = PolymarketConstants.SAFE_PROXY_FACTORY_ADDRESS
private val polygonRpcApi: EthereumRpcApi by lazy {
val rpcUrl = rpcNodeService.getHttpUrl()
retrofitFactory.createEthereumRpcApi(rpcUrl)
}
/** 遇到 429 限流时的重试次数 */
private val builderRelayerRateLimitMaxAttempts = 3
/** 429 限流重试退避基数(毫秒),第 n 次重试等待 baseMs * 2^(n-1) */
private val builderRelayerRateLimitBackoffMs = 2000L
/** Builder Relayer 配额用尽后的冷却截止时间(毫秒时间戳),在此时间前不再发起赎回 */
private val builderRelayerQuotaBlockedUntilMs = AtomicLong(0)
/**
* 是否处于 Builder Relayer 配额冷却期配额用尽后在该时间内不再发起赎回
*/
fun isBuilderRelayerQuotaBlocked(): Boolean = System.currentTimeMillis() < builderRelayerQuotaBlockedUntilMs.get()
/**
* 配额冷却剩余秒数未在冷却期时返回 0
*/
fun getBuilderRelayerQuotaBlockedRemainingSeconds(): Long {
val remaining = (builderRelayerQuotaBlockedUntilMs.get() - System.currentTimeMillis()) / 1000
return maxOf(0, remaining)
}
/**
* API 错误响应中解析 "quota exceeded... resets in N seconds"并设置配额冷却截止时间
*/
private fun updateQuotaBlockedFromErrorBody(errorBody: String) {
if (!errorBody.contains("quota exceeded", ignoreCase = true)) return
val regex = Regex("resets\\s+in\\s+(\\d+)\\s+seconds", RegexOption.IGNORE_CASE)
regex.find(errorBody)?.groupValues?.getOrNull(1)?.toLongOrNull()?.let { seconds ->
val untilMs = System.currentTimeMillis() + seconds * 1000
builderRelayerQuotaBlockedUntilMs.set(untilMs)
logger.warn("Builder Relayer 配额已用尽,${seconds}秒内不再发起赎回")
}
}
/**
* Builder Relayer API 调用进行 429 限流重试指数退避
* HTTP 状态为 429Too Many Requests Cloudflare 1015时等待后重试避免瞬时限流导致赎回失败
*/
private suspend fun <T> withBuilderRelayerRateLimitRetry(block: suspend () -> Response<T>): Response<T> {
var lastResponse: Response<T>? = null
for (attempt in 1..builderRelayerRateLimitMaxAttempts) {
val response = block()
lastResponse = response
if (response.code() != 429) return response
if (attempt == builderRelayerRateLimitMaxAttempts) return response
val delayMs = builderRelayerRateLimitBackoffMs * (1L shl (attempt - 1))
logger.warn("Builder Relayer API 限流(429)${delayMs}ms 后重试 (${attempt}/${builderRelayerRateLimitMaxAttempts})")
delay(delayMs)
}
return lastResponse!!
}
/**
* 获取 Builder Relayer API 客户端动态获取因为配置可能更新
*/
@@ -115,6 +192,7 @@ class RelayClientService(
Result.success(responseTime)
} else {
val errorBody = response.errorBody()?.string() ?: "未知错误"
updateQuotaBlockedFromErrorBody(errorBody)
Result.failure(Exception("Builder Relayer API 调用失败: ${response.code()} - $errorBody"))
}
} catch (e: Exception) {
@@ -160,19 +238,22 @@ class RelayClientService(
/**
* 创建赎回交易支持多个 indexSets用于批量赎回
* 参考 TypeScript: utils/redeem.ts createRedeemTx
* Neg Risk 市场使用 WrappedCollateral 作为抵押品需传 isNegRisk=true
*
* @param conditionId 市场条件ID
* @param indexSets 索引集合列表每个元素是 2^outcomeIndex
* @param isNegRisk 是否为 Neg Risk 市场true 时使用 WrappedCollateral 地址
* @return Safe 交易对象
*/
fun createRedeemTx(conditionId: String, indexSets: List<BigInteger>): SafeTransaction {
fun createRedeemTx(conditionId: String, indexSets: List<BigInteger>, isNegRisk: Boolean = false): SafeTransaction {
// 编码 redeemPositions 函数调用
val functionSelector = EthereumUtils.getFunctionSelector(
"redeemPositions(address,bytes32,bytes32,uint256[])"
)
// 编码参数
val encodedCollateral = EthereumUtils.encodeAddress(usdcContractAddress)
// Neg Risk 市场仓位由 WrappedCollateral 抵押,普通市场由 USDC 抵押
val collateralAddress = if (isNegRisk) negRiskWrappedCollateralAddress else usdcContractAddress
val encodedCollateral = EthereumUtils.encodeAddress(collateralAddress)
val encodedParentCollection = EthereumUtils.encodeBytes32(EMPTY_SET)
val encodedConditionId = EthereumUtils.encodeBytes32(conditionId)
@@ -201,33 +282,154 @@ class RelayClientService(
}
/**
* 执行 Safe 交易通过 Proxy.execTransaction
* 参考 TypeScript: RelayClient.execute()
* 创建 WCOL 解包交易 Wrapped Collateral 解包为 USDC.e
* 合约: Neg Risk WrappedCollateral 0x3A3BD7bb9528E159577F7C2e685CC81A765002E2
* 方法: unwrap(address _to, uint256 _amount)解包后 USDC.e 转到 _to
*
* 优先使用 Builder RelayerGasless如果未配置则回退到手动发送交易
* Safe Magic 共用此交易对象Safe [executeViaBuilderRelayer] / [executeManually]execTransaction
* Magic [executeViaBuilderRelayerProxy]encodeProxyTransactionData语义一致
*
* @param toAddress 接收 USDC.e 的地址通常为 proxy 自身使余额留在代理钱包
* @param amountWei WCOL 数量6 位小数对应的 raw balanceOf 返回一致
* @return Safe 交易对象
*/
fun createUnwrapWcolTx(toAddress: String, amountWei: BigInteger): SafeTransaction {
val functionSelector = EthereumUtils.getFunctionSelector("unwrap(address,uint256)")
val encodedTo = EthereumUtils.encodeAddress(toAddress)
val encodedAmount = EthereumUtils.encodeUint256(amountWei)
val callData = "0x" + functionSelector.removePrefix("0x") + encodedTo + encodedAmount
return SafeTransaction(
to = negRiskWrappedCollateralAddress,
operation = 0, // CALL
data = callData,
value = "0"
)
}
/**
* 创建 USDC approve 交易ERC20 approve(spender, amount)
* 用于 Polymarket 设置步骤3代币授权
*/
fun createUsdcApproveTx(spender: String, amount: BigInteger): SafeTransaction {
val functionSelector = EthereumUtils.getFunctionSelector("approve(address,uint256)")
val encodedSpender = EthereumUtils.encodeAddress(spender)
val encodedAmount = EthereumUtils.encodeUint256(amount)
val callData = "0x" + functionSelector.removePrefix("0x") + encodedSpender + encodedAmount
return SafeTransaction(
to = usdcContractAddress,
operation = 0, // CALL
data = callData,
value = "0"
)
}
/**
* 创建 MultiSend 交易合并多个 SafeTransaction 为一笔交易
* 参考 TypeScript: builder-relayer-client/src/encode/safe.ts createSafeMultisendTransaction
*
* 使用 Gnosis Safe MultiSend 合约将多个交易合并为一笔 DelegateCall 交易
*
* @param safeTxs 多个 Safe 交易
* @return 合并后的 MultiSend 交易operation = 1 = DelegateCall
*/
fun createMultiSendTx(safeTxs: List<SafeTransaction>): SafeTransaction {
if (safeTxs.isEmpty()) {
throw IllegalArgumentException("safeTxs 不能为空")
}
// 单个交易直接返回,不需要 MultiSend
if (safeTxs.size == 1) {
logger.debug("单个交易,不使用 MultiSend")
return safeTxs.first()
}
logger.debug("创建 MultiSend 交易: ${safeTxs.size} 个交易待合并")
// MultiSend 函数选择器:multiSend(bytes)
val multiSendSelector = EthereumUtils.getFunctionSelector("multiSend(bytes)")
// 编码每个交易:encodePacked([uint8 operation, address to, uint256 value, uint256 dataLength, bytes data])
// 与 builder-relayer-client encode/safe.ts 完全一致
val encodedTransactions = safeTxs.map { tx ->
val operation = tx.operation.toByte()
// address: 20 字节,右对齐(取最后 40 个十六进制字符)
val toHex = tx.to.removePrefix("0x").lowercase().padStart(40, '0').takeLast(40)
val to = EthereumUtils.hexToBytes(toHex)
// value: 32 字节大端
val valueHex = BigInteger(tx.value).toString(16).padStart(64, '0')
val value = EthereumUtils.hexToBytes(valueHex)
val dataBytes = EthereumUtils.hexToBytes(tx.data.removePrefix("0x"))
// dataLength: 32 字节大端,表示 data 的字节数
val dataLengthHex = BigInteger.valueOf(dataBytes.size.toLong()).toString(16).padStart(64, '0')
val dataLength = EthereumUtils.hexToBytes(dataLengthHex)
// encodePacked: operation(1) + to(20) + value(32) + dataLength(32) + data(variable)
byteArrayOf(operation) + to + value + dataLength + dataBytes
}
// 拼接所有交易(无 padding,与 viem concatHex 一致)
val concatenatedTransactions = encodedTransactions.reduce { acc, bytes -> acc + bytes }
val totalDataLength = concatenatedTransactions.size
// multiSend(bytes) 的 ABI 编码:offset(32) + length(32) + data(按 32 字节对齐 padding)
val paddedLength = ((totalDataLength + 31) / 32) * 32
val paddedData = concatenatedTransactions + ByteArray(paddedLength - totalDataLength)
val encodedOffset = EthereumUtils.encodeUint256(BigInteger.valueOf(32))
val encodedLength = EthereumUtils.encodeUint256(BigInteger.valueOf(totalDataLength.toLong()))
val encodedData = paddedData.joinToString("") { "%02x".format(it) }
val callData = "0x" + multiSendSelector.removePrefix("0x") + encodedOffset + encodedLength + encodedData
return SafeTransaction(
to = safeMultisendAddress,
operation = 1, // DelegateCall
data = callData,
value = "0"
)
}
/**
* 执行代理交易Safe Magic PROXY
* 参考 TypeScript: RelayClient.execute()
*
* @param privateKey 私钥
* @param proxyAddress 代理钱包地址
* @param safeTx Safe 交易对象
* @param safeTx 交易对象to/data/value
* @param walletType 钱包类型MAGIC 使用 PROXY GaslessSAFE 使用 Safe 流程
* @return 交易哈希
*/
suspend fun execute(
privateKey: String,
proxyAddress: String,
safeTx: SafeTransaction
safeTx: SafeTransaction,
walletType: WalletType = WalletType.SAFE
): Result<String> {
return try {
// 验证参数
if (proxyAddress.isBlank() || !proxyAddress.startsWith("0x") || proxyAddress.length != 42) {
return Result.failure(IllegalArgumentException("proxyAddress 格式错误,必须是有效的以太坊地址"))
}
// 检查 Builder API Key 是否已配置
val builderApiKey = systemConfigService.getBuilderApiKey()
val builderSecret = systemConfigService.getBuilderSecret()
val builderPassphrase = systemConfigService.getBuilderPassphrase()
// 优先使用 Builder RelayerGasless
if (walletType == WalletType.MAGIC) {
if (!isBuilderRelayerEnabled(builderApiKey, builderSecret, builderPassphrase)) {
return Result.failure(IllegalStateException("Magic 账户赎回必须配置 Builder API KeyGasless"))
}
logger.info("使用 Builder Relayer PROXY 执行 Magic 赎回")
return executeViaBuilderRelayerProxy(
privateKey,
proxyAddress,
safeTx,
builderApiKey!!,
builderSecret!!,
builderPassphrase!!
)
}
if (isBuilderRelayerEnabled(builderApiKey, builderSecret, builderPassphrase)) {
logger.info("使用 Builder Relayer 执行 Gasless 交易")
return executeViaBuilderRelayer(
@@ -240,15 +442,242 @@ class RelayClientService(
)
}
// 回退到手动发送交易(需要用户支付 gas)
logger.info("Builder Relayer 未配置,使用手动发送交易(需要用户支付 gas)")
return executeManually(privateKey, proxyAddress, safeTx)
} catch (e: Exception) {
logger.error("执行 Safe 交易失败: ${e.message}", e)
logger.error("执行交易失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 通过 Builder Relayer 执行 PROXYMagic交易Gasless
* 参考: builder-relayer-client client.ts executeProxyTransactions, builder/proxy.ts
*/
private suspend fun executeViaBuilderRelayerProxy(
privateKey: String,
proxyAddress: String,
safeTx: SafeTransaction,
builderApiKey: String,
builderSecret: String,
builderPassphrase: String
): Result<String> {
val relayerApi = retrofitFactory.createBuilderRelayerApi(
relayerUrl = PolymarketConstants.BUILDER_RELAYER_URL,
apiKey = builderApiKey,
secret = builderSecret,
passphrase = builderPassphrase
)
val cleanPrivateKey = privateKey.removePrefix("0x")
val privateKeyBigInt = BigInteger(cleanPrivateKey, 16)
val credentials = org.web3j.crypto.Credentials.create(privateKeyBigInt.toString(16))
val fromAddress = credentials.address
val relayPayloadResponse = withBuilderRelayerRateLimitRetry { relayerApi.getRelayPayload(fromAddress, RELAYER_TYPE_PROXY) }
if (!relayPayloadResponse.isSuccessful || relayPayloadResponse.body() == null) {
val errorBody = relayPayloadResponse.errorBody()?.string() ?: "未知错误"
updateQuotaBlockedFromErrorBody(errorBody)
logger.error("获取 Relay Payload 失败: code=${relayPayloadResponse.code()}, body=$errorBody")
return Result.failure(Exception("获取 Relay Payload 失败: ${relayPayloadResponse.code()} - $errorBody"))
}
val relayPayload = relayPayloadResponse.body()!!
val relayAddress = relayPayload.address
val nonce = relayPayload.nonce
val proxyCallData = encodeProxyTransactionData(safeTx)
// 估算 gas limit(参考 builder-relayer-client builder/proxy.ts getGasLimit
val gasLimit = try {
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 = withBuilderRelayerRateLimitRetry { relayerApi.submitTransaction(request) }
if (!response.isSuccessful || response.body() == null) {
val errorBody = response.errorBody()?.string() ?: "未知错误"
updateQuotaBlockedFromErrorBody(errorBody)
logger.error("Builder Relayer PROXY API 调用失败: code=${response.code()}, body=$errorBody")
return Result.failure(Exception("Builder Relayer PROXY 调用失败: ${response.code()} - $errorBody"))
}
val relayerResponse = response.body()!!
val txHash = relayerResponse.transactionHash ?: relayerResponse.hash
?: return Result.failure(Exception("Builder Relayer 返回的交易哈希为空"))
logger.info("Builder Relayer PROXY 执行成功: transactionID=${relayerResponse.transactionID}, txHash=$txHash")
return Result.success(txHash)
}
/**
* 编码 ProxyFactory.proxy(calls) 调用数据
* 参考: builder-relayer-client encode/proxy.ts, abis proxyFactory proxy((uint8,address,uint256,bytes)[])
*
* ABI 编码规则 tuple 数组中的 tuple 包含动态类型bytes需要先存储 tuple offset
* 结构
* - selector (4 bytes)
* - array offset (32 bytes) = 32
* - array length (32 bytes) = 1
* - tuple[0] offset (32 bytes) = 32 (指向 tuple 数据开始 array length 之后计算)
* - tuple[0] 数据
* - typeCode (32 bytes) = 1
* - to (32 bytes)
* - value (32 bytes) = 0
* - data offset (32 bytes) = 128 ( tuple 数据开始计算)
* - data length (32 bytes)
* - data (padded to 32-byte boundary)
*/
private fun encodeProxyTransactionData(safeTx: SafeTransaction): String {
val selector = EthereumUtils.getFunctionSelector("proxy((uint8,address,uint256,bytes)[])")
val callData = safeTx.data.removePrefix("0x")
val dataLen = callData.length / 2
val dataLenPadded = (dataLen + 31) / 32 * 32 * 2
val dataPadded = callData.padEnd(dataLenPadded, '0')
// ABI 编码:tuple 数组,tuple 包含动态类型 bytes
// 1. array offset: 32 (指向 array length)
val arrayOffset = EthereumUtils.encodeUint256(BigInteger.valueOf(32))
// 2. array length: 1
val arrayLength = EthereumUtils.encodeUint256(BigInteger.ONE)
// 3. tuple[0] offset: 32 (指向 tuple 数据开始,从 array length 之后计算)
val tupleOffset = EthereumUtils.encodeUint256(BigInteger.valueOf(32))
// 4. tuple[0] 数据:
// - typeCode: 1
val typeCode = EthereumUtils.encodeUint256(BigInteger.ONE)
// - to: address
val toEncoded = EthereumUtils.encodeAddress(safeTx.to)
// - value: 0
val valueEncoded = EthereumUtils.encodeUint256(BigInteger.ZERO)
// - data offset: 128 (从 tuple 数据开始计算,typeCode+to+value = 3*32 = 96,加上 offset 字段 = 128)
val dataOffsetInTuple = BigInteger.valueOf(128)
val dataOffsetEncoded = EthereumUtils.encodeUint256(dataOffsetInTuple)
// - data length
val dataLengthEncoded = EthereumUtils.encodeUint256(BigInteger.valueOf(dataLen.toLong()))
// - data (padded)
return "0x" + selector.removePrefix("0x") + arrayOffset + arrayLength +
tupleOffset + typeCode + toEncoded + valueEncoded + dataOffsetEncoded +
dataLengthEncoded + dataPadded
}
/**
* 估算 PROXY 交易的 gas limit
* 参考: builder-relayer-client builder/proxy.ts getGasLimit
*/
private suspend fun estimateProxyGasLimit(
from: String,
to: String,
data: String
): String {
val rpcApi = polygonRpcApi
val rpcRequest = JsonRpcRequest(
method = "eth_estimateGas",
params = listOf(
mapOf(
"from" to from,
"to" to to,
"data" to data
)
)
)
val response = rpcApi.call(rpcRequest)
if (!response.isSuccessful || response.body() == null) {
throw Exception("eth_estimateGas 调用失败: ${response.code()} ${response.message()}")
}
val rpcResponse = response.body()!!
if (rpcResponse.error != null) {
throw Exception("eth_estimateGas 返回错误: ${rpcResponse.error.message}")
}
val hexGasLimit = rpcResponse.result?.asString
?: throw Exception("eth_estimateGas 结果为空")
// 将十六进制转换为十进制字符串
val gasLimitBigInt = BigInteger(hexGasLimit.removePrefix("0x"), 16)
return gasLimitBigInt.toString()
}
/**
* 创建 PROXY 结构哈希参考 builder-relayer-client builder/proxy.ts createStructHash
* concat: "rlx:" + from + to + data + txFee + gasPrice + gasLimit + nonce + relayHub + relay, then keccak256
*/
private fun createProxyStructHash(
from: String,
to: String,
data: String,
txFee: String,
gasPrice: String,
gasLimit: String,
nonce: String,
relayHubAddress: String,
relayAddress: String
): ByteArray {
val rlxPrefix = "rlx:".toByteArray(Charsets.UTF_8)
val fromBytes = EthereumUtils.hexToBytes(from.lowercase().removePrefix("0x").padStart(40, '0'))
val toBytes = EthereumUtils.hexToBytes(to.lowercase().removePrefix("0x").padStart(40, '0'))
val dataBytes = EthereumUtils.hexToBytes(data.removePrefix("0x"))
val txFeeBytes = EthereumUtils.encodeUint256(BigInteger(txFee)).let { EthereumUtils.hexToBytes(it) }
val gasPriceBytes = EthereumUtils.encodeUint256(BigInteger(gasPrice)).let { EthereumUtils.hexToBytes(it) }
val gasLimitBytes = EthereumUtils.encodeUint256(BigInteger(gasLimit)).let { EthereumUtils.hexToBytes(it) }
val nonceBytes = EthereumUtils.encodeUint256(BigInteger(nonce)).let { EthereumUtils.hexToBytes(it) }
val relayHubBytes = EthereumUtils.hexToBytes(relayHubAddress.lowercase().removePrefix("0x").padStart(40, '0'))
val relayBytes = EthereumUtils.hexToBytes(relayAddress.lowercase().removePrefix("0x").padStart(40, '0'))
val concat = rlxPrefix + fromBytes + toBytes + dataBytes + txFeeBytes + gasPriceBytes +
gasLimitBytes + nonceBytes + relayHubBytes + relayBytes
return EthereumUtils.keccak256(concat)
}
/**
* 通过 Builder Relayer 执行交易Gasless
* 参考: builder-relayer-client/src/client.ts execute 方法
@@ -277,15 +706,27 @@ class RelayClientService(
// safeTx.data 已经是带 0x 前缀的完整调用数据
val redeemCallData = safeTx.data
// 获取 Proxy 的 nonce(通过 Builder Relayer API
val nonceResponse = relayerApi.getNonce(fromAddress, "SAFE")
// 获取 Proxy 的 nonce(通过 Builder Relayer API,遇 429 限流时重试
val nonceResponse = withBuilderRelayerRateLimitRetry { relayerApi.getNonce(fromAddress, RELAYER_TYPE_SAFE) }
if (!nonceResponse.isSuccessful || nonceResponse.body() == null) {
val errorBody = nonceResponse.errorBody()?.string() ?: "未知错误"
updateQuotaBlockedFromErrorBody(errorBody)
logger.error("获取 nonce 失败: code=${nonceResponse.code()}, body=$errorBody")
return Result.failure(Exception("获取 nonce 失败: ${nonceResponse.code()} - $errorBody"))
}
val proxyNonce = BigInteger(nonceResponse.body()!!.nonce)
// 调试 GS026:记录 nonce 与交易参数,便于与 relayer/链上对比
logger.debug(
"Safe exec 签名参数: nonce={}, to={}, value={}, dataLen={}, operation={}, proxyWallet={}",
proxyNonce,
safeTx.to,
safeTx.value,
redeemCallData.removePrefix("0x").length / 2,
safeTx.operation,
proxyAddress
)
// 构建 Safe 交易哈希并签名
// 注意:encodeSafeTx 需要 data 带 0x 前缀
val safeTxGas = BigInteger.ZERO
@@ -317,6 +758,12 @@ class RelayClientService(
messageHash = safeTxHash
)
// 调试 GS026:记录 EIP-712 structHash 与最终签名的 hash(可与 Safe.getTransactionHash 对比)
logger.debug(
"Safe exec 哈希: structHash=0x{}, hashToSign 将基于 prefix+structHash 的 keccak256",
safeTxStructuredHash.joinToString("") { "%02x".format(it) }
)
// 注意:ethers.js 的 signMessage 会添加 EIP-191 前缀
// 格式:\x19Ethereum Signed Message:\n<length><message>
// 我们需要模拟这个行为以匹配 TypeScript 实现
@@ -331,21 +778,21 @@ class RelayClientService(
val hashWithPrefix = ByteArray(keccak256.digestSize)
keccak256.doFinal(hashWithPrefix, 0)
logger.debug(
"Safe exec hashToSign=0x{} (personal_sign 后签名的 32 字节)",
hashWithPrefix.joinToString("") { "%02x".format(it) }
)
val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
val safeSignature = org.web3j.crypto.Sign.signMessage(hashWithPrefix, ecKeyPair, false)
// 打包签名(参考 builder-relayer-client/src/utils/index.ts 的 splitAndPackSig
val packedSignature = splitAndPackSig(safeSignature)
// 调试日志(地址已遮蔽)
logger.debug("=== Builder Relayer 签名调试 ===")
logger.debug("Safe: ${proxyAddress.take(10)}..., From: ${fromAddress.take(10)}..., Nonce: $proxyNonce")
logger.debug("Signature Length: ${packedSignature.length}")
// 构建 TransactionRequest(参考 builder-relayer-client/src/builder/safe.ts
// 注意:根据 TypeScript 实现,data 和 signature 都应该带 0x 前缀
val request = BuilderRelayerApi.TransactionRequest(
type = "SAFE",
type = RELAYER_TYPE_SAFE,
from = fromAddress,
to = safeTx.to,
proxyWallet = proxyAddress,
@@ -360,16 +807,19 @@ class RelayClientService(
gasToken = gasToken,
refundReceiver = refundReceiver
),
metadata = "Redeem positions via Builder Relayer"
metadata = if (safeTx.operation == 1) {
"MultiSend redeem positions via Builder Relayer"
} else {
"Redeem positions via Builder Relayer"
}
)
logger.debug("Request: type=${request.type}, dataLen=${request.data.length}, sigLen=${request.signature.length}, nonce=${request.nonce}")
// 调用 Builder Relayer API(认证头通过拦截器添加)
val response = relayerApi.submitTransaction(request)
// 调用 Builder Relayer API(认证头通过拦截器添加,遇 429 限流时重试)
val response = withBuilderRelayerRateLimitRetry { relayerApi.submitTransaction(request) }
if (!response.isSuccessful || response.body() == null) {
val errorBody = response.errorBody()?.string() ?: "未知错误"
updateQuotaBlockedFromErrorBody(errorBody)
logger.error("Builder Relayer API 调用失败: code=${response.code()}, body=$errorBody")
return Result.failure(Exception("Builder Relayer API 调用失败: ${response.code()} - $errorBody"))
}
@@ -382,6 +832,104 @@ class RelayClientService(
return Result.success(txHash)
}
/**
* 通过 Builder Relayer 部署 Safe 代理SAFE-CREATE
* 参考: builder-relayer-client client.ts deploy()builder/create.ts buildSafeCreateTransactionRequest
*
* @param privateKey EOA 私钥
* @param proxyAddress 待部署的 Safe 代理地址 getProxyAddress 一致
* @param fromAddress EOA 地址from
* @return 交易哈希
*/
suspend fun deploySafeViaBuilderRelayer(
privateKey: String,
proxyAddress: String,
fromAddress: String
): Result<String> {
return try {
val builderApiKey = systemConfigService.getBuilderApiKey()
val builderSecret = systemConfigService.getBuilderSecret()
val builderPassphrase = systemConfigService.getBuilderPassphrase()
if (!isBuilderRelayerEnabled(builderApiKey, builderSecret, builderPassphrase)) {
return Result.failure(IllegalStateException("Builder API Key 未配置,无法执行 Safe 部署"))
}
val relayerApi = retrofitFactory.createBuilderRelayerApi(
relayerUrl = PolymarketConstants.BUILDER_RELAYER_URL,
apiKey = builderApiKey!!,
secret = builderSecret!!,
passphrase = builderPassphrase!!
)
val zeroAddress = "0x0000000000000000000000000000000000000000"
val paymentToken = zeroAddress
val payment = "0"
val paymentReceiver = zeroAddress
val domainSeparator = Eip712Encoder.encodeSafeCreateDomain(
name = PolymarketConstants.SAFE_FACTORY_EIP712_NAME,
chainId = 137L,
verifyingContract = safeProxyFactoryAddress
)
val createProxyHash = Eip712Encoder.encodeCreateProxyMessage(
paymentToken = paymentToken,
payment = BigInteger.ZERO,
paymentReceiver = paymentReceiver
)
val digest = Eip712Encoder.hashStructuredData(domainSeparator, createProxyHash)
val cleanPrivateKey = privateKey.removePrefix("0x")
val privateKeyBigInt = BigInteger(cleanPrivateKey, 16)
val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
val signature = org.web3j.crypto.Sign.signMessage(digest, ecKeyPair, false)
// SAFE-CREATE 使用标准 EIP-712 签名格式(0x + r + s + vv 为 27/28),与 signTypedData 一致
val signatureHex = signatureToStandardHex(signature)
val request = BuilderRelayerApi.TransactionRequest(
type = RELAYER_TYPE_SAFE_CREATE,
from = fromAddress,
to = safeProxyFactoryAddress,
proxyWallet = proxyAddress,
data = "0x",
nonce = null,
signature = signatureHex,
signatureParams = BuilderRelayerApi.SignatureParams(
paymentToken = paymentToken,
payment = payment,
paymentReceiver = paymentReceiver
),
metadata = null
)
val response = withBuilderRelayerRateLimitRetry { relayerApi.submitTransaction(request) }
if (!response.isSuccessful || response.body() == null) {
val errorBody = response.errorBody()?.string() ?: "未知错误"
updateQuotaBlockedFromErrorBody(errorBody)
logger.error("Builder Relayer SAFE-CREATE 失败: code=${response.code()}, body=$errorBody")
return Result.failure(Exception("部署 Safe 失败: ${response.code()} - $errorBody"))
}
val relayerResponse = response.body()!!
val txHash = relayerResponse.transactionHash ?: relayerResponse.hash
?: return Result.failure(Exception("Builder Relayer 返回的交易哈希为空"))
logger.info("Safe 部署成功: proxy=$proxyAddress, txHash=$txHash")
Result.success(txHash)
} catch (e: Exception) {
logger.error("部署 Safe 失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* SignatureData 转为标准 hex 签名0x + r(64) + s(64) + v(2)v 27/28
* 用于 SAFE-CREATE viem signTypedData 输出格式一致
*/
private fun signatureToStandardHex(signature: org.web3j.crypto.Sign.SignatureData): String {
val rHex = org.web3j.utils.Numeric.toHexString(signature.r).removePrefix("0x").padStart(64, '0')
val sHex = org.web3j.utils.Numeric.toHexString(signature.s).removePrefix("0x").padStart(64, '0')
val vBytes = signature.v
val v = if (vBytes != null && vBytes.isNotEmpty()) {
vBytes[0].toInt() and 0xff
} else {
27
}
val vHex = String.format("%02x", v)
return "0x$rHex$sHex$vHex"
}
/**
* 打包签名参考 builder-relayer-client/src/utils/index.ts splitAndPackSig
* 将签名打包成 Gnosis Safe 接受的格式encodePacked(["uint256", "uint256", "uint8"], [r, s, v])
@@ -98,8 +98,8 @@ class RpcNodeService(
.filterNot { isDefaultNode(it) } // 排除默认节点
if (nodes.isEmpty()) {
logger.warn("没有配置任何 RPC 节点,使用默认节点: $DEFAULT_RPC_URL")
return Result.failure(IllegalStateException("没有配置任何 RPC 节点"))
logger.warn("没有配置任何启用的 RPC 节点,将使用默认节点")
return Result.success(createDefaultNodeConfig())
}
// 优先使用最近检查状态为 HEALTHY 的节点
@@ -136,39 +136,53 @@ class RpcNodeService(
}
}
// 所有节点都不可用,返回失败
logger.warn("所有 RPC 节点都不可用,将使用默认节点: $DEFAULT_RPC_URL")
Result.failure(IllegalStateException("所有 RPC 节点都不可用"))
// 所有节点都不可用,返回默认节点
logger.warn("所有启用的 RPC 节点都不可用,将使用默认节点: $DEFAULT_RPC_URL")
Result.success(createDefaultNodeConfig())
} catch (e: Exception) {
logger.error("获取可用节点失败: ${e.message}", e)
Result.failure(e)
// 即使失败也返回默认节点,确保系统可用
logger.warn("获取可用节点出现异常,使用默认节点作为兜底")
Result.success(createDefaultNodeConfig())
}
}
/**
* 创建默认节点配置
* 用于兜底确保系统始终有可用的 RPC 节点
*/
private fun createDefaultNodeConfig(): RpcNodeConfig {
return RpcNodeConfig(
id = 0L,
providerType = RpcProviderType.PUBLIC.name,
name = "默认节点",
httpUrl = DEFAULT_RPC_URL,
wsUrl = DEFAULT_WS_URL,
apiKey = null,
enabled = true,
priority = 9999,
lastCheckTime = System.currentTimeMillis(),
lastCheckStatus = NodeHealthStatus.HEALTHY.name,
responseTimeMs = null,
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis()
)
}
/**
* 获取节点的 HTTP URL (如果没有配置,使用默认节点)
*/
fun getHttpUrl(): String {
val nodeResult = getAvailableNode()
return if (nodeResult.isSuccess) {
nodeResult.getOrNull()?.httpUrl ?: DEFAULT_RPC_URL
} else {
logger.warn("没有可用的用户配置节点,使用默认节点")
DEFAULT_RPC_URL
}
val node = getAvailableNode().getOrNull()
return node?.httpUrl ?: DEFAULT_RPC_URL
}
/**
* 获取节点的 WebSocket URL (如果没有配置,使用默认节点)
*/
fun getWsUrl(): String {
val nodeResult = getAvailableNode()
return if (nodeResult.isSuccess) {
nodeResult.getOrNull()?.wsUrl ?: DEFAULT_WS_URL
} else {
logger.warn("没有可用的用户配置节点,使用默认 WS 节点")
DEFAULT_WS_URL
}
val node = getAvailableNode().getOrNull()
return node?.wsUrl ?: DEFAULT_WS_URL
}
/**
@@ -263,6 +277,13 @@ class RpcNodeService(
return Result.failure(IllegalArgumentException("默认节点不允许更新"))
}
// 检查是否禁用节点,如果是则清理缓存
val isDisabling = request.enabled == false && node.enabled == true
if (isDisabling) {
logger.info("节点被禁用,清理 RPC 缓存: ${node.httpUrl}")
retrofitFactory.clearRpcApiCache(node.httpUrl)
}
// 更新字段
val updatedNode = node.copy(
name = request.name ?: node.name,
@@ -272,7 +293,7 @@ class RpcNodeService(
)
val savedNode = rpcNodeConfigRepository.save(updatedNode)
logger.info("成功更新 RPC 节点: ${savedNode.name}")
logger.info("成功更新 RPC 节点: ${savedNode.name}, enabled=${savedNode.enabled}")
Result.success(savedNode)
} catch (e: Exception) {
logger.error("更新节点失败: ${e.message}", e)
@@ -295,6 +316,10 @@ class RpcNodeService(
return Result.failure(IllegalArgumentException("默认节点不允许删除"))
}
// 清理 RPC 缓存
logger.info("删除节点,清理 RPC 缓存: ${node.httpUrl}")
retrofitFactory.clearRpcApiCache(node.httpUrl)
rpcNodeConfigRepository.delete(node)
logger.info("成功删除 RPC 节点: ${node.name}")
Result.success(Unit)
@@ -25,19 +25,6 @@ class SystemConfigService(
const val CONFIG_KEY_BUILDER_SECRET = "builder.secret"
const val CONFIG_KEY_BUILDER_PASSPHRASE = "builder.passphrase"
const val CONFIG_KEY_AUTO_REDEEM = "auto_redeem"
/**
* 遮蔽敏感信息仅显示前4位和后4位
* 例如abcd1234...wxyz5678
*/
fun maskSensitiveValue(value: String?): String? {
if (value == null) return null
return when {
value.length <= 8 -> "****" // 太短则完全遮蔽
value.length <= 16 -> "${value.take(2)}...${value.takeLast(2)}"
else -> "${value.take(4)}...${value.takeLast(4)}"
}
}
}
/**
@@ -49,10 +36,10 @@ class SystemConfigService(
val builderPassphrase = getConfigValue(CONFIG_KEY_BUILDER_PASSPHRASE)
val autoRedeem = isAutoRedeemEnabled()
// 获取遮蔽后的显示值(仅显示部分字符,用于前端确认配置
// 获取完整显示值(用于前端展示与编辑
val builderApiKeyDisplay = builderApiKey?.let {
try {
maskSensitiveValue(cryptoUtils.decrypt(it))
cryptoUtils.decrypt(it)
} catch (e: Exception) {
null
}
@@ -60,7 +47,7 @@ class SystemConfigService(
val builderSecretDisplay = builderSecret?.let {
try {
maskSensitiveValue(cryptoUtils.decrypt(it))
cryptoUtils.decrypt(it)
} catch (e: Exception) {
null
}
@@ -68,7 +55,7 @@ class SystemConfigService(
val builderPassphraseDisplay = builderPassphrase?.let {
try {
maskSensitiveValue(cryptoUtils.decrypt(it))
cryptoUtils.decrypt(it)
} catch (e: Exception) {
null
}
@@ -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>"""
}
@@ -84,5 +84,38 @@ object DateUtils {
}
return displayFormatter.format(instant)
}
/**
* 将时间间隔毫秒格式化为可读的字符串
* 格式X天X小时X分钟 X小时X分钟 X分钟
* 只显示有意义的单位不显示0值单位
*
* @param milliseconds 时间间隔毫秒
* @return 格式化的时间间隔字符串 "2天3小时15分钟""5小时30分钟""45分钟"
*/
fun formatDuration(milliseconds: Long): String {
if (milliseconds < 0) {
return "0分钟"
}
val totalSeconds = milliseconds / 1000
val days = totalSeconds / (24 * 60 * 60)
val hours = (totalSeconds % (24 * 60 * 60)) / (60 * 60)
val minutes = (totalSeconds % (60 * 60)) / 60
val parts = mutableListOf<String>()
if (days > 0) {
parts.add("${days}")
}
if (hours > 0) {
parts.add("${hours}小时")
}
if (minutes > 0 || parts.isEmpty()) {
parts.add("${minutes}分钟")
}
return parts.joinToString("")
}
}
@@ -377,5 +377,62 @@ object Eip712Encoder {
return keccak256(encoded)
}
/**
* SafeCreate EIP712 Polymarket Contract Proxy Factory
* Domain: EIP712Domain(string name, uint256 chainId, address verifyingContract)
* 参考: builder-relayer-client/src/builder/create.ts createSafeCreateSignature
*/
fun encodeSafeCreateDomain(
name: String,
chainId: Long,
verifyingContract: String
): ByteArray {
val domainTypeHash = encodeType(
"EIP712Domain",
listOf(
"name" to "string",
"chainId" to "uint256",
"verifyingContract" to "address"
)
)
val nameHash = encodeString(name)
val chainIdBytes = encodeUint256(BigInteger.valueOf(chainId))
val contractBytes = encodeAddress(verifyingContract)
val encoded = ByteArray(32 + 32 + 32 + 32)
System.arraycopy(domainTypeHash, 0, encoded, 0, 32)
System.arraycopy(nameHash, 0, encoded, 32, 32)
System.arraycopy(chainIdBytes, 0, encoded, 64, 32)
System.arraycopy(contractBytes, 0, encoded, 96, 32)
return keccak256(encoded)
}
/**
* CreateProxy 消息哈希SafeCreate 签名用
* CreateProxy(address paymentToken, uint256 payment, address paymentReceiver)
*/
fun encodeCreateProxyMessage(
paymentToken: String,
payment: BigInteger,
paymentReceiver: String
): ByteArray {
val typeHash = encodeType(
"CreateProxy",
listOf(
"paymentToken" to "address",
"payment" to "uint256",
"paymentReceiver" to "address"
)
)
val tokenBytes = encodeAddress(paymentToken)
val paymentBytes = encodeUint256(payment)
val receiverBytes = encodeAddress(paymentReceiver)
val encoded = ByteArray(32 + 32 + 32 + 32)
System.arraycopy(typeHash, 0, encoded, 0, 32)
System.arraycopy(tokenBytes, 0, encoded, 32, 32)
System.arraycopy(paymentBytes, 0, encoded, 64, 32)
System.arraycopy(receiverBytes, 0, encoded, 96, 32)
return keccak256(encoded)
}
}
@@ -1,6 +1,7 @@
package com.wrbug.polymarketbot.util
import com.google.gson.Gson
import com.wrbug.polymarketbot.api.BinanceApi
import com.wrbug.polymarketbot.api.BuilderRelayerApi
import com.wrbug.polymarketbot.api.EthereumRpcApi
import com.wrbug.polymarketbot.api.GitHubApi
@@ -300,7 +301,18 @@ class RetrofitFactory(
fun createDataApi(): PolymarketDataApi {
return dataApi
}
private val binanceApi: BinanceApi by lazy {
Retrofit.Builder()
.baseUrl("https://api.binance.com/")
.client(sharedOkHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(BinanceApi::class.java)
}
fun createBinanceApi(): BinanceApi = binanceApi
/**
* 创建 Builder Relayer API 客户端
* relayerUrl 缓存避免重复创建
@@ -388,8 +400,6 @@ class RpcUrlReplaceInterceptor(
private val fixedBaseUrl: String,
private val actualRpcUrl: String
) : Interceptor {
private val logger = LoggerFactory.getLogger(RpcUrlReplaceInterceptor::class.java)
@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
@@ -403,8 +413,6 @@ class RpcUrlReplaceInterceptor(
val newUrl = newUrlString.toHttpUrlOrNull()
?: throw IllegalArgumentException("无效的 RPC URL: $newUrlString")
logger.debug("RPC URL 替换: $originalUrlString -> $newUrlString")
val newRequest = originalRequest.newBuilder()
.url(newUrl)
.build()
@@ -0,0 +1,8 @@
-- ============================================
-- V26: 移除最大仓位数量配置字段
-- 从 copy_trading 表中删除 max_position_count 字段
-- ============================================
ALTER TABLE copy_trading
DROP COLUMN max_position_count;
@@ -0,0 +1,97 @@
-- ============================================
-- 回测功能表创建
-- ============================================
-- ============================================
-- 2. 创建回测任务表
-- ============================================
CREATE TABLE IF NOT EXISTS backtest_task (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '回测任务ID',
task_name VARCHAR(100) NOT NULL COMMENT '回测任务名称',
leader_id BIGINT NOT NULL COMMENT 'Leader ID',
initial_balance DECIMAL(20, 8) NOT NULL COMMENT '初始资金',
final_balance DECIMAL(20, 8) DEFAULT NULL COMMENT '最终资金',
profit_amount DECIMAL(20, 8) DEFAULT NULL COMMENT '收益金额',
profit_rate DECIMAL(10, 4) DEFAULT NULL COMMENT '收益率(%)',
backtest_days INT NOT NULL COMMENT '回测天数',
start_time BIGINT NOT NULL COMMENT '回测开始时间(历史时间)',
end_time BIGINT DEFAULT NULL COMMENT '回测结束时间(历史时间)',
-- 跟单配置 (复制CopyTrading表结构)
copy_mode VARCHAR(10) NOT NULL DEFAULT 'RATIO' COMMENT '跟单模式: RATIO/FIXED',
copy_ratio DECIMAL(20, 8) NOT NULL DEFAULT 1.0 COMMENT '跟单比例',
fixed_amount DECIMAL(20, 8) DEFAULT NULL COMMENT '固定金额',
max_order_size DECIMAL(20, 8) NOT NULL DEFAULT 1000.0 COMMENT '最大单笔订单',
min_order_size DECIMAL(20, 8) NOT NULL DEFAULT 1.0 COMMENT '最小单笔订单',
max_daily_loss DECIMAL(20, 8) NOT NULL DEFAULT 10000.0 COMMENT '最大每日亏损',
max_daily_orders INT NOT NULL DEFAULT 100 COMMENT '最大每日订单数',
price_tolerance DECIMAL(5, 2) NOT NULL DEFAULT 5.0 COMMENT '价格容忍度(%)',
delay_seconds INT NOT NULL DEFAULT 0 COMMENT '延迟秒数',
support_sell BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否支持卖出',
min_order_depth DECIMAL(20, 8) DEFAULT NULL COMMENT '最小订单深度',
max_spread DECIMAL(20, 8) DEFAULT NULL COMMENT '最大价差',
min_price DECIMAL(20, 8) DEFAULT NULL COMMENT '最低价格',
max_price DECIMAL(20, 8) DEFAULT NULL COMMENT '最高价格',
max_position_value DECIMAL(20, 8) DEFAULT NULL COMMENT '最大仓位金额',
keyword_filter_mode VARCHAR(20) NOT NULL DEFAULT 'DISABLED' COMMENT '关键字过滤模式',
keywords JSON DEFAULT NULL COMMENT '关键字列表',
max_market_end_date BIGINT DEFAULT NULL COMMENT '市场截止时间限制',
-- 统计字段
avg_holding_time BIGINT DEFAULT NULL COMMENT '平均持仓时间(毫秒)',
data_source VARCHAR(50) DEFAULT 'MIXED' COMMENT '数据源: INTERNAL/API/MIXED',
-- 执行状态
status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '状态: PENDING/RUNNING/COMPLETED/STOPPED/FAILED',
progress INT DEFAULT 0 COMMENT '执行进度(0-100)',
total_trades INT DEFAULT 0 COMMENT '总交易笔数',
buy_trades INT DEFAULT 0 COMMENT '买入笔数',
sell_trades INT DEFAULT 0 COMMENT '卖出笔数',
win_trades INT DEFAULT 0 COMMENT '盈利交易笔数',
loss_trades INT DEFAULT 0 COMMENT '亏损交易笔数',
win_rate DECIMAL(5, 2) DEFAULT NULL COMMENT '胜率(%)',
max_profit DECIMAL(20, 8) DEFAULT NULL COMMENT '最大单笔盈利',
max_loss DECIMAL(20, 8) DEFAULT NULL COMMENT '最大单笔亏损',
max_drawdown DECIMAL(20, 8) DEFAULT NULL COMMENT '最大回撤',
error_message TEXT DEFAULT NULL COMMENT '错误信息',
created_at BIGINT NOT NULL COMMENT '创建时间',
execution_started_at BIGINT DEFAULT NULL COMMENT '执行开始时间(系统时间)',
execution_finished_at BIGINT DEFAULT NULL COMMENT '执行完成时间(系统时间)',
updated_at BIGINT NOT NULL COMMENT '更新时间',
INDEX idx_leader_id (leader_id),
INDEX idx_status (status),
INDEX idx_created_at (created_at),
INDEX idx_leader_profit (leader_id, profit_rate DESC),
INDEX idx_status_created (status, created_at DESC),
FOREIGN KEY (leader_id) REFERENCES copy_trading_leaders(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='回测任务表';
-- ============================================
-- 3. 创建回测交易记录表
-- ============================================
CREATE TABLE IF NOT EXISTS backtest_trade (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '交易记录ID',
backtest_task_id BIGINT NOT NULL COMMENT '回测任务ID',
trade_time BIGINT NOT NULL COMMENT '交易时间',
market_id VARCHAR(100) NOT NULL COMMENT '市场ID',
market_title VARCHAR(500) DEFAULT NULL COMMENT '市场标题',
side VARCHAR(20) NOT NULL COMMENT '方向: BUY/SELL/SETTLEMENT',
outcome VARCHAR(50) NOT NULL COMMENT '结果: YES/NO或outcomeIndex',
outcome_index INT DEFAULT NULL COMMENT '结果索引(0, 1, 2, ...),支持多元市场',
quantity DECIMAL(20, 8) NOT NULL COMMENT '数量',
price DECIMAL(20, 8) NOT NULL COMMENT '价格',
amount DECIMAL(20, 8) NOT NULL COMMENT '金额',
fee DECIMAL(20, 8) NOT NULL DEFAULT 0.0 COMMENT '手续费',
profit_loss DECIMAL(20, 8) DEFAULT NULL COMMENT '盈亏(仅卖出时)',
balance_after DECIMAL(20, 8) NOT NULL COMMENT '交易后余额',
leader_trade_id VARCHAR(100) DEFAULT NULL COMMENT 'Leader原始交易ID',
created_at BIGINT NOT NULL COMMENT '创建时间',
INDEX idx_backtest_task_id (backtest_task_id),
INDEX idx_trade_time (trade_time),
FOREIGN KEY (backtest_task_id) REFERENCES backtest_task(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='回测交易记录表';
@@ -0,0 +1,14 @@
-- ============================================
-- 回测功能恢复字段添加
-- ============================================
-- 添加恢复相关字段到回测任务表
ALTER TABLE backtest_task
ADD COLUMN last_processed_trade_time BIGINT DEFAULT NULL COMMENT '最后处理的交易时间(用于中断恢复)',
ADD COLUMN last_processed_trade_index INT DEFAULT 0 COMMENT '最后处理的交易索引(用于中断恢复)',
ADD COLUMN processed_trade_count INT DEFAULT 0 COMMENT '已处理的交易数量(用于显示真实进度)';
-- 添加索引以优化查询性能
ALTER TABLE backtest_task
ADD INDEX idx_last_processed_trade_time (last_processed_trade_time);
@@ -0,0 +1,13 @@
-- Drop unused columns from backtest_task table
-- These fields are not needed for backtest scenarios as they use historical data
-- Note: Using standard SQL syntax compatible with MySQL 5.7+
-- Check if columns exist before dropping (using standard approach)
ALTER TABLE backtest_task DROP COLUMN price_tolerance;
ALTER TABLE backtest_task DROP COLUMN delay_seconds;
ALTER TABLE backtest_task DROP COLUMN min_order_depth;
ALTER TABLE backtest_task DROP COLUMN max_spread;
ALTER TABLE backtest_task DROP COLUMN min_price;
ALTER TABLE backtest_task DROP COLUMN max_price;
ALTER TABLE backtest_task DROP COLUMN max_position_value;
ALTER TABLE backtest_task DROP COLUMN max_market_end_date;
@@ -0,0 +1,15 @@
-- ============================================
-- 修复回测恢复逻辑:将 last_processed_trade_index 默认值改为 NULL
-- ============================================
-- 问题:新建任务的 last_processed_trade_index 默认值为 0,导致被误判为恢复任务
-- 解决:将默认值改为 NULL,并将现有新任务的 0 值改为 NULL
-- 1. 将现有新任务(status='PENDING' 且 last_processed_trade_index=0)的索引值改为 NULL
UPDATE backtest_task
SET last_processed_trade_index = NULL
WHERE status = 'PENDING' AND last_processed_trade_index = 0;
-- 2. 修改字段定义,允许 NULL 并设置默认值为 NULL
ALTER TABLE backtest_task
MODIFY COLUMN last_processed_trade_index INT DEFAULT NULL COMMENT '最后处理的交易索引(用于中断恢复)';
@@ -0,0 +1,3 @@
-- 添加最大仓位金额配置到回测任务表
ALTER TABLE backtest_task
ADD COLUMN max_position_value DECIMAL(20, 8) COMMENT '最大仓位金额(USDC),NULL表示不启用';
@@ -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 时可存计算值)';

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