Administrator
4 days ago 04063bcb7b9e9d8e0242c1313f54ccc1b71f0b6e
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
package com.xcong.excoin.modules.okxApi;
 
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.xcong.excoin.utils.dingtalk.DingTalkUtils;
import lombok.extern.slf4j.Slf4j;
 
import java.math.BigDecimal;
import java.math.RoundingMode;
 
/**
 * OKX 盈利回收循环策略 — 永远持有多空双向底仓,盈利兑现 + 利润反向加仓。
 *
 * <h3>核心思想</h3>
 * <ol>
 *   <li>永远持有多空双向底仓</li>
 *   <li>盈利的一边不断兑现利润</li>
 *   <li>用部分利润强化亏损的一边</li>
 *   <li>利用市场波动不断放大利润</li>
 * </ol>
 *
 * <h3>策略流程(状态机)</h3>
 * <pre>
 *   INIT → 双开底仓 → MONITOR
 *     ↓
 *   盈利达到50% → 平50%盈利仓 → 利润拆分 → 反向加仓 → 继续监控
 *     ↓
 *   账户盈利5% → 全部平仓 → 重新双开
 * </pre>
 *
 * <h3>数学模型</h3>
 * <ul>
 *   <li>ROI = 未实现盈亏 / 该方向保证金</li>
 *   <li>触发条件:ROI ≥ profitTriggerRatio (默认50%)</li>
 *   <li>平仓张数:floor(positionSize / 2)</li>
 *   <li>收益 A = 已实现利润</li>
 *   <li>保证金 B = A × reinvestRatio (默认50%)</li>
 *   <li>单张保证金 = contractMultiplier × entryPrice / leverage</li>
 *   <li>补仓张数 = max(baseQty, floor(B / 单张保证金))</li>
 * </ul>
 *
 * <h3>风控</h3>
 * <ul>
 *   <li>风控1:反向仓位倍数上限 maxPositionMultiplier(默认10x)</li>
 *   <li>风控2:保证金占比检查(全局亏损 maxLoss 阈值)</li>
 *   <li>风控3:权益增长 equityRestartRatio(默认5%)全平重置</li>
 * </ul>
 *
 * @author Administrator
 */
@Slf4j
public class OkxProfitRecycleStrategy implements IOkxStrategy {
 
    public enum StrategyState {
        WAITING_KLINE,
        OPENING,
        ACTIVE,
        RESTARTING,
        STOPPED
    }
 
    private final OkxConfig config;
    private final OkxTradeExecutor executor;
 
    private volatile StrategyState state = StrategyState.WAITING_KLINE;
 
    // ---- 价格 ----
    private volatile BigDecimal lastPrice = BigDecimal.ZERO;
    private volatile BigDecimal markPrice = BigDecimal.ZERO;
 
    // ---- 持仓 ----
    private volatile BigDecimal longPositionSize = BigDecimal.ZERO;
    private volatile BigDecimal longEntryPrice = BigDecimal.ZERO;
    private volatile BigDecimal shortPositionSize = BigDecimal.ZERO;
    private volatile BigDecimal shortEntryPrice = BigDecimal.ZERO;
 
    // ---- 盈亏 ----
    private volatile BigDecimal cumulativePnl = BigDecimal.ZERO;
    private volatile BigDecimal initialPrincipal = BigDecimal.ZERO;
    private volatile boolean rebalancing = false;
    /** 循环计数器(每完成一次"平仓+反向加仓"计数+1) */
    private volatile int cycleCount = 0;
    /** 重置计数器(每完成一次权益重置计数+1) */
    private volatile int restartCount = 0;
 
    private volatile OkxKlineWebSocketClient wsClient;
 
    public OkxProfitRecycleStrategy(OkxConfig config) {
        this.config = config;
        this.executor = new OkxTradeExecutor(config);
    }
 
    // ==================== 生命周期 ====================
 
    public void init() {
        try {
            refreshInitialPrincipal();
            JSONObject posModeBody = new JSONObject();
            posModeBody.put("posMode", config.getPositionMode());
            executorPost("/api/v5/account/set-position-mode", posModeBody.toJSONString());
            log.info("[ProfitRecycle] 持仓模式: {}", config.getPositionMode());
 
            JSONObject levBody = new JSONObject();
            levBody.put("instId", config.getContract());
            levBody.put("lever", config.getLeverage());
            levBody.put("mgnMode", config.getMarginMode());
            executorPost("/api/v5/account/set-leverage", levBody.toJSONString());
            log.info("[ProfitRecycle] 杠杆: {}x {}", config.getLeverage(), config.getMarginMode());
 
            executor.cancelAllPriceTriggeredOrders();
            closeExistingPositions();
            log.info("[ProfitRecycle] 初始化完成, 本金: {} USDT, 合约: {}, 基础张数: {}",
                    initialPrincipal, config.getContract(), config.getBaseQuantity());
        } catch (Exception e) {
            log.error("[ProfitRecycle] 初始化失败", e);
        }
    }
 
    public void startStrategy() {
        if (state != StrategyState.WAITING_KLINE && state != StrategyState.STOPPED) {
            log.warn("[ProfitRecycle] 策略已在运行中, state:{}", state);
            return;
        }
        resetState();
        refreshInitialPrincipal();
        log.info("[ProfitRecycle] ✅ 策略启动 — 本金: {} USDT | 基础仓位: {}张 | 杠杆: {}x | "
                        + "触发ROI: {}% | 再投资: {}% | 仓位上限: {}x | 重置阈值: {}%",
                initialPrincipal, config.getBaseQuantity(), config.getLeverage(),
                config.getProfitTriggerRatio().multiply(new BigDecimal("100")),
                config.getReinvestRatio().multiply(new BigDecimal("100")),
                config.getMaxPositionMultiplier(),
                config.getEquityRestartRatio().multiply(new BigDecimal("100")));
    }
 
    public void stopStrategy() {
        state = StrategyState.STOPPED;
        executor.cancelAllPriceTriggeredOrders();
        closeExistingPositions();
        executor.shutdown();
        log.info("[ProfitRecycle] ⏹ 策略停止 — 累计盈亏: {} | 循环: {} | 重置: {}",
                cumulativePnl, cycleCount, restartCount);
    }
 
    @Override
    public boolean isStrategyActive() {
        return state != StrategyState.STOPPED && state != StrategyState.WAITING_KLINE;
    }
 
    private void resetState() {
        state = StrategyState.WAITING_KLINE;
        cumulativePnl = BigDecimal.ZERO;
        lastPrice = BigDecimal.ZERO;
        markPrice = BigDecimal.ZERO;
        longPositionSize = BigDecimal.ZERO;
        longEntryPrice = BigDecimal.ZERO;
        shortPositionSize = BigDecimal.ZERO;
        shortEntryPrice = BigDecimal.ZERO;
        rebalancing = false;
        cycleCount = 0;
    }
 
    // ==================== WS 回调 ====================
 
    @Override
    public void onKline(BigDecimal closePrice) {
        lastPrice = closePrice;
 
        if (state == StrategyState.WAITING_KLINE) {
            if (wsClient == null || !wsClient.areAllSubscribed()) return;
            state = StrategyState.OPENING;
            final String size = config.getBaseQuantity();
            log.info("[ProfitRecycle] 🚀 首根K线到达,多空双开各{}张", size);
            executor.openLong(size, ordId -> log.info("[ProfitRecycle] 多仓已提交 ordId:{}", ordId), null);
            executor.openShort(size, ordId -> log.info("[ProfitRecycle] 空仓已提交 ordId:{}", ordId), null);
            return;
        }
 
        if (state == StrategyState.ACTIVE) {
            checkAndRecycle();
        }
    }
 
    @Override
    public void setMarkPrice(BigDecimal markPrice) {
        this.markPrice = markPrice;
    }
 
    @Override
    public void onPositionUpdate(String contract, Direction direction,
                                 BigDecimal size, BigDecimal entryPrice) {
        if (state == StrategyState.STOPPED || state == StrategyState.WAITING_KLINE) return;
 
        boolean hasPos = size.abs().compareTo(BigDecimal.ZERO) > 0;
        boolean isLong = (direction == Direction.LONG);
 
        if (state == StrategyState.OPENING || state == StrategyState.RESTARTING) {
            if (isLong && hasPos) {
                longPositionSize = size;
                longEntryPrice = entryPrice;
                log.info("[ProfitRecycle] 多仓确认: {}张 @ {}", size, entryPrice);
                tryActivate();
            } else if (!isLong && hasPos) {
                shortPositionSize = size.abs();
                shortEntryPrice = entryPrice;
                log.info("[ProfitRecycle] 空仓确认: {}张 @ {}", size.abs(), entryPrice);
                tryActivate();
            }
        }
 
        if (state == StrategyState.ACTIVE) {
            if (isLong) {
                longPositionSize = hasPos ? size : BigDecimal.ZERO;
                longEntryPrice = hasPos ? entryPrice : BigDecimal.ZERO;
            } else {
                shortPositionSize = hasPos ? size.abs() : BigDecimal.ZERO;
                shortEntryPrice = hasPos ? entryPrice : BigDecimal.ZERO;
            }
        }
    }
 
    @Override
    public void onAutoOrder(String orderId, String status, String reason,
                            String orderType, String tradeId) {
        log.debug("[ProfitRecycle] 条件单: id={} status={}", orderId, status);
    }
 
    // ==================== 核心循环 ====================
 
    private void checkAndRecycle() {
        if (rebalancing) return;
 
        final BigDecimal price = resolvePrice();
        if (price.compareTo(BigDecimal.ZERO) <= 0) return;
 
        final BigDecimal mult = config.getContractMultiplier();
        final BigDecimal lev = new BigDecimal(config.getLeverage());
        final BigDecimal trigger = config.getProfitTriggerRatio();
 
        // 检查多仓
        if (longPositionSize.compareTo(BigDecimal.ZERO) > 0
                && longEntryPrice.compareTo(BigDecimal.ZERO) > 0) {
            BigDecimal pnl = longPositionSize.multiply(mult).multiply(price.subtract(longEntryPrice));
            BigDecimal margin = calcMargin(longPositionSize, longEntryPrice);
            BigDecimal roi = margin.compareTo(BigDecimal.ZERO) > 0
                    ? pnl.divide(margin, 4, RoundingMode.HALF_UP) : BigDecimal.ZERO;
 
            if (pnl.compareTo(margin.multiply(trigger)) >= 0) {
                log.info("[ProfitRecycle] 🔔 多头触发 | pnl={} margin={} ROI={}%", pnl, margin, roi.multiply(hundred()));
                executeRecycle(Direction.LONG, longPositionSize, pnl, longEntryPrice, price);
                return;
            }
        }
 
        // 检查空仓
        if (shortPositionSize.compareTo(BigDecimal.ZERO) > 0
                && shortEntryPrice.compareTo(BigDecimal.ZERO) > 0) {
            BigDecimal pnl = shortPositionSize.multiply(mult).multiply(shortEntryPrice.subtract(price));
            BigDecimal margin = calcMargin(shortPositionSize, shortEntryPrice);
            BigDecimal roi = margin.compareTo(BigDecimal.ZERO) > 0
                    ? pnl.divide(margin, 4, RoundingMode.HALF_UP) : BigDecimal.ZERO;
 
            if (pnl.compareTo(margin.multiply(trigger)) >= 0) {
                log.info("[ProfitRecycle] 🔔 空头触发 | pnl={} margin={} ROI={}%", pnl, margin, roi.multiply(hundred()));
                executeRecycle(Direction.SHORT, shortPositionSize, pnl, shortEntryPrice, price);
            }
        }
    }
 
    /**
     * 执行一次完整的盈利回收操作:
     * 平50%盈利仓 → 利润A → B=A×50% → 计算反方向张数 → 开反向仓
     */
    private void executeRecycle(Direction profitableSide,
                                 BigDecimal posSize, BigDecimal totalPnl,
                                 BigDecimal entryPrice, BigDecimal price) {
        rebalancing = true;
        final boolean isLongProfit = (profitableSide == Direction.LONG);
        final BigDecimal mult = config.getContractMultiplier();
        final BigDecimal lev = new BigDecimal(config.getLeverage());
        final String label = isLongProfit ? "多头" : "空头";
 
        // ---- Step 1: 平50%仓位 ----
        final int closeQty = Math.max(posSize.divide(two(), 0, RoundingMode.DOWN).intValue(), 1);
        final BigDecimal priceDiff = isLongProfit ? price.subtract(entryPrice) : entryPrice.subtract(price);
        final BigDecimal profitA = new BigDecimal(closeQty).multiply(mult).multiply(priceDiff);
 
        log.info("[ProfitRecycle] ═══ {} 触发回收 ═══", label);
        log.info("[ProfitRecycle] Step1 平仓: {}张(总{}张) → 预期利润A = {} USDT", closeQty, posSize, profitA);
 
        // ---- Step 2: B = A × reinvestRatio ----
        final BigDecimal reinvestRatio = config.getReinvestRatio();
        final BigDecimal marginB = profitA.multiply(reinvestRatio);
        log.info("[ProfitRecycle] Step2 拆分: B = {} × {} = {} USDT", profitA, reinvestRatio, marginB);
 
        // ---- Step 3: 计算反方向张数 (单张保证金法) ----
        // 单张保证金 = contractMultiplier × entryPrice / leverage
        final BigDecimal perContractMargin = mult.multiply(entryPrice).divide(lev, 8, RoundingMode.HALF_UP);
        final int rawQty = marginB.divide(perContractMargin, 0, RoundingMode.DOWN).intValue();
        final int baseQty = Integer.parseInt(config.getBaseQuantity());
 
        // 风控1: 反向仓位倍数上限
        final BigDecimal oppositeSize = isLongProfit ? shortPositionSize : longPositionSize;
        final int maxQty = baseQty * config.getMaxPositionMultiplier();
        final int availableSlots = maxQty - oppositeSize.intValue();
 
        int newQty = Math.max(rawQty, baseQty); // 至少开基础仓位
        if (availableSlots <= 0) {
            log.warn("[ProfitRecycle] 🛑 风控1触发: 反方向已达上限 {}张, 跳过加仓", maxQty);
            rebalancing = false;
            return;
        }
        if (newQty > availableSlots) {
            newQty = availableSlots;
            log.info("[ProfitRecycle] 风控1限制: 调整张数 {} → {}", Math.max(rawQty, baseQty), newQty);
        }
        final String openSize = String.valueOf(newQty);
 
        log.info("[ProfitRecycle] Step3 补仓: 单张保证金={} | raw={} | base={} | max={} | final={}张",
                perContractMargin, rawQty, baseQty, maxQty, openSize);
 
        // ---- Step 4: 执行平仓 → 回调中开反方向 ----
        final String closePosSide = isLongProfit ? "long" : "short";
 
        executor.marketClosePosition(String.valueOf(closeQty), closePosSide,
                () -> {
                    cumulativePnl = cumulativePnl.add(profitA);
                    cycleCount++;
                    updatePositionAfterClose(profitableSide, closeQty);
 
                    log.info("[ProfitRecycle] ✅ 平仓完成 | 累计盈亏: {} | 第{}次循环",
                            cumulativePnl, cycleCount);
 
                    // 开反方向
                    if (!isLongProfit) {
                        executor.openLong(openSize,
                                ordId -> log.info("[ProfitRecycle] 反方向开多{}张 ok ordId:{}", openSize, ordId),
                                () -> log.error("[ProfitRecycle] ❌ 反方向开多{}张失败", openSize));
                    } else {
                        executor.openShort(openSize,
                                ordId -> log.info("[ProfitRecycle] 反方向开空{}张 ok ordId:{}", openSize, ordId),
                                () -> log.error("[ProfitRecycle] ❌ 反方向开空{}张失败", openSize));
                    }
 
                    // 当前仓位状态
                    log.info("[ProfitRecycle] 当前仓位: LONG={}张 SHORT={}张",
                            longPositionSize, shortPositionSize);
 
                    // 风控检查
                    if (checkLossStop()) return;
                    if (checkEquityRestart()) return;
                    rebalancing = false;
                },
                () -> {
                    log.error("[ProfitRecycle] ❌ 平仓失败 direction:{}", closePosSide);
                    rebalancing = false;
                });
    }
 
    // ==================== 风控 ====================
 
    /** 风控2: 全局亏损超限 → 停止策略 */
    private boolean checkLossStop() {
        BigDecimal totalPnl = cumulativePnl.add(calcUnrealizedPnl());
        if (config.getMaxLoss() != null && config.getMaxLoss().compareTo(BigDecimal.ZERO) > 0
                && totalPnl.compareTo(config.getMaxLoss().negate()) <= 0) {
            String msg = StrUtil.format("[ProfitRecycle] 🛑 亏损超限! 合计:{} 已实现:{}",
                    totalPnl, cumulativePnl);
            log.warn(msg);
            DingTalkUtils.getDefault().sendActionCard("风险提醒", msg, config.getApiKey(), "");
            stopStrategy();
            return true;
        }
        return false;
    }
 
    /** 风控3: 账户权益增长≥5% → 全平重置 */
    private boolean checkEquityRestart() {
        BigDecimal equity = fetchCurrentEquity();
        if (equity.compareTo(BigDecimal.ZERO) <= 0) return false;
 
        BigDecimal threshold = initialPrincipal.multiply(
                BigDecimal.ONE.add(config.getEquityRestartRatio()));
        if (equity.compareTo(threshold) >= 0) {
            restartCount++;
            log.info("[ProfitRecycle] 🔄 权益重置触发! 当前权益: {} ≥ 目标: {} (初始: {} + {}%) | 第{}次重置",
                    equity, threshold, initialPrincipal,
                    config.getEquityRestartRatio().multiply(hundred()), restartCount);
            executeRestart();
            return true;
        }
        return false;
    }
 
    /**
     * 执行权益重置:全平 → 取消所有订单 → 刷新本金 → 重新双开底仓
     */
    private void executeRestart() {
        rebalancing = true;
        state = StrategyState.RESTARTING;
        log.info("[ProfitRecycle] 开始重置: 平所有仓位...");
 
        executor.cancelAllPriceTriggeredOrders();
        // 全平多空
        if (longPositionSize.compareTo(BigDecimal.ZERO) > 0) {
            executor.marketClosePosition(longPositionSize.toPlainString(), "long", null, null);
        }
        if (shortPositionSize.compareTo(BigDecimal.ZERO) > 0) {
            executor.marketClosePosition(shortPositionSize.toPlainString(), "short", null, null);
        }
 
        // 延迟后重新开仓
        executor.submitTask(() -> {
            try { Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            refreshInitialPrincipal();
            resetState();
            state = StrategyState.OPENING;
            final String size = config.getBaseQuantity();
            log.info("[ProfitRecycle] 🔄 重置开仓: 多空各{}张, 新本金: {} USDT", size, initialPrincipal);
            executor.openLong(size, ordId -> log.info("[ProfitRecycle] 重置多仓 ordId:{}", ordId), null);
            executor.openShort(size, ordId -> log.info("[ProfitRecycle] 重置空仓 ordId:{}", ordId), null);
            rebalancing = false;
            // 等待 WS 仓位确认后 tryActivate()
        });
    }
 
    // ==================== 辅助 ====================
 
    private BigDecimal calcMargin(BigDecimal size, BigDecimal entry) {
        return size.multiply(config.getContractMultiplier())
                .multiply(entry)
                .divide(new BigDecimal(config.getLeverage()), 8, RoundingMode.HALF_UP);
    }
 
    private BigDecimal calcUnrealizedPnl() {
        BigDecimal price = resolvePrice();
        if (price.compareTo(BigDecimal.ZERO) <= 0) return BigDecimal.ZERO;
        BigDecimal m = config.getContractMultiplier();
        BigDecimal lp = BigDecimal.ZERO, sp = BigDecimal.ZERO;
        if (longPositionSize.compareTo(BigDecimal.ZERO) > 0 && longEntryPrice.compareTo(BigDecimal.ZERO) > 0)
            lp = longPositionSize.multiply(m).multiply(price.subtract(longEntryPrice));
        if (shortPositionSize.compareTo(BigDecimal.ZERO) > 0 && shortEntryPrice.compareTo(BigDecimal.ZERO) > 0)
            sp = shortPositionSize.multiply(m).multiply(shortEntryPrice.subtract(price));
        return lp.add(sp);
    }
 
    private BigDecimal resolvePrice() {
        return (config.getUnrealizedPnlPriceMode() == OkxConfig.PnLPriceMode.MARK_PRICE
                && markPrice.compareTo(BigDecimal.ZERO) > 0) ? markPrice : lastPrice;
    }
 
    private BigDecimal fetchCurrentEquity() {
        try {
            JSONObject account = executorGet("/api/v5/account/balance");
            JSONArray details = account.getJSONArray("data").getJSONObject(0).getJSONArray("details");
            if (details != null) for (int i = 0; i < details.size(); i++) {
                if ("USDT".equals(details.getJSONObject(i).getString("ccy")))
                    return details.getJSONObject(i).getBigDecimal("eq");
            }
        } catch (Exception e) { log.warn("[ProfitRecycle] 获取权益失败", e); }
        return BigDecimal.ZERO;
    }
 
    private void refreshInitialPrincipal() {
        BigDecimal eq = fetchCurrentEquity();
        if (eq.compareTo(BigDecimal.ZERO) > 0) this.initialPrincipal = eq;
    }
 
    private void updatePositionAfterClose(Direction side, int closed) {
        if (side == Direction.LONG) {
            longPositionSize = longPositionSize.subtract(new BigDecimal(closed));
            if (longPositionSize.compareTo(BigDecimal.ZERO) <= 0) {
                longPositionSize = BigDecimal.ZERO;
                longEntryPrice = BigDecimal.ZERO;
            }
        } else {
            shortPositionSize = shortPositionSize.subtract(new BigDecimal(closed));
            if (shortPositionSize.compareTo(BigDecimal.ZERO) <= 0) {
                shortPositionSize = BigDecimal.ZERO;
                shortEntryPrice = BigDecimal.ZERO;
            }
        }
    }
 
    private void tryActivate() {
        if ((state == StrategyState.OPENING || state == StrategyState.RESTARTING)
                && longPositionSize.compareTo(BigDecimal.ZERO) > 0
                && shortPositionSize.compareTo(BigDecimal.ZERO) > 0) {
            state = StrategyState.ACTIVE;
            log.info("[ProfitRecycle] ═══ 策略激活 ═══");
            log.info("[ProfitRecycle] LONG: {}张 @ {} | SHORT: {}张 @ {} | "
                            + "多保证金: {} | 空保证金: {}",
                    longPositionSize, longEntryPrice, shortPositionSize, shortEntryPrice,
                    calcMargin(longPositionSize, longEntryPrice),
                    calcMargin(shortPositionSize, shortEntryPrice));
        }
    }
 
    private void closeExistingPositions() {
        try {
            JSONObject resp = executorGet("/api/v5/account/positions?instType=SWAP");
            JSONArray data = resp.getJSONArray("data");
            if (data == null || data.isEmpty()) return;
            for (int i = 0; i < data.size(); i++) {
                JSONObject pos = data.getJSONObject(i);
                if (!config.getContract().equals(pos.getString("instId"))) continue;
                String posVal = pos.getString("pos");
                if (posVal == null || "0".equals(posVal)) continue;
                String posSide = pos.getString("posSide");
                String side = "long".equals(posSide) ? "sell" : "buy";
                JSONObject body = new JSONObject();
                body.put("instId", config.getContract());
                body.put("tdMode", "cross");
                body.put("side", side);
                body.put("posSide", posSide);
                body.put("ordType", "market");
                body.put("sz", posVal);
                executorPost("/api/v5/trade/order", body.toJSONString());
                log.info("[ProfitRecycle] 平旧仓 posSide:{} sz:{}", posSide, posVal);
            }
        } catch (Exception e) { log.warn("[ProfitRecycle] 平仓异常", e); }
    }
 
    // ==================== REST 快捷 ====================
 
    private JSONObject executorGet(String path) throws Exception { return executor.okGet(path); }
 
    private JSONObject executorPost(String path, String body) throws Exception { return executor.okPost(path, body); }
 
    private static BigDecimal two() { return new BigDecimal("2"); }
 
    private static BigDecimal hundred() { return new BigDecimal("100"); }
 
    // ==================== Getters ====================
 
    public BigDecimal getLastKlinePrice() { return lastPrice; }
    public BigDecimal getCumulativePnl() { return cumulativePnl; }
    public StrategyState getState() { return state; }
    public int getCycleCount() { return cycleCount; }
    public int getRestartCount() { return restartCount; }
    public BigDecimal getInitialPrincipal() { return initialPrincipal; }
    public BigDecimal getLongPositionSize() { return longPositionSize; }
    public BigDecimal getShortPositionSize() { return shortPositionSize; }
    public BigDecimal getLongEntryPrice() { return longEntryPrice; }
    public BigDecimal getShortEntryPrice() { return shortEntryPrice; }
    @Override
    public void setWsClient(OkxKlineWebSocketClient wsClient) { this.wsClient = wsClient; }
}