36 files deleted
4 files added
15 files modified
| | |
| | | package com.xcong.excoin.configurations; |
| | | |
| | | import com.xcong.excoin.configurations.properties.AliOssProperties; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.springframework.boot.SpringBootConfiguration; |
| | | import org.springframework.web.method.support.HandlerMethodArgumentResolver; |
| | | import org.springframework.web.servlet.config.annotation.CorsRegistry; |
| | | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; |
| | | |
| | | import javax.annotation.Resource; |
| | | import java.util.List; |
| | | |
| | | /** |
| | |
| | | @SpringBootConfiguration |
| | | @Slf4j |
| | | public class WebMvcConfig implements WebMvcConfigurer { |
| | | |
| | | @Resource |
| | | private AliOssProperties aliOssProperties; |
| | | |
| | | |
| | | @Override |
| | | public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) { |
| | |
| | | // "B0C1CC8F39625B41140D93DC25039E33", |
| | | // "Aa12345678@", |
| | | // true); |
| | | OKX_UAT_ceshi("ffb4e79f-fcf5-4afb-82c5-2fbb64123f61", |
| | | "AA06C5ED1D7C7F5AFE6484052E231C55", |
| | | OKX_UAT_ceshi("ac76252d-e717-4459-a6f9-80512aed5ea0", |
| | | "A8168543EF4F08A6DBFE27AB23956898", |
| | | "Aa12345678@", |
| | | false); |
| | | // |
| New file |
| | |
| | | # OKX 网格交易策略 — 架构文档 |
| | | |
| | | ## 一、模块总览 |
| | | |
| | | ``` |
| | | okxNewPrice/ |
| | | ├── OkxConfig.java # 全局配置(Builder模式,策略唯一参数入口) |
| | | ├── OkxGridTradeService.java # 核心策略引擎(状态机 + 事件驱动循环) |
| | | ├── OkxTradeExecutor.java # REST API 异步执行器(单线程池 + 背压) |
| | | ├── OkxGridElement.java # 网格价格层级 + 8个全局O(1)静态索引 |
| | | ├── OkxTraderParam.java # 单笔挂单参数(方向/触发价/数量/订单ID) |
| | | ├── OkxGridWsClient.java # WebSocket 客户端(双连接:business + private) |
| | | ├── OkxWebSocketClientManager.java # Spring容器入口 + Bean组装 + 生命周期 |
| | | ├── ExchangeInfoEnum.java # 账户枚举 |
| | | │ |
| | | ├── gridWs/ |
| | | │ ├── OkxGridChannelHandler.java # 频道处理器接口 |
| | | │ ├── OkxKlineChannelHandler.java # candle1m → onKline(closePrice) |
| | | │ ├── OkxPositionsChannelHandler.java # positions → onPositionUpdate() |
| | | │ ├── OkxOrdersChannelHandler.java # orders → onOrderUpdate() (替代orders-algo) |
| | | │ └── OkxAlgoOrdersChannelHandler.java # orders-algo(测试网60018不可用,备用) |
| | | │ |
| | | └── okxpi/config/ # OKX V5 API 底层HTTP签名/请求工具链 |
| | | ├── Account.java / OKXAccount.java |
| | | ├── RequestHandler.java / ResponseHandler.java |
| | | └── utils/ (SignUtils, UrlBuilder, OkHttpUtils...) |
| | | ``` |
| | | |
| | | ## 二、Spring 容器入口 |
| | | |
| | | `OkxWebSocketClientManager` 是 `@Component + @PostConstruct` 驱动的组装点。 |
| | | |
| | | ### 2.1 激活条件 |
| | | |
| | | ```yaml |
| | | # application-test.yml |
| | | app: |
| | | quant: true # @ConditionalOnProperty 控制是否启动 |
| | | ``` |
| | | |
| | | ### 2.2 初始化顺序 (@PostConstruct) |
| | | |
| | | ``` |
| | | 1. ExchangeInfoEnum 取首个账户 |
| | | 2. 构建 OkxAccount(baseUrl + apiKey/secretKey/passphrase) |
| | | 3. OkxConfig.builder() 构建配置(BTC-USDT-SWAP, 100x, cross, gridRate=0.001) |
| | | 4. OkxGridTradeService.init() |
| | | ├── GET /api/v5/account/balance → 取 USDT details[].eq 作为初始本金 |
| | | ├── POST /api/v5/trade/cancel-algos → 清旧条件单 |
| | | ├── GET /api/v5/account/positions → 遍历平已有仓位 |
| | | └── POST /api/v5/account/set-leverage → 设置杠杆 |
| | | 5. 创建双 WS 客户端并注册频道处理器: |
| | | ├── gridWsClientPublic → /v5/business → candle1m (无需登录) |
| | | └── gridWsClientPrivate → /v5/private → positions + orders (先登录) |
| | | 6. gridTradeService.startGrid() → WAITING_KLINE |
| | | ``` |
| | | |
| | | ### 2.3 销毁顺序 (@PreDestroy) |
| | | |
| | | ``` |
| | | stopGrid() → cancelAllAlgoOrders + shutdown → gridWsClientPublic.destroy() → gridWsClientPrivate.destroy() |
| | | ``` |
| | | |
| | | ### 2.4 当前活跃配置 |
| | | |
| | | | 参数 | 值 | 说明 | |
| | | |------|-----|------| |
| | | | `instId` | `BTC-USDT-SWAP` | 合约 | |
| | | | `leverage` | `100` | 杠杆 | |
| | | | `tdMode` | `cross` | 全仓 | |
| | | | `gridRate` | `0.001` | 网格间距 0.1% | |
| | | | `expectedProfit` | `20` USDT | 盈亏达标重置线 | |
| | | | `maxLoss` | `30` USDT | 亏损风控告警线 | |
| | | | `quantity` | `1` | 每格下单张数 | |
| | | | `baseQuantity` | `10` | 基底开仓张数 | |
| | | | `priceScale` | `2` | 价格精度 | |
| | | | `ctVal` | `0.1` | 合约面值 | |
| | | | `gridQueueSize` | `300` | 价格队列容量 | |
| | | |
| | | ## 三、WS 连接架构 |
| | | |
| | | ``` |
| | | ┌──────────────────────────────────────────────────────────┐ |
| | | │ OkxWebSocketClientManager │ |
| | | │ │ |
| | | │ gridWsClientPublic (isPublic=true) │ |
| | | │ ├── URL: wss://wspap.okx.com:8443/ws/v5/business │ |
| | | │ ├── 连接后立即 subscribeAllHandlers()(无需登录) │ |
| | | │ └── OkxKlineChannelHandler(candle1m, instId) │ |
| | | │ │ |
| | | │ gridWsClientPrivate (isPublic=false) │ |
| | | │ ├── URL: wss://wspap.okx.com:8443/ws/v5/private │ |
| | | │ ├── 连接后 wsLogin() → 登录成功 → subscribeAllHandlers()│ |
| | | │ ├── OkxPositionsChannelHandler(positions, instType:SWAP)│ |
| | | │ └── OkxOrdersChannelHandler(orders, instType:SWAP) │ |
| | | └──────────────────────────────────────────────────────────┘ |
| | | ``` |
| | | |
| | | | 连接 | URL | 频道 | 订阅参数 | 回调方法 | |
| | | |------|-----|------|---------|---------| |
| | | | business | `/v5/business` | `candle1m` | `instId` | `onKline(closePrice)` | |
| | | | private | `/v5/private` | `positions` | `instType:SWAP` | `onPositionUpdate(instId, posSide, pos, avgPx)` | |
| | | | private | `/v5/private` | `orders` | `instType:SWAP` | `onOrderUpdate(algoId, state, ordType)` | |
| | | |
| | | > **orders 频道替代 orders-algo**:`orders-algo` 在测试网不可用(60018),改订阅 `orders` 频道。 |
| | | > algo 触发后生成普通市价单,fill 数据中 `algoId` 字段非空时可匹配回原始条件单。 |
| | | > `OkxOrdersChannelHandler` 过滤逻辑:`state=filled` AND `algoId` 非空。 |
| | | |
| | | ### 3.1 心跳机制 |
| | | |
| | | ``` |
| | | 10s 无消息 → send("ping") → server reply "pong" → 重置计时器 |
| | | 25s 定时检查 → 超时则 send("ping") |
| | | LostConnectionChecker: setConnectionLostTimeout(0) 已关闭(协议级ping OKX不响应) |
| | | ``` |
| | | |
| | | ## 四、策略生命周期 |
| | | |
| | | ### 4.1 状态机 |
| | | |
| | | | 状态 | 含义 | 进入条件 | 退出动作 | |
| | | |------|------|---------|---------| |
| | | | `WAITING_KLINE` | 等待首根K线 | startGrid() | 首根K线→OPENING | |
| | | | `OPENING` | 基底开仓中 | 首根K线 | 双基底成交→ACTIVE | |
| | | | `ACTIVE` | 策略运行 | 网格初始化完成 | 盈亏达标/持仓归零→STOPPED | |
| | | | `STOPPED` | 已停止 | 重置/达标 | 下根K线自动 startGrid() | |
| | | |
| | | ### 4.2 完整流程 |
| | | |
| | | ``` |
| | | ┌──────────────────────────────────────────────────────────────┐ |
| | | │ init() → startGrid() → WAITING_KLINE │ |
| | | │ ↓ │ |
| | | │ onKline(首根) → OPENING → executor.openLong/Short(10张) │ |
| | | │ ↓ │ |
| | | │ onPositionUpdate → 基底成交 → tryGenerateQueues() │ |
| | | │ ├── generateShortQueue(): shortBasePrice - step 向下步进 │ |
| | | │ ├── generateLongQueue(): shortBasePrice + step 向上步进 │ |
| | | │ ├── updateGridElements(): 构建 601个 OkxGridElement │ |
| | | │ │ ├── ID≤-1: 空仓区(降序) ID=0:基底 ID≥1:多仓区(升序)│ |
| | | │ │ └── ID索引 + 价格索引 + 6个订单ID索引 (O(1)) │ |
| | | │ ├── 多仓止损 -2~-11: POST order-algo │ |
| | | │ │ ordType=conditional, side=sell, posSide=long, │ |
| | | │ │ slTriggerPx=网格价, slOrdPx=-1, sz=quantity │ |
| | | │ └── 空仓止损 +2~+11: POST order-algo │ |
| | | │ ordType=conditional, side=buy, posSide=short, │ |
| | | │ slTriggerPx=网格价, slOrdPx=-1, sz=quantity │ |
| | | │ ↓ │ |
| | | │ state = ACTIVE ─────────────────────────────────────────────│ |
| | | └──────────────────────────────────────────────────────────────┘ |
| | | ``` |
| | | |
| | | ## 五、事件驱动循环 (ACTIVE 状态) |
| | | |
| | | ### 5.1 K线回调 `onKline(closePrice)` |
| | | |
| | | ``` |
| | | lastKlinePrice = closePrice |
| | | updateUnrealizedPnl() |
| | | |
| | | if STOPPED: |
| | | cancelAllAlgoOrders() + closeExistingPositions() + startGrid() → WAITING_KLINE |
| | | |
| | | if WAITING_KLINE: |
| | | 市价双开 baseQuantity 张 (openLong + openShort) → OPENING |
| | | |
| | | if ACTIVE: |
| | | checkProfitAndReset() // 每根K线检查盈亏达标 |
| | | ``` |
| | | |
| | | ### 5.2 仓位推送 `onPositionUpdate` |
| | | |
| | | ``` |
| | | long 有仓位: |
| | | 首次(基底) → 记录 baseLongOpened + 均价 → tryGenerateQueues() |
| | | 后续 → 更新 positionSize / entryPrice |
| | | 归零 → handlePositionZeroAndReset("多仓") |
| | | |
| | | short 有仓位: |
| | | 首次(基底) → 记录 baseShortOpened + 均价 → tryGenerateQueues() |
| | | 后续 → 更新 positionSize / entryPrice |
| | | 归零 → handlePositionZeroAndReset("空仓") |
| | | ``` |
| | | |
| | | ### 5.3 订单成交 `onOrderUpdate(algoId, state, ordType)` — 核心事件 |
| | | |
| | | 触发条件:`state == "filled"` (来自 `orders` 频道的成交推送) |
| | | |
| | | ``` |
| | | ┌─ findByLongStopLossOrderId(algoId) |
| | | │ → handleLongStopLossTriggered() |
| | | │ 止损-ID=-N → 清空止损ID |
| | | │ → 在 -(N-1) 挂 count×qty 张多单 |
| | | │ (ordType=trigger, triggerPx, orderPx=-1) |
| | | │ → N>2 时取消 -(N-2) 旧多单 |
| | | │ |
| | | ├─ findByShortStopLossOrderId(algoId) |
| | | │ → handleShortStopLossTriggered() |
| | | │ 止损ID=N → 清空止损ID |
| | | │ → 在 N-1 挂 count×qty 张空单 |
| | | │ (ordType=trigger, triggerPx, orderPx=-1) |
| | | │ → N>2 时取消 N-2 旧空单 |
| | | algoId 匹配 ────────┤ |
| | | ├─ findByShortOrderId(algoId) && hasShortOrder |
| | | │ → shortEntryTraderIdParam(null, false) |
| | | │ → extendShortStopLoss(filledQty) |
| | | │ 到最远空仓止损外扩 stopLossCount 个网格 |
| | | │ 每格挂 quantity 张止损 |
| | | │ |
| | | └─ findByLongOrderId(algoId) && hasLongOrder |
| | | → longEntryTraderIdParam(null, false) |
| | | → extendLongStopLoss(filledQty) |
| | | 到最远多仓止损外扩 stopLossCount 个网格 |
| | | 每格挂 quantity 张止损 |
| | | ``` |
| | | |
| | | ### 5.4 平仓推送 `onPositionClose` |
| | | |
| | | ``` |
| | | cumulativePnl += pnl |
| | | 总盈亏 ≤ -maxLoss → 钉钉告警(仅通知,不停止) |
| | | ``` |
| | | |
| | | ## 六、网格ID体系 |
| | | |
| | | ``` |
| | | 价格方向: 低 ←────────────── 基底价 ──────────────→ 高 |
| | | ID: ... -3 -2 -1 0 1 2 3 ... |
| | | 用途: 多仓止损/追单区 基底 空仓止损/追单区 |
| | | 初始化止损:-2~-11 初始化止损:2~11 |
| | | |
| | | 链表: ... ← -3 ← -2 ← -1 ← 0 → 1 → 2 → 3 → ... |
| | | (upId/downId + INDEX → O(1) 遍历) |
| | | ``` |
| | | |
| | | ### 6.1 GridElement 字段 |
| | | |
| | | | 类别 | 字段 | 说明 | |
| | | |------|------|------| |
| | | | 标识 | `id`, `gridPrice`, `upId`, `downId` | 编号、价格、双向链表指针 | |
| | | | 多仓 | `hasLongOrder`, `longOrderId`, `longTraderParam` | 多仓挂单状态 | |
| | | | 空仓 | `hasShortOrder`, `shortOrderId`, `shortTraderParam` | 空仓挂单状态 | |
| | | | 止盈 | `longTakeProfitOrderId`, `shortTakeProfitOrderId` | 止盈algoId | |
| | | | 止损 | `longStopLossOrderId`, `shortStopLossOrderId` | 止损algoId | |
| | | |
| | | ### 6.2 8个全局O(1)索引 |
| | | |
| | | ``` |
| | | INDEX → findById(int) |
| | | PRICE_INDEX → findByPrice(BigDecimal) |
| | | LONG_ORDER_ID_INDEX → findByLongOrderId(String) |
| | | SHORT_ORDER_ID_INDEX → findByShortOrderId(String) |
| | | LONG_TP_ORDER_ID_INDEX → findByLongTakeProfitOrderId(String) |
| | | SHORT_TP_ORDER_ID_INDEX→ findByShortTakeProfitOrderId(String) |
| | | LONG_SL_ORDER_ID_INDEX → findByLongStopLossOrderId(String) |
| | | SHORT_SL_ORDER_ID_INDEX→ findByShortStopLossOrderId(String) |
| | | ``` |
| | | |
| | | 每次订单状态变更后调用 `OkxGridElement.refreshIndices()` 增量重建,同时 `logAll()` 打印全量网格数据。 |
| | | |
| | | ## 七、关键公式 |
| | | |
| | | ### 7.1 网格步长 |
| | | |
| | | ``` |
| | | step = shortBaseEntryPrice × gridRate (priceScale 精度对齐) |
| | | ``` |
| | | |
| | | ### 7.2 止损触发 → 追单 |
| | | |
| | | ``` |
| | | priceDiff = |avgEntryPrice - newEntryGridPrice|.abs() |
| | | count = priceDiff / step (RoundingMode.DOWN, 最小1) |
| | | entryQty = count × quantity → 挂 ordType=trigger 条件单 |
| | | ``` |
| | | |
| | | ### 7.3 挂单成交 → 追挂止损 |
| | | |
| | | ``` |
| | | stopLossCount = filledQty / quantity |
| | | 从最远止损ID向外扩展 stopLossCount 个网格 |
| | | 每格挂 1 个 quantity 张止损 (ordType=conditional, slTriggerPx) |
| | | ``` |
| | | |
| | | ### 7.4 未实现盈亏 |
| | | |
| | | ``` |
| | | longPnl = longPositionSize × ctVal × (lastKlinePrice - longEntryPrice) |
| | | shortPnl = shortPositionSize × ctVal × (shortEntryPrice - lastKlinePrice) |
| | | unrealizedPnl = longPnl + shortPnl |
| | | ``` |
| | | |
| | | ### 7.5 盈亏达标检查 |
| | | |
| | | ``` |
| | | GET balance → upl (未实现盈亏) + availEq (可用保证金) |
| | | if upl + availEq > initialPrincipal + expectedProfit → STOPPED → 平仓+清条件单+startGrid() |
| | | ``` |
| | | |
| | | ## 八、REST API 映射 |
| | | |
| | | | 方法 | OKX API | ordType | 触发参数 | 用途 | |
| | | |------|---------|---------|---------|------| |
| | | | `openLong(size)` | `POST /api/v5/trade/order` | `market` | — | 市价开多 | |
| | | | `openShort(size)` | `POST /api/v5/trade/order` | `market` | — | 市价开空 | |
| | | | `marketClose(s,p,sz)` | `POST /api/v5/trade/order` | `market` | reduceOnly | 市价平仓 | |
| | | | `placeConditionalEntryOrder` | `POST /api/v5/trade/order-algo` | **`trigger`** | `triggerPx` | 计划委托开仓 | |
| | | | `placeTakeProfit` | `POST /api/v5/trade/order-algo` | **`conditional`** | `slTriggerPx` | 止损/止盈平仓 | |
| | | | `cancelAlgoOrder(id)` | `POST /api/v5/trade/cancel-algos` | — | array body | 取消单个条件单 | |
| | | | `cancelAllAlgoOrders()` | `POST /api/v5/trade/cancel-algos` | — | array body | 清除全部条件单 | |
| | | | `getBalance()` | `GET /api/v5/account/balance` | — | ccy=USDT | 查询余额 | |
| | | | `getPositions()` | `GET /api/v5/account/positions` | — | instId | 查询持仓 | |
| | | | `setLeverage(l)` | `POST /api/v5/account/set-leverage` | — | lever+mgnMode | 设置杠杆 | |
| | | |
| | | ### 8.1 ordType 对照表 |
| | | |
| | | | ordType | OKX含义 | 我们用途 | 触发价参数 | |
| | | |---------|--------|---------|-----------| |
| | | | `trigger` | 计划委托 | **开仓挂单**(等价格到位开仓) | `triggerPx` | |
| | | | `conditional` | 单向止盈止损 | **止损单**(等价格到位平仓) | `slTriggerPx` | |
| | | |
| | | ## 九、网格ID示例(BTC-USDT, step≈70) |
| | | |
| | | ``` |
| | | ID=-11 price=69309.67 ← 最远多仓止损 |
| | | ID=-10 price=69379.97 |
| | | ID= -9 price=69450.27 |
| | | ID= -8 price=69520.57 |
| | | ID= -7 price=69590.87 |
| | | ID= -6 price=69661.17 |
| | | ID= -5 price=69731.47 |
| | | ID= -4 price=69801.77 |
| | | ID= -3 price=69872.07 |
| | | ID= -2 price=69942.37 ← 多仓止损起点 |
| | | ID= -1 price=70012.67 |
| | | ID= 0 price=70082.97 ← 基底 (shortBaseEntryPrice) |
| | | ID= 1 price=70153.27 |
| | | ID= 2 price=70223.57 ← 空仓止损起点 |
| | | ID= 3 price=70293.87 |
| | | ID= 4 price=70364.17 |
| | | ID= 5 price=70434.47 |
| | | ID= 6 price=70504.77 |
| | | ID= 7 price=70575.07 |
| | | ID= 8 price=70645.37 |
| | | ID= 9 price=70715.67 |
| | | ID= 10 price=70785.97 |
| | | ID= 11 price=70856.27 ← 最远空仓止损 |
| | | ``` |
| | | |
| | | ## 十、线程模型 |
| | | |
| | | ``` |
| | | [ctReadThread-XX] WS回调线程(串行) [okx-trade-worker] Executor(单线程池) |
| | | │ │ |
| | | ├─ onKline(closePrice) ├─ openLong / openShort |
| | | │ └─ updateUnrealizedPnl() ├─ marketClose |
| | | │ └─ checkProfitAndReset()(同步REST) ├─ placeConditionalEntryOrder (trigger) |
| | | │ ├─ placeTakeProfit (conditional) |
| | | ├─ onPositionUpdate(...) └─ cancelAlgoOrder / cancelAllAlgoOrders |
| | | │ └─ tryGenerateQueues() → 批量挂止损 |
| | | │ └─ handlePositionZeroAndReset() |
| | | │ |
| | | └─ onOrderUpdate(algoId, state, ordType) |
| | | ├─ handleLongStopLossTriggered → placeConditionalEntryOrder |
| | | ├─ handleShortStopLossTriggered → placeConditionalEntryOrder |
| | | ├─ extendLongStopLoss → placeTakeProfit |
| | | └─ extendShortStopLoss → placeTakeProfit |
| | | ``` |
| | | |
| | | - WS 回调线程串行执行,天然线程安全 |
| | | - REST 提交到 `okx-trade-worker` 单线程异步执行,避免阻塞 WS |
| | | - `closeExistingPositions()` / `handlePositionZeroAndReset()` 在 WS 线程中同步调用 REST(IOC 市价单,秒级完成) |
| | | - `LostConnectionChecker(0)` 已禁用,由应用层 `send("ping")`/`handle "pong"` 接管 |
| | |
| | | private boolean isSimulate = false; |
| | | private int gridQueueSize = 300; |
| | | private BigDecimal marginRatioLimit = new BigDecimal("0.2"); |
| | | private BigDecimal ctVal = new BigDecimal("0.1"); |
| | | private BigDecimal ctVal = new BigDecimal("0.01"); |
| | | private int priceScale = 2; |
| | | |
| | | public Builder apiKey(String apiKey) { this.apiKey = apiKey; return this; } |
| | |
| | | */ |
| | | public void init() { |
| | | try { |
| | | // 1. 查询账户获取初始本金 |
| | | // 1. 查询账户获取初始本金(仅取 USDT 合约账户余额) |
| | | String balanceResp = executor.getBalance(); |
| | | if (balanceResp != null) { |
| | | JSONObject json = JSON.parseObject(balanceResp); |
| | | if ("0".equals(json.getString("code"))) { |
| | | JSONArray data = json.getJSONArray("data"); |
| | | if (data != null && !data.isEmpty()) { |
| | | JSONObject detail = data.getJSONObject(0); |
| | | String totalEq = detail.getString("totalEq"); |
| | | if (totalEq != null) { |
| | | this.initialPrincipal = new BigDecimal(totalEq); |
| | | log.info("[OKX] 初始本金: {} USDT", initialPrincipal); |
| | | JSONObject accountData = data.getJSONObject(0); |
| | | JSONArray details = accountData.getJSONArray("details"); |
| | | if (details != null) { |
| | | for (int i = 0; i < details.size(); i++) { |
| | | JSONObject detail = details.getJSONObject(i); |
| | | if ("USDT".equals(detail.getString("ccy"))) { |
| | | String eq = detail.getString("eq"); |
| | | if (eq != null) { |
| | | this.initialPrincipal = new BigDecimal(eq); |
| | | log.info("[OKX] 初始本金(USDT合约): {} USDT", initialPrincipal); |
| | | } |
| | | break; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | } |
| | |
| | | longPriceQueue.clear(); |
| | | currentLongOrderIds.clear(); |
| | | currentShortOrderIds.clear(); |
| | | log.info("[OKX] 网格策略已启动"); |
| | | |
| | | // 每次重启重新获取当前本金,确保盈亏对比基准正确 |
| | | refreshInitialPrincipal(); |
| | | |
| | | log.info("[OKX] 网格策略已启动, 当前本金: {} USDT", initialPrincipal); |
| | | } |
| | | |
| | | /** |
| | | * 重新获取当前账户权益作为初始本金。 |
| | | */ |
| | | private void refreshInitialPrincipal() { |
| | | try { |
| | | String balanceResp = executor.getBalance(); |
| | | if (balanceResp != null) { |
| | | JSONObject json = JSON.parseObject(balanceResp); |
| | | if ("0".equals(json.getString("code"))) { |
| | | JSONArray data = json.getJSONArray("data"); |
| | | if (data != null && !data.isEmpty()) { |
| | | JSONObject accountData = data.getJSONObject(0); |
| | | JSONArray details = accountData.getJSONArray("details"); |
| | | if (details != null) { |
| | | for (int i = 0; i < details.size(); i++) { |
| | | JSONObject detail = details.getJSONObject(i); |
| | | if ("USDT".equals(detail.getString("ccy"))) { |
| | | String eq = detail.getString("eq"); |
| | | if (eq != null) { |
| | | this.initialPrincipal = new BigDecimal(eq); |
| | | } |
| | | break; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | } |
| | | } |
| | | } catch (Exception e) { |
| | | log.warn("[OKX] 获取初始化本金失败,使用旧值: {}", initialPrincipal); |
| | | } |
| | | } |
| | | |
| | | public void stopGrid() { |
| | |
| | | // ---- 订单/条件单推送回调 ---- |
| | | |
| | | public void onOrderUpdate(String algoId, String state, String ordType) { |
| | | if (!"effective".equals(state) && !"canceled".equals(state)) { |
| | | if (!"filled".equals(state) && !"canceled".equals(state)) { |
| | | return; |
| | | } |
| | | |
| | |
| | | baseGridElement.setShortOrderId(baseShortTp.getEntryOrderId()); |
| | | baseGridElement.setHasShortOrder(true); |
| | | |
| | | // 挂多仓止损 (id=-2 到 -11) |
| | | // 挂多仓止损 (id=-2 到 -11),每格 quantity 张 |
| | | for (int id = -2; id >= -11; id--) { |
| | | OkxGridElement elem = OkxGridElement.findById(id); |
| | | if (elem == null) continue; |
| | | BigDecimal triggerPrice = elem.getGridPrice(); |
| | | int finalId = id; |
| | | executor.placeTakeProfit(triggerPrice.toString(), "sell", "long", "1", |
| | | executor.placeTakeProfit(triggerPrice.toString(), "sell", "long", config.getQuantity(), |
| | | profitId -> { |
| | | elem.setLongStopLossOrderId(profitId); |
| | | OkxGridElement.refreshIndices(); |
| | | log.info("[OKX] 多仓止损已挂, gridId:{}, 触发价:{}, stopLossId:{}", finalId, triggerPrice, profitId); |
| | | log.info("[OKX] 多仓止损已挂, gridId:{}, 触发价:{}, qty:{}, stopLossId:{}", |
| | | finalId, triggerPrice, config.getQuantity(), profitId); |
| | | }); |
| | | } |
| | | |
| | | // 挂空仓止损 (id=2 到 11) |
| | | // 挂空仓止损 (id=2 到 11),每格 quantity 张 |
| | | for (int id = 2; id <= 11; id++) { |
| | | OkxGridElement elem = OkxGridElement.findById(id); |
| | | if (elem == null) continue; |
| | | BigDecimal triggerPrice = elem.getGridPrice(); |
| | | int finalId = id; |
| | | executor.placeTakeProfit(triggerPrice.toString(), "buy", "short", "1", |
| | | executor.placeTakeProfit(triggerPrice.toString(), "buy", "short", config.getQuantity(), |
| | | profitId -> { |
| | | elem.setShortStopLossOrderId(profitId); |
| | | OkxGridElement.refreshIndices(); |
| | | log.info("[OKX] 空仓止损已挂, gridId:{}, 触发价:{}, stopLossId:{}", finalId, triggerPrice, profitId); |
| | | log.info("[OKX] 空仓止损已挂, gridId:{}, 触发价:{}, qty:{}, stopLossId:{}", |
| | | finalId, triggerPrice, config.getQuantity(), profitId); |
| | | }); |
| | | } |
| | | |
| | |
| | | |
| | | BigDecimal triggerPrice = newEntryGrid.getGridPrice(); |
| | | BigDecimal priceDiff = longEntryPrice.subtract(triggerPrice).abs(); |
| | | int entryQty = priceDiff.divide(config.getStep(), 0, RoundingMode.DOWN).intValue(); |
| | | entryQty = Math.max(1, entryQty); |
| | | int count = priceDiff.divide(config.getStep(), 0, RoundingMode.DOWN).intValue(); |
| | | count = Math.max(1, count); |
| | | int entryQty = count * Integer.parseInt(config.getQuantity()); |
| | | String size = String.valueOf(entryQty); |
| | | log.info("[OKX] 多仓止损触发 gridId:{}, 在gridId:{}挂{}张多单", gridId, newEntryGridId, entryQty); |
| | | log.info("[OKX] 多仓止损触发 gridId:{}, 在gridId:{}挂{}张多单(价差:{},步长:{},count:{},qty:{})", |
| | | gridId, newEntryGridId, entryQty, priceDiff, config.getStep(), count, config.getQuantity()); |
| | | newEntryGrid.getLongTraderParam().setQuantity(size); |
| | | placeEntryOrderWithPreFlag(newEntryGrid, true, triggerPrice, size); |
| | | } |
| | |
| | | |
| | | BigDecimal triggerPrice = newEntryGrid.getGridPrice(); |
| | | BigDecimal priceDiff = shortEntryPrice.subtract(triggerPrice).abs(); |
| | | int entryQty = priceDiff.divide(config.getStep(), 0, RoundingMode.DOWN).intValue(); |
| | | entryQty = Math.max(1, entryQty); |
| | | int count = priceDiff.divide(config.getStep(), 0, RoundingMode.DOWN).intValue(); |
| | | count = Math.max(1, count); |
| | | int entryQty = count * Integer.parseInt(config.getQuantity()); |
| | | String size = String.valueOf(entryQty); |
| | | log.info("[OKX] 空仓止损触发 gridId:{}, 在gridId:{}挂{}张空单", gridId, newEntryGridId, entryQty); |
| | | log.info("[OKX] 空仓止损触发 gridId:{}, 在gridId:{}挂{}张空单(价差:{},步长:{},count:{},qty:{})", |
| | | gridId, newEntryGridId, entryQty, priceDiff, config.getStep(), count, config.getQuantity()); |
| | | newEntryGrid.getShortTraderParam().setQuantity(size); |
| | | placeEntryOrderWithPreFlag(newEntryGrid, false, triggerPrice, size); |
| | | } |
| | | |
| | | private void extendLongStopLoss(int filledQty) { |
| | | // filledQty 为本次新增止损张数 = count * quantity, 需要按 quantity 为粒度拆分为 count 个止损单 |
| | | int qty = Integer.parseInt(config.getQuantity()); |
| | | int stopLossCount = filledQty / qty; |
| | | int furthestSlId = 0; |
| | | for (OkxGridElement e : config.getGridElements()) { |
| | | if (e.getLongStopLossOrderId() != null && e.getId() < furthestSlId) { |
| | |
| | | } |
| | | } |
| | | if (furthestSlId == 0) furthestSlId = -11; |
| | | log.info("[OKX] 多仓追挂止损, 当前最远止损gridId:{}, 追加{}张", furthestSlId, filledQty); |
| | | for (int i = 0; i < filledQty; i++) { |
| | | log.info("[OKX] 多仓追挂止损, 当前最远止损gridId:{}, 追加{}单, 每单{}张", furthestSlId, stopLossCount, qty); |
| | | for (int i = 0; i < stopLossCount; i++) { |
| | | int newSlId = furthestSlId - i - 1; |
| | | OkxGridElement elem = OkxGridElement.findById(newSlId); |
| | | if (elem == null) continue; |
| | | BigDecimal triggerPrice = elem.getGridPrice(); |
| | | int finalSlId = newSlId; |
| | | executor.placeTakeProfit(triggerPrice.toString(), "sell", "long", "1", |
| | | executor.placeTakeProfit(triggerPrice.toString(), "sell", "long", config.getQuantity(), |
| | | profitId -> { |
| | | elem.setLongStopLossOrderId(profitId); |
| | | OkxGridElement.refreshIndices(); |
| | |
| | | } |
| | | |
| | | private void extendShortStopLoss(int filledQty) { |
| | | int qty = Integer.parseInt(config.getQuantity()); |
| | | int stopLossCount = filledQty / qty; |
| | | int furthestSlId = 0; |
| | | for (OkxGridElement e : config.getGridElements()) { |
| | | if (e.getShortStopLossOrderId() != null && e.getId() > furthestSlId) { |
| | |
| | | } |
| | | } |
| | | if (furthestSlId == 0) furthestSlId = 11; |
| | | log.info("[OKX] 空仓追挂止损, 当前最远止损gridId:{}, 追加{}张", furthestSlId, filledQty); |
| | | for (int i = 0; i < filledQty; i++) { |
| | | log.info("[OKX] 空仓追挂止损, 当前最远止损gridId:{}, 追加{}单, 每单{}张", furthestSlId, stopLossCount, qty); |
| | | for (int i = 0; i < stopLossCount; i++) { |
| | | int newSlId = furthestSlId + i + 1; |
| | | OkxGridElement elem = OkxGridElement.findById(newSlId); |
| | | if (elem == null) continue; |
| | | BigDecimal triggerPrice = elem.getGridPrice(); |
| | | int finalSlId = newSlId; |
| | | executor.placeTakeProfit(triggerPrice.toString(), "buy", "short", "1", |
| | | executor.placeTakeProfit(triggerPrice.toString(), "buy", "short", config.getQuantity(), |
| | | profitId -> { |
| | | elem.setShortStopLossOrderId(profitId); |
| | | OkxGridElement.refreshIndices(); |
| | |
| | | |
| | | private static final int HEARTBEAT_TIMEOUT = 10; |
| | | |
| | | /** 模拟盘 WS 地址 */ |
| | | private static final String WS_URL_SIM = "wss://wspap.okx.com:8443/ws/v5/private"; |
| | | /** 实盘 WS 地址 */ |
| | | private static final String WS_URL_PROD = "wss://ws.okx.com:8443/ws/v5/private"; |
| | | /** 模拟盘业务 WS 地址(K线等行情数据) */ |
| | | private static final String WS_BUSINESS_URL_SIM = "wss://wspap.okx.com:8443/ws/v5/business"; |
| | | /** 实盘业务 WS 地址(K线等行情数据) */ |
| | | private static final String WS_BUSINESS_URL_PROD = "wss://ws.okx.com:8443/ws/v5/business"; |
| | | /** 模拟盘私有 WS 地址 */ |
| | | private static final String WS_PRIVATE_URL_SIM = "wss://wspap.okx.com:8443/ws/v5/private"; |
| | | /** 实盘私有 WS 地址 */ |
| | | private static final String WS_PRIVATE_URL_PROD = "wss://ws.okx.com:8443/ws/v5/private"; |
| | | |
| | | private final ExchangeInfoEnum account; |
| | | private final boolean isPublic; |
| | | private final String logPrefix; |
| | | private WebSocketClient webSocketClient; |
| | | private ScheduledExecutorService heartbeatExecutor; |
| | | private volatile ScheduledFuture<?> pongTimeoutFuture; |
| | |
| | | return t; |
| | | }); |
| | | |
| | | public OkxGridWsClient(ExchangeInfoEnum account) { |
| | | public OkxGridWsClient(ExchangeInfoEnum account, boolean isPublic) { |
| | | this.account = account; |
| | | this.isPublic = isPublic; |
| | | this.logPrefix = isPublic ? "[OKX-Grid-WS-PUB]" : "[OKX-Grid-WS-PRI]"; |
| | | } |
| | | |
| | | public void addChannelHandler(OkxGridChannelHandler handler) { |
| | |
| | | |
| | | public void init() { |
| | | if (!isInitialized.compareAndSet(false, true)) { |
| | | log.warn("[OKX-Grid-WS] 已初始化过,跳过重复初始化"); |
| | | log.warn("[{}] 已初始化过,跳过重复初始化", logPrefix); |
| | | return; |
| | | } |
| | | connect(); |
| | |
| | | } |
| | | |
| | | public void destroy() { |
| | | log.info("[OKX-Grid-WS] 开始销毁..."); |
| | | log.info("[{}] 开始销毁...", logPrefix); |
| | | if (webSocketClient != null && webSocketClient.isOpen()) { |
| | | for (OkxGridChannelHandler handler : channelHandlers) { |
| | | handler.unsubscribe(webSocketClient); |
| | |
| | | shutdownExecutorGracefully(heartbeatExecutor); |
| | | if (pongTimeoutFuture != null) pongTimeoutFuture.cancel(true); |
| | | shutdownExecutorGracefully(sharedExecutor); |
| | | log.info("[OKX-Grid-WS] 销毁完成"); |
| | | log.info("[{}] 销毁完成", logPrefix); |
| | | } |
| | | |
| | | private void connect() { |
| | | if (isConnecting.get() || !isConnecting.compareAndSet(false, true)) { |
| | | log.info("[OKX-Grid-WS] 连接进行中,跳过重复请求"); |
| | | log.info("[{}] 连接进行中,跳过重复请求", logPrefix); |
| | | return; |
| | | } |
| | | try { |
| | | SSLConfig.configureSSL(); |
| | | System.setProperty("https.protocols", "TLSv1.2,TLSv1.3"); |
| | | String wsUrl = account.isAccountType() ? WS_URL_PROD : WS_URL_SIM; |
| | | String wsUrl; |
| | | if (account.isAccountType()) { |
| | | wsUrl = isPublic ? WS_BUSINESS_URL_PROD : WS_PRIVATE_URL_PROD; |
| | | } else { |
| | | wsUrl = isPublic ? WS_BUSINESS_URL_SIM : WS_PRIVATE_URL_SIM; |
| | | } |
| | | URI uri = new URI(wsUrl); |
| | | |
| | | if (webSocketClient != null) { |
| | |
| | | webSocketClient = new WebSocketClient(uri) { |
| | | @Override |
| | | public void onOpen(ServerHandshake handshake) { |
| | | log.info("[OKX-Grid-WS] 连接成功"); |
| | | log.info("[{}] 连接成功", logPrefix); |
| | | isConnected.set(true); |
| | | isConnecting.set(false); |
| | | if (sharedExecutor != null && !sharedExecutor.isShutdown()) { |
| | | resetHeartbeatTimer(); |
| | | wsLogin(); |
| | | if (isPublic) { |
| | | subscribeAllHandlers(); |
| | | } else { |
| | | wsLogin(); |
| | | } |
| | | } |
| | | } |
| | | |
| | |
| | | |
| | | @Override |
| | | public void onClose(int code, String reason, boolean remote) { |
| | | log.warn("[OKX-Grid-WS] 连接关闭, code:{}, reason:{}", code, reason); |
| | | log.warn("[{}] 连接关闭, code:{}, reason:{}", logPrefix, code, reason); |
| | | isConnected.set(false); |
| | | isConnecting.set(false); |
| | | cancelPongTimeout(); |
| | | if (sharedExecutor != null && !sharedExecutor.isShutdown() && !sharedExecutor.isTerminated()) { |
| | | sharedExecutor.execute(() -> { |
| | | try { reconnectWithBackoff(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } catch (Exception e) { log.error("[OKX-Grid-WS] 重连失败", e); } |
| | | try { reconnectWithBackoff(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } catch (Exception e) { log.error("[{}] 重连失败", logPrefix, e); } |
| | | }); |
| | | } |
| | | } |
| | | |
| | | @Override |
| | | public void onError(Exception ex) { |
| | | log.error("[OKX-Grid-WS] 发生错误", ex); |
| | | log.error("[{}] 发生错误", logPrefix, ex); |
| | | isConnected.set(false); |
| | | } |
| | | }; |
| | | webSocketClient.setConnectionLostTimeout(0); |
| | | webSocketClient.connect(); |
| | | } catch (URISyntaxException e) { |
| | | log.error("[OKX-Grid-WS] URI格式错误", e); |
| | | log.error("[{}] URI格式错误", logPrefix, e); |
| | | isConnecting.set(false); |
| | | } |
| | | } |
| | |
| | | args.add(loginArgs); |
| | | msg.put("args", args); |
| | | webSocketClient.send(msg.toJSONString()); |
| | | log.info("[OKX-Grid-WS] 发送登录请求"); |
| | | log.info("[{}] 发送登录请求", logPrefix); |
| | | } catch (Exception e) { |
| | | log.error("[OKX-Grid-WS] 登录请求构建失败", e); |
| | | log.error("[{}] 登录请求构建失败", logPrefix, e); |
| | | } |
| | | } |
| | | |
| | | private void subscribeAllHandlers() { |
| | | log.info("[{}] 开始订阅频道", logPrefix); |
| | | for (OkxGridChannelHandler handler : channelHandlers) { |
| | | handler.subscribe(webSocketClient); |
| | | } |
| | | } |
| | | |
| | | private void handleMessage(String message) { |
| | | try { |
| | | if ("pong".equals(message)) { |
| | | log.debug("[{}] 收到 pong", logPrefix); |
| | | return; |
| | | } |
| | | JSONObject response = JSON.parseObject(message); |
| | | String event = response.getString("event"); |
| | | String op = response.getString("op"); |
| | | |
| | | // 登录成功 → 订阅所有频道 |
| | | if ("login".equals(event) || ("login".equals(op))) { |
| | | log.info("[OKX-Grid-WS] 登录成功, 开始订阅频道"); |
| | | for (OkxGridChannelHandler handler : channelHandlers) { |
| | | handler.subscribe(webSocketClient); |
| | | } |
| | | log.info("[{}] 登录成功, 开始订阅频道", logPrefix); |
| | | subscribeAllHandlers(); |
| | | return; |
| | | } |
| | | |
| | | // 订阅确认 |
| | | if ("subscribe".equals(event) || "unsubscribe".equals(event)) { |
| | | log.info("[OKX-Grid-WS] {}事件: {}", event, response.getString("arg")); |
| | | log.info("[{}] {}事件: {}", logPrefix, event, response.getString("arg")); |
| | | return; |
| | | } |
| | | |
| | | // 错误 |
| | | if ("error".equals(event)) { |
| | | log.error("[OKX-Grid-WS] 错误: {}", message); |
| | | log.error("[{}] 错误: {}", logPrefix, message); |
| | | return; |
| | | } |
| | | |
| | | // 数据推送 → 路由到 handler |
| | | for (OkxGridChannelHandler handler : channelHandlers) { |
| | | if (handler.handleMessage(response)) return; |
| | | } |
| | | } catch (Exception e) { |
| | | log.error("[OKX-Grid-WS] 处理消息失败: {}", message, e); |
| | | log.error("[{}] 处理消息失败: {}", logPrefix, message, e); |
| | | } |
| | | } |
| | | |
| | |
| | | try { |
| | | if (webSocketClient != null && webSocketClient.isOpen()) { |
| | | webSocketClient.send("ping"); |
| | | log.debug("[OKX-Grid-WS] 发送 ping"); |
| | | log.debug("[{}] 发送 ping", logPrefix); |
| | | } |
| | | } catch (Exception e) { log.warn("[OKX-Grid-WS] 发送 ping 失败", e); } |
| | | } catch (Exception e) { log.warn("[{}] 发送 ping 失败", logPrefix, e); } |
| | | } |
| | | |
| | | private synchronized void cancelPongTimeout() { |
| | |
| | | long delayMs = 5000; |
| | | while (attempt < maxAttempts) { |
| | | try { Thread.sleep(delayMs); connect(); return; } |
| | | catch (Exception e) { log.warn("[OKX-Grid-WS] 第{}次重连失败", attempt + 1, e); delayMs *= 2; attempt++; } |
| | | catch (Exception e) { log.warn("[{}] 第{}次重连失败", logPrefix, attempt + 1, e); delayMs *= 2; attempt++; } |
| | | } |
| | | log.error("[OKX-Grid-WS] 超过最大重试次数({}),放弃重连", maxAttempts); |
| | | log.error("[{}] 超过最大重试次数({}),放弃重连", logPrefix, maxAttempts); |
| | | } |
| | | |
| | | private void shutdownExecutorGracefully(ExecutorService executor) { |
| | |
| | | * @param onFailure 失败回调 |
| | | */ |
| | | public void openLong(String quantity, Consumer<String> onSuccess, Runnable onFailure) { |
| | | submitOrder("buy", "long", quantity, "market", null, false, "t-okx-grid-long", onSuccess, onFailure); |
| | | submitOrder("buy", "long", quantity, "market", null, false, null, onSuccess, onFailure); |
| | | } |
| | | |
| | | /** |
| | |
| | | * @param onFailure 失败回调 |
| | | */ |
| | | public void openShort(String quantity, Consumer<String> onSuccess, Runnable onFailure) { |
| | | submitOrder("sell", "short", quantity, "market", null, false, "t-okx-grid-short", onSuccess, onFailure); |
| | | submitOrder("sell", "short", quantity, "market", null, false, null, onSuccess, onFailure); |
| | | } |
| | | |
| | | /** |
| | |
| | | params.put("tdMode", tdMode); |
| | | params.put("side", side); |
| | | params.put("posSide", posSide); |
| | | params.put("ordType", "conditional"); |
| | | params.put("ordType", "trigger"); |
| | | params.put("sz", size); |
| | | params.put("triggerPx", triggerPrice); |
| | | params.put("triggerPxType", "last"); |
| | | params.put("orderPx", "-1"); // 市价成交 |
| | | params.put("orderPx", "-1"); |
| | | |
| | | String resp = okxAccount.requestHandler.sendSignedRequest( |
| | | okxAccount.baseUrl, "/api/v5/trade/order-algo", params, HttpMethod.POST, okxAccount.isSimluate()); |
| | |
| | | params.put("posSide", posSide); |
| | | params.put("ordType", "conditional"); |
| | | params.put("sz", size); |
| | | params.put("triggerPx", triggerPrice); |
| | | params.put("triggerPxType", "last"); |
| | | params.put("orderPx", "-1"); // 市价成交 |
| | | params.put("slTriggerPx", triggerPrice); |
| | | params.put("slTriggerPxType", "last"); |
| | | params.put("slOrdPx", "-1"); |
| | | |
| | | String resp = okxAccount.requestHandler.sendSignedRequest( |
| | | okxAccount.baseUrl, "/api/v5/trade/order-algo", params, HttpMethod.POST, okxAccount.isSimluate()); |
| | |
| | | } |
| | | executor.execute(() -> { |
| | | try { |
| | | LinkedHashMap<String, Object> params = new LinkedHashMap<>(); |
| | | params.put("instId", instId); |
| | | params.put("algoId", algoId); |
| | | String resp = okxAccount.requestHandler.sendSignedRequest( |
| | | okxAccount.baseUrl, "/api/v5/trade/cancel-algos", params, HttpMethod.POST, okxAccount.isSimluate()); |
| | | String body = "[{\"instId\":\"" + instId + "\",\"algoId\":\"" + algoId + "\"}]"; |
| | | String resp = okxAccount.requestHandler.sendSignedRequestRaw( |
| | | okxAccount.baseUrl, "/api/v5/trade/cancel-algos", body, HttpMethod.POST, okxAccount.isSimluate()); |
| | | log.info("[OkxExec] 条件单已取消, algoId:{}", algoId); |
| | | if (onSuccess != null) { |
| | | onSuccess.accept(algoId); |
| | |
| | | } |
| | | |
| | | /** |
| | | * 异步取消所有未完成的 algo 订单。 |
| | | * 异步取消所有未完成的 algo 订单(best-effort,失败仅警告)。 |
| | | */ |
| | | public void cancelAllAlgoOrders() { |
| | | executor.execute(() -> { |
| | | try { |
| | | LinkedHashMap<String, Object> params = new LinkedHashMap<>(); |
| | | params.put("instId", instId); |
| | | String resp = okxAccount.requestHandler.sendSignedRequest( |
| | | okxAccount.baseUrl, "/api/v5/trade/cancel-algos", params, HttpMethod.POST, okxAccount.isSimluate()); |
| | | String body = "[{\"instId\":\"" + instId + "\",\"instType\":\"SWAP\"}]"; |
| | | String resp = okxAccount.requestHandler.sendSignedRequestRaw( |
| | | okxAccount.baseUrl, "/api/v5/trade/cancel-algos", body, HttpMethod.POST, okxAccount.isSimluate()); |
| | | log.info("[OkxExec] 已尝试清除条件单, resp:{}", resp); |
| | | } catch (Exception e) { |
| | | log.error("[OkxExec] 清除条件单失败", e); |
| | | log.warn("[OkxExec] 清除条件单失败(若无挂单可忽略), msg:{}", e.getMessage()); |
| | | } |
| | | }); |
| | | } |
| | |
| | | |
| | | import com.xcong.excoin.modules.okxNewPrice.gridWs.OkxAlgoOrdersChannelHandler; |
| | | import com.xcong.excoin.modules.okxNewPrice.gridWs.OkxKlineChannelHandler; |
| | | import com.xcong.excoin.modules.okxNewPrice.gridWs.OkxOrdersChannelHandler; |
| | | import com.xcong.excoin.modules.okxNewPrice.gridWs.OkxPositionsChannelHandler; |
| | | import com.xcong.excoin.modules.okxNewPrice.okxpi.config.OKXAccount; |
| | | import com.xcong.excoin.modules.okxNewPrice.okxpi.config.enums.DefaultUrls; |
| | |
| | | @ConditionalOnProperty(prefix = "app", name = "quant", havingValue = "true") |
| | | public class OkxWebSocketClientManager { |
| | | |
| | | /** 网格交易 WS 客户端 */ |
| | | private OkxGridWsClient gridWsClient; |
| | | /** 网格交易公共 WS 客户端(candle1m) */ |
| | | private OkxGridWsClient gridWsClientPublic; |
| | | /** 网格交易私有 WS 客户端(positions / orders-algo) */ |
| | | private OkxGridWsClient gridWsClientPrivate; |
| | | /** 网格交易策略服务 */ |
| | | private OkxGridTradeService gridTradeService; |
| | | /** 统一配置 */ |
| | |
| | | .apiKey(primaryAccount.getApiKey()) |
| | | .secretKey(primaryAccount.getSecretKey()) |
| | | .passphrase(primaryAccount.getPassphrase()) |
| | | .instId("ETH-USDT-SWAP") |
| | | .instId("BTC-USDT-SWAP") |
| | | .leverage("100") |
| | | .tdMode("cross") |
| | | .gridRate(new BigDecimal("0.0025")) |
| | | .expectedProfit(new BigDecimal("2")) |
| | | .maxLoss(new BigDecimal("15")) |
| | | .gridRate(new BigDecimal("0.001")) |
| | | .expectedProfit(new BigDecimal("20")) |
| | | .maxLoss(new BigDecimal("30")) |
| | | .quantity("1") |
| | | .baseQuantity("10") |
| | | .priceScale(2) |
| | | .ctVal(new BigDecimal("0.1")) |
| | | .ctVal(new BigDecimal("0.01")) |
| | | .isSimulate(!primaryAccount.isAccountType()) |
| | | .gridQueueSize(300) |
| | | .marginRatioLimit(new BigDecimal("0.2")) |
| | |
| | | gridTradeService = new OkxGridTradeService(okxConfig, okxAccount); |
| | | gridTradeService.init(); |
| | | |
| | | // 4. 创建 WS 客户端并注册 3 个频道处理器 |
| | | gridWsClient = new OkxGridWsClient(primaryAccount); |
| | | gridWsClient.addChannelHandler(new OkxKlineChannelHandler(okxConfig.getInstId(), gridTradeService)); |
| | | gridWsClient.addChannelHandler(new OkxPositionsChannelHandler(okxConfig.getInstId(), gridTradeService)); |
| | | gridWsClient.addChannelHandler(new OkxAlgoOrdersChannelHandler(okxConfig.getInstId(), gridTradeService)); |
| | | gridWsClient.init(); |
| | | log.info("[OKX-Manager] WS已连接, 已注册 3 个频道处理器: candle1m/positions/orders-algo"); |
| | | // 4. 创建 WS 客户端并注册频道处理器 |
| | | // 业务 WS(/v5/business):candle1m |
| | | gridWsClientPublic = new OkxGridWsClient(primaryAccount, true); |
| | | gridWsClientPublic.addChannelHandler(new OkxKlineChannelHandler(okxConfig.getInstId(), gridTradeService)); |
| | | gridWsClientPublic.init(); |
| | | |
| | | // 私有 WS(/v5/private):positions + orders(algo触发后订单fill带algoId可匹配) |
| | | gridWsClientPrivate = new OkxGridWsClient(primaryAccount, false); |
| | | gridWsClientPrivate.addChannelHandler(new OkxPositionsChannelHandler(okxConfig.getInstId(), gridTradeService)); |
| | | gridWsClientPrivate.addChannelHandler(new OkxOrdersChannelHandler(okxConfig.getInstId(), gridTradeService)); |
| | | gridWsClientPrivate.init(); |
| | | |
| | | log.info("[OKX-Manager] WS已连接, business: candle1m, private: positions/orders"); |
| | | |
| | | // 5. 激活策略,等待首根 K 线触发基底双开 |
| | | gridTradeService.startGrid(); |
| | |
| | | log.error("[OKX-Manager] 停止策略失败", e); |
| | | } |
| | | } |
| | | if (gridWsClient != null) { |
| | | if (gridWsClientPublic != null) { |
| | | try { |
| | | gridWsClient.destroy(); |
| | | gridWsClientPublic.destroy(); |
| | | } catch (Exception e) { |
| | | log.error("[OKX-Manager] 销毁WS客户端失败", e); |
| | | log.error("[OKX-Manager] 销毁公共WS客户端失败", e); |
| | | } |
| | | } |
| | | if (gridWsClientPrivate != null) { |
| | | try { |
| | | gridWsClientPrivate.destroy(); |
| | | } catch (Exception e) { |
| | | log.error("[OKX-Manager] 销毁私有WS客户端失败", e); |
| | | } |
| | | } |
| | | |
| | |
| | | |
| | | /** @return 网格交易策略服务实例 */ |
| | | public OkxGridTradeService getGridTradeService() { return gridTradeService; } |
| | | /** @return 网格交易 WS 客户端实例 */ |
| | | public OkxGridWsClient getGridWsClient() { return gridWsClient; } |
| | | /** @return 统一配置实例 */ |
| | | /** @return 网格交易公共 WS 客户端实例 */ |
| | | public OkxGridWsClient getGridWsClientPublic() { return gridWsClientPublic; } |
| | | /** @return 网格交易私有 WS 客户端实例 */ |
| | | public OkxGridWsClient getGridWsClientPrivate() { return gridWsClientPrivate; } |
| | | public OkxConfig getOkxConfig() { return okxConfig; } |
| | | } |
| | |
| | | JSONObject msg = new JSONObject(); |
| | | JSONObject arg = new JSONObject(); |
| | | arg.put("channel", CHANNEL_NAME); |
| | | arg.put("instType", "SWAP"); |
| | | arg.put("instId", instId); |
| | | msg.put("op", "subscribe"); |
| | | JSONArray args = new JSONArray(); |
| | | args.add(arg); |
| | | msg.put("args", args); |
| | | ws.send(msg.toJSONString()); |
| | | log.info("[OKX-WS] {} 订阅成功", CHANNEL_NAME); |
| | | log.info("[OKX-WS] {} 订阅成功, instId:{}", CHANNEL_NAME, instId); |
| | | } |
| | | |
| | | @Override |
| | |
| | | JSONObject msg = new JSONObject(); |
| | | JSONObject arg = new JSONObject(); |
| | | arg.put("channel", CHANNEL_NAME); |
| | | arg.put("instType", "SWAP"); |
| | | arg.put("instId", instId); |
| | | msg.put("op", "unsubscribe"); |
| | | JSONArray args = new JSONArray(); |
| | | args.add(arg); |
| New file |
| | |
| | | package com.xcong.excoin.modules.okxNewPrice.gridWs; |
| | | |
| | | import com.alibaba.fastjson.JSONArray; |
| | | import com.alibaba.fastjson.JSONObject; |
| | | import com.xcong.excoin.modules.okxNewPrice.OkxGridTradeService; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.java_websocket.client.WebSocketClient; |
| | | |
| | | /** |
| | | * OKX 订单频道处理器 (orders),替代 orders-algo。 |
| | | * 当 algo 订单触发后生成普通市价单,其 fill 数据带 algoId,可匹配到原始条件单。 |
| | | */ |
| | | @Slf4j |
| | | public class OkxOrdersChannelHandler implements OkxGridChannelHandler { |
| | | |
| | | private static final String CHANNEL_NAME = "orders"; |
| | | |
| | | private final String instId; |
| | | private final OkxGridTradeService gridTradeService; |
| | | |
| | | public OkxOrdersChannelHandler(String instId, OkxGridTradeService gridTradeService) { |
| | | this.instId = instId; |
| | | this.gridTradeService = gridTradeService; |
| | | } |
| | | |
| | | @Override |
| | | public String getChannelName() { return CHANNEL_NAME; } |
| | | |
| | | @Override |
| | | public void subscribe(WebSocketClient ws) { |
| | | JSONObject msg = new JSONObject(); |
| | | JSONObject arg = new JSONObject(); |
| | | arg.put("channel", CHANNEL_NAME); |
| | | arg.put("instType", "SWAP"); |
| | | msg.put("op", "subscribe"); |
| | | JSONArray args = new JSONArray(); |
| | | args.add(arg); |
| | | msg.put("args", args); |
| | | ws.send(msg.toJSONString()); |
| | | log.info("[OKX-WS] {} 订阅成功", CHANNEL_NAME); |
| | | } |
| | | |
| | | @Override |
| | | public void unsubscribe(WebSocketClient ws) { |
| | | JSONObject msg = new JSONObject(); |
| | | JSONObject arg = new JSONObject(); |
| | | arg.put("channel", CHANNEL_NAME); |
| | | arg.put("instType", "SWAP"); |
| | | msg.put("op", "unsubscribe"); |
| | | JSONArray args = new JSONArray(); |
| | | args.add(arg); |
| | | msg.put("args", args); |
| | | ws.send(msg.toJSONString()); |
| | | log.info("[OKX-WS] {} 取消订阅成功", CHANNEL_NAME); |
| | | } |
| | | |
| | | @Override |
| | | public boolean handleMessage(JSONObject response) { |
| | | JSONObject arg = response.getJSONObject("arg"); |
| | | if (arg == null || !CHANNEL_NAME.equals(arg.getString("channel"))) { |
| | | return false; |
| | | } |
| | | try { |
| | | JSONArray data = response.getJSONArray("data"); |
| | | if (data == null || data.isEmpty()) return true; |
| | | for (int i = 0; i < data.size(); i++) { |
| | | JSONObject order = data.getJSONObject(i); |
| | | if (!instId.equals(order.getString("instId"))) continue; |
| | | |
| | | String algoId = order.getString("algoId"); |
| | | if (algoId == null || algoId.isEmpty()) continue; |
| | | |
| | | String state = order.getString("state"); |
| | | if (!"filled".equals(state)) continue; |
| | | |
| | | String ordType = order.getString("ordType"); |
| | | log.info("[OKX-WS] 订单成交(algo), algoId:{}, ordType:{}, fillPx:{}, fillSz:{}", |
| | | algoId, ordType, |
| | | order.getString("fillPx"), |
| | | order.getString("fillSz")); |
| | | |
| | | if (gridTradeService != null) { |
| | | gridTradeService.onOrderUpdate(algoId, state, ordType); |
| | | } |
| | | } |
| | | } catch (Exception e) { |
| | | log.error("[OKX-WS] {} 处理数据失败", CHANNEL_NAME, e); |
| | | } |
| | | return true; |
| | | } |
| | | } |
| | |
| | | if (data == null || data.isEmpty()) return true; |
| | | for (int i = 0; i < data.size(); i++) { |
| | | JSONObject pos = data.getJSONObject(i); |
| | | if (!instId.equals(pos.getString("instId"))) continue; |
| | | String posInstId = pos.getString("instId"); |
| | | if (posInstId == null || !instId.equals(posInstId)) continue; |
| | | |
| | | String posSide = pos.getString("posSide"); |
| | | BigDecimal posSize = new BigDecimal(pos.getString("pos")); |
| | | BigDecimal avgPx = new BigDecimal(pos.getString("avgPx")); |
| | | String posStr = pos.getString("pos"); |
| | | String avgPxStr = pos.getString("avgPx"); |
| | | if (posStr == null || posStr.isEmpty() || avgPxStr == null || avgPxStr.isEmpty()) continue; |
| | | |
| | | BigDecimal posSize = new BigDecimal(posStr); |
| | | BigDecimal avgPx = new BigDecimal(avgPxStr); |
| | | log.info("[OKX-WS] 持仓更新, instId:{}, posSide:{}, pos:{}, avgPx:{}", |
| | | instId, posSide, posSize, avgPx); |
| | | |
| | |
| | | if (null == secretKey || secretKey.isEmpty() || null == apiKey || apiKey.isEmpty()) { |
| | | throw new FebsException("[RequestHandler] Secret key/API key cannot be null or empty!"); |
| | | } |
| | | //parameters.put("timestamp", UrlBuilder.buildTimestamp()); |
| | | //String queryString = UrlBuilder.joinQueryParameters(parameters); |
| | | //String signature = SignatureGenerator.getSignature(queryString, secretKey); |
| | | return sendApiRequest(baseUrl, urlPath, parameters, httpMethod, RequestType.SIGNED, isSimluate); |
| | | } |
| | | |
| | | public String sendSignedRequestRaw(String baseUrl, String urlPath, String rawBody, |
| | | HttpMethod httpMethod, boolean isSimluate) { |
| | | if (null == secretKey || secretKey.isEmpty() || null == apiKey || apiKey.isEmpty()) { |
| | | throw new FebsException("[RequestHandler] Secret key/API key cannot be null or empty!"); |
| | | } |
| | | String fullUrl = UrlBuilder.buildFullUrl(baseUrl, urlPath, null, null); |
| | | log.debug("{} {}", httpMethod, fullUrl); |
| | | String timestamp = DateUtils.format(DateUtils.FORMAT_UTC_ISO8601, new Date(), 0); |
| | | String queryString = urlPath; |
| | | String sign = SignUtils.signRest(secretKey, timestamp, httpMethod.toString(), queryString, rawBody); |
| | | Request request = RequestBuilder.buildApiKeyRequest(fullUrl, rawBody, passphrase, sign, timestamp, httpMethod, apiKey, isSimluate); |
| | | return ResponseHandler.handleResponse(request, isSimluate); |
| | | } |
| | | } |
| | |
| | | } |
| | | |
| | | public static String handleResponse(Request request, boolean showLimitUsage) { |
| | | try (Response response = OkHttpUtils.okHttpClient.newCall(request).execute()) {//OkHttpUtils.builder().okHttpClient |
| | | try (Response response = OkHttpUtils.getClient().newCall(request).execute()) { |
| | | String responseAsString = getResponseBodyAsString(response.body()); |
| | | |
| | | if (response.code() >= HTTP_STATUS_CODE_400 && response.code() <= HTTP_STATUS_CODE_499) { |
| | |
| | | } |
| | | |
| | | /** |
| | | * 确保 OkHttpClient 在任何请求路径下都已初始化(包括直接使用 new Request.Builder() 的场景)。 |
| | | */ |
| | | public static OkHttpClient getClient() { |
| | | if (okHttpClient == null) { |
| | | new OkHttpUtils(); // 触发构造函数中的 DCL 初始化 |
| | | } |
| | | return okHttpClient; |
| | | } |
| | | |
| | | /** |
| | | * 创建OkHttpUtils |
| | | * |
| | | * @return |
| | |
| | | if (DEFAULT == null) { |
| | | synchronized (DingTalkUtils.class) { |
| | | if (DEFAULT == null) { |
| | | // DEFAULT = new DingTalkUtils( |
| | | // "57a3e695f78d7547fe20fb7aef82cf35a27de1846bbc6966e0194761976d7597", |
| | | // "SECd59a93c8939eeaef0d97b5b714639df4af95d922002d0a440bc82ad42710a89e"); |
| | | DEFAULT = new DingTalkUtils( |
| | | "e357a3417991da86a5f79ea5bc8785b529c1da8b9d27458febed3b3d10c857c4", |
| | | "SECf2b819e930cb4b367cf599f11a30eb8a5d0f4b0b1c069a57aa15328a3feebf8c"); |
| | | "57a3e695f78d7547fe20fb7aef82cf35a27de1846bbc6966e0194761976d7597", |
| | | "SECd59a93c8939eeaef0d97b5b714639df4af95d922002d0a440bc82ad42710a89e"); |
| | | // DEFAULT = new DingTalkUtils( |
| | | // "e357a3417991da86a5f79ea5bc8785b529c1da8b9d27458febed3b3d10c857c4", |
| | | // "SECf2b819e930cb4b367cf599f11a30eb8a5d0f4b0b1c069a57aa15328a3feebf8c"); |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | mybatis-plus: |
| | | mapper-locations: classpath*:mapper/blackchain/*.xml, classpath*:mapper/ding/*.xml, classpath*:mapper/price/*.xml, classpath*:mapper/combom/*.xml, classpath*:mapper/record/*.xml, classpath*:mapper/uinfo/*.xml, classpath*:mapper/push/*.xml, classpath*:mapper/coin/*.xml, classpath*:mapper/user/*.xml, classpath*:mapper/demo/*.xml |
| | |
| | | ## 国际化配置 |
| | | messages: |
| | | basename: i18n/messages |
| | | ## redis配置 |
| | | redis: |
| | | # Redis数据库索引(默认为 0) |
| | | database: 8 |
| | | # Redis服务器地址 |
| | | host: 120.27.238.55 |
| | | # Redis服务器连接端口 |
| | | port: 6479 |
| | | # Redis 密码 |
| | | password: d3y6dsdl;f.327 |
| | | lettuce: |
| | | pool: |
| | | # 连接池中的最小空闲连接 |
| | | min-idle: 8 |
| | | # 连接池中的最大空闲连接 |
| | | max-idle: 500 |
| | | # 连接池最大连接数(使用负值表示没有限制) |
| | | max-active: 2000 |
| | | # 连接池最大阻塞等待时间(使用负值表示没有限制) |
| | | max-wait: 10000 |
| | | # 连接超时时间(毫秒) |
| | | timeout: 500000 |
| | | |
| | | rabbitmq: |
| | | host: 120.27.238.55 |
| | | port: 5672 |
| | | username: ct_rabbit |
| | | password: 123456 |
| | | publisher-confirm-type: correlated |
| | | |
| | | |
| | | #custom: |
| | | # rabbitmq: |
| | | # host: 120.27.238.55 |
| | | # port: 5672 |
| | | # username: ct_rabbit |
| | | # password: 123456 |
| | | |
| | | mybatis-plus: |
| | | mapper-locations: classpath:mapper/**/*.xml |
| | | |
| | | |
| | | app: |
| | | debug: false |
| | |
| | | ## 国际化配置 |
| | | messages: |
| | | basename: i18n/messages |
| | | ## redis配置 |
| | | redis: |
| | | # Redis数据库索引(默认为 0) |
| | | database: 13 |
| | | # Redis服务器地址 |
| | | host: 120.27.238.55 |
| | | # Redis服务器连接端口 |
| | | port: 6479 |
| | | # Redis 密码 |
| | | password: d3y6dsdl;f.327 |
| | | lettuce: |
| | | pool: |
| | | # 连接池中的最小空闲连接 |
| | | min-idle: 8 |
| | | # 连接池中的最大空闲连接 |
| | | max-idle: 500 |
| | | # 连接池最大连接数(使用负值表示没有限制) |
| | | max-active: 2000 |
| | | # 连接池最大阻塞等待时间(使用负值表示没有限制) |
| | | max-wait: 10000 |
| | | # 连接超时时间(毫秒) |
| | | timeout: 500000 |
| | | |
| | | rabbitmq: |
| | | host: 120.27.238.55 |
| | | port: 5672 |
| | | username: ct_rabbit |
| | | password: 123456 |
| | | publisher-confirm-type: correlated |
| | | |
| | | |
| | | #custom: |
| | | # rabbitmq: |
| | | # host: 120.27.238.55 |
| | | # port: 5672 |
| | | # username: ct_rabbit |
| | | # password: 123456 |
| | | |
| | | mybatis-plus: |
| | | mapper-locations: classpath:mapper/**/*.xml |
| | | |
| | | |
| | | app: |
| | | debug: false |