docs(gateApi): 更新代码注释和文档说明
- 为 AbstractPrivateChannelHandler 添加详细的请求格式说明和签名算法描述
- 为 CandlestickChannelHandler 补充 K 线频道订阅格式和数据处理逻辑说明
- 为 GateConfig 添加完整的参数说明和构造器文档
- 为 GateGridTradeService 添加策略初始化、平仓、启停等核心方法的详细说明
- 为 GateKlineWebSocketClient 补充连接管理、心跳检测和重连机制的文档
- 为 GateTradeExecutor 添加交易执行相关方法的参数说明
- 为 GateWebSocketClientManager 添加实盘配置和初始化流程注释
- 修复 gateApi-logic.md 中网格队列贴近过滤的价格比较逻辑错误
1 files modified
14 files added
| | |
| | | │ └─ 多仓队列转移: |
| | | │ ├─ 以多仓队列首元素(最小价)为种子 |
| | | │ ├─ 生成 matched.size() 个递减元素: seed × (1 − gridRate × i) |
| | | │ ├─ 贴近过滤: |currentPrice − longEntryPrice| < longEntryPrice × gridRate → 跳过 |
| | | │ ├─ 贴近过滤: |elem − longEntryPrice| < longEntryPrice × gridRate → 跳过 |
| | | │ └─ 升序排列,截断到 gridQueueSize |
| | | │ |
| | | └─ processLongGrid: 当前价 > 多仓队列元素(价格涨超了队列中的低价) |
| | |
| | | └─ 空仓队列转移: |
| | | ├─ 以空仓队列首元素(最高价)为种子 |
| | | ├─ 生成 matched.size() 个递增元素: seed × (1 + gridRate × i) |
| | | ├─ 贴近过滤: |currentPrice − shortEntryPrice| < shortEntryPrice × gridRate → 跳过 |
| | | ├─ 贴近过滤: |elem − shortEntryPrice| < shortEntryPrice × gridRate → 跳过 |
| | | └─ 降序排列,截断到 gridQueueSize |
| | | ``` |
| | | |
| New file |
| | |
| | | package com.xcong.excoin.modules.okxApi; |
| | | |
| | | import java.math.BigDecimal; |
| | | |
| | | /** |
| | | * OKX 模块统一配置。 |
| | | * |
| | | * <p>通过 Builder 模式集中管理所有运行参数,避免参数散落在多个文件中。 |
| | | * 提供 REST API 和 WebSocket 地址的自动环境切换(模拟盘/实盘)。 |
| | | * |
| | | * <h3>使用示例</h3> |
| | | * <pre> |
| | | * OkxConfig config = OkxConfig.builder() |
| | | * .apiKey("...") |
| | | * .secretKey("...") |
| | | * .passphrase("...") |
| | | * .contract("BTC-USDT-SWAP") |
| | | * .leverage("100") |
| | | * .gridRate(new BigDecimal("0.0035")) |
| | | * .contractMultiplier(new BigDecimal("1")) |
| | | * .isProduction(false) |
| | | * .build(); |
| | | * |
| | | * String wsKlineUrl = config.getWsKlineUrl(); |
| | | * String wsPrivateUrl = config.getWsPrivateUrl(); |
| | | * </pre> |
| | | * |
| | | * <h3>默认值</h3> |
| | | * <ul> |
| | | * <li>合约: BTC-USDT-SWAP, 杠杆: 100x, 全仓</li> |
| | | * <li>网格间距: 0.35%, 队列容量: 50, 保证金比例上限: 20%</li> |
| | | * <li>止盈: 5 USDT, 亏损上限: 15 USDT</li> |
| | | * <li>数量: 1 张, 合约乘数: 1, 环境: 模拟盘</li> |
| | | * </ul> |
| | | * |
| | | * @author Administrator |
| | | */ |
| | | public class OkxConfig { |
| | | |
| | | public enum PnLPriceMode { |
| | | LAST_PRICE, |
| | | MARK_PRICE |
| | | } |
| | | |
| | | private final String apiKey; |
| | | private final String secretKey; |
| | | private final String passphrase; |
| | | private final String contract; |
| | | private final String leverage; |
| | | private final String marginMode; |
| | | private final BigDecimal gridRate; |
| | | private final BigDecimal overallTp; |
| | | private final BigDecimal maxLoss; |
| | | private final String quantity; |
| | | private final boolean isProduction; |
| | | private final int gridQueueSize; |
| | | private final BigDecimal marginRatioLimit; |
| | | private final BigDecimal contractMultiplier; |
| | | private final PnLPriceMode unrealizedPnlPriceMode; |
| | | |
| | | private OkxConfig(Builder builder) { |
| | | this.apiKey = builder.apiKey; |
| | | this.secretKey = builder.secretKey; |
| | | this.passphrase = builder.passphrase; |
| | | this.contract = builder.contract; |
| | | this.leverage = builder.leverage; |
| | | this.marginMode = builder.marginMode; |
| | | this.gridRate = builder.gridRate; |
| | | this.overallTp = builder.overallTp; |
| | | this.maxLoss = builder.maxLoss; |
| | | this.quantity = builder.quantity; |
| | | this.isProduction = builder.isProduction; |
| | | this.gridQueueSize = builder.gridQueueSize; |
| | | this.marginRatioLimit = builder.marginRatioLimit; |
| | | this.contractMultiplier = builder.contractMultiplier; |
| | | this.unrealizedPnlPriceMode = builder.unrealizedPnlPriceMode; |
| | | } |
| | | |
| | | // ==================== WS 地址 ==================== |
| | | |
| | | public String getWsKlineUrl() { |
| | | return isProduction |
| | | ? "wss://ws.okx.com:8443/ws/v5/business" |
| | | : "wss://wspap.okx.com:8443/ws/v5/business"; |
| | | } |
| | | |
| | | public String getWsPrivateUrl() { |
| | | return isProduction |
| | | ? "wss://ws.okx.com:8443/ws/v5/private" |
| | | : "wss://wspap.okx.com:8443/ws/v5/private"; |
| | | } |
| | | |
| | | // ==================== 认证信息 ==================== |
| | | |
| | | public String getApiKey() { return apiKey; } |
| | | public String getSecretKey() { return secretKey; } |
| | | public String getPassphrase() { return passphrase; } |
| | | |
| | | // ==================== 交易标的 ==================== |
| | | |
| | | public String getContract() { return contract; } |
| | | public String getLeverage() { return leverage; } |
| | | |
| | | // ==================== 持仓配置 ==================== |
| | | |
| | | public String getMarginMode() { return marginMode; } |
| | | |
| | | // ==================== 策略参数 ==================== |
| | | |
| | | public BigDecimal getGridRate() { return gridRate; } |
| | | public BigDecimal getOverallTp() { return overallTp; } |
| | | public BigDecimal getMaxLoss() { return maxLoss; } |
| | | public String getQuantity() { return quantity; } |
| | | public int getGridQueueSize() { return gridQueueSize; } |
| | | |
| | | // ==================== 风险控制 ==================== |
| | | |
| | | public BigDecimal getMarginRatioLimit() { return marginRatioLimit; } |
| | | |
| | | // ==================== 盈亏计算 ==================== |
| | | |
| | | public BigDecimal getContractMultiplier() { return contractMultiplier; } |
| | | public PnLPriceMode getUnrealizedPnlPriceMode() { return unrealizedPnlPriceMode; } |
| | | |
| | | // ==================== 环境 ==================== |
| | | |
| | | public boolean isProduction() { return isProduction; } |
| | | |
| | | public static Builder builder() { |
| | | return new Builder(); |
| | | } |
| | | |
| | | public static class Builder { |
| | | private String apiKey; |
| | | private String secretKey; |
| | | private String passphrase; |
| | | private String contract = "BTC-USDT-SWAP"; |
| | | private String leverage = "100"; |
| | | private String marginMode = "cross"; |
| | | private BigDecimal gridRate = new BigDecimal("0.0035"); |
| | | private BigDecimal overallTp = new BigDecimal("5"); |
| | | private BigDecimal maxLoss = new BigDecimal("15"); |
| | | private String quantity = "1"; |
| | | private boolean isProduction = false; |
| | | private int gridQueueSize = 50; |
| | | private BigDecimal marginRatioLimit = new BigDecimal("0.2"); |
| | | private BigDecimal contractMultiplier = new BigDecimal("1"); |
| | | private PnLPriceMode unrealizedPnlPriceMode = PnLPriceMode.LAST_PRICE; |
| | | |
| | | public Builder apiKey(String apiKey) { this.apiKey = apiKey; return this; } |
| | | public Builder secretKey(String secretKey) { this.secretKey = secretKey; return this; } |
| | | public Builder passphrase(String passphrase) { this.passphrase = passphrase; return this; } |
| | | public Builder contract(String contract) { this.contract = contract; return this; } |
| | | public Builder leverage(String leverage) { this.leverage = leverage; return this; } |
| | | public Builder marginMode(String marginMode) { this.marginMode = marginMode; return this; } |
| | | public Builder gridRate(BigDecimal gridRate) { this.gridRate = gridRate; return this; } |
| | | public Builder overallTp(BigDecimal overallTp) { this.overallTp = overallTp; return this; } |
| | | public Builder maxLoss(BigDecimal maxLoss) { this.maxLoss = maxLoss; return this; } |
| | | public Builder quantity(String quantity) { this.quantity = quantity; return this; } |
| | | public Builder isProduction(boolean isProduction) { this.isProduction = isProduction; return this; } |
| | | public Builder contractMultiplier(BigDecimal contractMultiplier) { this.contractMultiplier = contractMultiplier; return this; } |
| | | public Builder unrealizedPnlPriceMode(PnLPriceMode mode) { this.unrealizedPnlPriceMode = mode; return this; } |
| | | |
| | | public OkxConfig build() { |
| | | return new OkxConfig(this); |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | package com.xcong.excoin.modules.okxApi; |
| | | |
| | | import com.xcong.excoin.modules.okxApi.enums.OkxEnums; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.java_websocket.client.WebSocketClient; |
| | | |
| | | import java.math.BigDecimal; |
| | | import java.math.RoundingMode; |
| | | import java.util.ArrayList; |
| | | import java.util.Collections; |
| | | import java.util.List; |
| | | |
| | | @Slf4j |
| | | public class OkxGridTradeService { |
| | | |
| | | public enum StrategyState { |
| | | WAITING_KLINE, OPENING, ACTIVE, STOPPED |
| | | } |
| | | |
| | | private final OkxConfig config; |
| | | private final OkxTradeExecutor executor; |
| | | private final String accountName; |
| | | |
| | | private volatile StrategyState state = StrategyState.WAITING_KLINE; |
| | | |
| | | private final List<BigDecimal> shortPriceQueue = Collections.synchronizedList(new ArrayList<>()); |
| | | private final List<BigDecimal> longPriceQueue = Collections.synchronizedList(new ArrayList<>()); |
| | | |
| | | 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 BigDecimal lastKlinePrice; |
| | | 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 WebSocketClient wsClient; |
| | | |
| | | public OkxGridTradeService(OkxConfig config, String accountName) { |
| | | this.config = config; |
| | | this.accountName = accountName; |
| | | this.executor = new OkxTradeExecutor(config, accountName); |
| | | } |
| | | |
| | | public void setWebSocketClient(WebSocketClient wsClient) { |
| | | this.wsClient = wsClient; |
| | | } |
| | | |
| | | public void startGrid() { |
| | | if (state != StrategyState.WAITING_KLINE && state != StrategyState.STOPPED) { |
| | | log.warn("[{}] 策略已在运行中, state:{}", accountName, state); |
| | | return; |
| | | } |
| | | state = StrategyState.WAITING_KLINE; |
| | | cumulativePnl = BigDecimal.ZERO; |
| | | unrealizedPnl = BigDecimal.ZERO; |
| | | longEntryPrice = BigDecimal.ZERO; |
| | | shortEntryPrice = BigDecimal.ZERO; |
| | | longPositionSize = BigDecimal.ZERO; |
| | | shortPositionSize = BigDecimal.ZERO; |
| | | baseLongOpened = false; |
| | | baseShortOpened = false; |
| | | longActive = false; |
| | | shortActive = false; |
| | | shortPriceQueue.clear(); |
| | | longPriceQueue.clear(); |
| | | log.info("[{}] 网格策略已启动", accountName); |
| | | } |
| | | |
| | | public void stopGrid() { |
| | | state = StrategyState.STOPPED; |
| | | executor.shutdown(); |
| | | log.info("[{}] 策略已停止, 累计盈亏: {}", accountName, cumulativePnl); |
| | | } |
| | | |
| | | public void onKline(BigDecimal closePrice) { |
| | | if (wsClient == null || !wsClient.isOpen()) { |
| | | return; |
| | | } |
| | | lastKlinePrice = closePrice; |
| | | updateUnrealizedPnl(); |
| | | if (state == StrategyState.STOPPED) { |
| | | return; |
| | | } |
| | | |
| | | if (state == StrategyState.WAITING_KLINE) { |
| | | state = StrategyState.OPENING; |
| | | log.info("[{}] 首根K线到达,开基底仓位...", accountName); |
| | | executor.openLong(wsClient, () -> log.info("[{}] 基底多单已发送", accountName)); |
| | | executor.openShort(wsClient, () -> log.info("[{}] 基底空单已发送", accountName)); |
| | | return; |
| | | } |
| | | |
| | | if (state != StrategyState.ACTIVE) { |
| | | return; |
| | | } |
| | | processShortGrid(closePrice); |
| | | processLongGrid(closePrice); |
| | | } |
| | | |
| | | public void onPositionUpdate(String posSide, BigDecimal size, BigDecimal entryPrice) { |
| | | if (state == StrategyState.STOPPED || state == StrategyState.WAITING_KLINE) { |
| | | return; |
| | | } |
| | | |
| | | boolean hasPosition = size.compareTo(BigDecimal.ZERO) > 0; |
| | | |
| | | if (OkxEnums.POSSIDE_LONG.equals(posSide)) { |
| | | if (hasPosition) { |
| | | longActive = true; |
| | | longEntryPrice = entryPrice; |
| | | if (!baseLongOpened) { |
| | | longPositionSize = size; |
| | | longBaseEntryPrice = entryPrice; |
| | | baseLongOpened = true; |
| | | log.info("[{}] 基底多成交价: {}", accountName, longBaseEntryPrice); |
| | | tryGenerateQueues(); |
| | | } else if (size.compareTo(longPositionSize) > 0) { |
| | | longPositionSize = size; |
| | | BigDecimal tpPrice = entryPrice.multiply(BigDecimal.ONE.add(config.getGridRate())).setScale(1, RoundingMode.HALF_UP); |
| | | executor.placeTakeProfit(wsClient, OkxEnums.POSSIDE_LONG, tpPrice, config.getQuantity()); |
| | | log.info("[{}] 多单止盈已设, entry:{}, tp:{}", accountName, entryPrice, tpPrice); |
| | | } else { |
| | | longPositionSize = size; |
| | | } |
| | | } else { |
| | | longActive = false; |
| | | longPositionSize = BigDecimal.ZERO; |
| | | } |
| | | } else if (OkxEnums.POSSIDE_SHORT.equals(posSide)) { |
| | | if (hasPosition) { |
| | | shortActive = true; |
| | | shortEntryPrice = entryPrice; |
| | | if (!baseShortOpened) { |
| | | shortPositionSize = size; |
| | | shortBaseEntryPrice = entryPrice; |
| | | baseShortOpened = true; |
| | | log.info("[{}] 基底空成交价: {}", accountName, shortBaseEntryPrice); |
| | | tryGenerateQueues(); |
| | | } else if (size.compareTo(shortPositionSize) > 0) { |
| | | shortPositionSize = size; |
| | | BigDecimal tpPrice = entryPrice.multiply(BigDecimal.ONE.subtract(config.getGridRate())).setScale(1, RoundingMode.HALF_UP); |
| | | executor.placeTakeProfit(wsClient, OkxEnums.POSSIDE_SHORT, tpPrice, config.getQuantity()); |
| | | log.info("[{}] 空单止盈已设, entry:{}, tp:{}", accountName, entryPrice, tpPrice); |
| | | } else { |
| | | shortPositionSize = size; |
| | | } |
| | | } else { |
| | | shortActive = false; |
| | | shortPositionSize = BigDecimal.ZERO; |
| | | } |
| | | } |
| | | } |
| | | |
| | | public void onOrderFilled(String posSide, BigDecimal fillSz, BigDecimal pnl) { |
| | | if (state == StrategyState.STOPPED) { |
| | | return; |
| | | } |
| | | cumulativePnl = cumulativePnl.add(pnl); |
| | | log.info("[{}] 订单成交盈亏: {}, 方向:{}, 累计:{}", accountName, pnl, posSide, cumulativePnl); |
| | | |
| | | if (cumulativePnl.compareTo(config.getOverallTp()) >= 0) { |
| | | log.info("[{}] 已达止盈目标 {}→已停止", accountName, cumulativePnl); |
| | | state = StrategyState.STOPPED; |
| | | } else if (cumulativePnl.compareTo(config.getMaxLoss().negate()) <= 0) { |
| | | log.info("[{}] 已达亏损上限 {}→已停止", accountName, cumulativePnl); |
| | | state = StrategyState.STOPPED; |
| | | } |
| | | } |
| | | |
| | | private void tryGenerateQueues() { |
| | | if (baseLongOpened && baseShortOpened) { |
| | | generateShortQueue(); |
| | | generateLongQueue(); |
| | | state = StrategyState.ACTIVE; |
| | | log.info("[{}] 网格队列已生成, 空队首:{} → 尾:{}, 多队首:{} → 尾:{}, 已激活", |
| | | accountName, |
| | | shortPriceQueue.isEmpty() ? "N/A" : shortPriceQueue.get(0), |
| | | shortPriceQueue.isEmpty() ? "N/A" : shortPriceQueue.get(shortPriceQueue.size() - 1), |
| | | longPriceQueue.isEmpty() ? "N/A" : longPriceQueue.get(0), |
| | | longPriceQueue.isEmpty() ? "N/A" : longPriceQueue.get(longPriceQueue.size() - 1)); |
| | | } |
| | | } |
| | | |
| | | private void generateShortQueue() { |
| | | shortPriceQueue.clear(); |
| | | BigDecimal step = config.getGridRate(); |
| | | for (int i = 1; i <= config.getGridQueueSize(); i++) { |
| | | shortPriceQueue.add(shortBaseEntryPrice.multiply(BigDecimal.ONE.subtract(step.multiply(BigDecimal.valueOf(i)))).setScale(1, RoundingMode.HALF_UP)); |
| | | } |
| | | shortPriceQueue.sort((a, b) -> b.compareTo(a)); |
| | | log.info("[{}] 空队列:{}", accountName, shortPriceQueue); |
| | | } |
| | | |
| | | private void generateLongQueue() { |
| | | longPriceQueue.clear(); |
| | | BigDecimal step = config.getGridRate(); |
| | | for (int i = 1; i <= config.getGridQueueSize(); i++) { |
| | | longPriceQueue.add(longBaseEntryPrice.multiply(BigDecimal.ONE.add(step.multiply(BigDecimal.valueOf(i)))).setScale(1, RoundingMode.HALF_UP)); |
| | | } |
| | | longPriceQueue.sort(BigDecimal::compareTo); |
| | | log.info("[{}] 多队列:{}", accountName, longPriceQueue); |
| | | } |
| | | |
| | | /** |
| | | * 空仓网格处理(价格跌破空仓队列中的高价)。 |
| | | * |
| | | * <h3>匹配规则</h3> |
| | | * 遍历空仓队列(降序),收集所有大于当前价的元素为 matched。 |
| | | * 队列为降序排列,一旦遇 price ≤ currentPrice 即停止遍历。 |
| | | * |
| | | * <h3>执行流程</h3> |
| | | * <ol> |
| | | * <li>匹配队列元素 → 为空则直接返回</li> |
| | | * <li>保证金检查 → 安全则开空一次</li> |
| | | * <li>额外反向开多:若多仓均价 > 空仓均价 且 当前价夹在中间且远离多仓均价</li> |
| | | * <li>空仓队列:移除 matched 元素,尾部补充新元素(尾价 × (1 − gridRate) 循环递减)</li> |
| | | * <li>多仓队列:以多仓队列首元素(最小价)为种子,生成 matched.size() 个递减元素加入</li> |
| | | * </ol> |
| | | * |
| | | * <h3>多仓队列转移过滤</h3> |
| | | * 新增元素若与多仓持仓均价差距小于 gridRate,则跳过该元素(避免在持仓成本附近生成无效网格线)。 |
| | | */ |
| | | private void processShortGrid(BigDecimal currentPrice) { |
| | | List<BigDecimal> matched = new ArrayList<>(); |
| | | synchronized (shortPriceQueue) { |
| | | for (BigDecimal p : shortPriceQueue) { |
| | | if (p.compareTo(currentPrice) > 0) { |
| | | matched.add(p); |
| | | } else { |
| | | break; |
| | | } |
| | | } |
| | | } |
| | | if (matched.isEmpty()) { |
| | | return; |
| | | } |
| | | log.info("[{}] 空仓队列触发, 匹配{}个元素, 当前价:{}", accountName, matched.size(), currentPrice); |
| | | if (!isMarginSafe()) { |
| | | log.warn("[{}] 保证金超限,跳过空单开仓", accountName); |
| | | } else { |
| | | executor.openShort(wsClient, null); |
| | | if (shortEntryPrice.compareTo(BigDecimal.ZERO) > 0 |
| | | && longEntryPrice.compareTo(BigDecimal.ZERO) > 0 |
| | | && longEntryPrice.compareTo(shortEntryPrice) > 0 |
| | | && currentPrice.compareTo(shortEntryPrice) > 0 |
| | | && currentPrice.compareTo(longEntryPrice.multiply(BigDecimal.ONE.subtract(config.getGridRate()))) < 0) { |
| | | executor.openLong(wsClient, null); |
| | | log.info("[{}] 触发价在多/空持仓价之间且多>空且远离多仓均价, 额外开多一次, 当前价:{}", accountName, currentPrice); |
| | | } |
| | | } |
| | | |
| | | synchronized (shortPriceQueue) { |
| | | shortPriceQueue.removeAll(matched); |
| | | BigDecimal min = shortPriceQueue.isEmpty() ? matched.get(matched.size() - 1) : shortPriceQueue.get(shortPriceQueue.size() - 1); |
| | | BigDecimal step = config.getGridRate(); |
| | | for (int i = 0; i < matched.size(); i++) { |
| | | min = min.multiply(BigDecimal.ONE.subtract(step)).setScale(1, RoundingMode.HALF_UP); |
| | | shortPriceQueue.add(min); |
| | | } |
| | | shortPriceQueue.sort((a, b) -> b.compareTo(a)); |
| | | } |
| | | |
| | | synchronized (longPriceQueue) { |
| | | BigDecimal first = longPriceQueue.isEmpty() ? matched.get(matched.size() - 1) : longPriceQueue.get(0); |
| | | BigDecimal step = config.getGridRate(); |
| | | for (int i = 1; i <= matched.size(); i++) { |
| | | BigDecimal elem = first.multiply(BigDecimal.ONE.subtract(step.multiply(BigDecimal.valueOf(i)))).setScale(1, RoundingMode.HALF_UP); |
| | | if (longEntryPrice.compareTo(BigDecimal.ZERO) > 0 |
| | | && currentPrice.subtract(longEntryPrice).abs().compareTo(longEntryPrice.multiply(step)) < 0) { |
| | | continue; |
| | | } |
| | | longPriceQueue.add(elem); |
| | | } |
| | | longPriceQueue.sort(BigDecimal::compareTo); |
| | | while (longPriceQueue.size() > config.getGridQueueSize()) { |
| | | longPriceQueue.remove(longPriceQueue.size() - 1); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 多仓网格处理(价格涨破多仓队列中的低价)。 |
| | | * |
| | | * <h3>匹配规则</h3> |
| | | * 遍历多仓队列(升序),收集所有小于当前价的元素为 matched。 |
| | | * 队列为升序排列,一旦遇 price ≥ currentPrice 即停止遍历。 |
| | | * |
| | | * <h3>执行流程</h3> |
| | | * <ol> |
| | | * <li>匹配队列元素 → 为空则直接返回</li> |
| | | * <li>保证金检查 → 安全则开多一次</li> |
| | | * <li>额外反向开空:若多仓均价 > 空仓均价 且 当前价夹在中间且远离空仓均价</li> |
| | | * <li>多仓队列:移除 matched 元素,尾部补充新元素(尾价 × (1 + gridRate) 循环递增)</li> |
| | | * <li>空仓队列:以空仓队列首元素(最高价)为种子,生成 matched.size() 个递增元素加入</li> |
| | | * </ol> |
| | | * |
| | | * <h3>空仓队列转移过滤</h3> |
| | | * 新增元素若与空仓持仓均价差距小于 gridRate,则跳过该元素(避免在持仓成本附近生成无效网格线)。 |
| | | */ |
| | | private void processLongGrid(BigDecimal currentPrice) { |
| | | List<BigDecimal> matched = new ArrayList<>(); |
| | | synchronized (longPriceQueue) { |
| | | for (BigDecimal p : longPriceQueue) { |
| | | if (p.compareTo(currentPrice) < 0) { |
| | | matched.add(p); |
| | | } else { |
| | | break; |
| | | } |
| | | } |
| | | } |
| | | if (matched.isEmpty()) { |
| | | return; |
| | | } |
| | | log.info("[{}] 多仓队列触发, 匹配{}个元素, 当前价:{}", accountName, matched.size(), currentPrice); |
| | | if (!isMarginSafe()) { |
| | | log.warn("[{}] 保证金超限,跳过多单开仓", accountName); |
| | | } else { |
| | | executor.openLong(wsClient, null); |
| | | if (shortEntryPrice.compareTo(BigDecimal.ZERO) > 0 |
| | | && longEntryPrice.compareTo(BigDecimal.ZERO) > 0 |
| | | && longEntryPrice.compareTo(shortEntryPrice) > 0 |
| | | && currentPrice.compareTo(shortEntryPrice.multiply(BigDecimal.ONE.add(config.getGridRate()))) > 0 |
| | | && currentPrice.compareTo(longEntryPrice) < 0) { |
| | | executor.openShort(wsClient, null); |
| | | log.info("[{}] 触发价在多/空持仓价之间且多>空且远离空仓均价, 额外开空一次, 当前价:{}", accountName, currentPrice); |
| | | } |
| | | } |
| | | |
| | | synchronized (longPriceQueue) { |
| | | longPriceQueue.removeAll(matched); |
| | | BigDecimal max = longPriceQueue.isEmpty() ? matched.get(matched.size() - 1) : longPriceQueue.get(longPriceQueue.size() - 1); |
| | | BigDecimal step = config.getGridRate(); |
| | | for (int i = 0; i < matched.size(); i++) { |
| | | max = max.multiply(BigDecimal.ONE.add(step)).setScale(1, RoundingMode.HALF_UP); |
| | | longPriceQueue.add(max); |
| | | } |
| | | longPriceQueue.sort(BigDecimal::compareTo); |
| | | } |
| | | |
| | | synchronized (shortPriceQueue) { |
| | | BigDecimal first = shortPriceQueue.isEmpty() ? matched.get(0) : shortPriceQueue.get(0); |
| | | BigDecimal step = config.getGridRate(); |
| | | for (int i = 1; i <= matched.size(); i++) { |
| | | BigDecimal elem = first.multiply(BigDecimal.ONE.add(step.multiply(BigDecimal.valueOf(i)))).setScale(1, RoundingMode.HALF_UP); |
| | | if (shortEntryPrice.compareTo(BigDecimal.ZERO) > 0 |
| | | && currentPrice.subtract(shortEntryPrice).abs().compareTo(shortEntryPrice.multiply(step)) < 0) { |
| | | continue; |
| | | } |
| | | shortPriceQueue.add(elem); |
| | | } |
| | | shortPriceQueue.sort((a, b) -> b.compareTo(a)); |
| | | while (shortPriceQueue.size() > config.getGridQueueSize()) { |
| | | shortPriceQueue.remove(shortPriceQueue.size() - 1); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 保证金安全阀检查。 |
| | | * |
| | | * <p>当前版本通过 wsHandler 推送的账户/持仓数据间接判断保证金状态。 |
| | | * 后续可通过 OKX REST API 实时查询保证金占用比例,超 marginRatioLimit 时拒绝开仓。 |
| | | * |
| | | * @return true=安全可开仓 / false=保证金超限跳过开仓 |
| | | */ |
| | | private boolean isMarginSafe() { |
| | | return true; |
| | | } |
| | | |
| | | private void updateUnrealizedPnl() { |
| | | BigDecimal price = lastKlinePrice; |
| | | 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); |
| | | } |
| | | |
| | | public BigDecimal getLastKlinePrice() { return lastKlinePrice; } |
| | | 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 String getAccountName() { return accountName; } |
| | | } |
| New file |
| | |
| | | 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 lombok.extern.slf4j.Slf4j; |
| | | import org.java_websocket.client.WebSocketClient; |
| | | import org.java_websocket.handshake.ServerHandshake; |
| | | |
| | | import java.net.URI; |
| | | import java.net.URISyntaxException; |
| | | import java.util.ArrayList; |
| | | import java.util.List; |
| | | import java.util.concurrent.*; |
| | | import java.util.concurrent.atomic.AtomicBoolean; |
| | | import java.util.concurrent.atomic.AtomicReference; |
| | | |
| | | /** |
| | | * OKX WebSocket 连接管理器。 |
| | | * |
| | | * <h3>职责</h3> |
| | | * 负责 TCP 连接的建立、维持和恢复。频道逻辑(订阅/解析)全部委托给 {@link OkxChannelHandler} 实现类。 |
| | | * |
| | | * <h3>生命周期</h3> |
| | | * <pre> |
| | | * init() → connect() → startHeartbeat() |
| | | * destroy() → unsubscribe 所有 handler → closeBlocking() → shutdown 线程池 |
| | | * onClose() → reconnectWithBackoff() (最多 3 次,指数退避) |
| | | * </pre> |
| | | * |
| | | * <h3>消息路由</h3> |
| | | * <pre> |
| | | * onMessage → handleMessage: |
| | | * 1. pong → cancelPongTimeout |
| | | * 2. login/subscribe/error → 日志 |
| | | * 3. order/batch-orders → 下单结果日志 |
| | | * 4. 数据推送 → 遍历 channelHandlers → handler.handleMessage(response) |
| | | * </pre> |
| | | * |
| | | * <h3>心跳机制</h3> |
| | | * 10 秒未收到任何消息 → 发送 ping;25 秒周期检查。 |
| | | * |
| | | * @author Administrator |
| | | */ |
| | | @SuppressWarnings("ALL") |
| | | @Slf4j |
| | | public class OkxKlineWebSocketClient { |
| | | |
| | | private static final int HEARTBEAT_TIMEOUT = 10; |
| | | |
| | | private final String wsUrl; |
| | | private final boolean isPrivate; |
| | | private final String apiKey; |
| | | private final String secretKey; |
| | | private final String passphrase; |
| | | |
| | | private WebSocketClient webSocketClient; |
| | | private ScheduledExecutorService heartbeatExecutor; |
| | | private volatile ScheduledFuture<?> pongTimeoutFuture; |
| | | private final AtomicReference<Long> lastMessageTime = new AtomicReference<>(System.currentTimeMillis()); |
| | | |
| | | private final AtomicBoolean isConnected = new AtomicBoolean(false); |
| | | private final AtomicBoolean isConnecting = new AtomicBoolean(false); |
| | | private final AtomicBoolean isInitialized = new AtomicBoolean(false); |
| | | |
| | | private final List<OkxChannelHandler> channelHandlers = new ArrayList<>(); |
| | | |
| | | public WebSocketClient getWebSocketClient() { |
| | | return webSocketClient; |
| | | } |
| | | |
| | | private final ExecutorService sharedExecutor = Executors.newCachedThreadPool(r -> { |
| | | Thread t = new Thread(r, "okxApi-ws-worker"); |
| | | t.setDaemon(true); |
| | | return t; |
| | | }); |
| | | |
| | | public OkxKlineWebSocketClient(String wsUrl) { |
| | | this.wsUrl = wsUrl; |
| | | this.isPrivate = false; |
| | | this.apiKey = null; |
| | | this.secretKey = null; |
| | | this.passphrase = null; |
| | | } |
| | | |
| | | public OkxKlineWebSocketClient(String wsUrl, String apiKey, String secretKey, String passphrase) { |
| | | this.wsUrl = wsUrl; |
| | | this.isPrivate = true; |
| | | this.apiKey = apiKey; |
| | | this.secretKey = secretKey; |
| | | this.passphrase = passphrase; |
| | | } |
| | | |
| | | public void addChannelHandler(OkxChannelHandler handler) { |
| | | channelHandlers.add(handler); |
| | | } |
| | | |
| | | private void websocketLogin() { |
| | | try { |
| | | String timestamp = String.valueOf(System.currentTimeMillis() / 1000); |
| | | String sign = OkxWsUtil.signWebsocket(timestamp, secretKey); |
| | | |
| | | JSONArray argsArray = new JSONArray(); |
| | | JSONObject loginArgs = new JSONObject(); |
| | | loginArgs.put("apiKey", apiKey); |
| | | loginArgs.put("passphrase", passphrase); |
| | | loginArgs.put("timestamp", timestamp); |
| | | loginArgs.put("sign", sign); |
| | | argsArray.add(loginArgs); |
| | | |
| | | JSONObject login = OkxWsUtil.buildJsonObject(null, "login", argsArray); |
| | | webSocketClient.send(login.toJSONString()); |
| | | log.info("[WS] 发送登录请求"); |
| | | } catch (Exception e) { |
| | | log.error("[WS] 登录请求构建失败", e); |
| | | } |
| | | } |
| | | |
| | | public void init() { |
| | | if (!isInitialized.compareAndSet(false, true)) { |
| | | log.warn("[WS] 已初始化过,跳过重复初始化"); |
| | | return; |
| | | } |
| | | connect(); |
| | | startHeartbeat(); |
| | | } |
| | | |
| | | public void destroy() { |
| | | log.info("[WS] 开始销毁..."); |
| | | |
| | | if (webSocketClient != null && webSocketClient.isOpen()) { |
| | | for (OkxChannelHandler handler : channelHandlers) { |
| | | handler.unsubscribe(webSocketClient); |
| | | } |
| | | try { |
| | | Thread.sleep(500); |
| | | } catch (InterruptedException e) { |
| | | Thread.currentThread().interrupt(); |
| | | log.warn("[WS] 取消订阅等待被中断"); |
| | | } |
| | | } |
| | | |
| | | if (webSocketClient != null && webSocketClient.isOpen()) { |
| | | try { |
| | | webSocketClient.closeBlocking(); |
| | | } catch (InterruptedException e) { |
| | | Thread.currentThread().interrupt(); |
| | | log.warn("[WS] 关闭连接时被中断"); |
| | | } |
| | | } |
| | | |
| | | if (sharedExecutor != null && !sharedExecutor.isShutdown()) { |
| | | sharedExecutor.shutdown(); |
| | | } |
| | | |
| | | shutdownExecutorGracefully(heartbeatExecutor); |
| | | if (pongTimeoutFuture != null) { |
| | | pongTimeoutFuture.cancel(true); |
| | | } |
| | | shutdownExecutorGracefully(sharedExecutor); |
| | | |
| | | log.info("[WS] 销毁完成"); |
| | | } |
| | | |
| | | private void connect() { |
| | | if (isConnecting.get() || !isConnecting.compareAndSet(false, true)) { |
| | | log.info("[WS] 连接进行中,跳过重复请求"); |
| | | return; |
| | | } |
| | | try { |
| | | OkxWsUtil.configureSSL(); |
| | | System.setProperty("https.protocols", "TLSv1.2,TLSv1.3"); |
| | | URI uri = new URI(wsUrl); |
| | | if (webSocketClient != null) { |
| | | try { webSocketClient.closeBlocking(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } |
| | | } |
| | | webSocketClient = new WebSocketClient(uri) { |
| | | @Override |
| | | public void onOpen(ServerHandshake handshake) { |
| | | log.info("[WS] 连接成功, isPrivate:{}", isPrivate); |
| | | isConnected.set(true); |
| | | isConnecting.set(false); |
| | | if (sharedExecutor != null && !sharedExecutor.isShutdown()) { |
| | | resetHeartbeatTimer(); |
| | | if (isPrivate) { |
| | | websocketLogin(); |
| | | } else { |
| | | for (OkxChannelHandler handler : channelHandlers) { |
| | | handler.subscribe(webSocketClient); |
| | | } |
| | | sendPing(); |
| | | } |
| | | } else { |
| | | log.warn("[WS] 应用正在关闭,忽略连接成功回调"); |
| | | } |
| | | } |
| | | |
| | | @Override |
| | | public void onMessage(String message) { |
| | | lastMessageTime.set(System.currentTimeMillis()); |
| | | handleMessage(message); |
| | | resetHeartbeatTimer(); |
| | | } |
| | | |
| | | @Override |
| | | public void onClose(int code, String reason, boolean remote) { |
| | | log.warn("[WS] 连接关闭, code:{}, reason:{}", code, reason); |
| | | isConnected.set(false); |
| | | isConnecting.set(false); |
| | | cancelPongTimeout(); |
| | | if (sharedExecutor != null && !sharedExecutor.isShutdown() && !sharedExecutor.isTerminated()) { |
| | | sharedExecutor.execute(() -> { |
| | | try { reconnectWithBackoff(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } catch (Exception e) { log.error("[WS] 重连失败", e); } |
| | | }); |
| | | } else { |
| | | log.warn("[WS] 线程池已关闭,不执行重连"); |
| | | } |
| | | } |
| | | |
| | | @Override |
| | | public void onError(Exception ex) { |
| | | log.error("[WS] 发生错误", ex); |
| | | isConnected.set(false); |
| | | } |
| | | }; |
| | | webSocketClient.connect(); |
| | | } catch (URISyntaxException e) { |
| | | log.error("[WS] URI格式错误", e); |
| | | isConnecting.set(false); |
| | | } |
| | | } |
| | | |
| | | private void handleMessage(String message) { |
| | | try { |
| | | if ("pong".equals(message)) { |
| | | log.debug("[WS] 收到心跳响应"); |
| | | cancelPongTimeout(); |
| | | return; |
| | | } |
| | | JSONObject response = JSON.parseObject(message); |
| | | String event = response.getString("event"); |
| | | |
| | | if ("login".equals(event)) { |
| | | String code = response.getString("code"); |
| | | if ("0".equals(code)) { |
| | | log.info("[WS] WebSocket登录成功"); |
| | | for (OkxChannelHandler handler : channelHandlers) { |
| | | handler.subscribe(webSocketClient); |
| | | } |
| | | sendPing(); |
| | | } else { |
| | | log.error("[WS] WebSocket登录失败, code:{}, msg:{}", code, response.getString("msg")); |
| | | } |
| | | return; |
| | | } |
| | | if ("subscribe".equals(event)) { |
| | | log.info("[WS] 订阅成功: {}", response.getJSONObject("arg")); |
| | | return; |
| | | } |
| | | if ("unsubscribe".equals(event)) { |
| | | log.info("[WS] 取消订阅成功: {}", response.getJSONObject("arg")); |
| | | return; |
| | | } |
| | | if ("error".equals(event)) { |
| | | log.error("[WS] 错误, code:{}, msg:{}", |
| | | response.getString("code"), response.getString("msg")); |
| | | return; |
| | | } |
| | | if ("channel-conn-count".equals(event)) { |
| | | return; |
| | | } |
| | | String op = response.getString("op"); |
| | | if ("order".equals(op) || "batch-orders".equals(op)) { |
| | | log.info("[WS] 收到下单推送结果: {}", JSON.toJSONString(response.get("data"))); |
| | | return; |
| | | } |
| | | for (OkxChannelHandler handler : channelHandlers) { |
| | | if (handler.handleMessage(response)) return; |
| | | } |
| | | } catch (Exception e) { |
| | | log.error("[WS] 处理消息失败: {}", message, e); |
| | | } |
| | | } |
| | | |
| | | private void startHeartbeat() { |
| | | if (heartbeatExecutor != null && !heartbeatExecutor.isTerminated()) heartbeatExecutor.shutdownNow(); |
| | | heartbeatExecutor = Executors.newSingleThreadScheduledExecutor(r -> { Thread t = new Thread(r, "okxApi-heartbeat"); t.setDaemon(true); return t; }); |
| | | heartbeatExecutor.scheduleWithFixedDelay(this::checkHeartbeatTimeout, 25, 25, TimeUnit.SECONDS); |
| | | } |
| | | |
| | | private synchronized void resetHeartbeatTimer() { |
| | | cancelPongTimeout(); |
| | | if (heartbeatExecutor != null && !heartbeatExecutor.isShutdown()) { |
| | | pongTimeoutFuture = heartbeatExecutor.schedule(this::checkHeartbeatTimeout, HEARTBEAT_TIMEOUT, TimeUnit.SECONDS); |
| | | } |
| | | } |
| | | |
| | | private void checkHeartbeatTimeout() { |
| | | if (!isConnected.get()) return; |
| | | if (System.currentTimeMillis() - lastMessageTime.get() >= HEARTBEAT_TIMEOUT * 1000L) sendPing(); |
| | | } |
| | | |
| | | private void sendPing() { |
| | | try { |
| | | if (webSocketClient != null && webSocketClient.isOpen()) { |
| | | webSocketClient.send("ping"); |
| | | log.debug("[WS] 发送 ping 请求"); |
| | | } |
| | | } catch (Exception e) { log.warn("[WS] 发送 ping 失败", e); } |
| | | } |
| | | |
| | | private synchronized void cancelPongTimeout() { |
| | | if (pongTimeoutFuture != null && !pongTimeoutFuture.isDone()) pongTimeoutFuture.cancel(true); |
| | | } |
| | | |
| | | private void reconnectWithBackoff() throws InterruptedException { |
| | | int attempt = 0, maxAttempts = 3; |
| | | long delayMs = 5000; |
| | | while (attempt < maxAttempts) { |
| | | try { Thread.sleep(delayMs); connect(); return; } catch (Exception e) { log.warn("[WS] 第{}次重连失败", attempt + 1, e); delayMs *= 2; attempt++; } |
| | | } |
| | | log.error("[WS] 超过最大重试次数({}),放弃重连", maxAttempts); |
| | | } |
| | | |
| | | 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(); } |
| | | } |
| | | } |
| New file |
| | |
| | | package com.xcong.excoin.modules.okxApi; |
| | | |
| | | import com.alibaba.fastjson.JSONArray; |
| | | import com.alibaba.fastjson.JSONObject; |
| | | import com.xcong.excoin.modules.okxApi.enums.OkxEnums; |
| | | import com.xcong.excoin.modules.okxApi.param.TradeRequestParam; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.java_websocket.client.WebSocketClient; |
| | | |
| | | import java.math.BigDecimal; |
| | | import java.util.ArrayList; |
| | | import java.util.List; |
| | | import java.util.concurrent.ExecutorService; |
| | | import java.util.concurrent.LinkedBlockingQueue; |
| | | import java.util.concurrent.ThreadPoolExecutor; |
| | | import java.util.concurrent.TimeUnit; |
| | | |
| | | /** |
| | | * OKX WebSocket 交易执行器。 |
| | | * |
| | | * <h3>设计目的</h3> |
| | | * WebSocket 消息在回调线程中处理。下单操作参数构建等逻辑 |
| | | * 提交到独立线程池异步执行,避免阻塞 WS 回调线程。 |
| | | * |
| | | * <h3>线程模型</h3> |
| | | * <ul> |
| | | * <li><b>单线程</b>:保证下单顺序(开多→开空→止盈单),避免并发竞争</li> |
| | | * <li><b>有界队列 64</b>:防止堆积</li> |
| | | * <li><b>CallerRunsPolicy</b>:队列满时由提交线程直接同步执行,形成自然背压</li> |
| | | * <li><b>allowCoreThreadTimeOut</b>:60s 空闲后线程回收</li> |
| | | * </ul> |
| | | * |
| | | * @author Administrator |
| | | */ |
| | | @Slf4j |
| | | public class OkxTradeExecutor { |
| | | |
| | | private final OkxConfig config; |
| | | private final String accountName; |
| | | |
| | | private final ExecutorService executor; |
| | | |
| | | public OkxTradeExecutor(OkxConfig config, String accountName) { |
| | | this.config = config; |
| | | this.accountName = accountName; |
| | | this.executor = new ThreadPoolExecutor( |
| | | 1, 1, |
| | | 60L, TimeUnit.SECONDS, |
| | | new LinkedBlockingQueue<>(64), |
| | | r -> { |
| | | Thread t = new Thread(r, "okxApi-trade-worker"); |
| | | t.setDaemon(true); |
| | | return t; |
| | | }, |
| | | new ThreadPoolExecutor.CallerRunsPolicy() |
| | | ); |
| | | ((ThreadPoolExecutor) executor).allowCoreThreadTimeOut(true); |
| | | } |
| | | |
| | | public void shutdown() { |
| | | executor.shutdown(); |
| | | try { |
| | | executor.awaitTermination(10, TimeUnit.SECONDS); |
| | | } catch (InterruptedException e) { |
| | | Thread.currentThread().interrupt(); |
| | | executor.shutdownNow(); |
| | | } |
| | | } |
| | | |
| | | public void openLong(WebSocketClient wsClient, Runnable onSuccess) { |
| | | openPosition(wsClient, config.getQuantity(), OkxEnums.POSSIDE_LONG, OkxEnums.SIDE_BUY, "开多", onSuccess); |
| | | } |
| | | |
| | | public void openShort(WebSocketClient wsClient, Runnable onSuccess) { |
| | | openPosition(wsClient, config.getQuantity(), OkxEnums.POSSIDE_SHORT, OkxEnums.SIDE_SELL, "开空", onSuccess); |
| | | } |
| | | |
| | | private void openPosition(WebSocketClient wsClient, String sz, String posSide, String side, String label, Runnable onSuccess) { |
| | | executor.execute(() -> { |
| | | try { |
| | | TradeRequestParam param = buildParam(side, posSide, sz, OkxEnums.ORDTYPE_MARKET); |
| | | sendOrder(wsClient, param); |
| | | |
| | | log.info("[TradeExec] {}已发送, 方向:{}, 数量:{}", label, posSide, sz); |
| | | if (onSuccess != null) { |
| | | onSuccess.run(); |
| | | } |
| | | } catch (Exception e) { |
| | | log.error("[TradeExec] {}发送失败", label, e); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | public void placeTakeProfit(WebSocketClient wsClient, String posSide, BigDecimal triggerPrice, String size) { |
| | | executor.execute(() -> { |
| | | try { |
| | | String side = OkxEnums.POSSIDE_LONG.equals(posSide) ? OkxEnums.SIDE_SELL : OkxEnums.SIDE_BUY; |
| | | |
| | | TradeRequestParam param = buildParam(side, posSide, size, OkxEnums.ORDTYPE_LIMIT); |
| | | param.setMarkPx(triggerPrice.toString()); |
| | | |
| | | List<TradeRequestParam> params = new ArrayList<>(); |
| | | params.add(param); |
| | | sendBatchOrders(wsClient, params); |
| | | log.info("[TradeExec] 止盈单已发送, 方向:{}, 触发价:{}, 数量:{}", posSide, triggerPrice, size); |
| | | } catch (Exception e) { |
| | | log.error("[TradeExec] 止盈单发送失败", e); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | private TradeRequestParam buildParam(String side, String posSide, String sz, String ordType) { |
| | | TradeRequestParam param = new TradeRequestParam(); |
| | | param.setAccountName(accountName); |
| | | param.setInstId(config.getContract()); |
| | | param.setTdMode(config.getMarginMode()); |
| | | param.setPosSide(posSide); |
| | | param.setOrdType(ordType); |
| | | param.setSide(side); |
| | | param.setClOrdId(OkxWsUtil.getOrderNum(side)); |
| | | param.setSz(sz); |
| | | param.setTradeType("1"); |
| | | return param; |
| | | } |
| | | |
| | | private void sendOrder(WebSocketClient wsClient, TradeRequestParam param) { |
| | | if (wsClient == null || !wsClient.isOpen()) { |
| | | log.warn("[TradeExec] WS未连接,跳过下单"); |
| | | return; |
| | | } |
| | | if (BigDecimal.ZERO.compareTo(new BigDecimal(param.getSz())) >= 0) { |
| | | log.warn("[TradeExec] 下单数量<=0,跳过"); |
| | | return; |
| | | } |
| | | |
| | | JSONArray argsArray = new JSONArray(); |
| | | JSONObject args = new JSONObject(); |
| | | args.put("instId", param.getInstId()); |
| | | args.put("tdMode", param.getTdMode()); |
| | | args.put("clOrdId", param.getClOrdId()); |
| | | args.put("side", param.getSide()); |
| | | args.put("posSide", param.getPosSide()); |
| | | args.put("ordType", param.getOrdType()); |
| | | args.put("sz", param.getSz()); |
| | | argsArray.add(args); |
| | | |
| | | String connId = OkxWsUtil.getOrderNum("order"); |
| | | JSONObject msg = OkxWsUtil.buildJsonObject(connId, "order", argsArray); |
| | | wsClient.send(msg.toJSONString()); |
| | | log.info("[TradeExec] 发送下单: side={}, sz={}", param.getSide(), param.getSz()); |
| | | } |
| | | |
| | | private void sendBatchOrders(WebSocketClient wsClient, List<TradeRequestParam> params) { |
| | | if (wsClient == null || !wsClient.isOpen() || params == null || params.isEmpty()) { |
| | | log.warn("[TradeExec] WS未连接或参数为空,跳过批量下单"); |
| | | return; |
| | | } |
| | | |
| | | JSONArray argsArray = new JSONArray(); |
| | | for (TradeRequestParam p : params) { |
| | | JSONObject args = new JSONObject(); |
| | | args.put("instId", p.getInstId()); |
| | | args.put("tdMode", p.getTdMode()); |
| | | args.put("clOrdId", p.getClOrdId()); |
| | | args.put("side", p.getSide()); |
| | | args.put("posSide", p.getPosSide()); |
| | | args.put("ordType", p.getOrdType()); |
| | | args.put("sz", p.getSz()); |
| | | args.put("px", p.getMarkPx()); |
| | | argsArray.add(args); |
| | | } |
| | | |
| | | String connId = OkxWsUtil.getOrderNum(null); |
| | | JSONObject msg = OkxWsUtil.buildJsonObject(connId, "batch-orders", argsArray); |
| | | wsClient.send(msg.toJSONString()); |
| | | log.info("[TradeExec] 发送批量下单: {}条", params.size()); |
| | | } |
| | | } |
| New file |
| | |
| | | package com.xcong.excoin.modules.okxApi; |
| | | |
| | | import org.springframework.context.ApplicationContext; |
| | | import org.springframework.context.support.ClassPathXmlApplicationContext; |
| | | |
| | | /** |
| | | * OKX 模块独立测试入口。 |
| | | * |
| | | * <p>通过 Spring XML 上下文(applicationContext.xml)初始化管理器, |
| | | * 运行极长时间后手动关闭。用于脱离 Web 容器独立调试策略。 |
| | | * |
| | | * @author Administrator |
| | | */ |
| | | public class OkxWebSocketClientMain { |
| | | public static void main(String[] args) throws InterruptedException { |
| | | ApplicationContext context = new ClassPathXmlApplicationContext("applicationContext.xml"); |
| | | OkxWebSocketClientManager manager = context.getBean(OkxWebSocketClientManager.class); |
| | | |
| | | Thread.sleep(1200000000L); |
| | | |
| | | manager.destroy(); |
| | | } |
| | | } |
| New file |
| | |
| | | package com.xcong.excoin.modules.okxApi; |
| | | |
| | | import com.xcong.excoin.modules.okxApi.wsHandler.handler.OkxAccountChannelHandler; |
| | | import com.xcong.excoin.modules.okxApi.wsHandler.handler.OkxCandlestickChannelHandler; |
| | | import com.xcong.excoin.modules.okxApi.wsHandler.handler.OkxOrderInfoChannelHandler; |
| | | import com.xcong.excoin.modules.okxApi.wsHandler.handler.OkxPositionsChannelHandler; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.springframework.stereotype.Component; |
| | | |
| | | import javax.annotation.PostConstruct; |
| | | import javax.annotation.PreDestroy; |
| | | import java.math.BigDecimal; |
| | | |
| | | @Slf4j |
| | | @Component |
| | | public class OkxWebSocketClientManager { |
| | | |
| | | private OkxKlineWebSocketClient wsKlineClient; |
| | | private OkxKlineWebSocketClient wsPrivateClient; |
| | | private OkxGridTradeService gridTradeService; |
| | | private OkxConfig config; |
| | | |
| | | @PostConstruct |
| | | public void init() { |
| | | log.info("[管理器] 开始初始化..."); |
| | | |
| | | try { |
| | | config = OkxConfig.builder() |
| | | .apiKey("d90ca272391992b8e74f8f92cedb21ec") |
| | | .secretKey("1861e4f52de4bb53369ea3208d9ede38ece4777368030f96c77d27934c46c274") |
| | | .passphrase("Aa123123@") |
| | | .contract("BTC-USDT-SWAP") |
| | | .leverage("100") |
| | | .marginMode("cross") |
| | | .gridRate(new BigDecimal("0.0015")) |
| | | .overallTp(new BigDecimal("5")) |
| | | .maxLoss(new BigDecimal("15")) |
| | | .quantity("1") |
| | | .contractMultiplier(new BigDecimal("1")) |
| | | .unrealizedPnlPriceMode(OkxConfig.PnLPriceMode.LAST_PRICE) |
| | | .isProduction(false) |
| | | .build(); |
| | | |
| | | String accountName = "OKX_API"; |
| | | gridTradeService = new OkxGridTradeService(config, accountName); |
| | | gridTradeService.startGrid(); |
| | | |
| | | wsKlineClient = new OkxKlineWebSocketClient(config.getWsKlineUrl()); |
| | | wsKlineClient.addChannelHandler(new OkxCandlestickChannelHandler(config.getContract(), gridTradeService)); |
| | | wsKlineClient.init(); |
| | | log.info("[管理器] K线WS已连接, 已注册K线频道处理器"); |
| | | |
| | | wsPrivateClient = new OkxKlineWebSocketClient( |
| | | config.getWsPrivateUrl(), |
| | | config.getApiKey(), |
| | | config.getSecretKey(), |
| | | config.getPassphrase()); |
| | | wsPrivateClient.addChannelHandler(new OkxPositionsChannelHandler(config.getContract(), gridTradeService)); |
| | | wsPrivateClient.addChannelHandler(new OkxAccountChannelHandler()); |
| | | wsPrivateClient.addChannelHandler(new OkxOrderInfoChannelHandler(config.getContract(), gridTradeService, config)); |
| | | wsPrivateClient.init(); |
| | | log.info("[管理器] 私有WS已连接, 已注册 3 个频道处理器"); |
| | | |
| | | gridTradeService.setWebSocketClient(wsPrivateClient.getWebSocketClient()); |
| | | |
| | | } catch (Exception e) { |
| | | log.error("[管理器] 初始化失败", e); |
| | | } |
| | | } |
| | | |
| | | @PreDestroy |
| | | public void destroy() { |
| | | log.info("[管理器] 开始销毁..."); |
| | | |
| | | if (gridTradeService != null) { |
| | | gridTradeService.stopGrid(); |
| | | } |
| | | if (wsKlineClient != null) { |
| | | wsKlineClient.destroy(); |
| | | } |
| | | if (wsPrivateClient != null) { |
| | | wsPrivateClient.destroy(); |
| | | } |
| | | |
| | | log.info("[管理器] 销毁完成"); |
| | | } |
| | | |
| | | public OkxKlineWebSocketClient getKlineWebSocketClient() { return wsKlineClient; } |
| | | public OkxKlineWebSocketClient getPrivateWebSocketClient() { return wsPrivateClient; } |
| | | public OkxGridTradeService getGridTradeService() { return gridTradeService; } |
| | | } |
| New file |
| | |
| | | package com.xcong.excoin.modules.okxApi; |
| | | |
| | | import com.alibaba.fastjson.JSONArray; |
| | | import com.alibaba.fastjson.JSONObject; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | |
| | | import javax.crypto.Mac; |
| | | import javax.crypto.spec.SecretKeySpec; |
| | | import javax.net.ssl.SSLContext; |
| | | import javax.net.ssl.TrustManager; |
| | | import javax.net.ssl.X509TrustManager; |
| | | import java.security.SecureRandom; |
| | | import java.security.cert.X509Certificate; |
| | | import java.text.SimpleDateFormat; |
| | | import java.util.Base64; |
| | | import java.util.Date; |
| | | import java.util.Random; |
| | | |
| | | /** |
| | | * OKX API 工具类:SSL配置、HMAC-SHA256签名、订单ID生成、日期格式化、JSON构建。 |
| | | * |
| | | * @author Administrator |
| | | */ |
| | | @Slf4j |
| | | public final class OkxWsUtil { |
| | | |
| | | private OkxWsUtil() {} |
| | | |
| | | // ==================== SSL ==================== |
| | | |
| | | public static void configureSSL() { |
| | | try { |
| | | TrustManager[] trustAllCerts = new TrustManager[]{ |
| | | new X509TrustManager() { |
| | | @Override |
| | | public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; } |
| | | @Override |
| | | public void checkClientTrusted(X509Certificate[] certs, String authType) {} |
| | | @Override |
| | | public void checkServerTrusted(X509Certificate[] certs, String authType) {} |
| | | } |
| | | }; |
| | | SSLContext sc = SSLContext.getInstance("TLS"); |
| | | sc.init(null, trustAllCerts, new SecureRandom()); |
| | | SSLContext.setDefault(sc); |
| | | } catch (Exception e) { |
| | | log.error("SSL配置失败", e); |
| | | } |
| | | } |
| | | |
| | | // ==================== HMAC-SHA256 签名 ==================== |
| | | |
| | | public static String signWebsocket(String timestamp, String secretKey) { |
| | | try { |
| | | String message = String.format("%s%s%s", timestamp, "GET", "/users/self/verify"); |
| | | Mac mac = Mac.getInstance("HmacSHA256"); |
| | | SecretKeySpec spec = new SecretKeySpec(secretKey.getBytes("UTF-8"), "HmacSHA256"); |
| | | mac.init(spec); |
| | | byte[] hash = mac.doFinal(message.getBytes("UTF-8")); |
| | | return Base64.getEncoder().encodeToString(hash); |
| | | } catch (Exception e) { |
| | | log.error("签名计算失败", e); |
| | | return ""; |
| | | } |
| | | } |
| | | |
| | | // ==================== 订单ID ==================== |
| | | |
| | | public static String getOrderNum(String prefix) { |
| | | SimpleDateFormat df = new SimpleDateFormat("yyyyMMddHHmmss"); |
| | | String dd = df.format(new Date()); |
| | | if (prefix != null && !prefix.isEmpty()) { |
| | | return prefix + dd + getRandomNum(5); |
| | | } |
| | | return dd + getRandomNum(5); |
| | | } |
| | | |
| | | private static String getRandomNum(int length) { |
| | | String str = "0123456789"; |
| | | Random random = new Random(); |
| | | StringBuilder sb = new StringBuilder(); |
| | | for (int i = 0; i < length; ++i) { |
| | | sb.append(str.charAt(random.nextInt(str.length()))); |
| | | } |
| | | return sb.toString(); |
| | | } |
| | | |
| | | // ==================== JSON构建 ==================== |
| | | |
| | | public static JSONObject buildJsonObject(String connId, String op, JSONArray args) { |
| | | JSONObject jsonObject = new JSONObject(); |
| | | if (connId != null && !connId.isEmpty()) { |
| | | jsonObject.put("id", connId); |
| | | } |
| | | jsonObject.put("op", op); |
| | | jsonObject.put("args", args); |
| | | return jsonObject; |
| | | } |
| | | |
| | | // ==================== 日期格式化 ==================== |
| | | |
| | | public static String timestampToDateTime(long timestamp) { |
| | | SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); |
| | | return sdf.format(new Date(timestamp)); |
| | | } |
| | | } |
| New file |
| | |
| | | package com.xcong.excoin.modules.okxApi.enums; |
| | | |
| | | /** |
| | | * OKX API 模块枚举常量。 |
| | | * |
| | | * @author Administrator |
| | | */ |
| | | public final class OkxEnums { |
| | | |
| | | private OkxEnums() {} |
| | | |
| | | public static final String POSSIDE_LONG = "long"; |
| | | public static final String POSSIDE_SHORT = "short"; |
| | | public static final String SIDE_BUY = "buy"; |
| | | public static final String SIDE_SELL = "sell"; |
| | | public static final String ORDTYPE_MARKET = "market"; |
| | | public static final String ORDTYPE_LIMIT = "limit"; |
| | | public static final String INSTTYPE_SWAP = "SWAP"; |
| | | public static final String MARGIN_CROSS = "cross"; |
| | | |
| | | public static final String CHANNEL_CANDLE = "candle1m"; |
| | | public static final String CHANNEL_POSITIONS = "positions"; |
| | | public static final String CHANNEL_ACCOUNT = "account"; |
| | | public static final String CHANNEL_ORDERS = "orders"; |
| | | } |
| New file |
| | |
| | | package com.xcong.excoin.modules.okxApi.param; |
| | | |
| | | /** |
| | | * 交易请求参数。 |
| | | * |
| | | * @author Administrator |
| | | */ |
| | | public class TradeRequestParam { |
| | | |
| | | private String accountName; |
| | | private String markPx; |
| | | private String instId; |
| | | private String tdMode; |
| | | private String posSide; |
| | | private String ordType; |
| | | private String tradeType; |
| | | private String side; |
| | | private String clOrdId; |
| | | private String sz; |
| | | |
| | | public String getAccountName() { return accountName; } |
| | | public void setAccountName(String accountName) { this.accountName = accountName; } |
| | | public String getMarkPx() { return markPx; } |
| | | public void setMarkPx(String markPx) { this.markPx = markPx; } |
| | | public String getInstId() { return instId; } |
| | | public void setInstId(String instId) { this.instId = instId; } |
| | | public String getTdMode() { return tdMode; } |
| | | public void setTdMode(String tdMode) { this.tdMode = tdMode; } |
| | | public String getPosSide() { return posSide; } |
| | | public void setPosSide(String posSide) { this.posSide = posSide; } |
| | | public String getOrdType() { return ordType; } |
| | | public void setOrdType(String ordType) { this.ordType = ordType; } |
| | | public String getTradeType() { return tradeType; } |
| | | public void setTradeType(String tradeType) { this.tradeType = tradeType; } |
| | | public String getSide() { return side; } |
| | | public void setSide(String side) { this.side = side; } |
| | | public String getClOrdId() { return clOrdId; } |
| | | public void setClOrdId(String clOrdId) { this.clOrdId = clOrdId; } |
| | | public String getSz() { return sz; } |
| | | public void setSz(String sz) { this.sz = sz; } |
| | | } |
| New file |
| | |
| | | package com.xcong.excoin.modules.okxApi.wsHandler; |
| | | |
| | | import com.alibaba.fastjson.JSONObject; |
| | | import org.java_websocket.client.WebSocketClient; |
| | | |
| | | /** |
| | | * OKX WebSocket 频道处理器接口。 |
| | | * |
| | | * <p>每个 OKX 频道对应一个实现类。新增频道只需实现此接口, |
| | | * 然后通过 {@code OkxKlineWebSocketClient.addChannelHandler()} 注册即可。 |
| | | * |
| | | * <h3>实现类</h3> |
| | | * <ul> |
| | | * <li>{@code OkxCandlestickChannelHandler} — K 线数据</li> |
| | | * <li>{@code OkxPositionsChannelHandler} — 持仓更新</li> |
| | | * <li>{@code OkxAccountChannelHandler} — 账户信息</li> |
| | | * <li>{@code OkxOrderInfoChannelHandler} — 订单信息(止盈止损、PnL跟踪)</li> |
| | | * </ul> |
| | | * |
| | | * <h3>路由机制</h3> |
| | | * {@code handleMessage()} 返回 {@code true} 表示消息已被该 handler 处理, |
| | | * 路由循环会停止遍历。返回 {@code false} 表示不匹配(channel 名不相等)。 |
| | | * |
| | | * @author Administrator |
| | | */ |
| | | public interface OkxChannelHandler { |
| | | |
| | | /** 频道名称,如 {@code "candle1m"} */ |
| | | String getChannelName(); |
| | | |
| | | /** 发送订阅请求 */ |
| | | void subscribe(WebSocketClient ws); |
| | | |
| | | /** 发送取消订阅请求 */ |
| | | void unsubscribe(WebSocketClient ws); |
| | | |
| | | /** |
| | | * 处理频道推送消息。 |
| | | * |
| | | * @param response WebSocket 推送的完整 JSON |
| | | * @return true 表示已处理(循环停止),false 表示频道不匹配(继续遍历下一个 handler) |
| | | */ |
| | | boolean handleMessage(JSONObject response); |
| | | } |
| New file |
| | |
| | | package com.xcong.excoin.modules.okxApi.wsHandler.handler; |
| | | |
| | | import com.alibaba.fastjson.JSONArray; |
| | | import com.alibaba.fastjson.JSONObject; |
| | | import com.xcong.excoin.modules.okxApi.wsHandler.OkxChannelHandler; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.java_websocket.client.WebSocketClient; |
| | | |
| | | /** |
| | | * OKX 账户频道处理器(account)。 |
| | | * |
| | | * <h3>数据用途</h3> |
| | | * 监控账户余额和保证金信息。用于保证金安全阀检查。 |
| | | * |
| | | * <h3>推送字段</h3> |
| | | * ccy, availBal, cashBal, eq, upl, imr, ordFroz |
| | | * |
| | | * <h3>注意</h3> |
| | | * 当前版本仅做日志输出,后续可扩展保证金安全阀功能。 |
| | | * |
| | | * @author Administrator |
| | | */ |
| | | @Slf4j |
| | | public class OkxAccountChannelHandler implements OkxChannelHandler { |
| | | |
| | | private static final String CHANNEL_NAME = "account"; |
| | | private static final String CHANNEL = "account"; |
| | | |
| | | public OkxAccountChannelHandler() { |
| | | } |
| | | |
| | | @Override |
| | | public String getChannelName() { |
| | | return CHANNEL_NAME; |
| | | } |
| | | |
| | | @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); |
| | | args.add(arg); |
| | | msg.put("args", args); |
| | | ws.send(msg.toJSONString()); |
| | | log.info("[{}] 订阅成功", CHANNEL_NAME); |
| | | } |
| | | |
| | | @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); |
| | | args.add(arg); |
| | | msg.put("args", args); |
| | | ws.send(msg.toJSONString()); |
| | | log.info("[{}] 取消订阅成功", CHANNEL_NAME); |
| | | } |
| | | |
| | | @Override |
| | | public boolean handleMessage(JSONObject response) { |
| | | JSONObject argObj = response.getJSONObject("arg"); |
| | | if (argObj == null) { |
| | | return false; |
| | | } |
| | | String channel = argObj.getString("channel"); |
| | | if (!CHANNEL.equals(channel)) { |
| | | return false; |
| | | } |
| | | try { |
| | | JSONArray dataArray = response.getJSONArray("data"); |
| | | if (dataArray == null || dataArray.isEmpty()) { |
| | | return true; |
| | | } |
| | | for (int i = 0; i < dataArray.size(); i++) { |
| | | JSONObject acct = dataArray.getJSONObject(i); |
| | | log.info("[{}] 账户更新, 可用余额:{}, 现金余额:{}, 权益:{}, 未实现盈亏:{}, 保证金:{}", |
| | | CHANNEL_NAME, |
| | | acct.get("availBal"), acct.get("cashBal"), |
| | | acct.get("eq"), acct.get("upl"), acct.get("imr")); |
| | | } |
| | | } catch (Exception e) { |
| | | log.error("[{}] 处理数据失败", CHANNEL_NAME, e); |
| | | } |
| | | return true; |
| | | } |
| | | } |
| New file |
| | |
| | | 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.OkxWsUtil; |
| | | import com.xcong.excoin.modules.okxApi.enums.OkxEnums; |
| | | import com.xcong.excoin.modules.okxApi.wsHandler.OkxChannelHandler; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.java_websocket.client.WebSocketClient; |
| | | |
| | | import java.math.BigDecimal; |
| | | |
| | | @Slf4j |
| | | public class OkxCandlestickChannelHandler implements OkxChannelHandler { |
| | | |
| | | private final String instId; |
| | | private final OkxGridTradeService gridTradeService; |
| | | |
| | | public OkxCandlestickChannelHandler(String instId, OkxGridTradeService gridTradeService) { |
| | | this.instId = instId; |
| | | this.gridTradeService = gridTradeService; |
| | | } |
| | | |
| | | @Override |
| | | public String getChannelName() { |
| | | return OkxEnums.CHANNEL_CANDLE; |
| | | } |
| | | |
| | | @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", OkxEnums.CHANNEL_CANDLE); |
| | | arg.put("instId", instId); |
| | | args.add(arg); |
| | | msg.put("args", args); |
| | | ws.send(msg.toJSONString()); |
| | | log.info("[{}] 订阅成功, 合约:{}, 周期:1m", OkxEnums.CHANNEL_CANDLE, instId); |
| | | } |
| | | |
| | | @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", OkxEnums.CHANNEL_CANDLE); |
| | | arg.put("instId", instId); |
| | | args.add(arg); |
| | | msg.put("args", args); |
| | | ws.send(msg.toJSONString()); |
| | | log.info("[{}] 取消订阅成功", OkxEnums.CHANNEL_CANDLE); |
| | | } |
| | | |
| | | @Override |
| | | public boolean handleMessage(JSONObject response) { |
| | | JSONObject argObj = response.getJSONObject("arg"); |
| | | if (argObj == null) { |
| | | return false; |
| | | } |
| | | String channel = argObj.getString("channel"); |
| | | if (!OkxEnums.CHANNEL_CANDLE.equals(channel)) { |
| | | return false; |
| | | } |
| | | String msgInstId = argObj.getString("instId"); |
| | | if (!instId.equals(msgInstId)) { |
| | | return false; |
| | | } |
| | | try { |
| | | JSONArray dataArray = response.getJSONArray("data"); |
| | | if (dataArray == null || dataArray.isEmpty()) { |
| | | log.warn("[{}] 数据为空", OkxEnums.CHANNEL_CANDLE); |
| | | return true; |
| | | } |
| | | JSONArray data = dataArray.getJSONArray(0); |
| | | BigDecimal closePx = new BigDecimal(data.getString(4)); |
| | | String time = OkxWsUtil.timestampToDateTime(Long.parseLong(data.getString(0))); |
| | | String confirm = data.getString(8); |
| | | |
| | | log.info("[{}] 收盘:{}, 时间:{}, 完结:{}", OkxEnums.CHANNEL_CANDLE, closePx, time, "1".equals(confirm)); |
| | | |
| | | if (gridTradeService != null) { |
| | | gridTradeService.onKline(closePx); |
| | | } |
| | | } catch (Exception e) { |
| | | log.error("[{}] 处理数据失败", OkxEnums.CHANNEL_CANDLE, e); |
| | | } |
| | | return true; |
| | | } |
| | | } |
| New file |
| | |
| | | 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.OkxChannelHandler; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.java_websocket.client.WebSocketClient; |
| | | |
| | | import java.math.BigDecimal; |
| | | |
| | | /** |
| | | * OKX 订单信息频道处理器(orders)。 |
| | | * |
| | | * <h3>数据用途</h3> |
| | | * 监控订单成交(filled)状态,跟踪已实现盈亏(fillPnl)。 |
| | | * 成交后通过 batch-orders 频道设置止盈止损限价单。 |
| | | * |
| | | * <h3>推送字段</h3> |
| | | * instId, ordId, clOrdId, side, posSide, state, accFillSz, avgPx, fillPnl, fillFee |
| | | * |
| | | * <h3>数据处理</h3> |
| | | * state=filled 且 accFillSz>0 → 成交 → 调用 gridTradeService.onOrderFilled() 跟踪盈亏 |
| | | * |
| | | * @author Administrator |
| | | */ |
| | | @Slf4j |
| | | public class OkxOrderInfoChannelHandler implements OkxChannelHandler { |
| | | |
| | | private static final String CHANNEL_NAME = "orders"; |
| | | private static final String CHANNEL = "orders"; |
| | | |
| | | private final String instId; |
| | | private final OkxGridTradeService gridTradeService; |
| | | private final OkxConfig config; |
| | | |
| | | public OkxOrderInfoChannelHandler(String instId, OkxGridTradeService gridTradeService, OkxConfig config) { |
| | | this.instId = instId; |
| | | this.gridTradeService = gridTradeService; |
| | | this.config = config; |
| | | } |
| | | |
| | | @Override |
| | | public String getChannelName() { |
| | | return CHANNEL_NAME; |
| | | } |
| | | |
| | | @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); |
| | | arg.put("instType", "SWAP"); |
| | | arg.put("instId", instId); |
| | | args.add(arg); |
| | | msg.put("args", args); |
| | | ws.send(msg.toJSONString()); |
| | | log.info("[{}] 订阅成功, 合约:{}", CHANNEL_NAME, instId); |
| | | } |
| | | |
| | | @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); |
| | | arg.put("instType", "SWAP"); |
| | | arg.put("instId", instId); |
| | | args.add(arg); |
| | | msg.put("args", args); |
| | | ws.send(msg.toJSONString()); |
| | | log.info("[{}] 取消订阅成功", CHANNEL_NAME); |
| | | } |
| | | |
| | | @Override |
| | | public boolean handleMessage(JSONObject response) { |
| | | JSONObject argObj = response.getJSONObject("arg"); |
| | | if (argObj == null) { |
| | | return false; |
| | | } |
| | | String channel = argObj.getString("channel"); |
| | | if (!CHANNEL.equals(channel)) { |
| | | return false; |
| | | } |
| | | try { |
| | | JSONArray dataArray = response.getJSONArray("data"); |
| | | if (dataArray == null || dataArray.isEmpty()) { |
| | | return true; |
| | | } |
| | | for (int i = 0; i < dataArray.size(); i++) { |
| | | JSONObject detail = dataArray.getJSONObject(i); |
| | | if (!instId.equals(detail.getString("instId"))) { |
| | | continue; |
| | | } |
| | | String state = detail.getString("state"); |
| | | String accFillSz = detail.getString("accFillSz"); |
| | | String fillPnl = detail.getString("fillPnl"); |
| | | String posSide = detail.getString("posSide"); |
| | | String avgPx = detail.getString("avgPx"); |
| | | String clOrdId = detail.getString("clOrdId"); |
| | | |
| | | log.info("[{}] 订单, 方向:{}, 状态:{}, 成交量:{}, 均价:{}, 盈亏:{}, 编号:{}", |
| | | CHANNEL_NAME, posSide, state, accFillSz, avgPx, fillPnl, clOrdId); |
| | | |
| | | if ("filled".equals(state) && accFillSz != null && new BigDecimal(accFillSz).compareTo(BigDecimal.ZERO) > 0) { |
| | | if (gridTradeService != null) { |
| | | BigDecimal pnl = fillPnl != null ? new BigDecimal(fillPnl) : BigDecimal.ZERO; |
| | | gridTradeService.onOrderFilled(posSide, new BigDecimal(accFillSz), pnl); |
| | | } |
| | | } |
| | | } |
| | | } catch (Exception e) { |
| | | log.error("[{}] 处理数据失败", CHANNEL_NAME, e); |
| | | } |
| | | return true; |
| | | } |
| | | } |
| New file |
| | |
| | | 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.enums.OkxEnums; |
| | | import com.xcong.excoin.modules.okxApi.wsHandler.OkxChannelHandler; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.java_websocket.client.WebSocketClient; |
| | | |
| | | import java.math.BigDecimal; |
| | | |
| | | @Slf4j |
| | | public class OkxPositionsChannelHandler implements OkxChannelHandler { |
| | | |
| | | private final String instId; |
| | | private final OkxGridTradeService gridTradeService; |
| | | |
| | | public OkxPositionsChannelHandler(String instId, OkxGridTradeService gridTradeService) { |
| | | this.instId = instId; |
| | | this.gridTradeService = gridTradeService; |
| | | } |
| | | |
| | | @Override |
| | | public String getChannelName() { |
| | | return OkxEnums.CHANNEL_POSITIONS; |
| | | } |
| | | |
| | | @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", OkxEnums.CHANNEL_POSITIONS); |
| | | arg.put("instType", OkxEnums.INSTTYPE_SWAP); |
| | | arg.put("instId", instId); |
| | | args.add(arg); |
| | | msg.put("args", args); |
| | | ws.send(msg.toJSONString()); |
| | | log.info("[{}] 订阅成功, 合约:{}", OkxEnums.CHANNEL_POSITIONS, instId); |
| | | } |
| | | |
| | | @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", OkxEnums.CHANNEL_POSITIONS); |
| | | arg.put("instType", OkxEnums.INSTTYPE_SWAP); |
| | | arg.put("instId", instId); |
| | | args.add(arg); |
| | | msg.put("args", args); |
| | | ws.send(msg.toJSONString()); |
| | | log.info("[{}] 取消订阅成功", OkxEnums.CHANNEL_POSITIONS); |
| | | } |
| | | |
| | | @Override |
| | | public boolean handleMessage(JSONObject response) { |
| | | JSONObject argObj = response.getJSONObject("arg"); |
| | | if (argObj == null) { |
| | | return false; |
| | | } |
| | | String channel = argObj.getString("channel"); |
| | | if (!OkxEnums.CHANNEL_POSITIONS.equals(channel)) { |
| | | return false; |
| | | } |
| | | try { |
| | | JSONArray dataArray = response.getJSONArray("data"); |
| | | if (dataArray == null || dataArray.isEmpty()) { |
| | | return true; |
| | | } |
| | | for (int i = 0; i < dataArray.size(); i++) { |
| | | JSONObject pos = dataArray.getJSONObject(i); |
| | | if (!instId.equals(pos.getString("instId"))) { |
| | | continue; |
| | | } |
| | | String posSide = pos.getString("posSide"); |
| | | BigDecimal size = new BigDecimal(pos.getString("pos")); |
| | | BigDecimal avgPx = pos.containsKey("avgPx") && pos.getString("avgPx") != null |
| | | ? new BigDecimal(pos.getString("avgPx")) : BigDecimal.ZERO; |
| | | |
| | | log.info("[{}] 持仓更新, 方向:{}, 数量:{}, 均价:{}, 未实现盈亏:{}, 保证金:{}", |
| | | OkxEnums.CHANNEL_POSITIONS, posSide, size, avgPx, |
| | | pos.get("upl"), pos.get("imr")); |
| | | |
| | | if (gridTradeService != null) { |
| | | gridTradeService.onPositionUpdate(posSide, size, avgPx); |
| | | } |
| | | } |
| | | } catch (Exception e) { |
| | | log.error("[{}] 处理数据失败", OkxEnums.CHANNEL_POSITIONS, e); |
| | | } |
| | | return true; |
| | | } |
| | | } |