From 7bddda3c90afb5cc6dedb3b08a6c7436327891dd Mon Sep 17 00:00:00 2001
From: Administrator <15274802129@163.com>
Date: Mon, 21 Sep 2026 09:57:11 +0800
Subject: [PATCH] feat(gate): 添加自定义止损功能

---
 src/main/java/com/xcong/excoin/modules/gateApi/GateGridTradeService.java |  337 +++++++++++++++++++++++++++++++++++++++++++++++++-------
 1 files changed, 295 insertions(+), 42 deletions(-)

diff --git a/src/main/java/com/xcong/excoin/modules/gateApi/GateGridTradeService.java b/src/main/java/com/xcong/excoin/modules/gateApi/GateGridTradeService.java
index 9913adc..4893db4 100644
--- a/src/main/java/com/xcong/excoin/modules/gateApi/GateGridTradeService.java
+++ b/src/main/java/com/xcong/excoin/modules/gateApi/GateGridTradeService.java
@@ -15,6 +15,7 @@
 import java.math.BigDecimal;
 import java.math.RoundingMode;
 import java.util.*;
+import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
 
 import com.xcong.excoin.modules.gateApi.wsHandler.handler.CandlestickChannelHandler;
@@ -148,6 +149,8 @@
     private volatile int lastLongStopLossGridId = Integer.MAX_VALUE;
     /** 最近触发止损的空头网格ID */
     private volatile int lastShortStopLossGridId = Integer.MAX_VALUE;
+    /** 自定义止损模式激活标记:true=尚未发生第一次止损触发,第一次触发后置 false(触发未触发侧重挂默认) */
+    private final AtomicBoolean customStopLossModeActive = new AtomicBoolean(false);
 
     private volatile BigDecimal lastKlinePrice;
     private volatile BigDecimal markPrice = BigDecimal.ZERO;
@@ -354,12 +357,15 @@
         shortPositionSize = BigDecimal.ZERO;
         baseLongOpened = false;
         baseShortOpened = false;
+        config.setBaseLongTraderParam(null);
+        config.setBaseShortTraderParam(null);
         longActive = false;
         shortActive = false;
         accumulatedLongLossCount = 0;
         accumulatedShortLossCount = 0;
         lastLongStopLossGridId = Integer.MAX_VALUE;
         lastShortStopLossGridId = Integer.MAX_VALUE;
+        customStopLossModeActive.set(false);
         shortPriceQueue.clear();
         longPriceQueue.clear();
         totalShortPriceQueue.clear();
@@ -453,12 +459,14 @@
                         .entryOrderId(orderId)
                         .build();
                 config.setBaseLongTraderParam(baseLongTp);
+                tryGenerateQueues();
             }, null);
             executor.openShort(negate(size), (orderId) -> {
                 TraderParam baseShortTp = TraderParam.builder()
                         .entryOrderId(orderId)
                         .build();
                 config.setBaseShortTraderParam(baseShortTp);
+                tryGenerateQueues();
             }, null);
             return;
         }
@@ -757,8 +765,10 @@
                 int posSize = Math.max(queryPositionSize(Position.ModeEnum.DUAL_SHORT), shortPositionSize.intValue());
                 extendShortStopLoss(posSize, shortGridElement.getId());
                 // [Gate] 止盈挂单:超出基础仓位的部分,挂在多仓第一止损位
-                if (config.isPlaceExcessTakeProfit()) {
+                if (GateConfig.EXCESS_TP_CONCENTRATED.equals(config.getPlaceExcessTakeProfit())) {
                     placeExcessTakeProfit(posSize, false);
+                } else if (GateConfig.EXCESS_TP_DISPERSED.equals(config.getPlaceExcessTakeProfit())) {
+                    placeDispersedTakeProfit(posSize, false, shortGridElement.getId());
                 }
                 log.info("[Gate] 空单成交 gridId:{}, 当前持仓:{}张", filledQty, posSize);
 
@@ -797,8 +807,10 @@
                 int posSize = Math.max(queryPositionSize(Position.ModeEnum.DUAL_LONG), longPositionSize.intValue());
                 extendLongStopLoss(posSize, longGridElement.getId());
                 // [Gate] 止盈挂单:超出基础仓位的部分,挂在空仓第一止损位
-                if (config.isPlaceExcessTakeProfit()) {
+                if (GateConfig.EXCESS_TP_CONCENTRATED.equals(config.getPlaceExcessTakeProfit())) {
                     placeExcessTakeProfit(posSize, true);
+                } else if (GateConfig.EXCESS_TP_DISPERSED.equals(config.getPlaceExcessTakeProfit())) {
+                    placeDispersedTakeProfit(posSize, true, longGridElement.getId());
                 }
                 log.info("[Gate] 多单成交 gridId:{}, 当前持仓:{}张", filledQty, posSize);
 
@@ -868,17 +880,27 @@
      *   <li>状态切换为 ACTIVE</li>
      * </ol>
      */
-    private void tryGenerateQueues() {
-        if (baseLongOpened && baseShortOpened) {
+    private synchronized void tryGenerateQueues() {
+        // 幂等保护:生成队列后 state 切换为 ACTIVE,避免多线程重复执行
+        if (state != StrategyState.OPENING) {
+            return;
+        }
+        TraderParam baseLongTraderParam = config.getBaseLongTraderParam();
+        TraderParam baseShortTraderParam = config.getBaseShortTraderParam();
+        // 需同时满足:多空基底均已成交,且两个下单参数(orderId)均已通过下单成功回调拿到。
+        // IOC 市价单成交后 WS 持仓推送可能先于 REST 下单响应返回,必须等参数就绪再生成队列。
+        if (!(baseLongOpened && baseShortOpened)
+                || baseLongTraderParam == null || baseShortTraderParam == null) {
+            return;
+        }
+        {
             generateShortQueue();
             generateLongQueue();
             updateGridElements();
 
             GridElement baseGridElement = GridElement.findById(0);
-            TraderParam baseLongTraderParam = config.getBaseLongTraderParam();
             baseGridElement.setLongOrderId(baseLongTraderParam.getEntryOrderId());
             baseGridElement.setHasLongOrder(true);
-            TraderParam baseShortTraderParam = config.getBaseShortTraderParam();
             baseGridElement.setShortOrderId(baseShortTraderParam.getEntryOrderId());
             baseGridElement.setHasShortOrder(true);
 
@@ -956,56 +978,182 @@
 //                );
 //            }
 
-            int shortTime = Integer.parseInt(config.getBaseQuantity()) / Integer.parseInt(config.getQuantity()) + 1;
-            for (int id = 2; id <= shortTime; id++) {
-                GridElement elem = GridElement.findById(id);
-                if (elem == null) {
-                    continue;
-                }
-                BigDecimal triggerPrice = elem.getGridPrice();
-                String size = config.getQuantity();
-                int finalId = id;
-                executor.placeTakeProfit(
-                        triggerPrice,
-                        FuturesPriceTrigger.RuleEnum.NUMBER_1,
-                        ORDER_TYPE_CLOSE_SHORT,
-                        size,
-                        profitId -> {
-                            elem.addShortStopLossOrderId(profitId);
-                            GridElement.refreshIndices();
-                            log.info("[Gate] 空仓止损已挂, gridId:{}, 触发价:{}, stopLossId:{}", finalId, triggerPrice, profitId);
-                        }
-                );
+            if (config.isCustomStopLossEnabled()) {
+                placeCustomStopLosses();
+            } else {
+                placeDefaultStopLossLadders();
             }
 
+            state = StrategyState.ACTIVE;
+        }
+    }
 
+    // ========== 自定义止损(按触发价各挂一单) ==========
 
-            int longTime = Integer.parseInt(config.getBaseQuantity()) / Integer.parseInt(config.getQuantity()) + 1;
-            for (int id = -2; id >= -longTime; id--) {
-                GridElement elem = GridElement.findById(id);
-                if (elem == null) {
-                    continue;
-                }
-                BigDecimal triggerPrice = elem.getGridPrice();
-                String size = config.getQuantity();
-                int finalId = id;
+    /**
+     * 自定义止损初始化:按多空止损触发价匹配最近网格,各挂一张覆盖全仓位(baseQuantity)的止损单。
+     * 多仓止损:NUMBER_2(价格 ≤ 触发价平多),空仓止损:NUMBER_1(价格 ≥ 触发价平空)。
+     */
+    private void placeCustomStopLosses() {
+        customStopLossModeActive.set(true);
+        String baseQty = config.getBaseQuantity();
+        BigDecimal longPrice = config.getLongCustomStopLossPrice();
+        BigDecimal shortPrice = config.getShortCustomStopLossPrice();
+
+        if (longPrice != null && longPrice.compareTo(BigDecimal.ZERO) > 0) {
+            GridElement longElem = findNearestGridElementByPrice(longPrice);
+            if (longElem != null) {
+                BigDecimal triggerPrice = longElem.getGridPrice();
                 executor.placeTakeProfit(
                         triggerPrice,
                         FuturesPriceTrigger.RuleEnum.NUMBER_2,
                         ORDER_TYPE_CLOSE_LONG,
-                        negate(size),
+                        negate(baseQty),
                         profitId -> {
-                            elem.addLongStopLossOrderId(profitId);
+                            longElem.addLongStopLossOrderId(profitId);
                             GridElement.refreshIndices();
-                            log.info("[Gate] 多仓止损已挂, gridId:{}, 触发价:{}, stopLossId:{}", finalId, triggerPrice, profitId);
+                            log.info("[Gate] 自定义多仓止损已挂, 目标价:{}, 匹配gridId:{}, 触发价:{}, stopLossId:{}",
+                                    longPrice, longElem.getId(), triggerPrice, profitId);
                         }
                 );
+            } else {
+                log.warn("[Gate] 自定义多仓止损价:{} 未匹配到任何网格", longPrice);
             }
-
-            log.info("[Gate] 止损止盈单已全部挂完, 空仓止损:2~{}, 多仓止损:-2~-{}", shortTime, longTime);
-
-            state = StrategyState.ACTIVE;
         }
+
+        if (shortPrice != null && shortPrice.compareTo(BigDecimal.ZERO) > 0) {
+            GridElement shortElem = findNearestGridElementByPrice(shortPrice);
+            if (shortElem != null) {
+                BigDecimal triggerPrice = shortElem.getGridPrice();
+                executor.placeTakeProfit(
+                        triggerPrice,
+                        FuturesPriceTrigger.RuleEnum.NUMBER_1,
+                        ORDER_TYPE_CLOSE_SHORT,
+                        baseQty,
+                        profitId -> {
+                            shortElem.addShortStopLossOrderId(profitId);
+                            GridElement.refreshIndices();
+                            log.info("[Gate] 自定义空仓止损已挂, 目标价:{}, 匹配gridId:{}, 触发价:{}, stopLossId:{}",
+                                    shortPrice, shortElem.getId(), triggerPrice, profitId);
+                        }
+                );
+            } else {
+                log.warn("[Gate] 自定义空仓止损价:{} 未匹配到任何网格", shortPrice);
+            }
+        }
+        log.info("[Gate] 自定义止损初始化完成, 多仓止损价:{}, 空仓止损价:{}", longPrice, shortPrice);
+    }
+
+    /**
+     * 默认止损阶梯(原有逻辑):空仓从 ID=2 向上、多仓从 ID=-2 向下,各挂 baseQuantity/quantity 格、每格 quantity 张。
+     */
+    private void placeDefaultStopLossLadders() {
+        placeDefaultShortStopLossLadder();
+        placeDefaultLongStopLossLadder();
+        int shortTime = Integer.parseInt(config.getBaseQuantity()) / Integer.parseInt(config.getQuantity()) + 1;
+        int longTime = Integer.parseInt(config.getBaseQuantity()) / Integer.parseInt(config.getQuantity()) + 1;
+        log.info("[Gate] 止损止盈单已全部挂完, 空仓止损:2~{}, 多仓止损:-2~-{}", shortTime, longTime);
+    }
+
+    /**
+     * 默认空仓止损阶梯:从 ID=2 向上挂,每格 quantity 张,NUMBER_1(价格 ≥ 触发价平空)。
+     */
+    private void placeDefaultShortStopLossLadder() {
+        int shortTime = Integer.parseInt(config.getBaseQuantity()) / Integer.parseInt(config.getQuantity()) + 1;
+        for (int id = 2; id <= shortTime; id++) {
+            GridElement elem = GridElement.findById(id);
+            if (elem == null) {
+                continue;
+            }
+            BigDecimal triggerPrice = elem.getGridPrice();
+            String size = config.getQuantity();
+            int finalId = id;
+            executor.placeTakeProfit(
+                    triggerPrice,
+                    FuturesPriceTrigger.RuleEnum.NUMBER_1,
+                    ORDER_TYPE_CLOSE_SHORT,
+                    size,
+                    profitId -> {
+                        elem.addShortStopLossOrderId(profitId);
+                        GridElement.refreshIndices();
+                        log.info("[Gate] 空仓止损已挂, gridId:{}, 触发价:{}, stopLossId:{}", finalId, triggerPrice, profitId);
+                    }
+            );
+        }
+    }
+
+    /**
+     * 默认多仓止损阶梯:从 ID=-2 向下挂,每格 quantity 张,NUMBER_2(价格 ≤ 触发价平多)。
+     */
+    private void placeDefaultLongStopLossLadder() {
+        int longTime = Integer.parseInt(config.getBaseQuantity()) / Integer.parseInt(config.getQuantity()) + 1;
+        for (int id = -2; id >= -longTime; id--) {
+            GridElement elem = GridElement.findById(id);
+            if (elem == null) {
+                continue;
+            }
+            BigDecimal triggerPrice = elem.getGridPrice();
+            String size = config.getQuantity();
+            int finalId = id;
+            executor.placeTakeProfit(
+                    triggerPrice,
+                    FuturesPriceTrigger.RuleEnum.NUMBER_2,
+                    ORDER_TYPE_CLOSE_LONG,
+                    negate(size),
+                    profitId -> {
+                        elem.addLongStopLossOrderId(profitId);
+                        GridElement.refreshIndices();
+                        log.info("[Gate] 多仓止损已挂, gridId:{}, 触发价:{}, stopLossId:{}", finalId, triggerPrice, profitId);
+                    }
+            );
+        }
+    }
+
+    /**
+     * 按「价格 → 最近网格」匹配:返回 gridPrice 与目标价绝对值差最小的网格元素。
+     */
+    private GridElement findNearestGridElementByPrice(BigDecimal price) {
+        if (price == null || price.compareTo(BigDecimal.ZERO) <= 0) {
+            return null;
+        }
+        GridElement nearest = null;
+        BigDecimal minDiff = null;
+        for (GridElement e : config.getGridElements()) {
+            BigDecimal diff = e.getGridPrice().subtract(price).abs();
+            if (minDiff == null || diff.compareTo(minDiff) < 0) {
+                minDiff = diff;
+                nearest = e;
+            }
+        }
+        return nearest;
+    }
+
+    /**
+     * 只取消空仓止损单(保留空仓止盈),用于自定义止损首次触发时重挂未触发侧。
+     */
+    private void cancelAllShortStopLossesOnly() {
+        for (GridElement e : config.getGridElements()) {
+            for (String slId : new ArrayList<>(e.getShortStopLossOrderIds())) {
+                executor.cancelConditionalOrder(slId, oid -> {});
+            }
+            e.clearShortStopLossOrderIds();
+        }
+        GridElement.refreshIndices();
+        log.info("[Gate] 已提交取消所有空仓止损(保留止盈)");
+    }
+
+    /**
+     * 只取消多仓止损单(保留多仓止盈),用于自定义止损首次触发时重挂未触发侧。
+     */
+    private void cancelAllLongStopLossesOnly() {
+        for (GridElement e : config.getGridElements()) {
+            for (String slId : new ArrayList<>(e.getLongStopLossOrderIds())) {
+                executor.cancelConditionalOrder(slId, oid -> {});
+            }
+            e.clearLongStopLossOrderIds();
+        }
+        GridElement.refreshIndices();
+        log.info("[Gate] 已提交取消所有多仓止损(保留止盈)");
     }
 
     /**
@@ -1505,6 +1653,13 @@
             }
         }
 
+        // 自定义止损模式:第一次止损触发时,取消未触发侧(空仓)自定义止损单并重挂默认阶梯
+        if (config.isCustomStopLossEnabled() && customStopLossModeActive.compareAndSet(true, false)) {
+            cancelAllShortStopLossesOnly();
+            placeDefaultShortStopLossLadder();
+            log.info("[Gate] 自定义止损首次触发(多仓), 已取消空仓自定义止损并重挂默认阶梯");
+        }
+
     }
 
     private void handleShortStopLossTriggered(GridElement gridElement, String orderId) {
@@ -1555,6 +1710,13 @@
                     log.info("[Gate] 空仓止损触发, 取消gridId:{}的空单,{}", cancelGridId, cancelOrderId);
                 });
             }
+        }
+
+        // 自定义止损模式:第一次止损触发时,取消未触发侧(多仓)自定义止损单并重挂默认阶梯
+        if (config.isCustomStopLossEnabled() && customStopLossModeActive.compareAndSet(true, false)) {
+            cancelAllLongStopLossesOnly();
+            placeDefaultLongStopLossLadder();
+            log.info("[Gate] 自定义止损首次触发(空仓), 已取消多仓自定义止损并重挂默认阶梯");
         }
     }
 
@@ -2189,6 +2351,97 @@
     }
 
     /**
+     * 分散止盈:把超出基础仓位的部分,从入场网格下一格开始逐格挂止盈单,一直挂到对手盘止损位。
+     *
+     * <p>数量分配:excessQty ÷ 网格数向下取整,余数全部挂在最后一个止盈网格(对手盘止损位)。
+     *
+     * <p>方向:多仓成交向价格更高方向(id 递增)铺到空仓第一止损位;
+     * 空仓成交向价格更低方向(id 递减)铺到多仓第一止损位。
+     *
+     * @param posSize      当前总持仓张数
+     * @param isLong       true=多仓成交,false=空仓成交
+     * @param entryGridId  入场网格 ID
+     */
+    private void placeDispersedTakeProfit(int posSize, boolean isLong, int entryGridId) {
+        int baseQty = Integer.parseInt(config.getBaseQuantity());
+        int excessQty = posSize - baseQty;
+        if (excessQty <= 0) {
+            return;
+        }
+
+        GridElement endElem = isLong ? findFirstShortStopLossGrid() : findFirstLongStopLossGrid();
+        if (endElem == null) {
+            log.warn("[Gate] {}分散止盈失败:未找到对手盘止损位", isLong ? "多仓" : "空仓");
+            return;
+        }
+        int endId = endElem.getId();
+        int dir = isLong ? 1 : -1;
+        int startId = entryGridId + dir;
+
+        // 终点必须在起点前进方向上,否则退化为集中止盈(全部挂对手盘止损位)
+        boolean valid = isLong ? (endId >= startId) : (endId <= startId);
+        if (!valid) {
+            placeExcessTakeProfit(posSize, isLong);
+            return;
+        }
+
+        // 收集区间内全部网格(含两端)
+        List<GridElement> gridList = new ArrayList<>();
+        int id = startId;
+        while (true) {
+            GridElement e = GridElement.findById(id);
+            if (e == null) {
+                break;
+            }
+            gridList.add(e);
+            if (id == endId) {
+                break;
+            }
+            id += dir;
+            if (gridList.size() > config.getGridQueueSize() * 2 + 4) {
+                break;
+            }
+        }
+        if (gridList.isEmpty() || gridList.get(gridList.size() - 1).getId() != endId) {
+            // 未能铺满到终点(网格缺失),退化为集中止盈
+            placeExcessTakeProfit(posSize, isLong);
+            return;
+        }
+
+        int n = gridList.size();
+        int perGrid = excessQty / n;
+        int remainder = excessQty % n;
+
+        for (int i = 0; i < n; i++) {
+            int qty = perGrid;
+            if (i == n - 1) {
+                qty += remainder;
+            }
+            if (qty <= 0) {
+                continue;
+            }
+            final GridElement elem = gridList.get(i);
+            final int gid = elem.getId();
+            final BigDecimal triggerPrice = elem.getGridPrice();
+            final String size = isLong ? negate(String.valueOf(qty)) : String.valueOf(qty);
+            final String orderType = isLong ? ORDER_TYPE_CLOSE_LONG : ORDER_TYPE_CLOSE_SHORT;
+            final FuturesPriceTrigger.RuleEnum rule = isLong ? FuturesPriceTrigger.RuleEnum.NUMBER_1
+                    : FuturesPriceTrigger.RuleEnum.NUMBER_2;
+            executor.placeTakeProfit(triggerPrice, rule, orderType, size,
+                    profitId -> {
+                        if (isLong) {
+                            longTakeProfitTraderIdParam(elem, profitId, true);
+                        } else {
+                            shortTakeProfitTraderIdParam(elem, profitId, true);
+                        }
+                        log.info("[Gate] 分散止盈 {} gridId:{}, 触发价:{}, 数量:{}, tpId:{}",
+                                isLong ? "多仓" : "空仓", gid, triggerPrice, size, profitId);
+                    }
+            );
+        }
+    }
+
+    /**
      * 在指定网格挂一笔对手止盈单(非满仓超额止盈,挂在止损触发位的下一格)。
      */
     private void placeTakeProfitAtGrid(GridElement tpElem, boolean isLong, int qty, int times) {

--
Gitblit v1.9.1