1 files added
1 files modified
536 ■■■■ changed files
GateGridTradeService_Audit.md 185 ●●●●● patch | view | raw | blame | history
src/main/java/com/xcong/excoin/modules/gateApi/GateGridTradeService.java 351 ●●●● patch | view | raw | blame | history
GateGridTradeService_Audit.md
New file
@@ -0,0 +1,185 @@
# GateGridTradeService 代码审计报告
> 审计日期:2026-08-03
> 文件:`src/main/java/com/xcong/excoin/modules/gateApi/GateGridTradeService.java`
> 审查范围:全部 2147 行,含交叉验证 `GridElement` / `TraderParam` / `GateConfig` / `GateTradeExecutor`
---
## 一、总体评估
代码结构清晰,多空双方向网格交易的主流程(K线驱动 → 条件单挂单 → 成交回调 → 止损追单 → 级联查表)完整且自洽。发现 **4 个 Bug**,其中 **1 个 Critical** 直接影响止损级联计数准确性。
---
## 二、BUG 详细分析
### Bug #1 🔴 Critical:sameGrid 阻止同网格多个止损单触发
**位置**
- `handleLongStopLossTriggered` — 行 1283 ~ 1287
- `handleShortStopLossTriggered` — 行 1494 ~ 1498
**问题代码**
```java
boolean sameGrid = (gridId == lastLongStopLossGridId);
if (sameGrid) {
    log.info("[Gate] 多仓止损触发 同网格, 忽略");
    return;
}
lastLongStopLossGridId = gridId;
accumulatedLongLossCount++;
```
**根因**
`extendLongStopLoss` / `extendShortStopLoss` 中当 `gridCount == 1` 时,所有止损单挂在**同一个 gridId**:
```java
// extendLongStopLoss,行 1759-1761
if (gridCount == 1) {
    orders[0] = totalOrders;   // 多张止损单 → 同一个 gridId
}
// 行 1789:gridId - 2 - 0 = gridId - 2,同一个位置
```
以 `baseQty=2, qty=2, filledQty=4` 为例:
- `gridCount = 2/2 = 1`,`totalOrders = 4/2 = 2`
- **2 单止损全部挂在 gridId-2**
- 第 1 单触发 → `lastLongStopLossGridId = gridId`,count = 1
- 第 2 单触发 → `sameGrid = true` → **被丢弃**,count 少 1
**为什么可以用 `removeXxxStopLossOrderId` 替代**
`handleLongStopLossTriggered` 第一步就是 `gridElement.removeLongStopLossOrderId(orderId)`,从列表中删除该 orderId。`refreshIndices()` 会重建全局索引,确保同一 orderId 不会二次匹配。
> 重复推送的防线:若 WS 对**同一订单**推送两次,第二次 `findByLongStopLossOrderId` 找不到(已从索引清除),直接落到后续匹配逻辑,无副作用。
`sameGrid` 是**按 gridId 去重**,粒度太粗,误杀了**不同 orderId 但同一个 gridId** 的正常多单触发。
**修复方案**
```java
private void handleLongStopLossTriggered(GridElement gridElement, String orderId) {
    // 用 remove 返回值判断是否重复推送,代替 sameGrid
    if (!gridElement.removeLongStopLossOrderId(orderId)) {
        log.info("[Gate] 多仓止损重复推送 orderId:{}, 忽略", orderId);
        return;
    }
    int gridId = gridElement.getId();
    lastLongStopLossGridId = gridId;
    accumulatedLongLossCount++;
    log.info("[Gate] 多仓止损触发 gridId:{}, 止损次数:{}", gridId, accumulatedLongLossCount);
    int newEntryGridId = gridId + 1;
    // ... 后续追单逻辑不变 ...
}
```
`handleShortStopLossTriggered` 同理(`gridId - 1`)。
**影响范围**
- `accumulatedLongLossCount` / `accumulatedShortLossCount` **少计数**
- `STOP_LOSS_RULES` 级联规则表推进不到应有行数
- 对手止盈/己方止盈缺失,策略漏掉对冲机会
- 网格数越少 (`baseQty/qty` 小),问题越严重(所有止损同格)
---
### Bug #2 🟡 Low:三处死代码
| 方法 | 行号 | 功能 | 调用情况 |
|---|---|---|---|
| `checkLastTakeProfitAndRestart()` | 1549 | 止盈全部清空后检查跨度重启 | 全项目检索 → **0 处调用** |
| `placeOpponentTakeProfit()` | 2125 | 旧版对手止盈挂单逻辑 | 全项目检索 → **0 处调用** |
| `cancelAllInitialTakeProfits()` | 1719 | 取消初始化阶段止盈单 | 全项目检索 → **0 处调用** |
`checkLastTakeProfitAndRestart` 本身逻辑完整(跨度检查 → 清理条件单 → 平仓 → 重启),可能是某个版本的入口未被接上。
**影响**:仅代码整洁度,无功能影响。若跨度重启功能需要保留,需在合适回调点接入(如止盈触发后)。
---
### Bug #3 🟡 Low-Medium:整数截断使小 maxPos 下多行规则不可区分
**位置**:行 1389、1391(`checkLongStopShortProfit`)及镜像 1445、1447(`checkShortStopLongProfit`)
```java
int fullTpPctNum   = maxPos * (100 - fullTpPct) / 100;   // Java int 除法截断
int thresholdPosNum = maxPos * (100 - thresholdPct) / 100;
```
**以 `maxPos = 4` 为例**
| STOP_LOSS_RULES 行 | fullTpPct | fullTpPctNum | 触发条件 `oppPos > N` |
|---|---|---|---|
| 第 3 行 (times=3) | 30 | `4*70/100 = 2` | oppPos ≥ 3 |
| 第 6 行 (times=6) | 40 | `4*60/100 = 2` | oppPos ≥ 3 ⚠️ 与第3行相同 |
| 第 9 行 (times=9) | 50 | `4*50/100 = 2` | oppPos ≥ 3 ⚠️ 与第3/6行相同 |
前三行规则对于对手止盈**完全不可区分**,实际触发门槛都是 `maxPos` 的 75%。
同样 `thresholdPosNum` 也有精度损失:
| thresholdPct | thresholdPosNum (`maxPos=4`) | 实际百分比 |
|---|---|---|
| 40 | `4*60/100 = 2` | excess = selfPos - 2 |
| 50 | `4*50/100 = 2` | 同上 ⚠️ |
| 60 | `4*40/100 = 1` | excess = selfPos - 1 |
**影响**
- `maxPos < 10`:规则表精度差,多行等价
- `maxPos ≥ 20`:每 5% 差 1 张,基本可区分
- 如需保证小 `maxPos` 精度,可改用:
  ```java
  int fullTpPctNum = (int) Math.ceil(maxPos * (100.0 - fullTpPct) / 100.0);
  ```
---
### Bug #4 🟢 Very Minor:perOrderQty 整数截断(已有兜底)
**位置**:行 1410 ~ 1413(`checkLongStopShortProfit`)及镜像 1466 ~ 1469
```java
int perOrderQty = maxPos * DEFAULT_STOP_LOSS_PERCENT / 100;   // DEFAULT_STOP_LOSS_PERCENT = 5
if (perOrderQty <= 0) {
    perOrderQty = 1;    // 兜底
}
```
`maxPos = 4` → `4 * 5 / 100 = 0` → 兜底为 1。
**影响**:几乎无。`perOrderQty` 从设计的 "maxPos 的 5%" 降级为固定 1 张,但兜底保证了逻辑不崩溃。`maxPos ≥ 20` 后恢复正常。
---
## 三、审查确认正确的模块 ✅
以下模块经逐行审查,确认无 Bug:
| 模块 | 验证点 | 结论 |
|---|---|---|
| `checkLongStopShortProfit` | 对手(空)盈止位 `gridId`,己方(多)盈止位 `0+2,4,6...` | ✅ 正确 |
| `checkShortStopLongProfit` | 对手(多)盈止位 `gridId`,己方(空)盈止位 `0-2,-4,-6...` | ✅ 正确 |
| `cancelAllXxxTakeProfitsAndStopLosses` | 先清后建:取消 → extend → checkXxxProfit 重挂 | ✅ 无遗漏 |
| `onAutoOrder` 入口匹配 | 止损单 → 入口单 的顺序匹配 | ✅ 正确 |
| `extendStopLossInProgress` 防重入 | 同格多成交 → pendingReExtend → 重挂 | ✅ 正确 |
| `processShortGrid/processLongGrid` | 动量方向、队列匹配、下行守卫 | ✅ 正确 |
| `checkProfitAndReset` | 估算手续费、轮次控制 | ✅ 正确 |
| `onKline` 单边归零触发 | `longActive==false` 时触发 `processShortGrid` | ✅ 正确 |
---
## 四、优先级总结
| 优先级 | Bug | 修复难度 | 建议 |
|---|---|---|---|
| 🔴 P0 | #1 sameGrid 丢止损 | 低(删 3 行加 4 行) | **立即修复** |
| 🟡 P2 | #2 死代码 | — | 清理或恢复接入 |
| 🟡 P3 | #3 小 maxPos 精度 | 中(需改计算逻辑) | maxPos ≥ 10 可暂缓 |
| 🟢 P4 | #4 perOrderQty 截断 | — | 已有兜底,不需处理 |
src/main/java/com/xcong/excoin/modules/gateApi/GateGridTradeService.java
@@ -665,7 +665,6 @@
                }
                shortGridElement.setExtendStopLossInProgress(true);
                accumulatedShortLossCount = 0;
                lastShortStopLossGridId = Integer.MAX_VALUE;
                // [Gate-需求2] 加仓后先撤空仓所有止盈+止损,再查交易所持仓后重挂
                cancelAllShortTakeProfitsAndStopLosses();
@@ -673,8 +672,12 @@
                int posSize = Math.max(queryPositionSize(Position.ModeEnum.DUAL_SHORT), shortPositionSize.intValue());
                extendShortStopLoss(posSize, shortGridElement.getId());
                // [Gate] 止盈挂单:超出基础仓位的部分,挂在多仓第一止损位
                placeExcessTakeProfit(posSize, false);
//                placeExcessTakeProfit(posSize, false);
                log.info("[Gate] 空单成交 gridId:{}, 当前持仓:{}张", filledQty, posSize);
                accumulatedShortLossCount++;
                checkShortStopLongProfit(accumulatedShortLossCount, shortGridElement.getId() + 2);
            }
        }
@@ -695,7 +698,6 @@
                }
                longGridElement.setExtendStopLossInProgress(true);
                accumulatedLongLossCount = 0;
                lastLongStopLossGridId = Integer.MAX_VALUE;
                // [Gate-需求2] 加仓后先撤多仓所有止盈+止损,再查交易所持仓后重挂
                cancelAllLongTakeProfitsAndStopLosses();
@@ -703,8 +705,11 @@
                int posSize = Math.max(queryPositionSize(Position.ModeEnum.DUAL_LONG), longPositionSize.intValue());
                extendLongStopLoss(posSize, longGridElement.getId());
                // [Gate] 止盈挂单:超出基础仓位的部分,挂在空仓第一止损位
                placeExcessTakeProfit(posSize, true);
//                placeExcessTakeProfit(posSize, true);
                log.info("[Gate] 多单成交 gridId:{}, 当前持仓:{}张", filledQty, posSize);
                accumulatedLongLossCount++;
                checkLongStopShortProfit(accumulatedLongLossCount, longGridElement.getId() -2);
            }
        }
@@ -1152,8 +1157,8 @@
                    if (newEntryGrid != null) {
                        String quantity = String.valueOf(Math.max(queryPositionSize(Position.ModeEnum.DUAL_SHORT), shortPositionSize.intValue()) + Integer.parseInt(config.getQuantity()));
//                        String quantity = String.valueOf(Math.max(queryPositionSize(Position.ModeEnum.DUAL_SHORT), shortPositionSize.intValue()) + Integer.parseInt(config.getQuantity()));
                        String quantity = String.valueOf(config.getBaseQuantity());
                        // 向下检查是否已有多单挂在更低价格网格,有则跳过(防止价格回升后重复挂单)
                        boolean hasLongOrderBelow = false;
                        GridElement checkDownCursor = GridElement.findById(newEntryGrid.getDownId());
@@ -1223,8 +1228,8 @@
                    if (newEntryGrid != null) {
//                        String quantity = String.valueOf((accumulatedShortLossCount + 1) * Integer.parseInt(config.getQuantity()));
                        String quantity = String.valueOf(Math.max(queryPositionSize(Position.ModeEnum.DUAL_LONG), longPositionSize.intValue()) + Integer.parseInt(config.getQuantity()));
//                        String quantity = String.valueOf(Math.max(queryPositionSize(Position.ModeEnum.DUAL_LONG), longPositionSize.intValue()) + Integer.parseInt(config.getQuantity()));
                        String quantity = String.valueOf(config.getBaseQuantity());
                        // 向上检查是否已有空单挂在更高价格网格,有则跳过(防止价格回落后重复挂单)
                        boolean hasShortOrderAbove = false;
                        GridElement checkUpCursor = GridElement.findById(newEntryGrid.getUpId());
@@ -1295,7 +1300,8 @@
        // 止损追单:同一网格可有多笔挂单,不判断 isHasLongOrder,直接挂单
        BigDecimal triggerPrice = newEntryGrid.getGridPrice();
        String size = String.valueOf(Math.max(queryPositionSize(Position.ModeEnum.DUAL_SHORT), shortPositionSize.intValue()) + Integer.parseInt(config.getQuantity()));
//        String size = String.valueOf(Math.max(queryPositionSize(Position.ModeEnum.DUAL_SHORT), shortPositionSize.intValue()) + Integer.parseInt(config.getQuantity()));
        String size = String.valueOf(config.getBaseQuantity());
        newEntryGrid.getLongTraderParam().setQuantity(size);
        placeEntryOrderWithPreFlag(newEntryGrid, true, triggerPrice,
                FuturesPriceTrigger.RuleEnum.NUMBER_1, size);
@@ -1309,6 +1315,172 @@
                    longEntryTraderIdParam(cancelGrid, cancelOrderId, false);
                    log.info("[Gate] 多仓止损触发, 取消gridId:{}的多单,{}", cancelGridId, cancelOrderId);
                });
            }
        }
    }
    // ========== 止损触发操作表 ==========
    /**
     * value = int[2]: {对手满仓止盈%, 基础仓位阈值%}
     * <ul>
     *   <li>[0] 对手满仓:挂对手仓位此%做止盈;0=不挂</li>
     *   <li>[1] 基础仓位阈值(占maxPos的%):对手/己方仓位超出此阈值部分做止盈;0=不挂</li>
     * </ul>
     */
    private static final Map<Integer, int[]> STOP_LOSS_RULES = new LinkedHashMap<>();
    private static final int DEFAULT_STOP_LOSS_PERCENT = 5;
    static {
        // times  {满仓%, 阈值%}
        put(1,  new int[]{0,  0});
        put(2,  new int[]{0,  0});
        put(3,  new int[]{30, 0});
        put(4,  new int[]{0, 0});
        put(5,  new int[]{0, 0});
        put(6,  new int[]{40, 40});
        put(7,  new int[]{0, 0});
        put(8,  new int[]{0, 0});
        put(9,  new int[]{50, 50});
        put(10, new int[]{0, 0});
        put(11, new int[]{0, 0});
        put(12, new int[]{55, 60});
        put(13, new int[]{0, 0});
        put(14, new int[]{0, 0});
        put(15, new int[]{60, 60});
        put(16, new int[]{0, 0});
        put(17, new int[]{0, 0});
        put(18, new int[]{65, 65});
        put(19, new int[]{0, 0});
        put(20, new int[]{0, 0});
        put(21, new int[]{70, 70});
        put(22, new int[]{0, 0});
        put(23, new int[]{0, 0});
        put(24, new int[]{75, 75});
        put(25, new int[]{0, 0});
        put(26, new int[]{0, 0});
        put(27, new int[]{80, 80});
        put(28, new int[]{0, 0});
        put(29, new int[]{0, 0});
        put(30, new int[]{85, 85});
        put(31, new int[]{0, 0});
        put(32, new int[]{0, 0});
    }
    private static void put(int times, int[] rule) { STOP_LOSS_RULES.put(times, rule); }
    private static int[] ruleOf(int times) {
        int[] r = STOP_LOSS_RULES.get(times);
        return r != null ? r : STOP_LOSS_RULES.get(32);
    }
    /**
     * 多仓止损触发后,查表处理对手盘(空仓)盈止 + 己方盈止 + 激活。
     */
    private void checkLongStopShortProfit(int times, int gridId) {
        int[] r = ruleOf(times);
        int maxPos = config.getMaxPositionSize();
        if (maxPos <= 0) {
            return;
        }
        int fullTpPct = r[0];     // 对手满仓 → 止盈%
        int fullTpPctNum = maxPos * (100- fullTpPct)/ 100;
        int thresholdPct = r[1];  // 基础仓位阈值%
        int thresholdPosNum = maxPos * (100- thresholdPct)/ 100;
        // ---------- 对手盘(空仓)盈止 ----------
        int oppPos = Math.max(queryPositionSize(Position.ModeEnum.DUAL_SHORT), shortPositionSize.intValue());
        boolean isFullTp = oppPos > fullTpPctNum;
        if (isFullTp) {
            // 对手仓位超出阈值:超额整个挂一笔止盈,位置在触发格 - 1
            int excess = oppPos - fullTpPctNum;
            GridElement tpElem = GridElement.findById(gridId);
            if (tpElem != null) {
                placeTakeProfitAtGrid(tpElem, false, excess, times);
            }
        }
        // ---------- 己方(多仓)盈止(≥6次) ----------
        if (times >= 6 && thresholdPct > 0) {
            int selfPos = Math.max(queryPositionSize(Position.ModeEnum.DUAL_LONG), longPositionSize.intValue());
            int excess = selfPos - thresholdPosNum;
            if (excess > 0) {
                int perOrderQty = maxPos * DEFAULT_STOP_LOSS_PERCENT / 100;
                if (perOrderQty <= 0) {
                    perOrderQty = 1;
                }
                int totalOrders = excess / perOrderQty;
                if (totalOrders <= 0) {
                    totalOrders = 1;
                }
                int entryGridId = 0; // 多仓建仓格
                for (int i = 0; i < totalOrders; i++) {
                    int tpGridId = entryGridId + 2 * (i + 1); // 朝盈利方向(向上)间隔2格
                    GridElement tpElem = GridElement.findById(tpGridId);
                    if (tpElem == null) {
                        break;
                    }
                    placeTakeProfitAtGrid(tpElem, true, perOrderQty, times);
                }
                log.info("[Gate] 止损{}→己方多仓止盈, excess:{}, 挂{}单, 每单{}张",
                        times, excess, totalOrders, perOrderQty);
            }
        }
    }
    /**
     * 空仓止损触发后,查表处理对手盘(多仓)盈止 + 己方盈止。
     */
    private void checkShortStopLongProfit(int times, int gridId) {
        int[] r = ruleOf(times);
        int maxPos = config.getMaxPositionSize();
        if (maxPos <= 0) {
            return;
        }
        int fullTpPct = r[0];     // 对手满仓 → 止盈%
        int fullTpPctNum = maxPos * (100 - fullTpPct) / 100;
        int thresholdPct = r[1];  // 基础仓位阈值%
        int thresholdPosNum = maxPos * (100 - thresholdPct) / 100;
        // ---------- 对手盘(多仓)盈止 ----------
        int oppPos = Math.max(queryPositionSize(Position.ModeEnum.DUAL_LONG), longPositionSize.intValue());
        boolean isFullTp = oppPos > fullTpPctNum;
        if (isFullTp) {
            // 对手仓位超出阈值:超额整个挂一笔止盈,位置在触发格
            int excess = oppPos - fullTpPctNum;
            GridElement tpElem = GridElement.findById(gridId);
            if (tpElem != null) {
                placeTakeProfitAtGrid(tpElem, true, excess, times);
            }
        }
        // ---------- 己方(空仓)盈止(≥6次) ----------
        if (times >= 6 && thresholdPct > 0) {
            int selfPos = Math.max(queryPositionSize(Position.ModeEnum.DUAL_SHORT), shortPositionSize.intValue());
            int excess = selfPos - thresholdPosNum;
            if (excess > 0) {
                int perOrderQty = maxPos * DEFAULT_STOP_LOSS_PERCENT / 100;
                if (perOrderQty <= 0) {
                    perOrderQty = 1;
                }
                int totalOrders = excess / perOrderQty;
                if (totalOrders <= 0) {
                    totalOrders = 1;
                }
                int entryGridId = 0; // 空仓建仓格
                for (int i = 0; i < totalOrders; i++) {
                    int tpGridId = entryGridId - 2 * (i + 1); // 朝盈利方向(向下)间隔2格
                    GridElement tpElem = GridElement.findById(tpGridId);
                    if (tpElem == null) {
                        break;
                    }
                    placeTakeProfitAtGrid(tpElem, false, perOrderQty, times);
                }
                log.info("[Gate] 止损{}→己方空仓止盈, excess:{}, 挂{}单, 每单{}张",
                        times, excess, totalOrders, perOrderQty);
            }
        }
@@ -1337,7 +1509,8 @@
        // 止损追单:同一网格可有多笔挂单,不判断 isHasShortOrder,直接挂单
        BigDecimal triggerPrice = newEntryGrid.getGridPrice();
        String size = String.valueOf(Math.max(queryPositionSize(Position.ModeEnum.DUAL_LONG), longPositionSize.intValue()) + Integer.parseInt(config.getQuantity()));
//        String size = String.valueOf(Math.max(queryPositionSize(Position.ModeEnum.DUAL_LONG), longPositionSize.intValue()) + Integer.parseInt(config.getQuantity()));
        String size = String.valueOf(config.getBaseQuantity());
        newEntryGrid.getShortTraderParam().setQuantity(size);
        placeEntryOrderWithPreFlag(newEntryGrid, false, triggerPrice,
                FuturesPriceTrigger.RuleEnum.NUMBER_2, negate(size));
@@ -1688,98 +1861,6 @@
    }
    /**
     * 挂单成交后,将超出基础仓位的部分挂止盈单,挂在对向仓位的第一止损位上。
     *
     * <p>遍历所有 GridElement,找到对向仓位第一个有止损单的网格作为止盈挂单位置。
     *
     * <p>例:空仓成交后持仓 8 张,基础 4 张 → 超出 4 张,
     * 找到多仓第一止损位(如 gridId=-2)→ 在该位置挂空仓止盈单。
     *
     * @param posSize  当前总持仓张数
     * @param isLong   true=多仓成交,false=空仓成交
     */
    private void placeExcessTakeProfit(int posSize, boolean isLong) {
        int baseQty = Integer.parseInt(config.getBaseQuantity());
        int excessQty = posSize - baseQty;
        if (excessQty <= 0) {
            return;
        }
        // 遍历找到对向仓位第一个有止损单的网格
        GridElement tpElem = isLong ? findFirstShortStopLossGrid() : findFirstLongStopLossGrid();
        if (tpElem == null) {
            log.warn("[Gate] {}止盈挂单失败:未找到对向仓止损位", isLong ? "多仓" : "空仓");
            return;
        }
        int tpGridId = tpElem.getId();
        BigDecimal triggerPrice = tpElem.getGridPrice();
        String orderType = isLong ? ORDER_TYPE_CLOSE_LONG : ORDER_TYPE_CLOSE_SHORT;
        // 多仓止盈:价格≥触发价时平仓(NUMBER_1);空仓止盈:价格≤触发价时平仓(NUMBER_2)
        FuturesPriceTrigger.RuleEnum rule = isLong ? FuturesPriceTrigger.RuleEnum.NUMBER_1
                : FuturesPriceTrigger.RuleEnum.NUMBER_2;
        String size = isLong ? negate(String.valueOf(excessQty)) : String.valueOf(excessQty);
//        if (isLong && tpElem.getLongTakeProfitOrderId() != null) {
//            executor.cancelConditionalOrder(tpElem.getLongTakeProfitOrderId(), oid -> {
//                longTakeProfitTraderIdParam(tpElem, null, false);
//                log.info("[Gate] 取消旧止盈, gridId:{}, orderId:{}", tpGridId, oid);
//            });
//        } else if (!isLong && tpElem.getShortTakeProfitOrderId() != null) {
//            executor.cancelConditionalOrder(tpElem.getShortTakeProfitOrderId(), oid -> {
//                shortTakeProfitTraderIdParam(tpElem, null, false);
//                log.info("[Gate] 取消旧止盈, gridId:{}, orderId:{}", tpGridId, oid);
//            });
//        }
        String finalSize = size;
        int finalTpGridId = tpGridId;
        executor.placeTakeProfit(triggerPrice, rule, orderType, size,
                profitId -> {
                    if (isLong) {
                        longTakeProfitTraderIdParam(tpElem, profitId, true);
                    } else {
                        shortTakeProfitTraderIdParam(tpElem, profitId, true);
                    }
                    log.info("[Gate] {}止盈挂单, gridId:{}, 触发价:{}, 数量:{}, takeProfitId:{}",
                            isLong ? "多仓" : "空仓", finalTpGridId, triggerPrice, finalSize, profitId);
                }
        );
    }
    /**
     * 找到有长仓止损单且离 0 最近的网格(第一个多仓止损位)。
     */
    private GridElement findFirstLongStopLossGrid() {
        GridElement first = null;
        for (GridElement e : config.getGridElements()) {
            if (!e.getLongStopLossOrderIds().isEmpty()) {
                // 多仓止损在负数区,取 id 最大(最靠近 0)的
                if (first == null || e.getId() > first.getId()) {
                    first = e;
                }
            }
        }
        return first;
    }
    /**
     * 找到有空仓止损单且离 0 最近的网格(第一个空仓止损位)。
     */
    private GridElement findFirstShortStopLossGrid() {
        GridElement first = null;
        for (GridElement e : config.getGridElements()) {
            if (!e.getShortStopLossOrderIds().isEmpty()) {
                // 空仓止损在正数区,取 id 最小(最靠近 0)的
                if (first == null || e.getId() < first.getId()) {
                    first = e;
                }
            }
        }
        return first;
    }
    /**
     * 延展完成后重挂止损(处理被跳过的入场单成交)。
     * 取消已有止损单并用最新仓位重新挂单,确保止损覆盖最新持仓数。
     */
@@ -1994,4 +2075,70 @@
    public StrategyState getState() { return state; }
    /** 注入WS客户端,用于订阅状态检查 */
    public void setWsClient(GateKlineWebSocketClient wsClient) { this.wsClient = wsClient; }
    // ========== 止损查表辅助方法 ==========
    /** 找到第一个有多仓止损单的网格(首个匹配即返回) */
    private GridElement findFirstLongStopLossGrid() {
        for (GridElement e : config.getGridElements()) {
            if (!e.getLongStopLossOrderIds().isEmpty()) return e;
        }
        return null;
    }
    /** 找到第一个有空仓止损单的网格(首个匹配即返回) */
    private GridElement findFirstShortStopLossGrid() {
        for (GridElement e : config.getGridElements()) {
            if (!e.getShortStopLossOrderIds().isEmpty()) return e;
        }
        return null;
    }
    /**
     * 在指定网格挂一笔对手止盈单(非满仓超额止盈,挂在止损触发位的下一格)。
     */
    private void placeTakeProfitAtGrid(GridElement tpElem, boolean isLong, int qty, int times) {
        BigDecimal triggerPrice = tpElem.getGridPrice();
        String orderType = isLong ? ORDER_TYPE_CLOSE_LONG : ORDER_TYPE_CLOSE_SHORT;
        FuturesPriceTrigger.RuleEnum rule = isLong ? FuturesPriceTrigger.RuleEnum.NUMBER_1
                : FuturesPriceTrigger.RuleEnum.NUMBER_2;
        String size = isLong ? negate(String.valueOf(qty)) : String.valueOf(qty);
        int gridId = tpElem.getId();
        executor.placeTakeProfit(triggerPrice, rule, orderType, size,
                profitId -> {
                    if (isLong) {
                        longTakeProfitTraderIdParam(tpElem, profitId, true);
                    } else {
                        shortTakeProfitTraderIdParam(tpElem, profitId, true);
                    }
                    log.info("[Gate] 止损{}→对手超额止盈 gridId:{}, 量:{}, tpId:{}", times, gridId, qty, profitId);
                }
        );
    }
    /**
     * 挂对手盘止盈单:在对向仓位第一止损位挂止盈。
     * @param isLong true=挂多仓止盈(对空仓), false=挂空仓止盈(对多仓)
     */
    private void placeOpponentTakeProfit(boolean isLong, int tpQty, int times, int gridId) {
        GridElement tpElem = GridElement.findById(isLong ? gridId + 1 : gridId - 1);
        if (tpElem == null) {
            log.warn("[Gate] 对手止盈挂单失败:未找到止损位");
            return;
        }
        int tpGridId = tpElem.getId();
        BigDecimal triggerPrice = tpElem.getGridPrice();
        String orderType = isLong ? ORDER_TYPE_CLOSE_LONG : ORDER_TYPE_CLOSE_SHORT;
        FuturesPriceTrigger.RuleEnum rule = isLong ? FuturesPriceTrigger.RuleEnum.NUMBER_1
                : FuturesPriceTrigger.RuleEnum.NUMBER_2;
        String size = isLong ? negate(String.valueOf(tpQty)) : String.valueOf(tpQty);
        executor.placeTakeProfit(triggerPrice, rule, orderType, size,
                profitId -> {
                    if (isLong) longTakeProfitTraderIdParam(tpElem, profitId, true);
                    else        shortTakeProfitTraderIdParam(tpElem, profitId, true);
                    log.info("[Gate] 止损次数{}→对手{}止盈 gridId:{}, 量:{}, tpId:{}",
                            times, isLong ? "多仓" : "空仓", tpGridId, size, profitId);
                }
        );
    }
}