Administrator
4 days ago 5982ab32ef6f4af48426f35e57ccd829fea7bfbf
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
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 类型,tpTriggerPx)。
     *
     * <p>止盈单创建失败时立即调用 {@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) {
        placeConditionalClose(triggerPrice, orderType, size, onSuccess, false);
    }
 
    /**
     * 异步创建止损条件单(OKX 算法订单 — conditional 类型,slTriggerPx)。
     *
     * <p>止损单创建失败时立即调用 {@link #marketClose(String, String)} 市价平仓兜底。
     *
     * @param triggerPrice 触发价格
     * @param orderType    平仓类型:"close_long" 平多 / "close_short" 平空
     * @param size         平仓张数(正数,如 "15")
     * @param onSuccess    成功回调,接收 algoId(可为 null)
     */
    public void placeStopLoss(BigDecimal triggerPrice,
                               String orderType,
                               String size,
                               Consumer<String> onSuccess) {
        placeConditionalClose(triggerPrice, orderType, size, onSuccess, true);
    }
 
    /**
     * 通用平仓条件单:isStopLoss=true 用 slTriggerPx/slOrdPx,false 用 tpTriggerPx/tpOrdPx。
     */
    private void placeConditionalClose(BigDecimal triggerPrice,
                                        String orderType,
                                        String size,
                                        Consumer<String> onSuccess,
                                        boolean isStopLoss) {
        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;
                }
 
                String label = isStopLoss ? "止损" : "止盈";
                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);
                // 止盈用 tp 系列字段,止损用 sl 系列字段
                if (isStopLoss) {
                    body.put("slTriggerPx", triggerPrice.stripTrailingZeros().toPlainString());
                    body.put("slTriggerPxType", "last");
                    body.put("slOrdPx", "-1");
                } else {
                    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:{}, 立即市价{}",
                            label, code, resp.getString("msg"), label);
                    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:{}",
                        label, 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";
                String posSide = isLong ? "long" : "short";
                // OKX sz 必须为正数,strategy 层传入的负数需转正
                String absSz = size.startsWith("-") ? size.substring(1) : size;
 
                JSONObject body = new JSONObject();
                body.put("instId", contract);
                body.put("tdMode", "cross");
                body.put("side", side);
                body.put("posSide", posSide);            // 双向持仓模式必须指定
                body.put("ordType", "trigger");          // 计划委托 = 触发后开仓
                body.put("sz", absSz);
                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);
            }
        });
    }
 
    /**
     * 异步清除指定合约的所有算法订单(条件单/止盈止损单)。
     *
     * <p>OKX 的 cancel-algos 接口要求必须传 algoId 或 algoClOrdId,
     * 不能仅凭 instId 批量取消。因此先查询待处理列表,再逐个取消。
     */
    public void cancelAllPriceTriggeredOrders() {
        executor.execute(() -> {
            try {
                // ordType 是 orders-algo-pending 的必填参数,需分别查询 conditional 和 trigger
                JSONArray cancelBody = new JSONArray();
                for (String ordType : new String[]{"conditional", "trigger"}) {
                    String queryPath = "/api/v5/trade/orders-algo-pending?instId=" + contract
                            + "&ordType=" + ordType;
                    try {
                        JSONObject queryResp = okGet(queryPath);
                        if (!"0".equals(queryResp.getString("code"))) {
                            log.warn("[TradeExec-OKX] 查询 pending ordType={} 失败, code:{}, msg:{}",
                                    ordType, queryResp.getString("code"), queryResp.getString("msg"));
                            continue;
                        }
                        JSONArray data = queryResp.getJSONArray("data");
                        if (data != null) {
                            for (int i = 0; i < data.size(); i++) {
                                JSONObject order = data.getJSONObject(i);
                                String algoId = order.getString("algoId");
                                if (algoId == null) {
                                    continue;
                                }
                                JSONObject item = new JSONObject();
                                item.put("algoId", algoId);
                                item.put("instId", contract);
                                cancelBody.add(item);
                            }
                        }
                    } catch (Exception e) {
                        log.warn("[TradeExec-OKX] 查询待处理条件单失败, ordType:{}", ordType, e);
                    }
                }
 
                if (cancelBody.isEmpty()) {
                    log.info("[TradeExec-OKX] 无待处理条件单");
                    return;
                }
 
                // 批量取消
                JSONObject cancelResp = okPost("/api/v5/trade/cancel-algos", cancelBody.toJSONString());
                String cancelCode = cancelResp.getString("code");
                if (!"0".equals(cancelCode)) {
                    log.warn("[TradeExec-OKX] 清除条件单部分失败, code:{}, msg:{}",
                            cancelCode, cancelResp.getString("msg"));
                    return;
                }
                log.info("[TradeExec-OKX] 已清除{}个条件单", cancelBody.size());
            } 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());
    }
}