.codebuddy/settings.local.json
New file @@ -0,0 +1,5 @@ { "enabledPlugins": { "skill-creator@cb_teams_marketplace": true } } .codebuddy/skills/okx-api/SKILL.md
New file @@ -0,0 +1,87 @@ --- name: okx-api description: > This skill provides comprehensive reference for developing applications with the OKX V5 API. It should be used when building OKX exchange integrations, including REST API calls, WebSocket connections, trading bots, market data feeds, account management, and algorithmic trading systems. Trigger when the user mentions OKX, okx API, okx trading, okx websocket, cryptocurrency exchange integration, or requests to build trading/quant applications for OKX. --- # OKX V5 API ## Overview Act as an OKX V5 API expert. Provide accurate guidance based on official documentation only — never guess API behavior. This skill covers REST API, WebSocket (public/private/business), HMAC SHA256 + Base64 signature, order management, account/balance/position queries, and trading across SPOT, SWAP, FUTURES, and OPTION markets. ## When to Use This Skill Use this skill whenever the user requests: - OKX V5 API integration or development - Building trading bots, market data feeds, or quant strategies for OKX - WebSocket subscription (public/private/business channels) - API signature generation (HMAC SHA256 + Base64) - Order placement, modification, or cancellation - Account, balance, or position queries - Demo trading vs. live trading configuration - Rate limiting, connection management, or error code analysis ## Core Principles 1. Always reference official documentation; never guess endpoints or parameters. 2. Provide code examples in Java, Python, or Go based on user preference. 3. Clearly annotate request path, HTTP method, headers, and body. 4. Distinguish between REST and WebSocket approaches. 5. Distinguish between demo trading (x-simulated-trading: 1) and live trading. 6. All signatures follow: `timestamp + method + requestPath + body` → HMAC SHA256 → Base64. ## Quick Reference ### REST API Headers ``` OK-ACCESS-KEY: <api_key> OK-ACCESS-SIGN: <base64_signature> OK-ACCESS-TIMESTAMP: <iso_timestamp> OK-ACCESS-PASSPHRASE: <passphrase> ``` For demo/simulated trading, add: ``` x-simulated-trading: 1 ``` ### WebSocket Endpoints | Channel | URL | |-----------|----------------------------------------------| | Public | wss://ws.okx.com:8443/ws/v5/public | | Private | wss://ws.okx.com:8443/ws/v5/private | | Business | wss://ws.okx.com:8443/ws/v5/business | ### Connection Health Send a ping if no message is sent within 30 seconds. Wait for pong; otherwise reconnect. ### Common Endpoints | Method | Path | Description | |--------|----------------------------------|----------------------| | GET | /api/v5/account/balance | Account balance | | GET | /api/v5/account/positions | Account positions | | GET | /api/v5/account/instruments | Account instruments | ## Output Template When answering API-related questions, structure the response as: 1. **接口说明** - endpoint description and purpose 2. **请求示例** - full request example with headers and body 3. **返回字段** - response field documentation 4. **最佳实践** - best practices 5. **异常处理** - error handling guidance 6. **性能优化** - performance optimization tips ## Resources ### references/api_details.md Comprehensive API reference covering authentication with code examples (Java/Python/Go), full REST endpoint catalog (Account/Trade/Market Data), WebSocket channels (Public/Private/Business), error codes, rate limits, and best practices. Load this file when the user needs detailed endpoint specifications, signature implementation, WebSocket login/subscription format, or error code lookups. .codebuddy/skills/okx-api/references/api_details.md
New file @@ -0,0 +1,276 @@ # OKX V5 API Reference Documentation ## Authentication ### Signature Generation (HMAC SHA256 + Base64) The signature algorithm: ``` signature = Base64(HMAC_SHA256(secret_key, timestamp + method + request_path + body)) ``` **Java Example:** ```java import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.util.Base64; public static String sign(String timestamp, String method, String requestPath, String body, String secretKey) { try { String preHash = timestamp + method + requestPath + body; Mac mac = Mac.getInstance("HmacSHA256"); SecretKeySpec spec = new SecretKeySpec(secretKey.getBytes(), "HmacSHA256"); mac.init(spec); return Base64.getEncoder().encodeToString(mac.doFinal(preHash.getBytes())); } catch (Exception e) { throw new RuntimeException("Signature failed", e); } } ``` **Python Example:** ```python import hmac, base64, hashlib def sign(timestamp, method, request_path, body, secret_key): pre_hash = timestamp + method + request_path + body signature = hmac.new( secret_key.encode(), pre_hash.encode(), hashlib.sha256 ).digest() return base64.b64encode(signature).decode() ``` **Go Example:** ```go import ( "crypto/hmac" "crypto/sha256" "encoding/base64" ) func sign(timestamp, method, requestPath, body, secretKey string) string { preHash := timestamp + method + requestPath + body mac := hmac.New(sha256.New, []byte(secretKey)) mac.Write([]byte(preHash)) return base64.StdEncoding.EncodeToString(mac.Sum(nil)) } ``` ### Required Headers All authenticated REST requests MUST include: | Header | Description | |-------------------------|-----------------------------------------------| | OK-ACCESS-KEY | API key | | OK-ACCESS-SIGN | Base64-encoded HMAC SHA256 signature | | OK-ACCESS-TIMESTAMP | ISO 8601 timestamp (e.g., 2023-01-01T00:00:00.000Z) | | OK-ACCESS-PASSPHRASE | API passphrase | | x-simulated-trading | Set to "1" for demo trading (optional) | ## Market Types | Type | Description | |-----------|----------------------| | SPOT | Spot trading | | SWAP | Perpetual swap | | FUTURES | Futures contracts | | OPTION | Options trading | ## WebSocket ### Public Channel ``` wss://ws.okx.com:8443/ws/v5/public ``` Access market data without authentication: tickers, order books, trades, mark prices, funding rates, etc. ### Private Channel ``` wss://ws.okx.com:8443/ws/v5/private ``` Requires authentication (login). Access account data, order updates, position changes, balance updates. ### Business Channel ``` wss://ws.okx.com:8443/ws/v5/business ``` Requires authentication. Access business-specific channels (requires application). ### Ping/Pong - Client must send a ping message if no message has been sent in the last 30 seconds - Server responds with pong - If no pong received, client should reconnect ```json {"op": "ping"} ``` ```json {"op": "pong"} ``` ### Subscription Example ```json { "op": "subscribe", "args": [ { "channel": "tickers", "instId": "BTC-USDT" } ] } ``` ### Login (Private Channel) ```json { "op": "login", "args": [ { "apiKey": "YOUR_API_KEY", "passphrase": "YOUR_PASSPHRASE", "timestamp": "2023-01-01T00:00:00.000Z", "sign": "BASE64_SIGNATURE" } ] } ``` WebSocket login signature: `timestamp + "GET" + "/users/self/verify"` (no body). ## Common REST Endpoints ### Account | Method | Path | Description | |--------|------------------------------------|--------------------------| | GET | /api/v5/account/balance | Get account balance | | GET | /api/v5/account/positions | Get positions | | GET | /api/v5/account/positions-history | Get positions history | | GET | /api/v5/account/bills | Get bills (transaction history) | | GET | /api/v5/account/config | Get account configuration | | GET | /api/v5/account/instruments | Get instruments | | POST | /api/v5/account/set-position-mode | Set position mode | | GET | /api/v5/account/leverage-info | Get leverage | | POST | /api/v5/account/set-leverage | Set leverage | ### Trade | Method | Path | Description | |--------|------------------------------|---------------------| | POST | /api/v5/trade/order | Place order | | POST | /api/v5/trade/amend-order | Amend order | | POST | /api/v5/trade/cancel-order | Cancel order | | POST | /api/v5/trade/batch-orders | Place batch orders | | POST | /api/v5/trade/batch-amend-orders | Amend batch orders | | POST | /api/v5/trade/batch-cancel-orders | Cancel batch orders | | POST | /api/v5/trade/close-position | Close position | | GET | /api/v5/trade/order | Get order details | | GET | /api/v5/trade/orders-pending | Get pending orders | | GET | /api/v5/trade/orders-history | Get order history | | GET | /api/v5/trade/fills | Get transaction details | | GET | /api/v5/trade/fills-history | Get transaction history | ### Market Data | Method | Path | Description | |--------|------------------------------------|--------------------------| | GET | /api/v5/market/tickers | Get all tickers | | GET | /api/v5/market/ticker | Get single ticker | | GET | /api/v5/market/books | Get order book | | GET | /api/v5/market/candles | Get candlestick (K-line) | | GET | /api/v5/market/history-candles | Get historical candles | | GET | /api/v5/market/trades | Get recent trades | | GET | /api/v5/market/history-trades | Get historical trades | | GET | /api/v5/market/mark-price | Get mark price | | GET | /api/v5/market/funding-rate | Get funding rate | ## Order Placement Example ### REST - Place Limit Order ``` POST /api/v5/trade/order ``` **Request Body:** ```json { "instId": "BTC-USDT-SWAP", "tdMode": "cross", "side": "buy", "ordType": "limit", "sz": "1", "px": "20000" } ``` **Response:** ```json { "code": "0", "msg": "", "data": [ { "clOrdId": "", "ordId": "123456", "tag": "", "sCode": "0", "sMsg": "" } ] } ``` ## Rate Limits - REST: 20 requests / 2 seconds per endpoint (varies by endpoint) - WebSocket: 1 subscription request / second - Public WebSocket data: throttled per channel - Always implement exponential backoff on 429 responses ## Error Codes | Code | Meaning | |---------|--------------------------------| | 0 | Success | | 50001 | Request timeout | | 50002 | Service unavailable | | 50004 | Rate limit exceeded | | 50011 | API key does not exist | | 50012 | API key has expired | | 50013 | Invalid sign | | 50014 | Invalid passphrase | | 50015 | Invalid IP | | 50016 | Permission denied | | 50017 | Withdrawal address not whitelisted | | 50100 | Unsupported operation | | 51000 | Parameter error | | 51001 | Instrument ID does not exist | | 51006 | Order does not exist | | 51400 | Order cancellation failed | | 51500 | Insufficient balance | ## Best Practices 1. **Timestamp synchronization**: Ensure server time is synced via NTP; timestamp must be within 30 seconds of OKX server time 2. **Exponential backoff**: On rate limit (429) or 5xx errors, implement exponential backoff starting at 1s 3. **WebSocket reconnection**: Automatically reconnect on disconnect with backoff; resubscribe all channels 4. **Demo first**: Always test on demo trading (x-simulated-trading: 1) before live 5. **Idempotency**: Use `clOrdId` to prevent duplicate orders 6. **Error logging**: Log full error responses including code, msg, and data fields 7. **Connection pooling**: Reuse HTTP connections for REST calls 8. **WebSocket heartbeat**: Maintain 30-second ping/pong cycle src/main/java/com/xcong/excoin/modules/gateApi/Example.java
File was deleted src/main/java/com/xcong/excoin/modules/gateApi/GateGridTradeService.java
File was deleted src/main/java/com/xcong/excoin/modules/gateApi/GateKlineWebSocketClient.java
File was deleted src/main/java/com/xcong/excoin/modules/gateApi/GateTradeExecutor.java
File was deleted src/main/java/com/xcong/excoin/modules/gateApi/GateWebSocketClientMain.java
File was deleted src/main/java/com/xcong/excoin/modules/gateApi/GateWebSocketClientManager.java
File was deleted src/main/java/com/xcong/excoin/modules/gateApi/celue.outBinary files differ
src/main/java/com/xcong/excoin/modules/gateApi/gate-api.txt
File was deleted src/main/java/com/xcong/excoin/modules/gateApi/gate-v3.out
File was deleted src/main/java/com/xcong/excoin/modules/gateApi/gate-websocket.txt
File was deleted src/main/java/com/xcong/excoin/modules/gateApi/gateApi-logic.md
File was deleted src/main/java/com/xcong/excoin/modules/gateApi/simulation/GridSimulator.java
File was deleted src/main/java/com/xcong/excoin/modules/gateApi/wsHandler/AbstractPrivateChannelHandler.java
File was deleted src/main/java/com/xcong/excoin/modules/gateApi/wsHandler/GateChannelHandler.java
File was deleted src/main/java/com/xcong/excoin/modules/gateApi/wsHandler/handler/AutoOrdersChannelHandler.java
File was deleted src/main/java/com/xcong/excoin/modules/gateApi/wsHandler/handler/CandlestickChannelHandler.java
File was deleted src/main/java/com/xcong/excoin/modules/gateApi/wsHandler/handler/PositionClosesChannelHandler.java
File was deleted src/main/java/com/xcong/excoin/modules/gateApi/wsHandler/handler/PositionsChannelHandler.java
File was deleted src/main/java/com/xcong/excoin/modules/okxApi/GridElement.java
File was renamed from src/main/java/com/xcong/excoin/modules/gateApi/GridElement.java @@ -1,4 +1,4 @@ package com.xcong.excoin.modules.gateApi; package com.xcong.excoin.modules.okxApi; import java.math.BigDecimal; import java.util.ArrayList; @@ -48,7 +48,6 @@ * <h3>何时填充 TraderParam</h3> * 初始化时 {@code updateGridElements()} 为每个元素预填充 longTraderParam 和 shortTraderParam * (含 direction/entryPrice/takeProfitPrice/quantity),订单ID字段在挂单成功后由 * {@link GateGridTradeService} 的 4 个辅助方法写入。 * * <h3>使用示例</h3> * <pre> @@ -92,17 +91,17 @@ /** 空仓止损订单 ID */ private String shortStopLossOrderId; /** 全局 ID 索引,由 {@link GateConfig#setGridElements(List)} 触发重建,O(1) 查找 */ /** 全局 ID 索引,由 { GateConfig#setGridElements(List)} 触发重建,O(1) 查找 */ private static final Map<Integer, GridElement> INDEX = new ConcurrentHashMap<>(); /** 全局价格索引,由 {@link GateConfig#setGridElements(List)} 触发重建,O(1) 查找 */ /** 全局价格索引,由 { GateConfig#setGridElements(List)} 触发重建,O(1) 查找 */ private static final Map<BigDecimal, GridElement> PRICE_INDEX = new ConcurrentHashMap<>(); /** 全局多仓订单 ID 索引,由 {@link GateConfig#setGridElements(List)} 触发重建,O(1) 查找 */ /** 全局多仓订单 ID 索引,由 { GateConfig#setGridElements(List)} 触发重建,O(1) 查找 */ private static final Map<String, GridElement> LONG_ORDER_ID_INDEX = new ConcurrentHashMap<>(); /** 全局空仓订单 ID 索引,由 {@link GateConfig#setGridElements(List)} 触发重建,O(1) 查找 */ /** 全局空仓订单 ID 索引,由 { GateConfig#setGridElements(List)} 触发重建,O(1) 查找 */ private static final Map<String, GridElement> SHORT_ORDER_ID_INDEX = new ConcurrentHashMap<>(); /** 全局多仓止盈订单 ID 索引,由 {@link GateConfig#setGridElements(List)} 触发重建,O(1) 查找 */ /** 全局多仓止盈订单 ID 索引,由 { GateConfig#setGridElements(List)} 触发重建,O(1) 查找 */ private static final Map<String, GridElement> LONG_TP_ORDER_ID_INDEX = new ConcurrentHashMap<>(); /** 全局空仓止盈订单 ID 索引,由 {@link GateConfig#setGridElements(List)} 触发重建,O(1) 查找 */ /** 全局空仓止盈订单 ID 索引,由 { GateConfig#setGridElements(List)} 触发重建,O(1) 查找 */ private static final Map<String, GridElement> SHORT_TP_ORDER_ID_INDEX = new ConcurrentHashMap<>(); /** 全局多仓止损订单 ID 索引 */ private static final Map<String, GridElement> LONG_SL_ORDER_ID_INDEX = new ConcurrentHashMap<>(); @@ -191,7 +190,7 @@ /** * 从列表中重建全局 ID 索引和价格索引。 * 由 {@link GateConfig#setGridElements(List)} 在每次列表变更后调用。 * 由 { GateConfig#setGridElements(List)} 在每次列表变更后调用。 */ public static void rebuildIndex(List<GridElement> elements) { INDEX.clear(); src/main/java/com/xcong/excoin/modules/okxApi/OkxConfig.java
File was renamed from src/main/java/com/xcong/excoin/modules/gateApi/GateConfig.java @@ -1,21 +1,31 @@ package com.xcong.excoin.modules.gateApi; package com.xcong.excoin.modules.okxApi; import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; /** * Gate 交易模块全局配置,策略的唯一参数入口。 * OKX 交易模块全局配置,策略的唯一参数入口。 * * <h3>定位</h3> * 通过 Builder 模式将所有运行参数集中管理,避免策略参数散落在多个类中。 * 运行时动态参数(step、gridElements、baseLongTraderParam、baseShortTraderParam) * 由 {@link GateGridTradeService} 在策略执行过程中写入。 * 由 OkxGridTradeService 在策略执行过程中写入。 * * <h3>与 GateConfig 的主要差异</h3> * <ul> * <li>新增 {@code passphrase} 字段 — OKX API 认证需要 passphrase</li> * <li>REST 基础路径:生产环境 {@code https://www.okx.com},测试环境 {@code https://www.okx.cab}</li> * <li>WebSocket 分为 public/private 两个地址</li> * <li>合约格式:{@code ETH-USDT-SWAP}(OKX 用短横线分隔)</li> * <li>持仓模式默认值:{@code long_short_mode}(OKX)替代 {@code dual}(Gate)</li> * <li>数据模型(GridElement、TraderParam)复用 gateApi 包中的定义</li> * </ul> * * <h3>参数分类</h3> * <table> * <tr><th>类别</th><th>参数</th><th>用途</th></tr> * <tr><td>认证</td><td>apiKey, apiSecret</td><td>REST/WS 签名认证</td></tr> * <tr><td>认证</td><td>apiKey, apiSecret, passphrase</td><td>REST/WS 签名认证</td></tr> * <tr><td>交易标的</td><td>contract, leverage, quantity, contractMultiplier</td><td>合约、杠杆、张数、乘数</td></tr> * <tr><td>持仓</td><td>marginMode, positionMode</td><td>全仓/逐仓、单向/双向</td></tr> * <tr><td>网格策略</td><td>gridRate, gridQueueSize, priceScale</td><td>间距比例、队列容量、价格精度(交易所tick)</td></tr> @@ -25,26 +35,17 @@ * <tr><td>运行时</td><td>step, gridElements, baseLongTraderParam, baseShortTraderParam</td><td>由策略动态填充</td></tr> * </table> * * <h3>priceScale 说明</h3> * 价格精度表示交易所允许的最小价格单位的小数位数: * <ul> * <li>XAU_USDT: tick=0.1 → priceScale=1</li> * <li>ETH_USDT: tick=0.01 → priceScale=2</li> * </ul> * 所有价格计算必须对齐到 tick 整数倍,否则 Gate API 返回 * {@code invalid argument: trigger.price price is not an integer multiple of a price unit}。 * * <h3>gridElements 生命周期</h3> * <ol> * <li>项目启动时初始化为空 ArrayList</li> * <li>{@code tryGenerateQueues()} 中通过 {@code updateGridElements()} 填充,同时触发 * <li>队列生成逻辑中通过 {@code updateGridElements()} 填充,同时触发 * {@link GridElement#rebuildIndex(List)} 建立全局 O(1) 索引</li> * <li>每次挂单/止盈操作后通过 {@link GridElement#refreshIndices()} 更新索引</li> * </ol> * * @author Administrator */ public class GateConfig { public class OkxConfig { /** * 未实现盈亏(unrealizedPnl)计价模式。 @@ -57,22 +58,32 @@ public enum PnLPriceMode { /** 按最新成交价计算未实现盈亏 */ LAST_PRICE, /** 按标记价格计算未实现盈亏,需通过 {@link GateGridTradeService#setMarkPrice(BigDecimal)} 注入 */ /** 按标记价格计算未实现盈亏,需通过外部注入 */ MARK_PRICE } /** Gate API v4 密钥 */ // ==================== 认证信息 ==================== /** OKX API v5 密钥 */ private final String apiKey; /** Gate API v4 签名密钥 */ /** OKX API v5 签名密钥 */ private final String apiSecret; /** 合约名称(如 XAU_USDT) */ /** OKX API v5 口令密码(passphrase),创建 API Key 时设置 */ private final String passphrase; // ==================== 交易标的 ==================== /** 合约名称(如 ETH-USDT-SWAP,注意 OKX 使用短横线分隔) */ private final String contract; /** 杠杆倍数 */ private final String leverage; /** 保证金模式(cross / isolated) */ private final String marginMode; /** 持仓模式(single / dual / dual_plus) */ /** 持仓模式(long_short_mode=双向 / net_mode=单向) */ private final String positionMode; // ==================== 策略参数 ==================== /** 网格间距比例(如 0.0035 表示 0.35%) */ private final BigDecimal gridRate; /** 整体止盈阈值(USDT) */ @@ -93,9 +104,9 @@ private final int gridQueueSize; /** 保证金占初始本金比例上限 */ private final BigDecimal marginRatioLimit; /** 合约乘数(单张合约代表的基础资产数量,如 BTC_USDT=0.001, ETH_USDT=0.01) */ /** 合约乘数(单张合约代表的基础资产数量,如 ETH-USDT-SWAP=0.01) */ private final BigDecimal contractMultiplier; /** 价格精度(交易所价格的最小小数位数,如 XAU_USDT=1 表示 0.1 精度,ETH_USDT=2 表示 0.01 精度) */ /** 价格精度(交易所价格的最小小数位数,如 1 表示 0.1 精度,2 表示 0.01 精度) */ private final int priceScale; /** 未实现盈亏计价模式:最新价 / 标记价格 */ private final PnLPriceMode unrealizedPnlPriceMode; @@ -103,18 +114,24 @@ private final int maxPositionSize; /** 策略重启跨度阈值:多空两边止盈触发数量均达到此值后触发重启,0=禁用 */ private final int restartGridSpan; // ==================== 运行时参数 ==================== /** 网格绝对步长(shortBaseEntryPrice × gridRate),运行时由队列生成逻辑设置 */ private BigDecimal step; /** 网格元素列表,由队列初始化时同步填充,包含完整的多空仓挂单状态 */ private volatile List<GridElement> gridElements = new ArrayList<>(); /** 基座多头挂单参数,在基座成交后由 tryGenerateQueues 填充 */ /** 基座多头挂单参数,在基座成交后由队列生成逻辑填充 */ private TraderParam baseLongTraderParam; /** 基座空头挂单参数,在基座成交后由 tryGenerateQueues 填充 */ /** 基座空头挂单参数,在基座成交后由队列生成逻辑填充 */ private TraderParam baseShortTraderParam; private GateConfig(Builder builder) { // ==================== 构造器 ==================== private OkxConfig(Builder builder) { this.apiKey = builder.apiKey; this.apiSecret = builder.apiSecret; this.passphrase = builder.passphrase; this.contract = builder.contract; this.leverage = builder.leverage; this.marginMode = builder.marginMode; @@ -139,41 +156,54 @@ // ==================== REST/WS 地址 ==================== /** * 根据环境返回 REST API 基础路径。 * 返回 REST API 基础路径。 * <p>实盘和模拟盘使用相同的 REST 地址,通过 {@code x-simulated-trading: 1} 请求头区分。 * <ul> * <li>测试网: {@code https://api-testnet.gateapi.io/api/v4}</li> * <li>生产网: {@code https://api.gateio.ws/api/v4}</li> * <li>生产网/测试网: {@code https://openapi.okx.com}</li> * </ul> */ public String getRestBasePath() { return isProduction ? "https://api.gateio.ws/api/v4" : "https://api-testnet.gateapi.io/api/v4"; return "https://openapi.okx.com"; } /** * 根据环境返回 WebSocket 地址。 * 根据环境返回 WebSocket 公开频道地址。 * <ul> * <li>测试网: {@code wss://ws-testnet.gate.com/v4/ws/futures/usdt}</li> * <li>生产网: {@code wss://fx-ws.gateio.ws/v4/ws/usdt}</li> * <li>测试网: {@code wss://wspap.okx.com:8443/ws/v5/public}</li> * <li>生产网: {@code wss://ws.okx.com:8443/ws/v5/public}</li> * </ul> */ public String getWsUrl() { public String getWsPublicUrl() { return isProduction ? "wss://fx-ws.gateio.ws/v4/ws/usdt" : "wss://ws-testnet.gate.com/v4/ws/futures/usdt"; ? "wss://ws.okx.com:8443/ws/v5/public" : "wss://wspap.okx.com:8443/ws/v5/public"; } /** * 根据环境返回 WebSocket 私有频道地址。 * <ul> * <li>测试网: {@code wss://wspap.okx.com:8443/ws/v5/private}</li> * <li>生产网: {@code wss://ws.okx.com:8443/ws/v5/private}</li> * </ul> */ public String getWsPrivateUrl() { return isProduction ? "wss://ws.okx.com:8443/ws/v5/private" : "wss://wspap.okx.com:8443/ws/v5/private"; } // ==================== 认证信息 ==================== /** @return Gate API v4 密钥 */ /** @return OKX API v5 密钥 */ public String getApiKey() { return apiKey; } /** @return Gate API v4 签名密钥,用于 HMAC-SHA512 签名 */ /** @return OKX API v5 签名密钥,用于 HMAC-SHA256 签名 */ public String getApiSecret() { return apiSecret; } /** @return OKX API v5 口令密码(passphrase) */ public String getPassphrase() { return passphrase; } // ==================== 交易标的 ==================== /** @return 合约名称(如 ETH_USDT、XAU_USDT) */ /** @return 合约名称(如 ETH-USDT-SWAP,OKX 使用短横线分隔) */ public String getContract() { return contract; } /** @return 杠杆倍数(如 "100" 表示 100x) */ public String getLeverage() { return leverage; } @@ -182,7 +212,7 @@ /** @return 保证金模式(cross=全仓 / isolated=逐仓) */ public String getMarginMode() { return marginMode; } /** @return 持仓模式(single=单向 / dual=双向 / dual_plus) */ /** @return 持仓模式(long_short_mode=双向 / net_mode=单向) */ public String getPositionMode() { return positionMode; } // ==================== 策略参数 ==================== @@ -211,7 +241,7 @@ // ==================== 盈亏计算 ==================== /** @return 合约乘数(单张合约代表的基础资产数量,如 ETH_USDT=0.01) */ /** @return 合约乘数(单张合约代表的基础资产数量,如 ETH-USDT-SWAP=0.01) */ public BigDecimal getContractMultiplier() { return contractMultiplier; } /** @return 价格精度(交易所价格的小数位数,如 1=0.1精度,2=0.01精度),用于价格四舍五入 */ public int getPriceScale() { return priceScale; } @@ -226,7 +256,7 @@ /** @return 网格绝对步长(shortBaseEntryPrice × gridRate),运行时设置 */ public BigDecimal getStep() { return step; } /** 设置网格绝对步长(由 generateShortQueue 在运行时计算并注入) */ /** 设置网格绝对步长(由队列生成逻辑在运行时计算并注入) */ public void setStep(BigDecimal step) { this.step = step; } // ==================== 网格元素列表 ==================== @@ -243,12 +273,12 @@ /** @return 基座多头挂单参数 */ public TraderParam getBaseLongTraderParam() { return baseLongTraderParam; } /** 设置基座多头挂单参数(由 tryGenerateQueues 在基座成交后填充) */ /** 设置基座多头挂单参数(由队列生成逻辑在基座成交后填充) */ public void setBaseLongTraderParam(TraderParam baseLongTraderParam) { this.baseLongTraderParam = baseLongTraderParam; } /** @return 基座空头挂单参数 */ public TraderParam getBaseShortTraderParam() { return baseShortTraderParam; } /** 设置基座空头挂单参数(由 tryGenerateQueues 在基座成交后填充) */ /** 设置基座空头挂单参数(由队列生成逻辑在基座成交后填充) */ public void setBaseShortTraderParam(TraderParam baseShortTraderParam) { this.baseShortTraderParam = baseShortTraderParam; } // ==================== 环境 ==================== @@ -256,33 +286,37 @@ /** @return 是否为生产环境(true=实盘生产网 / false=模拟盘测试网) */ public boolean isProduction() { return isProduction; } // ==================== Builder ==================== public static Builder builder() { return new Builder(); } /** * GateConfig 的流式构造器,提供合理的默认值。 * OkxConfig 的流式构造器,提供合理的默认值。 * * <h3>必填项</h3> * {@code apiKey} 和 {@code apiSecret} 必须设置,其余参数均有默认值。 * {@code apiKey}、{@code apiSecret}、{@code passphrase} 必须设置,其余参数均有默认值。 * * <h3>默认值</h3> * BTC_USDT / 10x / cross(全仓) / dual(双向) / gridRate=0.35% / * ETH-USDT-SWAP / 10x / cross(全仓) / long_short_mode(双向) / gridRate=0.35% / * overallTp=0.5 / maxLoss=7.5 / quantity=1 / isProduction=false */ public static class Builder { /** Gate API v4 密钥(必填) */ /** OKX API v5 密钥(必填) */ private String apiKey; /** Gate API v4 签名密钥(必填) */ /** OKX API v5 签名密钥(必填) */ private String apiSecret; /** 合约名称,默认 BTC_USDT */ private String contract = "BTC_USDT"; /** OKX API v5 口令密码(必填) */ private String passphrase; /** 合约名称,默认 ETH-USDT-SWAP */ private String contract = "ETH-USDT-SWAP"; /** 杠杆倍数,默认 "10" */ private String leverage = "10"; /** 保证金模式,默认 "cross"(全仓) */ private String marginMode = "cross"; /** 持仓模式,默认 "dual"(双向) */ private String positionMode = "dual"; /** 持仓模式,默认 "long_short_mode"(双向) */ private String positionMode = "long_short_mode"; /** 网格间距比例,默认 0.0035(0.35%) */ private BigDecimal gridRate = new BigDecimal("0.0035"); /** 整体止盈阈值(USDT),默认 0.5 */ @@ -299,14 +333,14 @@ private boolean isProduction = false; /** 补仓最大重试次数,默认 3 */ private int reopenMaxRetries = 3; /** 网格队列容量,默认 50 */ /** 网格队列容量,默认 300 */ private int gridQueueSize = 300; /** 保证金占初始本金比例上限,默认 0.2(20%) */ private BigDecimal marginRatioLimit = new BigDecimal("0.2"); /** 合约乘数,默认 0.001 */ private BigDecimal contractMultiplier = new BigDecimal("0.001"); /** 价格精度(交易所价格的小数位数),默认 1(0.1 精度) */ private int priceScale = 1; /** 合约乘数,默认 0.01(ETH-USDT-SWAP=0.01) */ private BigDecimal contractMultiplier = new BigDecimal("0.01"); /** 价格精度(交易所价格的小数位数),默认 2(0.01 精度,适配 ETH) */ private int priceScale = 2; /** 未实现盈亏计价模式,默认 LAST_PRICE(最新成交价) */ private PnLPriceMode unrealizedPnlPriceMode = PnLPriceMode.LAST_PRICE; /** 最大持仓张数(单方向),默认 0=不限制 */ @@ -318,13 +352,15 @@ public Builder apiKey(String apiKey) { this.apiKey = apiKey; return this; } /** 设置 API Secret */ public Builder apiSecret(String apiSecret) { this.apiSecret = apiSecret; return this; } /** 设置合约名称 */ /** 设置 API Passphrase */ public Builder passphrase(String passphrase) { this.passphrase = passphrase; return this; } /** 设置合约名称(如 ETH-USDT-SWAP) */ public Builder contract(String contract) { this.contract = contract; return this; } /** 设置杠杆倍数 */ public Builder leverage(String leverage) { this.leverage = leverage; return this; } /** 设置保证金模式(cross=全仓 / isolated=逐仓) */ public Builder marginMode(String marginMode) { this.marginMode = marginMode; return this; } /** 设置持仓模式(single=单向 / dual=双向) */ /** 设置持仓模式(long_short_mode=双向 / net_mode=单向) */ public Builder positionMode(String positionMode) { this.positionMode = positionMode; return this; } /** 设置网格间距比例 */ public Builder gridRate(BigDecimal gridRate) { this.gridRate = gridRate; return this; } @@ -357,8 +393,8 @@ /** 设置策略重启跨度阈值:多空两边止盈触发数均达到此值后触发重启,0=禁用 */ public Builder restartGridSpan(int restartGridSpan) { this.restartGridSpan = restartGridSpan; return this; } public GateConfig build() { return new GateConfig(this); public OkxConfig build() { return new OkxConfig(this); } } } src/main/java/com/xcong/excoin/modules/okxApi/OkxGridTradeService.java
New file @@ -0,0 +1,1224 @@ package com.xcong.excoin.modules.okxApi; import cn.hutool.core.util.StrUtil; import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; import com.xcong.excoin.utils.dingtalk.DingTalkUtils; import lombok.extern.slf4j.Slf4j; import java.math.BigDecimal; import java.math.RoundingMode; import java.util.*; /** * OKX 网格交易策略引擎 — 多空对冲网格。 * * <h3>策略原理</h3> * 以空仓基底入场价(shortBaseEntryPrice)为价格基准,向上/向下各生成一个价格网格队列。 * 价格触发网格层级时挂条件单,成交后自动挂止盈单。每笔止盈盈利 = step - minTick。 * * <h3>与 GateGridTradeService 的关系</h3> * 策略逻辑完全一致,仅 API 层(REST/WS/签名)替换为 OKX 实现。 * GridElement 和 TraderParam 从 gateApi 包复用(交易所无关的数据模型)。 * * <h3>完整生命周期</h3> * <pre> * init() → startGrid() → WAITING_KLINE * ↓ * onKline(首根K线) → OPENING → 异步市价双开基底(开多+开空) * ↓ * onPositionUpdate() → 基底成交 → baseLongOpened && baseShortOpened * ↓ * tryGenerateQueues() * ├── generateShortQueue() ← 空仓价格队列(降序,从 shortBaseEntryPrice-step 向下) * ├── generateLongQueue() ← 多仓价格队列(升序,从 shortBaseEntryPrice+step 向上) * ├── updateGridElements() ← 构建 GridElement 列表 + TraderParam + 全局索引 * ├── 挂基座止盈单(ID=0 的 long/short takeProfit) * └── 挂初始条件单(up=-1 多单, down=1 空单) * ↓ * state = ACTIVE * ↓ * onKline() → processLongGrid() + processShortGrid() * ├── 匹配队列元素 → 队列补偿 → 保证金检查 * ├── 首元素方向:挂条件开仓单 → 订单ID + GridElement状态同步 * └── 反向守卫:在 downGrid 位置挂对向单 * ↓ * onAutoOrder() ← orders-algo WS 推送 * ├── 匹配止盈单ID → 清空止盈状态(已成交) * └── 匹配挂单ID → 挂止盈条件单 → 止盈ID + GridElement状态同步 * </pre> * * @author Administrator */ @Slf4j public class OkxGridTradeService { public enum StrategyState { WAITING_KLINE, OPENING, ACTIVE, STOPPED } /** 止盈条件单 order_type:平多仓 */ private static final String ORDER_TYPE_CLOSE_LONG = "plan-close-long-position"; /** 止盈条件单 order_type:平空仓 */ private static final String ORDER_TYPE_CLOSE_SHORT = "plan-close-short-position"; private final OkxConfig config; private final OkxTradeExecutor executor; private volatile StrategyState state = StrategyState.WAITING_KLINE; /** 空仓价格队列,降序排列(大→小),容量 gridQueueSize */ private final List<BigDecimal> shortPriceQueue = Collections.synchronizedList(new ArrayList<>()); /** 多仓价格队列,升序排列(小→大),容量 gridQueueSize */ private final List<BigDecimal> longPriceQueue = Collections.synchronizedList(new ArrayList<>()); private final List<BigDecimal> totalLongPriceQueue = Collections.synchronizedList(new ArrayList<>()); private final List<BigDecimal> totalShortPriceQueue = Collections.synchronizedList(new ArrayList<>()); /** 当前多仓条件单映射 */ private final Map<String, BigDecimal> currentLongOrderIds = Collections.synchronizedMap(new LinkedHashMap<>()); /** 当前空仓条件单映射 */ private final Map<String, BigDecimal> currentShortOrderIds = Collections.synchronizedMap(new LinkedHashMap<>()); /** 基底空头入场价 */ private BigDecimal shortBaseEntryPrice; /** 基底多头入场价 */ private BigDecimal longBaseEntryPrice; /** 基底多头是否已开 */ private volatile boolean baseLongOpened = false; /** 基底空头是否已开 */ private volatile boolean baseShortOpened = false; /** 空头是否活跃 */ private volatile boolean shortActive = false; /** 多头是否活跃 */ private volatile boolean longActive = false; /** 多头累计止损张数 */ private volatile int accumulatedLongLossCount = 0; /** 空头累计止损张数 */ private volatile int accumulatedShortLossCount = 0; private volatile BigDecimal lastKlinePrice; private volatile BigDecimal markPrice = BigDecimal.ZERO; private volatile BigDecimal cumulativePnl = BigDecimal.ZERO; private volatile BigDecimal unrealizedPnl = BigDecimal.ZERO; private volatile BigDecimal longEntryPrice = BigDecimal.ZERO; private volatile BigDecimal shortEntryPrice = BigDecimal.ZERO; private volatile BigDecimal longPositionSize = BigDecimal.ZERO; private volatile BigDecimal shortPositionSize = BigDecimal.ZERO; private volatile BigDecimal initialPrincipal = BigDecimal.ZERO; private volatile OkxKlineWebSocketClient wsClient; public OkxGridTradeService(OkxConfig config) { this.config = config; this.executor = new OkxTradeExecutor(config); } // ---- 仓位模式(替代 Gate 的 Position.ModeEnum)---- /** OKX 持仓方向枚举 */ public enum OkxPosMode { LONG, SHORT } /** 持仓查询结果 */ static class OkxPositionInfo { String size; String entryPrice; } // ---- 初始化 ---- /** * 初始化策略环境:获取账户信息 → 切双向持仓 → 清旧条件单 → 平仓 → 设杠杆。 */ public void init() { try { JSONObject account = executorGet("/api/v5/account/balance"); JSONArray details = account.getJSONArray("data"); if (details != null && !details.isEmpty()) { JSONObject total = details.getJSONObject(0); this.initialPrincipal = total.getBigDecimal("totalEq"); log.info("[OKX] 初始本金: {} USDT", initialPrincipal); } // 设置双向持仓模式 JSONObject posModeBody = new JSONObject(); posModeBody.put("posMode", config.getPositionMode()); executorPost("/api/v5/account/set-position-mode", posModeBody.toJSONString()); log.info("[OKX] 持仓模式已设为: {}", config.getPositionMode()); // 设置杠杆 JSONObject levBody = new JSONObject(); levBody.put("instId", config.getContract()); levBody.put("lever", config.getLeverage()); levBody.put("mgnMode", config.getMarginMode()); executorPost("/api/v5/account/set-leverage", levBody.toJSONString()); log.info("[OKX] 杠杆已设为: {}x {}", config.getLeverage(), config.getMarginMode()); executor.cancelAllPriceTriggeredOrders(); log.info("[OKX] 旧条件单已清除"); closeExistingPositions(); log.info("[OKX] 初始化完成"); } catch (Exception e) { log.error("[OKX] 初始化失败", e); } } /** * 平掉当前合约的所有已有仓位。 */ private void closeExistingPositions() { try { JSONObject resp = executorGet("/api/v5/account/positions?instType=SWAP"); JSONArray data = resp.getJSONArray("data"); if (data == null || data.isEmpty()) { log.info("[OKX] 无已有仓位"); return; } for (int i = 0; i < data.size(); i++) { JSONObject pos = data.getJSONObject(i); String instId = pos.getString("instId"); if (instId == null || !instId.equals(config.getContract())) { continue; } String posVal = pos.getString("pos"); if (posVal == null || "0".equals(posVal)) { continue; } String posSide = pos.getString("posSide"); String side = "long".equals(posSide) ? "sell" : "buy"; JSONObject body = new JSONObject(); body.put("instId", config.getContract()); body.put("tdMode", "cross"); body.put("side", side); body.put("posSide", posSide); body.put("ordType", "market"); body.put("sz", posVal); executorPost("/api/v5/trade/order", body.toJSONString()); log.info("[OKX] 平已有仓位, posSide:{}, sz:{}", posSide, posVal); } } catch (Exception e) { log.warn("[OKX] 平仓位异常", e); } } // ---- 启动/停止 ---- public void startGrid() { if (state != StrategyState.WAITING_KLINE && state != StrategyState.STOPPED) { log.warn("[OKX] 策略已在运行中, state:{}", state); return; } state = StrategyState.WAITING_KLINE; cumulativePnl = BigDecimal.ZERO; unrealizedPnl = BigDecimal.ZERO; markPrice = BigDecimal.ZERO; longEntryPrice = BigDecimal.ZERO; shortEntryPrice = BigDecimal.ZERO; longPositionSize = BigDecimal.ZERO; shortPositionSize = BigDecimal.ZERO; baseLongOpened = false; baseShortOpened = false; longActive = false; shortActive = false; accumulatedLongLossCount = 0; accumulatedShortLossCount = 0; shortPriceQueue.clear(); longPriceQueue.clear(); totalShortPriceQueue.clear(); totalLongPriceQueue.clear(); currentLongOrderIds.clear(); currentShortOrderIds.clear(); refreshInitialPrincipal(); log.info("[OKX] 网格策略已启动, 当前本金: {} USDT", initialPrincipal); } private void refreshInitialPrincipal() { try { JSONObject account = executorGet("/api/v5/account/balance"); JSONArray details = account.getJSONArray("data"); if (details != null && !details.isEmpty()) { this.initialPrincipal = details.getJSONObject(0).getBigDecimal("totalEq"); } } catch (Exception e) { log.warn("[OKX] 获取初始化本金失败,使用旧值: {}", initialPrincipal); } } public void stopGrid() { state = StrategyState.STOPPED; executor.cancelAllPriceTriggeredOrders(); closeExistingPositions(); executor.shutdown(); log.info("[OKX] 策略已停止, 累计盈亏: {}", cumulativePnl); } // ---- K线回调 ---- public void onKline(BigDecimal closePrice) { lastKlinePrice = closePrice; if (state == StrategyState.WAITING_KLINE) { if (wsClient == null || !wsClient.areAllSubscribed()) { return; } state = StrategyState.OPENING; String size = config.getBaseQuantity(); log.info("[OKX] 首根K线到达,开基底仓位 多空各{}张...", size); executor.openLong(size, (orderId) -> { TraderParam baseLongTp = TraderParam.builder() .entryOrderId(orderId) .build(); config.setBaseLongTraderParam(baseLongTp); }, null); executor.openShort(size, (orderId) -> { TraderParam baseShortTp = TraderParam.builder() .entryOrderId(orderId) .build(); config.setBaseShortTraderParam(baseShortTp); }, null); return; } if (state == StrategyState.ACTIVE && !longActive && longPositionSize.compareTo(BigDecimal.ZERO) == 0) { processShortGrid(closePrice); } if (state == StrategyState.ACTIVE && !shortActive && shortPositionSize.compareTo(BigDecimal.ZERO) == 0) { processLongGrid(closePrice); } } // ---- 仓位推送回调 ---- /** * 仓位推送回调。由 PositionsOkxChannelHandler 调用。 * direction 使用 TraderParam.Direction:LONG 表示多头,SHORT 表示空头。 */ public void onPositionUpdate(String contract, TraderParam.Direction direction, BigDecimal size, BigDecimal entryPrice) { if (state == StrategyState.STOPPED || state == StrategyState.WAITING_KLINE) { return; } boolean hasPosition = size.abs().compareTo(BigDecimal.ZERO) > 0; boolean isLong = (direction == TraderParam.Direction.LONG); if (state == StrategyState.OPENING) { if (isLong && hasPosition && !baseLongOpened) { longActive = true; longPositionSize = size; longEntryPrice = entryPrice; longBaseEntryPrice = entryPrice; baseLongOpened = true; log.info("[OKX] 基底多成交价: {}", longBaseEntryPrice); tryGenerateQueues(); } else if (!isLong && hasPosition && !baseShortOpened) { shortActive = true; shortPositionSize = size.abs(); shortEntryPrice = entryPrice; shortBaseEntryPrice = entryPrice; baseShortOpened = true; log.info("[OKX] 基底空成交价: {}", shortBaseEntryPrice); tryGenerateQueues(); } } if (state == StrategyState.ACTIVE) { if (isLong) { if (hasPosition) { longActive = true; longPositionSize = size; longEntryPrice = entryPrice; } else { log.info("[OKX-0]多仓归零: {}", shortBaseEntryPrice); longActive = false; longPositionSize = BigDecimal.ZERO; longEntryPrice = BigDecimal.ZERO; } } else { if (hasPosition) { shortActive = true; shortPositionSize = size.abs(); shortEntryPrice = entryPrice; } else { log.info("[OKX-0]空仓归零: {}", shortBaseEntryPrice); shortActive = false; shortPositionSize = BigDecimal.ZERO; shortEntryPrice = BigDecimal.ZERO; } } } if (state == StrategyState.ACTIVE && !shortActive && !longActive) { executor.cancelAllPriceTriggeredOrders(); closeExistingPositions(); state = StrategyState.STOPPED; executor.submitTask(() -> { try { Thread.sleep(3000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } startGrid(); }); log.info("[OKX] 重置策略"); } } // ---- 平仓推送回调 ---- /** * 平仓盈亏累加(OKX 目前没有独立的 position_closes 频道, * 盈亏信息可从 orders 频道或 REST API 获取,这里保留接口以备将来使用)。 */ public void onPositionClose(String contract, String side, BigDecimal pnl) { if (state == StrategyState.STOPPED) { return; } cumulativePnl = cumulativePnl.add(pnl); updateUnrealizedPnl(); BigDecimal totalPnl = cumulativePnl.add(unrealizedPnl); log.info("[OKX] 已实现:{}, 未实现:{}, 合计:{}", cumulativePnl, unrealizedPnl, totalPnl); if (totalPnl.compareTo(config.getMaxLoss().negate()) <= 0) { String logMessage = StrUtil.format("[OKX] 已达亏损风险值(合计{}), 已实现:{}, 未实现:{}", totalPnl, cumulativePnl, unrealizedPnl); log.info(logMessage); DingTalkUtils.getDefault().sendActionCard("风险提醒", logMessage, config.getApiKey(), ""); } } // ---- 自动订单(条件单)状态变更回调 ---- /** * 自动订单状态变更回调。由 OrderAlgoOkxChannelHandler 调用。 */ public void onAutoOrder(String orderId, String status, String reason, String orderType, String tradeId) { if (state == StrategyState.STOPPED) { return; } log.info("[OKX] 条件单状态变更, id:{}, status:{}, reason:{}, order_type:{}", orderId, status, reason, orderType); if (!"finished".equals(status)) { return; } // 多仓止盈触发 GridElement longTpElem = GridElement.findByLongTakeProfitOrderId(orderId); if (longTpElem != null && StrUtil.isNotEmpty(tradeId) && !tradeId.equals("0")) { longTakeProfitTraderIdParam(longTpElem, null, false); log.info("[OKX] 多仓止盈触发 gridId:{}, orderId:{}", longTpElem.getId(), orderId); cancelFarthestLongStopLoss(); checkLastTakeProfitAndRestart(); return; } // 空仓止盈触发 GridElement shortTpElem = GridElement.findByShortTakeProfitOrderId(orderId); if (shortTpElem != null && StrUtil.isNotEmpty(tradeId) && !tradeId.equals("0")) { shortTakeProfitTraderIdParam(shortTpElem, null, false); log.info("[OKX] 空仓止盈触发 gridId:{}, orderId:{}", shortTpElem.getId(), orderId); cancelFarthestShortStopLoss(); checkLastTakeProfitAndRestart(); return; } // 多仓止损触发 GridElement longStopLossElem = GridElement.findByLongStopLossOrderId(orderId); if (longStopLossElem != null && StrUtil.isNotEmpty(tradeId) && !tradeId.equals("0")) { handleLongStopLossTriggered(longStopLossElem); return; } // 空仓止损触发 GridElement shortStopLossElem = GridElement.findByShortStopLossOrderId(orderId); if (shortStopLossElem != null && StrUtil.isNotEmpty(tradeId) && !tradeId.equals("0")) { handleShortStopLossTriggered(shortStopLossElem); return; } // 空仓挂单成交 GridElement shortGridElement = GridElement.findByShortOrderId(orderId); if (shortGridElement != null) { if (shortGridElement.isHasShortOrder() && StrUtil.isNotEmpty(tradeId) && !tradeId.equals("0")) { int filledQty = Integer.parseInt(shortGridElement.getShortTraderParam().getQuantity()); shortEntryTraderIdParam(shortGridElement, null, false); cancelAllShortTakeProfitsAndStopLosses(); int posSize = queryPositionSize(OkxPosMode.SHORT); extendShortStopLoss(posSize, shortGridElement.getId()); accumulatedShortLossCount = 0; log.info("[OKX] 空单成交 gridId:{}, 当前持仓:{}张", filledQty, posSize); BigDecimal shortBaseQty = new BigDecimal(config.getBaseQuantity()); BigDecimal shortGridQty = new BigDecimal(config.getQuantity()); if (BigDecimal.valueOf(posSize).compareTo(shortBaseQty) > 0) { BigDecimal shortExcess = BigDecimal.valueOf(posSize).subtract(shortBaseQty); int shortExcessCount = shortExcess.divide(shortGridQty, 0, RoundingMode.DOWN).intValue(); for (int i = 0; i < shortExcessCount; i++) { int tpGridId = shortGridElement.getId() - 2 - i; if (i > 0) { tpGridId = tpGridId - 1; } GridElement tpElem = GridElement.findById(tpGridId); if (tpElem == null || tpElem.getShortTakeProfitOrderId() != null) { continue; } BigDecimal tpPrice = tpElem.getGridPrice(); int finalTpGridId = tpGridId; executor.placeTakeProfit( tpPrice, "close_short", config.getQuantity(), profitId -> { shortTakeProfitTraderIdParam(tpElem, profitId, true); log.info("[OKX] 空仓止盈挂单, gridId:{}, 触发价:{}, takeProfitId:{}", finalTpGridId, tpPrice, profitId); } ); } } } } // 多仓挂单成交 GridElement longGridElement = GridElement.findByLongOrderId(orderId); if (longGridElement != null) { if (longGridElement.isHasLongOrder() && StrUtil.isNotEmpty(tradeId) && !tradeId.equals("0")) { int filledQty = Integer.parseInt(longGridElement.getLongTraderParam().getQuantity()); longEntryTraderIdParam(longGridElement, null, false); cancelAllLongTakeProfitsAndStopLosses(); int posSize = queryPositionSize(OkxPosMode.LONG); extendLongStopLoss(posSize, longGridElement.getId()); accumulatedLongLossCount = 0; log.info("[OKX] 多单成交 gridId:{}, 当前持仓:{}张", filledQty, posSize); BigDecimal longBaseQty = new BigDecimal(config.getBaseQuantity()); BigDecimal longGridQty = new BigDecimal(config.getQuantity()); if (BigDecimal.valueOf(posSize).compareTo(longBaseQty) > 0) { BigDecimal longExcess = BigDecimal.valueOf(posSize).subtract(longBaseQty); int longExcessCount = longExcess.divide(longGridQty, 0, RoundingMode.DOWN).intValue(); for (int i = 0; i < longExcessCount; i++) { int tpGridId = longGridElement.getId() + 2 + i; if (i > 0) { tpGridId = tpGridId + 1; } GridElement tpElem = GridElement.findById(tpGridId); if (tpElem == null || tpElem.getLongTakeProfitOrderId() != null) { continue; } BigDecimal tpPrice = tpElem.getGridPrice(); int finalTpGridId = tpGridId; executor.placeTakeProfit( tpPrice, "close_long", config.getQuantity(), profitId -> { longTakeProfitTraderIdParam(tpElem, profitId, true); log.info("[OKX] 多仓止盈挂单, gridId:{}, 触发价:{}, takeProfitId:{}", finalTpGridId, tpPrice, profitId); } ); } } } } } // ========== REST 查询辅助 ========== private int queryPositionSize(OkxPosMode mode) { OkxPositionInfo p = queryPosition(mode); if (p != null) { return new BigDecimal(p.size).abs().intValue(); } return 0; } private BigDecimal queryEntryPrice(OkxPosMode mode) { OkxPositionInfo p = queryPosition(mode); if (p != null && p.entryPrice != null) { return new BigDecimal(p.entryPrice); } return BigDecimal.ZERO; } private OkxPositionInfo queryPosition(OkxPosMode mode) { try { JSONObject resp = executorGet("/api/v5/account/positions?instType=SWAP"); JSONArray data = resp.getJSONArray("data"); if (data != null) { for (int i = 0; i < data.size(); i++) { JSONObject p = data.getJSONObject(i); if (config.getContract().equals(p.getString("instId")) && mode.name().toLowerCase().equals(p.getString("posSide"))) { OkxPositionInfo info = new OkxPositionInfo(); info.size = p.getString("pos"); info.entryPrice = p.getString("avgPx"); return info; } } } } catch (Exception e) { log.warn("[OKX] 查询{}持仓失败", mode, e); } return null; } // ---- REST 快捷方法 ---- private JSONObject executorGet(String path) throws Exception { return executor.okGet(path); } private JSONObject executorPost(String path, String body) throws Exception { return executor.okPost(path, body); } // ---- 网格队列处理 ---- private void tryGenerateQueues() { if (baseLongOpened && baseShortOpened) { generateShortQueue(); generateLongQueue(); updateGridElements(); GridElement baseGridElement = GridElement.findById(0); TraderParam baseLongTraderParam = config.getBaseLongTraderParam(); baseGridElement.setLongOrderId(baseLongTraderParam.getEntryOrderId()); baseGridElement.setHasLongOrder(true); TraderParam baseShortTraderParam = config.getBaseShortTraderParam(); baseGridElement.setShortOrderId(baseShortTraderParam.getEntryOrderId()); baseGridElement.setHasShortOrder(true); // 挂基座止盈 { BigDecimal tpPrice = baseGridElement.getGridPrice().add(config.getStep()); executor.placeTakeProfit(tpPrice, "close_long", config.getBaseQuantity(), profitId -> { longTakeProfitTraderIdParam(baseGridElement, profitId, true); log.info("[OKX] 基座多仓止盈已挂, gridId:0, 触发价:{}, tpId:{}", tpPrice, profitId); }); } { BigDecimal tpPrice = baseGridElement.getGridPrice().subtract(config.getStep()); executor.placeTakeProfit(tpPrice, "close_short", config.getBaseQuantity(), profitId -> { shortTakeProfitTraderIdParam(baseGridElement, profitId, true); log.info("[OKX] 基座空仓止盈已挂, gridId:0, 触发价:{}, tpId:{}", tpPrice, profitId); }); } // 挂初始止损 int stopCount = Integer.parseInt(config.getBaseQuantity()) / Integer.parseInt(config.getQuantity()) + 1; for (int id = 2; id <= stopCount; id++) { GridElement elem = GridElement.findById(id); if (elem == null) { continue; } BigDecimal triggerPrice = elem.getGridPrice(); int finalId = id; executor.placeTakeProfit(triggerPrice, "close_short", config.getQuantity(), profitId -> { elem.setShortStopLossOrderId(profitId); GridElement.refreshIndices(); log.info("[OKX] 空仓止损已挂, gridId:{}, 触发价:{}, slId:{}", finalId, triggerPrice, profitId); }); } for (int id = -2; id >= -stopCount; id--) { GridElement elem = GridElement.findById(id); if (elem == null) { continue; } BigDecimal triggerPrice = elem.getGridPrice(); int finalId = id; executor.placeTakeProfit(triggerPrice, "close_long", config.getQuantity(), profitId -> { elem.setLongStopLossOrderId(profitId); GridElement.refreshIndices(); log.info("[OKX] 多仓止损已挂, gridId:{}, 触发价:{}, slId:{}", finalId, triggerPrice, profitId); }); } log.info("[OKX] 止损单已全部挂完, 空仓止损: 2~{}, 多仓止损: -2~-{}", stopCount, stopCount); // 挂初始条件开仓单 GridElement longFirst = GridElement.findById(1); if (longFirst != null && !longFirst.isHasLongOrder()) { BigDecimal triggerPrice = longFirst.getGridPrice(); log.info("[OKX] 挂初始多仓条件单, gridId:1, trigger:{}", triggerPrice); placeEntryOrderWithPreFlag(longFirst, true, triggerPrice, config.getBaseQuantity()); } GridElement shortFirst = GridElement.findById(-1); if (shortFirst != null && !shortFirst.isHasShortOrder()) { BigDecimal triggerPrice = shortFirst.getGridPrice(); log.info("[OKX] 挂初始空仓条件单, gridId:-1, trigger:{}", triggerPrice); placeEntryOrderWithPreFlag(shortFirst, false, triggerPrice, negate(config.getBaseQuantity())); } state = StrategyState.ACTIVE; } } // ---- TraderParam 更新辅助方法 ---- private void longTakeProfitTraderIdParam(GridElement e, String profitId, boolean flag) { TraderParam tp = e.getLongTraderParam(); tp.setTakeProfitOrderId(profitId); tp.setTakeProfitPlaced(flag); e.setLongTakeProfitOrderId(profitId); GridElement.refreshIndices(); } private void shortTakeProfitTraderIdParam(GridElement e, String profitId, boolean flag) { TraderParam tp = e.getShortTraderParam(); tp.setTakeProfitOrderId(profitId); tp.setTakeProfitPlaced(flag); e.setShortTakeProfitOrderId(profitId); GridElement.refreshIndices(); } private void longEntryTraderIdParam(GridElement e, String entryId, boolean flag) { TraderParam tp = e.getLongTraderParam(); tp.setEntryOrderId(entryId); tp.setEntryOrderPlaced(flag); e.setHasLongOrder(flag); e.setLongOrderId(entryId); GridElement.refreshIndices(); } private void shortEntryTraderIdParam(GridElement e, String entryId, boolean flag) { TraderParam tp = e.getShortTraderParam(); tp.setEntryOrderId(entryId); tp.setEntryOrderPlaced(flag); e.setHasShortOrder(flag); e.setShortOrderId(entryId); GridElement.refreshIndices(); } // ---- 队列生成 ---- private void generateShortQueue() { shortPriceQueue.clear(); totalShortPriceQueue.clear(); totalLongPriceQueue.clear(); int prec = config.getPriceScale(); BigDecimal step = shortBaseEntryPrice.multiply(config.getGridRate()).setScale(prec, RoundingMode.HALF_UP); config.setStep(step); BigDecimal elem = shortBaseEntryPrice.subtract(step).setScale(prec, RoundingMode.HALF_UP); for (int i = 0; i < config.getGridQueueSize(); i++) { shortPriceQueue.add(elem); totalLongPriceQueue.add(elem); totalShortPriceQueue.add(elem); elem = elem.subtract(step).setScale(prec, RoundingMode.HALF_UP); if (elem.compareTo(BigDecimal.ZERO) <= 0) { break; } } shortPriceQueue.sort((a, b) -> b.compareTo(a)); log.info("[OKX] 空队列:{}", shortPriceQueue); } private void generateLongQueue() { longPriceQueue.clear(); int prec = config.getPriceScale(); BigDecimal step = config.getStep(); BigDecimal elem = shortBaseEntryPrice.add(step).setScale(prec, RoundingMode.HALF_UP); for (int i = 0; i < config.getGridQueueSize(); i++) { longPriceQueue.add(elem); totalLongPriceQueue.add(elem); totalShortPriceQueue.add(elem); elem = elem.add(step).setScale(prec, RoundingMode.HALF_UP); } longPriceQueue.sort(BigDecimal::compareTo); log.info("[OKX] 多队列:{}", longPriceQueue); totalShortPriceQueue.sort((a, b) -> b.compareTo(a)); totalLongPriceQueue.sort(BigDecimal::compareTo); } private void updateGridElements() { List<GridElement> elements = new ArrayList<>(); int shortSize = shortPriceQueue.size(); int longSize = longPriceQueue.size(); int prec = config.getPriceScale(); BigDecimal step = config.getStep(); String qty = config.getQuantity(); // 空仓队列:id 从 -1 自减 for (int i = 0; i < shortSize; i++) { int id = -(i + 1); Integer upId = (i == 0) ? 0 : id + 1; Integer downId = (i == shortSize - 1) ? null : id - 1; BigDecimal price = shortPriceQueue.get(i); elements.add(GridElement.builder() .id(id).gridPrice(price).upId(upId).downId(downId) .longTraderParam(TraderParam.builder().direction(TraderParam.Direction.LONG) .entryPrice(price).takeProfitPrice(price.add(step).setScale(prec, RoundingMode.HALF_UP)) .quantity(qty).build()) .shortTraderParam(TraderParam.builder().direction(TraderParam.Direction.SHORT) .entryPrice(price).takeProfitPrice(price.subtract(step).setScale(prec, RoundingMode.HALF_UP)) .quantity(qty).build()) .build()); } // 位置 0 { BigDecimal price = shortBaseEntryPrice; elements.add(GridElement.builder() .id(0).gridPrice(price) .upId(longSize > 0 ? 1 : null).downId(shortSize > 0 ? -1 : null) .longTraderParam(TraderParam.builder().direction(TraderParam.Direction.LONG) .entryPrice(price).takeProfitPrice(price.add(step).setScale(prec, RoundingMode.HALF_UP)) .quantity(qty).build()) .shortTraderParam(TraderParam.builder().direction(TraderParam.Direction.SHORT) .entryPrice(price).takeProfitPrice(price.subtract(step).setScale(prec, RoundingMode.HALF_UP)) .quantity(qty).build()) .build()); } // 多仓队列:id 从 1 自增 for (int i = 0; i < longSize; i++) { int id = i + 1; Integer downId = (i == 0) ? 0 : id - 1; Integer upId = (i == longSize - 1) ? null : id + 1; BigDecimal price = longPriceQueue.get(i); elements.add(GridElement.builder() .id(id).gridPrice(price).upId(upId).downId(downId) .longTraderParam(TraderParam.builder().direction(TraderParam.Direction.LONG) .entryPrice(price).takeProfitPrice(price.add(step).setScale(prec, RoundingMode.HALF_UP)) .quantity(qty).build()) .shortTraderParam(TraderParam.builder().direction(TraderParam.Direction.SHORT) .entryPrice(price).takeProfitPrice(price.subtract(step).setScale(prec, RoundingMode.HALF_UP)) .quantity(qty).build()) .build()); } config.setGridElements(elements); log.info("[OKX] 网格元素列表已构建, 共{}个元素 (空仓:{} 位置:0 多仓:{})", elements.size(), shortSize, longSize); } // ---- processShortGrid / processLongGrid ---- private void processShortGrid(BigDecimal currentPrice) { BigDecimal matched = BigDecimal.ZERO; synchronized (totalLongPriceQueue) { for (BigDecimal p : totalLongPriceQueue) { if (p.compareTo(currentPrice) >= 0) { matched = p; break; } } if (BigDecimal.ZERO.compareTo(matched) == 0) { return; } GridElement matchedUpGridElement = GridElement.findByPrice(matched); if (matchedUpGridElement != null && !matchedUpGridElement.isHasLongOrder()) { Integer upId = matchedUpGridElement.getUpId(); GridElement newEntryGrid = GridElement.findById(upId); if (newEntryGrid != null) { GridElement cancelGridElement = GridElement.findById(newEntryGrid.getUpId()); String quantity = cancelGridElement != null ? cancelGridElement.getLongTraderParam().getQuantity() : config.getBaseQuantity(); if (cancelGridElement != null && cancelGridElement.isHasLongOrder()) { String longOrderId = cancelGridElement.getLongOrderId(); executor.cancelConditionalOrder(longOrderId, oid -> { longEntryTraderIdParam(cancelGridElement, null, false); log.info("[OKX] 多仓仓位归零, 取消gridId:{}的多单,{}", cancelGridElement.getId(), longOrderId); }); } if (!newEntryGrid.isHasLongOrder()) { BigDecimal triggerPrice = newEntryGrid.getGridPrice(); String size = quantity; log.info("[OKX] 多仓仓位归零 gridId:{}, 挂{}基础张多单", newEntryGrid.getId(), size); newEntryGrid.getLongTraderParam().setQuantity(size); placeEntryOrderWithPreFlag(newEntryGrid, true, triggerPrice, size); } } } } } private void processLongGrid(BigDecimal currentPrice) { BigDecimal matched = BigDecimal.ZERO; synchronized (totalShortPriceQueue) { for (BigDecimal p : totalShortPriceQueue) { if (p.compareTo(currentPrice) <= 0) { matched = p; break; } } if (BigDecimal.ZERO.compareTo(matched) == 0) { return; } GridElement matchedUpGridElement = GridElement.findByPrice(matched); if (matchedUpGridElement != null && !matchedUpGridElement.isHasShortOrder()) { Integer downId = matchedUpGridElement.getDownId(); GridElement newEntryGrid = GridElement.findById(downId); if (newEntryGrid != null) { GridElement cancelGridElement = GridElement.findById(newEntryGrid.getDownId()); String quantity = cancelGridElement != null ? cancelGridElement.getShortTraderParam().getQuantity() : config.getBaseQuantity(); if (cancelGridElement != null && cancelGridElement.isHasShortOrder()) { String shortOrderId = cancelGridElement.getShortOrderId(); executor.cancelConditionalOrder(shortOrderId, oid -> { shortEntryTraderIdParam(cancelGridElement, null, false); log.info("[OKX] 空仓仓位归零, 取消gridId:{}的空单{}", cancelGridElement.getId(), shortOrderId); }); } if (!newEntryGrid.isHasShortOrder()) { BigDecimal triggerPrice = newEntryGrid.getGridPrice(); String size = quantity; log.info("[OKX] 空仓仓位归零 gridId:{}, 挂{}基础张空单", newEntryGrid.getId(), size); newEntryGrid.getShortTraderParam().setQuantity(size); placeEntryOrderWithPreFlag(newEntryGrid, false, triggerPrice, negate(size)); } } } } } // ---- 止损处理 ---- private void handleLongStopLossTriggered(GridElement gridElement) { gridElement.setLongStopLossOrderId(null); int gridId = gridElement.getId(); log.info("[OKX] 多仓止损触发 gridId:{}, 开始追单", gridId); int newEntryGridId = gridId + 1; GridElement newEntryGrid = GridElement.findById(newEntryGridId); if (newEntryGrid == null) { log.warn("[OKX] 多仓止损触发 but gridId:{} 不存在", newEntryGridId); GridElement.refreshIndices(); return; } if (!newEntryGrid.isHasLongOrder()) { BigDecimal triggerPrice = newEntryGrid.getGridPrice(); accumulatedLongLossCount += Integer.parseInt(config.getQuantity()); String size = String.valueOf(accumulatedLongLossCount + Integer.parseInt(config.getQuantity())); log.info("[OKX] 多仓止损触发 gridId:{}, 在gridId:{}挂{}基础张多单", gridId, newEntryGridId, size); newEntryGrid.getLongTraderParam().setQuantity(size); placeEntryOrderWithPreFlag(newEntryGrid, true, triggerPrice, size); } else { log.warn("[OKX] 多仓止损触发 gridId:{}, 目标gridId:{}已有挂单,跳过", gridId, newEntryGridId); } int cancelGridId = gridId + 2; GridElement cancelGrid = GridElement.findById(cancelGridId); if (cancelGrid != null && cancelGrid.isHasLongOrder()) { executor.cancelConditionalOrder(cancelGrid.getLongOrderId(), oid -> { longEntryTraderIdParam(cancelGrid, null, false); log.info("[OKX] 多仓止损触发, 取消gridId:{}的多单", cancelGridId); }); } GridElement farthestLongTp = null; for (GridElement e : config.getGridElements()) { if (e.getLongTakeProfitOrderId() != null) { if (farthestLongTp == null || e.getGridPrice().compareTo(farthestLongTp.getGridPrice()) > 0) { farthestLongTp = e; } } } if (farthestLongTp != null) { String tpOrderId = farthestLongTp.getLongTakeProfitOrderId(); GridElement finalFarthest = farthestLongTp; executor.cancelConditionalOrder(tpOrderId, oid -> { longTakeProfitTraderIdParam(finalFarthest, null, false); log.info("[OKX] 多仓止损触发, 取消最远止盈 gridId:{}, orderId:{}", finalFarthest.getId(), tpOrderId); }); } } private void handleShortStopLossTriggered(GridElement gridElement) { gridElement.setShortStopLossOrderId(null); int gridId = gridElement.getId(); log.info("[OKX] 空仓止损触发 gridId:{}, 开始追单", gridId); int newEntryGridId = gridId - 1; GridElement newEntryGrid = GridElement.findById(newEntryGridId); if (newEntryGrid == null) { log.warn("[OKX] 空仓止损触发 but gridId:{} 不存在", newEntryGridId); GridElement.refreshIndices(); return; } if (!newEntryGrid.isHasShortOrder()) { BigDecimal triggerPrice = newEntryGrid.getGridPrice(); accumulatedShortLossCount += Integer.parseInt(config.getQuantity()); String size = String.valueOf(accumulatedShortLossCount + Integer.parseInt(config.getQuantity())); log.info("[OKX] 空仓止损触发 gridId:{}, 在gridId:{}挂{}基础张空单", gridId, newEntryGridId, size); newEntryGrid.getShortTraderParam().setQuantity(size); placeEntryOrderWithPreFlag(newEntryGrid, false, triggerPrice, negate(size)); } else { log.warn("[OKX] 空仓止损触发 gridId:{}, 目标gridId:{}已有挂单,跳过", gridId, newEntryGridId); } int cancelGridId = gridId - 2; GridElement cancelGrid = GridElement.findById(cancelGridId); if (cancelGrid != null && cancelGrid.isHasShortOrder()) { executor.cancelConditionalOrder(cancelGrid.getShortOrderId(), oid -> { shortEntryTraderIdParam(cancelGrid, null, false); log.info("[OKX] 空仓止损触发, 取消gridId:{}的空单", cancelGridId); }); } GridElement farthestShortTp = null; for (GridElement e : config.getGridElements()) { if (e.getShortTakeProfitOrderId() != null) { if (farthestShortTp == null || e.getGridPrice().compareTo(farthestShortTp.getGridPrice()) < 0) { farthestShortTp = e; } } } if (farthestShortTp != null) { String tpOrderId = farthestShortTp.getShortTakeProfitOrderId(); GridElement finalFarthest = farthestShortTp; executor.cancelConditionalOrder(tpOrderId, oid -> { shortTakeProfitTraderIdParam(finalFarthest, null, false); log.info("[OKX] 空仓止损触发, 取消最远止盈 gridId:{}, orderId:{}", finalFarthest.getId(), tpOrderId); }); } } // ---- 止盈/止损取消辅助 ---- private void checkLastTakeProfitAndRestart() { int span = config.getRestartGridSpan(); if (span <= 0) { return; } if (GridElement.getLongTakeProfitCount() > 0 || GridElement.getShortTakeProfitCount() > 0) { log.info("[OKX] 尚有未触发止盈单, 暂不检查跨度重启 longTpCount:{}, shortTpCount:{}", GridElement.getLongTakeProfitCount(), GridElement.getShortTakeProfitCount()); return; } BigDecimal step = config.getStep(); if (step == null || step.compareTo(BigDecimal.ZERO) == 0) { return; } BigDecimal threshold = step.multiply(new BigDecimal(span)); BigDecimal currentPrice = lastKlinePrice; if (currentPrice == null || currentPrice.compareTo(BigDecimal.ZERO) == 0) { return; } // 查交易所获取最新持仓均价 OkxPositionInfo longPos = queryPosition(OkxPosMode.LONG); OkxPositionInfo shortPos = queryPosition(OkxPosMode.SHORT); boolean hasLong = longPos != null && Math.abs(Integer.parseInt(longPos.size)) > 0; boolean hasShort = shortPos != null && Math.abs(Integer.parseInt(shortPos.size)) > 0; BigDecimal longAvgPrice = (longPos != null && longPos.entryPrice != null) ? new BigDecimal(longPos.entryPrice) : BigDecimal.ZERO; BigDecimal shortAvgPrice = (shortPos != null && shortPos.entryPrice != null) ? new BigDecimal(shortPos.entryPrice) : BigDecimal.ZERO; boolean shouldRestart = false; String reason = ""; if (hasLong && hasShort) { BigDecimal gap = shortAvgPrice.subtract(longAvgPrice); if (gap.compareTo(threshold) >= 0) { shouldRestart = true; reason = StrUtil.format("双边跨度 |多均价:{} − 空均价:{}| = {} >= {} (span:{}×step:{})", longAvgPrice, shortAvgPrice, gap, threshold, span, step); } } else if (hasLong) { BigDecimal gap = currentPrice.subtract(longAvgPrice); if (gap.compareTo(threshold) >= 0) { shouldRestart = true; reason = StrUtil.format("多仓跨度 当前价:{} − 多均价:{} = {} > {} (span:{}×step:{})", currentPrice, longAvgPrice, gap, threshold, span, step); } } else if (hasShort) { BigDecimal gap = shortAvgPrice.subtract(currentPrice); if (gap.compareTo(threshold) >= 0) { shouldRestart = true; reason = StrUtil.format("空仓跨度 空均价:{} − 当前价:{} = {} > {} (span:{}×step:{})", shortAvgPrice, currentPrice, gap, threshold, span, step); } } if (shouldRestart) { log.info("[OKX] 跨度已达要求 → {},最后一个止盈触发策略重启", reason); executor.cancelAllPriceTriggeredOrders(); closeExistingPositions(); state = StrategyState.STOPPED; executor.submitTask(() -> { try { Thread.sleep(3000); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); } startGrid(); }); } } private void cancelFarthestLongStopLoss() { GridElement farthest = null; for (GridElement e : config.getGridElements()) { if (e.getLongStopLossOrderId() != null) { if (farthest == null || e.getId() < farthest.getId()) { farthest = e; } } } if (farthest != null) { String slId = farthest.getLongStopLossOrderId(); farthest.setLongStopLossOrderId(null); GridElement.refreshIndices(); GridElement finalFarthest = farthest; executor.cancelConditionalOrder(slId, oid -> log.info("[OKX] 止盈触发, 取消最远多仓止损 gridId:{}, orderId:{}", finalFarthest.getId(), slId)); } } private void cancelFarthestShortStopLoss() { GridElement farthest = null; for (GridElement e : config.getGridElements()) { if (e.getShortStopLossOrderId() != null) { if (farthest == null || e.getId() > farthest.getId()) { farthest = e; } } } if (farthest != null) { String slId = farthest.getShortStopLossOrderId(); farthest.setShortStopLossOrderId(null); GridElement.refreshIndices(); GridElement finalFarthest = farthest; executor.cancelConditionalOrder(slId, oid -> log.info("[OKX] 止盈触发, 取消最远空仓止损 gridId:{}, orderId:{}", finalFarthest.getId(), slId)); } } private void cancelAllLongTakeProfitsAndStopLosses() { for (GridElement e : config.getGridElements()) { String tpId = e.getLongTakeProfitOrderId(); if (tpId != null) { e.setLongTakeProfitOrderId(null); executor.cancelConditionalOrder(tpId, oid -> {}); } String slId = e.getLongStopLossOrderId(); if (slId != null) { e.setLongStopLossOrderId(null); executor.cancelConditionalOrder(slId, oid -> {}); } } GridElement.refreshIndices(); log.info("[OKX] 已提交取消所有多仓止盈+止损"); } private void cancelAllShortTakeProfitsAndStopLosses() { for (GridElement e : config.getGridElements()) { String tpId = e.getShortTakeProfitOrderId(); if (tpId != null) { e.setShortTakeProfitOrderId(null); executor.cancelConditionalOrder(tpId, oid -> {}); } String slId = e.getShortStopLossOrderId(); if (slId != null) { e.setShortStopLossOrderId(null); executor.cancelConditionalOrder(slId, oid -> {}); } } GridElement.refreshIndices(); log.info("[OKX] 已提交取消所有空仓止盈+止损"); } // ---- 止损追单 ---- private void extendLongStopLoss(int filledQty, int gridId) { int furthestSlId = 0; for (GridElement e : config.getGridElements()) { if (e.getLongStopLossOrderId() != null && e.getId() < furthestSlId) { furthestSlId = e.getId(); } } int interval = 1; if (furthestSlId == 0) { furthestSlId = gridId; interval = 2; } int stopLossCount = filledQty / Integer.parseInt(config.getQuantity()); log.info("[OKX] 多仓追挂止损, 当前最远止损gridId:{}, 成交{}张, 追加{}个止损单", furthestSlId, filledQty, stopLossCount); for (int i = 0; i < stopLossCount; i++) { int newSlId = furthestSlId - i - interval; GridElement elem = GridElement.findById(newSlId); if (elem == null) { continue; } BigDecimal triggerPrice = elem.getGridPrice(); int finalSlId = newSlId; executor.placeTakeProfit(triggerPrice, "close_long", config.getQuantity(), profitId -> { elem.setLongStopLossOrderId(profitId); GridElement.refreshIndices(); log.info("[OKX] 多仓止损追加, gridId:{}, 触发价:{}, slId:{}", finalSlId, triggerPrice, profitId); }); } } private void extendShortStopLoss(int filledQty, int gridId) { int furthestSlId = 0; for (GridElement e : config.getGridElements()) { if (e.getShortStopLossOrderId() != null && e.getId() > furthestSlId) { furthestSlId = e.getId(); } } int interval = 1; if (furthestSlId == 0) { furthestSlId = gridId; interval = 2; } int stopLossCount = filledQty / Integer.parseInt(config.getQuantity()); log.info("[OKX] 空仓追挂止损, 当前最远止损gridId:{}, 成交{}张, 追加{}个止损单", furthestSlId, filledQty, stopLossCount); for (int i = 0; i < stopLossCount; i++) { int newSlId = furthestSlId + i + interval; GridElement elem = GridElement.findById(newSlId); if (elem == null) { continue; } BigDecimal triggerPrice = elem.getGridPrice(); int finalSlId = newSlId; executor.placeTakeProfit(triggerPrice, "close_short", config.getQuantity(), profitId -> { elem.setShortStopLossOrderId(profitId); GridElement.refreshIndices(); log.info("[OKX] 空仓止损追加, gridId:{}, 触发价:{}, slId:{}", finalSlId, triggerPrice, profitId); }); } } // ---- 工具 ---- private String negate(String qty) { return qty.startsWith("-") ? qty.substring(1) : "-" + qty; } private void placeEntryOrderWithPreFlag(GridElement gridElement, boolean isLong, BigDecimal triggerPrice, String size) { if (isLong) { gridElement.setHasLongOrder(true); } else { gridElement.setHasShortOrder(true); } executor.placeConditionalEntryOrder(triggerPrice, isLong, size, orderId -> { if (isLong) { longEntryTraderIdParam(gridElement, orderId, true); } else { shortEntryTraderIdParam(gridElement, orderId, true); } }, () -> { if (isLong) { gridElement.setHasLongOrder(false); gridElement.setLongOrderId(null); } else { gridElement.setHasShortOrder(false); gridElement.setShortOrderId(null); } GridElement.refreshIndices(); log.warn("[OKX] 条件单创建失败,回滚标志位 gridId:{}, isLong:{}", gridElement.getId(), isLong); }); } private void updateUnrealizedPnl() { BigDecimal price = resolvePnlPrice(); if (price == null || price.compareTo(BigDecimal.ZERO) == 0) { return; } BigDecimal multiplier = config.getContractMultiplier(); BigDecimal longPnl = BigDecimal.ZERO; BigDecimal shortPnl = BigDecimal.ZERO; if (longPositionSize.compareTo(BigDecimal.ZERO) > 0 && longEntryPrice.compareTo(BigDecimal.ZERO) > 0) { longPnl = longPositionSize.multiply(multiplier).multiply(price.subtract(longEntryPrice)); } if (shortPositionSize.compareTo(BigDecimal.ZERO) > 0 && shortEntryPrice.compareTo(BigDecimal.ZERO) > 0) { shortPnl = shortPositionSize.multiply(multiplier).multiply(shortEntryPrice.subtract(price)); } unrealizedPnl = longPnl.add(shortPnl); log.info("[OKX] 未实现盈亏: {}", unrealizedPnl); } private BigDecimal resolvePnlPrice() { if (config.getUnrealizedPnlPriceMode() == OkxConfig.PnLPriceMode.MARK_PRICE && markPrice.compareTo(BigDecimal.ZERO) > 0) { return markPrice; } return lastKlinePrice; } // ---- getters ---- public BigDecimal getLastKlinePrice() { return lastKlinePrice; } public void setMarkPrice(BigDecimal markPrice) { this.markPrice = markPrice; } public boolean isStrategyActive() { return state != StrategyState.STOPPED && state != StrategyState.WAITING_KLINE; } public BigDecimal getCumulativePnl() { return cumulativePnl; } public BigDecimal getUnrealizedPnl() { return unrealizedPnl; } public StrategyState getState() { return state; } public void setWsClient(OkxKlineWebSocketClient wsClient) { this.wsClient = wsClient; } } src/main/java/com/xcong/excoin/modules/okxApi/OkxKlineWebSocketClient.java
New file @@ -0,0 +1,753 @@ package com.xcong.excoin.modules.okxApi; import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; import com.xcong.excoin.modules.okxApi.wsHandler.OkxChannelHandler; import com.xcong.excoin.modules.okxNewPrice.utils.SSLConfig; import lombok.extern.slf4j.Slf4j; import org.java_websocket.client.WebSocketClient; import org.java_websocket.handshake.ServerHandshake; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.net.URI; import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; import java.text.SimpleDateFormat; import java.time.Instant; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Base64; import java.util.Date; import java.util.List; import java.util.TimeZone; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; /** * OKX WebSocket 连接管理器 — 双通道架构。 * * <h3>与 Gate 版本的关键区别</h3> * OKX 使用<b>两条独立的 WebSocket 连接</b>: * <ul> * <li><b>公开 WS</b> ({@code wss://ws.okx.com:8443/ws/v5/public}): * 无需认证,订阅 K 线等公开数据。</li> * <li><b>私有 WS</b> ({@code wss://ws.okx.com:8443/ws/v5/private}): * 需要登录认证(login 消息),订阅仓位、条件订单等私有数据。</li> * </ul> * 而 Gate 只有一条 WS 连接,通过签名区分公开/私有频道。 * * <h3>登录认证(私有 WS)</h3> * <pre> * { * "op": "login", * "args": [{ * "apiKey": "...", * "passphrase": "...", * "timestamp": "1734567890", // Unix 秒级时间戳 * "sign": "base64(HMAC-SHA256(timestamp + 'GET' + '/users/self/verify'))" * }] * } * </pre> * * <h3>心跳机制</h3> * OKX 标准格式为 JSON {@code {"op":"ping"}} / {@code {"op":"pong"}}, * 同时兼容纯文本 {@code "ping"} / {@code "pong"} 格式。 * * <h3>消息路由</h3> * <pre> * onMessage → handleMessage(message, isPrivate): * 1. "pong" (纯文本) → 日志 + cancelPongTimeout * 2. "ping" (纯文本) → 回复 "pong" * 3. {"op":"pong"} (JSON) → 日志 + cancelPongTimeout * 4. {"op":"ping"} (JSON) → 回复 {"op":"pong"} * 5. {"event":"login"} → 登录成功 → 订阅所有私有 handlers * 6. {"event":"subscribe"} → 标记对应 handler subscribed=true * 7. {"event":"error"} → 错误日志 * 8. {"arg":{...}, "data":[...]} → 遍历 handlers 路由 * </pre> * * <h3>生命周期</h3> * <pre> * init() → connect(public) + connect(private,true) → startHeartbeat() * destroy() → unsubscribe 所有 handler → closeBlocking() 两条连接 → shutdown 线程池 * onClose() → reconnectWithBackoff() 重连对应连接(最多 3 次,指数退避) * </pre> * * <h3>线程安全</h3> * 连接状态用 AtomicBoolean(isPublicConnected, isPrivateConnected, isConnecting, isInitialized)。 * 消息时间戳用 AtomicReference。心跳任务用 synchronized 保护。 * * @author Administrator */ @SuppressWarnings("ALL") @Slf4j public class OkxKlineWebSocketClient { // ==================== 常量 ==================== /** 心跳超时时间(秒) */ private static final int HEARTBEAT_TIMEOUT = 10; /** ISO 8601 时间格式化器(毫秒精度,UTC 时区) */ private static final DateTimeFormatter ISO_8601_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") .withZone(ZoneId.of("UTC")); // ==================== 配置 ==================== /** OKX 配置(提供 WS URL、API 密钥等) */ private final OkxConfig config; /** OKX API Key */ private final String apiKey; /** OKX API Secret */ private final String apiSecret; /** OKX API Passphrase */ private final String passphrase; // ==================== WebSocket 客户端 ==================== /** 公开频道 WebSocket 客户端(K线等) */ private WebSocketClient publicWsClient; /** 私有频道 WebSocket 客户端(仓位、条件单等) */ private WebSocketClient privateWsClient; /** 心跳检测调度器 */ private ScheduledExecutorService heartbeatExecutor; /** 心跳超时 Future */ private volatile ScheduledFuture<?> pongTimeoutFuture; /** 最后收到消息的时间戳(毫秒) */ private final AtomicReference<Long> lastMessageTime = new AtomicReference<>(System.currentTimeMillis()); // ==================== 连接状态 ==================== /** 公开频道连接状态 */ private final AtomicBoolean isPublicConnected = new AtomicBoolean(false); /** 私有频道连接状态 */ private final AtomicBoolean isPrivateConnected = new AtomicBoolean(false); /** 连接中标记,防重入 */ private final AtomicBoolean isConnecting = new AtomicBoolean(false); /** 初始化标记,防重复 init */ private final AtomicBoolean isInitialized = new AtomicBoolean(false); /** 私有频道登录成功标记 */ private final AtomicBoolean isPrivateLoggedIn = new AtomicBoolean(false); // ==================== 频道处理器 ==================== /** 公开频道处理器列表(如 K线) */ private final List<OkxChannelHandler> publicHandlers = new ArrayList<>(); /** 私有频道处理器列表(如 仓位、条件单) */ private final List<OkxChannelHandler> privateHandlers = new ArrayList<>(); // ==================== 异步线程池 ==================== /** 重连等异步任务的缓存线程池(daemon 线程) */ private final ExecutorService sharedExecutor = Executors.newCachedThreadPool(r -> { Thread t = new Thread(r, "okx-ws-worker"); t.setDaemon(true); return t; }); // ==================== 重连配置 ==================== /** 重连最大次数 */ private static final int MAX_RECONNECT_ATTEMPTS = 3; /** 重连初始延迟(毫秒) */ private static final long INITIAL_RECONNECT_DELAY_MS = 5000; // ==================== 构造器 ==================== /** * 构造 OKX WebSocket 客户端。 * * @param config OKX 配置(提供 WS URL、API 密钥等) */ public OkxKlineWebSocketClient(OkxConfig config) { this.config = config; this.apiKey = config.getApiKey(); this.apiSecret = config.getApiSecret(); this.passphrase = config.getPassphrase(); } // ==================== Handler 注册 ==================== /** * 注册公开频道处理器(如 K线)。需在 init() 前调用。 * * @param handler 实现了 {@link OkxChannelHandler} 接口的公开频道处理器 */ public void addPublicHandler(OkxChannelHandler handler) { publicHandlers.add(handler); log.info("[OKX-WS] 注册公开频道处理器: {}", handler.getChannelName()); } /** * 注册私有频道处理器(如 仓位、条件单)。需在 init() 前调用。 * * @param handler 实现了 {@link OkxChannelHandler} 接口的私有频道处理器 */ public void addPrivateHandler(OkxChannelHandler handler) { privateHandlers.add(handler); log.info("[OKX-WS] 注册私有频道处理器: {}", handler.getChannelName()); } // ==================== 生命周期 ==================== /** * 初始化:建立公开 WS 连接 + 私有 WS 连接 → 启动心跳检测。 * 使用 {@code AtomicBoolean} 防重入,同一实例只允许初始化一次。 */ public void init() { if (!isInitialized.compareAndSet(false, true)) { log.warn("[OKX-WS] 已初始化过,跳过重复初始化"); return; } connect(false); // 公开 WS connect(true); // 私有 WS startHeartbeat(); } /** * 销毁:取消所有频道订阅 → 关闭两条 WebSocket 连接 → 关闭线程池。 * * <h3>执行顺序</h3> * 先取消订阅(等待 500ms 确保发送完成),再 closeBlocking 关闭连接, * 最后 shutdown 线程池。先关连接再关线程池,避免 onClose 回调中的重连任务访问已关闭的线程池。 */ public void destroy() { log.info("[OKX-WS] 开始销毁..."); // 取消公开频道订阅 if (publicWsClient != null && publicWsClient.isOpen()) { for (OkxChannelHandler handler : publicHandlers) { handler.unsubscribe(publicWsClient); } } // 取消私有频道订阅 if (privateWsClient != null && privateWsClient.isOpen()) { for (OkxChannelHandler handler : privateHandlers) { handler.unsubscribe(privateWsClient); } } // 等待取消订阅消息发出 try { Thread.sleep(500); } catch (InterruptedException e) { Thread.currentThread().interrupt(); log.warn("[OKX-WS] 取消订阅等待被中断"); } // 关闭公开 WS closeWebSocket(publicWsClient); publicWsClient = null; // 关闭私有 WS closeWebSocket(privateWsClient); privateWsClient = null; // 关闭心跳 shutdownExecutorGracefully(heartbeatExecutor); if (pongTimeoutFuture != null) { pongTimeoutFuture.cancel(true); } // 关闭共享线程池 shutdownExecutorGracefully(sharedExecutor); log.info("[OKX-WS] 销毁完成"); } /** * 安全关闭 WebSocket 连接。 */ private void closeWebSocket(WebSocketClient ws) { if (ws != null && ws.isOpen()) { try { ws.closeBlocking(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); log.warn("[OKX-WS] 关闭连接时被中断"); } } } // ==================== 连接管理 ==================== /** * 建立 WebSocket 连接。 * * <h3>公开 WS 连接成功回调</h3> * 订阅所有公开 handlers(K线等)。 * * <h3>私有 WS 连接成功回调</h3> * 先发送 login 认证消息,登录成功后再订阅所有私有 handlers。 * * <h3>连接关闭回调</h3> * 设置断连状态 → 异步触发指数退避重连(最多3次)。 * * @param isPrivate true=私有 WS(需登录),false=公开 WS */ private void connect(boolean isPrivate) { String wsUrl = isPrivate ? config.getWsPrivateUrl() : config.getWsPublicUrl(); String label = isPrivate ? "私有" : "公开"; if (isConnecting.get() || !isConnecting.compareAndSet(false, true)) { log.info("[OKX-WS] 连接进行中,跳过重复{} WS请求", label); return; } try { SSLConfig.configureSSL(); System.setProperty("https.protocols", "TLSv1.2,TLSv1.3"); URI uri = new URI(wsUrl); WebSocketClient client = new WebSocketClient(uri) { @Override public void onOpen(ServerHandshake handshake) { log.info("[OKX-WS] {} WS连接成功", label); isConnecting.set(false); if (isPrivate) { isPrivateConnected.set(true); // 私有 WS 需要先登录 sendLogin(this); } else { isPublicConnected.set(true); // 公开 WS 直接订阅 if (sharedExecutor != null && !sharedExecutor.isShutdown()) { resetHeartbeatTimer(); for (OkxChannelHandler handler : publicHandlers) { handler.subscribe(this); } } else { log.warn("[OKX-WS] 应用正在关闭,忽略{} WS连接成功回调", label); } } } @Override public void onMessage(String message) { lastMessageTime.set(System.currentTimeMillis()); handleMessage(message, isPrivate, this); resetHeartbeatTimer(); } @Override public void onClose(int code, String reason, boolean remote) { log.warn("[OKX-WS] {} WS连接关闭, code:{}, reason:{}, remote:{}", label, code, reason, remote); if (isPrivate) { isPrivateConnected.set(false); isPrivateLoggedIn.set(false); } else { isPublicConnected.set(false); } isConnecting.set(false); cancelPongTimeout(); if (sharedExecutor != null && !sharedExecutor.isShutdown() && !sharedExecutor.isTerminated()) { sharedExecutor.execute(() -> { try { reconnectWithBackoff(isPrivate); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } catch (Exception e) { log.error("[OKX-WS] {} WS重连失败", label, e); } }); } else { log.warn("[OKX-WS] 线程池已关闭,不执行{} WS重连", label); } } @Override public void onError(Exception ex) { log.error("[OKX-WS] {} WS发生错误", label, ex); if (isPrivate) { isPrivateConnected.set(false); } else { isPublicConnected.set(false); } } }; client.setConnectionLostTimeout(0); client.connect(); if (isPrivate) { this.privateWsClient = client; } else { this.publicWsClient = client; } } catch (URISyntaxException e) { log.error("[OKX-WS] URI格式错误: {}", wsUrl, e); isConnecting.set(false); } } // ==================== 登录认证 ==================== /** * 发送 OKX 私有 WS 登录消息。 * * <h3>签名算法</h3> * <pre> * timestamp = ISO 8601 当前时间 (UTC, 毫秒精度) * message = timestamp + "GET" + "/users/self/verify" + "" * sign = Base64(HMAC-SHA256(apiSecret, message)) * </pre> * * <h3>登录消息格式</h3> * <pre> * { * "op": "login", * "args": [{ * "apiKey": "...", * "passphrase": "...", * "timestamp": "2023-01-01T00:00:00.000Z", * "sign": "..." * }] * } * </pre> * * @param ws 私有频道 WebSocket 客户端 */ private void sendLogin(WebSocketClient ws) { try { // OKX WS 登录必须使用 Unix 秒级时间戳(非 ISO 8601!) String timestamp = String.valueOf(System.currentTimeMillis() / 1000); String message = timestamp + "GET" + "/users/self/verify"; String sign = hmacSha256Base64(apiSecret, message); JSONObject loginMsg = new JSONObject(); loginMsg.put("op", "login"); JSONArray args = new JSONArray(); JSONObject arg = new JSONObject(); arg.put("apiKey", apiKey); arg.put("passphrase", passphrase); arg.put("timestamp", timestamp); arg.put("sign", sign); args.add(arg); loginMsg.put("args", args); ws.send(loginMsg.toJSONString()); log.info("[OKX-WS] 发送登录消息, timestamp: {}", timestamp); } catch (Exception e) { log.error("[OKX-WS] 发送登录消息失败", e); } } /** * HMAC-SHA256 签名并 Base64 编码。 * * @param secret 密钥 * @param message 待签名消息 * @return Base64 编码的签名字符串 */ private String hmacSha256Base64(String secret, String message) { try { Mac mac = Mac.getInstance("HmacSHA256"); SecretKeySpec spec = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); mac.init(spec); byte[] hash = mac.doFinal(message.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(hash); } catch (Exception e) { log.error("[OKX-WS] HMAC-SHA256签名失败", e); return ""; } } // ==================== 消息路由 ==================== /** * 消息分发:先处理系统事件(ping/pong/login/subscribe/error), * 再把数据推送路由到对应的 channelHandler。 * * <h3>路由规则</h3> * <ol> * <li>"pong" → 日志(忽略)</li> * <li>"ping" → 回复 "pong"</li> * <li>{"event":"login"} → 登录成功 → 订阅所有私有 handlers</li> * <li>{"event":"subscribe"} → 标记对应 handler subscribed=true</li> * <li>{"event":"error"} → 错误日志</li> * <li>{"arg":{...}, "data":[...]} → 遍历 handlers 路由</li> * </ol> * * @param message 原始消息文本 * @param isPrivate true=私有频道消息,false=公开频道消息 * @param ws 接收消息的 WebSocket 客户端 */ private void handleMessage(String message, boolean isPrivate, WebSocketClient ws) { try { // OKX ping/pong 混合格式兼容:JSON {"op":"ping"} 与纯文本 "ping" 均支持 if ("pong".equals(message)) { log.debug("[OKX-WS] 收到 pong 响应(纯文本)"); cancelPongTimeout(); return; } if ("ping".equals(message)) { log.debug("[OKX-WS] 收到 ping(纯文本),回复 pong"); if (ws != null && ws.isOpen()) { ws.send("pong"); } return; } JSONObject response = JSON.parseObject(message); // JSON 格式的 ping/pong (OKX 文档标准格式) String op = response.getString("op"); if ("pong".equals(op)) { log.debug("[OKX-WS] 收到 pong 响应"); cancelPongTimeout(); return; } if ("ping".equals(op)) { log.debug("[OKX-WS] 收到 ping,回复 pong"); if (ws != null && ws.isOpen()) { ws.send("{\"op\":\"pong\"}"); } return; } // 登录响应 String event = response.getString("event"); if ("login".equals(event)) { String code = response.getString("code"); if ("0".equals(code)) { log.info("[OKX-WS] 私有频道登录成功"); isPrivateLoggedIn.set(true); // 登录成功后订阅所有私有频道 for (OkxChannelHandler handler : privateHandlers) { handler.subscribe(ws); } } else { log.error("[OKX-WS] 私有频道登录失败, code:{}, msg:{}", code, response.getString("msg")); } return; } // 订阅确认 if ("subscribe".equals(event)) { JSONObject arg = response.getJSONObject("arg"); if (arg != null) { String channel = arg.getString("channel"); log.info("[OKX-WS] 订阅成功: {}", channel); List<OkxChannelHandler> handlers = isPrivate ? privateHandlers : publicHandlers; for (OkxChannelHandler handler : handlers) { if (channel.equals(handler.getChannelName())) { handler.setSubscribed(true); break; } } } return; } // 取消订阅确认 if ("unsubscribe".equals(event)) { JSONObject arg = response.getJSONObject("arg"); log.info("[OKX-WS] 取消订阅成功: {}", arg != null ? arg.getString("channel") : "unknown"); return; } // 错误 if ("error".equals(event)) { log.error("[OKX-WS] 错误, code:{}, msg:{}", response.getString("code"), response.getString("msg")); return; } // 数据推送: {"arg":{"channel":"positions",...}, "data":[...]} JSONObject arg = response.getJSONObject("arg"); if (arg != null && response.getJSONArray("data") != null) { String channel = arg.getString("channel"); if (channel == null) { return; } List<OkxChannelHandler> handlers = isPrivate ? privateHandlers : publicHandlers; for (OkxChannelHandler handler : handlers) { if (handler.handleMessage(response)) { return; } } } } catch (Exception e) { log.error("[OKX-WS] 处理消息失败: {}", message, e); } } // ==================== 订阅状态检查 ==================== /** * 检查所有已注册的频道是否都已收到订阅成功确认。 * 同时检查公开和私有频道的 handlers。 * * @return true 如果所有 handlers 都已订阅确认 */ public boolean areAllSubscribed() { List<OkxChannelHandler> allHandlers = new ArrayList<>(); allHandlers.addAll(publicHandlers); allHandlers.addAll(privateHandlers); if (allHandlers.isEmpty()) { return false; } for (OkxChannelHandler h : allHandlers) { if (!h.isSubscribed()) { return false; } } return true; } // ==================== 心跳机制 ==================== /** * 启动心跳检测器。 * 使用单线程 ScheduledExecutor,每 25 秒检查一次心跳超时。 */ private void startHeartbeat() { if (heartbeatExecutor != null && !heartbeatExecutor.isTerminated()) { heartbeatExecutor.shutdownNow(); } heartbeatExecutor = Executors.newSingleThreadScheduledExecutor(r -> { Thread t = new Thread(r, "okx-ws-heartbeat"); t.setDaemon(true); return t; }); heartbeatExecutor.scheduleWithFixedDelay( this::checkHeartbeatTimeout, 25, 25, TimeUnit.SECONDS); } /** * 重置心跳计时器:取消旧超时任务,提交新的 10 秒超时检测。 */ private synchronized void resetHeartbeatTimer() { cancelPongTimeout(); if (heartbeatExecutor != null && !heartbeatExecutor.isShutdown()) { pongTimeoutFuture = heartbeatExecutor.schedule( this::checkHeartbeatTimeout, HEARTBEAT_TIMEOUT, TimeUnit.SECONDS); } } /** * 检查心跳超时:如果距离上次收到消息超过 10 秒,主动发送 ping。 * OKX 服务端收到 ping 后会回复 pong。 */ private void checkHeartbeatTimeout() { boolean isAnyConnected = isPublicConnected.get() || isPrivateConnected.get(); if (!isAnyConnected) { return; } long elapsed = System.currentTimeMillis() - lastMessageTime.get(); if (elapsed >= HEARTBEAT_TIMEOUT * 1000L) { log.debug("[OKX-WS] 心跳超时 {}ms, 主动发送ping", elapsed); sendPing(); } } /** * 向两条 WS 连接主动发送 ping(OKX 文档标准 JSON 格式)。 */ private void sendPing() { try { String pingMsg = "{\"op\":\"ping\"}"; if (publicWsClient != null && publicWsClient.isOpen()) { publicWsClient.send(pingMsg); } if (privateWsClient != null && privateWsClient.isOpen()) { privateWsClient.send(pingMsg); } } catch (Exception e) { log.warn("[OKX-WS] 发送ping失败", e); } } /** * 取消心跳超时检测任务。 */ private synchronized void cancelPongTimeout() { if (pongTimeoutFuture != null && !pongTimeoutFuture.isDone()) { pongTimeoutFuture.cancel(true); } } // ==================== 重连机制 ==================== /** * 指数退避重连。初始延迟 5 秒,每次翻倍,最多重试 3 次。 * * @param isPrivate true=重连私有 WS,false=重连公开 WS * @throws InterruptedException 线程被中断 */ private void reconnectWithBackoff(boolean isPrivate) throws InterruptedException { String label = isPrivate ? "私有" : "公开"; int attempt = 0; long delayMs = INITIAL_RECONNECT_DELAY_MS; while (attempt < MAX_RECONNECT_ATTEMPTS) { try { Thread.sleep(delayMs); connect(isPrivate); log.info("[OKX-WS] {} WS第{}次重连成功", label, attempt + 1); return; } catch (Exception e) { log.warn("[OKX-WS] {} WS第{}次重连失败", label, attempt + 1, e); delayMs *= 2; attempt++; } } log.error("[OKX-WS] {} WS超过最大重试次数({}),放弃重连", label, MAX_RECONNECT_ATTEMPTS); } // ==================== 工具方法 ==================== /** * 优雅关闭线程池:先 shutdown,等待 5 秒,超时则 shutdownNow 强制中断。 * * @param executor 需要关闭的线程池 */ private void shutdownExecutorGracefully(ExecutorService executor) { if (executor == null || executor.isTerminated()) { return; } try { executor.shutdown(); if (!executor.awaitTermination(5, TimeUnit.SECONDS)) { executor.shutdownNow(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); executor.shutdownNow(); } } // ==================== 状态查询 ==================== /** @return 公开 WS 是否已连接 */ public boolean isPublicConnected() { return isPublicConnected.get(); } /** @return 私有 WS 是否已连接并登录成功 */ public boolean isPrivateConnected() { return isPrivateConnected.get() && isPrivateLoggedIn.get(); } } src/main/java/com/xcong/excoin/modules/okxApi/OkxTradeExecutor.java
New file @@ -0,0 +1,597 @@ package com.xcong.excoin.modules.okxApi; import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; import lombok.extern.slf4j.Slf4j; import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.io.IOException; import java.math.BigDecimal; import java.text.SimpleDateFormat; import java.util.Base64; import java.util.Date; import java.util.TimeZone; import java.util.concurrent.ExecutorService; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; /** * OKX REST API 异步执行器,所有下单/撤单操作经此类提交。 * * <h3>设计目的</h3> * REST API 调用可能耗时数百毫秒,若在 WebSocket 回调线程中同步执行会阻塞消息处理, * 导致心跳超时误判。本类将所有网络 I/O 提交到独立单线程池异步执行。 * * <h3>与 GateTradeExecutor 的主要差异</h3> * <ul> * <li>使用 OkHttp 直接调用 OKX REST API,而非 gateapi SDK</li> * <li>签名算法:HMAC-SHA256(Gate 使用 HMAC-SHA512)</li> * <li>认证头:OK-ACCESS-KEY / OK-ACCESS-SIGN / OK-ACCESS-TIMESTAMP / OK-ACCESS-PASSPHRASE</li> * <li>时间戳格式:ISO 8601(如 2023-01-01T00:00:00.000Z)</li> * <li>合约格式:ETH-USDT-SWAP(短横线分隔)</li> * </ul> * * <h3>线程模型</h3> * <ul> * <li><b>单线程 + 有界队列(64)</b> — 保证下单顺序,避免并发竞争</li> * <li><b>CallerRunsPolicy</b> — 队列满时由提交线程直接执行,形成自然背压</li> * <li><b>Daemon 线程</b> — 60s 空闲自动回收</li> * </ul> * * <h3>对外接口</h3> * <table> * <tr><th>方法</th><th>用途</th></tr> * <tr><td>openLong / openShort</td><td>市价基底开仓</td></tr> * <tr><td>placeConditionalEntryOrder</td><td>挂条件开仓单(价格触发后市价开仓)</td></tr> * <tr><td>placeTakeProfit</td><td>挂止盈条件单</td></tr> * <tr><td>cancelConditionalOrder</td><td>取消单个条件单</td></tr> * <tr><td>cancelAllPriceTriggeredOrders</td><td>取消所有条件单(策略停止时)</td></tr> * </table> * * <h3>容错</h3> * <ul> * <li>止盈单创建失败 → 立即 marketClose() 市价平仓</li> * <li>取消订单失败 → 仅 warn 日志(可能已成交/已取消)</li> * </ul> * * @author Administrator */ @Slf4j public class OkxTradeExecutor { /** JSON content-type */ private static final MediaType JSON_MEDIA_TYPE = MediaType.parse("application/json; charset=utf-8"); /** OKX 配置 */ private final OkxConfig config; /** 合约名称(如 ETH-USDT-SWAP) */ private final String contract; /** OKHttp 客户端 */ private final OkHttpClient httpClient; /** 交易线程池:单线程 + 有界队列 + 背压策略 */ private final ExecutorService executor; /** * 构造 OKX 交易执行器。 * * @param config OKX 配置对象(包含 API 密钥、合约、URL 等信息) */ public OkxTradeExecutor(OkxConfig config) { this.config = config; this.contract = config.getContract(); this.httpClient = new OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.SECONDS) .writeTimeout(10, TimeUnit.SECONDS) .build(); this.executor = new ThreadPoolExecutor( 1, 1, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(64), r -> { Thread t = new Thread(r, "okx-trade-worker"); t.setDaemon(true); return t; }, new ThreadPoolExecutor.CallerRunsPolicy() ); ((ThreadPoolExecutor) executor).allowCoreThreadTimeOut(true); } // ==================== 生命周期 ==================== /** * 优雅关闭:等待 10 秒让队列中的任务执行完毕,超时则强制中断。 * 关闭后的 REST 调用将通过 CallerRunsPolicy 直接在提交线程执行。 */ public void shutdown() { executor.shutdown(); try { executor.awaitTermination(10, TimeUnit.SECONDS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); executor.shutdownNow(); } } /** * 提交一个通用任务到交易线程池末尾。 * 利用单线程池的 FIFO 特性确保任务按提交顺序执行。 * * @param task 待执行的任务 */ public void submitTask(Runnable task) { executor.execute(task); } // ==================== 市价开仓 ==================== /** * 异步市价开多。 * * @param quantity 开仓张数(正数,如 "15") * @param onSuccess 成交成功回调,接收 ordId(可为 null) * @param onFailure 成交失败回调(可为 null) */ public void openLong(String quantity, Consumer<String> onSuccess, Runnable onFailure) { openPosition(quantity, "buy", "开多", onSuccess, onFailure); } /** * 异步市价开空。 * * @param quantity 开仓张数(正数,如 "15") * @param onSuccess 成交成功回调,接收 ordId(可为 null) * @param onFailure 成交失败回调(可为 null) */ public void openShort(String quantity, Consumer<String> onSuccess, Runnable onFailure) { openPosition(quantity, "sell", "开空", onSuccess, onFailure); } /** * 通用异步市价下单。 * * @param sz 下单张数 * @param side 交易方向(buy=开多 / sell=开空) * @param label 日志标签 * @param onSuccess 成功回调,接收 ordId * @param onFailure 失败回调 */ private void openPosition(String sz, String side, String label, Consumer<String> onSuccess, Runnable onFailure) { executor.execute(() -> { try { // long_short_mode 双向持仓下,开仓须指定 posSide String posSide = "buy".equals(side) ? "long" : "short"; JSONObject body = new JSONObject(); body.put("instId", contract); body.put("tdMode", "cross"); body.put("side", side); body.put("posSide", posSide); body.put("ordType", "market"); body.put("sz", sz); JSONObject resp = okPost("/api/v5/trade/order", body.toJSONString()); String code = resp.getString("code"); if (!"0".equals(code)) { log.error("[TradeExec-OKX] {}失败, code:{}, msg:{}", label, code, resp.getString("msg")); if (onFailure != null) { onFailure.run(); } return; } JSONArray data = resp.getJSONArray("data"); String ordId = (data != null && !data.isEmpty()) ? data.getJSONObject(0).getString("ordId") : null; log.info("[TradeExec-OKX] {}成功, sz:{}, ordId:{}", label, sz, ordId); if (onSuccess != null) { onSuccess.accept(ordId); } } catch (Exception e) { log.error("[TradeExec-OKX] {}失败", label, e); if (onFailure != null) { onFailure.run(); } } }); } // ==================== 止盈条件单 ==================== /** * 异步创建止盈条件单(OKX 算法订单 — conditional 类型)。 * * <p>服务器监控价格,达到触发价后自动以市价平仓。 * 使用 OKX 的 {@code order-algo} 接口,ordType=conditional。 * * <h3>止盈失败兜底</h3> * 止盈单创建失败时立即调用 {@link #marketClose(String, String)} 市价平仓, * 确保仓位不会因止损条件未挂上而无保护。 * * @param triggerPrice 触发价格 * @param orderType 平仓类型:"close_long" 平多 / "close_short" 平空 * @param size 平仓张数(正数,如 "15") * @param onSuccess 成功回调,接收 algoId(可为 null) */ public void placeTakeProfit(BigDecimal triggerPrice, String orderType, String size, Consumer<String> onSuccess) { executor.execute(() -> { String posSide = null; try { String side; if ("close_long".equals(orderType)) { side = "sell"; posSide = "long"; } else if ("close_short".equals(orderType)) { side = "buy"; posSide = "short"; } else { log.error("[TradeExec-OKX] 未知止盈类型: {}", orderType); return; } // OKX conditional 止盈止损使用 tpTriggerPx/tpOrdPx 或 slTriggerPx/slOrdPx JSONObject body = new JSONObject(); body.put("instId", contract); body.put("tdMode", "cross"); body.put("side", side); body.put("posSide", posSide); body.put("ordType", "conditional"); body.put("sz", size); // "close_long"=平多(止盈), "close_short"=平空(止盈) body.put("tpTriggerPx", triggerPrice.stripTrailingZeros().toPlainString()); body.put("tpTriggerPxType", "last"); body.put("tpOrdPx", "-1"); JSONObject resp = okPost("/api/v5/trade/order-algo", body.toJSONString()); String code = resp.getString("code"); if (!"0".equals(code)) { log.error("[TradeExec-OKX] 止盈单创建失败, code:{}, msg:{}, 立即市价止盈", code, resp.getString("msg")); marketClose(size, posSide); return; } JSONArray data = resp.getJSONArray("data"); String algoId = (data != null && !data.isEmpty()) ? data.getJSONObject(0).getString("algoId") : null; log.info("[TradeExec-OKX] 止盈单已创建, triggerPx:{}, type:{}, sz:{}, algoId:{}", triggerPrice, orderType, size, algoId); if (onSuccess != null) { onSuccess.accept(algoId); } } catch (Exception e) { log.error("[TradeExec-OKX] 止盈单创建失败, triggerPx:{}, sz:{}, 立即市价止盈", triggerPrice, size, e); if (posSide != null) { marketClose(size, posSide); } } }); } /** * 市价止盈兜底:在止盈条件单创建失败时立即市价平仓。 * * <p>通过 posSide 指定平仓方向: * <ul> * <li>posSide=long:平多(side=sell)</li> * <li>posSide=short:平空(side=buy)</li> * </ul> * * @param size 平仓张数(正数) * @param posSide 持仓方向(long / short) */ private void marketClose(String size, String posSide) { String side = "long".equals(posSide) ? "sell" : "buy"; marketClose(size, side, posSide); } /** * 指定方向的市价平仓。 * * @param sz 平仓张数 * @param side 交易方向(sell=平多 / buy=平空) * @param posSide 持仓方向(long / short) */ private void marketClose(String sz, String side, String posSide) { try { JSONObject body = new JSONObject(); body.put("instId", contract); body.put("tdMode", "cross"); body.put("side", side); body.put("posSide", posSide); body.put("ordType", "market"); body.put("sz", sz); JSONObject resp = okPost("/api/v5/trade/order", body.toJSONString()); String code = resp.getString("code"); if (!"0".equals(code)) { log.warn("[TradeExec-OKX] 市价止盈失败, side:{}, posSide:{}, sz:{}, code:{}, msg:{}", side, posSide, sz, code, resp.getString("msg")); return; } JSONArray data = resp.getJSONArray("data"); String ordId = (data != null && !data.isEmpty()) ? data.getJSONObject(0).getString("ordId") : null; log.info("[TradeExec-OKX] 市价止盈成功, side:{}, posSide:{}, sz:{}, ordId:{}", side, posSide, sz, ordId); } catch (Exception e) { log.error("[TradeExec-OKX] 市价止盈也失败, sz:{}", sz, e); } } // ==================== 条件开仓单 ==================== /** * 异步创建条件开仓单(价格触发后市价开仓)。 * * <p>使用 OKX 的 {@code order-algo} 接口,ordType=trigger(计划委托)。 * 服务器监控价格,达到触发价后以市价开仓。 * * <h3>与止盈止损的区别</h3> * <ul> * <li>开仓 = ordType=trigger,字段 triggerPx + orderPx</li> * <li>止盈 = ordType=conditional,字段 tpTriggerPx + tpOrdPx</li> * <li>止损 = ordType=conditional,字段 slTriggerPx + slOrdPx</li> * </ul> * * @param triggerPrice 触发价格 * @param isLong true=开多(side=buy)/ false=开空(side=sell) * @param size 开仓张数(正数,如 "1") * @param onSuccess 成功回调,接收 algoId(可为 null) * @param onFailure 失败回调(可为 null) */ public void placeConditionalEntryOrder(BigDecimal triggerPrice, boolean isLong, String size, Consumer<String> onSuccess, Runnable onFailure) { executor.execute(() -> { try { String side = isLong ? "buy" : "sell"; JSONObject body = new JSONObject(); body.put("instId", contract); body.put("tdMode", "cross"); body.put("side", side); body.put("ordType", "trigger"); // 计划委托 = 触发后开仓 body.put("sz", size); body.put("triggerPx", triggerPrice.stripTrailingZeros().toPlainString()); body.put("triggerPxType", "last"); body.put("orderPx", "-1"); // OKX 使用 orderPx,非 ordPx JSONObject resp = okPost("/api/v5/trade/order-algo", body.toJSONString()); String code = resp.getString("code"); if (!"0".equals(code)) { log.error("[TradeExec-OKX] 条件开仓单创建失败, code:{}, msg:{}", code, resp.getString("msg")); if (onFailure != null) { onFailure.run(); } return; } JSONArray data = resp.getJSONArray("data"); String algoId = (data != null && !data.isEmpty()) ? data.getJSONObject(0).getString("algoId") : null; log.info("[TradeExec-OKX] 条件开仓单已创建, triggerPx:{}, isLong:{}, sz:{}, algoId:{}", triggerPrice, isLong, size, algoId); if (onSuccess != null) { onSuccess.accept(algoId); } } catch (Exception e) { log.error("[TradeExec-OKX] 条件开仓单创建失败, triggerPx:{}, sz:{}", triggerPrice, size, e); if (onFailure != null) { onFailure.run(); } } }); } // ==================== 取消订单 ==================== /** * 异步取消单个算法订单(条件单)。 * * @param algoId 算法订单 ID,为 null 时跳过 * @param onSuccess 成功回调,接收 algoId(可为 null) */ public void cancelConditionalOrder(String algoId, Consumer<String> onSuccess) { if (algoId == null) { return; } executor.execute(() -> { try { JSONArray bodyArr = new JSONArray(); JSONObject item = new JSONObject(); item.put("algoId", algoId); item.put("instId", contract); bodyArr.add(item); JSONObject resp = okPost("/api/v5/trade/cancel-algos", bodyArr.toJSONString()); String code = resp.getString("code"); if (!"0".equals(code)) { log.warn("[TradeExec-OKX] 取消条件单失败(可能已触发), algoId:{}, code:{}, msg:{}", algoId, code, resp.getString("msg")); return; } log.info("[TradeExec-OKX] 条件单已取消, algoId:{}", algoId); if (onSuccess != null) { onSuccess.accept(algoId); } } catch (Exception e) { log.warn("[TradeExec-OKX] 取消条件单失败(可能已触发), algoId:{}", algoId, e); } }); } /** * 异步清除指定合约的所有算法订单(条件单)。 * 发送不含 algoId 的取消请求,OKX 会取消该合约下所有待触发算法单。 */ public void cancelAllPriceTriggeredOrders() { executor.execute(() -> { try { JSONArray bodyArr = new JSONArray(); JSONObject item = new JSONObject(); item.put("instId", contract); bodyArr.add(item); JSONObject resp = okPost("/api/v5/trade/cancel-algos", bodyArr.toJSONString()); String code = resp.getString("code"); if (!"0".equals(code)) { log.warn("[TradeExec-OKX] 清除所有条件单失败, code:{}, msg:{}", code, resp.getString("msg")); return; } log.info("[TradeExec-OKX] 已清除所有条件单"); } catch (Exception e) { log.error("[TradeExec-OKX] 清除条件单失败", e); } }); } // ==================== HTTP 请求帮助方法 ==================== /** * 发送 OKX 签名 POST 请求并返回解析后的 JSONObject。 * * <p>自动添加 OK-ACCESS-KEY、OK-ACCESS-SIGN、OK-ACCESS-TIMESTAMP、OK-ACCESS-PASSPHRASE * 四个认证头。签名算法:base64(HMAC-SHA256(timestamp + method + path + body))。 * * @param path API 路径(如 /api/v5/trade/order) * @param body 请求体 JSON 字符串 * @return 解析后的响应 JSONObject * @throws IOException 网络异常或业务错误 */ JSONObject okPost(String path, String body) throws IOException { String method = "POST"; String timestamp = getIsoTimestamp(); String sign = null; try { sign = sign(timestamp, method, path, body); } catch (Exception e) { e.printStackTrace(); } Request.Builder builder = new Request.Builder() .url(config.getRestBasePath() + path) .header("OK-ACCESS-KEY", config.getApiKey()) .header("OK-ACCESS-SIGN", sign) .header("OK-ACCESS-TIMESTAMP", timestamp) .header("OK-ACCESS-PASSPHRASE", config.getPassphrase()) .header("Content-Type", "application/json; charset=utf-8") .post(RequestBody.create(JSON_MEDIA_TYPE, body)); // 模拟盘需加 x-simulated-trading 头,与生产网共用同一 REST 地址 if (!config.isProduction()) { builder.header("x-simulated-trading", "1"); } Request request = builder.build(); try (Response response = httpClient.newCall(request).execute()) { String responseBody = response.body() != null ? response.body().string() : "{}"; if (!response.isSuccessful()) { log.error("[TradeExec-OKX] HTTP {} POST {}: {}", response.code(), path, responseBody); throw new IOException("HTTP " + response.code() + ": " + responseBody); } return JSON.parseObject(responseBody); } } /** * 发送 OKX 签名 GET 请求并返回解析后的 JSONObject。 * * <p>GET 请求的签名中 body 为空字符串。 * * @param path API 路径(如 /api/v5/account/positions) * @return 解析后的响应 JSONObject * @throws IOException 网络异常 */ JSONObject okGet(String path) throws IOException { String method = "GET"; String timestamp = getIsoTimestamp(); String sign = null; try { sign = sign(timestamp, method, path, ""); } catch (Exception e) { e.printStackTrace(); } Request.Builder builder = new Request.Builder() .url(config.getRestBasePath() + path) .header("OK-ACCESS-KEY", config.getApiKey()) .header("OK-ACCESS-SIGN", sign) .header("OK-ACCESS-TIMESTAMP", timestamp) .header("OK-ACCESS-PASSPHRASE", config.getPassphrase()) .get(); // 模拟盘需加 x-simulated-trading 头 if (!config.isProduction()) { builder.header("x-simulated-trading", "1"); } Request request = builder.build(); try (Response response = httpClient.newCall(request).execute()) { String responseBody = response.body() != null ? response.body().string() : "{}"; if (!response.isSuccessful()) { log.error("[TradeExec-OKX] HTTP {} GET {}: {}", response.code(), path, responseBody); throw new IOException("HTTP " + response.code() + ": " + responseBody); } return JSON.parseObject(responseBody); } } // ==================== 签名工具方法 ==================== /** * 生成 OKX API 签名。 * * <p>签名算法: * <ol> * <li>拼接签名字符串:{@code timestamp + method + path + body}</li> * <li>使用 apiSecret 对签名字符串做 HMAC-SHA256</li> * <li>Base64 编码</li> * </ol> * * @param timestamp OKX 格式时间戳(ISO 8601) * @param method HTTP 方法(GET/POST) * @param path API 路径(如 /api/v5/trade/order) * @param body 请求体(GET 请求传 "") * @return Base64 编码的签名字符串 * @throws Exception 签名计算异常 */ private String sign(String timestamp, String method, String path, String body) throws Exception { String signString = timestamp + method + path + body; Mac sha256Hmac = Mac.getInstance("HmacSHA256"); SecretKeySpec secretKey = new SecretKeySpec(config.getApiSecret().getBytes(), "HmacSHA256"); sha256Hmac.init(secretKey); byte[] signedBytes = sha256Hmac.doFinal(signString.getBytes()); return Base64.getEncoder().encodeToString(signedBytes); } /** * 获取 OKX 格式的 ISO 8601 时间戳。 * * <p>格式示例:{@code 2023-01-01T00:00:00.000Z} * * @return ISO 8601 格式的 UTC 时间戳字符串 */ private String getIsoTimestamp() { SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"); sdf.setTimeZone(TimeZone.getTimeZone("UTC")); return sdf.format(new Date()); } } src/main/java/com/xcong/excoin/modules/okxApi/OkxWebSocketClientMain.java
New file @@ -0,0 +1,63 @@ package com.xcong.excoin.modules.okxApi; import com.xcong.excoin.modules.okxApi.wsHandler.handler.MarkPriceOkxChannelHandler; import com.xcong.excoin.modules.okxApi.wsHandler.handler.OrderAlgoOkxChannelHandler; import com.xcong.excoin.modules.okxApi.wsHandler.handler.PositionsOkxChannelHandler; import java.math.BigDecimal; import java.util.concurrent.CountDownLatch; /** * OKX 网格策略独立启动入口(用于测试或非 Spring 环境)。 * * @author Administrator */ public class OkxWebSocketClientMain { public static void main(String[] args) throws InterruptedException { OkxConfig config = OkxConfig.builder() .apiKey("YOUR_OKX_API_KEY") .apiSecret("YOUR_OKX_API_SECRET") .passphrase("YOUR_OKX_PASSPHRASE") .contract("ETH-USDT-SWAP") .leverage("100") .marginMode("cross") .positionMode("long_short_mode") .gridRate(new BigDecimal("0.003")) .expectedProfit(new BigDecimal("25")) .maxLoss(new BigDecimal("15")) .baseQuantity("15") .quantity("15") .restartGridSpan(6) .priceScale(2) .contractMultiplier(new BigDecimal("0.01")) .isProduction(false) .build(); OkxGridTradeService gridTradeService = new OkxGridTradeService(config); gridTradeService.init(); OkxKlineWebSocketClient wsClient = new OkxKlineWebSocketClient(config); wsClient.addPublicHandler(new MarkPriceOkxChannelHandler( config.getContract(), gridTradeService)); wsClient.addPrivateHandler(new PositionsOkxChannelHandler( config, gridTradeService)); wsClient.addPrivateHandler(new OrderAlgoOkxChannelHandler( config, gridTradeService)); gridTradeService.setWsClient(wsClient); wsClient.init(); gridTradeService.startGrid(); // 保持主线程不退出 CountDownLatch latch = new CountDownLatch(1); Runtime.getRuntime().addShutdownHook(new Thread(() -> { gridTradeService.stopGrid(); wsClient.destroy(); latch.countDown(); })); latch.await(); } } src/main/java/com/xcong/excoin/modules/okxApi/OkxWebSocketClientManager.java
New file @@ -0,0 +1,117 @@ package com.xcong.excoin.modules.okxApi; import com.xcong.excoin.modules.okxApi.wsHandler.handler.MarkPriceOkxChannelHandler; import com.xcong.excoin.modules.okxApi.wsHandler.handler.OrderAlgoOkxChannelHandler; import com.xcong.excoin.modules.okxApi.wsHandler.handler.PositionsOkxChannelHandler; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; import java.math.BigDecimal; /** * OKX 模块 Spring 容器入口 — 组件组装 + 生命周期管理。 * * <h3>组装顺序({@code @PostConstruct})</h3> * <ol> * <li>{@link OkxConfig} — 构建配置(API 密钥、合约、策略参数)</li> * <li>{@link OkxGridTradeService} — init():切双向持仓 → 清旧条件单 → 平仓 → 设杠杆</li> * <li>{@link OkxKlineWebSocketClient} — 注册 3 个频道处理器 → init():建立 WS 连接并订阅</li> * <li>{@code gridTradeService.startGrid()} — 状态重置,等待首根 K 线</li> * </ol> * * <h3>3 个频道处理器</h3> * <ol> * <li>MarkPriceOkxChannelHandler — 公开频道,标记价格 → onKline() + setMarkPrice()</li> * <li>PositionsOkxChannelHandler — 私有频道,仓位 → onPositionUpdate()</li> * <li>OrderAlgoOkxChannelHandler — 私有频道,条件单状态 → onAutoOrder()</li> * </ol> * * <h3>销毁顺序({@code @PreDestroy})</h3> * <ol> * <li>gridTradeService.stopGrid():取消所有条件单 → 关闭交易线程池</li> * <li>wsClient.destroy():取消订阅 → 断开 WS → 关闭线程池</li> * </ol> * * @author Administrator */ @Slf4j @Component public class OkxWebSocketClientManager { private OkxKlineWebSocketClient wsClient; private OkxGridTradeService gridTradeService; private OkxConfig config; @PostConstruct public void init() { log.info("[OKX管理器] 开始初始化..."); try { // TODO: 替换为实际 API 密钥 config = OkxConfig.builder() .apiKey("ac76252d-e717-4459-a6f9-80512aed5ea0") .apiSecret("A8168543EF4F08A6DBFE27AB23956898") .passphrase("Aa12345678@") .contract("ETH-USDT-SWAP") .leverage("100") .marginMode("cross") .positionMode("long_short_mode") .gridRate(new BigDecimal("0.003")) .expectedProfit(new BigDecimal("25")) .maxLoss(new BigDecimal("15")) .baseQuantity("15") .quantity("15") .restartGridSpan(6) .maxPositionSize(2) .priceScale(2) .contractMultiplier(new BigDecimal("0.01")) .unrealizedPnlPriceMode(OkxConfig.PnLPriceMode.LAST_PRICE) .isProduction(true) .reopenMaxRetries(3) .build(); // 1. 初始化交易服务 gridTradeService = new OkxGridTradeService(config); gridTradeService.init(); // 2. 创建 WS 客户端并注册频道处理器 wsClient = new OkxKlineWebSocketClient(config); // 公开频道:标记价格(替代 K 线,同时驱动策略 onKline 和 PnL 计算) wsClient.addPublicHandler(new MarkPriceOkxChannelHandler( config.getContract(), gridTradeService)); // 私有频道:仓位 wsClient.addPrivateHandler(new PositionsOkxChannelHandler( config, gridTradeService)); // 私有频道:条件单 wsClient.addPrivateHandler(new OrderAlgoOkxChannelHandler( config, gridTradeService)); gridTradeService.setWsClient(wsClient); wsClient.init(); log.info("[OKX管理器] WS已连接, 已注册 3 个频道处理器"); // 3. 激活策略 gridTradeService.startGrid(); } catch (Exception e) { log.error("[OKX管理器] 初始化失败", e); } } @PreDestroy public void destroy() { log.info("[OKX管理器] 开始销毁..."); if (gridTradeService != null) { gridTradeService.stopGrid(); } if (wsClient != null) { wsClient.destroy(); } log.info("[OKX管理器] 销毁完成"); } public OkxKlineWebSocketClient getKlineWebSocketClient() { return wsClient; } public OkxGridTradeService getGridTradeService() { return gridTradeService; } } src/main/java/com/xcong/excoin/modules/okxApi/TraderParam.java
File was renamed from src/main/java/com/xcong/excoin/modules/gateApi/TraderParam.java @@ -1,4 +1,4 @@ package com.xcong.excoin.modules.gateApi; package com.xcong.excoin.modules.okxApi; import java.math.BigDecimal; src/main/java/com/xcong/excoin/modules/okxApi/wsHandler/AbstractOkxPrivateChannelHandler.java
New file @@ -0,0 +1,160 @@ package com.xcong.excoin.modules.okxApi.wsHandler; import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; import com.xcong.excoin.modules.okxApi.OkxGridTradeService; import lombok.extern.slf4j.Slf4j; import org.java_websocket.client.WebSocketClient; /** * OKX 私有频道 WS 处理器的抽象基类。 * * <h3>与 Gate 版本的关键区别</h3> * <ul> * <li>OKX 私有频道连接到<b>独立的私有 WebSocket 端点</b> ({@code wss://ws.okx.com:8443/ws/v5/private}), * 需要先发送 login 消息认证,不同于 Gate 的单一 WS + 签名订阅模式。</li> * <li>订阅格式使用 {@code op/subscribe} 而非 event/subscribe。</li> * <li>签名算法使用 HMAC-SHA256 + Base64,而非 Gate 的 HMAC-SHA512 + Hex。</li> * </ul> * * <h3>架构</h3> * 公有频道(如 k-line)连接到 public WS 端点,无需认证。 * 私有频道(如 positions、orders-algo)连接到 private WS 端点,由 * {@link com.xcong.excoin.modules.okxApi.OkxKlineWebSocketClient} 负责 * 在连接建立时发送 login 消息认证。 * * <h3>订阅格式</h3> * <pre> * {"op":"subscribe","args":[{"channel":"positions","instType":"SWAP"}]} * {"op":"subscribe","args":[{"channel":"orders-algo","instType":"SWAP","instId":"ETH-USDT-SWAP"}]} * </pre> * * <h3>取消订阅格式</h3> * <pre> * {"op":"unsubscribe","args":[{"channel":"positions","instType":"SWAP"}]} * </pre> * * @author Administrator */ @Slf4j public abstract class AbstractOkxPrivateChannelHandler implements OkxChannelHandler { /** 频道名称,如 "positions"、"orders-algo" */ private final String channelName; /** OKX API Key */ protected final String apiKey; /** OKX API Secret(用于签名) */ protected final String apiSecret; /** OKX API Passphrase */ protected final String passphrase; /** 交易对标识,如 "ETH-USDT-SWAP" */ private final String instId; /** 网格交易服务实例 */ private final OkxGridTradeService gridTradeService; /** 订阅确认状态 */ private volatile boolean subscribed = false; /** * 构造私有频道处理器。 * * @param channelName 频道名称(如 "positions"、"orders-algo") * @param apiKey OKX API Key * @param apiSecret OKX API Secret * @param passphrase OKX API Passphrase * @param instId 交易对标识(如 "ETH-USDT-SWAP") * @param gridTradeService 网格交易服务实例 */ public AbstractOkxPrivateChannelHandler(String channelName, String apiKey, String apiSecret, String passphrase, String instId, OkxGridTradeService gridTradeService) { this.channelName = channelName; this.apiKey = apiKey; this.apiSecret = apiSecret; this.passphrase = passphrase; this.instId = instId; this.gridTradeService = gridTradeService; } /** * @return 频道名称(如 "positions") */ @Override public String getChannelName() { return channelName; } /** * @return 交易对标识(如 "ETH-USDT-SWAP") */ @Override public String getInstId() { return instId; } /** * 发送订阅请求。 * 默认实现发送 {@code {"op":"subscribe","args":[{"channel":channelName,"instType":"SWAP"}]}}。 * 子类可覆盖以添加额外参数(如 instId)。 * * @param ws 私有频道 WebSocket 客户端 */ @Override public void subscribe(WebSocketClient ws) { JSONObject msg = new JSONObject(); msg.put("op", "subscribe"); JSONArray args = new JSONArray(); JSONObject arg = new JSONObject(); arg.put("channel", channelName); arg.put("instType", "SWAP"); args.add(arg); msg.put("args", args); ws.send(msg.toJSONString()); log.info("[OKX-WS] 订阅私有频道: {}, instType: SWAP", channelName); } /** * 发送取消订阅请求。 * * @param ws 私有频道 WebSocket 客户端 */ @Override public void unsubscribe(WebSocketClient ws) { JSONObject msg = new JSONObject(); msg.put("op", "unsubscribe"); JSONArray args = new JSONArray(); JSONObject arg = new JSONObject(); arg.put("channel", channelName); arg.put("instType", "SWAP"); args.add(arg); msg.put("args", args); ws.send(msg.toJSONString()); log.info("[OKX-WS] 取消订阅私有频道: {}", channelName); } /** * @return 网格交易服务实例 */ protected OkxGridTradeService getGridTradeService() { return gridTradeService; } // ==================== 订阅状态 ==================== @Override public boolean isSubscribed() { return subscribed; } @Override public void setSubscribed(boolean subscribed) { this.subscribed = subscribed; } } src/main/java/com/xcong/excoin/modules/okxApi/wsHandler/OkxChannelHandler.java
New file @@ -0,0 +1,102 @@ package com.xcong.excoin.modules.okxApi.wsHandler; import com.alibaba.fastjson.JSONObject; import org.java_websocket.client.WebSocketClient; /** * OKX WebSocket 频道处理器接口。 * * <h3>定位</h3> * 每个 OKX 频道对应一个实现类。新增频道只需实现此接口, * 然后通过 {@code OkxKlineWebSocketClient.addPublicHandler()} 或 * {@code addPrivateHandler()} 注册即可。 * * <h3>与 Gate 版本的区别</h3> * OKX 的公开频道和私有频道使用不同的 WebSocket 端点(public/private), * 订阅格式为 {@code {"op":"subscribe","args":[{"channel":"candle1m","instId":"ETH-USDT-SWAP"}]}}, * 与 Gate 的 {@code event/payload} 格式不同。 * * <h3>实现类</h3> * <ul> * <li>{@code CandlestickOkxChannelHandler} — 公开频道,K线数据</li> * <li>{@code AbstractOkxPrivateChannelHandler} — 私有频道抽象基类(separate WS + login auth)</li> * <li>{@code PositionsOkxChannelHandler} — 私有频道,仓位更新</li> * <li>{@code OrderAlgoOkxChannelHandler} — 私有频道,条件订单(algo)状态推送</li> * </ul> * * <h3>OKX 订阅确认格式</h3> * {@code {"event":"subscribe","arg":{"channel":"candle1m"}}} 表示订阅成功。 * * <h3>OKX 数据推送格式</h3> * {@code {"arg":{"channel":"positions","instType":"SWAP"},"data":[...]}}。 * * @author Administrator */ public interface OkxChannelHandler { /** * 频道名称,如 {@code "candle1m"}、{@code "positions"}、{@code "orders-algo"}。 * * @return OKX 频道标识字符串 */ String getChannelName(); /** * 交易对标识,如 {@code "ETH-USDT-SWAP"}。 * OKX 订阅需要 instId 参数来指定订阅的交易对。 * * @return OKX 格式的交易对标识 */ String getInstId(); /** * 发送订阅请求到指定的 WebSocket 连接。 * * <h3>公开频道格式</h3> * <pre> * {"op":"subscribe","args":[{"channel":"candle1m","instId":"ETH-USDT-SWAP"}]} * </pre> * * <h3>私有频道格式</h3> * <pre> * {"op":"subscribe","args":[{"channel":"positions","instType":"SWAP"}]} * </pre> * * @param ws 目标 WebSocket 客户端(公开或私有端点) */ void subscribe(WebSocketClient ws); /** * 发送取消订阅请求到指定的 WebSocket 连接。 * * @param ws 目标 WebSocket 客户端 */ void unsubscribe(WebSocketClient ws); /** * 处理频道推送消息。 * * <h3>路由规则</h3> * 如果消息的 {@code arg.channel} 匹配当前处理器的频道名, * 则提取 {@code data} 数组并处理业务逻辑,返回 {@code true}; * 否则返回 {@code false}(让路由器继续遍历其他 handler)。 * * @param response WebSocket 推送的完整 JSON * @return true 表示已处理(循环停止),false 表示频道不匹配(继续遍历下一个 handler) */ boolean handleMessage(JSONObject response); /** * 是否已收到订阅成功确认(即收到 {@code event:"subscribe"} 响应)。 * * @return true 表示订阅已确认 */ boolean isSubscribed(); /** * 标记订阅已确认/未确认。 * * @param subscribed true=已确认,false=未确认 */ void setSubscribed(boolean subscribed); } src/main/java/com/xcong/excoin/modules/okxApi/wsHandler/handler/CandlestickOkxChannelHandler.java
New file @@ -0,0 +1,195 @@ package com.xcong.excoin.modules.okxApi.wsHandler.handler; import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; import com.xcong.excoin.modules.okxApi.OkxGridTradeService; import com.xcong.excoin.modules.okxApi.wsHandler.OkxChannelHandler; import lombok.extern.slf4j.Slf4j; import org.java_websocket.client.WebSocketClient; import java.math.BigDecimal; /** * OKX K线频道处理器(candle1m)— 策略的唯一价格时间驱动源。 * * <h3>定位</h3> * 这是一个<b>公开频道</b>,连接到 OKX 公开 WebSocket 端点, * 无需登录认证。订阅 1 分钟 K 线实时推送,每收到一根 K 线即触发 * {@link OkxGridTradeService#onKline(BigDecimal)},由策略引擎决定是否开仓/止盈。 * * <h3>订阅格式</h3> * <pre> * {"op":"subscribe","args":[{"channel":"candle1m","instId":"ETH-USDT-SWAP"}]} * </pre> * * <h3>数据推送格式</h3> * <pre> * { * "arg": {"channel":"candle1m","instId":"ETH-USDT-SWAP"}, * "data": [ * ["timestamp","open","high","low","close","vol","volCcy","volCcyQuote","confirm"] * ] * } * </pre> * * <h3>字段说明</h3> * <table> * <tr><th>索引</th><th>字段</th><th>含义</th></tr> * <tr><td>0</td><td>ts</td><td>K线起始时间(Unix ms)</td></tr> * <tr><td>1</td><td>o</td><td>开盘价</td></tr> * <tr><td>2</td><td>h</td><td>最高价</td></tr> * <tr><td>3</td><td>l</td><td>最低价</td></tr> * <tr><td>4</td><td>c</td><td><b>收盘价</b>(用于驱动策略)</td></tr> * <tr><td>5</td><td>vol</td><td>成交量(张)</td></tr> * <tr><td>6</td><td>volCcy</td><td>成交量(币)</td></tr> * <tr><td>7</td><td>volCcyQuote</td><td>成交量(USDT)</td></tr> * <tr><td>8</td><td>confirm</td><td>K线状态("0"=未完结,"1"=已完结)</td></tr> * </table> * * <h3>注意</h3> * 不判断 confirm 字段(K线是否完结)——策略需要 tick 级实时响应价格变动, * 而非等 1 分钟烛线完结后才行动。 * * @author Administrator */ @Slf4j public class CandlestickOkxChannelHandler implements OkxChannelHandler { /** 频道名称 */ private static final String CHANNEL_NAME = "candle1m"; /** 交易对标识,如 "ETH-USDT-SWAP" */ private final String instId; /** 网格交易服务,接收 K 线回调 */ private final OkxGridTradeService gridTradeService; /** 订阅确认状态 */ private volatile boolean subscribed = false; /** * 构造 K 线频道处理器。 * * @param instId 交易对标识(如 "ETH-USDT-SWAP") * @param gridTradeService OKX 网格交易策略服务实例 */ public CandlestickOkxChannelHandler(String instId, OkxGridTradeService gridTradeService) { this.instId = instId; this.gridTradeService = gridTradeService; } /** * @return 频道名称 "candle1m" */ @Override public String getChannelName() { return CHANNEL_NAME; } /** * @return 交易对标识(如 "ETH-USDT-SWAP") */ @Override public String getInstId() { return instId; } /** * 发送 K 线频道订阅请求(公开频道,无需签名)。 * * <h3>订阅格式</h3> * <pre> * {"op":"subscribe","args":[{"channel":"candle1m","instId":"ETH-USDT-SWAP"}]} * </pre> * * @param ws OKX 公开 WebSocket 客户端 */ @Override public void subscribe(WebSocketClient ws) { JSONObject msg = new JSONObject(); msg.put("op", "subscribe"); JSONArray args = new JSONArray(); JSONObject arg = new JSONObject(); arg.put("channel", CHANNEL_NAME); arg.put("instId", instId); args.add(arg); msg.put("args", args); ws.send(msg.toJSONString()); log.info("[OKX-WS] 订阅K线频道, instId: {}", instId); } /** * 发送 K 线频道取消订阅请求。 * * @param ws OKX 公开 WebSocket 客户端 */ @Override public void unsubscribe(WebSocketClient ws) { JSONObject msg = new JSONObject(); msg.put("op", "unsubscribe"); JSONArray args = new JSONArray(); JSONObject arg = new JSONObject(); arg.put("channel", CHANNEL_NAME); arg.put("instId", instId); args.add(arg); msg.put("args", args); ws.send(msg.toJSONString()); log.info("[OKX-WS] 取消订阅K线频道, instId: {}", instId); } /** * 处理 K 线推送消息。 * * <h3>数据提取</h3> * 从 {@code data[0]} 数组中提取索引 4(收盘价 close), * 传给 {@code gridTradeService.onKline(closePrice)}。 * * <h3>OKX 数据格式</h3> * data 是一个二维数组,第一层是 K 线条数(通常 1 条), * 第二层是各字段值。data[0][4] = 收盘价。 * * @param response WebSocket 推送的完整 JSON * @return true 表示已处理(匹配成功) */ @Override public boolean handleMessage(JSONObject response) { JSONObject arg = response.getJSONObject("arg"); if (arg == null || !CHANNEL_NAME.equals(arg.getString("channel"))) { return false; } try { JSONArray dataArray = response.getJSONArray("data"); if (dataArray == null || dataArray.isEmpty()) { log.debug("[OKX-WS] candle1m 数据为空"); return true; } // data[0] 是一个数组: [ts, o, h, l, c, vol, volCcy, volCcyQuote, confirm] JSONArray candle = dataArray.getJSONArray(0); if (candle == null || candle.size() < 5) { log.warn("[OKX-WS] candle1m 数据格式异常: {}", dataArray); return true; } // 索引 4 = 收盘价 close BigDecimal closePrice = candle.getBigDecimal(4); if (gridTradeService != null && closePrice != null) { gridTradeService.onKline(closePrice); } } catch (Exception e) { log.error("[OKX-WS] 处理 candle1m 数据失败", e); } return true; } // ==================== 订阅状态 ==================== @Override public boolean isSubscribed() { return subscribed; } @Override public void setSubscribed(boolean subscribed) { this.subscribed = subscribed; } } src/main/java/com/xcong/excoin/modules/okxApi/wsHandler/handler/MarkPriceOkxChannelHandler.java
New file @@ -0,0 +1,188 @@ package com.xcong.excoin.modules.okxApi.wsHandler.handler; import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; import com.xcong.excoin.modules.okxApi.OkxGridTradeService; import com.xcong.excoin.modules.okxApi.wsHandler.OkxChannelHandler; import lombok.extern.slf4j.Slf4j; import org.java_websocket.client.WebSocketClient; import java.math.BigDecimal; /** * OKX 标记价格频道处理器(mark-price)— 策略的价格驱动源。 * * <h3>定位</h3> * 这是一个<b>公开频道</b>,连接到 OKX 公开 WebSocket 端点,无需登录认证。 * 替代 candle1m K 线频道,作为策略唯一的实时价格来源。 * 标记价格变化时每 200ms 推送一次,无变化时每 10s 推送一次。 * * <h3>双回调</h3> * <ul> * <li>{@link OkxGridTradeService#onKline(BigDecimal)} — 驱动网格策略(处理开仓/止盈逻辑)</li> * <li>{@link OkxGridTradeService#setMarkPrice(BigDecimal)} — PnLPriceMode.MARK_PRICE 计算未实现盈亏</li> * </ul> * * <h3>订阅格式</h3> * <pre> * {"op":"subscribe","args":[{"channel":"mark-price","instId":"ETH-USDT-SWAP"}]} * </pre> * * <h3>数据推送格式</h3> * <pre> * { * "arg": {"channel":"mark-price","instId":"ETH-USDT-SWAP"}, * "data": [{ * "instType": "SWAP", * "instId": "ETH-USDT-SWAP", * "markPx": "42310.6", * "ts": "1630049139746" * }] * } * </pre> * * <h3>推送频率</h3> * <ul> * <li>标记价格变化 → 每 200ms 推送一次</li> * <li>标记价格无变化 → 每 10s 推送一次</li> * </ul> * * @author Administrator */ @Slf4j public class MarkPriceOkxChannelHandler implements OkxChannelHandler { /** 频道名称 */ private static final String CHANNEL_NAME = "mark-price"; /** 交易对标识,如 "ETH-USDT-SWAP" */ private final String instId; /** 网格交易服务,接收标记价格回调 */ private final OkxGridTradeService gridTradeService; /** 订阅确认状态 */ private volatile boolean subscribed = false; /** * 构造标记价格频道处理器。 * * @param instId 交易对标识(如 "ETH-USDT-SWAP") * @param gridTradeService OKX 网格交易策略服务实例 */ public MarkPriceOkxChannelHandler(String instId, OkxGridTradeService gridTradeService) { this.instId = instId; this.gridTradeService = gridTradeService; } /** * @return 频道名称 "mark-price" */ @Override public String getChannelName() { return CHANNEL_NAME; } /** * @return 交易对标识(如 "ETH-USDT-SWAP") */ @Override public String getInstId() { return instId; } /** * 发送标记价格频道订阅请求(公开频道,无需签名)。 * * @param ws OKX 公开 WebSocket 客户端 */ @Override public void subscribe(WebSocketClient ws) { JSONObject msg = new JSONObject(); msg.put("op", "subscribe"); JSONArray args = new JSONArray(); JSONObject arg = new JSONObject(); arg.put("channel", CHANNEL_NAME); arg.put("instId", instId); args.add(arg); msg.put("args", args); ws.send(msg.toJSONString()); log.info("[OKX-WS] 订阅标记价格频道, instId: {}", instId); } /** * 发送标记价格频道取消订阅请求。 * * @param ws OKX 公开 WebSocket 客户端 */ @Override public void unsubscribe(WebSocketClient ws) { JSONObject msg = new JSONObject(); msg.put("op", "unsubscribe"); JSONArray args = new JSONArray(); JSONObject arg = new JSONObject(); arg.put("channel", CHANNEL_NAME); arg.put("instId", instId); args.add(arg); msg.put("args", args); ws.send(msg.toJSONString()); log.info("[OKX-WS] 取消订阅标记价格频道, instId: {}", instId); } /** * 处理标记价格推送消息。 * * <h3>数据提取</h3> * 从 {@code data[0].markPx} 提取标记价格,同时回调: * <ol> * <li>{@code gridTradeService.onKline(markPrice)} — 驱动网格策略</li> * <li>{@code gridTradeService.setMarkPrice(markPrice)} — PnL 计算用</li> * </ol> * * @param response WebSocket 推送的完整 JSON * @return true 表示已处理(匹配成功) */ @Override public boolean handleMessage(JSONObject response) { JSONObject arg = response.getJSONObject("arg"); if (arg == null || !CHANNEL_NAME.equals(arg.getString("channel"))) { return false; } try { JSONArray dataArray = response.getJSONArray("data"); if (dataArray == null || dataArray.isEmpty()) { return true; } // data[0] 是一个 JSONObject: {instType, instId, markPx, ts} JSONObject markData = dataArray.getJSONObject(0); if (markData == null) { return true; } String markPxStr = markData.getString("markPx"); if (markPxStr == null || markPxStr.isEmpty()) { return true; } BigDecimal markPrice = new BigDecimal(markPxStr); if (gridTradeService != null) { gridTradeService.setMarkPrice(markPrice); gridTradeService.onKline(markPrice); } } catch (Exception e) { log.error("[OKX-WS] 处理 mark-price 数据失败", e); } return true; } // ==================== 订阅状态 ==================== @Override public boolean isSubscribed() { return subscribed; } @Override public void setSubscribed(boolean subscribed) { this.subscribed = subscribed; } } src/main/java/com/xcong/excoin/modules/okxApi/wsHandler/handler/OrderAlgoOkxChannelHandler.java
New file @@ -0,0 +1,230 @@ package com.xcong.excoin.modules.okxApi.wsHandler.handler; import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; import com.xcong.excoin.modules.okxApi.OkxConfig; import com.xcong.excoin.modules.okxApi.OkxGridTradeService; import com.xcong.excoin.modules.okxApi.wsHandler.AbstractOkxPrivateChannelHandler; import lombok.extern.slf4j.Slf4j; import org.java_websocket.client.WebSocketClient; /** * OKX 条件订单频道处理器(orders-algo)。 * * <h3>定位</h3> * 订阅用户条件订单(algo order)状态推送。当条件单状态变更(触发、取消等)时, * 获得 algoId、状态、订单类型等信息。相当于 Gate 的 {@code futures.autoorders} 频道。 * * <h3>订阅格式</h3> * 私有频道,需要先登录认证。订阅时需要指定 instType 和 instId: * <pre> * {"op":"subscribe","args":[{"channel":"orders-algo","instType":"SWAP","instId":"ETH-USDT-SWAP"}]} * </pre> * * <h3>数据推送格式(条件单触发/成交)</h3> * <pre> * { * "arg": {"channel":"orders-algo","instType":"SWAP","instId":"ETH-USDT-SWAP"}, * "data": [{ * "algoId": "1234567890", * "state": "effective", // 状态: "effective"=已触发, "canceled"=已取消 * "ordType": "conditional", // 订单类型 * "actualSide": "buy", // 实际买卖方向: "buy"/"sell" * "posSide": "long", // 持仓方向: "long"/"short"/"net" * "sz": "1", // 委托数量 * "triggerPx": "3000", // 触发价格 * "ordPx": "3000.5", // 委托价格 * "tradeId": "9876543210" // 关联交易ID * }] * } * </pre> * * <h3>状态映射</h3> * <ul> * <li>"effective" → status="finished"(已触发/已完成)</li> * <li>"canceled" → status="cancelled"(已取消)</li> * </ul> * * <h3>订单类型映射(orderType)</h3> * 根据 posSide 和 actualSide 组合推断订单类型: * <ul> * <li>posSide=long, actualSide=sell → "plan-close-long-position"(平多仓)</li> * <li>posSide=short, actualSide=buy → "plan-close-short-position"(平空仓)</li> * <li>posSide=net, actualSide=buy → "entry-long"(开多仓)</li> * <li>posSide=net, actualSide=sell → "entry-short"(开空仓)</li> * </ul> * * @author Administrator */ @Slf4j public class OrderAlgoOkxChannelHandler extends AbstractOkxPrivateChannelHandler { /** OKX 条件订单频道名称 */ private static final String CHANNEL_NAME = "orders-algo"; /** OKX 配置 */ private final OkxConfig config; /** * 构造条件订单频道处理器。 * * @param config OKX 配置实例(提供合约名称等) * @param gridTradeService OKX 网格交易策略服务实例 */ public OrderAlgoOkxChannelHandler(OkxConfig config, OkxGridTradeService gridTradeService) { super(CHANNEL_NAME, config.getApiKey(), config.getApiSecret(), config.getPassphrase(), config.getContract(), gridTradeService); this.config = config; } /** * 发送订阅请求,需指定 instType 和 instId。 * * @param ws 私有频道 WebSocket 客户端 */ @Override public void subscribe(WebSocketClient ws) { JSONObject msg = new JSONObject(); msg.put("op", "subscribe"); JSONArray args = new JSONArray(); JSONObject arg = new JSONObject(); arg.put("channel", CHANNEL_NAME); arg.put("instType", "SWAP"); arg.put("instId", getInstId()); args.add(arg); msg.put("args", args); ws.send(msg.toJSONString()); log.info("[OKX-WS] 订阅条件订单频道, instId: {}", getInstId()); } /** * 发送取消订阅请求。 * * @param ws 私有频道 WebSocket 客户端 */ @Override public void unsubscribe(WebSocketClient ws) { JSONObject msg = new JSONObject(); msg.put("op", "unsubscribe"); JSONArray args = new JSONArray(); JSONObject arg = new JSONObject(); arg.put("channel", CHANNEL_NAME); arg.put("instType", "SWAP"); arg.put("instId", getInstId()); args.add(arg); msg.put("args", args); ws.send(msg.toJSONString()); log.info("[OKX-WS] 取消订阅条件订单频道, instId: {}", getInstId()); } /** * 处理条件订单推送消息。 * * <h3>处理流程</h3> * <ol> * <li>检查 arg.channel 是否匹配 "orders-algo"</li> * <li>遍历 data 数组,提取 algoId、state、actualSide、posSide、tradeId</li> * <li>映射 state → status(effective→finished, canceled→cancelled)</li> * <li>根据 posSide + actualSide 推断 orderType</li> * <li>调用 gridTradeService.onAutoOrder(algoId, status, reason, orderType, tradeId)</li> * </ol> * * @param response WebSocket 推送的完整 JSON * @return true 表示已处理(匹配成功) */ @Override public boolean handleMessage(JSONObject response) { JSONObject arg = response.getJSONObject("arg"); if (arg == null || !CHANNEL_NAME.equals(arg.getString("channel"))) { return false; } try { JSONArray dataArray = response.getJSONArray("data"); if (dataArray == null || dataArray.isEmpty()) { return true; } String contract = config.getContract(); for (int i = 0; i < dataArray.size(); i++) { JSONObject orderData = dataArray.getJSONObject(i); // 按 instId 过滤 String dataInstId = orderData.getString("instId"); String instId = arg.getString("instId"); if (instId != null && dataInstId != null && !instId.equals(dataInstId) && !dataInstId.startsWith(contract)) { continue; } String algoId = orderData.getString("algoId"); String state = orderData.getString("state"); String actualSide = orderData.getString("actualSide"); String posSide = orderData.getString("posSide"); String tradeId = orderData.getString("tradeId"); String ordType = orderData.getString("ordType"); // 状态映射 String status; if ("effective".equals(state)) { status = "finished"; } else if ("canceled".equals(state)) { status = "cancelled"; } else { // 其他状态(如 "pending")暂不处理 log.debug("[OKX-WS] orders-algo 忽略状态: algoId:{}, state:{}", algoId, state); continue; } // 推断 orderType String orderType = mapOrderType(posSide, actualSide); log.info("[OKX-WS] orders-algo 状态变更, algoId:{}, state:{}, status:{}, orderType:{}, ordType:{}, actualSide:{}, posSide:{}, tradeId:{}", algoId, state, status, orderType, ordType, actualSide, posSide, tradeId); if (getGridTradeService() != null) { getGridTradeService().onAutoOrder(algoId, status, state, orderType, tradeId); } } } catch (Exception e) { log.error("[OKX-WS] 处理 orders-algo 数据失败", e); } return true; } /** * 根据 OKX 的 posSide 和 actualSide 映射到策略内部的 orderType。 * * <h3>映射规则</h3> * <table> * <tr><th>posSide</th><th>actualSide</th><th>含义</th><th>orderType</th></tr> * <tr><td>long</td><td>sell</td><td>平多仓</td><td>plan-close-long-position</td></tr> * <tr><td>short</td><td>buy</td><td>平空仓</td><td>plan-close-short-position</td></tr> * <tr><td>net</td><td>buy</td><td>开多仓</td><td>entry-long</td></tr> * <tr><td>net</td><td>sell</td><td>开空仓</td><td>entry-short</td></tr> * </table> * * @param posSide OKX 持仓方向("long"/"short"/"net") * @param actualSide OKX 实际买卖方向("buy"/"sell") * @return 策略内部的订单类型字符串 */ private String mapOrderType(String posSide, String actualSide) { // 平仓方向(止盈/止损触发) if ("long".equals(posSide) && "sell".equals(actualSide)) { return "plan-close-long-position"; } else if ("short".equals(posSide) && "buy".equals(actualSide)) { return "plan-close-short-position"; } // 开仓方向 — 覆盖 long_short_mode 和 net_mode 两种模式 if (("long".equals(posSide) || "net".equals(posSide)) && "buy".equals(actualSide)) { return "entry-long"; } else if (("short".equals(posSide) || "net".equals(posSide)) && "sell".equals(actualSide)) { return "entry-short"; } // 默认值 log.warn("[OKX-WS] 未知的 orderType 映射, posSide:{}, actualSide:{}", posSide, actualSide); return "unknown"; } } src/main/java/com/xcong/excoin/modules/okxApi/wsHandler/handler/PositionsOkxChannelHandler.java
New file @@ -0,0 +1,140 @@ package com.xcong.excoin.modules.okxApi.wsHandler.handler; import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; import com.xcong.excoin.modules.okxApi.OkxConfig; import com.xcong.excoin.modules.okxApi.OkxGridTradeService; import com.xcong.excoin.modules.okxApi.wsHandler.AbstractOkxPrivateChannelHandler; import com.xcong.excoin.modules.okxApi.TraderParam; import lombok.extern.slf4j.Slf4j; import java.math.BigDecimal; /** * OKX 仓位频道处理器(positions),接收仓位更新推送并回调 * {@link OkxGridTradeService#onPositionUpdate(String, TraderParam.Direction, BigDecimal, BigDecimal)}。 * * <h3>订阅格式</h3> * 私有频道,需要先登录认证。订阅 arg 使用 instType: "SWAP"(不指定具体 instId, * 会在 handleMessage 中通过 config.getContract() 过滤)。 * <pre> * {"op":"subscribe","args":[{"channel":"positions","instType":"SWAP"}]} * </pre> * * <h3>数据推送格式</h3> * <pre> * { * "arg": {"channel":"positions","instType":"SWAP"}, * "data": [{ * "instId": "ETH-USDT-SWAP", * "posSide": "long", // "long" 或 "short" * "pos": "1", // 持仓张数 * "avgPx": "3000", // 开仓均价 * ... * }] * } * </pre> * * <h3>数据映射</h3> * <ul> * <li>posSide "long" → {@link TraderParam.Direction#LONG},映射为 DUAL_LONG</li> * <li>posSide "short" → {@link TraderParam.Direction#SHORT},映射为 DUAL_SHORT</li> * <li>pos → 持仓张数(绝对值)</li> * <li>avgPx → 开仓均价</li> * </ul> * * @author Administrator */ @Slf4j public class PositionsOkxChannelHandler extends AbstractOkxPrivateChannelHandler { /** OKX 仓位频道名称 */ private static final String CHANNEL_NAME = "positions"; /** OKX 配置,用于获取合约名称进行过滤 */ private final OkxConfig config; /** * 构造仓位频道处理器。 * * @param config OKX 配置实例(提供合约名称等) * @param gridTradeService OKX 网格交易策略服务实例 */ public PositionsOkxChannelHandler(OkxConfig config, OkxGridTradeService gridTradeService) { super(CHANNEL_NAME, config.getApiKey(), config.getApiSecret(), config.getPassphrase(), config.getContract(), gridTradeService); this.config = config; } /** * 处理仓位推送消息。 * * <h3>处理流程</h3> * <ol> * <li>检查 arg.channel 是否匹配 "positions"</li> * <li>遍历 data 数组,按 instId 过滤出 config.getContract() 对应的仓位</li> * <li>映射 posSide → Direction(long=DualLong, short=DualShort)</li> * <li>提取 pos(张数)、avgPx(均价)</li> * <li>调用 gridTradeService.onPositionUpdate(contract, direction, size, entryPrice)</li> * </ol> * * @param response WebSocket 推送的完整 JSON * @return true 表示已处理(匹配成功) */ @Override public boolean handleMessage(JSONObject response) { JSONObject arg = response.getJSONObject("arg"); if (arg == null || !CHANNEL_NAME.equals(arg.getString("channel"))) { return false; } try { JSONArray dataArray = response.getJSONArray("data"); if (dataArray == null || dataArray.isEmpty()) { return true; } String contract = config.getContract(); // e.g., "ETH-USDT" for (int i = 0; i < dataArray.size(); i++) { JSONObject posData = dataArray.getJSONObject(i); // 按 instId 精确过滤,只处理当前合约的仓位(避免误匹配交割合约) String dataInstId = posData.getString("instId"); if (dataInstId == null || !dataInstId.equals(contract)) { continue; } // 解析持仓方向:OKX 的 posSide 可以是 "long" 或 "short" String posSide = posData.getString("posSide"); TraderParam.Direction direction; if ("long".equals(posSide)) { direction = TraderParam.Direction.LONG; } else if ("short".equals(posSide)) { direction = TraderParam.Direction.SHORT; } else { log.debug("[OKX-WS] positions 忽略 net 方向: {}", posSide); continue; } String posStr = posData.getString("pos"); String avgPxStr = posData.getString("avgPx"); // 仓位归零时 OKX 推送 avgPx: ""(空串),需做防护 BigDecimal size = (posStr != null && !posStr.isEmpty()) ? new BigDecimal(posStr) : BigDecimal.ZERO; BigDecimal entryPrice = (avgPxStr != null && !avgPxStr.isEmpty()) ? new BigDecimal(avgPxStr) : BigDecimal.ZERO; log.info("[OKX-WS] positions 持仓更新, instId:{}, posSide:{}, pos:{}, avgPx:{}", dataInstId, posSide, size, entryPrice); if (getGridTradeService() != null) { getGridTradeService().onPositionUpdate(contract, direction, size, entryPrice); } } } catch (Exception e) { log.error("[OKX-WS] 处理 positions 数据失败", e); } return true; } } src/main/java/com/xcong/excoin/modules/okxNewPrice/OKX_QUANT_DOCUMENTATION.md
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/OkxKlineWebSocketClient.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/OkxNewPriceWebSocketClient.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/OkxQuantWebSocketClient.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/OkxWebSocketClientMain.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/OkxWebSocketClientManager.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/README.md
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/celue/CaoZuoService.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/celue/CaoZuoServiceImpl.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/indicator/AdvancedMA.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/indicator/BOLL.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/indicator/FifteenMinuteStrategyExample.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/indicator/FifteenMinuteTradingExample.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/indicator/FifteenMinuteTradingStrategy.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/indicator/IndicatorBase.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/indicator/KDJ.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/indicator/MA.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/indicator/MACD.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/indicator/MACDTest.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/indicator/RSI.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/indicator/TradingStrategy.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/indicator/macdAndMatrategy/EMACalculator.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/indicator/macdAndMatrategy/IndicatorUtils.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/indicator/macdAndMatrategy/MACDCalculator.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/indicator/macdAndMatrategy/MACDResult.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/indicator/macdAndMatrategy/MacdEmaStrategy.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/indicator/macdAndMatrategy/MacdMaStrategy.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/indicator/macdAndMatrategy/PriceData.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/indicator/macdAndMatrategy/Volatility.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/indicator/macdAndMatrategy/内容
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/indicator/strategy/AbstractTechnicalIndicatorStrategy.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/indicator/strategy/ComprehensiveTechnicalStrategy.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/indicator/strategy/CoreTechnicalStrategy.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/indicator/strategy/CoreTechnicalStrategyUsageGuide.md
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/indicator/strategy/TechnicalIndicatorStrategy.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/indicator/strategy/TradeSignal.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/jiaoyi/IMQService.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/jiaoyi/IMQServiceImpl.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxWs/AccountWs.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxWs/BalanceAndPositionWs.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxWs/InstrumentsWs.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxWs/LoginWs.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxWs/OrderInfoWs.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxWs/PositionsWs.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxWs/TradeOrderWs.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxWs/enums/CoinEnums.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxWs/enums/ExchangeInfoEnum.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxWs/enums/OrderParamEnums.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxWs/param/Kline.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxWs/param/StrategyParam.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxWs/param/TradeRequestParam.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxWs/wanggeList/WangGeListEnum.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxWs/wanggeList/WangGeListQueue.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxWs/wanggeList/WangGeListService.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxWs/wanggeList/WangGeListServiceImpl.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/DataUtil.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/MallUtils.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/config/Account.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/config/Dto/QuantApiMessage.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/config/Dto/SubmitOrderReqDto.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/config/Dto/TradeOrderDto.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/config/ExchangeInfoEnum.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/config/ExchangeLoginEventService.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/config/ExchangeLoginService.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/config/OKXAccount.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/config/RequestHandler.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/config/ResponseHandler.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/config/enums/DefaultUrls.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/config/enums/HttpMethod.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/config/enums/RequestType.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/config/impl/ExchangeLoginEventServiceImpl.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/config/utils/DateUtils.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/config/utils/JSONParser.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/config/utils/OKXContants.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/config/utils/OkHttpUtils.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/config/utils/ParameterChecker.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/config/utils/RequestBuilder.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/config/utils/SignUtils.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/config/utils/UrlBuilder.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/config/vo/BalanceVo.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/config/vo/InstrumentsVo.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/config/vo/PositionsVo.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/enumerates/TradeTypeEnum.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/order/ITradeOrderService.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/order/TradeOrderFactory.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/order/impl/OKXTradeOrderServiceImpl.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/order/vo/QuantExchangeReturnVo.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/query/IQueryOrderService.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/query/QueryOrderFactory.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/query/dto/QuantOperateRecode.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/query/impl/OKXQueryOrderServiceImpl.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/query/vo/QuantCheckOrderVo.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/query/vo/QuantOperateRecodeVo.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/trade/TradeEventEnum.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/trade/TradeEventRunner.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/trade/TradeRequest.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/trade/TradeRequestBuy.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/trade/TradeRequestSell.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/trade/TradeService.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/trade/impl/TradeServiceBuy.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/trade/impl/TradeServiceClosePosition.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/trade/impl/TradeServiceSell.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/trade/vo/SellOrderVo.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/verify/IVerifyAccountService.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/verify/VerifyAccountFactory.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/verify/dto/ApiAccountBalDto.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/verify/dto/ApiAccountBalanceDto.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/verify/dto/ApiAccountBalanceInfoDto.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/verify/dto/ApiAccountProfitDailyDto.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/verify/dto/ApiMessageDto.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/verify/dto/ApiPositionsInfoDto.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/verify/dto/ApiValidApiMessageDto.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/verify/dto/OperateCurrencyDto.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/verify/dto/OperateCurrencyLeverDto.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/verify/dto/TradeBigdataDto.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/verify/enums/PublicStatusEnum.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/verify/impl/OKXVerifyAccountServiceImpl.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/verify/vo/ApiAccountHoldVo.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/verify/vo/ApiPositionsInfoVo.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/verify/vo/ProductMessVo.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/okxpi/verify/vo/SinglemarketVo.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/utils/FebsException.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/utils/FebsResponse.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/utils/SSLConfig.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/utils/SignUtils.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/utils/WsMapBuild.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/utils/WsParamBuild.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/wangge/WangGeEnum.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/wangge/WangGeQueue.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/wangge/WangGeService.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/wangge/WangGeServiceImpl.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/zhanghu/ApiMessageServiceImpl.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/zhanghu/IApiMessageService.java
File was deleted src/main/java/com/xcong/excoin/modules/okxNewPrice/zhanghu/ZhangHuEnum.java
File was deleted