Compare commits

..

89 Commits

Author SHA1 Message Date
WrBug 1087591ff6 Merge pull request #59 from WrBug/dev
fix: 支持部署时连接宿主机 MySQL
2026-05-11 10:38:34 +08:00
WrBug 21c570c64c Merge pull request #55 from codychen123/feat/real-pnl-statistics
feat: 修正跟单盈亏统计,加入未实现盈亏和持仓市值
2026-05-11 10:37:49 +08:00
codychen123 83432cf1c4 fix: run leader research asynchronously 2026-05-10 16:26:35 +08:00
codychen123 017c58d5bf fix: preserve real degraded research failures 2026-05-10 15:54:45 +08:00
codychen123 1ce2f5d273 fix: avoid false partial research failures 2026-05-10 15:48:52 +08:00
WrBug 727fa7ed58 fix: 支持部署时连接宿主机 MySQL
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 01:02:55 +08:00
codyhhchen 4057b3f611 fix: gate research pool trial creation 2026-05-05 23:17:32 +08:00
codyhhchen 7c8761a049 fix: map leader research enums as varchar 2026-05-05 18:52:49 +08:00
codyhhchen a3f74b8567 feat: add leader research agent 2026-05-05 18:17:51 +08:00
codyhhchen 82ecf31867 chore: ignore gstack qa artifacts 2026-05-04 17:06:54 +08:00
codyhhchen e114312c0c fix(qa): ISSUE-001 localize leader pool statuses 2026-05-04 16:34:03 +08:00
codyhhchen e7894480f3 Merge remote-tracking branch 'fork/feat/real-pnl-statistics' into feat/real-pnl-statistics 2026-05-04 09:58:15 +08:00
codychen123 6cc7cfde10 feat: add copy trading safety and leader pool 2026-05-04 09:56:53 +08:00
codyhhchen a7bfe660fc feat: add copy trading safety and leader pool 2026-05-04 09:53:07 +08:00
codyhhchen 09f6e5346d Merge remote-tracking branch 'origin/main' into feat/real-pnl-statistics 2026-04-30 23:32:53 +08:00
codychen123 bea0c0e6f0 Merge branch 'main' into feat/real-pnl-statistics 2026-04-30 20:25:27 +08:00
github-actions[bot] cc6375104c Merge branch 'WrBug:main' into main 2026-04-29 05:27:23 +00:00
WrBug 4b58b7cd46 Merge pull request #57 from WrBug/dev 2026-04-28 22:32:45 +08:00
WrBug 8ab8ae5205 style: 全局将 USDC 展示替换为 $ 符号
前端所有金额展示从 "100.00 USDC" 改为 "$100.00" 格式,
后端 Telegram 通知模板和错误提示同步更新。

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

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

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

Made-with: Cursor
2026-04-28 21:34:43 +08:00
codychen123 504d0bbe36 perf: cache position quotes for PnL statistics 2026-04-26 17:07:54 +08:00
codychen123 ac1d6d64f2 feat: add real copy-trading pnl statistics 2026-04-26 15:32:06 +08:00
codychen123 5470b4dab6 ci: sync fork with upstream 2026-04-26 15:24:46 +08:00
WrBug 22a6544373 feat: CLOB V1 → V2 完整迁移
- 更新 EIP-712 签名结构: domain version "1"→"2", 移除 taker/expiration/nonce/feeRateBps, 新增 timestamp/metadata/builder
- 更新合约地址为 V2 (CTF_EXCHANGE, NEG_RISK_EXCHANGE)
- CLOB URL 切换到 clob-v2.polymarket.com
- 抵押品代币 USDC.e → pUSD (0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB)
- 新增 USDC.e → pUSD wrap 功能 (CollateralOnramp 合约)
- SignedOrderObject 补充 API payload 必需的 taker/expiration 字段
- NewOrderRequest 补充 deferExec/postOnly 字段
- 前端账户管理新增 pUSD 迁移按钮
- 移除所有 feeRateBps 相关逻辑

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 00:11:40 +08:00
WrBug 07c3548401 Merge pull request #30 from WrBug/feature/crypto-market-tail-session-strategy
feat(crypto-tail): 加密市场尾盘策略完整实现与优化
2026-02-15 02:07:30 +08:00
WrBug 364db124d1 Merge pull request #29 from WrBug/dev
Release v2.2.0: Magic 账户支持、按代理地址去重、导入与回测优化
2026-02-14 01:45:48 +08:00
224 changed files with 31713 additions and 3101 deletions
@@ -0,0 +1,156 @@
---
name: openspec-apply-change
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Implement tasks from an OpenSpec change.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
```bash
openspec instructions apply --change "<name>" --json
```
This returns:
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
**Handle states:**
- If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change
- If `state: "all_done"`: congratulate, suggest archive
- Otherwise: proceed to implementation
4. **Read context files**
Read every file path listed under `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output
5. **Show current progress**
Display:
- Schema being used
- Progress: "N/M tasks complete"
- Remaining tasks overview
- Dynamic instruction from CLI
6. **Implement tasks (loop until done or blocked)**
For each pending task:
- Show which task is being worked on
- Make the code changes required
- Keep changes minimal and focused
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
- Continue to next task
**Pause if:**
- Task is unclear → ask for clarification
- Implementation reveals a design issue → suggest updating artifacts
- Error or blocker encountered → report and wait for guidance
- User interrupts
7. **On completion or pause, show status**
Display:
- Tasks completed this session
- Overall progress: "N/M tasks complete"
- If all done: suggest archive
- If paused: explain why and wait for guidance
**Output During Implementation**
```
## Implementing: <change-name> (schema: <schema-name>)
Working on task 3/7: <task description>
[...implementation happening...]
✓ Task complete
Working on task 4/7: <task description>
[...implementation happening...]
✓ Task complete
```
**Output On Completion**
```
## Implementation Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 7/7 tasks complete ✓
### Completed This Session
- [x] Task 1
- [x] Task 2
...
All tasks complete! Ready to archive this change.
```
**Output On Pause (Issue Encountered)**
```
## Implementation Paused
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 4/7 tasks complete
### Issue Encountered
<description of the issue>
**Options:**
1. <option 1>
2. <option 2>
3. Other approach
What would you like to do?
```
**Guardrails**
- Keep going through tasks until done or blocked
- Always read context files before starting (from the apply instructions output)
- If task is ambiguous, pause and ask before implementing
- If implementation reveals issues, pause and suggest artifact updates
- Keep code changes minimal and scoped to each task
- Update task checkbox immediately after completing each task
- Pause on errors, blockers, or unclear requirements - don't guess
- Use contextFiles from CLI output, don't assume specific file names
**Fluid Workflow Integration**
This skill supports the "actions on a change" model:
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
@@ -0,0 +1,114 @@
---
name: openspec-archive-change
description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Archive a completed change in the experimental workflow.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show only active changes (not already archived).
Include the schema used for each change if available.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check artifact completion status**
Run `openspec status --change "<name>" --json` to check artifact completion.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:**
- Display warning listing incomplete artifacts
- Use **AskUserQuestion tool** to confirm user wants to proceed
- Proceed if user confirms
3. **Check task completion status**
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
- Use **AskUserQuestion tool** to confirm user wants to proceed
- Proceed if user confirms
**If no tasks file exists:** Proceed without task-related warning.
4. **Assess delta spec sync state**
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
- Determine what changes would be applied (adds, modifications, removals, renames)
- Show a combined summary before prompting
**Prompt options:**
- If changes needed: "Sync now (recommended)", "Archive without syncing"
- If already synced: "Archive now", "Sync anyway", "Cancel"
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
5. **Perform the archive**
Create the archive directory if it doesn't exist:
```bash
mkdir -p openspec/changes/archive
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move the change directory to archive
```bash
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
```
6. **Display summary**
Show archive completion summary including:
- Change name
- Schema that was used
- Archive location
- Whether specs were synced (if applicable)
- Note about any warnings (incomplete artifacts/tasks)
**Output On Success**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped")
All artifacts complete. All tasks complete.
```
**Guardrails**
- Always prompt for change selection if not provided
- Use artifact graph (openspec status --json) for completion checking
- Don't block archive on warnings - just inform and confirm
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
- Show clear summary of what happened
- If sync is requested, use openspec-sync-specs approach (agent-driven)
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
@@ -0,0 +1,246 @@
---
name: openspec-bulk-archive-change
description: Archive multiple completed changes at once. Use when archiving several parallel changes.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Archive multiple completed changes in a single operation.
This skill allows you to batch-archive changes, handling spec conflicts intelligently by checking the codebase to determine what's actually implemented.
**Input**: None required (prompts for selection)
**Steps**
1. **Get active changes**
Run `openspec list --json` to get all active changes.
If no active changes exist, inform user and stop.
2. **Prompt for change selection**
Use **AskUserQuestion tool** with multi-select to let user choose changes:
- Show each change with its schema
- Include an option for "All changes"
- Allow any number of selections (1+ works, 2+ is the typical use case)
**IMPORTANT**: Do NOT auto-select. Always let the user choose.
3. **Batch validation - gather status for all selected changes**
For each selected change, collect:
a. **Artifact status** - Run `openspec status --change "<name>" --json`
- Parse `schemaName` and `artifacts` list
- Note which artifacts are `done` vs other states
b. **Task completion** - Read `openspec/changes/<name>/tasks.md`
- Count `- [ ]` (incomplete) vs `- [x]` (complete)
- If no tasks file exists, note as "No tasks"
c. **Delta specs** - Check `openspec/changes/<name>/specs/` directory
- List which capability specs exist
- For each, extract requirement names (lines matching `### Requirement: <name>`)
4. **Detect spec conflicts**
Build a map of `capability -> [changes that touch it]`:
```
auth -> [change-a, change-b] <- CONFLICT (2+ changes)
api -> [change-c] <- OK (only 1 change)
```
A conflict exists when 2+ selected changes have delta specs for the same capability.
5. **Resolve conflicts agentically**
**For each conflict**, investigate the codebase:
a. **Read the delta specs** from each conflicting change to understand what each claims to add/modify
b. **Search the codebase** for implementation evidence:
- Look for code implementing requirements from each delta spec
- Check for related files, functions, or tests
c. **Determine resolution**:
- If only one change is actually implemented -> sync that one's specs
- If both implemented -> apply in chronological order (older first, newer overwrites)
- If neither implemented -> skip spec sync, warn user
d. **Record resolution** for each conflict:
- Which change's specs to apply
- In what order (if both)
- Rationale (what was found in codebase)
6. **Show consolidated status table**
Display a table summarizing all changes:
```
| Change | Artifacts | Tasks | Specs | Conflicts | Status |
|---------------------|-----------|-------|---------|-----------|--------|
| schema-management | Done | 5/5 | 2 delta | None | Ready |
| project-config | Done | 3/3 | 1 delta | None | Ready |
| add-oauth | Done | 4/4 | 1 delta | auth (!) | Ready* |
| add-verify-skill | 1 left | 2/5 | None | None | Warn |
```
For conflicts, show the resolution:
```
* Conflict resolution:
- auth spec: Will apply add-oauth then add-jwt (both implemented, chronological order)
```
For incomplete changes, show warnings:
```
Warnings:
- add-verify-skill: 1 incomplete artifact, 3 incomplete tasks
```
7. **Confirm batch operation**
Use **AskUserQuestion tool** with a single confirmation:
- "Archive N changes?" with options based on status
- Options might include:
- "Archive all N changes"
- "Archive only N ready changes (skip incomplete)"
- "Cancel"
If there are incomplete changes, make clear they'll be archived with warnings.
8. **Execute archive for each confirmed change**
Process changes in the determined order (respecting conflict resolution):
a. **Sync specs** if delta specs exist:
- Use the openspec-sync-specs approach (agent-driven intelligent merge)
- For conflicts, apply in resolved order
- Track if sync was done
b. **Perform the archive**:
```bash
mkdir -p openspec/changes/archive
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
```
c. **Track outcome** for each change:
- Success: archived successfully
- Failed: error during archive (record error)
- Skipped: user chose not to archive (if applicable)
9. **Display summary**
Show final results:
```
## Bulk Archive Complete
Archived 3 changes:
- schema-management-cli -> archive/2026-01-19-schema-management-cli/
- project-config -> archive/2026-01-19-project-config/
- add-oauth -> archive/2026-01-19-add-oauth/
Skipped 1 change:
- add-verify-skill (user chose not to archive incomplete)
Spec sync summary:
- 4 delta specs synced to main specs
- 1 conflict resolved (auth: applied both in chronological order)
```
If any failures:
```
Failed 1 change:
- some-change: Archive directory already exists
```
**Conflict Resolution Examples**
Example 1: Only one implemented
```
Conflict: specs/auth/spec.md touched by [add-oauth, add-jwt]
Checking add-oauth:
- Delta adds "OAuth Provider Integration" requirement
- Searching codebase... found src/auth/oauth.ts implementing OAuth flow
Checking add-jwt:
- Delta adds "JWT Token Handling" requirement
- Searching codebase... no JWT implementation found
Resolution: Only add-oauth is implemented. Will sync add-oauth specs only.
```
Example 2: Both implemented
```
Conflict: specs/api/spec.md touched by [add-rest-api, add-graphql]
Checking add-rest-api (created 2026-01-10):
- Delta adds "REST Endpoints" requirement
- Searching codebase... found src/api/rest.ts
Checking add-graphql (created 2026-01-15):
- Delta adds "GraphQL Schema" requirement
- Searching codebase... found src/api/graphql.ts
Resolution: Both implemented. Will apply add-rest-api specs first,
then add-graphql specs (chronological order, newer takes precedence).
```
**Output On Success**
```
## Bulk Archive Complete
Archived N changes:
- <change-1> -> archive/YYYY-MM-DD-<change-1>/
- <change-2> -> archive/YYYY-MM-DD-<change-2>/
Spec sync summary:
- N delta specs synced to main specs
- No conflicts (or: M conflicts resolved)
```
**Output On Partial Success**
```
## Bulk Archive Complete (partial)
Archived N changes:
- <change-1> -> archive/YYYY-MM-DD-<change-1>/
Skipped M changes:
- <change-2> (user chose not to archive incomplete)
Failed K changes:
- <change-3>: Archive directory already exists
```
**Output When No Changes**
```
## No Changes to Archive
No active changes found. Create a new change to get started.
```
**Guardrails**
- Allow any number of changes (1+ is fine, 2+ is the typical use case)
- Always prompt for selection, never auto-select
- Detect spec conflicts early and resolve by checking codebase
- When both changes are implemented, apply specs in chronological order
- Skip spec sync only when implementation is missing (warn user)
- Show clear per-change status before confirming
- Use single confirmation for entire batch
- Track and report all outcomes (success/skip/fail)
- Preserve .openspec.yaml when moving to archive
- Archive directory target uses current date: YYYY-MM-DD-<name>
- If archive target exists, fail that change but continue with others
@@ -0,0 +1,118 @@
---
name: openspec-continue-change
description: Continue working on an OpenSpec change by creating the next artifact. Use when the user wants to progress their change, create the next artifact, or continue their workflow.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Continue working on a change by creating the next artifact.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes sorted by most recently modified. Then use the **AskUserQuestion tool** to let the user select which change to work on.
Present the top 3-4 most recently modified changes as options, showing:
- Change name
- Schema (from `schema` field if present, otherwise "spec-driven")
- Status (e.g., "0/5 tasks", "complete", "no tasks")
- How recently it was modified (from `lastModified` field)
Mark the most recently modified change as "(Recommended)" since it's likely what the user wants to continue.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check current status**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand current state. The response includes:
- `schemaName`: The workflow schema being used (e.g., "spec-driven")
- `artifacts`: Array of artifacts with their status ("done", "ready", "blocked")
- `isComplete`: Boolean indicating if all artifacts are complete
3. **Act based on status**:
---
**If all artifacts are complete (`isComplete: true`)**:
- Congratulate the user
- Show final status including the schema used
- Suggest: "All artifacts created! You can now implement this change or archive it."
- STOP
---
**If artifacts are ready to create** (status shows artifacts with `status: "ready"`):
- Pick the FIRST artifact with `status: "ready"` from the status output
- Get its instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- Parse the JSON. The key fields are:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance
- `outputPath`: Where to write the artifact
- `dependencies`: Completed artifacts to read for context
- **Create the artifact file**:
- Read any completed dependency files for context
- Use `template` as the structure - fill in its sections
- Apply `context` and `rules` as constraints when writing - but do NOT copy them into the file
- Write to the output path specified in instructions
- Show what was created and what's now unlocked
- STOP after creating ONE artifact
---
**If no artifacts are ready (all blocked)**:
- This shouldn't happen with a valid schema
- Show status and suggest checking for issues
4. **After creating an artifact, show progress**
```bash
openspec status --change "<name>"
```
**Output**
After each invocation, show:
- Which artifact was created
- Schema workflow being used
- Current progress (N/M complete)
- What artifacts are now unlocked
- Prompt: "Want to continue? Just ask me to continue or tell me what to do next."
**Artifact Creation Guidelines**
The artifact types and their purpose depend on the schema. Use the `instruction` field from the instructions output to understand what to create.
Common artifact patterns:
**spec-driven schema** (proposal → specs → design → tasks):
- **proposal.md**: Ask user about the change if not clear. Fill in Why, What Changes, Capabilities, Impact.
- The Capabilities section is critical - each capability listed will need a spec file.
- **specs/<capability>/spec.md**: Create one spec per capability listed in the proposal's Capabilities section (use the capability name, not the change name).
- **design.md**: Document technical decisions, architecture, and implementation approach.
- **tasks.md**: Break down implementation into checkboxed tasks.
For other schemas, follow the `instruction` field from the CLI output.
**Guardrails**
- Create ONE artifact per invocation
- Always read dependency artifacts before creating a new one
- Never skip artifacts or create out of order
- If context is unclear, ask the user before creating
- Verify the artifact file exists after writing before marking progress
- Use the schema's artifact sequence, don't assume specific artifact names
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
+288
View File
@@ -0,0 +1,288 @@
---
name: openspec-explore
description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
---
## The Stance
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
- **Adaptive** - Follow interesting threads, pivot when new information emerges
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
---
## What You Might Do
Depending on what the user brings, you might:
**Explore the problem space**
- Ask clarifying questions that emerge from what they said
- Challenge assumptions
- Reframe the problem
- Find analogies
**Investigate the codebase**
- Map existing architecture relevant to the discussion
- Find integration points
- Identify patterns already in use
- Surface hidden complexity
**Compare options**
- Brainstorm multiple approaches
- Build comparison tables
- Sketch tradeoffs
- Recommend a path (if asked)
**Visualize**
```
┌─────────────────────────────────────────┐
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
│ dependency graphs, comparison tables │
│ │
└─────────────────────────────────────────┘
```
**Surface risks and unknowns**
- Identify what could go wrong
- Find gaps in understanding
- Suggest spikes or investigations
---
## OpenSpec Awareness
You have full context of the OpenSpec system. Use it naturally, don't force it.
### Check for context
At the start, quickly check what exists:
```bash
openspec list --json
```
This tells you:
- If there are active changes
- Their names, schemas, and status
- What the user might be working on
### When no change exists
Think freely. When insights crystallize, you might offer:
- "This feels solid enough to start a change. Want me to create a proposal?"
- Or keep exploring - no pressure to formalize
### When a change exists
If the user mentions a change or you detect one is relevant:
1. **Read existing artifacts for context**
- `openspec/changes/<name>/proposal.md`
- `openspec/changes/<name>/design.md`
- `openspec/changes/<name>/tasks.md`
- etc.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
- "The proposal scopes this to premium users, but we're now thinking everyone..."
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|----------------------------|--------------------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
- "This is a new requirement. Add it to specs?"
- "This changes scope. Update the proposal?"
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
---
## What You Don't Have To Do
- Follow a script
- Ask the same questions every time
- Produce a specific artifact
- Reach a conclusion
- Stay on topic if a tangent is valuable
- Be brief (this is thinking time)
---
## Handling Different Entry Points
**User brings a vague idea:**
```
User: I'm thinking about adding real-time collaboration
You: Real-time collab is a big space. Let me think about this...
COLLABORATION SPECTRUM
════════════════════════════════════════════
Awareness Coordination Sync
│ │ │
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│Presence│ │Cursors │ │ CRDT │
│ "3 │ │ Multi │ │Conflict│
│online" │ │ select │ │ free │
└────────┘ └────────┘ └────────┘
│ │ │
trivial moderate complex
Where's your head at?
```
**User brings a specific problem:**
```
User: The auth system is a mess
You: [reads codebase]
┌─────────────────────────────────────────────┐
│ CURRENT AUTH FLOW │
└─────────────────────────────────────────────┘
┌─────────────┼─────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Google │ │ GitHub │ │ Email │
│ OAuth │ │ OAuth │ │ Magic │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
└─────────────┼─────────────┘
┌───────────┐
│ Session │
└─────┬─────┘
┌───────────┐
│ Perms │
└───────────┘
I see three tangles. Which one's burning?
```
**User is stuck mid-implementation:**
```
User: /opsx:explore add-auth-system
The OAuth integration is more complex than expected
You: [reads change artifacts]
You're on task 4: "Implement OAuth flow"
Let me trace what's involved...
[draws diagram, explores options, suggests paths]
Want to update the design to reflect this?
Or add a spike task to investigate?
```
**User wants to compare options:**
```
User: Should we use Postgres or SQLite?
You: Generic answer is boring. What's the context?
User: A CLI tool that tracks local dev environments
You: That changes everything.
┌─────────────────────────────────────────────────┐
│ CLI TOOL DATA STORAGE │
└─────────────────────────────────────────────────┘
Key constraints:
• No daemon running
• Must work offline
• Single user
SQLite Postgres
Deployment embedded ✓ needs server ✗
Offline yes ✓ no ✗
Single file yes ✓ no ✗
SQLite. Not even close.
Unless... is there a sync component?
```
---
## Ending Discovery
There's no required ending. Discovery might:
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
- **Result in artifact updates**: "Updated design.md with these decisions"
- **Just provide clarity**: User has what they need, moves on
- **Continue later**: "We can pick this up anytime"
When it feels like things are crystallizing, you might summarize:
```
## What We Figured Out
**The problem**: [crystallized understanding]
**The approach**: [if one emerged]
**Open questions**: [if any remain]
**Next steps** (if ready):
- Create a change proposal
- Keep exploring: just keep talking
```
But this summary is optional. Sometimes the thinking IS the value.
---
## Guardrails
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
- **Don't auto-capture** - Offer to save insights, don't just do it
- **Do visualize** - A good diagram is worth many paragraphs
- **Do explore the codebase** - Ground discussions in reality
- **Do question assumptions** - Including the user's and your own
+101
View File
@@ -0,0 +1,101 @@
---
name: openspec-ff-change
description: Fast-forward through OpenSpec artifact creation. Use when the user wants to quickly create all artifacts needed for implementation without stepping through each one individually.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Fast-forward through artifact creation - generate everything needed to start implementation in one go.
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
**Steps**
1. **If no clear input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Create the change directory**
```bash
openspec new change "<name>"
```
This creates a scaffolded change at `openspec/changes/<name>/`.
3. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
4. **Create artifacts in sequence until apply-ready**
Use the **TodoWrite tool** to track progress through the artifacts.
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `outputPath`: Where to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "✓ Created <artifact-id>"
b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- Stop when all `applyRequires` artifacts are done
c. **If an artifact requires user input** (unclear context):
- Use **AskUserQuestion tool** to clarify
- Then continue with creation
5. **Show final status**
```bash
openspec status --change "<name>"
```
**Output**
After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions
- What's ready: "All artifacts created! Ready for implementation."
- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks."
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
**Guardrails**
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
- Always read dependency artifacts before creating a new one
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
- If a change with that name already exists, suggest continuing that change instead
- Verify each artifact file exists after writing before proceeding to next
@@ -0,0 +1,74 @@
---
name: openspec-new-change
description: Start a new OpenSpec change using the experimental artifact workflow. Use when the user wants to create a new feature, fix, or modification with a structured step-by-step approach.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Start a new change using the experimental artifact-driven approach.
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
**Steps**
1. **If no clear input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Determine the workflow schema**
Use the default schema (omit `--schema`) unless the user explicitly requests a different workflow.
**Use a different schema only if the user mentions:**
- A specific schema name → use `--schema <name>`
- "show workflows" or "what workflows" → run `openspec schemas --json` and let them choose
**Otherwise**: Omit `--schema` to use the default.
3. **Create the change directory**
```bash
openspec new change "<name>"
```
Add `--schema <name>` only if the user requested a specific workflow.
This creates a scaffolded change at `openspec/changes/<name>/` with the selected schema.
4. **Show the artifact status**
```bash
openspec status --change "<name>"
```
This shows which artifacts need to be created and which are ready (dependencies satisfied).
5. **Get instructions for the first artifact**
The first artifact depends on the schema (e.g., `proposal` for spec-driven).
Check the status output to find the first artifact with status "ready".
```bash
openspec instructions <first-artifact-id> --change "<name>"
```
This outputs the template and context for creating the first artifact.
6. **STOP and wait for user direction**
**Output**
After completing the steps, summarize:
- Change name and location
- Schema/workflow being used and its artifact sequence
- Current status (0/N artifacts complete)
- The template for the first artifact
- Prompt: "Ready to create the first artifact? Just describe what this change is about and I'll draft it, or ask me to continue."
**Guardrails**
- Do NOT create any artifacts yet - just show the instructions
- Do NOT advance beyond showing the first artifact template
- If the name is invalid (not kebab-case), ask for a valid name
- If a change with that name already exists, suggest continuing that change instead
- Pass --schema if using a non-default workflow
+554
View File
@@ -0,0 +1,554 @@
---
name: openspec-onboard
description: Guided onboarding for OpenSpec - walk through a complete workflow cycle with narration and real codebase work.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Guide the user through their first complete OpenSpec workflow cycle. This is a teaching experience—you'll do real work in their codebase while explaining each step.
---
## Preflight
Before starting, check if the OpenSpec CLI is installed:
```bash
# Unix/macOS
openspec --version 2>&1 || echo "CLI_NOT_INSTALLED"
# Windows (PowerShell)
# if (Get-Command openspec -ErrorAction SilentlyContinue) { openspec --version } else { echo "CLI_NOT_INSTALLED" }
```
**If CLI not installed:**
> OpenSpec CLI is not installed. Install it first, then come back to `/opsx:onboard`.
Stop here if not installed.
---
## Phase 1: Welcome
Display:
```
## Welcome to OpenSpec!
I'll walk you through a complete change cycle—from idea to implementation—using a real task in your codebase. Along the way, you'll learn the workflow by doing it.
**What we'll do:**
1. Pick a small, real task in your codebase
2. Explore the problem briefly
3. Create a change (the container for our work)
4. Build the artifacts: proposal → specs → design → tasks
5. Implement the tasks
6. Archive the completed change
**Time:** ~15-20 minutes
Let's start by finding something to work on.
```
---
## Phase 2: Task Selection
### Codebase Analysis
Scan the codebase for small improvement opportunities. Look for:
1. **TODO/FIXME comments** - Search for `TODO`, `FIXME`, `HACK`, `XXX` in code files
2. **Missing error handling** - `catch` blocks that swallow errors, risky operations without try-catch
3. **Functions without tests** - Cross-reference `src/` with test directories
4. **Type issues** - `any` types in TypeScript files (`: any`, `as any`)
5. **Debug artifacts** - `console.log`, `console.debug`, `debugger` statements in non-debug code
6. **Missing validation** - User input handlers without validation
Also check recent git activity:
```bash
# Unix/macOS
git log --oneline -10 2>/dev/null || echo "No git history"
# Windows (PowerShell)
# git log --oneline -10 2>$null; if ($LASTEXITCODE -ne 0) { echo "No git history" }
```
### Present Suggestions
From your analysis, present 3-4 specific suggestions:
```
## Task Suggestions
Based on scanning your codebase, here are some good starter tasks:
**1. [Most promising task]**
Location: `src/path/to/file.ts:42`
Scope: ~1-2 files, ~20-30 lines
Why it's good: [brief reason]
**2. [Second task]**
Location: `src/another/file.ts`
Scope: ~1 file, ~15 lines
Why it's good: [brief reason]
**3. [Third task]**
Location: [location]
Scope: [estimate]
Why it's good: [brief reason]
**4. Something else?**
Tell me what you'd like to work on.
Which task interests you? (Pick a number or describe your own)
```
**If nothing found:** Fall back to asking what the user wants to build:
> I didn't find obvious quick wins in your codebase. What's something small you've been meaning to add or fix?
### Scope Guardrail
If the user picks or describes something too large (major feature, multi-day work):
```
That's a valuable task, but it's probably larger than ideal for your first OpenSpec run-through.
For learning the workflow, smaller is better—it lets you see the full cycle without getting stuck in implementation details.
**Options:**
1. **Slice it smaller** - What's the smallest useful piece of [their task]? Maybe just [specific slice]?
2. **Pick something else** - One of the other suggestions, or a different small task?
3. **Do it anyway** - If you really want to tackle this, we can. Just know it'll take longer.
What would you prefer?
```
Let the user override if they insist—this is a soft guardrail.
---
## Phase 3: Explore Demo
Once a task is selected, briefly demonstrate explore mode:
```
Before we create a change, let me quickly show you **explore mode**—it's how you think through problems before committing to a direction.
```
Spend 1-2 minutes investigating the relevant code:
- Read the file(s) involved
- Draw a quick ASCII diagram if it helps
- Note any considerations
```
## Quick Exploration
[Your brief analysis—what you found, any considerations]
┌─────────────────────────────────────────┐
│ [Optional: ASCII diagram if helpful] │
└─────────────────────────────────────────┘
Explore mode (`/opsx:explore`) is for this kind of thinking—investigating before implementing. You can use it anytime you need to think through a problem.
Now let's create a change to hold our work.
```
**PAUSE** - Wait for user acknowledgment before proceeding.
---
## Phase 4: Create the Change
**EXPLAIN:**
```
## Creating a Change
A "change" in OpenSpec is a container for all the thinking and planning around a piece of work. It lives in `openspec/changes/<name>/` and holds your artifacts—proposal, specs, design, tasks.
Let me create one for our task.
```
**DO:** Create the change with a derived kebab-case name:
```bash
openspec new change "<derived-name>"
```
**SHOW:**
```
Created: `openspec/changes/<name>/`
The folder structure:
```
openspec/changes/<name>/
├── proposal.md ← Why we're doing this (empty, we'll fill it)
├── design.md ← How we'll build it (empty)
├── specs/ ← Detailed requirements (empty)
└── tasks.md ← Implementation checklist (empty)
```
Now let's fill in the first artifact—the proposal.
```
---
## Phase 5: Proposal
**EXPLAIN:**
```
## The Proposal
The proposal captures **why** we're making this change and **what** it involves at a high level. It's the "elevator pitch" for the work.
I'll draft one based on our task.
```
**DO:** Draft the proposal content (don't save yet):
```
Here's a draft proposal:
---
## Why
[1-2 sentences explaining the problem/opportunity]
## What Changes
[Bullet points of what will be different]
## Capabilities
### New Capabilities
- `<capability-name>`: [brief description]
### Modified Capabilities
<!-- If modifying existing behavior -->
## Impact
- `src/path/to/file.ts`: [what changes]
- [other files if applicable]
---
Does this capture the intent? I can adjust before we save it.
```
**PAUSE** - Wait for user approval/feedback.
After approval, save the proposal:
```bash
openspec instructions proposal --change "<name>" --json
```
Then write the content to `openspec/changes/<name>/proposal.md`.
```
Proposal saved. This is your "why" document—you can always come back and refine it as understanding evolves.
Next up: specs.
```
---
## Phase 6: Specs
**EXPLAIN:**
```
## Specs
Specs define **what** we're building in precise, testable terms. They use a requirement/scenario format that makes expected behavior crystal clear.
For a small task like this, we might only need one spec file.
```
**DO:** Create the spec file:
```bash
# Unix/macOS
mkdir -p openspec/changes/<name>/specs/<capability-name>
# Windows (PowerShell)
# New-Item -ItemType Directory -Force -Path "openspec/changes/<name>/specs/<capability-name>"
```
Draft the spec content:
```
Here's the spec:
---
## ADDED Requirements
### Requirement: <Name>
<Description of what the system should do>
#### Scenario: <Scenario name>
- **WHEN** <trigger condition>
- **THEN** <expected outcome>
- **AND** <additional outcome if needed>
---
This format—WHEN/THEN/AND—makes requirements testable. You can literally read them as test cases.
```
Save to `openspec/changes/<name>/specs/<capability>/spec.md`.
---
## Phase 7: Design
**EXPLAIN:**
```
## Design
The design captures **how** we'll build it—technical decisions, tradeoffs, approach.
For small changes, this might be brief. That's fine—not every change needs deep design discussion.
```
**DO:** Draft design.md:
```
Here's the design:
---
## Context
[Brief context about the current state]
## Goals / Non-Goals
**Goals:**
- [What we're trying to achieve]
**Non-Goals:**
- [What's explicitly out of scope]
## Decisions
### Decision 1: [Key decision]
[Explanation of approach and rationale]
---
For a small task, this captures the key decisions without over-engineering.
```
Save to `openspec/changes/<name>/design.md`.
---
## Phase 8: Tasks
**EXPLAIN:**
```
## Tasks
Finally, we break the work into implementation tasks—checkboxes that drive the apply phase.
These should be small, clear, and in logical order.
```
**DO:** Generate tasks based on specs and design:
```
Here are the implementation tasks:
---
## 1. [Category or file]
- [ ] 1.1 [Specific task]
- [ ] 1.2 [Specific task]
## 2. Verify
- [ ] 2.1 [Verification step]
---
Each checkbox becomes a unit of work in the apply phase. Ready to implement?
```
**PAUSE** - Wait for user to confirm they're ready to implement.
Save to `openspec/changes/<name>/tasks.md`.
---
## Phase 9: Apply (Implementation)
**EXPLAIN:**
```
## Implementation
Now we implement each task, checking them off as we go. I'll announce each one and occasionally note how the specs/design informed the approach.
```
**DO:** For each task:
1. Announce: "Working on task N: [description]"
2. Implement the change in the codebase
3. Reference specs/design naturally: "The spec says X, so I'm doing Y"
4. Mark complete in tasks.md: `- [ ]``- [x]`
5. Brief status: "✓ Task N complete"
Keep narration light—don't over-explain every line of code.
After all tasks:
```
## Implementation Complete
All tasks done:
- [x] Task 1
- [x] Task 2
- [x] ...
The change is implemented! One more step—let's archive it.
```
---
## Phase 10: Archive
**EXPLAIN:**
```
## Archiving
When a change is complete, we archive it. This moves it from `openspec/changes/` to `openspec/changes/archive/YYYY-MM-DD-<name>/`.
Archived changes become your project's decision history—you can always find them later to understand why something was built a certain way.
```
**DO:**
```bash
openspec archive "<name>"
```
**SHOW:**
```
Archived to: `openspec/changes/archive/YYYY-MM-DD-<name>/`
The change is now part of your project's history. The code is in your codebase, the decision record is preserved.
```
---
## Phase 11: Recap & Next Steps
```
## Congratulations!
You just completed a full OpenSpec cycle:
1. **Explore** - Thought through the problem
2. **New** - Created a change container
3. **Proposal** - Captured WHY
4. **Specs** - Defined WHAT in detail
5. **Design** - Decided HOW
6. **Tasks** - Broke it into steps
7. **Apply** - Implemented the work
8. **Archive** - Preserved the record
This same rhythm works for any size change—a small fix or a major feature.
---
## Command Reference
**Core workflow:**
| Command | What it does |
|-------------------|--------------------------------------------|
| `/opsx:propose` | Create a change and generate all artifacts |
| `/opsx:explore` | Think through problems before/during work |
| `/opsx:apply` | Implement tasks from a change |
| `/opsx:archive` | Archive a completed change |
**Additional commands:**
| Command | What it does |
|--------------------|----------------------------------------------------------|
| `/opsx:new` | Start a new change, step through artifacts one at a time |
| `/opsx:continue` | Continue working on an existing change |
| `/opsx:ff` | Fast-forward: create all artifacts at once |
| `/opsx:verify` | Verify implementation matches artifacts |
---
## What's Next?
Try `/opsx:propose` on something you actually want to build. You've got the rhythm now!
```
---
## Graceful Exit Handling
### User wants to stop mid-way
If the user says they need to stop, want to pause, or seem disengaged:
```
No problem! Your change is saved at `openspec/changes/<name>/`.
To pick up where we left off later:
- `/opsx:continue <name>` - Resume artifact creation
- `/opsx:apply <name>` - Jump to implementation (if tasks exist)
The work won't be lost. Come back whenever you're ready.
```
Exit gracefully without pressure.
### User just wants command reference
If the user says they just want to see the commands or skip the tutorial:
```
## OpenSpec Quick Reference
**Core workflow:**
| Command | What it does |
|--------------------------|--------------------------------------------|
| `/opsx:propose <name>` | Create a change and generate all artifacts |
| `/opsx:explore` | Think through problems (no code changes) |
| `/opsx:apply <name>` | Implement tasks |
| `/opsx:archive <name>` | Archive when done |
**Additional commands:**
| Command | What it does |
|---------------------------|-------------------------------------|
| `/opsx:new <name>` | Start a new change, step by step |
| `/opsx:continue <name>` | Continue an existing change |
| `/opsx:ff <name>` | Fast-forward: all artifacts at once |
| `/opsx:verify <name>` | Verify implementation |
Try `/opsx:propose` to start your first change.
```
Exit gracefully.
---
## Guardrails
- **Follow the EXPLAIN → DO → SHOW → PAUSE pattern** at key transitions (after explore, after proposal draft, after tasks, after archive)
- **Keep narration light** during implementation—teach without lecturing
- **Don't skip phases** even if the change is small—the goal is teaching the workflow
- **Pause for acknowledgment** at marked points, but don't over-pause
- **Handle exits gracefully**—never pressure the user to continue
- **Use real codebase tasks**—don't simulate or use fake examples
- **Adjust scope gently**—guide toward smaller tasks but respect user choice
+110
View File
@@ -0,0 +1,110 @@
---
name: openspec-propose
description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Propose a new change - create the change and generate all artifacts in one step.
I'll create a change with artifacts:
- proposal.md (what & why)
- design.md (how)
- tasks.md (implementation steps)
When ready to implement, run /opsx:apply
---
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
**Steps**
1. **If no clear input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Create the change directory**
```bash
openspec new change "<name>"
```
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
3. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
4. **Create artifacts in sequence until apply-ready**
Use the **TodoWrite tool** to track progress through the artifacts.
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `outputPath`: Where to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"
b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- Stop when all `applyRequires` artifacts are done
c. **If an artifact requires user input** (unclear context):
- Use **AskUserQuestion tool** to clarify
- Then continue with creation
5. **Show final status**
```bash
openspec status --change "<name>"
```
**Output**
After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions
- What's ready: "All artifacts created! Ready for implementation."
- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks."
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
**Guardrails**
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
- Always read dependency artifacts before creating a new one
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
- If a change with that name already exists, ask if user wants to continue it or create a new one
- Verify each artifact file exists after writing before proceeding to next
+138
View File
@@ -0,0 +1,138 @@
---
name: openspec-sync-specs
description: Sync delta specs from a change to main specs. Use when the user wants to update main specs with changes from a delta spec, without archiving the change.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Sync delta specs from a change to main specs.
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show changes that have delta specs (under `specs/` directory).
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Find delta specs**
Look for delta spec files in `openspec/changes/<name>/specs/*/spec.md`.
Each delta spec file contains sections like:
- `## ADDED Requirements` - New requirements to add
- `## MODIFIED Requirements` - Changes to existing requirements
- `## REMOVED Requirements` - Requirements to remove
- `## RENAMED Requirements` - Requirements to rename (FROM:/TO: format)
If no delta specs found, inform user and stop.
3. **For each delta spec, apply changes to main specs**
For each capability with a delta spec at `openspec/changes/<name>/specs/<capability>/spec.md`:
a. **Read the delta spec** to understand the intended changes
b. **Read the main spec** at `openspec/specs/<capability>/spec.md` (may not exist yet)
c. **Apply changes intelligently**:
**ADDED Requirements:**
- If requirement doesn't exist in main spec → add it
- If requirement already exists → update it to match (treat as implicit MODIFIED)
**MODIFIED Requirements:**
- Find the requirement in main spec
- Apply the changes - this can be:
- Adding new scenarios (don't need to copy existing ones)
- Modifying existing scenarios
- Changing the requirement description
- Preserve scenarios/content not mentioned in the delta
**REMOVED Requirements:**
- Remove the entire requirement block from main spec
**RENAMED Requirements:**
- Find the FROM requirement, rename to TO
d. **Create new main spec** if capability doesn't exist yet:
- Create `openspec/specs/<capability>/spec.md`
- Add Purpose section (can be brief, mark as TBD)
- Add Requirements section with the ADDED requirements
4. **Show summary**
After applying all changes, summarize:
- Which capabilities were updated
- What changes were made (requirements added/modified/removed/renamed)
**Delta Spec Format Reference**
```markdown
## ADDED Requirements
### Requirement: New Feature
The system SHALL do something new.
#### Scenario: Basic case
- **WHEN** user does X
- **THEN** system does Y
## MODIFIED Requirements
### Requirement: Existing Feature
#### Scenario: New scenario to add
- **WHEN** user does A
- **THEN** system does B
## REMOVED Requirements
### Requirement: Deprecated Feature
## RENAMED Requirements
- FROM: `### Requirement: Old Name`
- TO: `### Requirement: New Name`
```
**Key Principle: Intelligent Merging**
Unlike programmatic merging, you can apply **partial updates**:
- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios
- The delta represents *intent*, not a wholesale replacement
- Use your judgment to merge changes sensibly
**Output On Success**
```
## Specs Synced: <change-name>
Updated main specs:
**<capability-1>**:
- Added requirement: "New Feature"
- Modified requirement: "Existing Feature" (added 1 scenario)
**<capability-2>**:
- Created new spec file
- Added requirement: "Another Feature"
Main specs are now updated. The change remains active - archive when implementation is complete.
```
**Guardrails**
- Read both delta and main specs before making changes
- Preserve existing content not mentioned in delta
- If something is unclear, ask for clarification
- Show what you're changing as you go
- The operation should be idempotent - running twice should give same result
@@ -0,0 +1,168 @@
---
name: openspec-verify-change
description: Verify implementation matches change artifacts. Use when the user wants to validate that implementation is complete, correct, and coherent before archiving.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Verify that an implementation matches the change artifacts (specs, tasks, design).
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show changes that have implementation tasks (tasks artifact exists).
Include the schema used for each change if available.
Mark changes with incomplete tasks as "(In Progress)".
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- Which artifacts exist for this change
3. **Get the change directory and load artifacts**
```bash
openspec instructions apply --change "<name>" --json
```
This returns the change directory and `contextFiles` (artifact ID -> array of concrete file paths). Read all available artifacts from `contextFiles`.
4. **Initialize verification report structure**
Create a report structure with three dimensions:
- **Completeness**: Track tasks and spec coverage
- **Correctness**: Track requirement implementation and scenario coverage
- **Coherence**: Track design adherence and pattern consistency
Each dimension can have CRITICAL, WARNING, or SUGGESTION issues.
5. **Verify Completeness**
**Task Completion**:
- If `contextFiles.tasks` exists, read every file path in it
- Parse checkboxes: `- [ ]` (incomplete) vs `- [x]` (complete)
- Count complete vs total tasks
- If incomplete tasks exist:
- Add CRITICAL issue for each incomplete task
- Recommendation: "Complete task: <description>" or "Mark as done if already implemented"
**Spec Coverage**:
- If delta specs exist in `openspec/changes/<name>/specs/`:
- Extract all requirements (marked with "### Requirement:")
- For each requirement:
- Search codebase for keywords related to the requirement
- Assess if implementation likely exists
- If requirements appear unimplemented:
- Add CRITICAL issue: "Requirement not found: <requirement name>"
- Recommendation: "Implement requirement X: <description>"
6. **Verify Correctness**
**Requirement Implementation Mapping**:
- For each requirement from delta specs:
- Search codebase for implementation evidence
- If found, note file paths and line ranges
- Assess if implementation matches requirement intent
- If divergence detected:
- Add WARNING: "Implementation may diverge from spec: <details>"
- Recommendation: "Review <file>:<lines> against requirement X"
**Scenario Coverage**:
- For each scenario in delta specs (marked with "#### Scenario:"):
- Check if conditions are handled in code
- Check if tests exist covering the scenario
- If scenario appears uncovered:
- Add WARNING: "Scenario not covered: <scenario name>"
- Recommendation: "Add test or implementation for scenario: <description>"
7. **Verify Coherence**
**Design Adherence**:
- If `contextFiles.design` exists:
- Extract key decisions (look for sections like "Decision:", "Approach:", "Architecture:")
- Verify implementation follows those decisions
- If contradiction detected:
- Add WARNING: "Design decision not followed: <decision>"
- Recommendation: "Update implementation or revise design.md to match reality"
- If no design.md: Skip design adherence check, note "No design.md to verify against"
**Code Pattern Consistency**:
- Review new code for consistency with project patterns
- Check file naming, directory structure, coding style
- If significant deviations found:
- Add SUGGESTION: "Code pattern deviation: <details>"
- Recommendation: "Consider following project pattern: <example>"
8. **Generate Verification Report**
**Summary Scorecard**:
```
## Verification Report: <change-name>
### Summary
| Dimension | Status |
|--------------|------------------|
| Completeness | X/Y tasks, N reqs|
| Correctness | M/N reqs covered |
| Coherence | Followed/Issues |
```
**Issues by Priority**:
1. **CRITICAL** (Must fix before archive):
- Incomplete tasks
- Missing requirement implementations
- Each with specific, actionable recommendation
2. **WARNING** (Should fix):
- Spec/design divergences
- Missing scenario coverage
- Each with specific recommendation
3. **SUGGESTION** (Nice to fix):
- Pattern inconsistencies
- Minor improvements
- Each with specific recommendation
**Final Assessment**:
- If CRITICAL issues: "X critical issue(s) found. Fix before archiving."
- If only warnings: "No critical issues. Y warning(s) to consider. Ready for archive (with noted improvements)."
- If all clear: "All checks passed. Ready for archive."
**Verification Heuristics**
- **Completeness**: Focus on objective checklist items (checkboxes, requirements list)
- **Correctness**: Use keyword search, file path analysis, reasonable inference - don't require perfect certainty
- **Coherence**: Look for glaring inconsistencies, don't nitpick style
- **False Positives**: When uncertain, prefer SUGGESTION over WARNING, WARNING over CRITICAL
- **Actionability**: Every issue must have a specific recommendation with file/line references where applicable
**Graceful Degradation**
- If only tasks.md exists: verify task completion only, skip spec/design checks
- If tasks + specs exist: verify completeness and correctness, skip design
- If full artifacts: verify all three dimensions
- Always note which checks were skipped and why
**Output Format**
Use clear markdown with:
- Table for summary scorecard
- Grouped lists for issues (CRITICAL/WARNING/SUGGESTION)
- Code references in format: `file.ts:123`
- Specific, actionable recommendations
- No vague suggestions like "consider reviewing"
+20 -1
View File
@@ -14,6 +14,10 @@
│ └── package.json
├── backend/ # 后端相关 skill
└── common/ # 通用 skill
└── create-release/ # 创建 GitHub Release
├── SKILL.md
└── scripts/
└── create-release.sh
```
- **SKILL.md**YAML frontmatter`name``description` 必填,`name` 须与父文件夹名一致、小写连字符)+ 给 Agent 的详细指令。
@@ -27,7 +31,8 @@
## 示例
- `frontend/check-i18n-keys/SKILL.md` + `frontend/check-i18n-keys/scripts/` — 检查前端多语言 key
- `frontend/check-i18n-keys/SKILL.md` + `frontend/check-i18n-keys/scripts/` — 检查前端多语言 key
- `common/create-release/SKILL.md` + `common/create-release/scripts/` — 创建 GitHub Release
## 运行 check-i18n-keys
@@ -37,3 +42,17 @@ npm install
npm run check-i18n
```
## 运行 create-release
```bash
cd .cursor/skills/common/create-release/scripts
./create-release.sh -t v1.0.0 -T "Release v1.0.0" -d "发布说明"
```
参数说明:
- `-t` 版本号(必需,格式 v1.0.0
- `-T` Release 标题
- `-d` Release 描述
- `-p` 标记为 Pre-release(自动加 -beta 后缀)
- `-y` 无交互模式
@@ -0,0 +1,341 @@
---
name: create-release
description: 创建 PolyHermes 项目的 GitHub Release。当用户要求发布版本、创建 release、打 tag 或发布新版本时使用。
---
# Create Release
创建 PolyHermes 项目的 GitHub Release,包括创建 Git tag、推送 tag、创建 GitHub Release(支持 pre-release),并自动在 Issue #1 发布公告。
## 使用时机
- 用户要求「发布版本」「创建 release」「打 tag」「发布新版本」时
- 用户要求「创建 pre-release」「beta 版本」时
- 用户提到「v1.x.x」等版本号相关操作时
## 前置条件
1. **GitHub CLI 已安装**:确保 `gh` 命令可用
2. **已登录 GitHub**:运行 `gh auth status` 确认
3. **工作目录干净**:建议先提交所有更改
## 指令
### 步骤 1:收集发布信息
询问用户以下信息:
- 版本号(格式:vX.Y.Z,如 v1.0.0
- 是否为 Pre-release(测试版本)
- Release 标题和描述(可选,如未提供则自动生成)
### 步骤 2:生成 Release 内容
**重要**:如果用户未提供描述,需要根据 Git commits 自动生成。
1. **获取上一个版本的 tag**
```bash
git describe --tags --abbrev=0 HEAD
```
2. **获取版本间的 commits**
```bash
git log <PREVIOUS_TAG>..HEAD --oneline --no-merges
```
3. **过滤 commit 规则**
- **排除**:版本内新增功能的修复 commit
- **判断方法**:如果一个 commit 的消息包含「fix」「修复」「bugfix」等关键词,且是针对同一版本内新增代码的修复,则不包含
- **保留**:新功能、性能优化、重构、文档更新等
4. **生成中英文 Release 内容**
- 格式要求:**中文在上,英文在下**
- 使用分隔线 `---` 分隔中英文部分
- 按功能类型分组(新功能、改进、修复等)
示例格式:
```markdown
## 新功能
- 添加了 A 功能
- 支持了 B 操作
## 改进
- 优化了 C 性能
---
## New Features
- Added feature A
- Supported operation B
## Improvements
- Optimized performance C
```
### 步骤 3:运行发布脚本
在项目根目录下执行:
```bash
cd .cursor/skills/common/create-release/scripts && \
chmod +x create-release.sh && \
./create-release.sh -t <VERSION> [-T "<TITLE>"] [-d "<DESCRIPTION>"] [-p] [-y]
```
### 步骤 4:发布公告到 Issue #1
**重要**:Release 创建成功后,必须自动在 Issue #1 下发布公告 comment。
1. **生成公告内容**(面向用户,通俗易懂):
公告格式模板:
```markdown
# 🎉 PolyHermes vX.X.X 版本发布公告
## 📅 发布日期
YYYY年MM月DD日
---
## ✨ 本次更新亮点
### 🚀 新功能
**功能名称**
- 用通俗的语言描述这个功能是什么
- 用户能从中获得什么好处
- 如何使用这个功能
### 🔧 改进优化
- 优化了 XXX,现在 XXX 更快/更稳定了
- 改进了 XXX 体验,操作更简单了
### 🐛 问题修复
- 修复了 XXX 问题,不再出现 XXX 情况
---
## 📦 如何更新
### Docker 部署(推荐)
```bash
# 拉取最新镜像
docker pull wrbug/polyhermes:vX.X.X
# 重启服务
docker-compose -f docker-compose.prod.yml down
docker-compose -f docker-compose.prod.yml up -d
```
---
## ⚠️ 安全提醒
**请务必使用官方 Docker 镜像源,避免财产损失!**
**官方镜像地址**`wrbug/polyhermes`
---
## 📚 相关链接
- **GitHub Release**: https://github.com/WrBug/PolyHermes/releases/tag/vX.X.X
- **Docker Hub**: https://hub.docker.com/r/wrbug/polyhermes
```
2. **公告内容编写原则**
- ✅ **通俗易懂**:避免技术术语,用用户能理解的语言
- ✅ **突出价值**:告诉用户这个更新对他们有什么好处
- ✅ **简洁明了**:每个功能点用 1-2 句话说明
- ✅ **包含操作指引**:告诉用户如何使用新功能
- ✅ **中英文双语**:中文在上,英文在下(可选)
- ❌ **避免**commit hash、代码细节、内部实现
3. **执行发布公告命令**
```bash
gh issue comment 1 --repo WrBug/PolyHermes --body "$(cat <<'EOF'
# 🎉 PolyHermes vX.X.X 版本发布公告
[公告内容...]
---
EOF
)"
```
或使用文件方式(内容较长时推荐):
```bash
echo "[公告内容...]" > /tmp/announcement.md
gh issue comment 1 --repo WrBug/PolyHermes --body-file /tmp/announcement.md
```
### 参数说明
| 参数 | 说明 | 示例 |
|------|------|------|
| `-t, --tag` | 版本号(必需) | `-t v1.0.0` |
| `-T, --title` | Release 标题 | `-T "Release v1.0.0"` |
| `-d, --description` | Release 描述 | `-d "## 新功能\n- 功能1"` |
| `-f, --description-file` | 从文件读取描述 | `-f CHANGELOG.md` |
| `-p, --prerelease` | 标记为 Pre-release(自动加 -beta 后缀) | `-p` |
| `-y, --yes` | 无交互模式 | `-y` |
## 版本号格式
- 必须格式:`v数字.数字.数字`(如 v1.0.0, v1.10.2, v1.1.12
- 如果指定 `--prerelease`,会自动拼接 `-beta` 后缀(如 v1.0.1 → v1.0.1-beta
## Release 内容生成规则
### 中英文格式
Release 描述**必须**使用中英文双语格式:
```markdown
## 中文标题
- 内容项1
- 内容项2
---
## English Title
- Item 1
- Item 2
```
### Commit 过滤规则
**需要排除的 commit 类型**
1. **版本内修复**:对同一版本新增功能的后续修复
- 例如:v1.0.1 新增了功能 A,然后有一个 commit 修复功能 A 的 bug → 不包含
- 判断依据:commit 消息包含「fix」「修复」「bugfix」且相关功能在本版本新增
2. **琐碎修改**
- typo 修正
- 代码格式调整
- 注释更新
**需要保留的 commit 类型**
1. 新功能(feat、feature
2. 改进/优化(improve、optimize、enhance
3. 重要 bug 修复(针对旧版本的 bug)
4. 重构(refactor
5. 文档更新(docs
### 分组建议
- **新功能 / New Features**
- **改进 / Improvements**
- **修复 / Bug Fixes**(仅包含对旧版本 bug 的修复)
- **其他 / Others**
## 公告内容示例
以下是一个面向用户的公告示例:
```markdown
# 🎉 PolyHermes v1.2.0 版本发布公告
## 📅 发布日期
2026年3月2日
---
## ✨ 本次更新亮点
### 🚀 新功能
**系统自动更新**
- 现在可以在网页上直接更新系统,无需手动重启 Docker
- 更新过程约 30-60 秒,系统会自动处理
- 如果更新失败,系统会自动恢复到旧版本
**RPC 节点管理**
- 可以在系统设置中添加、编辑、删除自定义 RPC 节点
- 可以随时启用或禁用节点
- 系统会自动选择可用的节点
### 🔧 改进优化
- **更快的跟单响应**:通过实时监听链上交易,跟单速度提升到秒级
- **更准确的盈亏统计**:系统会自动追踪实际成交价,统计数据更准确
- **内存占用优化**:修复了内存泄漏问题,系统可以长时间稳定运行
### 🐛 问题修复
- 修复了部分市场无法正确查询价格的问题
- 修复了卖出订单偶发失败的问题
---
## 📦 如何更新
### 方式一:网页更新(推荐)
1. 登录系统,进入 **系统设置** → **系统更新**
2. 点击 **检查更新**
3. 如果有新版本,点击 **立即升级**
4. 等待更新完成即可
### 方式二:Docker 更新
```bash
docker pull wrbug/polyhermes:v1.2.0
docker-compose -f docker-compose.prod.yml down
docker-compose -f docker-compose.prod.yml up -d
```
---
## ⚠️ 安全提醒
**请务必使用官方 Docker 镜像源!**
官方镜像:`wrbug/polyhermes`
---
## 📚 相关链接
- **GitHub Release**: https://github.com/WrBug/PolyHermes/releases/tag/v1.2.0
- **Docker Hub**: https://hub.docker.com/r/wrbug/polyhermes
```
## 发布流程
1. 验证版本号格式
2. 检查 Git 工作目录状态
3. 检查 tag 是否已存在
4. 生成 Release 内容(中英文)
5. 创建本地 tag
6. 推送 tag 到远程
7. 创建 GitHub Release
8. **生成面向用户的公告内容**
9. **发布公告到 Issue #1**
10. 返回 Release URL 和公告链接
## 注意事项
- Pre-release 版本不会触发 Telegram 通知
- GitHub Actions 会自动触发构建流程
- 如果 tag 已存在,会提示是否删除并重新创建
- **必须**在 Release 创建成功后发布公告到 Issue #1
## 可选目录说明
- `scripts/`:包含 `create-release.sh` 发布脚本
@@ -346,4 +346,3 @@ main() {
# 执行主函数
main "$@"
+201
View File
@@ -0,0 +1,201 @@
---
name: 🤖 Bug Report for AI Fix / AI Bug 报告
description: Bug 报告模板(提交后请手动添加 'fix via ai' 标签触发自动修复)
title: '[Bug] / '
assignees: []
body:
- type: markdown
attributes:
value: |
## Bug Report Template 🐛
本模板用于报告 PolyHermes 项目的 bug。
⚠️ **Important / 重要提示**
- After submission, if you need AI auto-fix, please manually add the `fix via ai` label
/ 提交后,如需 AI 自动修复,请手动添加 `fix via ai` 标签
- AI fixes will be on the `fix_issues_by_ai` branch / AI 修复将在 `fix_issues_by_ai` 分支上进行
- All AI fixes require human review before merging / 所有 AI 修复需要人工审核后才能合并
- Security vulnerabilities, database migrations, major changes are not recommended for AI auto-fix
/ 涉及安全漏洞、数据库迁移、重大变更等问题不建议使用 AI 自动修复
---
本模板用于报告 PolyHermes 项目的 bug。
This template is for reporting bugs in the PolyHermes project.
- type: textarea
id: description
attributes:
label: 📝 Bug Description / Bug 描述
description: Clearly and concisely describe the bug / 清晰简洁地描述这个 bug
placeholder: Describe the bug you encountered / 描述你遇到的问题...
validations:
required: true
- type: dropdown
id: type
attributes:
label: 🎯 Bug Type / 问题类型
description: Select the type of bug / 选择问题类型
options:
- Frontend bug (UI/UX/interaction) / 前端 bug (UI/UX/交互问题)
- Backend bug (API/logic/data) / 后端 bug (API/逻辑/数据处理)
- Database issue / 数据库问题
- Performance issue / 性能问题
- Configuration/Deployment / 配置/部署问题
- Documentation / 文档问题
- Other / 其他
validations:
required: true
- type: dropdown
id: scope
attributes:
label: 📍 Affected Scope / 影响范围
description: Select the scope of impact / 选择问题影响范围
options:
- Specific page/function only / 仅影响特定页面/功能
- Entire system / 影响整个系统
- Specific user role / 影响特定用户角色
- Only in specific environment / 仅在特定环境下重现
validations:
required: true
- type: textarea
id: steps
attributes:
label: 🔍 Steps to Reproduce / 复现步骤
description: Provide clear, detailed steps to reproduce the bug / 提供清晰、详细的步骤来重现这个 bug
placeholder: |
1. Visit page: `...` / 访问页面:`...`
2. Click button: `...` / 点击按钮:`...`
3. Input data: `...` / 输入数据:`...`
4. Submit form: `...` / 提交表单:`...`
5. Observe error: `...` / 观察到错误:`...`
validations:
required: true
- type: dropdown
id: frequency
attributes:
label: Reproduction Frequency / 复现频率
options:
- Always reproducible (100%) / 总是能复现 (100%)
- Frequently reproducible (50%+) / 经常能复现 (50%+)
- Occasionally reproducible (<50%) / 偶尔能复现 (<50%)
- Hard to reproduce / 很难复现
validations:
required: true
- type: textarea
id: expected
attributes:
label: 💻 Expected Behavior / 预期行为
description: Describe what you expected to happen / 描述你期望发生什么
placeholder: What should happen / 应该发生什么...
validations:
required: true
- type: textarea
id: actual
attributes:
label: ❌ Actual Behavior / 实际行为
description: Describe what actually happened / 描述实际发生了什么
placeholder: What actually happened / 实际发生了什么...
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: 📸 Screenshots / Recordings / 截图/录屏
description: If applicable, add screenshots or recordings to illustrate the problem (drag and drop files here)
/ 如果适用,添加截图或录屏来说明问题(可以拖拽文件到这里)
placeholder: Add screenshots or recordings / 添加截图或录屏...
- type: textarea
id: environment
attributes:
label: 🌐 Environment / 环境
description: Provide relevant environment information / 提供相关环境信息
value: |
**Browser (for frontend issues) / 浏览器(前端问题):**
- Browser: ______ / 浏览器:______
- Browser version: ______ / 浏览器版本:______
- Operating System: ______ / 操作系统:______
**Backend Environment (for backend issues) / 后端环境(后端问题):**
- Node.js version: ______ / Node.js 版本:______
- Database version: ______ / 数据库版本:______
- Docker version (if used): ______ / Docker 版本(如果使用):______
- Other relevant dependencies: ______ / 其他相关依赖版本:______
validations:
required: false
- type: textarea
id: related-files
attributes:
label: 📁 Related Files / Code / 相关文件/代码
description: Provide relevant file paths or code snippets / 提供可能涉及的文件路径或相关代码片段
placeholder: |
Possibly related files / 可能涉及的文件:
- frontend/src/components/...
- backend/src/main/kotlin/...
Error logs / 错误日志:
```
Paste error logs here / 粘贴错误日志
```
validations:
required: false
- type: textarea
id: suggestions
attributes:
label: 🎯 Fix Suggestions (Optional) / 修复建议(可选)
description: If you have fix ideas, describe them briefly / 如果你有修复思路,可以在这里简单描述
placeholder: |
Suggest adding ZZZ check in the YYY method of file XXX
/ 建议在 XXX 文件的 YYY 方法中,添加 ZZZ 检查
...
- type: dropdown
id: priority
attributes:
label: 🚨 Priority / 优先级
options:
- 🔴 High - Blocking core functionality, affects user experience / 高 - 阻塞核心功能,影响用户体验
- 🟡 Medium - Limited functionality but not blocking / 中 - 功能受限但不阻塞
- 🟢 Low - Minor issue, doesn't affect usage / 低 - 小问题,不影响使用
validations:
required: true
- type: textarea
id: additional
attributes:
label: 📝 Additional Information / 补充说明
description: Any other information that helps AI understand and fix the issue / 任何其他有助于 AI 理解和修复问题的信息
placeholder: |
- Was this bug introduced recently? / 这个 bug 是最近引入的吗?
- Is it related to a specific PR or commit? / 是否与某个特定的 PR 或 commit 相关?
- Does it only occur with specific datasets or users? / 是否只在特定数据集或特定用户情况下出现?
/ ...
Any other context / 其他任何上下文信息...
- type: checkboxes
id: ai-fix-approval
attributes:
label: 🤖 AI Auto-Fix Confirmation / AI 自动修复确认
description: If you need AI to auto-fix this issue, check the options below (remember to add 'fix via ai' label after submission)
/ 如需 AI 自动修复此 issue,请勾选下方选项(提交后记得添加 'fix via ai' 标签)
options:
- label: |
I understand the AI auto-fix workflow and agree to manually review the AI-created PR
/ 我已了解 AI 自动修复的工作流程,并同意在 AI 创建 PR 后进行人工审核
required: false
- label: |
This issue is suitable for AI auto-fix (not a security vulnerability, not a database migration, not a major change)
/ 此问题适合 AI 自动修复(非安全漏洞、非数据库迁移、非重大变更)
required: false
@@ -0,0 +1,127 @@
# PR 合并后自动关闭关联的 Issue
# 当 PR 从 ai_fix/N_xxx 分支合并到 main 时,关闭 #N 对应的 Issue(若 PR 描述中未含 Closes #N 则通过分支名解析)
# 关闭后发送 Telegram 通知(复用 TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID
name: Close issue on PR merge
on:
pull_request:
types: [closed]
branches: [main]
jobs:
close-issue:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: read
steps:
- name: Get PR info
id: pr
run: |
# 从 PR body 查找 Closes #N / Fixes #N(若已有则 GitHub 已自动关 issue,本 step 仅做解析)
BODY="${{ github.event.pull_request.body }}"
HEAD_REF="${{ github.event.pull_request.head.ref }}"
# 优先从 PR 描述解析(无匹配时 grep 会 exit 1,需 || true 避免 set -e 导致脚本退出)
ISSUE_NUM=$(echo "$BODY" | grep -oE '(Closes|Fixes|Resolves) #([0-9]+)' | head -1 | grep -oE '[0-9]+' || true)
if [ -z "$ISSUE_NUM" ]; then
# 从分支名解析 ai_fix/N_xxx
ISSUE_NUM=$(echo "$HEAD_REF" | sed -n 's|^ai_fix/\([0-9]*\)_.*|\1|p')
fi
if [ -z "$ISSUE_NUM" ]; then
echo "ISSUE_NUMBER=" >> $GITHUB_OUTPUT
echo "skip=true" >> $GITHUB_OUTPUT
echo "未从 PR 描述或分支名解析到 Issue 编号,跳过关闭"
exit 0
fi
echo "ISSUE_NUMBER=$ISSUE_NUM" >> $GITHUB_OUTPUT
echo "skip=false" >> $GITHUB_OUTPUT
echo "解析到 Issue #$ISSUE_NUM"
- name: Get issue details
if: steps.pr.outputs.skip != 'true'
id: issue
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
ISSUE_NUM="${{ steps.pr.outputs.ISSUE_NUMBER }}"
# 获取 issue 标题与 URL(用于 TG 消息)
JSON=$(gh issue view "$ISSUE_NUM" --json title,url 2>/dev/null || echo '{"title":"","url":""}')
TITLE=$(echo "$JSON" | jq -r '.title')
ISSUE_URL=$(echo "$JSON" | jq -r '.url')
echo "title<<EOF" >> $GITHUB_OUTPUT
echo "$TITLE" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
echo "url=$ISSUE_URL" >> $GITHUB_OUTPUT
- name: Close issue
if: steps.pr.outputs.skip != 'true'
uses: actions/github-script@v7
with:
script: |
const issueNumber = parseInt('${{ steps.pr.outputs.ISSUE_NUMBER }}', 10);
if (!issueNumber || isNaN(issueNumber)) {
console.log('No valid issue number, skip');
return;
}
const { data: issue } = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber
});
if (issue.state === 'open') {
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
state: 'closed'
});
console.log(`Issue #${issueNumber} closed.`);
} else {
console.log(`Issue #${issueNumber} already closed.`);
}
- name: Send Telegram notification
if: steps.pr.outputs.skip != 'true'
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
run: |
# 与 docker-build 一致:未配置则跳过
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
echo "⚠️ Telegram Bot Token 或 Chat ID 未配置,跳过通知"
exit 0
fi
ISSUE_NUM="${{ steps.pr.outputs.ISSUE_NUMBER }}"
ISSUE_TITLE="${{ steps.issue.outputs.title }}"
ISSUE_URL="${{ steps.issue.outputs.url }}"
PR_URL="${{ github.event.pull_request.html_url }}"
# 与 docker-build 相同的 HTML 消息格式
MESSAGE="✅ <b>AI 修复的 Issue 已关闭</b>"$'\n'$'\n'"🔢 <b>Issue:</b> #${ISSUE_NUM} ${ISSUE_TITLE}"$'\n'"📎 <a href=\"${ISSUE_URL}\">查看 Issue</a>"$'\n'"🔗 <a href=\"${PR_URL}\">查看 PR</a>"
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg chat_id "$TELEGRAM_CHAT_ID" \
--arg text "$MESSAGE" \
'{chat_id: $chat_id, text: $text, parse_mode: "HTML", disable_web_page_preview: false}')" > /tmp/telegram_response.json
if [ $? -eq 0 ]; then
RESPONSE=$(cat /tmp/telegram_response.json)
if echo "$RESPONSE" | grep -q '"ok":true'; then
echo "✅ Telegram 通知发送成功"
else
echo "❌ Telegram 通知发送失败: $RESPONSE"
exit 0
fi
else
echo "❌ 发送 Telegram 消息时发生错误"
exit 0
fi
+53 -98
View File
@@ -6,14 +6,6 @@ on:
- published # 当通过 GitHub Releases 页面创建 release 时触发
workflow_dispatch:
inputs:
build_type:
description: '构建类型'
required: true
type: choice
options:
- package-only # 只打包产物
- package-and-docker # 打包产物 + Docker 镜像
default: 'package-and-docker'
version:
description: '版本号(例如: v1.0.0'
required: false
@@ -36,22 +28,6 @@ jobs:
with:
ref: ${{ github.event.release.tag_name || github.event.inputs.tag_name || github.event.inputs.version || github.ref }}
- name: Determine build type
id: build_config
run: |
# 确定构建类型
if [ "${{ github.event_name }}" = "release" ]; then
# Release 事件:默认只打包产物(不构建 Docker)
BUILD_TYPE="package-only"
echo "📦 Release 事件:将只打包产物(不构建 Docker)"
else
# workflow_dispatch 事件:使用用户输入
BUILD_TYPE="${{ github.event.inputs.build_type }}"
echo "🔧 手动触发:构建类型 = ${BUILD_TYPE}"
fi
echo "BUILD_TYPE=${BUILD_TYPE}" >> $GITHUB_OUTPUT
- name: Extract version and check if pre-release
id: extract_version
run: |
@@ -100,53 +76,6 @@ jobs:
echo "📦 这是正式版本: $TAG_NAME"
fi
- name: Send Telegram notification (build started)
if: steps.extract_version.outputs.IS_PRERELEASE == 'false' && steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
run: |
# 检查必要的环境变量
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
echo "⚠️ Telegram Bot Token 或 Chat ID 未配置,跳过通知"
exit 0
fi
# 获取构建信息
TAG="${{ steps.extract_version.outputs.TAG }}"
if [ "${{ github.event_name }}" = "release" ]; then
RELEASE_URL="${{ github.event.release.html_url }}"
MESSAGE="🔨 <b>Release 构建中</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"
else
WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
MESSAGE="🔨 <b>构建中</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">查看 Workflow</a>"
fi
# 发送 Telegram 消息(使用 jq 转义 JSON
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg chat_id "$TELEGRAM_CHAT_ID" \
--arg text "$MESSAGE" \
'{chat_id: $chat_id, text: $text, parse_mode: "HTML", disable_web_page_preview: false}')" > /tmp/telegram_response.json
# 检查发送结果
if [ $? -eq 0 ]; then
RESPONSE=$(cat /tmp/telegram_response.json)
if echo "$RESPONSE" | grep -q '"ok":true'; then
echo "✅ Telegram 通知发送成功"
else
echo "❌ Telegram 通知发送失败: $RESPONSE"
# 通知失败不应该导致整个 job 失败
exit 0
fi
else
echo "❌ 发送 Telegram 消息时发生错误"
# 通知失败不应该导致整个 job 失败
exit 0
fi
# ============ 编译前后端产物 ============
- name: Setup JDK 17
uses: actions/setup-java@v4
@@ -263,22 +192,66 @@ jobs:
checksums.txt
retention-days: 30
# ============ 发送产物上传成功通知 ============
- name: Send Telegram notification (package uploaded)
if: steps.extract_version.outputs.IS_PRERELEASE == 'false'
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
run: |
# 检查必要的环境变量
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
echo "⚠️ Telegram Bot Token 或 Chat ID 未配置,跳过通知"
exit 0
fi
# 获取构建信息
TAG="${{ steps.extract_version.outputs.TAG }}"
if [ "${{ github.event_name }}" = "release" ]; then
RELEASE_URL="${{ github.event.release.html_url }}"
MESSAGE="✅ <b>PolyHermes Package Built</b>"$'\n'$'\n'"🏷️ <b>Version:</b> <code>${TAG}</code>"$'\n'"🔧 <b>Type:</b> Online Update"$'\n'"🔗 <a href=\"${RELEASE_URL}\">View Release</a>"$'\n'"📍 <b>Update Path:</b> System Management → Overview → Check for Updates"$'\n'$'\n'"🐳 Building Docker image..."
else
WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
MESSAGE="✅ <b>PolyHermes Package Built</b>"$'\n'$'\n'"🏷️ <b>Version:</b> <code>${TAG}</code>"$'\n'"🔧 <b>Type:</b> Online Update"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">View Workflow</a>"$'\n'$'\n'"🐳 Building Docker image..."
fi
# 发送 Telegram 消息(使用 jq 转义 JSON
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg chat_id "$TELEGRAM_CHAT_ID" \
--arg text "$MESSAGE" \
'{chat_id: $chat_id, text: $text, parse_mode: "HTML", disable_web_page_preview: false}')" > /tmp/telegram_response.json
# 检查发送结果
if [ $? -eq 0 ]; then
RESPONSE=$(cat /tmp/telegram_response.json)
if echo "$RESPONSE" | grep -q '"ok":true'; then
echo "✅ Telegram 通知发送成功"
else
echo "❌ Telegram 通知发送失败: $RESPONSE"
exit 0
fi
else
echo "❌ 发送 Telegram 消息时发生错误"
exit 0
fi
# ============ Docker 构建 ============
- name: Set up Docker Buildx
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
uses: docker/setup-buildx-action@v3
with:
# 启用多架构构建支持
platforms: linux/amd64,linux/arm64
- name: Log in to Docker Hub
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Prepare Docker build context
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
run: |
echo "📦 准备 Docker 构建上下文..."
# 确保构建产物存在且可访问
@@ -295,7 +268,7 @@ jobs:
ls -lh backend/build/libs/*.jar
- name: Build and push Docker image
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
id: docker_build
uses: docker/build-push-action@v5
with:
context: .
@@ -314,13 +287,8 @@ jobs:
cache-from: type=registry,ref=wrbug/polyhermes:latest
cache-to: type=inline
- name: Skip Docker build notice
if: steps.build_config.outputs.BUILD_TYPE == 'package-only'
run: |
echo "⏭️ 跳过 Docker 镜像构建(构建类型:package-only"
echo "✅ 仅打包产物已完成"
- name: Send Telegram notification
# ============ 发送 Docker 构建成功通知 ============
- name: Send Telegram notification (Docker build completed)
if: steps.extract_version.outputs.IS_PRERELEASE == 'false'
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
@@ -335,25 +303,14 @@ jobs:
# 获取构建信息
VERSION="${{ steps.extract_version.outputs.VERSION }}"
TAG="${{ steps.extract_version.outputs.TAG }}"
BUILD_TYPE="${{ steps.build_config.outputs.BUILD_TYPE }}"
# 构建消息内容(仅包含关键信息)
DEPLOY_DOC_URL="https://github.com/WrBug/PolyHermes/blob/main/docs/zh/DEPLOYMENT.md"
if [ "${{ github.event_name }}" = "release" ]; then
RELEASE_URL="${{ github.event.release.html_url }}"
if [ "$BUILD_TYPE" = "package-and-docker" ]; then
MESSAGE="✅ <b>Release 构建成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker 部署文档</a>"
else
MESSAGE="✅ <b>Release 打包成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> 在线升级"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"$'\n'"📍 <b>升级路径:</b> 系统管理 → 概览 → 检查更新"
fi
MESSAGE="🐳 <b>Docker Image Built Successfully</b>"$'\n'$'\n'"🏷️ <b>Version:</b> <code>${TAG}</code>"$'\n'"📦 <b>Image:</b> <code>wrbug/polyhermes:${TAG}</code>"$'\n'"🔗 <a href=\"${RELEASE_URL}\">View Release</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker Deployment Guide</a>"
else
WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
if [ "$BUILD_TYPE" = "package-and-docker" ]; then
MESSAGE="✅ <b>构建成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">查看 Workflow</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker 部署文档</a>"
else
MESSAGE="✅ <b>打包成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> 在线升级"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">查看 Workflow</a>"$'\n'"📍 <b>升级路径:</b> 系统管理 → 概览 → 检查更新"
fi
MESSAGE="🐳 <b>Docker Image Built Successfully</b>"$'\n'$'\n'"🏷️ <b>Version:</b> <code>${TAG}</code>"$'\n'"📦 <b>Image:</b> <code>wrbug/polyhermes:${TAG}</code>"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">View Workflow</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker Deployment Guide</a>"
fi
# 发送 Telegram 消息(使用 jq 转义 JSON
@@ -371,11 +328,9 @@ jobs:
echo "✅ Telegram 通知发送成功"
else
echo "❌ Telegram 通知发送失败: $RESPONSE"
# 构建成功,通知失败不应该导致整个 job 失败
exit 0
fi
else
echo "❌ 发送 Telegram 消息时发生错误"
# 构建成功,通知失败不应该导致整个 job 失败
exit 0
fi
fi
+27
View File
@@ -0,0 +1,27 @@
name: Sync fork
on:
workflow_dispatch:
schedule:
# 10:00 Asia/Shanghai every day (GitHub Actions cron uses UTC).
- cron: "0 2 * * *"
permissions:
contents: write
concurrency:
group: sync-fork-main
cancel-in-progress: false
jobs:
sync-main:
if: github.repository == 'codychen123/PolyHermes'
runs-on: ubuntu-latest
steps:
- name: Sync fork main from upstream
run: |
gh repo sync "$GITHUB_REPOSITORY" \
--source WrBug/PolyHermes \
--branch main
env:
GH_TOKEN: ${{ github.token }}
+4 -1
View File
@@ -95,6 +95,7 @@ coverage/
# Test
test-results/
*.test.log
.playwright-cli/
# Python
__pycache__/
@@ -112,4 +113,6 @@ __pycache__/
clob-client/
builder-relayer-client/
landing-page/
clob-client-v2/
settings.local.json
.gstack/
+12 -1
View File
@@ -100,6 +100,17 @@ RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
fi; \
fi
# 统一选出可执行 JAR,避免 *-plain.jar 和 bootJar 同时存在导致最终 COPY 匹配多个文件
RUN set -e; \
JAR="$(find build/libs -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | head -n 1)"; \
if [ -z "$JAR" ]; then \
echo "❌ 错误:找不到可执行 JAR(已排除 *-plain.jar"; \
exit 1; \
fi; \
if [ "$JAR" != "build/libs/app.jar" ]; then \
cp "$JAR" build/libs/app.jar; \
fi
# ==================== 阶段3:运行环境 ====================
FROM eclipse-temurin:17-jre-jammy
@@ -114,7 +125,7 @@ RUN apt-get update && \
# 从构建阶段复制文件
# 当 BUILD_IN_DOCKER=false 时,构建阶段已经复制了外部产物
COPY --from=frontend-build /app/frontend/dist /usr/share/nginx/html
COPY --from=backend-build /app/backend/build/libs/*.jar app.jar
COPY --from=backend-build /app/backend/build/libs/app.jar app.jar
# 复制 Nginx 配置
COPY docker/nginx.conf /etc/nginx/nginx.conf
+2
View File
@@ -421,6 +421,8 @@ cd frontend
- [开发文档](docs/zh/DEVELOPMENT.md) - 开发指南
- [跟单系统需求文档](docs/zh/copy-trading-requirements.md) - 后端 API 接口文档
- [前端需求文档](docs/zh/copy-trading-frontend-requirements.md) - 前端功能文档
- [跟单亏损诊断与风险安全带](docs/zh/copy-trading-risk-seatbelt.md) - PnL 诊断、安全带确认流程和运维排查命令
- [Leader Research Agent](docs/zh/leader-research-agent.md) - 自动发现、纸跟、评分和禁用试跟审批说明
- [动态更新文档](docs/zh/DYNAMIC_UPDATE.md) - 动态更新功能说明
### 🤝 贡献指南
+1 -1
View File
@@ -420,6 +420,7 @@ The development documentation includes:
- [Development Documentation](docs/en/DEVELOPMENT.md) - Development guide
- [Copy Trading System Requirements](docs/zh/copy-trading-requirements.md) - Backend API documentation (Chinese only)
- [Frontend Requirements](docs/zh/copy-trading-frontend-requirements.md) - Frontend feature documentation (Chinese only)
- [Leader Research Agent](docs/zh/leader-research-agent.md) - Auto-discovery, paper trading, scoring, and disabled-trial approval guide (Chinese only)
### 🤝 Contributing
@@ -467,4 +468,3 @@ Thanks to all developers and users who have contributed to this project!
---
**⭐ If this project helps you, please give it a Star!**
File diff suppressed because it is too large Load Diff
@@ -92,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 前缀)
@@ -138,7 +138,17 @@ interface BuilderRelayerApi {
val relayHub: String? = null,
@SerializedName("relay")
val relay: String? = null
val relay: String? = null,
/** SAFE-CREATE 签名参数 */
@SerializedName("paymentToken")
val paymentToken: String? = null,
@SerializedName("payment")
val payment: String? = null,
@SerializedName("paymentReceiver")
val paymentReceiver: String? = null
)
/**
@@ -73,7 +73,7 @@ interface PolymarketClobApi {
*/
@POST("/orders/batch")
suspend fun createOrdersBatch(
@Body request: CreateOrdersBatchRequest
@Body request: List<NewOrderRequest>
): Response<List<OrderResponse>>
/**
@@ -174,22 +174,25 @@ interface PolymarketClobApi {
// 请求和响应数据类
/**
* 签名的订单对象根据官方文档
* 参考: https://docs.polymarket.com/developers/CLOB/orders/create-order
* V2 签名的订单对象
* EIP-712 签名字段: salt, maker, signer, tokenId, makerAmount, takerAmount, side, signatureType, timestamp, metadata, builder
* API payload 额外字段: taker, expiration (不在 EIP-712 签名中 API 请求需要)
* 参考: clob-client-v2/src/types/ordersV2.ts NewOrderV2
*/
data class SignedOrderObject(
val salt: Long, // random salt used to create unique order
val maker: String, // maker address (funder)
val signer: String, // signing address
val taker: String, // taker address (operator)
val taker: String, // taker address (zero address for public orders, NOT in EIP-712 signing)
val tokenId: String, // ERC1155 token ID of conditional token being traded
val makerAmount: String, // maximum amount maker is willing to spend
val takerAmount: String, // minimum amount taker will pay the maker in return
val expiration: String, // unix expiration timestamp
val nonce: String, // maker's exchange nonce of the order is associated
val feeRateBps: String, // fee rate basis points as required by the operator
val side: String, // buy or sell enum index ("BUY" or "SELL")
val signatureType: Int, // signature type enum index
val timestamp: String, // order creation time in milliseconds (V2)
val expiration: String, // expiration timestamp unix seconds, "0" = no expiration (NOT in EIP-712 signing)
val metadata: String, // bytes32 metadata (V2)
val builder: String, // bytes32 builder code (V2)
val signature: String // hex encoded signature
)
@@ -198,10 +201,11 @@ data class SignedOrderObject(
* 参考: https://docs.polymarket.com/developers/CLOB/orders/create-order
*/
data class NewOrderRequest(
val order: SignedOrderObject, // signed object
val order: SignedOrderObject, // V2 signed object
val owner: String, // api key of order owner
val orderType: String, // order type ("FOK", "GTC", "GTD", "FAK")
val deferExec: Boolean = false // defer execution flag
val deferExec: Boolean = false, // defer execution
val postOnly: Boolean = false // post only (maker-only)
)
/**
@@ -212,6 +216,7 @@ data class NewOrderResponse(
val success: Boolean, // boolean indicating if server-side error
@SerializedName("errorMsg")
val errorMsg: String? = null, // error message in case of unsuccessful placement
val error: String? = null, // error message (alternative field, e.g. "Trading restricted in your region...")
@SerializedName("orderID")
val orderId: String? = null, // id of orderAPI 返回字段名为 orderID
@SerializedName("transactionsHashes")
@@ -222,27 +227,17 @@ data class NewOrderResponse(
val takingAmount: String? = null, // taking amount
@SerializedName("makingAmount")
val makingAmount: String? = null // making amount
)
/**
* 旧的订单请求格式已废弃保留用于兼容
* @deprecated 使用 NewOrderRequest 代替
*/
@Deprecated("使用 NewOrderRequest 代替,需要签名的订单对象")
data class CreateOrderRequest(
val market: String? = null, // condition ID(可选,如果提供tokenId则不需要)
val token_id: String? = null, // token ID(可选,如果提供market则不需要)
val side: String, // "BUY" or "SELL"
val price: String,
val size: String,
val type: String = "LIMIT",
val expiration: Long? = null
)
@Deprecated("使用 NewOrderRequest 代替")
data class CreateOrdersBatchRequest(
val orders: List<NewOrderRequest>
)
) {
/**
* 获取错误信息的便捷方法
* 优先返回 errorMsg其次返回 error最后返回默认消息
*/
fun getErrorMessage(): String {
return errorMsg?.takeIf { it.isNotBlank() }
?: error?.takeIf { it.isNotBlank() }
?: "创建订单失败"
}
}
data class CancelOrdersBatchRequest(
val orderIds: List<String>
@@ -0,0 +1,23 @@
package com.wrbug.polymarketbot.config
import com.wrbug.polymarketbot.service.common.WebSocketSubscriptionService
import com.wrbug.polymarketbot.service.cryptotail.CryptoTailMonitorService
import jakarta.annotation.PostConstruct
import org.springframework.context.annotation.Configuration
/**
* 加密价差策略监控服务配置
* 处理 WebSocketSubscriptionService CryptoTailMonitorService 之间的循环依赖
*/
@Configuration
class MonitorServiceConfig(
private val webSocketSubscriptionService: WebSocketSubscriptionService,
private val cryptoTailMonitorService: CryptoTailMonitorService
) {
@PostConstruct
fun init() {
// 在所有 Bean 初始化后设置引用
webSocketSubscriptionService.setCryptoTailMonitorService(cryptoTailMonitorService)
}
}
@@ -44,5 +44,14 @@ object PolymarketConstants {
* 用于 Gasless 交易
*/
const val BUILDER_RELAYER_URL = "https://relayer-v2.polymarket.com/"
/**
* Polymarket Safe 代理工厂合约地址Polygon 主网
* 用于 Safe 类型账户的代理部署SAFE-CREATE
*/
const val SAFE_PROXY_FACTORY_ADDRESS = "0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b"
/** SafeCreate 用 EIP-712 domain name,与 builder-relayer-client 一致 */
const val SAFE_FACTORY_EIP712_NAME = "Polymarket Contract Proxy Factory"
}
@@ -204,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))
}
}
/**
* 查询账户详情
*/
@@ -496,5 +572,53 @@ class AccountController(
}
}
/**
* USDC.e wrap pUSDV2 迁移
*/
@PostMapping("/wrap-to-pusd")
fun wrapToPusd(@RequestBody request: Map<String, Any>): ResponseEntity<ApiResponse<Map<String, String?>>> {
return try {
val accountId = (request["accountId"] as? Number)?.toLong()
?: return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ACCOUNT_ID_INVALID, messageSource = messageSource))
val result = runBlocking { accountService.wrapUsdcToPusd(accountId) }
result.fold(
onSuccess = { txHash ->
ResponseEntity.ok(ApiResponse.success(mapOf("transactionHash" to txHash)))
},
onFailure = { e ->
logger.error("USDC.e → pUSD wrap 失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("USDC.e → pUSD wrap 异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
/**
* 查询 USDC.e 余额V2 迁移用
*/
@PostMapping("/usdce-balance")
fun getUsdceBalance(@RequestBody request: Map<String, Any>): ResponseEntity<ApiResponse<Map<String, String>>> {
return try {
val accountId = (request["accountId"] as? Number)?.toLong()
?: return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ACCOUNT_ID_INVALID, messageSource = messageSource))
val result = runBlocking { accountService.getUsdceBalance(accountId) }
result.fold(
onSuccess = { balance ->
ResponseEntity.ok(ApiResponse.success(mapOf("balance" to balance.toPlainString())))
},
onFailure = { e ->
logger.error("查询 USDC.e 余额失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("查询 USDC.e 余额异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
}
@@ -112,6 +112,43 @@ class CopyTradingController(
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_COPY_TRADING_UPDATE_FAILED, e.message, messageSource))
}
}
/**
* 应用诊断建议的保守风控配置
*/
@PostMapping("/apply-conservative-config")
fun applyConservativeConfig(@RequestBody request: ApplyConservativeConfigRequest): ResponseEntity<ApiResponse<CopyTradingDto>> {
return try {
if (request.copyTradingId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_COPY_TRADING_ID_INVALID, messageSource = messageSource))
}
val result = copyTradingService.applyConservativeConfig(request)
result.fold(
onSuccess = { copyTrading ->
ResponseEntity.ok(ApiResponse.success(copyTrading))
},
onFailure = { e ->
logger.error("应用保守配置失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> {
val errorCode = if (e.message == "跟单配置不存在") {
ErrorCode.COPY_TRADING_NOT_FOUND
} else {
ErrorCode.PARAM_ERROR
}
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
}
is IllegalStateException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.BUSINESS_ERROR, e.message, messageSource))
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_COPY_TRADING_UPDATE_FAILED, e.message, messageSource))
}
}
)
} catch (e: Exception) {
logger.error("应用保守配置异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_COPY_TRADING_UPDATE_FAILED, e.message, messageSource))
}
}
/**
* 更新跟单状态兼容旧接口
@@ -219,4 +256,3 @@ class CopyTradingController(
}
}
}
@@ -0,0 +1,175 @@
package com.wrbug.polymarketbot.controller.copytrading.leaderpool
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.copytrading.leaderpool.LeaderPoolAlreadyExistsException
import com.wrbug.polymarketbot.service.copytrading.leaderpool.LeaderPoolConfirmRequiredException
import com.wrbug.polymarketbot.service.copytrading.leaderpool.LeaderPoolDuplicateTrialConfigException
import com.wrbug.polymarketbot.service.copytrading.leaderpool.LeaderPoolNotFoundException
import com.wrbug.polymarketbot.service.copytrading.leaderpool.LeaderPoolResearchCandidateNotReadyException
import com.wrbug.polymarketbot.service.copytrading.leaderpool.LeaderPoolService
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/copy-trading/leader-pool")
class LeaderPoolController(
private val leaderPoolService: LeaderPoolService,
private val messageSource: MessageSource
) {
private val logger = LoggerFactory.getLogger(LeaderPoolController::class.java)
@PostMapping("/list")
fun list(@RequestBody request: LeaderPoolListRequest): ResponseEntity<ApiResponse<LeaderPoolListResponse>> {
return try {
leaderPoolService.getPoolList(request).fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("查询 Leader 池失败: ${e.message}", e)
errorResponse(e, ErrorCode.SERVER_LEADER_POOL_LIST_FETCH_FAILED)
}
)
} catch (e: Exception) {
logger.error("查询 Leader 池异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_LEADER_POOL_LIST_FETCH_FAILED, e.message, messageSource))
}
}
@PostMapping("/add")
fun add(@RequestBody request: LeaderPoolAddRequest): ResponseEntity<ApiResponse<LeaderPoolItemDto>> {
return try {
if (request.leaderId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_LEADER_ID_INVALID, messageSource = messageSource))
}
leaderPoolService.addToPool(request).fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("加入 Leader 池失败: ${e.message}", e)
errorResponse(e, ErrorCode.SERVER_LEADER_POOL_SAVE_FAILED)
}
)
} catch (e: Exception) {
logger.error("加入 Leader 池异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_LEADER_POOL_SAVE_FAILED, e.message, messageSource))
}
}
@PostMapping("/update-status")
fun updateStatus(@RequestBody request: LeaderPoolUpdateStatusRequest): ResponseEntity<ApiResponse<LeaderPoolItemDto>> {
return try {
if (request.poolId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_INVALID, "poolId 无效", messageSource))
}
if (request.status.isBlank()) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, "status 不能为空", messageSource))
}
leaderPoolService.updateStatus(request).fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("更新 Leader 池状态失败: ${e.message}", e)
errorResponse(e, ErrorCode.SERVER_LEADER_POOL_SAVE_FAILED)
}
)
} catch (e: Exception) {
logger.error("更新 Leader 池状态异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_LEADER_POOL_SAVE_FAILED, e.message, messageSource))
}
}
@PostMapping("/update-plan")
fun updatePlan(@RequestBody request: LeaderPoolUpdatePlanRequest): ResponseEntity<ApiResponse<LeaderPoolItemDto>> {
return try {
if (request.poolId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_INVALID, "poolId 无效", messageSource))
}
leaderPoolService.updatePlan(request).fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("更新 Leader 池建议配置失败: ${e.message}", e)
errorResponse(e, ErrorCode.SERVER_LEADER_POOL_SAVE_FAILED)
}
)
} catch (e: Exception) {
logger.error("更新 Leader 池建议配置异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_LEADER_POOL_SAVE_FAILED, e.message, messageSource))
}
}
@PostMapping("/create-trial-config")
fun createTrialConfig(@RequestBody request: LeaderPoolCreateTrialConfigRequest): ResponseEntity<ApiResponse<CopyTradingDto>> {
return try {
if (request.poolId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_INVALID, "poolId 无效", messageSource))
}
if (request.accountId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ACCOUNT_ID_INVALID, messageSource = messageSource))
}
leaderPoolService.createTrialConfig(request).fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("创建 Leader 池试跟配置失败: ${e.message}", e)
errorResponse(e, ErrorCode.SERVER_LEADER_POOL_CREATE_TRIAL_FAILED)
}
)
} catch (e: Exception) {
logger.error("创建 Leader 池试跟配置异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_LEADER_POOL_CREATE_TRIAL_FAILED, e.message, messageSource))
}
}
@PostMapping("/remove")
fun remove(@RequestBody request: LeaderPoolRemoveRequest): ResponseEntity<ApiResponse<Unit>> {
return try {
if (request.poolId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_INVALID, "poolId 无效", messageSource))
}
leaderPoolService.remove(request).fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(Unit)) },
onFailure = { e ->
logger.error("移除 Leader 池项失败: ${e.message}", e)
errorResponse(e, ErrorCode.SERVER_LEADER_POOL_SAVE_FAILED)
}
)
} catch (e: Exception) {
logger.error("移除 Leader 池项异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_LEADER_POOL_SAVE_FAILED, e.message, messageSource))
}
}
private fun mapErrorCode(e: Throwable, fallback: ErrorCode): ErrorCode {
return when (e) {
is LeaderPoolNotFoundException -> ErrorCode.LEADER_POOL_NOT_FOUND
is LeaderPoolAlreadyExistsException -> ErrorCode.LEADER_POOL_ALREADY_EXISTS
is LeaderPoolDuplicateTrialConfigException -> ErrorCode.LEADER_POOL_DUPLICATE_TRIAL_CONFIG
is LeaderPoolConfirmRequiredException -> ErrorCode.LEADER_POOL_CONFIRM_REQUIRED
is LeaderPoolResearchCandidateNotReadyException -> ErrorCode.LEADER_RESEARCH_CANDIDATE_NOT_READY
is IllegalArgumentException -> when (e.message) {
"账户不存在" -> ErrorCode.ACCOUNT_NOT_FOUND
"Leader 不存在" -> ErrorCode.LEADER_NOT_FOUND
else -> ErrorCode.PARAM_ERROR
}
else -> fallback
}
}
private fun <T> errorResponse(e: Throwable, fallback: ErrorCode): ResponseEntity<ApiResponse<T>> {
val errorCode = mapErrorCode(e, fallback)
val customMsg = if (usesI18nMessage(errorCode)) null else e.message
return ResponseEntity.ok(ApiResponse.error(errorCode, customMsg, messageSource))
}
private fun usesI18nMessage(errorCode: ErrorCode): Boolean {
return errorCode == ErrorCode.LEADER_POOL_NOT_FOUND ||
errorCode == ErrorCode.LEADER_POOL_ALREADY_EXISTS ||
errorCode == ErrorCode.LEADER_POOL_DUPLICATE_TRIAL_CONFIG ||
errorCode == ErrorCode.LEADER_POOL_CONFIRM_REQUIRED ||
errorCode == ErrorCode.LEADER_RESEARCH_CANDIDATE_NOT_READY ||
errorCode == ErrorCode.ACCOUNT_NOT_FOUND ||
errorCode == ErrorCode.LEADER_NOT_FOUND
}
}
@@ -0,0 +1,146 @@
package com.wrbug.polymarketbot.controller.copytrading.research
import com.wrbug.polymarketbot.dto.ApiResponse
import com.wrbug.polymarketbot.dto.LeaderResearchApprovalRequest
import com.wrbug.polymarketbot.dto.LeaderResearchApprovalResponse
import com.wrbug.polymarketbot.dto.LeaderResearchCandidateDetailDto
import com.wrbug.polymarketbot.dto.LeaderResearchCandidateListRequest
import com.wrbug.polymarketbot.dto.LeaderResearchCandidateListResponse
import com.wrbug.polymarketbot.dto.LeaderResearchEventDto
import com.wrbug.polymarketbot.dto.LeaderPaperSessionDto
import com.wrbug.polymarketbot.dto.LeaderResearchRunDto
import com.wrbug.polymarketbot.dto.LeaderResearchRunRequest
import com.wrbug.polymarketbot.dto.LeaderResearchSourceStateDto
import com.wrbug.polymarketbot.dto.LeaderResearchSummaryDto
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.enums.LeaderResearchTriggerType
import com.wrbug.polymarketbot.service.copytrading.research.LeaderResearchApprovalConfirmRequiredException
import com.wrbug.polymarketbot.service.copytrading.research.LeaderResearchApprovalService
import com.wrbug.polymarketbot.service.copytrading.research.LeaderResearchCandidateNotReadyException
import com.wrbug.polymarketbot.service.copytrading.research.LeaderResearchCandidateLockedException
import com.wrbug.polymarketbot.service.copytrading.research.LeaderResearchDuplicateTrialConfigException
import com.wrbug.polymarketbot.service.copytrading.research.LeaderResearchJobService
import com.wrbug.polymarketbot.service.copytrading.research.LeaderResearchMapper
import com.wrbug.polymarketbot.service.copytrading.research.LeaderResearchRealMoneyForbiddenException
import com.wrbug.polymarketbot.service.copytrading.research.LeaderResearchService
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
data class LeaderResearchDetailRequest(val candidateId: Long)
data class LeaderResearchEventsRequest(val page: Int = 0, val size: Int = 50)
data class LeaderResearchPaperSessionsRequest(val candidateId: Long)
@RestController
@RequestMapping("/api/copy-trading/leader-research")
class LeaderResearchController(
private val jobService: LeaderResearchJobService,
private val researchService: LeaderResearchService,
private val approvalService: LeaderResearchApprovalService,
private val mapper: LeaderResearchMapper,
private val messageSource: MessageSource
) {
private val logger = LoggerFactory.getLogger(LeaderResearchController::class.java)
@PostMapping("/run")
fun run(@RequestBody request: LeaderResearchRunRequest): ResponseEntity<ApiResponse<LeaderResearchRunDto>> {
return try {
val trigger = runCatching { LeaderResearchTriggerType.valueOf(request.triggerType.uppercase()) }
.getOrDefault(LeaderResearchTriggerType.MANUAL)
val run = if (request.dryRun || trigger == LeaderResearchTriggerType.PREVIEW) {
jobService.runOnce(request.dryRun, trigger)
} else {
jobService.startAsync(request.dryRun, trigger)
}
ResponseEntity.ok(ApiResponse.success(mapper.runDto(run)))
} catch (e: Exception) {
logger.error("Leader research run failed", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_LEADER_RESEARCH_RUN_FAILED, e.message, messageSource))
}
}
@PostMapping("/summary")
fun summary(): ResponseEntity<ApiResponse<LeaderResearchSummaryDto>> {
return safe(ErrorCode.SERVER_LEADER_RESEARCH_FETCH_FAILED) { researchService.summary() }
}
@PostMapping("/candidates/list")
fun list(@RequestBody request: LeaderResearchCandidateListRequest): ResponseEntity<ApiResponse<LeaderResearchCandidateListResponse>> {
return safe(ErrorCode.SERVER_LEADER_RESEARCH_FETCH_FAILED) { researchService.listCandidates(request) }
}
@PostMapping("/candidates/detail")
fun detail(@RequestBody request: LeaderResearchDetailRequest): ResponseEntity<ApiResponse<LeaderResearchCandidateDetailDto>> {
if (request.candidateId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_INVALID, "candidateId 无效", messageSource))
}
return safe(ErrorCode.SERVER_LEADER_RESEARCH_FETCH_FAILED) { researchService.detail(request.candidateId) }
}
@PostMapping("/paper-sessions")
fun paperSessions(@RequestBody request: LeaderResearchPaperSessionsRequest): ResponseEntity<ApiResponse<List<LeaderPaperSessionDto>>> {
if (request.candidateId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_INVALID, "candidateId 无效", messageSource))
}
return safe(ErrorCode.SERVER_LEADER_RESEARCH_FETCH_FAILED) { researchService.paperSessions(request.candidateId) }
}
@PostMapping("/source-health")
fun sourceHealth(): ResponseEntity<ApiResponse<List<LeaderResearchSourceStateDto>>> {
return safe(ErrorCode.SERVER_LEADER_RESEARCH_FETCH_FAILED) { researchService.sourceHealth() }
}
@PostMapping("/events/list")
fun events(@RequestBody request: LeaderResearchEventsRequest): ResponseEntity<ApiResponse<List<LeaderResearchEventDto>>> {
return safe(ErrorCode.SERVER_LEADER_RESEARCH_FETCH_FAILED) { researchService.events(request.page, request.size) }
}
@PostMapping("/approval/create-disabled-trial-config")
fun approve(@RequestBody request: LeaderResearchApprovalRequest): ResponseEntity<ApiResponse<LeaderResearchApprovalResponse>> {
if (request.candidateId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_INVALID, "candidateId 无效", messageSource))
}
if (request.accountId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ACCOUNT_ID_INVALID, messageSource = messageSource))
}
return try {
approvalService.createDisabledTrialConfig(request).fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e -> errorResponse(e, ErrorCode.SERVER_LEADER_RESEARCH_APPROVAL_FAILED) }
)
} catch (e: Exception) {
logger.error("Leader research approval failed", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_LEADER_RESEARCH_APPROVAL_FAILED, e.message, messageSource))
}
}
private fun <T> safe(errorCode: ErrorCode, block: () -> T): ResponseEntity<ApiResponse<T>> {
return try {
ResponseEntity.ok(ApiResponse.success(block()))
} catch (e: Exception) {
logger.error("Leader research request failed", e)
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
}
}
private fun <T> errorResponse(e: Throwable, fallback: ErrorCode): ResponseEntity<ApiResponse<T>> {
val errorCode = when (e) {
is LeaderResearchCandidateNotReadyException -> ErrorCode.LEADER_RESEARCH_CANDIDATE_NOT_READY
is LeaderResearchApprovalConfirmRequiredException -> ErrorCode.LEADER_RESEARCH_APPROVAL_CONFIRM_REQUIRED
is LeaderResearchDuplicateTrialConfigException -> ErrorCode.LEADER_RESEARCH_DUPLICATE_TRIAL_CONFIG
is LeaderResearchRealMoneyForbiddenException -> ErrorCode.LEADER_RESEARCH_REAL_MONEY_FORBIDDEN
is LeaderResearchCandidateLockedException -> ErrorCode.LEADER_RESEARCH_CANDIDATE_LOCKED
is IllegalArgumentException -> when (e.message) {
"账户不存在" -> ErrorCode.ACCOUNT_NOT_FOUND
"候选不存在" -> ErrorCode.LEADER_RESEARCH_CANDIDATE_NOT_FOUND
else -> ErrorCode.PARAM_ERROR
}
else -> fallback
}
return ResponseEntity.ok(ApiResponse.error(errorCode, null, messageSource))
}
}
@@ -11,9 +11,17 @@ import com.wrbug.polymarketbot.dto.CryptoTailStrategyTriggerListResponse
import com.wrbug.polymarketbot.dto.CryptoTailStrategyUpdateRequest
import com.wrbug.polymarketbot.dto.CryptoTailMarketOptionDto
import com.wrbug.polymarketbot.dto.CryptoTailAutoMinSpreadResponse
import com.wrbug.polymarketbot.dto.CryptoTailMonitorInitRequest
import com.wrbug.polymarketbot.dto.CryptoTailMonitorInitResponse
import com.wrbug.polymarketbot.dto.CryptoTailManualOrderRequest
import com.wrbug.polymarketbot.dto.CryptoTailManualOrderResponse
import com.wrbug.polymarketbot.dto.CryptoTailPnlCurveRequest
import com.wrbug.polymarketbot.dto.CryptoTailPnlCurveResponse
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService
import com.wrbug.polymarketbot.service.cryptotail.CryptoTailStrategyService
import com.wrbug.polymarketbot.service.cryptotail.CryptoTailMonitorService
import com.wrbug.polymarketbot.service.cryptotail.CryptoTailStrategyExecutionService
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
import org.springframework.http.ResponseEntity
@@ -21,11 +29,14 @@ import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import kotlinx.coroutines.runBlocking
@RestController
@RequestMapping("/api/crypto-tail-strategy")
class CryptoTailStrategyController(
private val cryptoTailStrategyService: CryptoTailStrategyService,
private val cryptoTailMonitorService: CryptoTailMonitorService,
private val cryptoTailStrategyExecutionService: CryptoTailStrategyExecutionService,
private val binanceKlineAutoSpreadService: BinanceKlineAutoSpreadService,
private val messageSource: MessageSource
) {
@@ -39,12 +50,12 @@ class CryptoTailStrategyController(
result.fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("查询尾盘策略列表失败: ${e.message}", 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)
logger.error("查询加密价差策略列表异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_LIST_FETCH_FAILED, e.message, messageSource))
}
}
@@ -56,7 +67,7 @@ class CryptoTailStrategyController(
result.fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("创建尾盘策略失败: ${e.message}", 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
@@ -68,7 +79,7 @@ class CryptoTailStrategyController(
}
)
} catch (e: Exception) {
logger.error("创建尾盘策略异常: ${e.message}", e)
logger.error("创建加密价差策略异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_CREATE_FAILED, e.message, messageSource))
}
}
@@ -83,7 +94,7 @@ class CryptoTailStrategyController(
result.fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("更新尾盘策略失败: ${e.message}", 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
@@ -95,7 +106,7 @@ class CryptoTailStrategyController(
}
)
} catch (e: Exception) {
logger.error("更新尾盘策略异常: ${e.message}", e)
logger.error("更新加密价差策略异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_UPDATE_FAILED, e.message, messageSource))
}
}
@@ -111,16 +122,36 @@ class CryptoTailStrategyController(
result.fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(Unit)) },
onFailure = { e ->
logger.error("删除尾盘策略失败: ${e.message}", 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)
logger.error("删除加密价差策略异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_DELETE_FAILED, e.message, messageSource))
}
}
@PostMapping("/pnl-curve")
fun getPnlCurve(@RequestBody request: CryptoTailPnlCurveRequest): ResponseEntity<ApiResponse<CryptoTailPnlCurveResponse>> {
return try {
if (request.strategyId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource))
}
val result = cryptoTailStrategyService.getPnlCurve(request)
result.fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("查询收益曲线失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("查询收益曲线异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED, e.message, messageSource))
}
}
@PostMapping("/triggers")
fun getTriggerRecords(@RequestBody request: CryptoTailStrategyTriggerListRequest): ResponseEntity<ApiResponse<CryptoTailStrategyTriggerListResponse>> {
return try {
@@ -146,7 +177,13 @@ class CryptoTailStrategyController(
return try {
val options = listOf(
CryptoTailMarketOptionDto(slug = "btc-updown-5m", title = "Bitcoin Up or Down - 5 minute", intervalSeconds = 300, periodStartUnix = 0L, endDate = null),
CryptoTailMarketOptionDto(slug = "btc-updown-15m", title = "Bitcoin Up or Down - 15 minute", intervalSeconds = 900, periodStartUnix = 0L, endDate = null)
CryptoTailMarketOptionDto(slug = "btc-updown-15m", title = "Bitcoin Up or Down - 15 minute", intervalSeconds = 900, periodStartUnix = 0L, endDate = null),
CryptoTailMarketOptionDto(slug = "eth-updown-5m", title = "Ethereum Up or Down - 5 minute", intervalSeconds = 300, periodStartUnix = 0L, endDate = null),
CryptoTailMarketOptionDto(slug = "eth-updown-15m", title = "Ethereum Up or Down - 15 minute", intervalSeconds = 900, periodStartUnix = 0L, endDate = null),
CryptoTailMarketOptionDto(slug = "sol-updown-5m", title = "Solana Up or Down - 5 minute", intervalSeconds = 300, periodStartUnix = 0L, endDate = null),
CryptoTailMarketOptionDto(slug = "sol-updown-15m", title = "Solana Up or Down - 15 minute", intervalSeconds = 900, periodStartUnix = 0L, endDate = null),
CryptoTailMarketOptionDto(slug = "xrp-updown-5m", title = "XRP Up or Down - 5 minute", intervalSeconds = 300, periodStartUnix = 0L, endDate = null),
CryptoTailMarketOptionDto(slug = "xrp-updown-15m", title = "XRP Up or Down - 15 minute", intervalSeconds = 900, periodStartUnix = 0L, endDate = null)
)
ResponseEntity.ok(ApiResponse.success(options))
} catch (e: Exception) {
@@ -167,8 +204,10 @@ class CryptoTailStrategyController(
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)
?: ((System.currentTimeMillis() / 1000 / intervalSeconds) * intervalSeconds)
// 默认使用 BTC 市场(向后兼容)
val marketSlugPrefix = (request["marketSlugPrefix"] as? String) ?: "btc-updown"
val pair = binanceKlineAutoSpreadService.computeAndCache(marketSlugPrefix, intervalSeconds, periodStartUnix)
?: return ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "fetch_failed", messageSource))
val body = CryptoTailAutoMinSpreadResponse(
minSpreadUp = pair.first.toPlainString(),
@@ -180,4 +219,65 @@ class CryptoTailStrategyController(
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
/**
* 初始化加密价差策略监控
* 返回策略信息开盘价tokenIds等初始化数据
*/
@PostMapping("/monitor/init")
fun initMonitor(@RequestBody request: CryptoTailMonitorInitRequest): ResponseEntity<ApiResponse<CryptoTailMonitorInitResponse>> {
return try {
if (request.strategyId <= 0) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource))
}
val result = cryptoTailMonitorService.initMonitor(request)
result.fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("初始化加密价差策略监控失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("初始化加密价差策略监控异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
/**
* 手动下单
* 用户主动触发下单不检查任何条件仅检查当前周期是否已下单
*/
@PostMapping("/manual-order")
fun manualOrder(@RequestBody request: CryptoTailManualOrderRequest): ResponseEntity<ApiResponse<CryptoTailManualOrderResponse>> {
return runBlocking {
try {
if (request.strategyId <= 0) {
return@runBlocking ResponseEntity.ok(
ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource)
)
}
val result = cryptoTailStrategyExecutionService.manualOrder(request)
result.fold(
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
onFailure = { e ->
logger.error("手动下单失败: ${e.message}", e)
val code = when (e.message) {
"策略不存在" -> ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND
"当前周期已下单" -> ErrorCode.PARAM_ERROR
"价格必须在 0~1 之间" -> ErrorCode.PARAM_ERROR
"数量不能少于 1" -> ErrorCode.PARAM_ERROR
"总金额不能少于 $1" -> ErrorCode.PARAM_ERROR
"总金额超过策略配置的投入金额" -> ErrorCode.PARAM_ERROR
else -> ErrorCode.SERVER_ERROR
}
ResponseEntity.ok(ApiResponse.error(code, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("手动下单异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
}
}
@@ -3,6 +3,7 @@ package com.wrbug.polymarketbot.controller.system
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.system.NotificationConfigService
import com.wrbug.polymarketbot.service.system.NotificationTemplateService
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
@@ -18,6 +19,7 @@ import org.springframework.web.bind.annotation.*
class NotificationController(
private val notificationConfigService: NotificationConfigService,
private val telegramNotificationService: TelegramNotificationService,
private val notificationTemplateService: NotificationTemplateService,
private val messageSource: MessageSource
) {
@@ -335,6 +337,155 @@ class NotificationController(
))
}
}
// ==================== 模板相关 API ====================
/**
* 获取所有模板类型
*/
@PostMapping("/templates/types")
fun getTemplateTypes(): ResponseEntity<ApiResponse<List<TemplateTypeInfoDto>>> {
return try {
val types = notificationTemplateService.getTemplateTypes()
ResponseEntity.ok(ApiResponse.success(types))
} catch (e: Exception) {
logger.error("获取模板类型失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource))
}
}
/**
* 获取所有模板
*/
@PostMapping("/templates/list")
fun getTemplates(): ResponseEntity<ApiResponse<List<NotificationTemplateDto>>> {
return try {
val templates = notificationTemplateService.getAllTemplates()
ResponseEntity.ok(ApiResponse.success(templates))
} catch (e: Exception) {
logger.error("获取模板列表失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource))
}
}
/**
* 获取单个模板
*/
@PostMapping("/templates/detail")
fun getTemplateDetail(@RequestBody request: TemplateDetailRequest): ResponseEntity<ApiResponse<NotificationTemplateDto>> {
return try {
if (request.templateType.isBlank()) {
return ResponseEntity.ok(ApiResponse.paramError("模板类型不能为空"))
}
val template = notificationTemplateService.getTemplate(request.templateType)
if (template == null) {
ResponseEntity.ok(ApiResponse.error(ErrorCode.NOT_FOUND, messageSource = messageSource))
} else {
ResponseEntity.ok(ApiResponse.success(template))
}
} catch (e: Exception) {
logger.error("获取模板详情失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource))
}
}
/**
* 获取模板可用变量
*/
@PostMapping("/templates/variables")
fun getTemplateVariables(@RequestBody request: TemplateDetailRequest): ResponseEntity<ApiResponse<TemplateVariablesResponse>> {
return try {
if (request.templateType.isBlank()) {
return ResponseEntity.ok(ApiResponse.paramError("模板类型不能为空"))
}
val variables = notificationTemplateService.getTemplateVariables(request.templateType)
if (variables == null) {
ResponseEntity.ok(ApiResponse.error(ErrorCode.NOT_FOUND, messageSource = messageSource))
} else {
ResponseEntity.ok(ApiResponse.success(variables))
}
} catch (e: Exception) {
logger.error("获取模板变量失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource))
}
}
/**
* 更新模板
*/
@PostMapping("/templates/update")
fun updateTemplate(@RequestBody request: UpdateTemplateRequestWithId): ResponseEntity<ApiResponse<NotificationTemplateDto>> {
return try {
if (request.templateType.isBlank()) {
return ResponseEntity.ok(ApiResponse.paramError("模板类型不能为空"))
}
if (request.templateContent.isBlank()) {
return ResponseEntity.ok(ApiResponse.paramError("模板内容不能为空"))
}
val template = notificationTemplateService.updateTemplate(request.templateType, request.templateContent)
ResponseEntity.ok(ApiResponse.success(template))
} catch (e: Exception) {
logger.error("更新模板失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource))
}
}
/**
* 重置模板为默认
*/
@PostMapping("/templates/reset")
fun resetTemplate(@RequestBody request: TemplateDetailRequest): ResponseEntity<ApiResponse<NotificationTemplateDto>> {
return try {
if (request.templateType.isBlank()) {
return ResponseEntity.ok(ApiResponse.paramError("模板类型不能为空"))
}
val template = notificationTemplateService.resetTemplate(request.templateType)
if (template == null) {
ResponseEntity.ok(ApiResponse.error(ErrorCode.NOT_FOUND, messageSource = messageSource))
} else {
ResponseEntity.ok(ApiResponse.success(template))
}
} catch (e: Exception) {
logger.error("重置模板失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource))
}
}
/**
* 发送模板测试消息
*/
@PostMapping("/templates/test")
fun testTemplate(@RequestBody request: TestTemplateRequest): ResponseEntity<ApiResponse<Boolean>> {
return try {
if (request.templateType.isBlank()) {
return ResponseEntity.ok(ApiResponse.paramError("模板类型不能为空"))
}
val success = runBlocking {
notificationTemplateService.sendTestMessage(request.templateType, request.templateContent)
}
if (success) {
ResponseEntity.ok(ApiResponse.success(true))
} else {
ResponseEntity.ok(ApiResponse.error(
ErrorCode.NOTIFICATION_TEST_FAILED,
messageSource = messageSource
))
}
} catch (e: Exception) {
logger.error("发送模板测试消息失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(
ErrorCode.NOTIFICATION_TEST_FAILED,
customMsg = "发送测试消息失败:${e.message}",
messageSource = messageSource
))
}
}
}
/**
@@ -384,3 +535,18 @@ data class NotificationConfigDeleteRequest(
val id: Long
)
/**
* 模板详情请求
*/
data class TemplateDetailRequest(
val templateType: String
)
/**
* 更新模板请求带类型
*/
data class UpdateTemplateRequestWithId(
val templateType: String,
val templateContent: String
)
@@ -68,9 +68,9 @@ data class SystemConfigDto(
val builderApiKeyConfigured: Boolean, // Builder API Key 是否已配置
val builderSecretConfigured: Boolean, // Builder Secret 是否已配置
val builderPassphraseConfigured: Boolean, // Builder Passphrase 是否已配置
val builderApiKeyDisplay: String? = null, // Builder API Key 显示值(部分显示,用于前端展示)
val builderSecretDisplay: String? = null, // Builder Secret 显示值(部分显示,用于前端展示)
val builderPassphraseDisplay: String? = null, // Builder Passphrase 显示值(部分显示,用于前端展示)
val builderApiKeyDisplay: String? = null, // Builder API Key 显示值(完整,用于前端展示)
val builderSecretDisplay: String? = null, // Builder Secret 显示值(完整,用于前端展示)
val builderPassphraseDisplay: String? = null, // Builder Passphrase 显示值(完整,用于前端展示)
val autoRedeemEnabled: Boolean = true // 自动赎回(系统级别配置,默认开启)
)
@@ -0,0 +1,63 @@
package com.wrbug.polymarketbot.dto
/**
* 账户设置状态检查结果
*/
data class AccountSetupStatusDto(
/**
* 步骤1代理钱包是否已部署
*/
val proxyDeployed: Boolean,
/**
* 步骤2交易是否已启用API Key 是否已配置
*/
val tradingEnabled: Boolean,
/**
* 步骤3代币是否已批准
*/
val tokensApproved: Boolean,
/**
* 代币批准详情各合约的授权额度
* Key: 合约名称CTF_CONTRACT, CTF_EXCHANGE, NEG_RISK_EXCHANGE, NEG_RISK_ADAPTER
* Value: 授权额度USDC6位小数
*/
val approvalDetails: Map<String, String>? = null,
/**
* 检查错误信息如果有
*/
val error: String? = null
)
/**
* 执行设置步骤请求
*/
data class ExecuteSetupStepRequest(
/** 账户 ID */
val accountId: Long? = null,
/** 步骤:1=部署代理, 2=启用交易, 3=批准代币 */
val step: Int? = null
)
/**
* 执行设置步骤响应
*/
data class ExecuteSetupStepResponse(
/** 是否由后端执行成功(步骤1 仅返回跳转链接,为 false) */
val success: Boolean = false,
/** 需跳转时由后端提供的 URL(步骤1 使用) */
val redirectUrl: String? = null,
/** 链上交易哈希(步骤3 批准代币成功时返回) */
val transactionHash: String? = null
)
/**
* 账户导入响应扩展包含设置状态
*/
data class AccountImportResponse(
val account: AccountDto,
val setupStatus: AccountSetupStatusDto? = null // 设置状态检查结果(可选)
)
@@ -84,6 +84,24 @@ data class CopyTradingUpdateRequest(
val maxMarketEndDate: Long? = null // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
)
/**
* 应用保守风控配置请求
*
* 只暴露安全带允许修改的白名单字段避免误改 leader启用状态金额模式等真实交易行为
*/
data class ApplyConservativeConfigRequest(
val copyTradingId: Long,
val confirm: Boolean = false,
val maxDailyOrders: Int? = null,
val maxDailyLoss: String? = null,
val minPrice: String? = null,
val maxPrice: String? = null,
val maxPositionValue: String? = null,
val minOrderDepth: String? = null,
val maxSpread: String? = null,
val priceTolerance: String? = null
)
/**
* 跟单列表请求
*/
@@ -189,4 +207,3 @@ data class AccountTemplatesResponse(
val list: List<AccountTemplateDto>,
val total: Long
)
@@ -24,7 +24,16 @@ data class CopyTradingStatisticsResponse(
// 持仓统计
val currentPositionQuantity: String,
val currentPositionCost: String,
val currentPositionValue: String,
val zeroValuePositionCost: String = "0",
val confirmedZeroValuePositionCost: String = "0",
val quoteOverallStatus: String = "AVAILABLE",
val quoteAvailableCount: Int = 0,
val quoteNoMatchCount: Int = 0,
val quoteUnavailableCount: Int = 0,
val quoteIncomplete: Boolean = false,
val riskDiagnosis: CopyTradingRiskDiagnosisDto? = null,
// 盈亏统计
val totalRealizedPnl: String,
@@ -33,6 +42,49 @@ data class CopyTradingStatisticsResponse(
val totalPnlPercent: String
)
data class CopyTradingRiskDiagnosisDto(
val copyTradingId: Long,
val totalRealizedPnl: String,
val totalUnrealizedPnl: String,
val totalPnl: String,
val currentPositionCost: String,
val currentPositionValue: String,
val zeroValuePositionCost: String,
val confirmedZeroValuePositionCost: String,
val zeroSellLoss: String,
val openPositionQuantity: String,
val totalBuyOrders: Int,
val totalSellRecords: Int,
val totalMatchDetails: Int,
val filteredOrderCount: Long,
val sampleSize: Int,
val lowConfidence: Boolean,
val confidenceReason: String,
val quoteOverallStatus: String,
val quoteAvailableCount: Int,
val quoteNoMatchCount: Int,
val quoteUnavailableCount: Int,
val dataIncomplete: Boolean,
val missingSources: List<String>,
val topLosingMarkets: List<TopLosingMarketDto>,
val riskWarnings: List<RiskWarningDto>,
val generatedAt: Long
)
data class TopLosingMarketDto(
val marketId: String,
val realizedPnl: String,
val matchedOrders: Int
)
data class RiskWarningDto(
val field: String,
val currentValue: String?,
val suggestedValue: String,
val severity: String,
val reason: String
)
/**
* 买入订单信息
*/
@@ -208,4 +260,3 @@ data class StatisticsResponse(
val maxProfit: String,
val maxLoss: String
)
@@ -0,0 +1,21 @@
package com.wrbug.polymarketbot.dto
/**
* 加密价差策略手动下单请求
*/
data class CryptoTailManualOrderRequest(
/** 策略ID */
val strategyId: Long = 0L,
/** 当前周期开始时间 (Unix 秒) */
val periodStartUnix: Long = 0L,
/** 下单方向: UP or DOWN */
val direction: String = "UP",
/** 下单价格 */
val price: String = "0",
/** 下单数量 */
val size: String = "1",
/** 市场标题(用于记录) */
val marketTitle: String = "",
/** Token IDs */
val tokenIds: List<String> = emptyList()
)
@@ -0,0 +1,31 @@
package com.wrbug.polymarketbot.dto
/**
* 加密价差策略手动下单响应
*/
data class CryptoTailManualOrderResponse(
/** 是否成功 */
val success: Boolean = false,
/** 订单ID */
val orderId: String? = null,
/** 提示消息 */
val message: String = "",
/** 下单详情 */
val orderDetails: ManualOrderDetails? = null
)
/**
* 手动下单详情
*/
data class ManualOrderDetails(
/** 策略ID */
val strategyId: Long = 0L,
/** 方向 */
val direction: String = "",
/** 下单价格 */
val price: String = "",
/** 下单数量 */
val size: String = "",
/** 总金额 */
val totalAmount: String = ""
)
@@ -0,0 +1,111 @@
package com.wrbug.polymarketbot.dto
/**
* 加密价差策略监控初始化请求
*/
data class CryptoTailMonitorInitRequest(
/** 策略ID */
val strategyId: Long = 0L,
/** 指定周期开始时间 (Unix 秒),不传则用服务器当前周期 */
val periodStartUnix: Long? = null
)
/**
* 加密价差策略监控初始化响应
*/
data class CryptoTailMonitorInitResponse(
/** 策略ID */
val strategyId: Long = 0L,
/** 策略名称 */
val name: String = "",
/** 账户ID */
val accountId: Long = 0L,
/** 账户名称 */
val accountName: String = "",
/** 市场 slug 前缀 */
val marketSlugPrefix: String = "",
/** 市场标题 */
val marketTitle: String = "",
/** 周期秒数 (300=5m, 900=15m) */
val intervalSeconds: Int = 300,
/** 当前周期开始时间 (Unix 秒) */
val periodStartUnix: Long = 0L,
/** 时间窗口开始秒数 */
val windowStartSeconds: Int = 0,
/** 时间窗口结束秒数 */
val windowEndSeconds: Int = 0,
/** 最低价格 */
val minPrice: String = "0",
/** 最高价格 */
val maxPrice: String = "1",
/** 最小价差模式: NONE, FIXED, AUTO */
val minSpreadMode: String = "NONE",
/** 价差方向: MIN(显示周期内最小价差), MAX(显示周期内最大价差) */
val spreadDirection: String = "MIN",
/** 最小价差数值 (FIXED 时有值) */
val minSpreadValue: String? = null,
/** 自动计算的最小价差 (Up方向) */
val autoMinSpreadUp: String? = null,
/** 自动计算的最小价差 (Down方向) */
val autoMinSpreadDown: String? = null,
/** BTC 开盘价 USDC(来自币安 K 线 open */
val openPriceBtc: String? = null,
/** Up tokenId */
val tokenIdUp: String? = null,
/** Down tokenId */
val tokenIdDown: String? = null,
/** 当前时间 (毫秒时间戳) */
val currentTimestamp: Long = System.currentTimeMillis(),
/** 是否启用 */
val enabled: Boolean = true,
/** 投入金额模式: FIXED or RATIO */
val amountMode: String? = null,
/** 投入金额数值 */
val amountValue: String? = null
)
/**
* 加密价差策略监控实时推送数据
*/
data class CryptoTailMonitorPushData(
/** 策略ID */
val strategyId: Long = 0L,
/** 推送时间 (毫秒时间戳) */
val timestamp: Long = System.currentTimeMillis(),
/** 当前周期开始时间 (Unix 秒) */
val periodStartUnix: Long = 0L,
/** 当前周期市场标题(周期切换时更新) */
val marketTitle: String = "",
/** 当前价格 (Up方向,来自订单簿) */
val currentPriceUp: String? = null,
/** 当前价格 (Down方向,来自订单簿) */
val currentPriceDown: String? = null,
/** 当前价差 (Up方向: 1 - currentPriceUp) */
val spreadUp: String? = null,
/** 当前价差 (Down方向: currentPriceUp) */
val spreadDown: String? = null,
/** 最小价差线 (Up方向) */
val minSpreadLineUp: String? = null,
/** 最小价差线 (Down方向,USDC 价差) */
val minSpreadLineDown: String? = null,
/** BTC 开盘价 USDC(币安 K 线 open */
val openPriceBtc: String? = null,
/** BTC 最新价 USDC(币安 K 线 close,当前周期实时) */
val currentPriceBtc: String? = null,
/** BTC 价差 USDCcurrentPriceBtc - openPriceBtc */
val spreadBtc: String? = null,
/** 周期剩余秒数 */
val remainingSeconds: Int = 0,
/** 是否在时间窗口内 */
val inTimeWindow: Boolean = false,
/** 是否在价格区间内 (Up方向) */
val inPriceRangeUp: Boolean = false,
/** 是否在价格区间内 (Down方向) */
val inPriceRangeDown: Boolean = false,
/** 是否已触发 */
val triggered: Boolean = false,
/** 触发方向: UP, DOWN, null */
val triggerDirection: String? = null,
/** 周期是否已结束 */
val periodEnded: Boolean = false
)
@@ -1,7 +1,7 @@
package com.wrbug.polymarketbot.dto
/**
* 尾盘策略创建请求
* 加密价差策略创建请求
* 金额与价格使用 String后端转为 BigDecimal
*/
data class CryptoTailStrategyCreateRequest(
@@ -15,13 +15,17 @@ data class CryptoTailStrategyCreateRequest(
val maxPrice: String? = null,
val amountMode: String = "RATIO",
val amountValue: String = "0",
val minSpreadMode: String = "NONE",
val minSpreadValue: String? = null,
/** 价差模式: 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,
@@ -32,13 +36,17 @@ data class CryptoTailStrategyUpdateRequest(
val maxPrice: String? = null,
val amountMode: String? = null,
val amountValue: String? = null,
val minSpreadMode: String? = null,
val minSpreadValue: 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,
@@ -46,7 +54,7 @@ data class CryptoTailStrategyListRequest(
)
/**
* 尾盘策略 DTO列表与详情
* 加密价差策略 DTO列表与详情
*/
data class CryptoTailStrategyDto(
val id: Long = 0L,
@@ -61,8 +69,12 @@ data class CryptoTailStrategyDto(
val maxPrice: String = "1",
val amountMode: String = "RATIO",
val amountValue: String = "0",
val minSpreadMode: String = "NONE",
val minSpreadValue: String? = null,
/** 价差模式: 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 之和) */
@@ -78,14 +90,14 @@ data class CryptoTailStrategyDto(
)
/**
* 尾盘策略列表响应
* 加密价差策略列表响应
*/
data class CryptoTailStrategyListResponse(
val list: List<CryptoTailStrategyDto> = emptyList()
)
/**
* 尾盘策略删除请求
* 加密价差策略删除请求
*/
data class CryptoTailStrategyDeleteRequest(
val strategyId: Long = 0L
@@ -138,7 +150,7 @@ data class CryptoTailStrategyTriggerListResponse(
)
/**
* 自动最小价差计算响应 30 根历史 K 线 + IQR 剔除后 × 0.7
* 自动价差计算响应 30 根历史 K 线 + IQR 剔除后 × 0.7
*/
data class CryptoTailAutoMinSpreadResponse(
val minSpreadUp: String = "0",
@@ -155,3 +167,45 @@ data class CryptoTailMarketOptionDto(
val periodStartUnix: Long = 0L,
val endDate: String? = null
)
/**
* 收益曲线请求
* @param strategyId 策略ID
* @param startDate 开始时间毫秒时间戳null 表示不限制
* @param endDate 结束时间毫秒时间戳null 表示不限制
*/
data class CryptoTailPnlCurveRequest(
val strategyId: Long = 0L,
val startDate: Long? = null,
val endDate: Long? = null
)
/**
* 收益曲线单点数据
*/
data class CryptoTailPnlCurvePoint(
/** 时间点(毫秒时间戳,结算时间或创建时间) */
val timestamp: Long = 0L,
/** 累计收益 USDC */
val cumulativePnl: String = "0",
/** 当笔收益 USDC */
val pointPnl: String = "0",
/** 截至该点累计已结算笔数 */
val settledCount: Long = 0L
)
/**
* 收益曲线响应
*/
data class CryptoTailPnlCurveResponse(
val strategyId: Long = 0L,
val strategyName: String = "",
/** 筛选范围内总已实现收益 USDC */
val totalRealizedPnl: String = "0",
val settledCount: Long = 0L,
val winCount: Long = 0L,
val winRate: String? = null,
/** 最大回撤 USDC(正数表示回撤幅度) */
val maxDrawdown: String? = null,
val curveData: List<CryptoTailPnlCurvePoint> = emptyList()
)
@@ -0,0 +1,92 @@
package com.wrbug.polymarketbot.dto
data class LeaderPoolListRequest(
val status: String? = null
)
data class LeaderPoolAddRequest(
val leaderId: Long,
val source: String? = null,
val reason: String? = null,
val notes: String? = null
)
data class LeaderPoolUpdateStatusRequest(
val poolId: Long,
val status: String,
val cooldownUntil: Long? = null,
val locked: Boolean? = null
)
data class LeaderPoolUpdatePlanRequest(
val poolId: Long,
val suggestedFixedAmount: String? = null,
val suggestedMaxDailyOrders: Int? = null,
val suggestedMaxDailyLoss: String? = null,
val suggestedMinPrice: String? = null,
val suggestedMaxPrice: String? = null,
val suggestedMaxPositionValue: String? = null,
val reason: String? = null,
val notes: String? = null
)
data class LeaderPoolCreateTrialConfigRequest(
val poolId: Long,
val accountId: Long,
val enableImmediately: Boolean = false,
val confirm: Boolean = false
)
data class LeaderPoolRemoveRequest(
val poolId: Long
)
data class LeaderPoolSummaryDto(
val totalCount: Int,
val trialCount: Int,
val estimatedWorstExposure: String,
val pendingRiskCount: Int,
val defaultExperimentBudget: String = "50"
)
data class LeaderPoolItemDto(
val id: Long,
val leaderId: Long,
val leaderName: String?,
val leaderAddress: String,
val category: String?,
val profileUrl: String,
val status: String,
val source: String,
val sourceRank: Int?,
val score: String?,
val reason: String?,
val notes: String?,
val suggestedFixedAmount: String,
val suggestedMaxDailyOrders: Int,
val suggestedMaxDailyLoss: String,
val suggestedMinPrice: String?,
val suggestedMaxPrice: String?,
val suggestedMaxPositionValue: String?,
val copyTradingCount: Int,
val hasEnabledCopyTrading: Boolean,
val estimatedWorstExposure: String,
val lastReviewedAt: Long?,
val lastPromotedAt: Long?,
val cooldownUntil: Long?,
val locked: Boolean,
val researchCandidateId: Long?,
val researchState: String?,
val researchBadge: String?,
val researchSummary: String?,
val researchScore: String?,
val researchUpdatedAt: Long?,
val createdAt: Long,
val updatedAt: Long
)
data class LeaderPoolListResponse(
val summary: LeaderPoolSummaryDto,
val list: List<LeaderPoolItemDto>,
val total: Int
)
@@ -0,0 +1,224 @@
package com.wrbug.polymarketbot.dto
data class LeaderResearchRunRequest(
val dryRun: Boolean = false,
val triggerType: String = "MANUAL"
)
data class LeaderResearchRunDto(
val id: Long,
val status: String,
val triggerType: String,
val dryRun: Boolean,
val startedAt: Long,
val finishedAt: Long?,
val durationMs: Long?,
val sourceCountsJson: String?,
val candidateCountsJson: String?,
val partialFailure: Boolean,
val skippedReason: String?,
val errorClass: String?,
val errorMessage: String?
)
data class LeaderResearchSummaryDto(
val discoveredCount: Long,
val candidateCount: Long,
val paperCount: Long,
val trialReadyCount: Long,
val cooldownCount: Long,
val retiredCount: Long,
val activePaperSessions: Long,
val pendingRiskCount: Long,
val lastRun: LeaderResearchRunDto?,
val sourceLimitations: List<String>
)
data class LeaderResearchCandidateListRequest(
val page: Int = 0,
val size: Int = 20,
val state: String? = null,
val query: String? = null
)
data class LeaderResearchCandidateListResponse(
val list: List<LeaderResearchCandidateDto>,
val total: Long,
val summary: LeaderResearchSummaryDto
)
data class LeaderResearchCandidateDto(
val id: Long,
val normalizedWallet: String,
val leaderId: Long?,
val leaderName: String?,
val poolId: Long?,
val poolStatus: String?,
val suggestedFixedAmount: String?,
val suggestedMaxDailyLoss: String?,
val suggestedMaxDailyOrders: Int?,
val suggestedMinPrice: String?,
val suggestedMaxPrice: String?,
val suggestedMaxPositionValue: String?,
val researchState: String,
val source: String,
val sourceRank: Int?,
val score: String?,
val scoreVersion: String?,
val reason: String?,
val riskFlags: List<String>,
val locked: Boolean,
val agentOwned: Boolean,
val provenance: String,
val sourceEvidence: String?,
val firstSeenAt: Long,
val lastSourceSeenAt: Long?,
val lastScoredAt: Long?,
val cooldownUntil: Long?,
val cooldownCount: Int,
val trialReadyAt: Long?,
val retiredAt: Long?,
val lastPaperSessionId: Long?,
val latestPaperSession: LeaderPaperSessionDto?
)
data class LeaderResearchCandidateDetailDto(
val candidate: LeaderResearchCandidateDto,
val latestScore: LeaderResearchScoreDto?,
val paperSessions: List<LeaderPaperSessionDto>,
val paperTrades: List<LeaderPaperTradeDto>,
val paperPositions: List<LeaderPaperPositionDto>,
val events: List<LeaderResearchEventDto>
)
data class LeaderResearchScoreDto(
val id: Long,
val candidateId: Long,
val runId: Long?,
val scoreVersion: String,
val totalScore: String,
val profitSignal: String,
val repeatability: String,
val liquidityFit: String,
val entryPriceFit: String,
val slippageRisk: String,
val holdingPeriodFit: String,
val marketTypeRisk: String,
val drawdownRisk: String,
val exitLiquidityRisk: String,
val dataFreshness: String,
val filterPassRate: String,
val sampleTradeCount: Int,
val reason: String?,
val createdAt: Long
)
data class LeaderPaperSessionDto(
val id: Long,
val candidateId: Long,
val status: String,
val startedAt: Long,
val endedAt: Long?,
val tradeCount: Int,
val filteredCount: Int,
val openExposure: String,
val totalRealizedPnl: String,
val totalUnrealizedPnl: String,
val copyablePnl: String,
val maxDrawdown: String,
val unknownValuationExposure: String,
val confirmedZeroExposure: String,
val filteredRatio: String,
val lastProcessedEventTime: Long?,
val scoreSnapshot: String?
)
data class LeaderPaperTradeDto(
val id: Long,
val sessionId: Long,
val candidateId: Long,
val activityEventId: Long?,
val leaderTradeId: String,
val marketId: String,
val marketTitle: String?,
val marketSlug: String?,
val side: String,
val outcome: String?,
val outcomeIndex: Int?,
val leaderPrice: String?,
val leaderSize: String?,
val simulatedPrice: String?,
val simulatedSize: String?,
val simulatedAmount: String?,
val fillAssumption: String,
val quoteConfidence: String,
val quoteSource: String?,
val quoteTimestamp: Long?,
val filterResult: String,
val filterReason: String?,
val valuationStatus: String,
val realizedPnl: String?,
val eventTime: Long,
val createdAt: Long
)
data class LeaderPaperPositionDto(
val id: Long,
val sessionId: Long,
val candidateId: Long,
val marketId: String,
val outcome: String?,
val outcomeIndex: Int?,
val quantity: String,
val cost: String,
val avgPrice: String,
val currentPrice: String?,
val currentValue: String,
val realizedPnl: String,
val unrealizedPnl: String,
val valuationStatus: String,
val quoteConfidence: String,
val quoteSource: String?,
val quoteTimestamp: Long?,
val updatedAt: Long
)
data class LeaderResearchSourceStateDto(
val sourceType: String,
val status: String,
val lastSuccessAt: Long?,
val lastFailureAt: Long?,
val lastRunAt: Long?,
val lastCandidateCount: Int,
val errorClass: String?,
val errorMessage: String?,
val stale: Boolean,
val disabledReason: String?,
val lastCursor: String?,
val updatedAt: Long
)
data class LeaderResearchEventDto(
val id: Long,
val candidateId: Long?,
val runId: Long?,
val eventType: String,
val reason: String?,
val payloadSummary: String?,
val notificationStatus: String,
val notificationError: String?,
val dedupeKey: String?,
val createdAt: Long,
val notifiedAt: Long?
)
data class LeaderResearchApprovalRequest(
val candidateId: Long,
val accountId: Long,
val confirm: Boolean = false
)
data class LeaderResearchApprovalResponse(
val copyTrading: CopyTradingDto,
val warning: String = "已创建禁用状态的试跟配置;需要你手动启用后才会真钱跟单。"
)
@@ -0,0 +1,63 @@
package com.wrbug.polymarketbot.dto
/**
* 消息模板 DTO
*/
data class NotificationTemplateDto(
val id: Long? = null,
val templateType: String, // 模板类型
val templateContent: String, // 模板内容
val isDefault: Boolean = false, // 是否使用默认模板
val createdAt: Long? = null,
val updatedAt: Long? = null
)
/**
* 模板变量 DTO
*/
data class TemplateVariableDto(
val key: String, // 变量名,如 account_name
val category: String, // 分类:common, order, copy_trading, redeem, error
val sortOrder: Int = 0 // 排序顺序
)
/**
* 模板变量分类 DTO
*/
data class TemplateVariableCategoryDto(
val key: String, // 分类 key
val sortOrder: Int = 0 // 排序顺序
)
/**
* 模板变量列表响应
*/
data class TemplateVariablesResponse(
val templateType: String, // 模板类型
val categories: List<TemplateVariableCategoryDto>, // 分类列表
val variables: List<TemplateVariableDto> // 变量列表
)
/**
* 更新模板请求
*/
data class UpdateTemplateRequest(
val templateContent: String // 模板内容
)
/**
* 测试模板请求
*/
data class TestTemplateRequest(
val templateType: String, // 模板类型
val templateContent: String? = null // 可选,如果不提供则使用已保存的模板
)
/**
* 模板类型信息
*/
data class TemplateTypeInfoDto(
val type: String, // 模板类型
val name: String, // 类型名称
val description: String // 类型描述
)
@@ -51,18 +51,21 @@ data class OrderPushMessage(
/**
* 订单详情通过 API 获取
* @param price 订单限价用户提交的买入/卖出价
* @param avgFilledPrice 实际成交价 = original_size * price / size_matched有成交时优先用于推送展示
*/
data class OrderDetailDto(
val id: String, // 订单 ID
val market: String, // 市场 ID (condition ID)
val side: String, // BUY/SELL
val price: String, // 价
val price: String, // 订单限
val size: String, // 订单大小
val filled: String, // 已成交数量
val status: String, // 订单状态
val createdAt: String, // 创建时间(ISO 8601 格式)
val marketName: String? = null, // 市场名称(通过 Data API 获取)
val marketSlug: String? = null, // 市场 slug
val marketIcon: String? = null // 市场图标
val marketIcon: String? = null, // 市场图标
val avgFilledPrice: String? = null // 实际成交价 = original_size*price/size_matched(有成交时使用)
)
@@ -1,11 +1,14 @@
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
import com.wrbug.polymarketbot.util.toSafeBigDecimal
/**
* 加密市场尾盘策略实体
* 加密价差策略实体
* 5/15 分钟 Up or Down 市场在周期内时间窗口价格进入区间时市价买入
*/
@Entity
@@ -45,11 +48,19 @@ data class CryptoTailStrategy(
@Column(name = "amount_value", nullable = false, precision = 20, scale = 8)
val amountValue: BigDecimal = BigDecimal.ZERO,
@Column(name = "min_spread_mode", nullable = false, length = 16)
val minSpreadMode: String = "NONE",
/** 价差模式: NONE=不校验, FIXED=固定值, AUTO=历史计算 */
@Convert(converter = SpreadModeConverter::class)
@Column(name = "spread_mode", nullable = false, columnDefinition = "TINYINT")
val spreadMode: SpreadMode = SpreadMode.NONE,
@Column(name = "min_spread_value", precision = 20, scale = 8)
val minSpreadValue: BigDecimal? = null,
/** 价差数值(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,
@@ -5,7 +5,7 @@ import java.math.BigDecimal
import com.wrbug.polymarketbot.util.toSafeBigDecimal
/**
* 尾盘策略触发记录
* 加密价差策略触发记录
*/
@Entity
@Table(name = "crypto_tail_strategy_trigger")
@@ -56,6 +56,9 @@ data class CryptoTailStrategyTrigger(
@Column(name = "fail_reason", length = 500)
val failReason: String? = null,
@Column(name = "trigger_type", nullable = false, length = 20)
val triggerType: String = "AUTO",
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@@ -0,0 +1,91 @@
package com.wrbug.polymarketbot.entity
import com.wrbug.polymarketbot.enums.LeaderPoolStatus
import com.wrbug.polymarketbot.enums.LeaderResearchState
import jakarta.persistence.*
import java.math.BigDecimal
@Entity
@Table(name = "copy_trading_leader_pool")
data class LeaderPool(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "leader_id", nullable = false)
val leaderId: Long,
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20, columnDefinition = "VARCHAR(20)")
val status: LeaderPoolStatus = LeaderPoolStatus.CANDIDATE,
@Column(name = "source", nullable = false, length = 50)
val source: String = "MANUAL",
@Column(name = "source_rank")
val sourceRank: Int? = null,
@Column(name = "score", precision = 20, scale = 8)
val score: BigDecimal? = null,
@Column(name = "reason", columnDefinition = "TEXT")
val reason: String? = null,
@Column(name = "notes", columnDefinition = "TEXT")
val notes: String? = null,
@Column(name = "suggested_fixed_amount", nullable = false, precision = 20, scale = 8)
val suggestedFixedAmount: BigDecimal = BigDecimal("1.00000000"),
@Column(name = "suggested_max_daily_orders", nullable = false)
val suggestedMaxDailyOrders: Int = 10,
@Column(name = "suggested_max_daily_loss", nullable = false, precision = 20, scale = 8)
val suggestedMaxDailyLoss: BigDecimal = BigDecimal("5.00000000"),
@Column(name = "suggested_min_price", precision = 20, scale = 8)
val suggestedMinPrice: BigDecimal? = BigDecimal("0.10000000"),
@Column(name = "suggested_max_price", precision = 20, scale = 8)
val suggestedMaxPrice: BigDecimal? = BigDecimal("0.80000000"),
@Column(name = "suggested_max_position_value", precision = 20, scale = 8)
val suggestedMaxPositionValue: BigDecimal? = BigDecimal("5.00000000"),
@Column(name = "last_reviewed_at")
val lastReviewedAt: Long? = null,
@Column(name = "last_promoted_at")
val lastPromotedAt: Long? = null,
@Column(name = "cooldown_until")
val cooldownUntil: Long? = null,
@Column(name = "locked", nullable = false)
val locked: Boolean = false,
@Column(name = "research_candidate_id")
val researchCandidateId: Long? = null,
@Enumerated(EnumType.STRING)
@Column(name = "research_state", length = 30, columnDefinition = "VARCHAR(30)")
val researchState: LeaderResearchState? = null,
@Column(name = "research_badge", length = 50)
val researchBadge: String? = null,
@Column(name = "research_summary", columnDefinition = "TEXT")
val researchSummary: String? = null,
@Column(name = "research_score", precision = 20, scale = 8)
val researchScore: BigDecimal? = null,
@Column(name = "research_updated_at")
val researchUpdatedAt: Long? = null,
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@Column(name = "updated_at", nullable = false)
var updatedAt: Long = System.currentTimeMillis()
)
@@ -0,0 +1,600 @@
package com.wrbug.polymarketbot.entity
import com.wrbug.polymarketbot.enums.*
import jakarta.persistence.*
import java.math.BigDecimal
@Entity
@Table(name = "leader_research_run")
data class LeaderResearchRun(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 30, columnDefinition = "VARCHAR(30)")
val status: LeaderResearchRunStatus = LeaderResearchRunStatus.RUNNING,
@Enumerated(EnumType.STRING)
@Column(name = "trigger_type", nullable = false, length = 30, columnDefinition = "VARCHAR(30)")
val triggerType: LeaderResearchTriggerType = LeaderResearchTriggerType.MANUAL,
@Column(name = "dry_run", nullable = false)
val dryRun: Boolean = false,
@Column(name = "started_at", nullable = false)
val startedAt: Long = System.currentTimeMillis(),
@Column(name = "finished_at")
val finishedAt: Long? = null,
@Column(name = "duration_ms")
val durationMs: Long? = null,
@Column(name = "source_counts_json", columnDefinition = "TEXT")
val sourceCountsJson: String? = null,
@Column(name = "candidate_counts_json", columnDefinition = "TEXT")
val candidateCountsJson: String? = null,
@Column(name = "error_class")
val errorClass: String? = null,
@Column(name = "error_message", columnDefinition = "TEXT")
val errorMessage: String? = null,
@Column(name = "partial_failure", nullable = false)
val partialFailure: Boolean = false,
@Column(name = "skipped_reason")
val skippedReason: String? = null,
@Column(name = "last_event_cursor")
val lastEventCursor: String? = null,
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@Column(name = "updated_at", nullable = false)
val updatedAt: Long = System.currentTimeMillis()
)
@Entity
@Table(name = "leader_research_candidate")
data class LeaderResearchCandidate(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "normalized_wallet", nullable = false, length = 42, unique = true)
val normalizedWallet: String,
@Column(name = "leader_id")
val leaderId: Long? = null,
@Column(name = "pool_id")
val poolId: Long? = null,
@Enumerated(EnumType.STRING)
@Column(name = "research_state", nullable = false, length = 30, columnDefinition = "VARCHAR(30)")
val researchState: LeaderResearchState = LeaderResearchState.DISCOVERED,
@Column(name = "source", nullable = false, length = 50)
val source: String = LeaderResearchSourceType.ACTIVITY_DERIVED.name,
@Column(name = "source_rank")
val sourceRank: Int? = null,
@Column(name = "score", precision = 20, scale = 8)
val score: BigDecimal? = null,
@Column(name = "score_version", length = 100)
val scoreVersion: String? = null,
@Column(name = "reason", columnDefinition = "TEXT")
val reason: String? = null,
@Column(name = "risk_flags", columnDefinition = "TEXT")
val riskFlags: String? = null,
@Column(name = "locked", nullable = false)
val locked: Boolean = false,
@Column(name = "agent_owned", nullable = false)
val agentOwned: Boolean = true,
@Enumerated(EnumType.STRING)
@Column(name = "provenance", nullable = false, length = 50, columnDefinition = "VARCHAR(50)")
val provenance: LeaderCandidateProvenance = LeaderCandidateProvenance.AGENT_CREATED,
@Column(name = "source_evidence", columnDefinition = "TEXT")
val sourceEvidence: String? = null,
@Column(name = "first_seen_at", nullable = false)
val firstSeenAt: Long = System.currentTimeMillis(),
@Column(name = "last_source_seen_at")
val lastSourceSeenAt: Long? = null,
@Column(name = "last_scored_at")
val lastScoredAt: Long? = null,
@Column(name = "cooldown_until")
val cooldownUntil: Long? = null,
@Column(name = "cooldown_count", nullable = false)
val cooldownCount: Int = 0,
@Column(name = "last_transition_at")
val lastTransitionAt: Long? = null,
@Column(name = "trial_ready_at")
val trialReadyAt: Long? = null,
@Column(name = "retired_at")
val retiredAt: Long? = null,
@Column(name = "last_paper_session_id")
val lastPaperSessionId: Long? = null,
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@Column(name = "updated_at", nullable = false)
val updatedAt: Long = System.currentTimeMillis()
)
@Entity
@Table(name = "leader_research_score")
data class LeaderResearchScore(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "candidate_id", nullable = false)
val candidateId: Long,
@Column(name = "run_id")
val runId: Long? = null,
@Column(name = "score_version", nullable = false, length = 100)
val scoreVersion: String,
@Column(name = "total_score", nullable = false, precision = 20, scale = 8)
val totalScore: BigDecimal = BigDecimal.ZERO,
@Column(name = "profit_signal", nullable = false, precision = 20, scale = 8)
val profitSignal: BigDecimal = BigDecimal.ZERO,
@Column(name = "repeatability", nullable = false, precision = 20, scale = 8)
val repeatability: BigDecimal = BigDecimal.ZERO,
@Column(name = "liquidity_fit", nullable = false, precision = 20, scale = 8)
val liquidityFit: BigDecimal = BigDecimal.ZERO,
@Column(name = "entry_price_fit", nullable = false, precision = 20, scale = 8)
val entryPriceFit: BigDecimal = BigDecimal.ZERO,
@Column(name = "slippage_risk", nullable = false, precision = 20, scale = 8)
val slippageRisk: BigDecimal = BigDecimal.ZERO,
@Column(name = "holding_period_fit", nullable = false, precision = 20, scale = 8)
val holdingPeriodFit: BigDecimal = BigDecimal.ZERO,
@Column(name = "market_type_risk", nullable = false, precision = 20, scale = 8)
val marketTypeRisk: BigDecimal = BigDecimal.ZERO,
@Column(name = "drawdown_risk", nullable = false, precision = 20, scale = 8)
val drawdownRisk: BigDecimal = BigDecimal.ZERO,
@Column(name = "exit_liquidity_risk", nullable = false, precision = 20, scale = 8)
val exitLiquidityRisk: BigDecimal = BigDecimal.ZERO,
@Column(name = "data_freshness", nullable = false, precision = 20, scale = 8)
val dataFreshness: BigDecimal = BigDecimal.ZERO,
@Column(name = "filter_pass_rate", nullable = false, precision = 20, scale = 8)
val filterPassRate: BigDecimal = BigDecimal.ZERO,
@Column(name = "sample_trade_count", nullable = false)
val sampleTradeCount: Int = 0,
@Column(name = "reason", columnDefinition = "TEXT")
val reason: String? = null,
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis()
)
@Entity
@Table(name = "leader_research_event")
data class LeaderResearchEvent(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "candidate_id")
val candidateId: Long? = null,
@Column(name = "run_id")
val runId: Long? = null,
@Enumerated(EnumType.STRING)
@Column(name = "event_type", nullable = false, length = 50, columnDefinition = "VARCHAR(50)")
val eventType: LeaderResearchEventType,
@Column(name = "reason", columnDefinition = "TEXT")
val reason: String? = null,
@Column(name = "payload_summary", columnDefinition = "TEXT")
val payloadSummary: String? = null,
@Enumerated(EnumType.STRING)
@Column(name = "notification_status", nullable = false, length = 30, columnDefinition = "VARCHAR(30)")
val notificationStatus: LeaderResearchNotificationStatus = LeaderResearchNotificationStatus.PENDING,
@Column(name = "notification_error", columnDefinition = "TEXT")
val notificationError: String? = null,
@Column(name = "dedupe_key")
val dedupeKey: String? = null,
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@Column(name = "notified_at")
val notifiedAt: Long? = null
)
@Entity
@Table(name = "leader_research_source_state")
data class LeaderResearchSourceState(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Enumerated(EnumType.STRING)
@Column(name = "source_type", nullable = false, length = 50, unique = true, columnDefinition = "VARCHAR(50)")
val sourceType: LeaderResearchSourceType,
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 30, columnDefinition = "VARCHAR(30)")
val status: LeaderResearchSourceStatus = LeaderResearchSourceStatus.DISABLED,
@Column(name = "last_success_at")
val lastSuccessAt: Long? = null,
@Column(name = "last_failure_at")
val lastFailureAt: Long? = null,
@Column(name = "last_run_at")
val lastRunAt: Long? = null,
@Column(name = "last_candidate_count", nullable = false)
val lastCandidateCount: Int = 0,
@Column(name = "error_class")
val errorClass: String? = null,
@Column(name = "error_message", columnDefinition = "TEXT")
val errorMessage: String? = null,
@Column(name = "stale", nullable = false)
val stale: Boolean = false,
@Column(name = "disabled_reason")
val disabledReason: String? = null,
@Column(name = "last_cursor")
val lastCursor: String? = null,
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@Column(name = "updated_at", nullable = false)
val updatedAt: Long = System.currentTimeMillis()
)
@Entity
@Table(name = "leader_activity_event")
data class LeaderActivityEvent(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "source", nullable = false, length = 50)
val source: String,
@Column(name = "source_event_id")
val sourceEventId: String? = null,
@Column(name = "stable_event_key", nullable = false, unique = true)
val stableEventKey: String,
@Column(name = "normalized_wallet", length = 42)
val normalizedWallet: String? = null,
@Column(name = "market_id")
val marketId: String? = null,
@Column(name = "market_title")
val marketTitle: String? = null,
@Column(name = "market_slug")
val marketSlug: String? = null,
@Column(name = "asset")
val asset: String? = null,
@Column(name = "side", length = 20)
val side: String? = null,
@Column(name = "outcome")
val outcome: String? = null,
@Column(name = "outcome_index")
val outcomeIndex: Int? = null,
@Column(name = "price", precision = 20, scale = 8)
val price: BigDecimal? = null,
@Column(name = "size", precision = 20, scale = 8)
val size: BigDecimal? = null,
@Column(name = "amount", precision = 20, scale = 8)
val amount: BigDecimal? = null,
@Column(name = "event_time", nullable = false)
val eventTime: Long,
@Column(name = "raw_payload_hash", nullable = false, length = 128)
val rawPayloadHash: String = "",
@Column(name = "payload_summary", columnDefinition = "TEXT")
val payloadSummary: String? = null,
@Column(name = "usable_for_discovery", nullable = false)
val usableForDiscovery: Boolean = false,
@Column(name = "usable_for_paper", nullable = false)
val usableForPaper: Boolean = false,
@Column(name = "unusable_reason")
val unusableReason: String? = null,
@Enumerated(EnumType.STRING)
@Column(name = "paper_processing_status", nullable = false, length = 30, columnDefinition = "VARCHAR(30)")
val paperProcessingStatus: LeaderPaperProcessingStatus = LeaderPaperProcessingStatus.NEW,
@Column(name = "processing_attempts", nullable = false)
val processingAttempts: Int = 0,
@Column(name = "paper_processing_started_at")
val paperProcessingStartedAt: Long? = null,
@Column(name = "paper_processed_at")
val paperProcessedAt: Long? = null,
@Column(name = "last_processing_error", columnDefinition = "TEXT")
val lastProcessingError: String? = null,
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@Column(name = "updated_at", nullable = false)
val updatedAt: Long = System.currentTimeMillis()
)
@Entity
@Table(name = "leader_paper_session")
data class LeaderPaperSession(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "candidate_id", nullable = false)
val candidateId: Long,
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 30, columnDefinition = "VARCHAR(30)")
val status: LeaderPaperSessionStatus = LeaderPaperSessionStatus.ACTIVE,
@Column(name = "started_at", nullable = false)
val startedAt: Long = System.currentTimeMillis(),
@Column(name = "ended_at")
val endedAt: Long? = null,
@Column(name = "trade_count", nullable = false)
val tradeCount: Int = 0,
@Column(name = "filtered_count", nullable = false)
val filteredCount: Int = 0,
@Column(name = "open_exposure", nullable = false, precision = 20, scale = 8)
val openExposure: BigDecimal = BigDecimal.ZERO,
@Column(name = "total_realized_pnl", nullable = false, precision = 20, scale = 8)
val totalRealizedPnl: BigDecimal = BigDecimal.ZERO,
@Column(name = "total_unrealized_pnl", nullable = false, precision = 20, scale = 8)
val totalUnrealizedPnl: BigDecimal = BigDecimal.ZERO,
@Column(name = "copyable_pnl", nullable = false, precision = 20, scale = 8)
val copyablePnl: BigDecimal = BigDecimal.ZERO,
@Column(name = "max_drawdown", nullable = false, precision = 20, scale = 8)
val maxDrawdown: BigDecimal = BigDecimal.ZERO,
@Column(name = "unknown_valuation_exposure", nullable = false, precision = 20, scale = 8)
val unknownValuationExposure: BigDecimal = BigDecimal.ZERO,
@Column(name = "confirmed_zero_exposure", nullable = false, precision = 20, scale = 8)
val confirmedZeroExposure: BigDecimal = BigDecimal.ZERO,
@Column(name = "filtered_ratio", nullable = false, precision = 20, scale = 8)
val filteredRatio: BigDecimal = BigDecimal.ZERO,
@Column(name = "last_processed_event_time")
val lastProcessedEventTime: Long? = null,
@Column(name = "score_snapshot", precision = 20, scale = 8)
val scoreSnapshot: BigDecimal? = null,
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@Column(name = "updated_at", nullable = false)
val updatedAt: Long = System.currentTimeMillis()
)
@Entity
@Table(name = "leader_paper_trade")
data class LeaderPaperTrade(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "session_id", nullable = false)
val sessionId: Long,
@Column(name = "candidate_id", nullable = false)
val candidateId: Long,
@Column(name = "activity_event_id")
val activityEventId: Long? = null,
@Column(name = "leader_trade_id", nullable = false)
val leaderTradeId: String,
@Column(name = "market_id", nullable = false)
val marketId: String,
@Column(name = "market_title")
val marketTitle: String? = null,
@Column(name = "market_slug")
val marketSlug: String? = null,
@Column(name = "side", nullable = false, length = 20)
val side: String,
@Column(name = "outcome")
val outcome: String? = null,
@Column(name = "outcome_index")
val outcomeIndex: Int? = null,
@Column(name = "leader_price", precision = 20, scale = 8)
val leaderPrice: BigDecimal? = null,
@Column(name = "leader_size", precision = 20, scale = 8)
val leaderSize: BigDecimal? = null,
@Column(name = "simulated_price", precision = 20, scale = 8)
val simulatedPrice: BigDecimal? = null,
@Column(name = "simulated_size", precision = 20, scale = 8)
val simulatedSize: BigDecimal? = null,
@Column(name = "simulated_amount", precision = 20, scale = 8)
val simulatedAmount: BigDecimal? = null,
@Enumerated(EnumType.STRING)
@Column(name = "fill_assumption", nullable = false, length = 30, columnDefinition = "VARCHAR(30)")
val fillAssumption: LeaderPaperFillAssumption = LeaderPaperFillAssumption.LEADER_PRICE,
@Enumerated(EnumType.STRING)
@Column(name = "quote_confidence", nullable = false, length = 30, columnDefinition = "VARCHAR(30)")
val quoteConfidence: LeaderResearchQuoteConfidence = LeaderResearchQuoteConfidence.UNKNOWN,
@Column(name = "quote_source", length = 50)
val quoteSource: String? = null,
@Column(name = "quote_timestamp")
val quoteTimestamp: Long? = null,
@Enumerated(EnumType.STRING)
@Column(name = "filter_result", nullable = false, length = 30, columnDefinition = "VARCHAR(30)")
val filterResult: LeaderPaperFilterResult = LeaderPaperFilterResult.PASSED,
@Column(name = "filter_reason", columnDefinition = "TEXT")
val filterReason: String? = null,
@Enumerated(EnumType.STRING)
@Column(name = "valuation_status", nullable = false, length = 30, columnDefinition = "VARCHAR(30)")
val valuationStatus: LeaderResearchValuationStatus = LeaderResearchValuationStatus.UNKNOWN,
@Column(name = "realized_pnl", precision = 20, scale = 8)
val realizedPnl: BigDecimal? = null,
@Column(name = "event_time", nullable = false)
val eventTime: Long,
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis()
)
@Entity
@Table(name = "leader_paper_position")
data class LeaderPaperPosition(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "session_id", nullable = false)
val sessionId: Long,
@Column(name = "candidate_id", nullable = false)
val candidateId: Long,
@Column(name = "market_id", nullable = false)
val marketId: String,
@Column(name = "outcome")
val outcome: String? = null,
@Column(name = "outcome_index")
val outcomeIndex: Int? = null,
@Column(name = "quantity", nullable = false, precision = 20, scale = 8)
val quantity: BigDecimal = BigDecimal.ZERO,
@Column(name = "cost", nullable = false, precision = 20, scale = 8)
val cost: BigDecimal = BigDecimal.ZERO,
@Column(name = "avg_price", nullable = false, precision = 20, scale = 8)
val avgPrice: BigDecimal = BigDecimal.ZERO,
@Column(name = "current_price", precision = 20, scale = 8)
val currentPrice: BigDecimal? = null,
@Column(name = "current_value", nullable = false, precision = 20, scale = 8)
val currentValue: BigDecimal = BigDecimal.ZERO,
@Column(name = "realized_pnl", nullable = false, precision = 20, scale = 8)
val realizedPnl: BigDecimal = BigDecimal.ZERO,
@Column(name = "unrealized_pnl", nullable = false, precision = 20, scale = 8)
val unrealizedPnl: BigDecimal = BigDecimal.ZERO,
@Enumerated(EnumType.STRING)
@Column(name = "valuation_status", nullable = false, length = 30, columnDefinition = "VARCHAR(30)")
val valuationStatus: LeaderResearchValuationStatus = LeaderResearchValuationStatus.UNKNOWN,
@Enumerated(EnumType.STRING)
@Column(name = "quote_confidence", nullable = false, length = 30, columnDefinition = "VARCHAR(30)")
val quoteConfidence: LeaderResearchQuoteConfidence = LeaderResearchQuoteConfidence.UNKNOWN,
@Column(name = "quote_source", length = 50)
val quoteSource: String? = null,
@Column(name = "quote_timestamp")
val quoteTimestamp: Long? = null,
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@Column(name = "updated_at", nullable = false)
val updatedAt: Long = System.currentTimeMillis()
)
@@ -0,0 +1,30 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
/**
* 消息推送模板实体
* 用于存储用户自定义的消息模板
*/
@Entity
@Table(name = "notification_templates")
data class NotificationTemplate(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "template_type", unique = true, nullable = false, length = 50)
val templateType: String, // ORDER_SUCCESS, ORDER_FAILED, ORDER_FILTERED, CRYPTO_TAIL_SUCCESS, REDEEM_SUCCESS, REDEEM_NO_RETURN
@Column(name = "template_content", nullable = false, columnDefinition = "TEXT")
var templateContent: String, // 模板内容,支持 {{variable}} 变量
@Column(name = "is_default", nullable = false)
var isDefault: Boolean = false, // 是否使用默认模板
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@Column(name = "updated_at", nullable = false)
var updatedAt: Long = System.currentTimeMillis()
)
@@ -110,6 +110,18 @@ enum class ErrorCode(
COPY_TRADING_DISABLED(4202, "跟单关系已禁用", "error.copy_trading_disabled"),
COPY_TRADING_ENABLED(4203, "跟单关系已启用", "error.copy_trading_enabled"),
NO_ENABLED_COPY_TRADINGS(4204, "没有启用的跟单关系", "error.no_enabled_copy_tradings"),
LEADER_POOL_NOT_FOUND(4251, "Leader 池项不存在", "error.leader_pool_not_found"),
LEADER_POOL_ALREADY_EXISTS(4252, "Leader 已在池子中", "error.leader_pool_already_exists"),
LEADER_POOL_DUPLICATE_TRIAL_CONFIG(4253, "该账户已存在此 Leader 的跟单配置", "error.leader_pool_duplicate_trial_config"),
LEADER_POOL_CONFIRM_REQUIRED(4254, "立即启用试跟配置需要显式确认", "error.leader_pool_confirm_required"),
LEADER_RESEARCH_CANDIDATE_NOT_FOUND(4261, "研究候选不存在", "error.leader_research_candidate_not_found"),
LEADER_RESEARCH_CANDIDATE_NOT_READY(4262, "研究候选尚未进入试跟建议状态", "error.leader_research_candidate_not_ready"),
LEADER_RESEARCH_APPROVAL_CONFIRM_REQUIRED(4263, "创建禁用试跟配置需要显式确认", "error.leader_research_approval_confirm_required"),
LEADER_RESEARCH_DUPLICATE_TRIAL_CONFIG(4264, "该账户已存在此 Leader 的跟单配置", "error.leader_research_duplicate_trial_config"),
LEADER_RESEARCH_REAL_MONEY_FORBIDDEN(4265, "研究 Agent 不允许自动启用真钱跟单", "error.leader_research_real_money_forbidden"),
LEADER_RESEARCH_CANDIDATE_LOCKED(4266, "研究候选已锁定", "error.leader_research_candidate_locked"),
LEADER_RESEARCH_SOURCE_UNAVAILABLE(4267, "研究来源不可用", "error.leader_research_source_unavailable"),
LEADER_RESEARCH_PAPER_VALUATION_UNAVAILABLE(4268, "纸跟估值不可用", "error.leader_research_paper_valuation_unavailable"),
// 订单相关 (4301-4399)
ORDER_CREATE_FAILED(4301, "创建订单失败", "error.order_create_failed"),
@@ -158,8 +170,8 @@ 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"),
// 加密价差策略 (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"),
@@ -213,6 +225,12 @@ enum class ErrorCode(
SERVER_COPY_TRADING_DELETE_FAILED(5403, "删除跟单失败", "error.server.copy_trading_delete_failed"),
SERVER_COPY_TRADING_LIST_FETCH_FAILED(5404, "查询跟单列表失败", "error.server.copy_trading_list_fetch_failed"),
SERVER_COPY_TRADING_TEMPLATES_FETCH_FAILED(5405, "查询钱包绑定的模板失败", "error.server.copy_trading_templates_fetch_failed"),
SERVER_LEADER_POOL_LIST_FETCH_FAILED(5451, "查询 Leader 池失败", "error.server.leader_pool_list_fetch_failed"),
SERVER_LEADER_POOL_SAVE_FAILED(5452, "保存 Leader 池失败", "error.server.leader_pool_save_failed"),
SERVER_LEADER_POOL_CREATE_TRIAL_FAILED(5453, "创建 Leader 池试跟配置失败", "error.server.leader_pool_create_trial_failed"),
SERVER_LEADER_RESEARCH_RUN_FAILED(5454, "运行 Leader Research Agent 失败", "error.server.leader_research_run_failed"),
SERVER_LEADER_RESEARCH_FETCH_FAILED(5455, "查询 Leader Research 数据失败", "error.server.leader_research_fetch_failed"),
SERVER_LEADER_RESEARCH_APPROVAL_FAILED(5456, "创建禁用试跟配置失败", "error.server.leader_research_approval_failed"),
// 市场服务错误 (5501-5599)
SERVER_MARKET_PRICE_FETCH_FAILED(5501, "获取市场价格失败", "error.server.market_price_fetch_failed"),
@@ -259,11 +277,11 @@ enum class ErrorCode(
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"),
// 加密价差策略服务 (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 {
@@ -283,4 +301,3 @@ enum class ErrorCode(
}
}
}
@@ -0,0 +1,11 @@
package com.wrbug.polymarketbot.enums
enum class LeaderPoolStatus {
CANDIDATE,
WATCH,
PAPER,
TRIAL,
ACTIVE,
COOLDOWN,
RETIRED
}
@@ -0,0 +1,123 @@
package com.wrbug.polymarketbot.enums
enum class LeaderResearchState {
DISCOVERED,
CANDIDATE,
PAPER,
TRIAL_READY,
COOLDOWN,
RETIRED
}
enum class LeaderResearchRunStatus {
RUNNING,
SUCCESS,
PARTIAL_FAILURE,
FAILED,
SKIPPED
}
enum class LeaderResearchTriggerType {
MANUAL,
SCHEDULED,
PREVIEW
}
enum class LeaderResearchSourceType {
WATCHLIST,
EXISTING_LEADER,
ACTIVITY_DERIVED,
GLOBAL_ACTIVITY_CAPTURE,
PUBLIC_LEADERBOARD
}
enum class LeaderResearchSourceStatus {
SUCCESS,
FAILURE,
STALE,
DISABLED,
DEGRADED
}
enum class LeaderCandidateProvenance {
AGENT_CREATED,
USER_LEADER,
USER_POOL,
MANUAL_LOCKED
}
enum class LeaderPaperSessionStatus {
ACTIVE,
PAUSED,
COMPLETED,
FAILED
}
enum class LeaderPaperProcessingStatus {
NEW,
PROCESSING,
PROCESSED,
FILTERED,
RETRYABLE,
FAILED
}
enum class LeaderResearchValuationStatus {
AVAILABLE,
NO_MATCH,
UNAVAILABLE,
CONFIRMED_ZERO,
UNKNOWN
}
enum class LeaderResearchQuoteConfidence {
HIGH,
MEDIUM,
LOW,
UNKNOWN
}
enum class LeaderPaperFillAssumption {
LEADER_PRICE,
BEST_ASK_AT_EVENT,
MID_PRICE,
UNKNOWN
}
enum class LeaderPaperFilterResult {
PASSED,
FILTERED
}
enum class LeaderResearchEventType {
RUN_STARTED,
RUN_COMPLETED,
RUN_FAILED,
RUN_SKIPPED,
SOURCE_SUCCESS,
SOURCE_FAILURE,
SOURCE_DISABLED,
CANDIDATE_DISCOVERED,
CANDIDATE_UPDATED,
PAPER_STARTED,
PAPER_TRADE_RECORDED,
PAPER_TRADE_FILTERED,
PAPER_PROCESSING_FAILED,
STATE_TRANSITION,
TRIAL_READY,
COOLDOWN,
RETIRED,
VALUATION_STALE,
APPROVAL_CREATED_DISABLED_CONFIG,
APPROVAL_REJECTED,
DUPLICATE_APPROVAL,
REAL_MONEY_ACTIVATION_FORBIDDEN,
NOTIFICATION_SUMMARY
}
enum class LeaderResearchNotificationStatus {
PENDING,
SENT,
FAILED,
SKIPPED
}
@@ -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)
}
}
@@ -3,6 +3,6 @@ package com.wrbug.polymarketbot.event
import org.springframework.context.ApplicationEvent
/**
* 尾盘策略创建/更新/启用状态变更后发布用于立即触发一轮执行检查
* 加密价差策略创建/更新/启用状态变更后发布用于立即触发一轮执行检查
*/
class CryptoTailStrategyChangedEvent(source: Any) : ApplicationEvent(source)
@@ -1,7 +1,11 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.Account
import jakarta.persistence.LockModeType
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Lock
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository
/**
@@ -19,6 +23,10 @@ interface AccountRepository : JpaRepository<Account, Long> {
* 查找默认账户
*/
fun findByIsDefaultTrue(): Account?
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select a from Account a where a.id = :id")
fun findByIdForUpdate(@Param("id") id: Long): Account?
/**
* 查找所有账户按创建时间排序
@@ -35,4 +43,3 @@ interface AccountRepository : JpaRepository<Account, Long> {
*/
fun existsByProxyAddress(proxyAddress: String): Boolean
}
@@ -19,6 +19,11 @@ interface CopyTradingRepository : JpaRepository<CopyTrading, Long> {
* 根据 Leader ID 查找跟单列表
*/
fun findByLeaderId(leaderId: Long): List<CopyTrading>
/**
* 根据 Leader ID 批量查找跟单列表用于聚合页面避免 N+1 查询
*/
fun findByLeaderIdIn(leaderIds: Collection<Long>): List<CopyTrading>
/**
* 根据账户ID和Leader ID查找跟单列表
@@ -48,4 +53,3 @@ interface CopyTradingRepository : JpaRepository<CopyTrading, Long> {
*/
fun countByLeaderId(leaderId: Long): Long
}
@@ -23,7 +23,7 @@ interface CryptoTailStrategyTriggerRepository : JpaRepository<CryptoTailStrategy
/** 轮询结算:仅处理下单成功的订单(status=success 且 orderId 非空)、且未结算的触发记录 */
fun findByStatusAndResolvedAndOrderIdIsNotNullOrderByCreatedAtAsc(status: String, resolved: Boolean): List<CryptoTailStrategyTrigger>
/** 根据订单 ID 查询尾盘触发记录 */
/** 根据订单 ID 查询加密价差策略触发记录 */
fun findByOrderId(orderId: String): CryptoTailStrategyTrigger?
/** 轮询发 TGstatus=success、orderId 非空、未发过通知,按创建时间正序 */
@@ -40,4 +40,16 @@ interface CryptoTailStrategyTriggerRepository : JpaRepository<CryptoTailStrategy
/** 策略已结算中赢的笔数(outcome_index = winner_outcome_index */
@Query("SELECT COUNT(t) FROM CryptoTailStrategyTrigger t WHERE t.strategyId = :strategyId AND t.resolved = true AND t.outcomeIndex = t.winnerOutcomeIndex")
fun countWinsByStrategyId(@Param("strategyId") strategyId: Long): Long
/** 收益曲线:已结算记录,按结算时间(无则创建时间)在区间内升序 */
@Query(
"SELECT t FROM CryptoTailStrategyTrigger t WHERE t.strategyId = :strategyId AND t.resolved = true " +
"AND COALESCE(t.settledAt, t.createdAt) >= :start AND COALESCE(t.settledAt, t.createdAt) <= :end " +
"ORDER BY COALESCE(t.settledAt, t.createdAt) ASC"
)
fun findResolvedByStrategyIdAndTimeRangeOrderBySettledAsc(
@Param("strategyId") strategyId: Long,
@Param("start") start: Long,
@Param("end") end: Long
): List<CryptoTailStrategyTrigger>
}
@@ -0,0 +1,21 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.LeaderPool
import com.wrbug.polymarketbot.enums.LeaderPoolStatus
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface LeaderPoolRepository : JpaRepository<LeaderPool, Long> {
fun findByLeaderId(leaderId: Long): LeaderPool?
fun existsByLeaderId(leaderId: Long): Boolean
fun findByStatus(status: LeaderPoolStatus): List<LeaderPool>
fun findAllByOrderByCreatedAtDesc(): List<LeaderPool>
fun deleteByLeaderId(leaderId: Long)
fun findByIdIn(ids: Collection<Long>): List<LeaderPool>
}
@@ -29,5 +29,6 @@ interface LeaderRepository : JpaRepository<Leader, Long> {
* 查找所有 Leader按创建时间排序
*/
fun findAllByOrderByCreatedAtAsc(): List<Leader>
}
fun findByIdIn(ids: Collection<Long>): List<Leader>
}
@@ -0,0 +1,132 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.*
import com.wrbug.polymarketbot.enums.*
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.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository
@Repository
interface LeaderResearchRunRepository : JpaRepository<LeaderResearchRun, Long> {
fun findTopByOrderByStartedAtDesc(): LeaderResearchRun?
fun findByStatus(status: LeaderResearchRunStatus): List<LeaderResearchRun>
fun findTopByStatusOrderByStartedAtDesc(status: LeaderResearchRunStatus): LeaderResearchRun?
}
@Repository
interface LeaderResearchCandidateRepository : JpaRepository<LeaderResearchCandidate, Long> {
fun findByNormalizedWallet(normalizedWallet: String): LeaderResearchCandidate?
fun findByLeaderId(leaderId: Long): LeaderResearchCandidate?
fun findByPoolId(poolId: Long): LeaderResearchCandidate?
fun findByResearchState(researchState: LeaderResearchState): List<LeaderResearchCandidate>
fun findByResearchStateIn(states: Collection<LeaderResearchState>): List<LeaderResearchCandidate>
fun findByResearchStateIn(states: Collection<LeaderResearchState>, pageable: Pageable): Page<LeaderResearchCandidate>
fun findAllByOrderByUpdatedAtDesc(pageable: Pageable): Page<LeaderResearchCandidate>
fun countByResearchState(researchState: LeaderResearchState): Long
@Query(
"""
select c from LeaderResearchCandidate c
where (:state is null or c.researchState = :state)
and (
:query is null
or lower(c.normalizedWallet) like lower(concat(concat('%', :query), '%'))
or lower(c.source) like lower(concat(concat('%', :query), '%'))
or lower(coalesce(c.reason, '')) like lower(concat(concat('%', :query), '%'))
or lower(coalesce(c.sourceEvidence, '')) like lower(concat(concat('%', :query), '%'))
)
order by c.updatedAt desc
"""
)
fun search(
@Param("state") state: LeaderResearchState?,
@Param("query") query: String?,
pageable: Pageable
): Page<LeaderResearchCandidate>
}
@Repository
interface LeaderResearchScoreRepository : JpaRepository<LeaderResearchScore, Long> {
fun findTopByCandidateIdOrderByCreatedAtDesc(candidateId: Long): LeaderResearchScore?
fun findByCandidateIdOrderByCreatedAtDesc(candidateId: Long): List<LeaderResearchScore>
}
@Repository
interface LeaderResearchEventRepository : JpaRepository<LeaderResearchEvent, Long> {
fun findByCandidateIdOrderByCreatedAtDesc(candidateId: Long, pageable: Pageable): Page<LeaderResearchEvent>
fun findByRunIdOrderByCreatedAtDesc(runId: Long): List<LeaderResearchEvent>
fun findByNotificationStatusOrderByCreatedAtAsc(status: LeaderResearchNotificationStatus, pageable: Pageable): Page<LeaderResearchEvent>
fun findTopByDedupeKey(dedupeKey: String): LeaderResearchEvent?
fun findAllByOrderByCreatedAtDesc(pageable: Pageable): Page<LeaderResearchEvent>
}
@Repository
interface LeaderResearchSourceStateRepository : JpaRepository<LeaderResearchSourceState, Long> {
fun findBySourceType(sourceType: LeaderResearchSourceType): LeaderResearchSourceState?
fun findAllByOrderByUpdatedAtDesc(): List<LeaderResearchSourceState>
}
@Repository
interface LeaderActivityEventRepository : JpaRepository<LeaderActivityEvent, Long> {
fun findByStableEventKey(stableEventKey: String): LeaderActivityEvent?
fun findBySourceAndSourceEventId(source: String, sourceEventId: String): LeaderActivityEvent?
fun findTopByOrderByEventTimeDesc(): LeaderActivityEvent?
fun findByNormalizedWalletAndEventTimeBetweenOrderByEventTimeAsc(normalizedWallet: String, start: Long, end: Long): List<LeaderActivityEvent>
fun findByUsableForDiscoveryTrueAndEventTimeGreaterThanEqual(eventTime: Long): List<LeaderActivityEvent>
fun findByPaperProcessingStatusInAndUsableForPaperTrueOrderByEventTimeAsc(statuses: Collection<LeaderPaperProcessingStatus>, pageable: Pageable): Page<LeaderActivityEvent>
fun deleteByEventTimeLessThanAndPaperProcessingStatusIn(
eventTime: Long,
statuses: Collection<LeaderPaperProcessingStatus>
): Long
@Modifying
@Query(
"update LeaderActivityEvent e set e.paperProcessingStatus = :nextStatus, e.paperProcessingStartedAt = :startedAt, e.processingAttempts = e.processingAttempts + 1, e.updatedAt = :startedAt where e.id = :id and e.paperProcessingStatus in :allowed"
)
fun claimForPaperProcessing(
@Param("id") id: Long,
@Param("allowed") allowed: Collection<LeaderPaperProcessingStatus>,
@Param("nextStatus") nextStatus: LeaderPaperProcessingStatus,
@Param("startedAt") startedAt: Long
): Int
}
@Repository
interface LeaderPaperSessionRepository : JpaRepository<LeaderPaperSession, Long> {
fun findTopByCandidateIdAndStatusOrderByStartedAtDesc(candidateId: Long, status: LeaderPaperSessionStatus): LeaderPaperSession?
fun findTopByCandidateIdOrderByStartedAtDesc(candidateId: Long): LeaderPaperSession?
fun findByCandidateIdOrderByStartedAtDesc(candidateId: Long): List<LeaderPaperSession>
fun findByUpdatedAtLessThanAndStatusIn(updatedAt: Long, statuses: Collection<LeaderPaperSessionStatus>, pageable: Pageable): Page<LeaderPaperSession>
@Query(
"""
select s from LeaderPaperSession s
where s.candidateId in :candidateIds
and s.startedAt = (
select max(s2.startedAt) from LeaderPaperSession s2 where s2.candidateId = s.candidateId
)
"""
)
fun findLatestByCandidateIds(@Param("candidateIds") candidateIds: Collection<Long>): List<LeaderPaperSession>
}
@Repository
interface LeaderPaperTradeRepository : JpaRepository<LeaderPaperTrade, Long> {
fun existsBySessionIdAndLeaderTradeIdAndSide(sessionId: Long, leaderTradeId: String, side: String): Boolean
fun findBySessionIdOrderByEventTimeDesc(sessionId: Long, pageable: Pageable): Page<LeaderPaperTrade>
fun findBySessionIdOrderByEventTimeAsc(sessionId: Long): List<LeaderPaperTrade>
fun countBySessionId(sessionId: Long): Long
fun countBySessionIdAndFilterResult(sessionId: Long, filterResult: LeaderPaperFilterResult): Long
}
@Repository
interface LeaderPaperPositionRepository : JpaRepository<LeaderPaperPosition, Long> {
fun findBySessionIdAndMarketIdAndOutcomeIndex(sessionId: Long, marketId: String, outcomeIndex: Int?): LeaderPaperPosition?
fun findBySessionIdOrderByUpdatedAtDesc(sessionId: Long): List<LeaderPaperPosition>
fun findByCandidateIdOrderByUpdatedAtDesc(candidateId: Long): List<LeaderPaperPosition>
}
@@ -0,0 +1,11 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.NotificationTemplate
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface NotificationTemplateRepository : JpaRepository<NotificationTemplate, Long> {
fun findByTemplateType(templateType: String): NotificationTemplate?
fun existsByTemplateType(templateType: String): Boolean
}
@@ -10,7 +10,10 @@ import com.wrbug.polymarketbot.util.toSafeBigDecimal
import com.wrbug.polymarketbot.util.eq
import com.wrbug.polymarketbot.util.gt
import com.wrbug.polymarketbot.util.JsonUtils
import com.wrbug.polymarketbot.util.fromJson
import com.wrbug.polymarketbot.util.getEventSlug
import com.google.gson.JsonObject
import com.google.gson.JsonPrimitive
import com.wrbug.polymarketbot.service.common.PolymarketClobService
import com.wrbug.polymarketbot.service.common.BlockchainService
import com.wrbug.polymarketbot.service.common.MarketService
@@ -125,8 +128,8 @@ class AccountService(
// 7. 加密敏感信息
val encryptedPrivateKey = cryptoUtils.encrypt(request.privateKey)
val encryptedApiSecret = apiKeyCreds.secret?.let { cryptoUtils.encrypt(it) }
val encryptedApiPassphrase = apiKeyCreds.passphrase?.let { cryptoUtils.encrypt(it) }
val encryptedApiSecret = apiKeyCreds.secret.let { cryptoUtils.encrypt(it) }
val encryptedApiPassphrase = apiKeyCreds.passphrase.let { cryptoUtils.encrypt(it) }
// 8. 生成账户名称(如果未提供,使用 SAFE/MAGIC-代理地址后4位)
val accountName = if (request.accountName.isNullOrBlank()) {
@@ -361,6 +364,215 @@ class AccountService(
}
}
/**
* Polymarket 代币批准检查pUSD 需授权的 spender 合约地址Polygon 主网
* 来源Polymarket/magic-safe-builder-example README §6 Token Approvals
* neg-risk-ctf-adapter 仓库 addresses.json (chainId 137)
*/
private val setupApprovalSpenders = mapOf(
"CTF_CONTRACT" to "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045", // Conditional Tokens
"CTF_EXCHANGE" to "0xE111180000d2663C0091e4f400237545B87B996B", // 普通市场交易所
"NEG_RISK_EXCHANGE" to "0xe2222d279d744050d28e00520010520000310F59", // 负风险市场交易所
"NEG_RISK_ADAPTER" to "0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296" // 负风险适配器(非 WCOL 地址)
)
/** USDC 精度(6 位小数) */
private val usdcDecimals = java.math.BigDecimal("1000000")
/** ERC20 无限授权额度(type(uint256).max),Polymarket 默认使用无限授权 */
private val unlimitedAllowance = BigInteger("115792089237316195423570985008687907853269984665640564039457584007913129639935")
/**
* 检查账户设置状态代理部署交易启用代币批准
* @param accountId 账户 ID
* @return AccountSetupStatusDto
*/
suspend fun checkAccountSetupStatus(accountId: Long): Result<AccountSetupStatusDto> {
return try {
if (accountId <= 0) {
return Result.failure(IllegalArgumentException("账户 ID 无效"))
}
val account = accountRepository.findById(accountId).orElse(null)
?: return Result.failure(IllegalArgumentException("账户不存在"))
val proxyAddress = account.proxyAddress
if (proxyAddress.isBlank()) {
return Result.success(
AccountSetupStatusDto(
proxyDeployed = false,
tradingEnabled = account.apiKey != null && account.apiSecret != null && account.apiPassphrase != null,
tokensApproved = false,
approvalDetails = null,
error = "代理地址为空"
)
)
}
// 步骤1:代理钱包是否已部署
val proxyDeployed = blockchainService.isProxyDeployed(proxyAddress)
// 步骤2:交易是否已启用(API 凭证是否已配置)
val tradingEnabled = account.apiKey != null &&
account.apiSecret != null &&
account.apiPassphrase != null
// 步骤3:代币是否已批准(USDC 对各 spender 的 allowance,默认无限授权)
val approvalDetails = mutableMapOf<String, String>()
var tokensApproved = true
for ((name, spender) in setupApprovalSpenders) {
val allowanceResult = blockchainService.getUsdcAllowance(proxyAddress, spender)
val allowance = allowanceResult.getOrNull() ?: BigInteger.ZERO
val displayAmount = if (allowance >= unlimitedAllowance) {
"unlimited"
} else {
java.math.BigDecimal(allowance).divide(usdcDecimals, 6, java.math.RoundingMode.DOWN).toPlainString()
}
approvalDetails[name] = displayAmount
if (allowance <= BigInteger.ZERO) {
tokensApproved = false
}
}
Result.success(
AccountSetupStatusDto(
proxyDeployed = proxyDeployed,
tradingEnabled = tradingEnabled,
tokensApproved = tokensApproved,
approvalDetails = approvalDetails,
error = null
)
)
} catch (e: Exception) {
logger.error("检查账户设置状态失败: accountId=$accountId, ${e.message}", e)
Result.failure(e)
}
}
/** 步骤1 跳转 URL(代理部署需在 Polymarket 完成) */
private val setupStep1RedirectUrl = "https://polymarket.com/settings/wallet"
/**
* 执行设置步骤由后端实现或返回跳转
* 步骤1仅返回跳转 URL由用户前往 Polymarket 完成部署
* 步骤2创建/派生 API Key 并更新账户
* 步骤3通过代理钱包批量执行 USDC 授权
*/
suspend fun executeSetupStep(accountId: Long, step: Int): Result<ExecuteSetupStepResponse> {
return try {
if (accountId <= 0) {
return Result.failure(IllegalArgumentException("账户 ID 无效"))
}
val account = accountRepository.findById(accountId).orElse(null)
?: return Result.failure(IllegalArgumentException("账户不存在"))
when (step) {
1 -> {
val walletType = WalletType.fromStringOrDefault(account.walletType, WalletType.MAGIC)
if (walletType == WalletType.MAGIC) {
Result.success(
ExecuteSetupStepResponse(
success = false,
redirectUrl = setupStep1RedirectUrl
)
)
} else {
val proxyAddress = account.proxyAddress
if (proxyAddress.isBlank()) {
return Result.failure(IllegalArgumentException("代理地址为空"))
}
val alreadyDeployed = blockchainService.isProxyDeployed(proxyAddress)
if (alreadyDeployed) {
Result.success(ExecuteSetupStepResponse(success = true))
} else {
val privateKey = decryptPrivateKey(account)
val deployResult = relayClientService.deploySafeViaBuilderRelayer(
privateKey = privateKey,
proxyAddress = proxyAddress,
fromAddress = account.walletAddress
)
deployResult.fold(
onSuccess = { txHash ->
Result.success(
ExecuteSetupStepResponse(
success = true,
transactionHash = txHash
)
)
},
onFailure = { e ->
logger.error("Safe 部署失败: accountId=$accountId, ${e.message}", e)
Result.failure(e)
}
)
}
}
}
2 -> {
val privateKey = decryptPrivateKey(account)
val result = apiKeyService.createOrDeriveApiKey(
privateKey = privateKey,
walletAddress = account.walletAddress,
chainId = 137L
)
if (result.isFailure) {
val e = result.exceptionOrNull()
logger.error("启用交易(API Key)失败: accountId=$accountId, ${e?.message}", e)
return Result.failure(e ?: IllegalStateException("获取 API Key 失败"))
}
val creds = result.getOrNull()
?: return Result.failure(IllegalStateException("API Key 返回为空"))
val encryptedSecret = creds.secret.let { cryptoUtils.encrypt(it) }
val encryptedPassphrase = creds.passphrase.let { cryptoUtils.encrypt(it) }
val updated = account.copy(
apiKey = creds.apiKey,
apiSecret = encryptedSecret,
apiPassphrase = encryptedPassphrase,
updatedAt = System.currentTimeMillis()
)
accountRepository.save(updated)
orderPushService.refreshSubscriptions()
Result.success(ExecuteSetupStepResponse(success = true))
}
3 -> {
val proxyAddress = account.proxyAddress
if (proxyAddress.isBlank()) {
return Result.failure(IllegalArgumentException("代理地址为空,请先完成步骤1"))
}
val privateKey = decryptPrivateKey(account)
val walletType = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
val approveTxs = setupApprovalSpenders.values.map { spender ->
relayClientService.createUsdcApproveTx(spender, unlimitedAllowance)
}
val multiSendTx = relayClientService.createMultiSendTx(approveTxs)
val executeResult = relayClientService.execute(
privateKey = privateKey,
proxyAddress = proxyAddress,
safeTx = multiSendTx,
walletType = walletType
)
executeResult.fold(
onSuccess = { txHash ->
Result.success(
ExecuteSetupStepResponse(
success = true,
transactionHash = txHash
)
)
},
onFailure = { e ->
logger.error("代币授权执行失败: accountId=$accountId, ${e.message}", e)
Result.failure(e)
}
)
}
else -> Result.failure(IllegalArgumentException("无效的步骤: $step,应为 1、2 或 3"))
}
} catch (e: Exception) {
logger.error("执行设置步骤失败: accountId=$accountId, step=$step, ${e.message}", e)
Result.failure(e)
}
}
/**
* 更新账户信息
*/
@@ -727,7 +939,38 @@ class AccountService(
throw RuntimeException("解密私钥失败: ${e.message}", e)
}
}
/**
* 轮询用遍历所有账户对代理地址 WCOL 余额 > 0 的执行解包
* WcolUnwrapJobService 20 秒调用赎回后无需在赎回流程内等待确认与解包
*/
suspend fun runWcolUnwrapForAllAccounts() {
val accounts = accountRepository.findAllByOrderByCreatedAtAsc()
if (accounts.isEmpty()) return
for (account in accounts) {
try {
val privateKey = decryptPrivateKey(account)
val walletType = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
blockchainService.unwrapWcolForProxy(
privateKey = privateKey,
proxyAddress = account.proxyAddress,
walletType = walletType
).fold(
onSuccess = { txHash ->
if (txHash != null) {
logger.info("轮询解包 WCOL: accountId=${account.id}, proxy=${account.proxyAddress.take(10)}..., txHash=$txHash")
}
},
onFailure = { e ->
logger.warn("轮询解包 WCOL 失败 accountId=${account.id}: ${e.message}")
}
)
} catch (e: Exception) {
logger.warn("轮询解包 WCOL 跳过 accountId=${account.id}: ${e.message}")
}
}
}
/**
* 解密账户 API Secret
*/
@@ -888,7 +1131,7 @@ class AccountService(
// 3. 验证仓位是否存在并获取原始数量
val positionsResult = getAllPositions()
val (position, originalQuantity) = positionsResult.fold(
val (_, originalQuantity) = positionsResult.fold(
onSuccess = { positionListResponse ->
val position = positionListResponse.currentPositions.find {
it.accountId == request.accountId &&
@@ -921,7 +1164,7 @@ class AccountService(
onFailure = { e ->
return Result.failure(Exception("查询仓位失败: ${e.message}"))
}
) ?: return Result.failure(IllegalArgumentException("仓位不存在"))
)
// 4. 计算实际卖出数量
val sellQuantity = if (percentDecimal != null) {
@@ -1001,22 +1244,9 @@ class AccountService(
else -> "GTC"
}
// GTC 和 FOK 订单的 expiration 必须为 "0"
// 只有 GTD 订单才需要设置具体的过期时间
val expiration = "0"
// 7. 解密私钥
val decryptedPrivateKey = decryptPrivateKey(account)
// 获取费率(根据 Polymarket Maker Rebates Program 要求)
val feeRateResult = clobService.getFeeRate(tokenId)
val feeRateBps = if (feeRateResult.isSuccess) {
feeRateResult.getOrNull()?.toString() ?: "0"
} else {
logger.warn("获取费率失败,使用默认值 0: tokenId=$tokenId, error=${feeRateResult.exceptionOrNull()?.message}")
"0"
}
// 11. 创建并签名订单(使用计算后的卖出数量,按账户钱包类型使用对应 signatureType
val signedOrder = try {
orderSigningService.createAndSignOrder(
@@ -1026,10 +1256,7 @@ class AccountService(
side = "SELL",
price = sellPrice,
size = sellQuantity.toPlainString(), // 使用计算后的卖出数量
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType),
nonce = "0",
feeRateBps = feeRateBps, // 使用动态获取的费率
expiration = expiration
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
)
} catch (e: Exception) {
logger.error("创建并签名订单失败", e)
@@ -1040,9 +1267,8 @@ class AccountService(
val newOrderRequest = com.wrbug.polymarketbot.api.NewOrderRequest(
order = signedOrder,
owner = account.apiKey!!, // API Key
orderType = orderType,
deferExec = false
owner = account.apiKey, // API Key
orderType = orderType
)
// 13. 解密 API 凭证并使用账户的API凭证创建订单
@@ -1060,7 +1286,7 @@ class AccountService(
}
val clobApi = retrofitFactory.createClobApi(
account.apiKey!!,
account.apiKey,
apiSecret,
apiPassphrase,
account.walletAddress
@@ -1091,13 +1317,22 @@ class AccountService(
// 使用当前时间作为订单创建时间
val orderTime = System.currentTimeMillis()
// 查询可用余额
val availableBalance = try {
blockchainService.getUsdcBalance(account.walletAddress, account.proxyAddress).getOrNull()
} catch (e: Exception) {
logger.warn("查询可用余额失败: accountId=${account.id}, ${e.message}")
null
}
telegramNotificationService?.sendOrderSuccessNotification(
orderId = orderId,
marketTitle = marketTitle,
marketId = request.marketId,
marketSlug = marketSlug,
side = request.side,
side = "SELL", // 手动卖出订单,方向固定为 SELL
outcome = request.side, // request.side 是市场方向(YES/NO
price = sellPrice, // 直接传递卖出价格
size = sellQuantity.toPlainString(), // 直接传递卖出数量
accountName = account.accountName,
@@ -1108,7 +1343,8 @@ class AccountService(
apiPassphrase = try { cryptoUtils.decrypt(account.apiPassphrase!!) } catch (e: Exception) { null },
walletAddressForApi = account.walletAddress,
locale = locale,
orderTime = orderTime // 使用订单创建时间
orderTime = orderTime, // 使用订单创建时间
availableBalance = availableBalance
)
} catch (e: Exception) {
logger.warn("发送订单成功通知失败: ${e.message}", e)
@@ -1128,7 +1364,7 @@ class AccountService(
)
)
} else {
val errorMsg = response.errorMsg ?: "未知错误"
val errorMsg = response.getErrorMessage()
val fullErrorMsg = "创建订单失败: accountId=${account.id}, marketId=${request.marketId}, side=${request.side}, orderType=${request.orderType}, price=${if (request.orderType == "LIMIT") sellPrice else "MARKET"}, quantity=${sellQuantity.toPlainString()}, errorMsg=$errorMsg"
logger.error(fullErrorMsg)
@@ -1173,6 +1409,14 @@ class AccountService(
} catch (e: Exception) {
null
}
// 尝试从 errorBody 解析 error 字段(使用 Gson
val apiError = try {
(errorBody?.fromJson<JsonObject>()?.get("error") as? JsonPrimitive)?.asString
} catch (e: Exception) {
null
}
val fullErrorMsg = "创建订单失败: accountId=${account.id}, marketId=${request.marketId}, side=${request.side}, orderType=${request.orderType}, price=${if (request.orderType == "LIMIT") sellPrice else "MARKET"}, quantity=${sellQuantity.toPlainString()}, code=${orderResponse.code()}, message=${orderResponse.message()}${if (errorBody != null) ", errorBody=$errorBody" else ""}"
logger.error(fullErrorMsg)
@@ -1191,8 +1435,10 @@ class AccountService(
java.util.Locale("zh", "CN") // 默认简体中文
}
// 只传递后端返回的 msg,不传递完整堆栈
val errorMsg = orderResponse.body()?.errorMsg ?: "创建订单失败"
// 优先使用解析的 API error,其次使用响应体的 errorMsg,最后使用默认消息
val errorMsg = apiError
?: orderResponse.body()?.getErrorMessage()
?: "创建订单失败 (HTTP ${orderResponse.code()})"
telegramNotificationService?.sendOrderFailureNotification(
marketTitle = marketTitle,
@@ -1202,7 +1448,7 @@ class AccountService(
outcome = null, // 失败时可能没有 outcome
price = if (request.orderType == "LIMIT") sellPrice.toString() else "MARKET",
size = sellQuantity.toString(),
errorMessage = errorMsg, // 只传递后端返回的 msg
errorMessage = errorMsg, // 只传递后端返回的错误信息
accountName = account.accountName,
walletAddress = account.walletAddress,
locale = locale
@@ -1464,21 +1710,30 @@ 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 walletTypeEnum = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
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
)
@@ -1487,11 +1742,38 @@ class AccountService(
lastTxHash = txHash
},
onFailure = { e ->
logger.error("账户 $accountId 市场 $marketId 赎回失败: ${e.message}", e)
return Result.failure(Exception("赎回失败: 账户 $accountId 市场 $marketId - ${e.message}"))
logger.error("账户 $accountId MultiSend 批量赎回失败: ${e.message}", e)
return Result.failure(Exception("赎回失败: 账户 $accountId - ${e.message}"))
}
)
} else {
// Magic 钱包或单个市场:逐笔赎回
for ((marketId, marketPositions) in positionsByMarket) {
val indexSets = marketPositions.map { it.second }
val isNegRisk = marketService.getNegRiskByConditionId(marketId) == true
val redeemResult = blockchainService.redeemPositions(
privateKey = decryptedPrivateKey,
proxyAddress = account.proxyAddress,
conditionId = marketId,
indexSets = indexSets,
isNegRisk = isNegRisk,
walletType = walletTypeEnum
)
redeemResult.fold(
onSuccess = { txHash ->
lastTxHash = txHash
},
onFailure = { e ->
logger.error("账户 $accountId 市场 $marketId 赎回失败: ${e.message}", e)
return Result.failure(Exception("赎回失败: 账户 $accountId 市场 $marketId - ${e.message}"))
}
)
}
}
// WCOL 解包由 WcolUnwrapJobService 每 20 秒轮询统一处理,赎回流程不再等待确认与解包
// 计算该账户的赎回总价值
val accountTotalValue = redeemedInfo.fold(BigDecimal.ZERO) { sum, info ->
@@ -1524,16 +1806,42 @@ class AccountService(
for (transaction in accountTransactions) {
val account = accounts[transaction.accountId]
if (account != null) {
telegramNotificationService?.sendRedeemNotification(
accountName = account.accountName,
walletAddress = account.walletAddress,
transactionHash = transaction.transactionHash,
totalRedeemedValue = transaction.positions.fold(BigDecimal.ZERO) { sum, info ->
sum.add(info.value.toSafeBigDecimal())
}.toPlainString(),
positions = transaction.positions,
locale = locale
)
// 查询可用余额
val availableBalance = try {
blockchainService.getUsdcBalance(account.walletAddress, account.proxyAddress).getOrNull()
} catch (e: Exception) {
logger.warn("查询可用余额失败: accountId=${account.id}, ${e.message}")
null
}
// 计算该账户的赎回总价值
val accountTotalValue = transaction.positions.fold(BigDecimal.ZERO) { sum, info ->
sum.add(info.value.toSafeBigDecimal())
}
// 根据赎回价值选择不同的通知类型
if (accountTotalValue.gt(BigDecimal.ZERO)) {
// 有收益:发送赎回成功通知
telegramNotificationService?.sendRedeemNotification(
accountName = account.accountName,
walletAddress = account.walletAddress,
transactionHash = transaction.transactionHash,
totalRedeemedValue = accountTotalValue.toPlainString(),
positions = transaction.positions,
locale = locale,
availableBalance = availableBalance
)
} else {
// 无收益(输的仓位):发送已结算无收益通知
telegramNotificationService?.sendRedeemNoReturnNotification(
accountName = account.accountName,
walletAddress = account.walletAddress,
transactionHash = transaction.transactionHash,
positions = transaction.positions,
locale = locale,
availableBalance = availableBalance
)
}
}
}
} catch (e: Exception) {
@@ -1600,6 +1908,32 @@ class AccountService(
false
}
}
/**
* 将账户的 USDC.e wrap pUSD
*/
suspend fun wrapUsdcToPusd(accountId: Long): Result<String?> {
val account = accountRepository.findById(accountId).orElse(null)
?: return Result.failure(IllegalArgumentException("账户不存在"))
if (account.proxyAddress.isBlank()) {
return Result.failure(IllegalStateException("账户代理地址不存在"))
}
val privateKey = cryptoUtils.decrypt(account.privateKey)
val walletType = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
return blockchainService.wrapUsdcToPusd(privateKey, account.proxyAddress, walletType)
}
/**
* 查询 USDC.e 余额用于迁移提示
*/
suspend fun getUsdceBalance(accountId: Long): Result<BigDecimal> {
val account = accountRepository.findById(accountId).orElse(null)
?: return Result.failure(IllegalArgumentException("账户不存在"))
if (account.proxyAddress.isBlank()) {
return Result.failure(IllegalStateException("账户代理地址不存在"))
}
return blockchainService.queryUsdceBalance(account.proxyAddress)
}
}
@@ -25,6 +25,7 @@ import com.wrbug.polymarketbot.service.common.MarketPriceService
import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
/**
* 仓位检查服务
@@ -77,7 +78,10 @@ class PositionCheckService(
// 同步锁,确保订阅任务的启动和停止是线程安全的
private val lock = Any()
// 防止 checkRedeemablePositions 重入:上一轮检查未完成时,新一轮轮询直接跳过
private val redeemCheckInProgress = AtomicBoolean(false)
/**
* 初始化服务订阅 PositionPollingService 的事件启动缓存清理任务
*/
@@ -328,18 +332,23 @@ class PositionCheckService(
/**
* 逻辑1处理待赎回仓位
https://clob.polymarket.com * 按照以下逻辑处理
* 按照以下逻辑处理
* 1. 无待赎回仓位跳过
* 2. (未配置apikey || autoredeem==false) && 有待赎回的仓位发送通知事件
* 3. (已配置) && 有待赎回的仓位处理订单逻辑
* 防重入上一轮检查未完成时本轮直接跳过避免并发赎回
*/
private suspend fun checkRedeemablePositions(redeemablePositions: List<AccountPositionDto>) {
if (!redeemCheckInProgress.compareAndSet(false, true)) {
logger.debug("跳过本次待赎回仓位检查:上一次检查尚未完成")
return
}
try {
// 1. 无待赎回仓位:跳过
if (redeemablePositions.isEmpty()) {
return
}
// 检查系统级别的自动赎回配置
val autoRedeemEnabled = systemConfigService.isAutoRedeemEnabled()
val apiKeyConfigured = relayClientService.isBuilderApiKeyConfigured()
@@ -373,14 +382,21 @@ 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 }
@@ -451,9 +467,11 @@ class PositionCheckService(
}
} catch (e: Exception) {
logger.error("处理待赎回仓位异常: ${e.message}", e)
} finally {
redeemCheckInProgress.set(false)
}
}
/**
* 逻辑2处理未卖出订单
* 检查所有未卖出的订单匹配仓位
@@ -0,0 +1,51 @@
package com.wrbug.polymarketbot.service.accounts
import com.wrbug.polymarketbot.service.system.RelayClientService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.slf4j.LoggerFactory
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
/**
* WCOL 解包轮询任务
* 20 秒轮询一次遍历所有账户的代理地址 WCOL 余额 > 0 则执行解包
* 同一时间仅允许单次执行若上次执行未结束则本次忽略与现有轮询逻辑一致
* 若未配置 Builder API Key直接跳过本轮解包依赖 Relayer Gasless未配置则无法执行
*/
@Service
class WcolUnwrapJobService(
private val accountService: AccountService,
private val relayClientService: RelayClientService
) {
private val logger = LoggerFactory.getLogger(WcolUnwrapJobService::class.java)
private val scope = kotlinx.coroutines.CoroutineScope(Dispatchers.IO + SupervisorJob())
private var unwrapJob: Job? = null
/**
* 20 秒触发一次若未配置 Builder Key 或当前任务仍在执行则跳过本次
*/
@Scheduled(fixedRate = 20_000)
fun runWcolUnwrapPolling() {
if (!relayClientService.isBuilderApiKeyConfigured()) {
logger.debug("Builder API Key 未配置,跳过 WCOL 解包轮询")
return
}
if (unwrapJob?.isActive == true) {
logger.debug("上一轮 WCOL 解包任务仍在执行,跳过本次")
return
}
unwrapJob = scope.launch {
try {
accountService.runWcolUnwrapForAllAccounts()
} catch (e: Exception) {
logger.error("WCOL 解包轮询异常: ${e.message}", e)
} finally {
unwrapJob = null
}
}
}
}
@@ -408,10 +408,25 @@ class BacktestExecutionService(
val cost = actualSellQuantity.multiply(position.avgPrice)
val profitLoss = netAmount.subtract(cost)
// 更新余额和持仓
// Bug #39 Fix: correctly reduce position quantity after sell
currentBalance += netAmount
if (position.quantity <= BigDecimal.ZERO) {
val remainingQuantity = position.quantity - actualSellQuantity
val remainingLeaderBuyQuantity = if (position.leaderBuyQuantity != null && position.leaderBuyQuantity > BigDecimal.ZERO) {
val totalQty = position.quantity
val leaderReduction = actualSellQuantity.divide(
totalQty, 8, java.math.RoundingMode.DOWN
)
(position.leaderBuyQuantity - leaderReduction).coerceAtLeast(BigDecimal.ZERO)
} else {
position.leaderBuyQuantity
}
if (remainingQuantity <= BigDecimal.ZERO) {
positions.remove(positionKey)
} else {
positions[positionKey] = position.copy(
quantity = remainingQuantity,
leaderBuyQuantity = remainingLeaderBuyQuantity
)
}
// 记录交易到当前页列表
@@ -632,7 +647,8 @@ class BacktestExecutionService(
val settlementPrice = avgPrice
val settlementValue = quantity.multiply(settlementPrice)
val profitLoss = settlementValue.negate()
// Bug #39 Fix: profitLoss for closed settlement at avgPrice should be ~0
val profitLoss = settlementValue.subtract(quantity.multiply(avgPrice))
balance += settlementValue
@@ -702,7 +718,8 @@ class BacktestExecutionService(
if (balance > peakBalance) {
peakBalance = balance
}
val drawdown = peakBalance - runningBalance
// Bug #39 Fix: use current balance, not runningBalance from previous iteration
val drawdown = peakBalance - balance
if (drawdown > maxDrawdown) {
maxDrawdown = drawdown
}
@@ -9,7 +9,7 @@ import java.math.RoundingMode
import java.util.concurrent.ConcurrentHashMap
/**
* 自动最小价差按周期计算每个周期首次需要时拉取该周期前的 20 根已收盘 K 线按方向筛选IQR 剔除后求平均缓存 100% 基准值 (interval, period)
* 自动最小价差按周期计算每个周期首次需要时拉取该周期前的 20 根已收盘 K 线按方向筛选IQR 剔除后求平均缓存 100% 基准值 (marketSlugPrefix, interval, period)
* 触发时由调用方按窗口进度计算动态系数100%50%后得到有效最小价差不在保存策略时计算
*/
@Service
@@ -19,29 +19,68 @@ class BinanceKlineAutoSpreadService(
private val logger = LoggerFactory.getLogger(BinanceKlineAutoSpreadService::class.java)
private val symbol = "BTCUSDC"
/** 市场 slug 前缀 -> Binance 交易对映射 */
private val marketToSymbol = mapOf(
"btc-updown" to "BTCUSDC",
"eth-updown" to "ETHUSDC",
"sol-updown" to "SOLUSDC",
"xrp-updown" to "XRPUSDC"
)
private val historyLimit = 20
private val minSamplesAfterIqr = 3
/** (intervalSeconds, periodStartUnix) -> (baseSpreadUp, baseSpreadDown)100% 基准价差 */
/** (marketSlugPrefix, intervalSeconds, periodStartUnix) -> (baseSpreadUp, baseSpreadDown)100% 基准价差 */
private val cache = ConcurrentHashMap<String, Pair<BigDecimal, BigDecimal>>()
private fun cacheKey(intervalSeconds: Int, periodStartUnix: Long): String = "$intervalSeconds-$periodStartUnix"
/** 缓存保留时间(秒),超过则清理,防止无界增长 */
private val cacheExpireSeconds = 3600L
/** 从市场 slug 前缀获取 Binance 交易对;支持完整 slug(如 eth-updown-5m)或前缀(如 eth-updown */
private fun getSymbol(marketSlugPrefix: String): String? {
val base = marketSlugPrefix.lowercase().removeSuffix("-15m").removeSuffix("-5m")
return marketToSymbol[base]
}
private fun cacheKey(marketSlugPrefix: String, intervalSeconds: Int, periodStartUnix: Long): String {
return "$marketSlugPrefix-$intervalSeconds-$periodStartUnix"
}
/** 清理已过期的价差缓存,避免内存泄漏 */
private fun cleanExpiredCache() {
val nowSeconds = System.currentTimeMillis() / 1000
val expireThreshold = nowSeconds - cacheExpireSeconds
val keysToRemove = cache.keys.filter { key ->
// key 格式: marketSlugPrefix-intervalSeconds-periodStartUnix
val parts = key.split('-')
if (parts.size >= 3) {
parts.last().toLongOrNull()?.let { it < expireThreshold } ?: false
} else {
false
}
}
keysToRemove.forEach { cache.remove(it) }
}
/** 返回该周期、该方向的 100% 基准价差,供调用方按窗口进度应用动态系数。 */
fun getAutoMinSpreadBase(intervalSeconds: Int, periodStartUnix: Long, outcomeIndex: Int): BigDecimal? {
val key = cacheKey(intervalSeconds, periodStartUnix)
fun getAutoMinSpreadBase(marketSlugPrefix: String, intervalSeconds: Int, periodStartUnix: Long, outcomeIndex: Int): BigDecimal? {
val key = cacheKey(marketSlugPrefix, intervalSeconds, periodStartUnix)
val (up, down) = cache[key] ?: run {
computeAndCache(intervalSeconds, periodStartUnix) ?: return null
computeAndCache(marketSlugPrefix, intervalSeconds, periodStartUnix) ?: return null
}
return if (outcomeIndex == 0) up else down
}
/** 计算并缓存 100% 基准价差(IQR 平均,不乘系数)。预加载与触发时共用此缓存。 */
fun computeAndCache(intervalSeconds: Int, periodStartUnix: Long): Pair<BigDecimal, BigDecimal>? {
fun computeAndCache(marketSlugPrefix: String, intervalSeconds: Int, periodStartUnix: Long): Pair<BigDecimal, BigDecimal>? {
cleanExpiredCache()
val symbol = getSymbol(marketSlugPrefix) ?: run {
logger.warn("不支持的市场 slug 前缀: $marketSlugPrefix")
return null
}
val intervalStr = if (intervalSeconds == 300) "5m" else "15m"
val endTimeMs = periodStartUnix * 1000L
val klines = fetchKlines(intervalStr, historyLimit, endTime = endTimeMs) ?: return null
val klines = fetchKlines(symbol, intervalStr, historyLimit, endTime = endTimeMs) ?: return null
val spreadsUp = mutableListOf<BigDecimal>()
val spreadsDown = mutableListOf<BigDecimal>()
for (k in klines) {
@@ -53,16 +92,16 @@ class BinanceKlineAutoSpreadService(
}
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
cache[cacheKey(marketSlugPrefix, intervalSeconds, periodStartUnix)] = baseUp to baseDown
logger.info(
"尾盘自动价差已计算并缓存(100%基准): interval=${intervalSeconds}s periodStartUnix=$periodStartUnix | " +
"加密价差策略自动价差已计算并缓存(100%基准): market=$marketSlugPrefix symbol=$symbol interval=${intervalSeconds}s periodStartUnix=$periodStartUnix | " +
"Up方向: 样本数=${spreadsUp.size}, baseSpreadUp=${baseUp.toPlainString()} | " +
"Down方向: 样本数=${spreadsDown.size}, baseSpreadDown=${baseDown.toPlainString()}"
)
return baseUp to baseDown
}
private fun fetchKlines(interval: String, limit: Int, endTime: Long? = null): List<List<Any>>? {
private fun fetchKlines(symbol: String, interval: String, limit: Int, endTime: Long? = null): List<List<Any>>? {
return try {
val api = retrofitFactory.createBinanceApi()
val call = api.getKlines(symbol = symbol, interval = interval, limit = limit, endTime = endTime)
@@ -16,10 +16,11 @@ import org.springframework.stereotype.Service
import java.math.BigDecimal
import jakarta.annotation.PreDestroy
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
/**
* 币安 K 线 WebSocket订阅 BTCUSDC 5m/15m维护当前周期 (open, close)尾盘策略价差校验使用
* 币安 K 线 WebSocket按需订阅加密价差策略使用的币种 5m/15m维护当前周期 (open, close)供价差校验使用
* 仅当存在启用策略且策略使用到某市场时才订阅对应币种无策略时不建立连接
*/
@Service
class BinanceKlineService {
@@ -28,91 +29,138 @@ class BinanceKlineService {
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private val wsBase = "wss://stream.binance.com:9443"
private val client = createClient().build()
private val client by lazy {
createClient().build()
}
/** (intervalSeconds, periodStartUnix) -> (open, close) */
/** (marketSlugPrefix, 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()
/** 市场 slug 前缀(如 btc-updown-> Binance 交易对映射 */
private val marketToSymbol = mapOf(
"btc-updown" to "BTCUSDC",
"eth-updown" to "ETHUSDC",
"sol-updown" to "SOLUSDC",
"xrp-updown" to "XRPUSDC"
)
/** 已连接的 WebSocket: wsKey (symbol-interval) -> WebSocket */
private val connectedWebSockets = ConcurrentHashMap<String, WebSocket>()
/** 当前需要订阅的完整市场集合(如 btc-updown-5m、btc-updown-15m),由加密价差策略刷新时更新 */
private val requiredMarketPrefixes = AtomicReference<Set<String>>(emptySet())
private val subscriptionLock = Any()
private var reconnectJob: Job? = null
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 }
/** 解析完整市场 slug(如 btc-updown-5m)为 (basePrefix, interval),不支持则返回 null */
private fun parseMarketSlug(full: String): Pair<String, String>? {
val lower = full.lowercase()
return when {
lower.endsWith("-5m") -> Pair(lower.removeSuffix("-5m"), "5m")
lower.endsWith("-15m") -> Pair(lower.removeSuffix("-15m"), "15m")
else -> null
}
}
/** 从市场 base 前缀(如 btc-updown)获取 Binance 交易对 */
private fun getSymbol(basePrefix: String): String? = marketToSymbol[basePrefix]
private fun key(marketSlugPrefix: String, intervalSeconds: Int, periodStartUnix: Long): String {
return "$marketSlugPrefix-$intervalSeconds-$periodStartUnix"
}
fun getCurrentOpenClose(marketSlugPrefix: String, intervalSeconds: Int, periodStartUnix: Long): Pair<BigDecimal, BigDecimal>? {
return openCloseByPeriod[key(marketSlugPrefix, intervalSeconds, periodStartUnix)]
}
/** 供 API 健康检查使用:各币种各周期的连接状态 */
fun getConnectionStatuses(): Map<String, Boolean> {
return connectedWebSockets.keys.associateWith { connectedWebSockets[it] != null }
}
/**
* 按需更新订阅仅订阅策略用到的 (币种, 周期)例如只开 btc 5min 则只建 btc 5min K 线连接
* CryptoTailOrderbookWsService 在刷新订阅时根据启用策略的 marketSlugPrefix 调用
* @param marketPrefixes 当前启用策略用到的完整市场集合 ["btc-updown-5m"] ["btc-updown-5m", "eth-updown-15m"]空集合时关闭所有连接
*/
fun updateSubscriptions(marketPrefixes: Set<String>) {
val normalized = marketPrefixes.map { it.lowercase() }.toSet()
val parsed = normalized.mapNotNull { full ->
parseMarketSlug(full)?.let { (base, interval) ->
getSymbol(base)?.let { symbol -> Triple(full, symbol, interval) }
}
}.toSet()
val wsKeysNeeded = parsed.map { (_, symbol, interval) -> "$symbol-$interval" }.toSet()
// 检查是否有需要的 WebSocket 连接缺失(可能因网络问题断开)
val hasMissingConnection = wsKeysNeeded.any { it !in connectedWebSockets.keys }
// 只有当集合相同且所有需要的连接都存在时才跳过
if (normalized == requiredMarketPrefixes.get() && !hasMissingConnection) return
requiredMarketPrefixes.set(normalized)
synchronized(subscriptionLock) {
connectedWebSockets.keys.toList().forEach { wsKey ->
if (wsKey !in wsKeysNeeded) {
connectedWebSockets.remove(wsKey)?.close(1000, "subscription_update")
logger.info("币安 K 线 WS 已关闭(无策略使用): $wsKey")
}
}
parsed.forEach { (fullPrefix, symbol, interval) ->
connectStream(symbol, interval, fullPrefix) { marketPrefixParam, intervalSec, tMs, openP, closeP ->
val periodSec = tMs / 1000
openCloseByPeriod[key(marketPrefixParam, intervalSec, periodSec)] = openP to closeP
}
}
}
}
private fun connectStream(
streamName: String,
onKline: (intervalSeconds: Int, openTimeMs: Long, open: BigDecimal, close: BigDecimal) -> Unit
): WebSocket {
symbol: String,
interval: String,
marketPrefix: String,
onKline: (marketPrefix: String, intervalSeconds: Int, openTimeMs: Long, open: BigDecimal, close: BigDecimal) -> Unit
) {
val streamName = "${symbol.lowercase()}@kline_$interval"
val wsKey = "$symbol-$interval"
if (connectedWebSockets[wsKey] != null) return
val url = "$wsBase/ws/$streamName"
val intervalSeconds = when {
streamName.contains("kline_5m") -> 300
streamName.contains("kline_15m") -> 900
val intervalSeconds = when (interval) {
"5m" -> 300
"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() {
client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
connectedFlag?.set(true)
connectedWebSockets[wsKey] = webSocket
logger.info("币安 K 线 WS 已连接: $streamName")
}
override fun onMessage(webSocket: WebSocket, text: String) {
parseKlineMessage(text, intervalSeconds)?.let { (tMs, o, c) ->
onKline(intervalSeconds, tMs, o, c)
parseKlineMessage(text)?.let { (tMs, o, c) ->
onKline(marketPrefix, intervalSeconds, tMs, o, c)
}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
connectedFlag?.set(false)
connectedWebSockets.remove(wsKey)
logger.warn("币安 K 线 WS 异常 $streamName: ${t.message}")
scheduleReconnect()
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
connectedFlag?.set(false)
connectedWebSockets.remove(wsKey)
if (code != 1000) scheduleReconnect()
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
connectedFlag?.set(false)
connectedWebSockets.remove(wsKey)
}
})
logger.info("币安 K 线 WS 已连接: $streamName")
return ws
}
private fun parseKlineMessage(text: String, intervalSeconds: Int): Triple<Long, BigDecimal, BigDecimal>? {
private fun parseKlineMessage(text: String): Triple<Long, BigDecimal, BigDecimal>? {
return try {
val json = com.google.gson.JsonParser.parseString(text).asJsonObject
if (json.get("e")?.asString != "kline") return null
@@ -132,23 +180,20 @@ class BinanceKlineService {
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)
val current = requiredMarketPrefixes.get()
connectedWebSockets.values.forEach { it.close(1000, "reconnect") }
connectedWebSockets.clear()
logger.info("币安 K 线 WS 尝试重连")
connectAll()
// 清空 requiredMarketPrefixes,否则 updateSubscriptions(current) 内会因 normalized == requiredMarketPrefixes.get() 直接 return,不会重新 connectStream
requiredMarketPrefixes.set(emptySet())
updateSubscriptions(current)
}
}
@PreDestroy
fun destroy() {
reconnectJob?.cancel()
ws5m?.close(1000, "shutdown")
ws15m?.close(1000, "shutdown")
ws5m = null
ws15m = null
connectedWebSockets.values.forEach { it.close(1000, "shutdown") }
connectedWebSockets.clear()
}
}
@@ -18,6 +18,7 @@ import com.wrbug.polymarketbot.util.toSafeBigDecimal
import org.slf4j.LoggerFactory
import com.wrbug.polymarketbot.service.system.RelayClientService
import com.wrbug.polymarketbot.service.system.RpcNodeService
import kotlinx.coroutines.delay
import org.springframework.stereotype.Service
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
@@ -38,8 +39,8 @@ class BlockchainService(
private val logger = LoggerFactory.getLogger(BlockchainService::class.java)
// USDC 合约地址(Polygon 主网,Polymarket 使用 Polygon
private val usdcContractAddress = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
// pUSD 合约地址(Polygon 主网,Polymarket 使用 Polygon
private val usdcContractAddress = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB"
// Polymarket Safe 代理工厂合约地址(Polygon 主网,用于 MetaMask 用户)
// 合约地址: 0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b
@@ -54,6 +55,9 @@ class BlockchainService(
// ConditionalTokens 合约地址(Polygon 主网)
private val conditionalTokensAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"
// Neg Risk WrappedCollateral 合约地址(Polygon
private val wcolContractAddress = "0x3A3BD7bb9528E159577F7C2e685CC81A765002E2"
// 空集合ID(用于计算collectionId
private val EMPTY_SET = "0x0000000000000000000000000000000000000000000000000000000000000000"
@@ -243,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 余额
@@ -587,6 +647,7 @@ class BlockchainService(
* @param proxyAddress 代理地址Safe Magic 代理钱包地址
* @param conditionId 市场条件IDbytes32必须是 0x 开头的 66 位十六进制字符串
* @param indexSets 要赎回的索引集合列表每个元素是 2^outcomeIndex
* @param isNegRisk 是否为 Neg Risk 市场true 时使用 WrappedCollateral 作为抵押品
* @param walletType 钱包类型MAGIC SAFE用于选择执行路径
* @return 交易哈希
*/
@@ -595,6 +656,7 @@ class BlockchainService(
proxyAddress: String,
conditionId: String,
indexSets: List<BigInteger>,
isNegRisk: Boolean = false,
walletType: WalletType = WalletType.SAFE
): Result<String> {
return try {
@@ -608,14 +670,284 @@ class BlockchainService(
return Result.failure(IllegalArgumentException("proxyAddress 格式错误,必须是有效的以太坊地址"))
}
val redeemTx = relayClientService.createRedeemTx(conditionId, indexSets)
val redeemTx = relayClientService.createRedeemTx(conditionId, indexSets, isNegRisk)
relayClientService.execute(privateKey, proxyAddress, redeemTx, walletType)
} catch (e: Exception) {
logger.error("赎回仓位失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 批量赎回多个市场的仓位使用 MultiSend 合并为一笔交易
* 仅支持 Safe 钱包类型Magic 钱包不支持 MultiSend
*
* @param privateKey 私钥原始钱包的私钥用于签名交易
* @param proxyAddress 代理地址Safe 代理钱包地址
* @param redeemRequests 赎回请求列表每个元素是 (conditionId, indexSets, isNegRisk)
* @param walletType 钱包类型仅支持 SAFE
* @return 交易哈希
*/
suspend fun redeemPositionsBatch(
privateKey: String,
proxyAddress: String,
redeemRequests: List<Triple<String, List<BigInteger>, Boolean>>,
walletType: WalletType = WalletType.SAFE
): Result<String> {
return try {
if (redeemRequests.isEmpty()) {
return Result.failure(IllegalArgumentException("redeemRequests 不能为空"))
}
// Magic 钱包不支持 MultiSend
if (walletType == WalletType.MAGIC) {
return Result.failure(IllegalArgumentException("Magic 钱包不支持 MultiSend 批量赎回,请使用逐笔赎回"))
}
if (proxyAddress.isBlank() || !proxyAddress.startsWith("0x") || proxyAddress.length != 42) {
return Result.failure(IllegalArgumentException("proxyAddress 格式错误,必须是有效的以太坊地址"))
}
// 验证所有 conditionId 格式
for ((conditionId, _, _) in redeemRequests) {
if (conditionId.isBlank() || !conditionId.startsWith("0x") || conditionId.length != 66) {
return Result.failure(IllegalArgumentException("conditionId 格式错误: $conditionId"))
}
}
// 创建每个市场的赎回交易(Neg Risk 市场使用 WrappedCollateral
val redeemTxs = redeemRequests.map { (conditionId, indexSets, isNegRisk) ->
if (indexSets.isEmpty()) {
throw IllegalArgumentException("indexSets 不能为空: $conditionId")
}
relayClientService.createRedeemTx(conditionId, indexSets, isNegRisk)
}
// 使用 MultiSend 合并所有交易
val multiSendTx = relayClientService.createMultiSendTx(redeemTxs)
logger.info("批量赎回: 合并 ${redeemRequests.size} 个市场为一笔交易")
relayClientService.execute(privateKey, proxyAddress, multiSendTx, walletType)
} catch (e: Exception) {
logger.error("批量赎回仓位失败: ${e.message}", e)
Result.failure(e)
}
}
/**
* 轮询等待交易上链并确认成功
* @param txHash 交易 hash0x 开头
* @param maxWaitMs 最大等待毫秒数
* @param pollIntervalMs 轮询间隔毫秒数
* @return 成功返回 Unit超时或 revert 返回 Result.failure
*/
suspend fun waitForTransactionConfirmed(
txHash: String,
maxWaitMs: Long = 120_000,
pollIntervalMs: Long = 3_000
): Result<Unit> {
val rpcApi = polygonRpcApi
val start = System.currentTimeMillis()
while (System.currentTimeMillis() - start < maxWaitMs) {
val req = JsonRpcRequest(method = "eth_getTransactionReceipt", params = listOf(txHash))
val response = rpcApi.call(req)
if (!response.isSuccessful || response.body() == null) {
delay(pollIntervalMs)
continue
}
val body = response.body()!!
if (body.error != null) {
delay(pollIntervalMs)
continue
}
val result = body.result
if (result == null || result.isJsonNull) {
delay(pollIntervalMs)
continue
}
val status = result.asJsonObject?.get("status")?.asString
if (status == null) {
delay(pollIntervalMs)
continue
}
return when (status) {
"0x1" -> Result.success(Unit)
"0x0" -> Result.failure(Exception("交易已上链但执行失败 (revert)"))
else -> Result.failure(Exception("交易状态异常: $status"))
}
}
return Result.failure(Exception("等待交易确认超时 (${maxWaitMs}ms)"))
}
/**
* 查询代理地址的 WCOLWrapped Collateral余额raw6 位小数
*/
suspend fun getWcolBalance(proxyAddress: String): Result<BigInteger> {
val rpcApi = polygonRpcApi
val functionSelector = "0x70a08231" // balanceOf(address)
val paddedAddress = proxyAddress.removePrefix("0x").lowercase().padStart(64, '0')
val data = functionSelector + paddedAddress
val rpcRequest = JsonRpcRequest(
method = "eth_call",
params = listOf(
mapOf(
"to" to wcolContractAddress,
"data" to data
),
"latest"
)
)
val response = rpcApi.call(rpcRequest)
if (!response.isSuccessful || response.body() == null) {
return Result.failure(Exception("查询 WCOL 余额失败: ${response.code()} ${response.message()}"))
}
val rpcResponse = response.body()!!
if (rpcResponse.error != null) {
return Result.failure(Exception("查询 WCOL 余额失败: ${rpcResponse.error.message}"))
}
val hexBalance = rpcResponse.result?.asString ?: return Result.failure(Exception("WCOL 余额结果为空"))
val balance = EthereumUtils.decodeUint256(hexBalance)
return Result.success(balance)
}
/**
* 将代理钱包内的 WCOL 执行解包解包后转入代理地址
* 赎回 Neg Risk 仓位后到账为 WCOL调用此方法可执行解包后续资产处理
*
* Safe Magic 使用同一套逻辑同一 [createUnwrapWcolTx] + [RelayClientService.execute]
* Safe execTransactionMagic PROXY 编码最终均为代理合约调用 WCOL.unwrap(proxyAddress, amount)
*
* @param privateKey 主钱包私钥
* @param proxyAddress 代理地址Safe Magic 代理
* @param walletType 钱包类型SAFE / MAGIC用于选择 Relayer 执行路径
* @return 成功返回交易 hash余额为 0 返回 null失败返回 Result.failure
*/
suspend fun unwrapWcolForProxy(
privateKey: String,
proxyAddress: String,
walletType: WalletType
): Result<String?> {
return try {
val balanceResult = getWcolBalance(proxyAddress)
val balance = balanceResult.getOrElse {
logger.warn("查询 WCOL 余额失败,跳过解包: ${it.message}")
return Result.success(null)
}
if (balance == BigInteger.ZERO) {
return Result.success(null)
}
val unwrapTx = relayClientService.createUnwrapWcolTx(proxyAddress, balance)
val executeResult = relayClientService.execute(privateKey, proxyAddress, unwrapTx, walletType)
executeResult.fold(
onSuccess = { txHash ->
logger.info("WCOL 解包成功: proxy=${proxyAddress.take(10)}..., txHash=$txHash")
Result.success(txHash)
},
onFailure = { e ->
logger.error("WCOL 解包失败: ${e.message}", e)
Result.failure(e)
}
)
} catch (e: Exception) {
logger.error("WCOL 解包异常: ${e.message}", e)
Result.failure(e)
}
}
// USDC.e 合约地址(仅用于 wrap 查询)
private val usdceContractAddress = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
/**
* 查询 USDC.e 余额用于 wrap 前检查
*/
suspend fun queryUsdceBalance(walletAddress: String): Result<BigDecimal> {
return try {
val rpcApi = polygonRpcApi
val functionSelector = "0x70a08231"
val paddedAddress = walletAddress.removePrefix("0x").lowercase().padStart(64, '0')
val data = functionSelector + paddedAddress
val rpcRequest = JsonRpcRequest(
method = "eth_call",
params = listOf(mapOf("to" to usdceContractAddress, "data" to data), "latest")
)
val response = rpcApi.call(rpcRequest)
if (!response.isSuccessful || response.body() == null) {
return Result.failure(Exception("RPC 请求失败"))
}
val hexBalance = response.body()!!.result?.asString ?: return Result.failure(Exception("result 为空"))
val balanceWei = BigInteger(hexBalance.removePrefix("0x"), 16)
Result.success(BigDecimal(balanceWei).divide(BigDecimal("1000000")))
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* USDC.e wrap pUSD
* 步骤1) 检查 USDC.e 余额 2) approve CollateralOnramp 3) wrap
*/
suspend fun wrapUsdcToPusd(
privateKey: String,
proxyAddress: String,
walletType: WalletType
): Result<String?> {
return try {
val balanceResult = queryUsdceBalance(proxyAddress)
val balance = balanceResult.getOrElse {
logger.warn("查询 USDC.e 余额失败: ${it.message}")
return Result.failure(it)
}
if (balance <= BigDecimal.ZERO) {
return Result.success(null)
}
val wrapAmountWei = balance.movePointRight(6).toBigInteger()
logger.info("开始 wrap USDC.e → pUSD: proxy=${proxyAddress.take(10)}..., amount=$balance")
val unlimitedAllowance = BigInteger.valueOf(2).pow(256).minus(BigInteger.ONE)
val approveTx = relayClientService.createUsdceApproveForWrapTx(unlimitedAllowance)
val wrapTx = relayClientService.createWrapToPusdTx(proxyAddress, wrapAmountWei)
if (walletType == WalletType.MAGIC) {
// MAGIC 账户走 PROXY 时,不使用 Safe MultiSend(delegatecall)
// 改为顺序执行两笔 CALL,避免内层 delegatecall 回滚导致“外层成功但业务失败”。
val approveResult = relayClientService.execute(privateKey, proxyAddress, approveTx, walletType)
val approveHash = approveResult.getOrElse {
logger.error("USDC.e approve 失败: ${it.message}", it)
return Result.failure(it)
}
logger.info("USDC.e approve 成功: txHash=$approveHash")
val wrapResult = relayClientService.execute(privateKey, proxyAddress, wrapTx, walletType)
wrapResult.fold(
onSuccess = { txHash ->
logger.info("USDC.e → pUSD wrap 成功: txHash=$txHash")
Result.success(txHash)
},
onFailure = { e ->
logger.error("USDC.e → pUSD wrap 失败: ${e.message}", e)
Result.failure(e)
}
)
} else {
val safeTx = relayClientService.createMultiSendTx(listOf(approveTx, wrapTx))
val executeResult = relayClientService.execute(privateKey, proxyAddress, safeTx, walletType)
executeResult.fold(
onSuccess = { txHash ->
logger.info("USDC.e → pUSD wrap 成功: txHash=$txHash")
Result.success(txHash)
},
onFailure = { e ->
logger.error("USDC.e → pUSD wrap 失败: ${e.message}", e)
Result.failure(e)
}
)
}
} catch (e: Exception) {
logger.error("USDC.e → pUSD wrap 异常: ${e.message}", e)
Result.failure(e)
}
}
/**
* 获取代理钱包的 nonce用于构建 Safe 交易
*/
@@ -223,15 +223,7 @@ class PolymarketClobService(
}
}
/**
* 创建订单已废弃使用 createSignedOrder 代替
* @deprecated 使用 createSignedOrder 代替需要签名的订单对象
*/
@Deprecated("使用 createSignedOrder 代替")
suspend fun createOrder(request: CreateOrderRequest): Result<OrderResponse> {
return Result.failure(UnsupportedOperationException("已废弃,请使用 createSignedOrder 方法"))
}
/**
* 创建签名的订单
* 注意此方法需要完整的订单签名逻辑当前为占位实现
@@ -401,7 +393,7 @@ class PolymarketClobService(
Result.failure(e)
}
}
/**
* 获取费率
* 文档: https://docs.polymarket.com/developers/market-makers/maker-rebates-program#1-fetch-the-fee-rate
@@ -1,11 +1,13 @@
package com.wrbug.polymarketbot.service.common
import com.wrbug.polymarketbot.dto.CryptoTailMonitorPushData
import com.wrbug.polymarketbot.dto.OrderPushMessage
import com.wrbug.polymarketbot.dto.PositionPushMessage
import com.wrbug.polymarketbot.dto.WebSocketMessage as WsMessage
import com.wrbug.polymarketbot.dto.WebSocketMessageType
import com.wrbug.polymarketbot.service.accounts.PositionPushService
import com.wrbug.polymarketbot.service.copytrading.orders.OrderPushService
import com.wrbug.polymarketbot.service.cryptotail.CryptoTailMonitorService
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
@@ -38,28 +40,47 @@ class WebSocketSubscriptionService(
// 存储 order 频道的订阅回调:sessionId -> callback(用于取消订阅)
private val orderChannelCallbacks = ConcurrentHashMap<String, (OrderPushMessage) -> Unit>()
// 存储加密价差策略监控频道的订阅回调:sessionId -> (strategyId -> callback)
private val monitorChannelCallbacks = ConcurrentHashMap<String, MutableMap<Long, (CryptoTailMonitorPushData) -> Unit>>()
// 加密价差策略监控服务(延迟注入,避免循环依赖)
private var cryptoTailMonitorService: CryptoTailMonitorService? = null
/**
* 设置加密价差策略监控服务 Spring 在初始化后调用
*/
fun setCryptoTailMonitorService(service: CryptoTailMonitorService) {
cryptoTailMonitorService = service
}
/**
* 注册会话
*/
fun registerSession(sessionId: String, callback: (WsMessage) -> Unit) {
sessionCallbacks[sessionId] = callback
sessionSubscriptions[sessionId] = mutableSetOf()
monitorChannelCallbacks[sessionId] = mutableMapOf()
}
/**
* 注销会话
*/
fun unregisterSession(sessionId: String) {
// 取消所有订阅
val channels = sessionSubscriptions.remove(sessionId) ?: emptySet()
channels.forEach { channel ->
unsubscribe(sessionId, channel)
}
// 清理 order 频道的回调
orderChannelCallbacks.remove(sessionId)
// 清理加密价差策略监控频道的回调
val monitorCallbacks = monitorChannelCallbacks.remove(sessionId)
monitorCallbacks?.keys?.forEach { strategyId ->
cryptoTailMonitorService?.unsubscribe(sessionId, strategyId)
}
sessionCallbacks.remove(sessionId)
}
@@ -83,8 +104,8 @@ class WebSocketSubscriptionService(
sendSubscribeAck(sessionId, channel, true)
// 根据频道类型启动推送服务
when (channel) {
"position" -> {
when {
channel == "position" -> {
positionPushService.subscribe(sessionId) { message ->
pushData(sessionId, channel, message)
}
@@ -97,7 +118,7 @@ class WebSocketSubscriptionService(
}
}
}
"order" -> {
channel == "order" -> {
// 订单推送:自动订阅所有启用的账户
val callback: (OrderPushMessage) -> Unit = { message ->
pushData(sessionId, channel, message)
@@ -105,6 +126,20 @@ class WebSocketSubscriptionService(
orderChannelCallbacks[sessionId] = callback
orderPushService.subscribeAllEnabled(callback)
}
channel.startsWith("crypto_tail_monitor_") -> {
// 加密价差策略监控频道
val strategyId = channel.removePrefix("crypto_tail_monitor_").toLongOrNull()
if (strategyId != null && cryptoTailMonitorService != null) {
val callback: (CryptoTailMonitorPushData) -> Unit = { message ->
pushData(sessionId, channel, message)
}
monitorChannelCallbacks.getOrPut(sessionId) { mutableMapOf() }[strategyId] = callback
cryptoTailMonitorService!!.subscribe(sessionId, strategyId, callback)
} else {
logger.warn("无效的加密价差策略监控频道或服务未初始化: $channel")
sendSubscribeAck(sessionId, channel, false, "无效的策略ID")
}
}
else -> {
logger.warn("未知的频道: $channel")
sendSubscribeAck(sessionId, channel, false, "未知的频道")
@@ -122,15 +157,58 @@ class WebSocketSubscriptionService(
channelSubscriptions[channel]?.remove(sessionId)
// 取消推送服务的订阅(推送服务内部会处理是否停止轮询)
when (channel) {
"position" -> positionPushService.unsubscribe(sessionId)
"order" -> {
when {
channel == "position" -> positionPushService.unsubscribe(sessionId)
channel == "order" -> {
// 取消订阅所有账户的订单推送
val callback = orderChannelCallbacks.remove(sessionId)
if (callback != null) {
orderPushService.unsubscribeAll(callback)
}
}
channel.startsWith("crypto_tail_monitor_") -> {
// 取消加密价差策略监控订阅
val strategyId = channel.removePrefix("crypto_tail_monitor_").toLongOrNull()
if (strategyId != null) {
monitorChannelCallbacks[sessionId]?.remove(strategyId)
cryptoTailMonitorService?.unsubscribe(sessionId, strategyId)
}
}
}
}
/**
* 注册加密价差策略监控回调 CryptoTailMonitorService 调用
*/
fun registerMonitorCallback(sessionId: String, strategyId: Long, callback: (CryptoTailMonitorPushData) -> Unit) {
monitorChannelCallbacks.getOrPut(sessionId) { mutableMapOf() }[strategyId] = callback
}
/**
* 注销加密价差策略监控回调 CryptoTailMonitorService 调用
*/
fun unregisterMonitorCallback(sessionId: String, strategyId: Long) {
monitorChannelCallbacks[sessionId]?.remove(strategyId)
}
/**
* 推送加密价差策略监控数据 CryptoTailMonitorService 调用
*/
fun pushMonitorData(strategyId: Long, data: CryptoTailMonitorPushData) {
val channel = "crypto_tail_monitor_$strategyId"
val sessionIds = channelSubscriptions[channel] ?: return
for (sessionId in sessionIds) {
val callback = sessionCallbacks[sessionId]
if (callback != null) {
val message = WsMessage(
type = WebSocketMessageType.DATA.value,
channel = channel,
payload = data,
timestamp = System.currentTimeMillis()
)
callback(message)
}
}
}
@@ -168,4 +246,3 @@ class WebSocketSubscriptionService(
}
}
}
@@ -0,0 +1,87 @@
package com.wrbug.polymarketbot.service.copytrading.configs
import com.wrbug.polymarketbot.dto.ApplyConservativeConfigRequest
import com.wrbug.polymarketbot.entity.CopyTrading
import java.math.BigDecimal
object CopyTradingSafetyConfigService {
private val MAX_DAILY_LOSS_LIMIT = BigDecimal("10")
private val MAX_POSITION_VALUE_LIMIT = BigDecimal("10")
private val MIN_ORDER_DEPTH_LIMIT = BigDecimal("100")
private val MAX_SPREAD_LIMIT = BigDecimal("0.03")
private val PRICE_TOLERANCE_LIMIT = BigDecimal("3")
fun applyConservativeConfig(
current: CopyTrading,
request: ApplyConservativeConfigRequest
): CopyTrading {
if (!request.confirm) {
throw IllegalStateException("应用保守配置需要显式确认")
}
val maxDailyOrders = request.maxDailyOrders?.also {
if (it !in 1..20) {
throw IllegalArgumentException("maxDailyOrders 必须在 1 到 20 之间")
}
} ?: current.maxDailyOrders
val maxDailyLoss = request.maxDailyLoss?.asPositiveDecimalAtMost("maxDailyLoss", MAX_DAILY_LOSS_LIMIT)
?: current.maxDailyLoss
val minPrice = request.minPrice?.asPrice("minPrice") ?: current.minPrice
val maxPrice = request.maxPrice?.asPrice("maxPrice") ?: current.maxPrice
if (minPrice != null && maxPrice != null && minPrice > maxPrice) {
throw IllegalArgumentException("minPrice 不能大于 maxPrice")
}
return current.copy(
maxDailyOrders = maxDailyOrders,
maxDailyLoss = maxDailyLoss,
minPrice = minPrice,
maxPrice = maxPrice,
maxPositionValue = request.maxPositionValue?.asPositiveDecimalAtMost("maxPositionValue", MAX_POSITION_VALUE_LIMIT)
?: current.maxPositionValue,
minOrderDepth = request.minOrderDepth?.asPositiveDecimalAtLeast("minOrderDepth", MIN_ORDER_DEPTH_LIMIT)
?: current.minOrderDepth,
maxSpread = request.maxSpread?.asPositiveDecimalAtMost("maxSpread", MAX_SPREAD_LIMIT)
?: current.maxSpread,
priceTolerance = request.priceTolerance?.asPositiveDecimalAtMost("priceTolerance", PRICE_TOLERANCE_LIMIT)
?: current.priceTolerance,
updatedAt = System.currentTimeMillis()
)
}
private fun String.asPositiveDecimal(field: String): BigDecimal {
val value = trim().toBigDecimalOrNull()
?: throw IllegalArgumentException("$field 必须是有效数字")
if (value <= BigDecimal.ZERO) {
throw IllegalArgumentException("$field 必须大于 0")
}
return value
}
private fun String.asPositiveDecimalAtMost(field: String, max: BigDecimal): BigDecimal {
val value = asPositiveDecimal(field)
if (value > max) {
throw IllegalArgumentException("$field 必须大于 0 且不超过 ${max.strip()}")
}
return value
}
private fun String.asPositiveDecimalAtLeast(field: String, min: BigDecimal): BigDecimal {
val value = asPositiveDecimal(field)
if (value < min) {
throw IllegalArgumentException("$field 必须不小于 ${min.strip()}")
}
return value
}
private fun String.asPrice(field: String): BigDecimal {
val value = asPositiveDecimal(field)
if (value > BigDecimal.ONE) {
throw IllegalArgumentException("$field 必须在 0 到 1 之间")
}
return value
}
private fun BigDecimal.strip(): String = stripTrailingZeros().toPlainString()
}
@@ -328,6 +328,36 @@ class CopyTradingService(
Result.failure(e)
}
}
@Transactional
fun applyConservativeConfig(request: ApplyConservativeConfigRequest): Result<CopyTradingDto> {
return try {
val copyTrading = copyTradingRepository.findById(request.copyTradingId).orElse(null)
?: return Result.failure(IllegalArgumentException("跟单配置不存在"))
val updated = CopyTradingSafetyConfigService.applyConservativeConfig(copyTrading, request)
val saved = copyTradingRepository.save(updated)
kotlinx.coroutines.runBlocking {
try {
monitorService.updateLeaderMonitoring(saved.leaderId)
monitorService.updateAccountMonitoring(saved.accountId)
} catch (e: Exception) {
logger.error("更新监听失败", e)
}
}
val account = accountRepository.findById(saved.accountId).orElse(null)
val leader = leaderRepository.findById(saved.leaderId).orElse(null)
if (account == null || leader == null) {
return Result.failure(IllegalStateException("跟单配置数据不完整"))
}
Result.success(toDto(saved, account, leader))
} catch (e: Exception) {
logger.error("应用保守配置失败", e)
Result.failure(e)
}
}
/**
* 更新跟单状态兼容旧接口
@@ -0,0 +1,13 @@
package com.wrbug.polymarketbot.service.copytrading.leaderpool
class LeaderPoolNotFoundException(message: String = "Leader 池项不存在") : RuntimeException(message)
class LeaderPoolAlreadyExistsException(message: String = "Leader 已在池子中") : RuntimeException(message)
class LeaderPoolDuplicateTrialConfigException(message: String = "该账户已存在此 Leader 的跟单配置") : RuntimeException(message)
class LeaderPoolConfirmRequiredException(message: String = "立即启用试跟配置需要显式确认") : RuntimeException(message)
class LeaderPoolResearchCandidateNotReadyException(
message: String = "研究候选尚未进入试跟建议状态"
) : RuntimeException(message)
@@ -0,0 +1,451 @@
package com.wrbug.polymarketbot.service.copytrading.leaderpool
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.entity.CopyTrading
import com.wrbug.polymarketbot.entity.Leader
import com.wrbug.polymarketbot.entity.LeaderPool
import com.wrbug.polymarketbot.enums.LeaderPoolStatus
import com.wrbug.polymarketbot.enums.LeaderResearchState
import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.repository.CopyTradingRepository
import com.wrbug.polymarketbot.repository.LeaderPoolRepository
import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.service.copytrading.configs.CopyTradingService
import org.slf4j.LoggerFactory
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
@Service
class LeaderPoolService(
private val leaderPoolRepository: LeaderPoolRepository,
private val leaderRepository: LeaderRepository,
private val copyTradingRepository: CopyTradingRepository,
private val accountRepository: AccountRepository,
private val copyTradingService: CopyTradingService
) {
private val logger = LoggerFactory.getLogger(LeaderPoolService::class.java)
@Transactional
open fun addToPool(request: LeaderPoolAddRequest): Result<LeaderPoolItemDto> {
return try {
val leader = leaderRepository.findById(request.leaderId).orElse(null)
if (leader == null) {
logger.warn("拒绝加入 Leader 池,Leader 不存在: leaderId={}", request.leaderId)
return Result.failure(IllegalArgumentException("Leader 不存在"))
}
leaderPoolRepository.findByLeaderId(request.leaderId)?.let {
logger.warn("Leader 已在池子中: leaderId={}, poolId={}", request.leaderId, it.id)
return Result.failure(LeaderPoolAlreadyExistsException())
}
val now = System.currentTimeMillis()
val pool = LeaderPool(
leaderId = request.leaderId,
source = request.source?.trim().takeUnless { it.isNullOrBlank() } ?: "MANUAL",
reason = request.reason?.trim().takeUnless { it.isNullOrBlank() },
notes = request.notes?.trim().takeUnless { it.isNullOrBlank() },
createdAt = now,
updatedAt = now
)
val saved = try {
leaderPoolRepository.saveAndFlush(pool)
} catch (e: DataIntegrityViolationException) {
logger.warn("并发重复加入 Leader 池: leaderId={}", request.leaderId, e)
return Result.failure(LeaderPoolAlreadyExistsException())
}
logger.info("Leader 加入池子: leaderId={}, poolId={}, status={}", saved.leaderId, saved.id, saved.status)
Result.success(toDto(saved, leader, emptyList()))
} catch (e: Exception) {
logger.error("加入 Leader 池失败: leaderId=${request.leaderId}", e)
Result.failure(e)
}
}
open fun getPoolList(request: LeaderPoolListRequest): Result<LeaderPoolListResponse> {
return try {
val status = request.status?.trim().takeUnless { it.isNullOrBlank() }?.let { parseStatus(it) }
val pools = if (status != null) {
leaderPoolRepository.findByStatus(status).sortedByDescending { it.createdAt }
} else {
leaderPoolRepository.findAllByOrderByCreatedAtDesc()
}
val leaderIds = pools.map { it.leaderId }.distinct()
val leaders = if (leaderIds.isEmpty()) {
emptyMap()
} else {
leaderRepository.findAllById(leaderIds).associateBy { it.id!! }
}
val copyTradingsByLeader = if (leaderIds.isEmpty()) {
emptyMap()
} else {
copyTradingRepository.findByLeaderIdIn(leaderIds).groupBy { it.leaderId }
}
val items = pools.mapNotNull { pool ->
val leader = leaders[pool.leaderId]
if (leader == null) {
logger.warn("Leader 池项缺少 leader 记录: poolId={}, leaderId={}", pool.id, pool.leaderId)
null
} else {
toDto(pool, leader, copyTradingsByLeader[pool.leaderId].orEmpty())
}
}
val estimatedWorstExposure = items.fold(BigDecimal.ZERO) { acc, item ->
acc + BigDecimal(item.estimatedWorstExposure)
}
val summary = LeaderPoolSummaryDto(
totalCount = items.size,
trialCount = items.count { it.status == LeaderPoolStatus.TRIAL.name || it.status == LeaderPoolStatus.ACTIVE.name },
estimatedWorstExposure = estimatedWorstExposure.strip(),
pendingRiskCount = items.count { it.status == LeaderPoolStatus.COOLDOWN.name || it.hasEnabledCopyTrading },
)
Result.success(
LeaderPoolListResponse(
summary = summary,
list = items,
total = items.size
)
)
} catch (e: Exception) {
logger.error("查询 Leader 池列表失败", e)
Result.failure(e)
}
}
@Transactional
open fun updateStatus(request: LeaderPoolUpdateStatusRequest): Result<LeaderPoolItemDto> {
return try {
val pool = findPool(request.poolId)
val leader = findLeader(pool.leaderId)
val newStatus = parseStatus(request.status)
val now = System.currentTimeMillis()
val updated = pool.copy(
status = newStatus,
cooldownUntil = if (newStatus == LeaderPoolStatus.COOLDOWN) request.cooldownUntil else request.cooldownUntil ?: pool.cooldownUntil,
locked = request.locked ?: pool.locked,
lastReviewedAt = now,
updatedAt = now
)
val saved = leaderPoolRepository.save(updated)
logger.info(
"Leader 池状态变化: poolId={}, leaderId={}, status={}, cooldownUntil={}",
saved.id,
saved.leaderId,
saved.status,
saved.cooldownUntil
)
Result.success(toDto(saved, leader, copyTradingRepository.findByLeaderId(saved.leaderId)))
} catch (e: Exception) {
logger.error("更新 Leader 池状态失败: poolId=${request.poolId}", e)
Result.failure(e)
}
}
@Transactional
open fun updatePlan(request: LeaderPoolUpdatePlanRequest): Result<LeaderPoolItemDto> {
return try {
val pool = findPool(request.poolId)
val leader = findLeader(pool.leaderId)
val nextFixedAmount = parseDecimal("suggestedFixedAmount", request.suggestedFixedAmount) ?: pool.suggestedFixedAmount
val nextMaxDailyOrders = request.suggestedMaxDailyOrders ?: pool.suggestedMaxDailyOrders
val nextMaxDailyLoss = parseDecimal("suggestedMaxDailyLoss", request.suggestedMaxDailyLoss) ?: pool.suggestedMaxDailyLoss
val nextMinPrice = parseNullableDecimal("suggestedMinPrice", request.suggestedMinPrice, pool.suggestedMinPrice)
val nextMaxPrice = parseNullableDecimal("suggestedMaxPrice", request.suggestedMaxPrice, pool.suggestedMaxPrice)
val nextMaxPositionValue = parseNullableDecimal(
"suggestedMaxPositionValue",
request.suggestedMaxPositionValue,
pool.suggestedMaxPositionValue
)
validateSuggestedPlan(
fixedAmount = nextFixedAmount,
maxDailyOrders = nextMaxDailyOrders,
maxDailyLoss = nextMaxDailyLoss,
minPrice = nextMinPrice,
maxPrice = nextMaxPrice,
maxPositionValue = nextMaxPositionValue
)
val now = System.currentTimeMillis()
val updated = pool.copy(
suggestedFixedAmount = nextFixedAmount,
suggestedMaxDailyOrders = nextMaxDailyOrders,
suggestedMaxDailyLoss = nextMaxDailyLoss,
suggestedMinPrice = nextMinPrice,
suggestedMaxPrice = nextMaxPrice,
suggestedMaxPositionValue = nextMaxPositionValue,
reason = request.reason?.trim().takeUnless { it.isNullOrBlank() } ?: pool.reason,
notes = request.notes?.trim().takeUnless { it.isNullOrBlank() } ?: pool.notes,
lastReviewedAt = now,
updatedAt = now
)
val saved = leaderPoolRepository.save(updated)
logger.info("Leader 池建议配置更新: poolId={}, leaderId={}", saved.id, saved.leaderId)
Result.success(toDto(saved, leader, copyTradingRepository.findByLeaderId(saved.leaderId)))
} catch (e: Exception) {
logger.error("更新 Leader 池建议配置失败: poolId=${request.poolId}", e)
Result.failure(e)
}
}
@Transactional
open fun createTrialConfig(request: LeaderPoolCreateTrialConfigRequest): Result<CopyTradingDto> {
val pool = try {
findPool(request.poolId)
} catch (e: Exception) {
logger.error("创建 Leader 池试跟配置失败: poolId=${request.poolId}", e)
return Result.failure(e)
}
return try {
if (pool.researchCandidateId != null && pool.researchState != LeaderResearchState.TRIAL_READY) {
logger.warn(
"拒绝从未试跟建议的研究候选创建 Leader 池试跟配置: poolId={}, leaderId={}, researchState={}",
pool.id,
pool.leaderId,
pool.researchState
)
return Result.failure(LeaderPoolResearchCandidateNotReadyException())
}
if (request.enableImmediately && !request.confirm) {
logger.warn("拒绝未确认的立即启用试跟配置: poolId={}, leaderId={}", pool.id, pool.leaderId)
return Result.failure(LeaderPoolConfirmRequiredException())
}
val account = accountRepository.findById(request.accountId).orElse(null)
if (account == null) {
logger.warn(
"拒绝创建 Leader 池试跟配置,账户不存在: poolId={}, leaderId={}, accountId={}",
pool.id,
pool.leaderId,
request.accountId
)
return Result.failure(IllegalArgumentException("账户不存在"))
}
val leader = findLeader(pool.leaderId)
validateSuggestedPlan(
fixedAmount = pool.suggestedFixedAmount,
maxDailyOrders = pool.suggestedMaxDailyOrders,
maxDailyLoss = pool.suggestedMaxDailyLoss,
minPrice = pool.suggestedMinPrice,
maxPrice = pool.suggestedMaxPrice,
maxPositionValue = pool.suggestedMaxPositionValue
)
if (copyTradingRepository.findByAccountIdAndLeaderId(request.accountId, pool.leaderId).isNotEmpty()) {
logger.warn(
"拒绝重复创建 Leader 池试跟配置: poolId={}, leaderId={}, accountId={}",
pool.id,
pool.leaderId,
request.accountId
)
return Result.failure(LeaderPoolDuplicateTrialConfigException())
}
val copyTradingRequest = CopyTradingCreateRequest(
accountId = request.accountId,
leaderId = pool.leaderId,
enabled = request.enableImmediately && request.confirm,
copyMode = "FIXED",
copyRatio = "1",
fixedAmount = pool.suggestedFixedAmount.strip(),
maxOrderSize = pool.suggestedFixedAmount.strip(),
minOrderSize = "1",
maxDailyLoss = pool.suggestedMaxDailyLoss.strip(),
maxDailyOrders = pool.suggestedMaxDailyOrders,
priceTolerance = "1",
delaySeconds = 0,
pollIntervalSeconds = 5,
useWebSocket = true,
websocketReconnectInterval = 5000,
websocketMaxRetries = 10,
supportSell = true,
minPrice = pool.suggestedMinPrice?.strip(),
maxPrice = pool.suggestedMaxPrice?.strip(),
maxPositionValue = pool.suggestedMaxPositionValue?.strip(),
keywordFilterMode = "DISABLED",
keywords = null,
configName = buildTrialConfigName(leader),
pushFailedOrders = true,
pushFilteredOrders = true
)
val result = copyTradingService.createCopyTrading(copyTradingRequest)
result.onSuccess {
val now = System.currentTimeMillis()
leaderPoolRepository.save(
pool.copy(
status = LeaderPoolStatus.TRIAL,
lastPromotedAt = now,
lastReviewedAt = now,
updatedAt = now
)
)
logger.info(
"Leader 池试跟配置创建成功: poolId={}, leaderId={}, accountId={}, copyTradingId={}",
pool.id,
pool.leaderId,
request.accountId,
it.id
)
}.onFailure {
logger.error(
"Leader 池试跟配置创建失败: poolId=${pool.id}, leaderId=${pool.leaderId}, accountId=${request.accountId}, error=${it.message}",
it
)
}
} catch (e: Exception) {
logger.error(
"创建 Leader 池试跟配置异常: poolId=${pool.id}, leaderId=${pool.leaderId}, accountId=${request.accountId}",
e
)
Result.failure(e)
}
}
@Transactional
open fun remove(request: LeaderPoolRemoveRequest): Result<Unit> {
return try {
val pool = findPool(request.poolId)
leaderPoolRepository.delete(pool)
logger.info("Leader 池项移除: poolId={}, leaderId={}", pool.id, pool.leaderId)
Result.success(Unit)
} catch (e: Exception) {
logger.error("移除 Leader 池项失败: poolId=${request.poolId}", e)
Result.failure(e)
}
}
private fun findPool(poolId: Long): LeaderPool {
return leaderPoolRepository.findById(poolId).orElse(null) ?: throw LeaderPoolNotFoundException()
}
private fun findLeader(leaderId: Long): Leader {
return leaderRepository.findById(leaderId).orElse(null) ?: throw IllegalArgumentException("Leader 不存在")
}
private fun parseStatus(status: String): LeaderPoolStatus {
return try {
LeaderPoolStatus.valueOf(status.trim().uppercase())
} catch (e: Exception) {
throw IllegalArgumentException("Leader 池状态无效")
}
}
private fun parseDecimal(fieldName: String, value: String?): BigDecimal? {
if (value == null) return null
return value.trim().takeUnless { it.isBlank() }?.toBigDecimalOrNull()
?: throw IllegalArgumentException("$fieldName 必须是有效数字")
}
private fun parseNullableDecimal(fieldName: String, value: String?, current: BigDecimal?): BigDecimal? {
if (value == null) return current
val trimmed = value.trim()
if (trimmed.isBlank()) return null
return trimmed.toBigDecimalOrNull() ?: throw IllegalArgumentException("$fieldName 必须是有效数字")
}
private fun validateSuggestedPlan(
fixedAmount: BigDecimal,
maxDailyOrders: Int,
maxDailyLoss: BigDecimal,
minPrice: BigDecimal?,
maxPrice: BigDecimal?,
maxPositionValue: BigDecimal?
) {
if (fixedAmount <= BigDecimal.ZERO) {
throw IllegalArgumentException("suggestedFixedAmount 必须大于 0")
}
if (maxDailyOrders !in 1..100) {
throw IllegalArgumentException("suggestedMaxDailyOrders 必须在 1 到 100 之间")
}
if (maxDailyLoss <= BigDecimal.ZERO) {
throw IllegalArgumentException("suggestedMaxDailyLoss 必须大于 0")
}
minPrice?.let {
if (it < BigDecimal.ZERO || it > BigDecimal.ONE) {
throw IllegalArgumentException("suggestedMinPrice 必须在 0 到 1 之间")
}
}
maxPrice?.let {
if (it < BigDecimal.ZERO || it > BigDecimal.ONE) {
throw IllegalArgumentException("suggestedMaxPrice 必须在 0 到 1 之间")
}
}
if (minPrice != null && maxPrice != null && minPrice > maxPrice) {
throw IllegalArgumentException("suggestedMinPrice 不能大于 suggestedMaxPrice")
}
maxPositionValue?.let {
if (it <= BigDecimal.ZERO) {
throw IllegalArgumentException("suggestedMaxPositionValue 必须大于 0")
}
}
}
private fun toDto(pool: LeaderPool, leader: Leader, copyTradings: List<CopyTrading>): LeaderPoolItemDto {
val isTrialOrActive = pool.status == LeaderPoolStatus.TRIAL || pool.status == LeaderPoolStatus.ACTIVE
val estimatedWorstExposure = if (isTrialOrActive) {
pool.suggestedMaxPositionValue ?: DEFAULT_MAX_POSITION_VALUE
} else {
BigDecimal.ZERO
}
val leaderAddress = leader.leaderAddress
return LeaderPoolItemDto(
id = pool.id!!,
leaderId = pool.leaderId,
leaderName = leader.leaderName,
leaderAddress = leaderAddress,
category = leader.category,
profileUrl = "https://polymarket.com/profile/$leaderAddress",
status = pool.status.name,
source = pool.source,
sourceRank = pool.sourceRank,
score = pool.score?.strip(),
reason = pool.reason,
notes = pool.notes,
suggestedFixedAmount = pool.suggestedFixedAmount.strip(),
suggestedMaxDailyOrders = pool.suggestedMaxDailyOrders,
suggestedMaxDailyLoss = pool.suggestedMaxDailyLoss.strip(),
suggestedMinPrice = pool.suggestedMinPrice?.strip(),
suggestedMaxPrice = pool.suggestedMaxPrice?.strip(),
suggestedMaxPositionValue = pool.suggestedMaxPositionValue?.strip(),
copyTradingCount = copyTradings.size,
hasEnabledCopyTrading = copyTradings.any { it.enabled },
estimatedWorstExposure = estimatedWorstExposure.strip(),
lastReviewedAt = pool.lastReviewedAt,
lastPromotedAt = pool.lastPromotedAt,
cooldownUntil = pool.cooldownUntil,
locked = pool.locked,
researchCandidateId = pool.researchCandidateId,
researchState = pool.researchState?.name,
researchBadge = pool.researchBadge,
researchSummary = pool.researchSummary,
researchScore = pool.researchScore?.strip(),
researchUpdatedAt = pool.researchUpdatedAt,
createdAt = pool.createdAt,
updatedAt = pool.updatedAt
)
}
private fun buildTrialConfigName(leader: Leader): String {
val baseName = leader.leaderName?.trim().takeUnless { it.isNullOrBlank() }
?: leader.leaderAddress.takeLast(6)
return "Leader池-$baseName"
}
private fun BigDecimal.strip(): String = stripTrailingZeros().toPlainString()
companion object {
private val DEFAULT_MAX_POSITION_VALUE = BigDecimal("5")
}
}
@@ -45,7 +45,9 @@ object OnChainWsUtils {
}
// 合约地址
const val USDC_CONTRACT = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
const val PUSD_CONTRACT = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB" // V2 pUSD
const val USDC_CONTRACT = PUSD_CONTRACT // 默认使用 pUSD
private val COLLATERAL_CONTRACTS = setOf(PUSD_CONTRACT.lowercase())
const val ERC1155_CONTRACT = "0x4d97dcd97ec945f40cf65f87097ace5ea0476045"
const val ERC20_TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
const val ERC1155_TRANSFER_SINGLE_TOPIC = "0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62"
@@ -96,8 +98,8 @@ object OnChainWsUtils {
val t0 = topics[0].lowercase()
val data = log.get("data")?.asString ?: "0x"
// USDC ERC20 Transfer
if (address == USDC_CONTRACT.lowercase() && t0 == ERC20_TRANSFER_TOPIC && topics.size >= 3) {
// 抵押品 ERC20 Transfer(当前仅匹配 pUSD
if (address in COLLATERAL_CONTRACTS && t0 == ERC20_TRANSFER_TOPIC && topics.size >= 3) {
val from = topicToAddress(topics[1])
val to = topicToAddress(topics[2])
val value = hexToBigInt(data)
@@ -149,6 +151,42 @@ object OnChainWsUtils {
return Pair(erc20, erc1155)
}
/**
* 通过 CLOB 交易历史获取 Leader 真实成交价用于无 USDC 转账的 ERC1155-only 场景
* 查询该 token 最近的成交记录匹配 side 方向的第一条作为成交价
* 返回 usdcRaw = price × sizeRaw
*/
private suspend fun fetchEstimatedUsdcRaw(
tokenId: String,
side: String,
sizeRaw: BigInteger,
retrofitFactory: RetrofitFactory
): BigInteger? {
return try {
val clobApi = retrofitFactory.createClobApiWithoutAuth()
val response = clobApi.getTrades(asset_id = tokenId)
if (!response.isSuccessful || response.body() == null) {
logger.warn("CLOB 交易历史查询失败: tokenId=$tokenId, code=${response.code()}")
return null
}
val trades = response.body()!!.data
val clobSide = side.lowercase()
val matchedTrade = trades.firstOrNull { it.side.equals(clobSide, ignoreCase = true) }
if (matchedTrade == null) {
logger.warn("CLOB 交易历史中未找到匹配成交: tokenId=$tokenId, side=$clobSide, totalTrades=${trades.size}")
return null
}
val price = matchedTrade.price.toBigDecimal()
val usdcRaw = price.multiply(sizeRaw.toBigDecimal())
.setScale(0, java.math.RoundingMode.DOWN).toBigInteger()
logger.debug("CLOB 交易历史估算: tokenId=$tokenId, side=$clobSide, tradePrice=${matchedTrade.price}, tradeSize=${matchedTrade.size}, sizeRaw=$sizeRaw, usdcRaw=$usdcRaw")
usdcRaw
} catch (e: Exception) {
logger.warn("获取 CLOB 交易历史失败: tokenId=$tokenId, side=$side, error=${e.message}")
null
}
}
/**
* Transfer 日志解析交易信息
*/
@@ -205,6 +243,32 @@ object OnChainWsUtils {
asset = bestOutId
sizeRaw = bestOutVal
usdcRaw = usdcIn
} else if (bestInId != null && bestInVal > BigInteger.ZERO && bestOutId == null
&& usdcOut == BigInteger.ZERO && usdcIn == BigInteger.ZERO
) {
// BUY(无 USDC: 只收到 ERC1155 token,无 USDC 流动(CLOB 内部结算等场景)
side = "BUY"
asset = bestInId
sizeRaw = bestInVal
usdcRaw = fetchEstimatedUsdcRaw(bestInId.toString(), "BUY", bestInVal, retrofitFactory)
?: run {
logger.warn("无法获取估算价格(ERC1155-only BUY: txHash=$txHash, tokenId=$bestInId")
return null
}
logger.debug("ERC1155-only BUY: txHash=$txHash, tokenId=$bestInId, sizeRaw=$sizeRaw, usdcRaw=$usdcRaw")
} else if (bestOutId != null && bestOutVal > BigInteger.ZERO && bestInId == null
&& usdcOut == BigInteger.ZERO && usdcIn == BigInteger.ZERO
) {
// SELL(无 USDC: 只发出 ERC1155 token,无 USDC 流动
side = "SELL"
asset = bestOutId
sizeRaw = bestOutVal
usdcRaw = fetchEstimatedUsdcRaw(bestOutId.toString(), "SELL", bestOutVal, retrofitFactory)
?: run {
logger.warn("无法获取估算价格(ERC1155-only SELL: txHash=$txHash, tokenId=$bestOutId")
return null
}
logger.debug("ERC1155-only SELL: txHash=$txHash, tokenId=$bestOutId, sizeRaw=$sizeRaw, usdcRaw=$usdcRaw")
} else {
// 无法判断交易方向
logger.debug("无法判断交易方向: txHash=$txHash, bestInId=$bestInId, bestInVal=$bestInVal, bestOutId=$bestOutId, bestOutVal=$bestOutVal, usdcOut=$usdcOut, usdcIn=$usdcIn")
@@ -6,7 +6,11 @@ import com.wrbug.polymarketbot.api.TradeResponse
import com.wrbug.polymarketbot.dto.ActivityTradeMessage
import com.wrbug.polymarketbot.dto.ActivityTradePayload
import com.wrbug.polymarketbot.entity.Leader
import com.wrbug.polymarketbot.enums.LeaderResearchSourceStatus
import com.wrbug.polymarketbot.enums.LeaderResearchSourceType
import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.service.copytrading.research.LeaderActivityIngestionService
import com.wrbug.polymarketbot.service.copytrading.research.LeaderResearchSourceHealthService
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
import com.wrbug.polymarketbot.util.fromJson
import com.wrbug.polymarketbot.constants.PolymarketConstants
@@ -14,6 +18,8 @@ import com.wrbug.polymarketbot.websocket.PolymarketWebSocketClient
import jakarta.annotation.PreDestroy
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.ObjectProvider
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.util.concurrent.ConcurrentHashMap
@@ -27,7 +33,11 @@ import java.util.concurrent.TimeUnit
@Service
class PolymarketActivityWsService(
private val copyOrderTrackingService: CopyOrderTrackingService,
private val leaderRepository: LeaderRepository
private val leaderRepository: LeaderRepository,
private val researchIngestionProvider: ObjectProvider<LeaderActivityIngestionService>,
private val researchSourceHealthProvider: ObjectProvider<LeaderResearchSourceHealthService>,
@Value("\${leader.research.global-capture.enabled:false}") private val researchGlobalCaptureEnabled: Boolean,
@Value("\${leader.research.global-capture.max-writes-per-minute:120}") private val researchGlobalCaptureMaxWritesPerMinute: Long
) {
private val logger = LoggerFactory.getLogger(PolymarketActivityWsService::class.java)
@@ -65,6 +75,10 @@ class PolymarketActivityWsService(
private var addressMatchMessages = 0L
private var jsonParseMessages = 0L
private var duplicateTxHashMessages = 0L
private var researchCaptureWindowMinute = 0L
private var researchCaptureWritesThisMinute = 0L
private var researchCaptureLastHealthStatus: LeaderResearchSourceStatus? = null
private var researchCaptureLastHealthWriteAt = 0L
/**
* 启动监听
@@ -314,6 +328,8 @@ class PolymarketActivityWsService(
return
}
maybeCaptureResearchActivity(message)
// 快速预检查:检查是否包含监听地址
// 绝大部分消息会在这一步被过滤掉,避免不必要的 JSON 解析
if (!containsMonitoredAddress(message)) {
@@ -390,6 +406,100 @@ class PolymarketActivityWsService(
}
}
private fun maybeCaptureResearchActivity(message: String) {
if (!researchGlobalCaptureEnabled) {
recordResearchCaptureHealth(
status = LeaderResearchSourceStatus.DISABLED,
disabledReason = "Global activity capture is disabled"
)
return
}
val currentMinute = System.currentTimeMillis() / 60_000
if (researchCaptureWindowMinute != currentMinute) {
researchCaptureWindowMinute = currentMinute
researchCaptureWritesThisMinute = 0
}
if (researchCaptureWritesThisMinute >= researchGlobalCaptureMaxWritesPerMinute) {
recordResearchCaptureHealth(
status = LeaderResearchSourceStatus.DEGRADED,
errorClass = "WriteCapReached",
errorMessage = "write capped at $researchGlobalCaptureMaxWritesPerMinute events per minute"
)
return
}
val tradeMessage = message.fromJson<ActivityTradeMessage>() ?: run {
recordResearchCaptureHealth(
status = LeaderResearchSourceStatus.FAILURE,
errorClass = "JsonParseFailure",
errorMessage = "failed to parse activity websocket message"
)
return
}
if (tradeMessage.topic != "activity" || (tradeMessage.type != "trades" && tradeMessage.type != "orders_matched")) {
return
}
val ingestionService = researchIngestionProvider.getIfAvailable() ?: return
try {
val event = ingestionService.ingestWebSocketTrade(tradeMessage)
researchCaptureWritesThisMinute++
recordResearchCaptureHealth(
status = LeaderResearchSourceStatus.SUCCESS,
candidateCount = researchCaptureWritesThisMinute.toInt(),
lastCursor = "${event.eventTime}:${event.stableEventKey}"
)
} catch (e: Exception) {
logger.warn("Research global activity capture failed: {}", e.message)
recordResearchCaptureHealth(
status = LeaderResearchSourceStatus.FAILURE,
errorClass = e::class.java.simpleName,
errorMessage = e.message
)
}
}
private fun recordResearchCaptureHealth(
status: LeaderResearchSourceStatus,
candidateCount: Int = 0,
errorClass: String? = null,
errorMessage: String? = null,
disabledReason: String? = null,
lastCursor: String? = null
) {
if (shouldThrottleResearchCaptureHealth(status)) {
return
}
try {
researchSourceHealthProvider.getIfAvailable()?.record(
sourceType = LeaderResearchSourceType.GLOBAL_ACTIVITY_CAPTURE,
status = status,
candidateCount = candidateCount,
errorClass = errorClass,
errorMessage = errorMessage,
disabledReason = disabledReason,
lastCursor = lastCursor
)
} catch (e: Exception) {
logger.warn("记录研究全局 activity 来源健康失败: status={}, error={}", status, e.message)
}
}
private fun shouldThrottleResearchCaptureHealth(status: LeaderResearchSourceStatus): Boolean {
val now = System.currentTimeMillis()
val throttleWindow = when (status) {
LeaderResearchSourceStatus.DISABLED -> RESEARCH_CAPTURE_DISABLED_HEALTH_THROTTLE_MS
LeaderResearchSourceStatus.SUCCESS -> 0L
else -> RESEARCH_CAPTURE_HEALTH_THROTTLE_MS
}
val throttle = throttleWindow > 0 &&
status == researchCaptureLastHealthStatus &&
now - researchCaptureLastHealthWriteAt < throttleWindow
if (!throttle) {
researchCaptureLastHealthStatus = status
researchCaptureLastHealthWriteAt = now
}
return throttle
}
/**
* 提取交易者地址
* 优先检查 trader.addressfallback proxyWallet
@@ -574,5 +684,9 @@ class PolymarketActivityWsService(
stop()
scope.cancel()
}
}
companion object {
private const val RESEARCH_CAPTURE_HEALTH_THROTTLE_MS = 60_000L
private const val RESEARCH_CAPTURE_DISABLED_HEALTH_THROTTLE_MS = 60L * 60L * 1000L
}
}
@@ -20,7 +20,12 @@ import com.wrbug.polymarketbot.repository.CopyTradingRepository
import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.constants.PolymarketConstants
import com.wrbug.polymarketbot.service.common.MarketService
import com.wrbug.polymarketbot.util.div
import com.wrbug.polymarketbot.util.gt
import com.wrbug.polymarketbot.util.multi
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.util.concurrent.ConcurrentHashMap
/**
@@ -426,20 +431,29 @@ class OrderPushService(
// 获取市场信息(使用 MarketService,优先从数据库/缓存获取)
val market = marketService.getMarket(conditionId ?: openOrder.market)
// 转换为 DTO
// 有成交时按公式计算实际成交价:original_size * price / size_matched,数量用 size_matched
val sizeMatched = openOrder.sizeMatched.toSafeBigDecimal()
val avgFilledPrice = if (sizeMatched.gt(BigDecimal.ZERO)) {
openOrder.originalSize.toSafeBigDecimal()
.multi(openOrder.price)
.div(sizeMatched, 18)
} else null
// 转换为 DTO(展示数量用 size_matched
// 注意:createdAt 是 unix timestamp (Long),需要转换为字符串
OrderDetailDto(
id = openOrder.id,
market = openOrder.market,
side = openOrder.side,
price = openOrder.price,
size = openOrder.originalSize, // 使用 original_size
filled = openOrder.sizeMatched, // 使用 size_matched
size = openOrder.originalSize,
filled = openOrder.sizeMatched, // 已成交数量用 size_matched
status = openOrder.status,
createdAt = openOrder.createdAt.toString(), // unix timestamp 转换为字符串
marketName = market?.title,
marketSlug = market?.slug, // 显示用的 slug
marketIcon = market?.icon
marketIcon = market?.icon,
avgFilledPrice = avgFilledPrice?.toPlainString() // 实际成交价 = original_size*price/size_matched
)
},
onFailure = { e ->
@@ -41,10 +41,9 @@ class OrderSigningService {
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"
// V2 合约地址
private val EXCHANGE_CONTRACT = "0xE111180000d2663C0091e4f400237545B87B996B"
private val NEG_RISK_EXCHANGE_CONTRACT = "0xe2222d279d744050d28e00520010520000310F59"
private val CHAIN_ID = 137L
// USDC 有 6 位小数
@@ -156,8 +155,8 @@ class OrderSigningService {
}
/**
* 创建并签名订单
*
* 创建并签名订单 (V2)
*
* @param privateKey 私钥十六进制字符串
* @param makerAddress maker 地址funder通常是 proxyAddress
* @param tokenId token ID
@@ -165,9 +164,6 @@ class OrderSigningService {
* @param price 价格
* @param size 数量
* @param signatureType 签名类型1: Email/Magic, 2: Browser Wallet, 0: EOA
* @param nonce nonce默认 "0"
* @param feeRateBps 费率基点默认 "0"
* @param expiration 过期时间戳0 表示永不过期
* @param exchangeContract 签约用 exchange 合约地址null 时用标准 CTF Exchangeneg risk 市场需传 Neg Risk Exchange
* @return 签名的订单对象
*/
@@ -178,10 +174,7 @@ class OrderSigningService {
side: String,
price: String,
size: String,
signatureType: Int = 2, // 默认使用 Browser Wallet(与正确订单数据一致)
nonce: String = "0",
feeRateBps: String = "0",
expiration: String = "0",
signatureType: Int = 2,
exchangeContract: String? = null
): SignedOrderObject {
try {
@@ -189,33 +182,32 @@ class OrderSigningService {
val cleanPrivateKey = privateKey.removePrefix("0x")
val privateKeyBigInt = BigInteger(cleanPrivateKey, 16)
val credentials = Credentials.create(privateKeyBigInt.toString(16))
// 统一转换为小写,确保与 EIP-712 编码时使用的地址格式一致
// EIP-712 编码时地址会被转换为小写,所以订单对象中的地址也应该是小写
val signerAddress = credentials.address.lowercase()
// 2. 计算订单金额
val amounts = calculateOrderAmounts(side, size, price)
// 3. 生成 salt(使用时间戳,毫秒
// 3. 生成 salt 和 timestampV2: timestamp 替代 nonce 保证唯一性
val salt = generateSalt()
// 4. taker 地址(默认使用零地址)
val taker = "0x0000000000000000000000000000000000000000"
val timestamp = System.currentTimeMillis().toString()
// 4. V2 字段默认值
val metadata = "0x0000000000000000000000000000000000000000000000000000000000000000"
val builder = "0x0000000000000000000000000000000000000000000000000000000000000000"
// 5. 确保 maker 地址也是小写格式
val makerAddressLower = makerAddress.lowercase()
// 打印签名前的订单参数(DEBUG 级别,避免敏感信息泄露)
logger.debug("========== 订单签名前参数 ==========")
logger.debug("========== 订单签名前参数 (V2) ==========")
logger.debug("订单方向: $side, 价格: $price, 数量: $size")
logger.debug("Token ID: $tokenId")
logger.debug("Maker: ${makerAddressLower.take(10)}...${makerAddressLower.takeLast(6)}")
logger.debug("Signer: ${signerAddress.take(10)}...${signerAddress.takeLast(6)}")
logger.debug("Amounts - Maker: ${amounts.makerAmount}, Taker: ${amounts.takerAmount}")
logger.debug("Salt: $salt, Expiration: $expiration, Nonce: $nonce, FeeRateBPS: $feeRateBps")
logger.debug("Salt: $salt, Timestamp: $timestamp")
logger.debug("Signature Type: $signatureType, Chain ID: $CHAIN_ID")
// 6. 构建订单数据并签名neg risk 市场需用 NEG_RISK_EXCHANGE_CONTRACT
// 6. 构建订单数据并签名
val contract = exchangeContract?.takeIf { it.isNotBlank() } ?: EXCHANGE_CONTRACT
val signature = signOrder(
privateKey = privateKey,
@@ -224,44 +216,41 @@ class OrderSigningService {
salt = salt,
maker = makerAddressLower,
signer = signerAddress,
taker = taker,
tokenId = tokenId,
makerAmount = amounts.makerAmount,
takerAmount = amounts.takerAmount,
expiration = expiration,
nonce = nonce,
feeRateBps = feeRateBps,
side = side.uppercase(),
signatureType = signatureType
signatureType = signatureType,
timestamp = timestamp,
metadata = metadata,
builder = builder
)
// 7. 创建签名订单对象
// 注意:所有地址字段都使用小写格式,确保与签名时使用的地址一致
// 7. 创建 V2 签名订单对象
return SignedOrderObject(
salt = salt,
maker = makerAddressLower,
signer = signerAddress,
taker = taker,
taker = "0x0000000000000000000000000000000000000000",
tokenId = tokenId,
makerAmount = amounts.makerAmount,
takerAmount = amounts.takerAmount,
expiration = expiration,
nonce = nonce,
feeRateBps = feeRateBps,
side = side.uppercase(),
signatureType = signatureType,
timestamp = timestamp,
expiration = "0",
metadata = metadata,
builder = builder,
signature = signature
)
} catch (e: Exception) {
logger.error("创建并签名订单失败", e)
throw RuntimeException("创建并签名订单失败: ${e.message}", e)
logger.error("创建并签名订单失败 (V2)", e)
throw RuntimeException("创建并签名订单失败 (V2): ${e.message}", e)
}
}
/**
* 签名订单EIP-712
*
* 参考: @polymarket/order-utils ExchangeOrderBuilder
* 签名订单 V2EIP-712
*/
private fun signOrder(
privateKey: String,
@@ -270,56 +259,47 @@ class OrderSigningService {
salt: Long,
maker: String,
signer: String,
taker: String,
tokenId: String,
makerAmount: String,
takerAmount: String,
expiration: String,
nonce: String,
feeRateBps: String,
side: String,
signatureType: Int
signatureType: Int,
timestamp: String,
metadata: String,
builder: String
): String {
try {
// 1. 私钥与密钥对
val cleanPrivateKey = privateKey.removePrefix("0x")
val privateKeyBigInt = BigInteger(cleanPrivateKey, 16)
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.lowercase()
)
// 3. 编码订单消息哈希
// signatureType1 = POLY_PROXY (Magic), 2 = POLY_GNOSIS_SAFE (Safe), 0 = EOA
val orderHash = com.wrbug.polymarketbot.util.Eip712Encoder.encodeExchangeOrder(
salt = salt,
maker = maker,
signer = signer,
taker = taker,
tokenId = tokenId,
makerAmount = makerAmount,
takerAmount = takerAmount,
expiration = expiration,
nonce = nonce,
feeRateBps = feeRateBps,
side = side,
signatureType = signatureType
signatureType = signatureType,
timestamp = timestamp,
metadata = metadata,
builder = builder
)
// 4. 计算完整 EIP-712 结构化数据哈希
val structuredHash = com.wrbug.polymarketbot.util.Eip712Encoder.hashStructuredData(
domainSeparator = domainSeparator,
messageHash = orderHash
)
// 5. 使用私钥签名(needToHash=false,对 32 字节 hash 直接签名)
val signature = org.web3j.crypto.Sign.signMessage(structuredHash, ecKeyPair, false)
// 6. 组合 r + s + v
val rHex = org.web3j.utils.Numeric.toHexString(signature.r).removePrefix("0x").padStart(64, '0')
val sHex = org.web3j.utils.Numeric.toHexString(signature.s).removePrefix("0x").padStart(64, '0')
val vBytes = signature.v
@@ -328,8 +308,8 @@ class OrderSigningService {
return "0x$rHex$sHex$vHex"
} catch (e: Exception) {
logger.error("订单签名失败", e)
throw RuntimeException("订单签名失败: ${e.message}", e)
logger.error("订单签名失败 (V2)", e)
throw RuntimeException("订单签名失败 (V2): ${e.message}", e)
}
}
@@ -0,0 +1,203 @@
package com.wrbug.polymarketbot.service.copytrading.research
import com.google.gson.Gson
import com.wrbug.polymarketbot.api.UserActivityResponse
import com.wrbug.polymarketbot.dto.ActivityTradeMessage
import com.wrbug.polymarketbot.entity.LeaderActivityEvent
import com.wrbug.polymarketbot.enums.LeaderPaperProcessingStatus
import com.wrbug.polymarketbot.enums.LeaderResearchSourceType
import com.wrbug.polymarketbot.repository.LeaderActivityEventRepository
import org.slf4j.LoggerFactory
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
import java.security.MessageDigest
@Service
class LeaderActivityIngestionService(
private val activityEventRepository: LeaderActivityEventRepository,
private val gson: Gson
) {
private val logger = LoggerFactory.getLogger(LeaderActivityIngestionService::class.java)
@Transactional
fun ingestUserActivity(
activity: UserActivityResponse,
source: LeaderResearchSourceType = LeaderResearchSourceType.ACTIVITY_DERIVED
): LeaderActivityEvent {
val raw = gson.toJson(activity)
val normalizedWallet = normalizeWallet(activity.proxyWallet)
val isTrade = activity.type.equals("TRADE", ignoreCase = true)
val hasRequiredTradeFields = isTrade &&
!normalizedWallet.isNullOrBlank() &&
!activity.conditionId.isNullOrBlank() &&
!activity.side.isNullOrBlank() &&
activity.price != null &&
activity.size != null
val eventTime = normalizeTimestamp(activity.timestamp)
val stableKey = activity.transactionHash?.trim()?.takeIf { it.isNotBlank() }
?: sha256("${source.name}:${activity.proxyWallet}:${activity.conditionId}:${activity.side}:${activity.asset}:${eventTime}:${activity.price}:${activity.size}")
val event = LeaderActivityEvent(
source = source.name,
sourceEventId = activity.transactionHash,
stableEventKey = stableKey,
normalizedWallet = normalizedWallet,
marketId = activity.conditionId,
marketTitle = activity.title,
marketSlug = activity.slug,
asset = activity.asset,
side = activity.side?.uppercase(),
outcome = activity.outcome,
outcomeIndex = activity.outcomeIndex,
price = activity.price?.let { BigDecimal.valueOf(it) },
size = activity.size?.let { BigDecimal.valueOf(it) },
amount = activity.usdcSize?.let { BigDecimal.valueOf(it) }
?: amount(activity.price, activity.size),
eventTime = eventTime,
rawPayloadHash = sha256(raw),
payloadSummary = summarize(
wallet = normalizedWallet,
side = activity.side,
marketTitle = activity.title,
price = activity.price?.toString(),
size = activity.size?.toString()
),
usableForDiscovery = !normalizedWallet.isNullOrBlank() && isTrade,
usableForPaper = hasRequiredTradeFields,
unusableReason = if (hasRequiredTradeFields) null else buildUnusableReason(isTrade, normalizedWallet, activity.conditionId, activity.side, activity.price, activity.size),
paperProcessingStatus = LeaderPaperProcessingStatus.NEW,
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis()
)
return saveDeduped(event)
}
@Transactional
fun ingestWebSocketTrade(
message: ActivityTradeMessage,
source: LeaderResearchSourceType = LeaderResearchSourceType.GLOBAL_ACTIVITY_CAPTURE
): LeaderActivityEvent {
val payload = message.payload
val raw = gson.toJson(message)
val normalizedWallet = normalizeWallet(payload.trader?.address ?: payload.proxyWallet)
val eventTime = normalizeTimestamp(payload.timestamp ?: message.timestamp)
val price = payload.price.toBigDecimalOrNull()
val size = payload.size.toBigDecimalOrNull()
val hasRequiredTradeFields = !normalizedWallet.isNullOrBlank() &&
payload.conditionId.isNotBlank() &&
payload.side.isNotBlank() &&
price != null &&
size != null
val stableKey = payload.transactionHash?.trim()?.takeIf { it.isNotBlank() }
?: sha256("${source.name}:$normalizedWallet:${payload.conditionId}:${payload.side}:${payload.asset}:$eventTime:$price:$size")
val event = LeaderActivityEvent(
source = source.name,
sourceEventId = payload.transactionHash,
stableEventKey = stableKey,
normalizedWallet = normalizedWallet,
marketId = payload.conditionId.takeIf { it.isNotBlank() },
marketSlug = payload.slug,
asset = payload.asset.takeIf { it.isNotBlank() },
side = payload.side.uppercase().takeIf { it.isNotBlank() },
outcome = payload.outcome,
outcomeIndex = payload.outcomeIndex,
price = price,
size = size,
amount = if (price != null && size != null) price.multiply(size) else null,
eventTime = eventTime,
rawPayloadHash = sha256(raw),
payloadSummary = summarize(
wallet = normalizedWallet,
side = payload.side,
marketTitle = payload.slug,
price = price?.toPlainString(),
size = size?.toPlainString()
),
usableForDiscovery = !normalizedWallet.isNullOrBlank(),
usableForPaper = hasRequiredTradeFields,
unusableReason = if (hasRequiredTradeFields) null else buildUnusableReason(true, normalizedWallet, payload.conditionId, payload.side, price, size),
paperProcessingStatus = LeaderPaperProcessingStatus.NEW,
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis()
)
return saveDeduped(event)
}
fun normalizeWallet(wallet: String?): String? {
val trimmed = wallet?.trim()?.lowercase() ?: return null
val evm = Regex("^0x[a-f0-9]{40}$")
return trimmed.takeIf { evm.matches(it) }
}
fun stableHash(raw: String): String = sha256(raw)
private fun saveDeduped(event: LeaderActivityEvent): LeaderActivityEvent {
activityEventRepository.findByStableEventKey(event.stableEventKey)?.let { return it }
event.sourceEventId?.takeIf { it.isNotBlank() }?.let { sourceEventId ->
activityEventRepository.findBySourceAndSourceEventId(event.source, sourceEventId)?.let { return it }
}
return try {
activityEventRepository.save(event)
} catch (e: DataIntegrityViolationException) {
logger.debug("Activity event deduped: stableKey={}", event.stableEventKey)
activityEventRepository.findByStableEventKey(event.stableEventKey)
?: event.sourceEventId?.takeIf { it.isNotBlank() }?.let { activityEventRepository.findBySourceAndSourceEventId(event.source, it) }
?: throw e
}
}
private fun normalizeTimestamp(value: Any?): Long {
val number = when (value) {
is Number -> value.toLong()
is String -> value.toLongOrNull()
else -> null
} ?: return System.currentTimeMillis()
return if (number < 10_000_000_000L) number * 1000 else number
}
private fun Any?.toBigDecimalOrNull(): BigDecimal? {
return when (this) {
is BigDecimal -> this
is Number -> BigDecimal.valueOf(this.toDouble())
is String -> this.trim().takeIf { it.isNotBlank() }?.let { runCatching { BigDecimal(it) }.getOrNull() }
else -> null
}
}
private fun amount(price: Double?, size: Double?): BigDecimal? {
if (price == null || size == null) return null
return BigDecimal.valueOf(price).multiply(BigDecimal.valueOf(size))
}
private fun buildUnusableReason(
isTrade: Boolean,
wallet: String?,
marketId: String?,
side: String?,
price: Any?,
size: Any?
): String {
val reasons = mutableListOf<String>()
if (!isTrade) reasons += "not_trade"
if (wallet.isNullOrBlank()) reasons += "wallet_missing_or_invalid"
if (marketId.isNullOrBlank()) reasons += "market_missing"
if (side.isNullOrBlank()) reasons += "side_missing"
if (price == null) reasons += "price_missing"
if (size == null) reasons += "size_missing"
return reasons.joinToString(",")
}
private fun summarize(wallet: String?, side: String?, marketTitle: String?, price: String?, size: String?): String {
return listOfNotNull(wallet, side?.uppercase(), marketTitle, price?.let { "price=$it" }, size?.let { "size=$it" })
.joinToString(" | ")
.take(1000)
}
private fun sha256(raw: String): String {
val digest = MessageDigest.getInstance("SHA-256").digest(raw.toByteArray(Charsets.UTF_8))
return digest.joinToString("") { "%02x".format(it) }
}
}
@@ -0,0 +1,530 @@
package com.wrbug.polymarketbot.service.copytrading.research
import com.wrbug.polymarketbot.entity.LeaderActivityEvent
import com.wrbug.polymarketbot.entity.LeaderPaperPosition
import com.wrbug.polymarketbot.entity.LeaderPaperSession
import com.wrbug.polymarketbot.entity.LeaderPaperTrade
import com.wrbug.polymarketbot.entity.LeaderResearchCandidate
import com.wrbug.polymarketbot.enums.LeaderPaperFillAssumption
import com.wrbug.polymarketbot.enums.LeaderPaperFilterResult
import com.wrbug.polymarketbot.enums.LeaderPaperProcessingStatus
import com.wrbug.polymarketbot.enums.LeaderPaperSessionStatus
import com.wrbug.polymarketbot.enums.LeaderResearchEventType
import com.wrbug.polymarketbot.enums.LeaderResearchQuoteConfidence
import com.wrbug.polymarketbot.enums.LeaderResearchState
import com.wrbug.polymarketbot.enums.LeaderResearchValuationStatus
import com.wrbug.polymarketbot.repository.LeaderActivityEventRepository
import com.wrbug.polymarketbot.repository.LeaderPaperPositionRepository
import com.wrbug.polymarketbot.repository.LeaderPaperSessionRepository
import com.wrbug.polymarketbot.repository.LeaderPaperTradeRepository
import com.wrbug.polymarketbot.repository.LeaderResearchCandidateRepository
import com.wrbug.polymarketbot.service.common.MarketPriceService
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
import java.math.RoundingMode
data class LeaderPaperProcessingResult(
val processed: Int,
val filtered: Int,
val failed: Int
)
@Service
class LeaderPaperTradingService(
private val candidateRepository: LeaderResearchCandidateRepository,
private val activityEventRepository: LeaderActivityEventRepository,
private val paperSessionRepository: LeaderPaperSessionRepository,
private val paperTradeRepository: LeaderPaperTradeRepository,
private val paperPositionRepository: LeaderPaperPositionRepository,
private val marketPriceService: MarketPriceService,
private val eventService: LeaderResearchEventService
) {
private val logger = LoggerFactory.getLogger(LeaderPaperTradingService::class.java)
@Transactional
fun ensureSession(candidate: LeaderResearchCandidate, runId: Long? = null): LeaderPaperSession {
val candidateId = candidate.id ?: throw IllegalArgumentException("candidate id missing")
paperSessionRepository.findTopByCandidateIdAndStatusOrderByStartedAtDesc(candidateId, LeaderPaperSessionStatus.ACTIVE)
?.let { return it }
val now = System.currentTimeMillis()
val session = paperSessionRepository.save(
LeaderPaperSession(
candidateId = candidateId,
status = LeaderPaperSessionStatus.ACTIVE,
startedAt = now,
createdAt = now,
updatedAt = now
)
)
candidateRepository.save(
candidate.copy(
lastPaperSessionId = session.id,
updatedAt = now
)
)
eventService.record(
type = LeaderResearchEventType.PAPER_STARTED,
candidateId = candidateId,
runId = runId,
reason = "Paper session started",
dedupeKey = "paper-started:$candidateId:${session.id}"
)
return session
}
@Transactional
fun processPaperCandidates(runId: Long? = null, batchSize: Int = 200): LeaderPaperProcessingResult {
val paperCandidates = candidateRepository.findByResearchStateIn(
listOf(LeaderResearchState.PAPER, LeaderResearchState.TRIAL_READY)
)
if (paperCandidates.isEmpty()) {
return LeaderPaperProcessingResult(processed = 0, filtered = 0, failed = 0)
}
val candidatesByWallet = paperCandidates.associateBy { it.normalizedWallet }
paperCandidates.forEach { ensureSession(it, runId) }
val page = activityEventRepository.findByPaperProcessingStatusInAndUsableForPaperTrueOrderByEventTimeAsc(
listOf(LeaderPaperProcessingStatus.NEW, LeaderPaperProcessingStatus.RETRYABLE),
PageRequest.of(0, batchSize)
)
var processed = 0
var filtered = 0
var failed = 0
val now = System.currentTimeMillis()
page.content.forEach { event ->
val wallet = event.normalizedWallet ?: return@forEach
val candidate = candidatesByWallet[wallet] ?: return@forEach
val eventId = event.id ?: return@forEach
val claimed = activityEventRepository.claimForPaperProcessing(
id = eventId,
allowed = listOf(LeaderPaperProcessingStatus.NEW, LeaderPaperProcessingStatus.RETRYABLE),
nextStatus = LeaderPaperProcessingStatus.PROCESSING,
startedAt = now
)
if (claimed != 1) return@forEach
val claimedEvent = event.copy(
paperProcessingStatus = LeaderPaperProcessingStatus.PROCESSING,
processingAttempts = event.processingAttempts + 1,
paperProcessingStartedAt = now,
updatedAt = now
)
try {
val session = ensureSession(candidate, runId)
val outcome = processEvent(candidate, session, claimedEvent)
when (outcome) {
LeaderPaperFilterResult.PASSED -> processed += 1
LeaderPaperFilterResult.FILTERED -> filtered += 1
}
} catch (e: Exception) {
failed += 1
val nextAttempts = claimedEvent.processingAttempts
val nextStatus = if (nextAttempts >= MAX_PROCESSING_ATTEMPTS) {
LeaderPaperProcessingStatus.FAILED
} else {
LeaderPaperProcessingStatus.RETRYABLE
}
logger.warn("Paper event processing failed: eventId={}, error={}", eventId, e.message, e)
activityEventRepository.save(
claimedEvent.copy(
paperProcessingStatus = nextStatus,
paperProcessedAt = System.currentTimeMillis(),
lastProcessingError = e.message,
updatedAt = System.currentTimeMillis()
)
)
eventService.record(
type = LeaderResearchEventType.PAPER_PROCESSING_FAILED,
candidateId = candidate.id,
runId = runId,
reason = "Paper event processing failed with status=$nextStatus: ${e.message}",
payloadSummary = claimedEvent.payloadSummary,
dedupeKey = "paper-processing-failed:$eventId:$nextAttempts"
)
}
}
return LeaderPaperProcessingResult(processed = processed, filtered = filtered, failed = failed)
}
fun isEligibleForTrialReady(session: LeaderPaperSession, now: Long = System.currentTimeMillis()): Boolean {
val ageMs = now - session.startedAt
val totalTrades = session.tradeCount + session.filteredCount
val unknownRatio = if (session.openExposure > BigDecimal.ZERO) {
session.unknownValuationExposure.safeDivide(session.openExposure)
} else {
BigDecimal.ZERO
}
return ageMs >= PAPER_MIN_AGE_MS &&
session.tradeCount >= PAPER_MIN_TRADES &&
session.copyablePnl > BigDecimal.ZERO &&
session.maxDrawdown >= BigDecimal("-15") &&
unknownRatio <= BigDecimal("0.20") &&
session.filteredRatio < BigDecimal("0.50") &&
totalTrades >= PAPER_MIN_TRADES
}
fun shouldEnterCooldown(session: LeaderPaperSession, sourceFresh: Boolean): String? {
if (session.maxDrawdown < BigDecimal("-20")) return "paper_drawdown_below_-20"
if (session.tradeCount >= 10 && session.copyablePnl < BigDecimal("-5")) return "copyable_pnl_below_-5_after_10_trades"
if (!sourceFresh) return "source_stale_over_72h"
if ((session.openExposure > BigDecimal.ZERO) &&
session.unknownValuationExposure.safeDivide(session.openExposure) > BigDecimal("0.50")
) {
return "thin_liquidity_exit_risk"
}
return null
}
@Transactional
fun refreshSessionSummary(sessionId: Long): LeaderPaperSession {
val session = paperSessionRepository.findById(sessionId).orElseThrow { IllegalArgumentException("Paper session not found") }
return saveSessionSummary(session)
}
private fun processEvent(
candidate: LeaderResearchCandidate,
session: LeaderPaperSession,
event: LeaderActivityEvent
): LeaderPaperFilterResult {
val candidateId = candidate.id ?: throw IllegalArgumentException("candidate id missing")
val sessionId = session.id ?: throw IllegalArgumentException("session id missing")
val filterReason = filterReason(event)
if (filterReason != null) {
val trade = buildTrade(
candidateId = candidateId,
sessionId = sessionId,
event = event,
filterResult = LeaderPaperFilterResult.FILTERED,
filterReason = filterReason,
simulatedPrice = null,
simulatedSize = null,
simulatedAmount = null,
valuationStatus = LeaderResearchValuationStatus.UNKNOWN
)
saveTradeIfAbsent(trade)
markEvent(event, LeaderPaperProcessingStatus.FILTERED, null)
saveSessionSummary(session)
eventService.record(
type = LeaderResearchEventType.PAPER_TRADE_FILTERED,
candidateId = candidateId,
reason = filterReason,
payloadSummary = event.payloadSummary,
dedupeKey = "paper-filtered:${sessionId}:${event.stableEventKey}"
)
return LeaderPaperFilterResult.FILTERED
}
val side = event.side!!.uppercase()
val price = event.price!!
val marketId = event.marketId!!
val outcomeIndex = event.outcomeIndex ?: 0
if (paperTradeRepository.existsBySessionIdAndLeaderTradeIdAndSide(sessionId, event.stableEventKey, side)) {
markEvent(event, LeaderPaperProcessingStatus.PROCESSED, null)
return LeaderPaperFilterResult.PASSED
}
val existingPosition = paperPositionRepository.findBySessionIdAndMarketIdAndOutcomeIndex(sessionId, marketId, outcomeIndex)
val simulatedAmount = when (side) {
"BUY" -> minDecimal(event.amount ?: price.multiply(event.size ?: BigDecimal.ZERO), PAPER_FIXED_AMOUNT)
"SELL" -> {
val maxSell = existingPosition?.quantity ?: BigDecimal.ZERO
val eventSellSize = event.size ?: maxSell
minDecimal(maxSell, eventSellSize).multiply(price)
}
else -> BigDecimal.ZERO
}.atLeast(BigDecimal.ZERO)
val simulatedSize = if (price > BigDecimal.ZERO) simulatedAmount.safeDivide(price) else BigDecimal.ZERO
val valuation = quoteMarket(marketId, outcomeIndex)
val realizedPnl = applyPosition(
session = session,
event = event,
side = side,
simulatedPrice = price,
simulatedSize = simulatedSize,
simulatedAmount = simulatedAmount,
valuation = valuation
)
val trade = buildTrade(
candidateId = candidateId,
sessionId = sessionId,
event = event,
filterResult = LeaderPaperFilterResult.PASSED,
filterReason = null,
simulatedPrice = price,
simulatedSize = simulatedSize,
simulatedAmount = simulatedAmount,
valuationStatus = valuation.status,
realizedPnl = realizedPnl
)
saveTradeIfAbsent(trade)
markEvent(event, LeaderPaperProcessingStatus.PROCESSED, null)
saveSessionSummary(session)
eventService.record(
type = LeaderResearchEventType.PAPER_TRADE_RECORDED,
candidateId = candidateId,
reason = "${side} paper trade recorded",
payloadSummary = event.payloadSummary,
dedupeKey = "paper-trade:${sessionId}:${event.stableEventKey}:$side"
)
if (valuation.status == LeaderResearchValuationStatus.UNKNOWN || valuation.status == LeaderResearchValuationStatus.UNAVAILABLE) {
eventService.record(
type = LeaderResearchEventType.VALUATION_STALE,
candidateId = candidateId,
reason = "Valuation unavailable for $marketId/$outcomeIndex",
dedupeKey = "paper-valuation:${sessionId}:${event.stableEventKey}"
)
}
return LeaderPaperFilterResult.PASSED
}
private fun applyPosition(
session: LeaderPaperSession,
event: LeaderActivityEvent,
side: String,
simulatedPrice: BigDecimal,
simulatedSize: BigDecimal,
simulatedAmount: BigDecimal,
valuation: PaperQuote
): BigDecimal {
val sessionId = session.id ?: throw IllegalArgumentException("session id missing")
val candidateId = session.candidateId
val marketId = event.marketId!!
val outcomeIndex = event.outcomeIndex ?: 0
val now = System.currentTimeMillis()
val existing = paperPositionRepository.findBySessionIdAndMarketIdAndOutcomeIndex(sessionId, marketId, outcomeIndex)
val updated = if (side == "SELL") {
val position = existing ?: LeaderPaperPosition(
sessionId = sessionId,
candidateId = candidateId,
marketId = marketId,
outcome = event.outcome,
outcomeIndex = outcomeIndex,
createdAt = now,
updatedAt = now
)
val sellSize = minDecimal(position.quantity, simulatedSize)
val costPortion = if (position.quantity > BigDecimal.ZERO) {
position.cost.multiply(sellSize).safeDivide(position.quantity)
} else {
BigDecimal.ZERO
}
val realized = simulatedPrice.multiply(sellSize).subtract(costPortion)
val remainingQuantity = position.quantity.subtract(sellSize).atLeast(BigDecimal.ZERO)
val remainingCost = position.cost.subtract(costPortion).atLeast(BigDecimal.ZERO)
val currentValue = valuation.price?.multiply(remainingQuantity) ?: BigDecimal.ZERO
position.copy(
quantity = remainingQuantity,
cost = remainingCost,
avgPrice = if (remainingQuantity > BigDecimal.ZERO) remainingCost.safeDivide(remainingQuantity) else BigDecimal.ZERO,
currentPrice = valuation.price,
currentValue = currentValue,
realizedPnl = position.realizedPnl.add(realized),
unrealizedPnl = currentValue.subtract(remainingCost),
valuationStatus = valuation.status,
quoteConfidence = valuation.confidence,
quoteSource = valuation.source,
quoteTimestamp = valuation.timestamp,
updatedAt = now
)
} else {
val position = existing ?: LeaderPaperPosition(
sessionId = sessionId,
candidateId = candidateId,
marketId = marketId,
outcome = event.outcome,
outcomeIndex = outcomeIndex,
createdAt = now,
updatedAt = now
)
val newQuantity = position.quantity.add(simulatedSize)
val newCost = position.cost.add(simulatedAmount)
val currentValue = valuation.price?.multiply(newQuantity) ?: BigDecimal.ZERO
position.copy(
quantity = newQuantity,
cost = newCost,
avgPrice = if (newQuantity > BigDecimal.ZERO) newCost.safeDivide(newQuantity) else BigDecimal.ZERO,
currentPrice = valuation.price,
currentValue = currentValue,
unrealizedPnl = currentValue.subtract(newCost),
valuationStatus = valuation.status,
quoteConfidence = valuation.confidence,
quoteSource = valuation.source,
quoteTimestamp = valuation.timestamp,
updatedAt = now
)
}
val saved = paperPositionRepository.save(updated)
return if (side == "SELL") saved.realizedPnl.subtract(existing?.realizedPnl ?: BigDecimal.ZERO) else BigDecimal.ZERO
}
private fun saveSessionSummary(session: LeaderPaperSession): LeaderPaperSession {
val sessionId = session.id ?: throw IllegalArgumentException("session id missing")
val positions = paperPositionRepository.findBySessionIdOrderByUpdatedAtDesc(sessionId)
val trades = paperTradeRepository.findBySessionIdOrderByEventTimeAsc(sessionId)
val tradeCount = trades.count { it.filterResult == LeaderPaperFilterResult.PASSED }
val filteredCount = trades.count { it.filterResult == LeaderPaperFilterResult.FILTERED }
val totalEvents = tradeCount + filteredCount
val realized = positions.fold(BigDecimal.ZERO) { acc, position -> acc + position.realizedPnl }
val availableUnrealized = positions
.filter { it.valuationStatus == LeaderResearchValuationStatus.AVAILABLE || it.valuationStatus == LeaderResearchValuationStatus.CONFIRMED_ZERO }
.fold(BigDecimal.ZERO) { acc, position -> acc + position.unrealizedPnl }
val unknownExposure = positions
.filter { it.valuationStatus == LeaderResearchValuationStatus.UNKNOWN || it.valuationStatus == LeaderResearchValuationStatus.UNAVAILABLE || it.valuationStatus == LeaderResearchValuationStatus.NO_MATCH }
.fold(BigDecimal.ZERO) { acc, position -> acc + position.cost }
val confirmedZeroExposure = positions
.filter { it.valuationStatus == LeaderResearchValuationStatus.CONFIRMED_ZERO }
.fold(BigDecimal.ZERO) { acc, position -> acc + position.cost }
val openExposure = positions.fold(BigDecimal.ZERO) { acc, position -> acc + position.cost }
val copyablePnl = realized.add(availableUnrealized)
val maxDrawdown = minDecimal(session.maxDrawdown, copyablePnl)
return paperSessionRepository.save(
session.copy(
tradeCount = tradeCount,
filteredCount = filteredCount,
openExposure = openExposure,
totalRealizedPnl = realized,
totalUnrealizedPnl = positions.fold(BigDecimal.ZERO) { acc, position -> acc + position.unrealizedPnl },
copyablePnl = copyablePnl,
maxDrawdown = maxDrawdown,
unknownValuationExposure = unknownExposure,
confirmedZeroExposure = confirmedZeroExposure,
filteredRatio = if (totalEvents > 0) BigDecimal(filteredCount).safeDivide(BigDecimal(totalEvents)) else BigDecimal.ZERO,
lastProcessedEventTime = trades.maxOfOrNull { it.eventTime } ?: session.lastProcessedEventTime,
updatedAt = System.currentTimeMillis()
)
)
}
private fun buildTrade(
candidateId: Long,
sessionId: Long,
event: LeaderActivityEvent,
filterResult: LeaderPaperFilterResult,
filterReason: String?,
simulatedPrice: BigDecimal?,
simulatedSize: BigDecimal?,
simulatedAmount: BigDecimal?,
valuationStatus: LeaderResearchValuationStatus,
realizedPnl: BigDecimal? = null
): LeaderPaperTrade {
return LeaderPaperTrade(
sessionId = sessionId,
candidateId = candidateId,
activityEventId = event.id,
leaderTradeId = event.stableEventKey,
marketId = event.marketId ?: "unknown",
marketTitle = event.marketTitle,
marketSlug = event.marketSlug,
side = event.side?.uppercase() ?: "UNKNOWN",
outcome = event.outcome,
outcomeIndex = event.outcomeIndex,
leaderPrice = event.price,
leaderSize = event.size,
simulatedPrice = simulatedPrice,
simulatedSize = simulatedSize,
simulatedAmount = simulatedAmount,
fillAssumption = if (simulatedPrice != null) LeaderPaperFillAssumption.LEADER_PRICE else LeaderPaperFillAssumption.UNKNOWN,
quoteConfidence = if (valuationStatus == LeaderResearchValuationStatus.AVAILABLE || valuationStatus == LeaderResearchValuationStatus.CONFIRMED_ZERO) {
LeaderResearchQuoteConfidence.MEDIUM
} else {
LeaderResearchQuoteConfidence.UNKNOWN
},
quoteSource = "paper_v1",
quoteTimestamp = System.currentTimeMillis(),
filterResult = filterResult,
filterReason = filterReason,
valuationStatus = valuationStatus,
realizedPnl = realizedPnl,
eventTime = event.eventTime,
createdAt = System.currentTimeMillis()
)
}
private fun saveTradeIfAbsent(trade: LeaderPaperTrade): LeaderPaperTrade {
if (paperTradeRepository.existsBySessionIdAndLeaderTradeIdAndSide(trade.sessionId, trade.leaderTradeId, trade.side)) {
return paperTradeRepository.findBySessionIdOrderByEventTimeAsc(trade.sessionId)
.first { it.leaderTradeId == trade.leaderTradeId && it.side == trade.side }
}
return paperTradeRepository.save(trade)
}
private fun markEvent(event: LeaderActivityEvent, status: LeaderPaperProcessingStatus, error: String?) {
activityEventRepository.save(
event.copy(
paperProcessingStatus = status,
processingAttempts = event.processingAttempts,
paperProcessingStartedAt = event.paperProcessingStartedAt,
paperProcessedAt = System.currentTimeMillis(),
lastProcessingError = error,
updatedAt = System.currentTimeMillis()
)
)
}
private fun filterReason(event: LeaderActivityEvent): String? {
if (event.marketId.isNullOrBlank()) return "market_missing"
if (event.side.isNullOrBlank()) return "side_missing"
if (event.side.uppercase() !in setOf("BUY", "SELL")) return "unsupported_side:${event.side}"
if (event.price == null || event.price <= BigDecimal.ZERO) return "price_missing_or_invalid"
if (event.price < MIN_PRICE || event.price > MAX_PRICE) return "price_outside_safe_band"
if (event.size == null || event.size <= BigDecimal.ZERO) return "size_missing_or_invalid"
if (event.side.uppercase() == "BUY" && (event.amount ?: event.price.multiply(event.size)) <= BigDecimal.ZERO) return "amount_missing_or_invalid"
return null
}
private fun quoteMarket(marketId: String, outcomeIndex: Int): PaperQuote {
return try {
val price = runBlocking { marketPriceService.getCurrentMarketPrice(marketId, outcomeIndex) }
PaperQuote(
price = price,
status = if (price.compareTo(BigDecimal.ZERO) == 0) LeaderResearchValuationStatus.CONFIRMED_ZERO else LeaderResearchValuationStatus.AVAILABLE,
confidence = LeaderResearchQuoteConfidence.MEDIUM,
source = "MarketPriceService",
timestamp = System.currentTimeMillis()
)
} catch (e: Exception) {
logger.debug("Paper valuation unavailable: marketId={}, outcomeIndex={}, error={}", marketId, outcomeIndex, e.message)
PaperQuote(
price = null,
status = LeaderResearchValuationStatus.UNKNOWN,
confidence = LeaderResearchQuoteConfidence.UNKNOWN,
source = "MarketPriceService",
timestamp = System.currentTimeMillis()
)
}
}
private fun BigDecimal.safeDivide(other: BigDecimal): BigDecimal {
if (other.compareTo(BigDecimal.ZERO) == 0) return BigDecimal.ZERO
return divide(other, 8, RoundingMode.HALF_UP)
}
private fun BigDecimal.atLeast(other: BigDecimal): BigDecimal = if (this >= other) this else other
private fun minDecimal(left: BigDecimal, right: BigDecimal): BigDecimal = if (left <= right) left else right
private data class PaperQuote(
val price: BigDecimal?,
val status: LeaderResearchValuationStatus,
val confidence: LeaderResearchQuoteConfidence,
val source: String,
val timestamp: Long
)
companion object {
private val PAPER_FIXED_AMOUNT = BigDecimal("1.00000000")
private val MIN_PRICE = BigDecimal("0.10000000")
private val MAX_PRICE = BigDecimal("0.80000000")
private const val PAPER_MIN_TRADES = 10
private const val PAPER_MIN_AGE_MS = 7L * 24 * 60 * 60 * 1000
private const val MAX_PROCESSING_ATTEMPTS = 3
}
}
@@ -0,0 +1,146 @@
package com.wrbug.polymarketbot.service.copytrading.research
import com.wrbug.polymarketbot.dto.CopyTradingCreateRequest
import com.wrbug.polymarketbot.dto.LeaderResearchApprovalRequest
import com.wrbug.polymarketbot.dto.LeaderResearchApprovalResponse
import com.wrbug.polymarketbot.entity.LeaderPool
import com.wrbug.polymarketbot.enums.LeaderPoolStatus
import com.wrbug.polymarketbot.enums.LeaderResearchEventType
import com.wrbug.polymarketbot.enums.LeaderResearchState
import com.wrbug.polymarketbot.repository.AccountRepository
import com.wrbug.polymarketbot.repository.CopyTradingRepository
import com.wrbug.polymarketbot.repository.LeaderPoolRepository
import com.wrbug.polymarketbot.repository.LeaderResearchCandidateRepository
import com.wrbug.polymarketbot.service.copytrading.configs.CopyTradingService
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
class LeaderResearchCandidateNotReadyException : RuntimeException("候选尚未进入 TRIAL_READY,不能创建试跟配置")
class LeaderResearchApprovalConfirmRequiredException : RuntimeException("创建禁用试跟配置需要显式确认")
class LeaderResearchDuplicateTrialConfigException : RuntimeException("该账户已存在此 Leader 的跟单配置")
class LeaderResearchRealMoneyForbiddenException : RuntimeException("Leader Research Agent 不允许自动启用真钱跟单")
class LeaderResearchCandidateLockedException : RuntimeException("研究候选已锁定")
@Service
class LeaderResearchApprovalService(
private val candidateRepository: LeaderResearchCandidateRepository,
private val accountRepository: AccountRepository,
private val copyTradingRepository: CopyTradingRepository,
private val leaderPoolRepository: LeaderPoolRepository,
private val copyTradingService: CopyTradingService,
private val poolMappingService: LeaderResearchPoolMappingService,
private val eventService: LeaderResearchEventService
) {
private val logger = LoggerFactory.getLogger(LeaderResearchApprovalService::class.java)
@Transactional
fun createDisabledTrialConfig(request: LeaderResearchApprovalRequest): Result<LeaderResearchApprovalResponse> {
return try {
if (!request.confirm) {
return Result.failure(LeaderResearchApprovalConfirmRequiredException())
}
val candidate = candidateRepository.findById(request.candidateId).orElse(null)
?: return Result.failure(IllegalArgumentException("候选不存在"))
if (candidate.locked) {
eventService.record(
type = LeaderResearchEventType.APPROVAL_REJECTED,
candidateId = candidate.id,
reason = "Candidate is locked; manual unlock is required before approval"
)
return Result.failure(LeaderResearchCandidateLockedException())
}
if (candidate.researchState != LeaderResearchState.TRIAL_READY) {
eventService.record(
type = LeaderResearchEventType.APPROVAL_REJECTED,
candidateId = candidate.id,
reason = "Candidate state is ${candidate.researchState}, not TRIAL_READY"
)
return Result.failure(LeaderResearchCandidateNotReadyException())
}
val account = accountRepository.findByIdForUpdate(request.accountId)
?: return Result.failure(IllegalArgumentException("账户不存在"))
val synced = poolMappingService.syncCandidate(candidate)
val pool = synced.poolId?.let { leaderPoolRepository.findById(it).orElse(null) }
?: return Result.failure(IllegalStateException("Leader Pool 同步失败"))
val leaderId = synced.leaderId ?: pool.leaderId
if (copyTradingRepository.findByAccountIdAndLeaderId(account.id ?: request.accountId, leaderId).isNotEmpty()) {
eventService.record(
type = LeaderResearchEventType.DUPLICATE_APPROVAL,
candidateId = candidate.id,
reason = "Duplicate copy trading config for account=${account.id}, leader=$leaderId"
)
return Result.failure(LeaderResearchDuplicateTrialConfigException())
}
val copyRequest = buildDisabledCopyTradingRequest(pool, request.accountId, leaderId)
if (copyRequest.enabled) {
eventService.record(
type = LeaderResearchEventType.REAL_MONEY_ACTIVATION_FORBIDDEN,
candidateId = candidate.id,
reason = "Research approval attempted to create enabled copy trading config",
dedupeKey = "approval-real-money-forbidden:${candidate.id}:${request.accountId}"
)
return Result.failure(LeaderResearchRealMoneyForbiddenException())
}
val copyTrading = copyTradingService.createCopyTrading(copyRequest).getOrThrow()
val now = System.currentTimeMillis()
leaderPoolRepository.save(
pool.copy(
status = LeaderPoolStatus.TRIAL,
lastPromotedAt = now,
lastReviewedAt = now,
researchState = LeaderResearchState.TRIAL_READY,
researchBadge = "DISABLED_TRIAL_CREATED",
researchUpdatedAt = now,
updatedAt = now
)
)
eventService.record(
type = LeaderResearchEventType.APPROVAL_CREATED_DISABLED_CONFIG,
candidateId = candidate.id,
reason = "Created disabled copy trading config id=${copyTrading.id}; manual enable required",
payloadSummary = "accountId=${request.accountId}, leaderId=$leaderId",
dedupeKey = "approval-disabled:${candidate.id}:${request.accountId}"
)
Result.success(LeaderResearchApprovalResponse(copyTrading))
} catch (e: Exception) {
logger.error("Leader research approval failed: candidateId=${request.candidateId}", e)
Result.failure(e)
}
}
private fun buildDisabledCopyTradingRequest(pool: LeaderPool, accountId: Long, leaderId: Long): CopyTradingCreateRequest {
val fixedAmount = pool.suggestedFixedAmount.takeIf { it > BigDecimal.ZERO } ?: BigDecimal("1.00000000")
return CopyTradingCreateRequest(
accountId = accountId,
leaderId = leaderId,
enabled = false,
copyMode = "FIXED",
copyRatio = "1",
fixedAmount = fixedAmount.strip(),
maxOrderSize = fixedAmount.strip(),
minOrderSize = "1",
maxDailyLoss = (pool.suggestedMaxDailyLoss.takeIf { it > BigDecimal.ZERO } ?: BigDecimal("5.00000000")).strip(),
maxDailyOrders = pool.suggestedMaxDailyOrders.coerceIn(1, 10),
priceTolerance = "1",
delaySeconds = 0,
pollIntervalSeconds = 5,
useWebSocket = true,
websocketReconnectInterval = 5000,
websocketMaxRetries = 10,
supportSell = true,
minPrice = pool.suggestedMinPrice?.strip() ?: "0.1",
maxPrice = pool.suggestedMaxPrice?.strip() ?: "0.8",
maxPositionValue = pool.suggestedMaxPositionValue?.strip() ?: "5",
keywordFilterMode = "DISABLED",
keywords = null,
configName = "Research试跟-${pool.researchCandidateId ?: pool.leaderId}",
pushFailedOrders = true,
pushFilteredOrders = true
)
}
private fun BigDecimal.strip(): String = stripTrailingZeros().toPlainString()
}
@@ -0,0 +1,50 @@
package com.wrbug.polymarketbot.service.copytrading.research
import com.wrbug.polymarketbot.entity.LeaderResearchEvent
import com.wrbug.polymarketbot.enums.LeaderResearchEventType
import com.wrbug.polymarketbot.enums.LeaderResearchNotificationStatus
import com.wrbug.polymarketbot.repository.LeaderResearchEventRepository
import org.slf4j.LoggerFactory
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.stereotype.Service
@Service
class LeaderResearchEventService(
private val eventRepository: LeaderResearchEventRepository
) {
private val logger = LoggerFactory.getLogger(LeaderResearchEventService::class.java)
fun record(
type: LeaderResearchEventType,
candidateId: Long? = null,
runId: Long? = null,
reason: String? = null,
payloadSummary: String? = null,
dedupeKey: String? = null,
notificationStatus: LeaderResearchNotificationStatus = LeaderResearchNotificationStatus.PENDING
): LeaderResearchEvent? {
return try {
if (!dedupeKey.isNullOrBlank()) {
eventRepository.findTopByDedupeKey(dedupeKey)?.let { return it }
}
eventRepository.save(
LeaderResearchEvent(
candidateId = candidateId,
runId = runId,
eventType = type,
reason = reason,
payloadSummary = payloadSummary,
notificationStatus = notificationStatus,
dedupeKey = dedupeKey,
createdAt = System.currentTimeMillis()
)
)
} catch (e: DataIntegrityViolationException) {
logger.debug("Research event deduped: type={}, dedupeKey={}", type, dedupeKey)
dedupeKey?.let { eventRepository.findTopByDedupeKey(it) }
} catch (e: Exception) {
logger.warn("Failed to record research event: type={}, candidateId={}, error={}", type, candidateId, e.message)
null
}
}
}
@@ -0,0 +1,195 @@
package com.wrbug.polymarketbot.service.copytrading.research
import com.wrbug.polymarketbot.entity.LeaderResearchRun
import com.wrbug.polymarketbot.enums.LeaderResearchEventType
import com.wrbug.polymarketbot.enums.LeaderResearchRunStatus
import com.wrbug.polymarketbot.enums.LeaderResearchSourceStatus
import com.wrbug.polymarketbot.enums.LeaderResearchState
import com.wrbug.polymarketbot.enums.LeaderResearchTriggerType
import com.wrbug.polymarketbot.repository.LeaderActivityEventRepository
import com.wrbug.polymarketbot.repository.LeaderResearchCandidateRepository
import com.wrbug.polymarketbot.repository.LeaderResearchRunRepository
import jakarta.annotation.PreDestroy
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
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.atomic.AtomicBoolean
@Service
class LeaderResearchJobService(
private val runRepository: LeaderResearchRunRepository,
private val activityEventRepository: LeaderActivityEventRepository,
private val candidateRepository: LeaderResearchCandidateRepository,
private val sourceService: LeaderResearchSourceService,
private val paperTradingService: LeaderPaperTradingService,
private val scoringService: LeaderResearchScoringService,
private val stateMachine: LeaderResearchStateMachine,
private val eventService: LeaderResearchEventService,
@Value("\${leader.research.enabled:false}") private val scheduledEnabled: Boolean
) {
private val logger = LoggerFactory.getLogger(LeaderResearchJobService::class.java)
private val running = AtomicBoolean(false)
internal var runExecutor: ExecutorService = Executors.newSingleThreadExecutor { runnable ->
Thread(runnable, "leader-research-runner").apply { isDaemon = true }
}
@Scheduled(fixedDelayString = "\${leader.research.fixed-delay-ms:900000}")
fun scheduledRun() {
if (!scheduledEnabled) return
runOnce(dryRun = false, triggerType = LeaderResearchTriggerType.SCHEDULED)
}
fun runOnce(dryRun: Boolean, triggerType: LeaderResearchTriggerType = LeaderResearchTriggerType.MANUAL): LeaderResearchRun {
val start = startRunRecord(dryRun, triggerType)
if (!start.acquired) return start.run
return executeStartedRun(start.run)
}
fun startAsync(dryRun: Boolean, triggerType: LeaderResearchTriggerType = LeaderResearchTriggerType.MANUAL): LeaderResearchRun {
val start = startRunRecord(dryRun, triggerType)
if (!start.acquired) return start.run
return try {
runExecutor.submit {
logger.info("Leader research async run started: runId={}", start.run.id)
executeStartedRun(start.run)
}
logger.info("Leader research async run queued: runId={}, triggerType={}", start.run.id, triggerType)
start.run
} catch (e: RuntimeException) {
logger.error("Failed to queue leader research async run: runId={}", start.run.id, e)
failStartedRun(start.run, e).also { running.set(false) }
}
}
@PreDestroy
fun shutdown() {
runExecutor.shutdownNow()
}
private data class RunStart(val run: LeaderResearchRun, val acquired: Boolean)
private fun startRunRecord(dryRun: Boolean, triggerType: LeaderResearchTriggerType): RunStart {
if (!running.compareAndSet(false, true)) {
val now = System.currentTimeMillis()
val skipped = runRepository.save(
LeaderResearchRun(
status = LeaderResearchRunStatus.SKIPPED,
triggerType = triggerType,
dryRun = dryRun,
startedAt = now,
finishedAt = now,
durationMs = 0,
skippedReason = "another_run_in_progress",
createdAt = now,
updatedAt = now
)
)
eventService.record(
type = LeaderResearchEventType.RUN_SKIPPED,
runId = skipped.id,
reason = "Skipped because another research run is in progress"
)
return RunStart(skipped, acquired = false)
}
val startedAt = System.currentTimeMillis()
return try {
val run = runRepository.save(
LeaderResearchRun(
status = LeaderResearchRunStatus.RUNNING,
triggerType = triggerType,
dryRun = dryRun,
startedAt = startedAt,
createdAt = startedAt,
updatedAt = startedAt
)
)
eventService.record(
type = LeaderResearchEventType.RUN_STARTED,
runId = run.id,
reason = "Leader research run started"
)
RunStart(run, acquired = true)
} catch (e: RuntimeException) {
running.set(false)
throw e
}
}
private fun executeStartedRun(startedRun: LeaderResearchRun): LeaderResearchRun {
var run = startedRun
val startedAt = startedRun.startedAt
return try {
val isPreview = run.dryRun || run.triggerType == LeaderResearchTriggerType.PREVIEW
val sourceResults = if (isPreview) sourceService.previewCandidates() else sourceService.discoverCandidates(run.id)
if (!isPreview) {
scoringService.scoreAll(run.id)
stateMachine.advanceAll(run.id)
paperTradingService.processPaperCandidates(run.id)
scoringService.scoreAll(run.id)
stateMachine.advanceAll(run.id)
}
val now = System.currentTimeMillis()
val sourceCounts = sourceResults.joinToString(",", prefix = "{", postfix = "}") {
"\"${it.sourceType.name}\":${it.candidates.size}"
}
val candidateCounts = LeaderResearchState.values().joinToString(",", prefix = "{", postfix = "}") { state ->
"\"${state.name}\":${candidateRepository.countByResearchState(state)}"
}
val lastEventCursor = activityEventRepository.findTopByOrderByEventTimeDesc()
?.let { "${it.eventTime}:${it.stableEventKey}" }
val hasSourceProblems = sourceResults.any {
!it.expectedLimitation && (it.status == LeaderResearchSourceStatus.FAILURE || it.status == LeaderResearchSourceStatus.DEGRADED)
}
run = runRepository.save(
run.copy(
status = if (hasSourceProblems) LeaderResearchRunStatus.PARTIAL_FAILURE else LeaderResearchRunStatus.SUCCESS,
finishedAt = now,
durationMs = now - startedAt,
sourceCountsJson = sourceCounts,
candidateCountsJson = candidateCounts,
lastEventCursor = lastEventCursor,
partialFailure = hasSourceProblems,
updatedAt = now
)
)
eventService.record(
type = LeaderResearchEventType.RUN_COMPLETED,
runId = run.id,
reason = "Leader research run completed",
payloadSummary = "sourceCounts=$sourceCounts candidateCounts=$candidateCounts"
)
run
} catch (e: Exception) {
failStartedRun(run, e)
} finally {
running.set(false)
}
}
private fun failStartedRun(run: LeaderResearchRun, e: Exception): LeaderResearchRun {
logger.error("Leader research run failed", e)
val now = System.currentTimeMillis()
return runRepository.save(
run.copy(
status = LeaderResearchRunStatus.FAILED,
finishedAt = now,
durationMs = now - run.startedAt,
errorClass = e::class.java.simpleName,
errorMessage = e.message,
updatedAt = now
)
).also {
eventService.record(
type = LeaderResearchEventType.RUN_FAILED,
runId = it.id,
reason = e.message,
payloadSummary = e::class.java.name
)
}
}
}
@@ -0,0 +1,251 @@
package com.wrbug.polymarketbot.service.copytrading.research
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.entity.*
import com.wrbug.polymarketbot.enums.LeaderPoolStatus
import com.wrbug.polymarketbot.repository.LeaderPoolRepository
import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.repository.LeaderResearchSourceStateRepository
import org.springframework.stereotype.Component
import java.math.BigDecimal
data class LeaderResearchCandidateDtoContext(
val leadersById: Map<Long, Leader> = emptyMap(),
val poolsById: Map<Long, LeaderPool> = emptyMap(),
val latestSessionsByCandidateId: Map<Long, LeaderPaperSession> = emptyMap()
)
@Component
class LeaderResearchMapper(
private val leaderRepository: LeaderRepository,
private val leaderPoolRepository: LeaderPoolRepository,
private val sourceStateRepository: LeaderResearchSourceStateRepository
) {
fun runDto(run: LeaderResearchRun): LeaderResearchRunDto {
return LeaderResearchRunDto(
id = run.id ?: 0,
status = run.status.name,
triggerType = run.triggerType.name,
dryRun = run.dryRun,
startedAt = run.startedAt,
finishedAt = run.finishedAt,
durationMs = run.durationMs,
sourceCountsJson = run.sourceCountsJson,
candidateCountsJson = run.candidateCountsJson,
partialFailure = run.partialFailure,
skippedReason = run.skippedReason,
errorClass = run.errorClass,
errorMessage = run.errorMessage
)
}
fun candidateDto(candidate: LeaderResearchCandidate, latestSession: LeaderPaperSession? = null): LeaderResearchCandidateDto {
val leader = candidate.leaderId?.let { leaderRepository.findById(it).orElse(null) }
val pool = candidate.poolId?.let { leaderPoolRepository.findById(it).orElse(null) }
return candidateDto(candidate, leader, pool, latestSession)
}
fun candidateDtos(candidates: List<LeaderResearchCandidate>, context: LeaderResearchCandidateDtoContext): List<LeaderResearchCandidateDto> {
return candidates.map { candidate ->
val candidateId = candidate.id
candidateDto(
candidate = candidate,
leader = candidate.leaderId?.let { context.leadersById[it] },
pool = candidate.poolId?.let { context.poolsById[it] },
latestSession = candidateId?.let { context.latestSessionsByCandidateId[it] }
)
}
}
private fun candidateDto(
candidate: LeaderResearchCandidate,
leader: Leader?,
pool: LeaderPool?,
latestSession: LeaderPaperSession?
): LeaderResearchCandidateDto {
return LeaderResearchCandidateDto(
id = candidate.id ?: 0,
normalizedWallet = candidate.normalizedWallet,
leaderId = candidate.leaderId,
leaderName = leader?.leaderName,
poolId = candidate.poolId,
poolStatus = pool?.status?.name,
suggestedFixedAmount = pool?.suggestedFixedAmount?.strip(),
suggestedMaxDailyLoss = pool?.suggestedMaxDailyLoss?.strip(),
suggestedMaxDailyOrders = pool?.suggestedMaxDailyOrders,
suggestedMinPrice = pool?.suggestedMinPrice?.strip(),
suggestedMaxPrice = pool?.suggestedMaxPrice?.strip(),
suggestedMaxPositionValue = pool?.suggestedMaxPositionValue?.strip(),
researchState = candidate.researchState.name,
source = candidate.source,
sourceRank = candidate.sourceRank,
score = candidate.score?.strip(),
scoreVersion = candidate.scoreVersion,
reason = candidate.reason,
riskFlags = splitFlags(candidate.riskFlags),
locked = candidate.locked,
agentOwned = candidate.agentOwned,
provenance = candidate.provenance.name,
sourceEvidence = candidate.sourceEvidence,
firstSeenAt = candidate.firstSeenAt,
lastSourceSeenAt = candidate.lastSourceSeenAt,
lastScoredAt = candidate.lastScoredAt,
cooldownUntil = candidate.cooldownUntil,
cooldownCount = candidate.cooldownCount,
trialReadyAt = candidate.trialReadyAt,
retiredAt = candidate.retiredAt,
lastPaperSessionId = candidate.lastPaperSessionId,
latestPaperSession = latestSession?.let { paperSessionDto(it) }
)
}
fun scoreDto(score: LeaderResearchScore): LeaderResearchScoreDto {
return LeaderResearchScoreDto(
id = score.id ?: 0,
candidateId = score.candidateId,
runId = score.runId,
scoreVersion = score.scoreVersion,
totalScore = score.totalScore.strip(),
profitSignal = score.profitSignal.strip(),
repeatability = score.repeatability.strip(),
liquidityFit = score.liquidityFit.strip(),
entryPriceFit = score.entryPriceFit.strip(),
slippageRisk = score.slippageRisk.strip(),
holdingPeriodFit = score.holdingPeriodFit.strip(),
marketTypeRisk = score.marketTypeRisk.strip(),
drawdownRisk = score.drawdownRisk.strip(),
exitLiquidityRisk = score.exitLiquidityRisk.strip(),
dataFreshness = score.dataFreshness.strip(),
filterPassRate = score.filterPassRate.strip(),
sampleTradeCount = score.sampleTradeCount,
reason = score.reason,
createdAt = score.createdAt
)
}
fun paperSessionDto(session: LeaderPaperSession): LeaderPaperSessionDto {
return LeaderPaperSessionDto(
id = session.id ?: 0,
candidateId = session.candidateId,
status = session.status.name,
startedAt = session.startedAt,
endedAt = session.endedAt,
tradeCount = session.tradeCount,
filteredCount = session.filteredCount,
openExposure = session.openExposure.strip(),
totalRealizedPnl = session.totalRealizedPnl.strip(),
totalUnrealizedPnl = session.totalUnrealizedPnl.strip(),
copyablePnl = session.copyablePnl.strip(),
maxDrawdown = session.maxDrawdown.strip(),
unknownValuationExposure = session.unknownValuationExposure.strip(),
confirmedZeroExposure = session.confirmedZeroExposure.strip(),
filteredRatio = session.filteredRatio.strip(),
lastProcessedEventTime = session.lastProcessedEventTime,
scoreSnapshot = session.scoreSnapshot?.strip()
)
}
fun paperTradeDto(trade: LeaderPaperTrade): LeaderPaperTradeDto {
return LeaderPaperTradeDto(
id = trade.id ?: 0,
sessionId = trade.sessionId,
candidateId = trade.candidateId,
activityEventId = trade.activityEventId,
leaderTradeId = trade.leaderTradeId,
marketId = trade.marketId,
marketTitle = trade.marketTitle,
marketSlug = trade.marketSlug,
side = trade.side,
outcome = trade.outcome,
outcomeIndex = trade.outcomeIndex,
leaderPrice = trade.leaderPrice?.strip(),
leaderSize = trade.leaderSize?.strip(),
simulatedPrice = trade.simulatedPrice?.strip(),
simulatedSize = trade.simulatedSize?.strip(),
simulatedAmount = trade.simulatedAmount?.strip(),
fillAssumption = trade.fillAssumption.name,
quoteConfidence = trade.quoteConfidence.name,
quoteSource = trade.quoteSource,
quoteTimestamp = trade.quoteTimestamp,
filterResult = trade.filterResult.name,
filterReason = trade.filterReason,
valuationStatus = trade.valuationStatus.name,
realizedPnl = trade.realizedPnl?.strip(),
eventTime = trade.eventTime,
createdAt = trade.createdAt
)
}
fun paperPositionDto(position: LeaderPaperPosition): LeaderPaperPositionDto {
return LeaderPaperPositionDto(
id = position.id ?: 0,
sessionId = position.sessionId,
candidateId = position.candidateId,
marketId = position.marketId,
outcome = position.outcome,
outcomeIndex = position.outcomeIndex,
quantity = position.quantity.strip(),
cost = position.cost.strip(),
avgPrice = position.avgPrice.strip(),
currentPrice = position.currentPrice?.strip(),
currentValue = position.currentValue.strip(),
realizedPnl = position.realizedPnl.strip(),
unrealizedPnl = position.unrealizedPnl.strip(),
valuationStatus = position.valuationStatus.name,
quoteConfidence = position.quoteConfidence.name,
quoteSource = position.quoteSource,
quoteTimestamp = position.quoteTimestamp,
updatedAt = position.updatedAt
)
}
fun sourceStateDto(state: LeaderResearchSourceState): LeaderResearchSourceStateDto {
return LeaderResearchSourceStateDto(
sourceType = state.sourceType.name,
status = state.status.name,
lastSuccessAt = state.lastSuccessAt,
lastFailureAt = state.lastFailureAt,
lastRunAt = state.lastRunAt,
lastCandidateCount = state.lastCandidateCount,
errorClass = state.errorClass,
errorMessage = state.errorMessage,
stale = state.stale,
disabledReason = state.disabledReason,
lastCursor = state.lastCursor,
updatedAt = state.updatedAt
)
}
fun eventDto(event: LeaderResearchEvent): LeaderResearchEventDto {
return LeaderResearchEventDto(
id = event.id ?: 0,
candidateId = event.candidateId,
runId = event.runId,
eventType = event.eventType.name,
reason = event.reason,
payloadSummary = event.payloadSummary,
notificationStatus = event.notificationStatus.name,
notificationError = event.notificationError,
dedupeKey = event.dedupeKey,
createdAt = event.createdAt,
notifiedAt = event.notifiedAt
)
}
fun sourceLimitations(): List<String> {
return sourceStateRepository.findAllByOrderByUpdatedAtDesc()
.filter { it.stale || it.status.name == "DISABLED" || !it.disabledReason.isNullOrBlank() }
.map { "${it.sourceType.name}: ${it.disabledReason ?: it.errorMessage ?: it.status.name}" }
}
fun isTrialOrActive(status: LeaderPoolStatus?): Boolean {
return status == LeaderPoolStatus.TRIAL || status == LeaderPoolStatus.ACTIVE
}
private fun splitFlags(raw: String?): List<String> {
if (raw.isNullOrBlank()) return emptyList()
return raw.split(",", "\n", ";").map { it.trim() }.filter { it.isNotEmpty() }
}
private fun BigDecimal.strip(): String = stripTrailingZeros().toPlainString()
}
@@ -0,0 +1,72 @@
package com.wrbug.polymarketbot.service.copytrading.research
import com.wrbug.polymarketbot.entity.LeaderResearchEvent
import com.wrbug.polymarketbot.enums.LeaderResearchEventType
import com.wrbug.polymarketbot.enums.LeaderResearchNotificationStatus
import com.wrbug.polymarketbot.repository.LeaderResearchEventRepository
import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
data class LeaderResearchNotificationSummary(
val total: Int,
val newCandidates: Int,
val trialReady: Int,
val cooldowns: Int,
val sourceFailures: Int,
val valuationWarnings: Int,
val approvalWarnings: Int,
val lines: List<String>
)
@Service
class LeaderResearchNotificationSummaryService(
private val eventRepository: LeaderResearchEventRepository
) {
fun buildPendingSummary(limit: Int = 100): LeaderResearchNotificationSummary {
val events = eventRepository.findByNotificationStatusOrderByCreatedAtAsc(
LeaderResearchNotificationStatus.PENDING,
PageRequest.of(0, limit.coerceIn(1, 500))
).content
return summarize(events)
}
@Transactional
fun markPendingAsSkipped(limit: Int = 100, reason: String = "operator_console_only"): LeaderResearchNotificationSummary {
val events = eventRepository.findByNotificationStatusOrderByCreatedAtAsc(
LeaderResearchNotificationStatus.PENDING,
PageRequest.of(0, limit.coerceIn(1, 500))
).content
val now = System.currentTimeMillis()
events.forEach { event ->
eventRepository.save(
event.copy(
notificationStatus = LeaderResearchNotificationStatus.SKIPPED,
notificationError = reason,
notifiedAt = now
)
)
}
return summarize(events)
}
private fun summarize(events: List<LeaderResearchEvent>): LeaderResearchNotificationSummary {
val lines = events.take(20).map { event ->
"${event.eventType.name}: ${event.reason ?: event.payloadSummary ?: "no details"}"
}
return LeaderResearchNotificationSummary(
total = events.size,
newCandidates = events.count { it.eventType == LeaderResearchEventType.CANDIDATE_DISCOVERED },
trialReady = events.count { it.eventType == LeaderResearchEventType.TRIAL_READY },
cooldowns = events.count { it.eventType == LeaderResearchEventType.COOLDOWN },
sourceFailures = events.count { it.eventType == LeaderResearchEventType.SOURCE_FAILURE },
valuationWarnings = events.count { it.eventType == LeaderResearchEventType.VALUATION_STALE },
approvalWarnings = events.count {
it.eventType == LeaderResearchEventType.APPROVAL_REJECTED ||
it.eventType == LeaderResearchEventType.DUPLICATE_APPROVAL ||
it.eventType == LeaderResearchEventType.REAL_MONEY_ACTIVATION_FORBIDDEN
},
lines = lines
)
}
}
@@ -0,0 +1,99 @@
package com.wrbug.polymarketbot.service.copytrading.research
import com.wrbug.polymarketbot.entity.Leader
import com.wrbug.polymarketbot.entity.LeaderPool
import com.wrbug.polymarketbot.entity.LeaderResearchCandidate
import com.wrbug.polymarketbot.enums.LeaderPoolStatus
import com.wrbug.polymarketbot.enums.LeaderResearchState
import com.wrbug.polymarketbot.repository.LeaderPoolRepository
import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.repository.LeaderResearchCandidateRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
@Service
class LeaderResearchPoolMappingService(
private val leaderRepository: LeaderRepository,
private val leaderPoolRepository: LeaderPoolRepository,
private val candidateRepository: LeaderResearchCandidateRepository
) {
@Transactional
fun syncCandidate(candidate: LeaderResearchCandidate): LeaderResearchCandidate {
require(candidate.researchState != LeaderResearchState.DISCOVERED) {
"DISCOVERED research candidates must not be synced to Leader Pool"
}
val now = System.currentTimeMillis()
val leader = ensureLeader(candidate)
val pool = ensurePool(candidate, leader)
val badge = when (candidate.researchState) {
LeaderResearchState.TRIAL_READY -> "RESEARCH_TRIAL_READY"
LeaderResearchState.PAPER -> "RESEARCH_PAPER"
LeaderResearchState.COOLDOWN -> "RESEARCH_COOLDOWN"
else -> null
}
val savedPool = leaderPoolRepository.save(
pool.copy(
researchCandidateId = candidate.id,
researchState = candidate.researchState,
researchBadge = badge,
researchSummary = candidate.reason?.take(1000),
researchScore = candidate.score,
researchUpdatedAt = now,
updatedAt = now
)
)
return candidateRepository.save(
candidate.copy(
leaderId = leader.id,
poolId = savedPool.id,
updatedAt = now
)
)
}
private fun ensureLeader(candidate: LeaderResearchCandidate): Leader {
candidate.leaderId?.let { id ->
leaderRepository.findById(id).orElse(null)?.let { return it }
}
leaderRepository.findByLeaderAddress(candidate.normalizedWallet)?.let { return it }
val now = System.currentTimeMillis()
return leaderRepository.save(
Leader(
leaderAddress = candidate.normalizedWallet,
leaderName = "Research ${candidate.normalizedWallet.take(6)}...${candidate.normalizedWallet.takeLast(4)}",
remark = "Created by Leader Research Agent. Manual enable is required before real-money copy trading.",
createdAt = now,
updatedAt = now
)
)
}
private fun ensurePool(candidate: LeaderResearchCandidate, leader: Leader): LeaderPool {
leader.id?.let { leaderPoolRepository.findByLeaderId(it) }?.let { return it }
val now = System.currentTimeMillis()
return leaderPoolRepository.save(
LeaderPool(
leaderId = leader.id ?: 0,
status = LeaderPoolStatus.WATCH,
source = "RESEARCH_AGENT",
score = candidate.score,
reason = candidate.reason,
notes = "Research agent candidate. Pool row is informational until you approve a disabled trial config.",
suggestedFixedAmount = BigDecimal("1.00000000"),
suggestedMaxDailyOrders = 10,
suggestedMaxDailyLoss = BigDecimal("5.00000000"),
suggestedMinPrice = BigDecimal("0.10000000"),
suggestedMaxPrice = BigDecimal("0.80000000"),
suggestedMaxPositionValue = BigDecimal("5.00000000"),
researchCandidateId = candidate.id,
researchState = candidate.researchState,
researchScore = candidate.score,
researchSummary = candidate.reason,
researchUpdatedAt = now,
createdAt = now,
updatedAt = now
)
)
}
}
@@ -0,0 +1,61 @@
package com.wrbug.polymarketbot.service.copytrading.research
import com.wrbug.polymarketbot.enums.LeaderPaperProcessingStatus
import com.wrbug.polymarketbot.enums.LeaderPaperSessionStatus
import com.wrbug.polymarketbot.repository.LeaderActivityEventRepository
import com.wrbug.polymarketbot.repository.LeaderPaperSessionRepository
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.PageRequest
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
data class LeaderResearchRetentionResult(
val deletedActivityEvents: Long,
val deletedPaperSessions: Long
)
@Service
class LeaderResearchRetentionService(
private val activityEventRepository: LeaderActivityEventRepository,
private val paperSessionRepository: LeaderPaperSessionRepository,
@Value("\${leader.research.retention.enabled:true}") private val enabled: Boolean,
@Value("\${leader.research.retention.activity-days:90}") private val activityRetentionDays: Long,
@Value("\${leader.research.retention.paper-session-days:180}") private val paperSessionRetentionDays: Long,
@Value("\${leader.research.retention.max-paper-sessions-per-run:100}") private val maxPaperSessionsPerRun: Int
) {
@Scheduled(cron = "\${leader.research.retention.cron:0 17 3 * * *}")
fun scheduledCleanup() {
if (!enabled) return
cleanup()
}
@Transactional
fun cleanup(now: Long = System.currentTimeMillis()): LeaderResearchRetentionResult {
if (!enabled) return LeaderResearchRetentionResult(0, 0)
val activityCutoff = now - activityRetentionDays.coerceAtLeast(7) * MILLIS_PER_DAY
val paperCutoff = now - paperSessionRetentionDays.coerceAtLeast(30) * MILLIS_PER_DAY
val deletedActivities = activityEventRepository.deleteByEventTimeLessThanAndPaperProcessingStatusIn(
activityCutoff,
listOf(
LeaderPaperProcessingStatus.PROCESSED,
LeaderPaperProcessingStatus.FILTERED,
LeaderPaperProcessingStatus.FAILED
)
)
val staleSessions = paperSessionRepository.findByUpdatedAtLessThanAndStatusIn(
paperCutoff,
listOf(LeaderPaperSessionStatus.COMPLETED, LeaderPaperSessionStatus.FAILED),
PageRequest.of(0, maxPaperSessionsPerRun.coerceIn(1, 1000))
)
paperSessionRepository.deleteAll(staleSessions.content)
return LeaderResearchRetentionResult(
deletedActivityEvents = deletedActivities,
deletedPaperSessions = staleSessions.content.size.toLong()
)
}
companion object {
private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000
}
}
@@ -0,0 +1,166 @@
package com.wrbug.polymarketbot.service.copytrading.research
import com.wrbug.polymarketbot.entity.LeaderPaperSession
import com.wrbug.polymarketbot.entity.LeaderResearchCandidate
import com.wrbug.polymarketbot.entity.LeaderResearchScore
import com.wrbug.polymarketbot.enums.LeaderResearchState
import com.wrbug.polymarketbot.repository.LeaderPaperSessionRepository
import com.wrbug.polymarketbot.repository.LeaderResearchCandidateRepository
import com.wrbug.polymarketbot.repository.LeaderResearchScoreRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
import java.math.RoundingMode
@Service
class LeaderResearchScoringService(
private val candidateRepository: LeaderResearchCandidateRepository,
private val paperSessionRepository: LeaderPaperSessionRepository,
private val scoreRepository: LeaderResearchScoreRepository
) {
@Transactional
fun scoreAll(runId: Long?): List<LeaderResearchScore> {
return candidateRepository.findByResearchStateIn(
listOf(
LeaderResearchState.DISCOVERED,
LeaderResearchState.CANDIDATE,
LeaderResearchState.PAPER,
LeaderResearchState.TRIAL_READY,
LeaderResearchState.COOLDOWN
)
).map { scoreCandidate(it, runId) }
}
@Transactional
fun scoreCandidate(candidate: LeaderResearchCandidate, runId: Long?): LeaderResearchScore {
val session = candidate.id?.let { paperSessionRepository.findTopByCandidateIdOrderByStartedAtDesc(it) }
val score = compute(candidate, session, runId)
val savedScore = scoreRepository.save(score)
val now = System.currentTimeMillis()
candidateRepository.save(
candidate.copy(
score = savedScore.totalScore,
scoreVersion = savedScore.scoreVersion,
reason = savedScore.reason,
riskFlags = buildRiskFlags(session),
lastScoredAt = now,
updatedAt = now
)
)
return savedScore
}
fun compute(candidate: LeaderResearchCandidate, session: LeaderPaperSession?, runId: Long?): LeaderResearchScore {
val now = System.currentTimeMillis()
val sourceFresh = candidate.lastSourceSeenAt?.let { now - it <= SOURCE_FRESH_MS } == true
val paperAgeMs = session?.let { now - it.startedAt } ?: 0L
val unknownRatio = session?.unknownRatio() ?: BigDecimal.ONE
val filteredRatio = session?.filteredRatio ?: BigDecimal.ONE
val copyablePnl = session?.copyablePnl ?: BigDecimal.ZERO
val tradeCount = session?.tradeCount ?: 0
val profitSignal = when {
copyablePnl > BigDecimal("10") -> BigDecimal("20")
copyablePnl > BigDecimal.ZERO -> copyablePnl.multiply(BigDecimal("2")).clamp(BigDecimal.ZERO, BigDecimal("20"))
else -> BigDecimal.ZERO
}
val repeatability = BigDecimal(tradeCount).multiply(BigDecimal("1.5")).clamp(BigDecimal.ZERO, BigDecimal("15"))
val liquidityFit = BigDecimal("10").subtract(unknownRatio.multiply(BigDecimal("10"))).clamp(BigDecimal.ZERO, BigDecimal("10"))
val entryPriceFit = BigDecimal("10").subtract(filteredRatio.multiply(BigDecimal("10"))).clamp(BigDecimal.ZERO, BigDecimal("10"))
val slippageRisk = if (unknownRatio <= BigDecimal("0.20")) BigDecimal("10") else BigDecimal("4")
val holdingPeriodFit = if (paperAgeMs >= PAPER_MIN_AGE_MS) BigDecimal("5") else BigDecimal(paperAgeMs).safeDivide(BigDecimal(PAPER_MIN_AGE_MS)).multiply(BigDecimal("5"))
val marketTypeRisk = BigDecimal("5")
val drawdownRisk = when {
session == null -> BigDecimal("5")
session.maxDrawdown >= BigDecimal("-5") -> BigDecimal("10")
session.maxDrawdown >= BigDecimal("-15") -> BigDecimal("7")
session.maxDrawdown >= BigDecimal("-20") -> BigDecimal("3")
else -> BigDecimal.ZERO
}
val exitLiquidityRisk = if (unknownRatio <= BigDecimal("0.20")) BigDecimal("5") else BigDecimal("1")
val dataFreshness = if (sourceFresh) BigDecimal("5") else BigDecimal.ZERO
val filterPassRate = BigDecimal("5").subtract(filteredRatio.multiply(BigDecimal("5"))).clamp(BigDecimal.ZERO, BigDecimal("5"))
val rawTotal = listOf(
profitSignal,
repeatability,
liquidityFit,
entryPriceFit,
slippageRisk,
holdingPeriodFit,
marketTypeRisk,
drawdownRisk,
exitLiquidityRisk,
dataFreshness,
filterPassRate
).fold(BigDecimal.ZERO, BigDecimal::add).setScale(8, RoundingMode.HALF_UP)
val sampleCapApplied = tradeCount < PAPER_MIN_TRADES && rawTotal > SAMPLE_INSUFFICIENT_CAP
val total = if (sampleCapApplied) SAMPLE_INSUFFICIENT_CAP else rawTotal
val reason = listOf(
"score_v1=$total",
"copyable_pnl=$copyablePnl",
"trades=$tradeCount",
"sample_cap_applied=$sampleCapApplied",
"unknown_quote_ratio=${unknownRatio.setScale(4, RoundingMode.HALF_UP)}",
"filtered_ratio=${filteredRatio.setScale(4, RoundingMode.HALF_UP)}",
"source_fresh=$sourceFresh"
).joinToString("; ")
return LeaderResearchScore(
candidateId = candidate.id ?: 0,
runId = runId,
scoreVersion = SCORE_VERSION,
totalScore = total,
profitSignal = profitSignal,
repeatability = repeatability,
liquidityFit = liquidityFit,
entryPriceFit = entryPriceFit,
slippageRisk = slippageRisk,
holdingPeriodFit = holdingPeriodFit,
marketTypeRisk = marketTypeRisk,
drawdownRisk = drawdownRisk,
exitLiquidityRisk = exitLiquidityRisk,
dataFreshness = dataFreshness,
filterPassRate = filterPassRate,
sampleTradeCount = tradeCount,
reason = reason,
createdAt = System.currentTimeMillis()
)
}
private fun buildRiskFlags(session: LeaderPaperSession?): String? {
if (session == null) return "no_paper_session"
val flags = mutableListOf<String>()
if (session.maxDrawdown < BigDecimal("-15")) flags += "drawdown_gt_15"
if (session.filteredRatio >= BigDecimal("0.50")) flags += "high_filtered_ratio"
if (session.unknownRatio() > BigDecimal("0.20")) flags += "high_unknown_quote_exposure"
if (session.tradeCount < 10) flags += "small_sample"
return flags.takeIf { it.isNotEmpty() }?.joinToString(",")
}
private fun LeaderPaperSession.unknownRatio(): BigDecimal {
if (openExposure <= BigDecimal.ZERO) return BigDecimal.ZERO
return unknownValuationExposure.safeDivide(openExposure)
}
private fun BigDecimal.safeDivide(other: BigDecimal): BigDecimal {
if (other.compareTo(BigDecimal.ZERO) == 0) return BigDecimal.ZERO
return divide(other, 8, RoundingMode.HALF_UP)
}
private fun BigDecimal.clamp(min: BigDecimal, max: BigDecimal): BigDecimal {
return when {
this < min -> min
this > max -> max
else -> this
}
}
companion object {
const val SCORE_VERSION = "research-copyability-v1"
private val SAMPLE_INSUFFICIENT_CAP = BigDecimal("59")
private const val PAPER_MIN_TRADES = 10
private const val SOURCE_FRESH_MS = 48L * 60 * 60 * 1000
private const val PAPER_MIN_AGE_MS = 7L * 24 * 60 * 60 * 1000
}
}
@@ -0,0 +1,115 @@
package com.wrbug.polymarketbot.service.copytrading.research
import com.wrbug.polymarketbot.dto.LeaderPaperSessionDto
import com.wrbug.polymarketbot.dto.LeaderResearchCandidateDetailDto
import com.wrbug.polymarketbot.dto.LeaderResearchCandidateDto
import com.wrbug.polymarketbot.dto.LeaderResearchCandidateListRequest
import com.wrbug.polymarketbot.dto.LeaderResearchCandidateListResponse
import com.wrbug.polymarketbot.dto.LeaderResearchEventDto
import com.wrbug.polymarketbot.dto.LeaderResearchSourceStateDto
import com.wrbug.polymarketbot.dto.LeaderResearchSummaryDto
import com.wrbug.polymarketbot.enums.LeaderResearchState
import com.wrbug.polymarketbot.repository.LeaderPaperPositionRepository
import com.wrbug.polymarketbot.repository.LeaderPaperSessionRepository
import com.wrbug.polymarketbot.repository.LeaderPaperTradeRepository
import com.wrbug.polymarketbot.repository.LeaderPoolRepository
import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.repository.LeaderResearchCandidateRepository
import com.wrbug.polymarketbot.repository.LeaderResearchEventRepository
import com.wrbug.polymarketbot.repository.LeaderResearchRunRepository
import com.wrbug.polymarketbot.repository.LeaderResearchScoreRepository
import com.wrbug.polymarketbot.repository.LeaderResearchSourceStateRepository
import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Service
@Service
class LeaderResearchService(
private val candidateRepository: LeaderResearchCandidateRepository,
private val runRepository: LeaderResearchRunRepository,
private val scoreRepository: LeaderResearchScoreRepository,
private val sourceStateRepository: LeaderResearchSourceStateRepository,
private val eventRepository: LeaderResearchEventRepository,
private val paperSessionRepository: LeaderPaperSessionRepository,
private val paperTradeRepository: LeaderPaperTradeRepository,
private val paperPositionRepository: LeaderPaperPositionRepository,
private val leaderRepository: LeaderRepository,
private val leaderPoolRepository: LeaderPoolRepository,
private val mapper: LeaderResearchMapper
) {
fun summary(): LeaderResearchSummaryDto {
return LeaderResearchSummaryDto(
discoveredCount = candidateRepository.countByResearchState(LeaderResearchState.DISCOVERED),
candidateCount = candidateRepository.countByResearchState(LeaderResearchState.CANDIDATE),
paperCount = candidateRepository.countByResearchState(LeaderResearchState.PAPER),
trialReadyCount = candidateRepository.countByResearchState(LeaderResearchState.TRIAL_READY),
cooldownCount = candidateRepository.countByResearchState(LeaderResearchState.COOLDOWN),
retiredCount = candidateRepository.countByResearchState(LeaderResearchState.RETIRED),
activePaperSessions = candidateRepository.findByResearchStateIn(listOf(LeaderResearchState.PAPER, LeaderResearchState.TRIAL_READY)).count().toLong(),
pendingRiskCount = candidateRepository.findByResearchStateIn(listOf(LeaderResearchState.COOLDOWN)).count().toLong(),
lastRun = runRepository.findTopByOrderByStartedAtDesc()?.let { mapper.runDto(it) },
sourceLimitations = mapper.sourceLimitations()
)
}
fun listCandidates(request: LeaderResearchCandidateListRequest): LeaderResearchCandidateListResponse {
val pageable = PageRequest.of(request.page.coerceAtLeast(0), request.size.coerceIn(1, 100))
val state = request.state?.trim()?.takeIf { it.isNotBlank() }?.let { LeaderResearchState.valueOf(it.uppercase()) }
val query = request.query?.trim()?.lowercase()?.takeIf { it.isNotBlank() }
val page = candidateRepository.search(state, query, pageable)
val content = page.content
return LeaderResearchCandidateListResponse(
list = mapper.candidateDtos(content, listContext(content)),
total = page.totalElements,
summary = summary()
)
}
private fun listContext(candidates: List<com.wrbug.polymarketbot.entity.LeaderResearchCandidate>): LeaderResearchCandidateDtoContext {
if (candidates.isEmpty()) return LeaderResearchCandidateDtoContext()
val leaderIds = candidates.mapNotNull { it.leaderId }.distinct()
val poolIds = candidates.mapNotNull { it.poolId }.distinct()
val candidateIds = candidates.mapNotNull { it.id }.distinct()
return LeaderResearchCandidateDtoContext(
leadersById = if (leaderIds.isEmpty()) emptyMap() else leaderRepository.findByIdIn(leaderIds)
.mapNotNull { leader -> leader.id?.let { it to leader } }
.toMap(),
poolsById = if (poolIds.isEmpty()) emptyMap() else leaderPoolRepository.findByIdIn(poolIds)
.mapNotNull { pool -> pool.id?.let { it to pool } }
.toMap(),
latestSessionsByCandidateId = if (candidateIds.isEmpty()) emptyMap() else paperSessionRepository.findLatestByCandidateIds(candidateIds)
.associateBy { it.candidateId }
)
}
fun detail(candidateId: Long): LeaderResearchCandidateDetailDto {
val candidate = candidateRepository.findById(candidateId).orElseThrow { IllegalArgumentException("候选不存在") }
val sessions = paperSessionRepository.findByCandidateIdOrderByStartedAtDesc(candidateId)
val latestSession = sessions.firstOrNull()
val trades = latestSession?.id?.let {
paperTradeRepository.findBySessionIdOrderByEventTimeDesc(it, PageRequest.of(0, 100)).content
}.orEmpty()
val positions = latestSession?.id?.let { paperPositionRepository.findBySessionIdOrderByUpdatedAtDesc(it) }.orEmpty()
return LeaderResearchCandidateDetailDto(
candidate = mapper.candidateDto(candidate, latestSession),
latestScore = scoreRepository.findTopByCandidateIdOrderByCreatedAtDesc(candidateId)?.let { mapper.scoreDto(it) },
paperSessions = sessions.map { mapper.paperSessionDto(it) },
paperTrades = trades.map { mapper.paperTradeDto(it) },
paperPositions = positions.map { mapper.paperPositionDto(it) },
events = eventRepository.findByCandidateIdOrderByCreatedAtDesc(candidateId, PageRequest.of(0, 100)).content.map { mapper.eventDto(it) }
)
}
fun sourceHealth(): List<LeaderResearchSourceStateDto> {
return sourceStateRepository.findAllByOrderByUpdatedAtDesc().map { mapper.sourceStateDto(it) }
}
fun events(page: Int, size: Int): List<LeaderResearchEventDto> {
return eventRepository.findAllByOrderByCreatedAtDesc(PageRequest.of(page.coerceAtLeast(0), size.coerceIn(1, 100)))
.content
.map { mapper.eventDto(it) }
}
fun paperSessions(candidateId: Long): List<LeaderPaperSessionDto> {
return paperSessionRepository.findByCandidateIdOrderByStartedAtDesc(candidateId).map { mapper.paperSessionDto(it) }
}
}
@@ -0,0 +1,64 @@
package com.wrbug.polymarketbot.service.copytrading.research
import com.wrbug.polymarketbot.entity.LeaderResearchSourceState
import com.wrbug.polymarketbot.enums.LeaderResearchSourceStatus
import com.wrbug.polymarketbot.enums.LeaderResearchSourceType
import com.wrbug.polymarketbot.repository.LeaderResearchSourceStateRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class LeaderResearchSourceHealthService(
private val sourceStateRepository: LeaderResearchSourceStateRepository
) {
@Transactional
fun record(
sourceType: LeaderResearchSourceType,
status: LeaderResearchSourceStatus,
candidateCount: Int = 0,
errorClass: String? = null,
errorMessage: String? = null,
disabledReason: String? = null,
stale: Boolean = false,
lastCursor: String? = null,
now: Long = System.currentTimeMillis()
): LeaderResearchSourceState {
val existing = sourceStateRepository.findBySourceType(sourceType)
val failedLike = status == LeaderResearchSourceStatus.FAILURE ||
status == LeaderResearchSourceStatus.DEGRADED ||
status == LeaderResearchSourceStatus.STALE
val nextDisabledReason = when {
disabledReason != null -> disabledReason
status == LeaderResearchSourceStatus.SUCCESS -> null
else -> existing?.disabledReason
}
val state = existing?.copy(
status = status,
lastSuccessAt = if (status == LeaderResearchSourceStatus.SUCCESS) now else existing.lastSuccessAt,
lastFailureAt = if (failedLike) now else existing.lastFailureAt,
lastRunAt = now,
lastCandidateCount = candidateCount,
errorClass = errorClass,
errorMessage = errorMessage,
stale = stale || status == LeaderResearchSourceStatus.STALE,
disabledReason = nextDisabledReason,
lastCursor = lastCursor ?: existing.lastCursor,
updatedAt = now
) ?: LeaderResearchSourceState(
sourceType = sourceType,
status = status,
lastSuccessAt = if (status == LeaderResearchSourceStatus.SUCCESS) now else null,
lastFailureAt = if (failedLike) now else null,
lastRunAt = now,
lastCandidateCount = candidateCount,
errorClass = errorClass,
errorMessage = errorMessage,
stale = stale || status == LeaderResearchSourceStatus.STALE,
disabledReason = nextDisabledReason,
lastCursor = lastCursor,
createdAt = now,
updatedAt = now
)
return sourceStateRepository.save(state)
}
}
@@ -0,0 +1,518 @@
package com.wrbug.polymarketbot.service.copytrading.research
import com.wrbug.polymarketbot.entity.Leader
import com.wrbug.polymarketbot.entity.LeaderResearchCandidate
import com.wrbug.polymarketbot.enums.LeaderCandidateProvenance
import com.wrbug.polymarketbot.enums.LeaderResearchEventType
import com.wrbug.polymarketbot.enums.LeaderResearchSourceStatus
import com.wrbug.polymarketbot.enums.LeaderResearchSourceType
import com.wrbug.polymarketbot.enums.LeaderResearchState
import com.wrbug.polymarketbot.repository.LeaderActivityEventRepository
import com.wrbug.polymarketbot.repository.LeaderPoolRepository
import com.wrbug.polymarketbot.repository.LeaderRepository
import com.wrbug.polymarketbot.repository.LeaderResearchCandidateRepository
import com.wrbug.polymarketbot.repository.SystemConfigRepository
import com.wrbug.polymarketbot.util.RetrofitFactory
import kotlinx.coroutines.runBlocking
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
data class LeaderResearchSourceRunResult(
val sourceType: LeaderResearchSourceType,
val candidates: List<LeaderResearchCandidate>,
val status: LeaderResearchSourceStatus,
val errorClass: String? = null,
val errorMessage: String? = null,
val limitation: String? = null,
val expectedLimitation: Boolean = false
)
private data class SourceDiscovery(
val candidates: List<LeaderResearchCandidate>,
val status: LeaderResearchSourceStatus = LeaderResearchSourceStatus.SUCCESS,
val errorClass: String? = null,
val errorMessage: String? = null,
val limitation: String? = null
)
private data class BackfillFailure(
val wallet: String,
val errorClass: String,
val errorMessage: String?
)
private data class BackfillResult(
val attemptedWallets: Int,
val failures: List<BackfillFailure>
) {
val hasFailures: Boolean = failures.isNotEmpty()
fun status(): LeaderResearchSourceStatus =
if (hasFailures) LeaderResearchSourceStatus.DEGRADED else LeaderResearchSourceStatus.SUCCESS
fun errorClass(): String? = failures.firstOrNull()?.errorClass
fun errorMessage(): String? {
if (failures.isEmpty()) return null
val sampled = failures.take(3).joinToString("; ") { "${it.wallet}: ${it.errorMessage ?: it.errorClass}" }
val suffix = if (failures.size > 3) "; +${failures.size - 3} more" else ""
return "Data API backfill failed for ${failures.size}/$attemptedWallets wallets: $sampled$suffix"
}
}
@Service
class LeaderResearchSourceService(
private val candidateRepository: LeaderResearchCandidateRepository,
private val leaderRepository: LeaderRepository,
private val leaderPoolRepository: LeaderPoolRepository,
private val activityEventRepository: LeaderActivityEventRepository,
private val sourceHealthService: LeaderResearchSourceHealthService,
private val systemConfigRepository: SystemConfigRepository,
private val retrofitFactory: RetrofitFactory,
private val eventService: LeaderResearchEventService,
private val ingestionService: LeaderActivityIngestionService,
@Value("\${leader.research.data-api-backfill.limit:200}") private val backfillLimit: Int,
@Value("\${leader.research.global-capture.enabled:false}") private val globalCaptureEnabled: Boolean
) {
private val logger = LoggerFactory.getLogger(LeaderResearchSourceService::class.java)
@Transactional
fun discoverCandidates(runId: Long?): List<LeaderResearchSourceRunResult> {
val results = mutableListOf<LeaderResearchSourceRunResult>()
results += captureSource(LeaderResearchSourceType.WATCHLIST, runId) { discoverWatchlist(runId) }
results += captureSource(LeaderResearchSourceType.EXISTING_LEADER, runId) { discoverExistingLeaders(runId) }
val activityResult = captureSource(LeaderResearchSourceType.ACTIVITY_DERIVED, runId) { discoverFromPersistedActivity(runId) }
results += if (globalCaptureEnabled) activityResult else markActivityDerivedDegraded(activityResult)
if (!globalCaptureEnabled) {
results += markGlobalActivityCaptureDisabled(runId)
}
results += markPublicLeaderboardDisabled(runId)
return results
}
fun previewCandidates(): List<LeaderResearchSourceRunResult> {
val freshAfter = System.currentTimeMillis() - FRESH_ACTIVITY_WINDOW_MS
val watchlist = watchlistWallets().map { transientCandidate(it, LeaderResearchSourceType.WATCHLIST) }
val existing = leaderRepository.findAllByOrderByCreatedAtAsc().map {
transientCandidate(it.leaderAddress, LeaderResearchSourceType.EXISTING_LEADER, it)
}
val activity = activityEventRepository.findByUsableForDiscoveryTrueAndEventTimeGreaterThanEqual(freshAfter)
.mapNotNull { it.normalizedWallet }
.distinct()
.mapIndexed { index, wallet -> transientCandidate(wallet, LeaderResearchSourceType.ACTIVITY_DERIVED, sourceRank = index + 1) }
val results = mutableListOf(
LeaderResearchSourceRunResult(LeaderResearchSourceType.WATCHLIST, watchlist, LeaderResearchSourceStatus.SUCCESS),
LeaderResearchSourceRunResult(LeaderResearchSourceType.EXISTING_LEADER, existing, LeaderResearchSourceStatus.SUCCESS),
LeaderResearchSourceRunResult(
LeaderResearchSourceType.ACTIVITY_DERIVED,
activity,
if (globalCaptureEnabled) LeaderResearchSourceStatus.SUCCESS else LeaderResearchSourceStatus.DEGRADED,
limitation = if (globalCaptureEnabled) null else GLOBAL_CAPTURE_DISABLED_LIMITATION,
expectedLimitation = !globalCaptureEnabled
)
)
if (!globalCaptureEnabled) {
results += LeaderResearchSourceRunResult(
LeaderResearchSourceType.GLOBAL_ACTIVITY_CAPTURE,
emptyList(),
LeaderResearchSourceStatus.DISABLED,
limitation = GLOBAL_CAPTURE_DISABLED_LIMITATION,
expectedLimitation = true
)
}
results += LeaderResearchSourceRunResult(
LeaderResearchSourceType.PUBLIC_LEADERBOARD,
emptyList(),
LeaderResearchSourceStatus.DISABLED,
limitation = PUBLIC_LEADERBOARD_DISABLED_LIMITATION,
expectedLimitation = true
)
return results
}
fun watchlistWallets(): List<String> {
val raw = systemConfigRepository.findByConfigKey(CONFIG_WATCHLIST)?.configValue ?: return emptyList()
return raw.split(",", "\n", ";", " ", "\t")
.mapNotNull { ingestionService.normalizeWallet(it) }
.distinct()
}
private fun captureSource(
sourceType: LeaderResearchSourceType,
runId: Long?,
block: () -> SourceDiscovery
): LeaderResearchSourceRunResult {
val now = System.currentTimeMillis()
return try {
val discovery = block()
saveSourceState(
sourceType = sourceType,
status = discovery.status,
now = now,
candidateCount = discovery.candidates.size,
errorClass = discovery.errorClass,
errorMessage = discovery.errorMessage
)
eventService.record(
type = if (discovery.status == LeaderResearchSourceStatus.SUCCESS) {
LeaderResearchEventType.SOURCE_SUCCESS
} else {
LeaderResearchEventType.SOURCE_FAILURE
},
runId = runId,
reason = if (discovery.status == LeaderResearchSourceStatus.SUCCESS) {
"${sourceType.name} discovered ${discovery.candidates.size} candidates"
} else {
"${sourceType.name} degraded: ${discovery.errorMessage ?: discovery.limitation ?: discovery.status.name}"
},
dedupeKey = "source:${sourceType.name}:$runId:${discovery.status.name.lowercase()}"
)
LeaderResearchSourceRunResult(
sourceType = sourceType,
candidates = discovery.candidates,
status = discovery.status,
errorClass = discovery.errorClass,
errorMessage = discovery.errorMessage,
limitation = discovery.limitation
)
} catch (e: Exception) {
logger.warn("Leader research source failed: source={}, error={}", sourceType, e.message, e)
saveSourceState(
sourceType = sourceType,
status = LeaderResearchSourceStatus.FAILURE,
now = now,
candidateCount = 0,
errorClass = e::class.java.simpleName,
errorMessage = e.message
)
eventService.record(
type = LeaderResearchEventType.SOURCE_FAILURE,
runId = runId,
reason = "${sourceType.name} failed: ${e.message}",
dedupeKey = "source:${sourceType.name}:$runId:failure"
)
LeaderResearchSourceRunResult(sourceType, emptyList(), LeaderResearchSourceStatus.FAILURE, e::class.java.simpleName, e.message)
}
}
private fun discoverWatchlist(runId: Long?): SourceDiscovery {
val wallets = watchlistWallets()
val backfill = backfillWalletActivities(wallets, LeaderResearchSourceType.WATCHLIST, runId)
val candidates = wallets.map { wallet ->
upsertCandidate(
wallet = wallet,
sourceType = LeaderResearchSourceType.WATCHLIST,
leader = leaderRepository.findByLeaderAddress(wallet),
sourceRank = null,
provenance = LeaderCandidateProvenance.AGENT_CREATED,
sourceEvidence = "system_config:$CONFIG_WATCHLIST",
runId = runId
)
}
return SourceDiscovery(
candidates = candidates,
status = backfill.status(),
errorClass = backfill.errorClass(),
errorMessage = backfill.errorMessage()
)
}
private fun discoverExistingLeaders(runId: Long?): SourceDiscovery {
val leaders = leaderRepository.findAllByOrderByCreatedAtAsc()
val backfill = backfillWalletActivities(leaders.map { it.leaderAddress }, LeaderResearchSourceType.EXISTING_LEADER, runId)
val candidates = leaders.map { leader ->
val pool = leader.id?.let { leaderPoolRepository.findByLeaderId(it) }
upsertCandidate(
wallet = leader.leaderAddress,
sourceType = LeaderResearchSourceType.EXISTING_LEADER,
leader = leader,
poolId = pool?.id,
sourceRank = null,
provenance = if (pool == null) LeaderCandidateProvenance.USER_LEADER else LeaderCandidateProvenance.USER_POOL,
sourceEvidence = "existing_leader:${leader.id}",
runId = runId
)
}
return SourceDiscovery(
candidates = candidates,
status = backfill.status(),
errorClass = backfill.errorClass(),
errorMessage = backfill.errorMessage()
)
}
private fun discoverFromPersistedActivity(runId: Long?): SourceDiscovery {
val backfill = backfillWalletActivities(activeResearchWallets(), LeaderResearchSourceType.ACTIVITY_DERIVED, runId)
val freshAfter = System.currentTimeMillis() - FRESH_ACTIVITY_WINDOW_MS
val events = activityEventRepository.findByUsableForDiscoveryTrueAndEventTimeGreaterThanEqual(freshAfter)
val wallets = events.mapNotNull { it.normalizedWallet }.distinct()
val candidates = wallets.mapIndexed { index, wallet ->
upsertCandidate(
wallet = wallet,
sourceType = LeaderResearchSourceType.ACTIVITY_DERIVED,
leader = leaderRepository.findByLeaderAddress(wallet),
sourceRank = index + 1,
provenance = LeaderCandidateProvenance.AGENT_CREATED,
sourceEvidence = "leader_activity_event:fresh_count=${events.count { it.normalizedWallet == wallet }}",
runId = runId
)
}
return SourceDiscovery(
candidates = candidates,
status = backfill.status(),
errorClass = backfill.errorClass(),
errorMessage = backfill.errorMessage()
)
}
private fun activeResearchWallets(): List<String> {
return candidateRepository.findByResearchStateIn(
listOf(LeaderResearchState.DISCOVERED, LeaderResearchState.CANDIDATE, LeaderResearchState.PAPER, LeaderResearchState.TRIAL_READY)
).map { it.normalizedWallet }.distinct()
}
private fun backfillWalletActivities(wallets: List<String>, sourceType: LeaderResearchSourceType, runId: Long?): BackfillResult {
val normalizedWallets = wallets.mapNotNull { ingestionService.normalizeWallet(it) }.distinct()
if (normalizedWallets.isEmpty()) return BackfillResult(0, emptyList())
val dataApi = retrofitFactory.createDataApi()
val startSeconds = (System.currentTimeMillis() - FRESH_ACTIVITY_WINDOW_MS) / 1000
val endSeconds = System.currentTimeMillis() / 1000
val sampledWallets = normalizedWallets.take(MAX_BACKFILL_WALLETS_PER_RUN)
val failures = mutableListOf<BackfillFailure>()
sampledWallets.forEach { wallet ->
try {
val response = runBlocking {
dataApi.getUserActivity(
user = wallet,
type = listOf("TRADE"),
start = startSeconds,
end = endSeconds,
limit = backfillLimit.coerceIn(1, 500),
offset = null,
sortBy = "TIMESTAMP",
sortDirection = "ASC"
)
}
if (!response.isSuccessful || response.body() == null) {
throw IllegalStateException("Data API backfill failed: ${response.code()} ${response.message()}")
}
response.body().orEmpty().forEach { activity ->
ingestionService.ingestUserActivity(activity, sourceType)
}
} catch (e: Exception) {
failures += BackfillFailure(wallet, e::class.java.simpleName, e.message)
eventService.record(
type = LeaderResearchEventType.SOURCE_FAILURE,
runId = runId,
reason = "Data API backfill failed for $wallet: ${e.message}",
payloadSummary = sourceType.name,
dedupeKey = "data-api-backfill:${sourceType.name}:$wallet:${System.currentTimeMillis() / 3600000}"
)
logger.warn("Research Data API backfill failed: source={}, wallet={}, error={}", sourceType, wallet, e.message)
}
}
return BackfillResult(sampledWallets.size, failures)
}
private fun upsertCandidate(
wallet: String,
sourceType: LeaderResearchSourceType,
leader: Leader?,
poolId: Long? = null,
sourceRank: Int?,
provenance: LeaderCandidateProvenance,
sourceEvidence: String,
runId: Long?
): LeaderResearchCandidate {
val normalized = ingestionService.normalizeWallet(wallet)
?: throw IllegalArgumentException("Invalid wallet for research candidate: $wallet")
val now = System.currentTimeMillis()
val existing = candidateRepository.findByNormalizedWallet(normalized)
val saved = if (existing == null) {
candidateRepository.save(
LeaderResearchCandidate(
normalizedWallet = normalized,
leaderId = leader?.id,
poolId = poolId,
researchState = LeaderResearchState.DISCOVERED,
source = sourceType.name,
sourceRank = sourceRank,
agentOwned = provenance == LeaderCandidateProvenance.AGENT_CREATED,
provenance = provenance,
sourceEvidence = sourceEvidence,
firstSeenAt = now,
lastSourceSeenAt = now,
lastTransitionAt = now,
createdAt = now,
updatedAt = now
)
)
} else {
val shouldPreserveHuman = existing.locked || existing.provenance == LeaderCandidateProvenance.MANUAL_LOCKED
candidateRepository.save(
existing.copy(
leaderId = existing.leaderId ?: leader?.id,
poolId = existing.poolId ?: poolId,
source = if (shouldPreserveHuman) existing.source else mergeSource(existing.source, sourceType.name),
sourceRank = existing.sourceRank ?: sourceRank,
provenance = if (shouldPreserveHuman) existing.provenance else strongestProvenance(existing.provenance, provenance),
sourceEvidence = appendEvidence(existing.sourceEvidence, sourceEvidence),
lastSourceSeenAt = now,
updatedAt = now
)
)
}
eventService.record(
type = if (existing == null) LeaderResearchEventType.CANDIDATE_DISCOVERED else LeaderResearchEventType.CANDIDATE_UPDATED,
candidateId = saved.id,
runId = runId,
reason = "Candidate seen from ${sourceType.name}",
payloadSummary = sourceEvidence,
dedupeKey = "candidate:${saved.normalizedWallet}:${sourceType.name}:$runId"
)
return saved
}
private fun saveSourceState(
sourceType: LeaderResearchSourceType,
status: LeaderResearchSourceStatus,
now: Long,
candidateCount: Int,
errorClass: String? = null,
errorMessage: String? = null,
disabledReason: String? = null,
stale: Boolean = false
) {
sourceHealthService.record(
sourceType = sourceType,
status = status,
now = now,
candidateCount = candidateCount,
errorClass = errorClass,
errorMessage = errorMessage,
disabledReason = disabledReason,
stale = stale
)
}
private fun markActivityDerivedDegraded(result: LeaderResearchSourceRunResult): LeaderResearchSourceRunResult {
val expectedLimitation = result.status == LeaderResearchSourceStatus.SUCCESS &&
result.errorClass == null &&
result.errorMessage == null
saveSourceState(
sourceType = LeaderResearchSourceType.ACTIVITY_DERIVED,
status = LeaderResearchSourceStatus.DEGRADED,
now = System.currentTimeMillis(),
candidateCount = result.candidates.size,
errorClass = result.errorClass,
errorMessage = result.errorMessage,
disabledReason = GLOBAL_CAPTURE_DISABLED_LIMITATION,
stale = false
)
return result.copy(
status = LeaderResearchSourceStatus.DEGRADED,
limitation = GLOBAL_CAPTURE_DISABLED_LIMITATION,
expectedLimitation = expectedLimitation
)
}
private fun markPublicLeaderboardDisabled(runId: Long?): LeaderResearchSourceRunResult {
saveSourceState(
sourceType = LeaderResearchSourceType.PUBLIC_LEADERBOARD,
status = LeaderResearchSourceStatus.DISABLED,
now = System.currentTimeMillis(),
candidateCount = 0,
disabledReason = PUBLIC_LEADERBOARD_DISABLED_LIMITATION,
stale = false
)
eventService.record(
type = LeaderResearchEventType.SOURCE_DISABLED,
runId = runId,
reason = PUBLIC_LEADERBOARD_DISABLED_LIMITATION,
dedupeKey = "source:${LeaderResearchSourceType.PUBLIC_LEADERBOARD.name}:disabled"
)
return LeaderResearchSourceRunResult(
sourceType = LeaderResearchSourceType.PUBLIC_LEADERBOARD,
candidates = emptyList(),
status = LeaderResearchSourceStatus.DISABLED,
limitation = PUBLIC_LEADERBOARD_DISABLED_LIMITATION,
expectedLimitation = true
)
}
private fun markGlobalActivityCaptureDisabled(runId: Long?): LeaderResearchSourceRunResult {
saveSourceState(
sourceType = LeaderResearchSourceType.GLOBAL_ACTIVITY_CAPTURE,
status = LeaderResearchSourceStatus.DISABLED,
now = System.currentTimeMillis(),
candidateCount = 0,
disabledReason = GLOBAL_CAPTURE_DISABLED_LIMITATION,
stale = false
)
eventService.record(
type = LeaderResearchEventType.SOURCE_DISABLED,
runId = runId,
reason = GLOBAL_CAPTURE_DISABLED_LIMITATION,
dedupeKey = "source:${LeaderResearchSourceType.GLOBAL_ACTIVITY_CAPTURE.name}:disabled"
)
return LeaderResearchSourceRunResult(
sourceType = LeaderResearchSourceType.GLOBAL_ACTIVITY_CAPTURE,
candidates = emptyList(),
status = LeaderResearchSourceStatus.DISABLED,
limitation = GLOBAL_CAPTURE_DISABLED_LIMITATION,
expectedLimitation = true
)
}
private fun transientCandidate(
wallet: String,
sourceType: LeaderResearchSourceType,
leader: Leader? = leaderRepository.findByLeaderAddress(wallet),
sourceRank: Int? = null
): LeaderResearchCandidate {
val normalized = ingestionService.normalizeWallet(wallet)
?: throw IllegalArgumentException("Invalid wallet for research preview candidate: $wallet")
return LeaderResearchCandidate(
normalizedWallet = normalized,
leaderId = leader?.id,
source = sourceType.name,
sourceRank = sourceRank,
provenance = if (leader == null) LeaderCandidateProvenance.AGENT_CREATED else LeaderCandidateProvenance.USER_LEADER,
sourceEvidence = "preview:${sourceType.name}",
firstSeenAt = System.currentTimeMillis(),
lastSourceSeenAt = System.currentTimeMillis()
)
}
private fun mergeSource(existing: String, incoming: String): String {
val sources = (existing.split(",") + incoming).map { it.trim() }.filter { it.isNotBlank() }.distinct()
return sources.joinToString(",")
}
private fun strongestProvenance(current: LeaderCandidateProvenance, incoming: LeaderCandidateProvenance): LeaderCandidateProvenance {
val rank = mapOf(
LeaderCandidateProvenance.MANUAL_LOCKED to 4,
LeaderCandidateProvenance.USER_POOL to 3,
LeaderCandidateProvenance.USER_LEADER to 2,
LeaderCandidateProvenance.AGENT_CREATED to 1
)
return if ((rank[incoming] ?: 0) > (rank[current] ?: 0)) incoming else current
}
private fun appendEvidence(existing: String?, incoming: String): String {
val lines = (existing?.lines().orEmpty() + incoming).map { it.trim() }.filter { it.isNotBlank() }.distinct()
return lines.takeLast(10).joinToString("\n")
}
companion object {
const val CONFIG_WATCHLIST = "leader_research.watchlist"
const val FRESH_ACTIVITY_WINDOW_MS = 48L * 60 * 60 * 1000
const val MAX_BACKFILL_WALLETS_PER_RUN = 50
private const val GLOBAL_CAPTURE_DISABLED_LIMITATION =
"Global activity capture is disabled; activity-derived discovery only uses already persisted research events."
private const val PUBLIC_LEADERBOARD_DISABLED_LIMITATION =
"Public leaderboard source is intentionally disabled in v1; discovery uses watchlist, existing leaders, and persisted activity only."
}
}
@@ -0,0 +1,154 @@
package com.wrbug.polymarketbot.service.copytrading.research
import com.wrbug.polymarketbot.entity.LeaderPaperSession
import com.wrbug.polymarketbot.entity.LeaderResearchCandidate
import com.wrbug.polymarketbot.enums.LeaderResearchEventType
import com.wrbug.polymarketbot.enums.LeaderResearchState
import com.wrbug.polymarketbot.repository.LeaderPaperSessionRepository
import com.wrbug.polymarketbot.repository.LeaderResearchCandidateRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
@Service
class LeaderResearchStateMachine(
private val candidateRepository: LeaderResearchCandidateRepository,
private val paperSessionRepository: LeaderPaperSessionRepository,
private val paperTradingService: LeaderPaperTradingService,
private val poolMappingService: LeaderResearchPoolMappingService,
private val eventService: LeaderResearchEventService
) {
@Transactional
fun advanceAll(runId: Long?): List<LeaderResearchCandidate> {
return candidateRepository.findByResearchStateIn(
listOf(
LeaderResearchState.DISCOVERED,
LeaderResearchState.CANDIDATE,
LeaderResearchState.PAPER,
LeaderResearchState.TRIAL_READY,
LeaderResearchState.COOLDOWN
)
).map { advance(it, runId) }
}
@Transactional
fun advance(candidate: LeaderResearchCandidate, runId: Long?): LeaderResearchCandidate {
if (candidate.locked) return candidate
val now = System.currentTimeMillis()
val latestSession = candidate.id?.let { paperSessionRepository.findTopByCandidateIdOrderByStartedAtDesc(it) }
val sourceFresh48h = candidate.lastSourceSeenAt?.let { now - it <= SOURCE_FRESH_48H_MS } == true
val sourceFresh72h = candidate.lastSourceSeenAt?.let { now - it <= SOURCE_STALE_72H_MS } == true
val score = candidate.score ?: BigDecimal.ZERO
val nextState = when (candidate.researchState) {
LeaderResearchState.DISCOVERED -> {
if (sourceFresh48h && (score >= BigDecimal("60") || canBootstrapPaperObservation(candidate))) {
LeaderResearchState.CANDIDATE
} else {
candidate.researchState
}
}
LeaderResearchState.CANDIDATE -> {
if (sourceFresh48h && (score >= BigDecimal("60") || latestSession == null && canBootstrapPaperObservation(candidate))) {
LeaderResearchState.PAPER
} else {
candidate.researchState
}
}
LeaderResearchState.PAPER -> {
cooldownReason(latestSession, sourceFresh72h)?.let {
return transition(candidate, LeaderResearchState.COOLDOWN, runId, it)
}
if (latestSession != null && paperTradingService.isEligibleForTrialReady(latestSession, now)) {
LeaderResearchState.TRIAL_READY
} else {
candidate.researchState
}
}
LeaderResearchState.TRIAL_READY -> {
cooldownReason(latestSession, sourceFresh72h)?.let {
return transition(candidate, LeaderResearchState.COOLDOWN, runId, it)
}
candidate.researchState
}
LeaderResearchState.COOLDOWN -> {
val cooldownElapsed = candidate.cooldownUntil?.let { now >= it } ?: true
when {
candidate.cooldownCount >= 3 || candidate.lastSourceSeenAt?.let { now - it > SOURCE_RETIRE_30D_MS } == true -> LeaderResearchState.RETIRED
cooldownElapsed && sourceFresh48h -> LeaderResearchState.CANDIDATE
else -> candidate.researchState
}
}
LeaderResearchState.RETIRED -> candidate.researchState
}
val saved = if (nextState != candidate.researchState) {
transition(candidate, nextState, runId, "state criteria satisfied")
} else {
candidate
}
val withSession = if (saved.researchState == LeaderResearchState.PAPER && latestSession == null) {
val session = paperTradingService.ensureSession(saved, runId)
candidateRepository.save(saved.copy(lastPaperSessionId = session.id, updatedAt = now))
} else {
saved
}
return if (withSession.researchState.canSyncToLeaderPool()) {
poolMappingService.syncCandidate(withSession)
} else {
withSession
}
}
private fun LeaderResearchState.canSyncToLeaderPool(): Boolean {
return this != LeaderResearchState.DISCOVERED
}
private fun cooldownReason(session: LeaderPaperSession?, sourceFresh72h: Boolean): String? {
if (session == null) return null
return paperTradingService.shouldEnterCooldown(session, sourceFresh72h)
}
private fun canBootstrapPaperObservation(candidate: LeaderResearchCandidate): Boolean {
return candidate.agentOwned || candidate.leaderId != null || candidate.poolId != null
}
private fun transition(
candidate: LeaderResearchCandidate,
nextState: LeaderResearchState,
runId: Long?,
reason: String
): LeaderResearchCandidate {
val now = System.currentTimeMillis()
val updated = candidate.copy(
researchState = nextState,
cooldownUntil = if (nextState == LeaderResearchState.COOLDOWN) now + COOLDOWN_MS else candidate.cooldownUntil,
cooldownCount = if (nextState == LeaderResearchState.COOLDOWN) candidate.cooldownCount + 1 else candidate.cooldownCount,
trialReadyAt = if (nextState == LeaderResearchState.TRIAL_READY) now else candidate.trialReadyAt,
retiredAt = if (nextState == LeaderResearchState.RETIRED) now else candidate.retiredAt,
lastTransitionAt = now,
updatedAt = now
)
val saved = candidateRepository.save(updated)
eventService.record(
type = when (nextState) {
LeaderResearchState.TRIAL_READY -> LeaderResearchEventType.TRIAL_READY
LeaderResearchState.COOLDOWN -> LeaderResearchEventType.COOLDOWN
LeaderResearchState.RETIRED -> LeaderResearchEventType.RETIRED
else -> LeaderResearchEventType.STATE_TRANSITION
},
candidateId = saved.id,
runId = runId,
reason = "${candidate.researchState.name} -> ${nextState.name}: $reason",
dedupeKey = "state:${candidate.id}:${nextState.name}:${now / 60000}"
)
return saved
}
companion object {
private const val SOURCE_FRESH_48H_MS = 48L * 60 * 60 * 1000
private const val SOURCE_STALE_72H_MS = 72L * 60 * 60 * 1000
private const val SOURCE_RETIRE_30D_MS = 30L * 24 * 60 * 60 * 1000
private const val COOLDOWN_MS = 3L * 24 * 60 * 60 * 1000
}
}
@@ -562,16 +562,7 @@ open class CopyOrderTrackingService(
// 解密私钥
val decryptedPrivateKey = decryptPrivateKey(account)
// 获取费率(根据 Polymarket Maker Rebates Program 要求)
val feeRateResult = clobService.getFeeRate(tokenId)
val feeRateBps = if (feeRateResult.isSuccess) {
feeRateResult.getOrNull()?.toString() ?: "0"
} else {
logger.warn("获取费率失败,使用默认值 0: tokenId=$tokenId, error=${feeRateResult.exceptionOrNull()?.message}")
"0"
}
logger.info("准备创建买入订单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}, leaderPrice=${trade.price}, tolerance=${copyTrading.priceTolerance}, calculatedPrice=$buyPrice, quantity=$finalBuyQuantity, baseFee=$feeRateBps")
logger.info("准备创建买入订单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}, leaderPrice=${trade.price}, tolerance=${copyTrading.priceTolerance}, calculatedPrice=$buyPrice, quantity=$finalBuyQuantity")
// Neg Risk 市场需用 Neg Risk Exchange 签约,否则服务端返回 invalid signature
val negRisk = marketService.getNegRiskByConditionId(effectiveMarketId) == true
@@ -594,7 +585,6 @@ open class CopyOrderTrackingService(
owner = account.apiKey,
copyTradingId = copyTrading.id!!,
tradeId = trade.id,
feeRateBps = feeRateBps,
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
)
@@ -1018,15 +1008,6 @@ open class CopyOrderTrackingService(
// 8. 解密私钥(在方法开始时解密一次,后续复用)
val decryptedPrivateKey = decryptPrivateKey(account)
// 获取费率(根据 Polymarket Maker Rebates Program 要求)
val feeRateResult = clobService.getFeeRate(tokenId)
val feeRateBps = if (feeRateResult.isSuccess) {
feeRateResult.getOrNull()?.toString() ?: "0"
} else {
logger.warn("获取费率失败,使用默认值 0: tokenId=$tokenId, error=${feeRateResult.exceptionOrNull()?.message}")
"0"
}
// 9. Neg Risk 市场需用 Neg Risk Exchange 签约
val negRiskSell = marketService.getNegRiskByConditionId(leaderSellTrade.market) == true
val exchangeContractSell = orderSigningService.getExchangeContract(negRiskSell)
@@ -1042,9 +1023,6 @@ open class CopyOrderTrackingService(
price = sellPrice.toString(),
size = totalMatched.toString(),
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType),
nonce = "0",
feeRateBps = feeRateBps, // 使用动态获取的费率
expiration = "0",
exchangeContract = exchangeContractSell
)
} catch (e: Exception) {
@@ -1058,8 +1036,7 @@ open class CopyOrderTrackingService(
val orderRequest = NewOrderRequest(
order = signedOrder,
owner = account.apiKey,
orderType = "FAK", // Fill-And-Kill
deferExec = false
orderType = "FAK" // Fill-And-Kill
)
// 12. 创建带认证的CLOB API客户端(使用解密后的凭证)
@@ -1084,7 +1061,6 @@ open class CopyOrderTrackingService(
owner = account.apiKey,
copyTradingId = copyTrading.id,
tradeId = leaderSellTrade.id,
feeRateBps = feeRateBps,
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
)
@@ -1179,7 +1155,6 @@ open class CopyOrderTrackingService(
* @param owner API Key用于owner字段
* @param copyTradingId 跟单配置ID用于日志
* @param tradeId Leader 交易ID用于日志
* @param feeRateBps 费率基点从API动态获取
* @param signatureType 签名类型1=Magic, 2=Safe
* @return 成功返回订单ID失败返回异常
*/
@@ -1196,7 +1171,6 @@ open class CopyOrderTrackingService(
owner: String,
copyTradingId: Long,
tradeId: String,
feeRateBps: String,
signatureType: Int
): Result<String> {
var lastError: Exception? = null
@@ -1213,9 +1187,6 @@ open class CopyOrderTrackingService(
price = price,
size = size,
signatureType = signatureType,
nonce = "0",
feeRateBps = feeRateBps, // 使用动态获取的费率
expiration = "0",
exchangeContract = exchangeContract
)
@@ -1232,8 +1203,7 @@ open class CopyOrderTrackingService(
val orderRequest = NewOrderRequest(
order = signedOrder,
owner = owner,
orderType = "FAK", // Fill-And-Kill
deferExec = false
orderType = "FAK" // Fill-And-Kill
)
// 调用 API 创建订单
@@ -0,0 +1,199 @@
package com.wrbug.polymarketbot.service.copytrading.statistics
import com.wrbug.polymarketbot.entity.CopyOrderTracking
import com.wrbug.polymarketbot.entity.SellMatchDetail
import com.wrbug.polymarketbot.entity.SellMatchRecord
import com.wrbug.polymarketbot.util.div
import com.wrbug.polymarketbot.util.gt
import com.wrbug.polymarketbot.util.lte
import com.wrbug.polymarketbot.util.multi
import com.wrbug.polymarketbot.util.toSafeBigDecimal
import java.math.BigDecimal
import java.math.RoundingMode
/**
* Pure calculator for copy-trading PnL.
*
* The statistics API used to expose totalPnl as realized-only PnL and hard-code
* unrealized PnL/current position value to zero. That makes active or expired
* open positions invisible. This calculator keeps the accounting explicit:
*
* - currentPositionCost: remaining shares at their tracked buy cost
* - currentPositionValue: remaining shares marked by current Polymarket price
* - totalUnrealizedPnl: current value - current cost
* - totalPnl: realized + unrealized
*/
object CopyTradingPnlCalculator {
fun calculate(
buyOrders: List<CopyOrderTracking>,
sellRecords: List<SellMatchRecord>,
matchDetails: List<SellMatchDetail>,
quotes: List<PositionValuationQuote> = emptyList()
): CopyTradingPnlStatistics {
val totalBuyQuantity = buyOrders.sumOf { it.quantity.toSafeBigDecimal() }
val totalBuyAmount = buyOrders.sumOf { it.quantity.toSafeBigDecimal().multi(it.price) }
val totalBuyOrders = buyOrders.size.toLong()
val avgBuyPrice = if (totalBuyQuantity.gt(BigDecimal.ZERO)) {
totalBuyAmount.div(totalBuyQuantity)
} else {
BigDecimal.ZERO
}
val totalSellQuantity = sellRecords.sumOf { it.totalMatchedQuantity.toSafeBigDecimal() }
val totalSellAmount = matchDetails.sumOf { it.matchedQuantity.toSafeBigDecimal().multi(it.sellPrice) }
val totalSellOrders = sellRecords.size.toLong()
val openOrders = buyOrders.filter { it.remainingQuantity.toSafeBigDecimal().gt(BigDecimal.ZERO) }
val currentPositionQuantity = openOrders.sumOf { it.remainingQuantity.toSafeBigDecimal() }
val currentPositionCost = openOrders.sumOf { it.remainingQuantity.toSafeBigDecimal().multi(it.price) }
val hasUnavailableQuotes = quotes.any { it.status == PositionQuoteStatus.UNAVAILABLE }
val quotedOpenPositions = openOrders.map { order ->
val quote = findQuote(order, quotes)
val status = when {
quote?.status == PositionQuoteStatus.AVAILABLE -> PositionQuoteStatus.AVAILABLE
hasUnavailableQuotes -> PositionQuoteStatus.UNAVAILABLE
else -> PositionQuoteStatus.NO_MATCH
}
QuotedOpenPosition(
order = order,
status = status,
currentPrice = quote?.currentPrice ?: BigDecimal.ZERO
)
}
val currentPositionValue = quotedOpenPositions.sumOf { position ->
position.order.remainingQuantity.toSafeBigDecimal().multi(position.currentPrice)
}
val zeroValuePositionCost = quotedOpenPositions
.filter { it.currentPrice.lte(BigDecimal.ZERO) }
.sumOf { it.order.remainingQuantity.toSafeBigDecimal().multi(it.order.price) }
val confirmedZeroValuePositionCost = quotedOpenPositions
.filter { it.status == PositionQuoteStatus.AVAILABLE && it.currentPrice.lte(BigDecimal.ZERO) }
.sumOf { it.order.remainingQuantity.toSafeBigDecimal().multi(it.order.price) }
val quoteStatusSummary = QuoteStatusSummary.from(quotedOpenPositions.map { it.status })
val totalRealizedPnl = matchDetails.sumOf { it.realizedPnl.toSafeBigDecimal() }
val totalUnrealizedPnl = currentPositionValue.subtract(currentPositionCost)
val totalPnl = totalRealizedPnl.add(totalUnrealizedPnl)
return CopyTradingPnlStatistics(
totalBuyQuantity = totalBuyQuantity,
totalBuyOrders = totalBuyOrders,
totalBuyAmount = totalBuyAmount,
avgBuyPrice = avgBuyPrice,
totalSellQuantity = totalSellQuantity,
totalSellOrders = totalSellOrders,
totalSellAmount = totalSellAmount,
currentPositionQuantity = currentPositionQuantity,
currentPositionCost = currentPositionCost,
currentPositionValue = currentPositionValue,
zeroValuePositionCost = zeroValuePositionCost,
confirmedZeroValuePositionCost = confirmedZeroValuePositionCost,
quoteStatusSummary = quoteStatusSummary,
totalRealizedPnl = totalRealizedPnl,
totalUnrealizedPnl = totalUnrealizedPnl,
totalPnl = totalPnl,
totalPnlPercent = calculatePnlPercent(totalBuyAmount, totalPnl)
)
}
private fun findQuote(
order: CopyOrderTracking,
quotes: List<PositionValuationQuote>
): PositionValuationQuote? {
return quotes.firstOrNull { quote ->
quote.marketId == order.marketId &&
order.outcomeIndex != null &&
quote.outcomeIndex == order.outcomeIndex
} ?: quotes.firstOrNull { quote ->
quote.marketId == order.marketId &&
order.outcomeIndex == null &&
!quote.side.isNullOrBlank() &&
quote.side.equals(order.side, ignoreCase = true)
}
}
private fun calculatePnlPercent(totalBuyAmount: BigDecimal, totalPnl: BigDecimal): BigDecimal {
if (totalBuyAmount.lte(BigDecimal.ZERO)) return BigDecimal.ZERO.setScale(2)
return totalPnl.div(totalBuyAmount).multi(100).setScale(2, RoundingMode.HALF_UP)
}
}
data class PositionValuationQuote(
val marketId: String,
val outcomeIndex: Int?,
val side: String?,
val currentPrice: BigDecimal,
val status: PositionQuoteStatus = PositionQuoteStatus.AVAILABLE,
val failureReason: String? = null
) {
companion object {
fun unavailable(reason: String? = null): PositionValuationQuote {
return PositionValuationQuote(
marketId = "__unavailable__",
outcomeIndex = null,
side = null,
currentPrice = BigDecimal.ZERO,
status = PositionQuoteStatus.UNAVAILABLE,
failureReason = reason
)
}
}
}
enum class PositionQuoteStatus {
AVAILABLE,
NO_MATCH,
UNAVAILABLE
}
data class QuoteStatusSummary(
val overallStatus: PositionQuoteStatus,
val availableCount: Int,
val noMatchCount: Int,
val unavailableCount: Int
) {
companion object {
fun from(statuses: List<PositionQuoteStatus>): QuoteStatusSummary {
val unavailableCount = statuses.count { it == PositionQuoteStatus.UNAVAILABLE }
val noMatchCount = statuses.count { it == PositionQuoteStatus.NO_MATCH }
val availableCount = statuses.count { it == PositionQuoteStatus.AVAILABLE }
val overallStatus = when {
unavailableCount > 0 -> PositionQuoteStatus.UNAVAILABLE
noMatchCount > 0 -> PositionQuoteStatus.NO_MATCH
else -> PositionQuoteStatus.AVAILABLE
}
return QuoteStatusSummary(
overallStatus = overallStatus,
availableCount = availableCount,
noMatchCount = noMatchCount,
unavailableCount = unavailableCount
)
}
}
}
private data class QuotedOpenPosition(
val order: CopyOrderTracking,
val status: PositionQuoteStatus,
val currentPrice: BigDecimal
)
data class CopyTradingPnlStatistics(
val totalBuyQuantity: BigDecimal,
val totalBuyOrders: Long,
val totalBuyAmount: BigDecimal,
val avgBuyPrice: BigDecimal,
val totalSellQuantity: BigDecimal,
val totalSellOrders: Long,
val totalSellAmount: BigDecimal,
val currentPositionQuantity: BigDecimal,
val currentPositionCost: BigDecimal,
val currentPositionValue: BigDecimal,
val zeroValuePositionCost: BigDecimal,
val confirmedZeroValuePositionCost: BigDecimal,
val quoteStatusSummary: QuoteStatusSummary,
val totalRealizedPnl: BigDecimal,
val totalUnrealizedPnl: BigDecimal,
val totalPnl: BigDecimal,
val totalPnlPercent: BigDecimal
)
@@ -0,0 +1,155 @@
package com.wrbug.polymarketbot.service.copytrading.statistics
import com.wrbug.polymarketbot.dto.CopyTradingRiskDiagnosisDto
import com.wrbug.polymarketbot.dto.RiskWarningDto
import com.wrbug.polymarketbot.dto.TopLosingMarketDto
import com.wrbug.polymarketbot.entity.CopyOrderTracking
import com.wrbug.polymarketbot.entity.CopyTrading
import com.wrbug.polymarketbot.entity.SellMatchDetail
import com.wrbug.polymarketbot.util.gt
import com.wrbug.polymarketbot.util.lte
import java.math.BigDecimal
object CopyTradingRiskDiagnosisService {
private const val MIN_CONFIDENCE_SAMPLE_SIZE = 10
fun buildDiagnosis(
copyTrading: CopyTrading,
buyOrders: List<CopyOrderTracking>,
sellRecordsCount: Int,
matchDetails: List<SellMatchDetail>,
filteredOrderCount: Long,
pnl: CopyTradingPnlStatistics,
generatedAt: Long = System.currentTimeMillis()
): CopyTradingRiskDiagnosisDto {
val trackingByBuyOrderId = buyOrders.associateBy { it.buyOrderId }
val topLosingMarkets = matchDetails
.groupBy { detail -> trackingByBuyOrderId[detail.buyOrderId]?.marketId ?: detail.buyOrderId }
.map { (marketId, details) ->
TopLosingMarketDto(
marketId = marketId,
realizedPnl = details.sumOf { it.realizedPnl }.toPlainString(),
matchedOrders = details.size
)
}
.filter { it.realizedPnl.toBigDecimalOrNull()?.lt(BigDecimal.ZERO) == true }
.sortedBy { it.realizedPnl.toBigDecimalOrNull() ?: BigDecimal.ZERO }
.take(10)
val zeroSellLoss = matchDetails
.filter { it.sellPrice.lte(BigDecimal.ZERO) }
.sumOf { it.realizedPnl.abs() }
val sampleSize = buyOrders.size
val lowConfidence = pnl.totalPnl.gt(BigDecimal.ZERO) && sampleSize < MIN_CONFIDENCE_SAMPLE_SIZE
val missingSources = buildList {
if (pnl.quoteStatusSummary.overallStatus == PositionQuoteStatus.UNAVAILABLE) add("position-quotes")
}
return CopyTradingRiskDiagnosisDto(
copyTradingId = copyTrading.id ?: 0,
totalRealizedPnl = pnl.totalRealizedPnl.toPlainString(),
totalUnrealizedPnl = pnl.totalUnrealizedPnl.toPlainString(),
totalPnl = pnl.totalPnl.toPlainString(),
currentPositionCost = pnl.currentPositionCost.toPlainString(),
currentPositionValue = pnl.currentPositionValue.toPlainString(),
zeroValuePositionCost = pnl.zeroValuePositionCost.toPlainString(),
confirmedZeroValuePositionCost = pnl.confirmedZeroValuePositionCost.toPlainString(),
zeroSellLoss = zeroSellLoss.toPlainString(),
openPositionQuantity = pnl.currentPositionQuantity.toPlainString(),
totalBuyOrders = buyOrders.size,
totalSellRecords = sellRecordsCount,
totalMatchDetails = matchDetails.size,
filteredOrderCount = filteredOrderCount,
sampleSize = sampleSize,
lowConfidence = lowConfidence,
confidenceReason = if (lowConfidence) {
"样本量 ${sampleSize} 笔,低于 ${MIN_CONFIDENCE_SAMPLE_SIZE} 笔,不能视为已验证盈利"
} else {
"样本量满足第一版诊断阈值"
},
quoteOverallStatus = pnl.quoteStatusSummary.overallStatus.name,
quoteAvailableCount = pnl.quoteStatusSummary.availableCount,
quoteNoMatchCount = pnl.quoteStatusSummary.noMatchCount,
quoteUnavailableCount = pnl.quoteStatusSummary.unavailableCount,
dataIncomplete = missingSources.isNotEmpty(),
missingSources = missingSources,
topLosingMarkets = topLosingMarkets,
riskWarnings = inspectRiskConfig(copyTrading),
generatedAt = generatedAt
)
}
fun inspectRiskConfig(copyTrading: CopyTrading): List<RiskWarningDto> {
return buildList {
if (copyTrading.maxDailyOrders > 20) {
add(
RiskWarningDto(
field = "maxDailyOrders",
currentValue = copyTrading.maxDailyOrders.toString(),
suggestedValue = "20",
severity = RiskSeverity.HIGH.name,
reason = "每日订单数过高,短周期市场会快速放大亏损"
)
)
}
if (copyTrading.maxDailyLoss > BigDecimal.TEN) {
add(
RiskWarningDto(
field = "maxDailyLoss",
currentValue = copyTrading.maxDailyLoss.strip(),
suggestedValue = "10",
severity = RiskSeverity.HIGH.name,
reason = "每日最大亏损过高,不能起到止血作用"
)
)
}
if (copyTrading.minPrice == null) {
addMissingGuard("minPrice", "0.10", "缺少最低价格限制,容易跟到极端赔率订单")
}
if (copyTrading.maxPrice == null) {
addMissingGuard("maxPrice", "0.80", "缺少最高价格限制,容易在高价位承担不对称下行")
}
if (copyTrading.maxPositionValue == null) {
addMissingGuard("maxPositionValue", "10", "缺少单市场仓位上限,同一市场可以堆出过大暴露")
}
if (copyTrading.minOrderDepth == null) {
addMissingGuard("minOrderDepth", "100", "缺少深度过滤,薄盘口订单更容易滑点")
}
if (copyTrading.maxSpread == null) {
addMissingGuard("maxSpread", "0.03", "缺少价差过滤,宽价差市场成交质量更差")
}
if (copyTrading.priceTolerance > BigDecimal("3")) {
add(
RiskWarningDto(
field = "priceTolerance",
currentValue = copyTrading.priceTolerance.strip(),
suggestedValue = "3",
severity = RiskSeverity.MEDIUM.name,
reason = "价格容忍度偏宽,实际成交可能偏离 leader 价格"
)
)
}
}
}
private fun MutableList<RiskWarningDto>.addMissingGuard(field: String, suggestedValue: String, reason: String) {
add(
RiskWarningDto(
field = field,
currentValue = null,
suggestedValue = suggestedValue,
severity = RiskSeverity.HIGH.name,
reason = reason
)
)
}
private fun BigDecimal.strip(): String = stripTrailingZeros().toPlainString()
private fun BigDecimal.lt(other: BigDecimal): Boolean = compareTo(other) < 0
}
enum class RiskSeverity {
LOW,
MEDIUM,
HIGH
}
@@ -7,17 +7,16 @@ import com.wrbug.polymarketbot.util.toSafeBigDecimal
import com.wrbug.polymarketbot.util.multi
import com.wrbug.polymarketbot.util.div
import com.wrbug.polymarketbot.util.gt
import com.wrbug.polymarketbot.util.eq
import com.wrbug.polymarketbot.util.lte
import org.slf4j.LoggerFactory
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import com.wrbug.polymarketbot.service.accounts.AccountService
import com.wrbug.polymarketbot.service.common.BlockchainService
import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.math.RoundingMode
import java.util.concurrent.ConcurrentHashMap
/**
* 跟单统计服务
@@ -31,10 +30,14 @@ class CopyTradingStatisticsService(
private val sellMatchDetailRepository: SellMatchDetailRepository,
private val accountRepository: AccountRepository,
private val leaderRepository: LeaderRepository,
private val marketService: com.wrbug.polymarketbot.service.common.MarketService
private val filteredOrderRepository: FilteredOrderRepository,
private val marketService: com.wrbug.polymarketbot.service.common.MarketService,
private val blockchainService: BlockchainService
) {
private val logger = LoggerFactory.getLogger(CopyTradingStatisticsService::class.java)
private val quoteCacheTtlMillis = 30_000L
private val quoteCache = ConcurrentHashMap<String, CachedPositionQuotes>()
/**
* 获取跟单关系统计
@@ -58,15 +61,28 @@ class CopyTradingStatisticsService(
// 5. 获取匹配明细
val matchDetails = sellMatchDetailRepository.findByCopyTradingId(copyTradingId)
// 6. 计算统计信息
val statistics = calculateStatistics(buyOrders, sellRecords, matchDetails)
// 6. 获取当前价格并计算真实口径统
// currentPositionCost 使用跟单系统记录的剩余仓位成本;currentPositionValue 使用
// Polymarket Data API 当前价格按剩余份额估值。缺失报价仍按 0 参与旧字段计算,
// 但必须通过 quote status 告诉 UI 这是已确认归零、未匹配还是接口不可用。
val hasOpenPosition = buyOrders.any { it.remainingQuantity.toSafeBigDecimal().gt(BigDecimal.ZERO) }
val quotes = if (hasOpenPosition) {
buildPositionValuationQuotes(account?.proxyAddress)
} else {
emptyList()
}
val statistics = CopyTradingPnlCalculator.calculate(buyOrders, sellRecords, matchDetails, quotes)
val filteredOrderCount = filteredOrderRepository.countByCopyTradingId(copyTradingId)
val diagnosis = CopyTradingRiskDiagnosisService.buildDiagnosis(
copyTrading = copyTrading,
buyOrders = buyOrders,
sellRecordsCount = sellRecords.size,
matchDetails = matchDetails,
filteredOrderCount = filteredOrderCount,
pnl = statistics
)
// 7. 不再计算未实现盈亏和持仓价值(优化性能
// 未实现盈亏计算需要查询链上持仓和市场价格,性能开销大
val unrealizedPnl = "0"
val positionValue = "0"
// 8. 构建响应(总盈亏 = 已实现盈亏)
// 7. 构建响应(总盈亏 = 已实现盈亏 + 未实现盈亏
val response = CopyTradingStatisticsResponse(
copyTradingId = copyTradingId,
accountId = copyTrading.accountId,
@@ -74,19 +90,28 @@ class CopyTradingStatisticsService(
leaderId = copyTrading.leaderId,
leaderName = leader?.leaderName,
enabled = copyTrading.enabled,
totalBuyQuantity = statistics.totalBuyQuantity,
totalBuyQuantity = statistics.totalBuyQuantity.toString(),
totalBuyOrders = statistics.totalBuyOrders,
totalBuyAmount = statistics.totalBuyAmount,
avgBuyPrice = statistics.avgBuyPrice,
totalSellQuantity = statistics.totalSellQuantity,
totalBuyAmount = statistics.totalBuyAmount.toString(),
avgBuyPrice = statistics.avgBuyPrice.toString(),
totalSellQuantity = statistics.totalSellQuantity.toString(),
totalSellOrders = statistics.totalSellOrders,
totalSellAmount = statistics.totalSellAmount,
currentPositionQuantity = statistics.currentPositionQuantity,
currentPositionValue = positionValue,
totalRealizedPnl = statistics.totalRealizedPnl,
totalUnrealizedPnl = unrealizedPnl,
totalPnl = statistics.totalRealizedPnl,
totalPnlPercent = calculatePnlPercentOnlyRealized(statistics.totalBuyAmount, statistics.totalRealizedPnl)
totalSellAmount = statistics.totalSellAmount.toString(),
currentPositionQuantity = statistics.currentPositionQuantity.toString(),
currentPositionCost = statistics.currentPositionCost.toString(),
currentPositionValue = statistics.currentPositionValue.toString(),
zeroValuePositionCost = statistics.zeroValuePositionCost.toString(),
confirmedZeroValuePositionCost = statistics.confirmedZeroValuePositionCost.toString(),
quoteOverallStatus = statistics.quoteStatusSummary.overallStatus.name,
quoteAvailableCount = statistics.quoteStatusSummary.availableCount,
quoteNoMatchCount = statistics.quoteStatusSummary.noMatchCount,
quoteUnavailableCount = statistics.quoteStatusSummary.unavailableCount,
quoteIncomplete = statistics.quoteStatusSummary.overallStatus != PositionQuoteStatus.AVAILABLE,
riskDiagnosis = diagnosis,
totalRealizedPnl = statistics.totalRealizedPnl.toString(),
totalUnrealizedPnl = statistics.totalUnrealizedPnl.toString(),
totalPnl = statistics.totalPnl.toString(),
totalPnlPercent = statistics.totalPnlPercent.toString()
)
Result.success(response)
@@ -96,6 +121,65 @@ class CopyTradingStatisticsService(
}
}
/**
* 获取账户当前仓位报价用于给跟单系统中仍有 remainingQuantity 的订单做市值估算
*
* 注意报价只用于估值不直接使用 Data API size/currentValue 汇总这样可以按
* copyTradingId 归因避免同一钱包下多个 Leader 或手工仓位混在一起
*/
private suspend fun buildPositionValuationQuotes(proxyAddress: String?): List<PositionValuationQuote> {
val normalizedProxyAddress = proxyAddress?.trim()?.lowercase()?.takeIf { it.isNotBlank() }
?: return emptyList()
val now = System.currentTimeMillis()
quoteCache[normalizedProxyAddress]
?.takeIf { it.expiresAtMillis > now }
?.let { return it.quotes }
return try {
val positionsResult = blockchainService.getPositions(normalizedProxyAddress)
if (positionsResult.isFailure) {
val reason = positionsResult.exceptionOrNull()?.message
logger.warn("获取持仓报价失败: proxyAddress=${normalizedProxyAddress.take(10)}..., error=$reason")
return listOf(PositionValuationQuote.unavailable(reason = reason))
}
val quotes = positionsResult.getOrNull().orEmpty().mapNotNull { position ->
val marketId = position.conditionId?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
val currentPrice = position.curPrice?.toSafeBigDecimal()
?: derivePriceFromPositionValue(position.currentValue, position.size)
?: BigDecimal.ZERO
PositionValuationQuote(
marketId = marketId,
outcomeIndex = position.outcomeIndex,
side = position.outcome,
currentPrice = currentPrice
)
}
quoteCache[normalizedProxyAddress] = CachedPositionQuotes(
quotes = quotes,
expiresAtMillis = now + quoteCacheTtlMillis
)
quotes
} catch (e: Exception) {
logger.warn("获取持仓报价异常: proxyAddress=${normalizedProxyAddress.take(10)}..., error=${e.message}", e)
listOf(PositionValuationQuote.unavailable(reason = e.message))
}
}
private fun derivePriceFromPositionValue(currentValue: Double?, size: Double?): BigDecimal? {
val value = currentValue?.toSafeBigDecimal() ?: return null
val quantity = size?.toSafeBigDecimal() ?: return null
if (quantity.lte(BigDecimal.ZERO)) return null
return value.div(quantity)
}
private data class CachedPositionQuotes(
val quotes: List<PositionValuationQuote>,
val expiresAtMillis: Long
)
/**
* 查询订单列表
*/
@@ -337,65 +421,6 @@ class CopyTradingStatisticsService(
return Pair(list, total)
}
/**
* 计算统计信息
*/
private fun calculateStatistics(
buyOrders: List<CopyOrderTracking>,
sellRecords: List<SellMatchRecord>,
matchDetails: List<SellMatchDetail>
): StatisticsData {
// 买入统计
val totalBuyQuantity = buyOrders.sumOf { it.quantity.toSafeBigDecimal() }
val totalBuyAmount = buyOrders.sumOf { it.quantity.toSafeBigDecimal().multi(it.price) }
val totalBuyOrders = buyOrders.size.toLong()
val avgBuyPrice = if (totalBuyQuantity.gt(BigDecimal.ZERO)) {
totalBuyAmount.div(totalBuyQuantity)
} else {
BigDecimal.ZERO
}
// 卖出统计
// 使用 SellMatchDetail 计算总卖出金额,确保准确性
// 因为每个明细都记录了准确的匹配数量和卖出价格
val totalSellQuantity = sellRecords.sumOf { it.totalMatchedQuantity.toSafeBigDecimal() }
val totalSellAmount = matchDetails.sumOf { it.matchedQuantity.toSafeBigDecimal().multi(it.sellPrice) }
val totalSellOrders = sellRecords.size.toLong()
// 持仓统计
val currentPositionQuantity = buyOrders.sumOf { it.remainingQuantity.toSafeBigDecimal() }
// 已实现盈亏
val totalRealizedPnl = matchDetails.sumOf { it.realizedPnl.toSafeBigDecimal() }
return StatisticsData(
totalBuyQuantity = totalBuyQuantity.toString(),
totalBuyOrders = totalBuyOrders,
totalBuyAmount = totalBuyAmount.toString(),
avgBuyPrice = avgBuyPrice.toString(),
totalSellQuantity = totalSellQuantity.toString(),
totalSellOrders = totalSellOrders,
totalSellAmount = totalSellAmount.toString(),
currentPositionQuantity = currentPositionQuantity.toString(),
totalRealizedPnl = totalRealizedPnl.toString()
)
}
/**
* 计算盈亏百分比仅基于已实现盈亏
*/
private fun calculatePnlPercentOnlyRealized(
totalBuyAmount: String,
totalRealizedPnl: String
): String {
val buyAmount = totalBuyAmount.toSafeBigDecimal()
if (buyAmount.lte(BigDecimal.ZERO)) return "0"
val percent = totalRealizedPnl.toSafeBigDecimal().div(buyAmount).multi(100)
return percent.setScale(2, RoundingMode.HALF_UP).toString()
}
/**
* 获取全局统计
*/
@@ -556,21 +581,6 @@ class CopyTradingStatisticsService(
)
}
/**
* 统计数据结构
*/
private data class StatisticsData(
val totalBuyQuantity: String,
val totalBuyOrders: Long,
val totalBuyAmount: String,
val avgBuyPrice: String,
val totalSellQuantity: String,
val totalSellOrders: Long,
val totalSellAmount: String,
val currentPositionQuantity: String,
val totalRealizedPnl: String
)
/**
* 获取按市场分组的买入订单列表
*/

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