src/main/java/com/xcong/excoin/modules/gateApi/GateConfig.java
@@ -121,8 +121,15 @@ private final int maxPositionPerSide; /** 加仓启动阈值:前N次止损不触发加仓,默认 1(第1次止损不加仓,第2次起按公式计算) */ private final int addPositionStartThreshold; /** 超额止盈开关:true=挂单成交后将超出基础仓位的部分挂止盈单(挂在对手第一止损位),默认 false */ private final boolean placeExcessTakeProfit; /** 止盈挂单模式:关闭 */ public static final String EXCESS_TP_OFF = "off"; /** 止盈挂单模式:集中止盈(挂在对手第一止损位) */ public static final String EXCESS_TP_CONCENTRATED = "concentrated"; /** 止盈挂单模式:分散止盈(逐格挂到对手盘止损位) */ public static final String EXCESS_TP_DISPERSED = "dispersed"; /** 止盈挂单模式:off=关闭 / concentrated=集中止盈 / dispersed=分散止盈,默认 off */ private final String placeExcessTakeProfit; /** 网格绝对步长(shortBaseEntryPrice × gridRate),运行时由队列生成逻辑设置 */ private BigDecimal step; /** 网格元素列表,由队列初始化时同步填充,包含完整的多空仓挂单状态 */ @@ -269,8 +276,8 @@ public int getMaxPositionPerSide() { return maxPositionPerSide; } /** @return 加仓启动阈值:前N次止损不触发加仓 */ public int getAddPositionStartThreshold() { return addPositionStartThreshold; } /** @return 超额止盈开关:true=挂单成交后将超出基础仓位的部分挂止盈单 */ public boolean isPlaceExcessTakeProfit() { return placeExcessTakeProfit; } /** @return 止盈挂单模式:off=关闭 / concentrated=集中止盈 / dispersed=分散止盈 */ public String getPlaceExcessTakeProfit() { return placeExcessTakeProfit; } // ==================== 运行时参数 ==================== @@ -381,8 +388,8 @@ private int maxPositionPerSide = 0; /** 加仓启动阈值:前N次止损不触发加仓,调整后的 effectiveCount = max(0, count - threshold)。默认 1 */ private int addPositionStartThreshold = 1; /** 超额止盈开关:true=挂单成交后将超出基础仓位的部分挂止盈单,默认 false */ private boolean placeExcessTakeProfit = false; /** 止盈挂单模式:off=关闭 / concentrated=集中止盈 / dispersed=分散止盈,默认 off */ private String placeExcessTakeProfit = EXCESS_TP_OFF; /** 设置 API Key */ public Builder apiKey(String apiKey) { this.apiKey = apiKey; return this; } @@ -444,8 +451,8 @@ public Builder maxPositionPerSide(int maxPositionPerSide) { this.maxPositionPerSide = maxPositionPerSide; return this; } /** 设置加仓启动阈值:前N次止损不触发加仓,0=立即启动 */ public Builder addPositionStartThreshold(int addPositionStartThreshold) { this.addPositionStartThreshold = addPositionStartThreshold; return this; } /** 设置超额止盈开关:true=挂单成交后将超出基础仓位的部分挂止盈单 */ public Builder placeExcessTakeProfit(boolean placeExcessTakeProfit) { this.placeExcessTakeProfit = placeExcessTakeProfit; return this; } /** 设置止盈挂单模式:off / concentrated / dispersed */ public Builder placeExcessTakeProfit(String placeExcessTakeProfit) { this.placeExcessTakeProfit = placeExcessTakeProfit; return this; } public GateConfig build() { return new GateConfig(this); src/main/java/com/xcong/excoin/modules/gateApi/GateConfigDTO.java
@@ -69,8 +69,8 @@ private int maxPositionPerSide; /** 加仓启动阈值:前N次止损不触发加仓,默认 1 */ private int addPositionStartThreshold; /** 超额止盈开关:true=挂单成交后将超出基础仓位的部分挂止盈单,默认 false */ private boolean placeExcessTakeProfit; /** 止盈挂单模式:off=关闭 / concentrated=集中止盈 / dispersed=分散止盈,默认 off */ private String placeExcessTakeProfit; /** * 从 GateConfig 构建 DTO(不暴露 apiSecret)。 @@ -103,7 +103,7 @@ .addPositionQuantity(config.getAddPositionQuantity()) .maxPositionPerSide(config.getMaxPositionPerSide()) .addPositionStartThreshold(config.getAddPositionStartThreshold()) .placeExcessTakeProfit(config.isPlaceExcessTakeProfit()) .placeExcessTakeProfit(config.getPlaceExcessTakeProfit()) .build(); } @@ -164,7 +164,7 @@ .addPositionQuantity(1) .maxPositionPerSide(0) .addPositionStartThreshold(1) .placeExcessTakeProfit(false) .placeExcessTakeProfit("off") .build(); } } src/main/java/com/xcong/excoin/modules/gateApi/GateConfigPersistenceService.java
@@ -111,7 +111,7 @@ .addPositionQuantity(dto.getAddPositionQuantity() > 0 ? dto.getAddPositionQuantity() : 1) .maxPositionPerSide(dto.getMaxPositionPerSide()) .addPositionStartThreshold(dto.getAddPositionStartThreshold() >= 0 ? dto.getAddPositionStartThreshold() : 1) .placeExcessTakeProfit(dto.isPlaceExcessTakeProfit()) .placeExcessTakeProfit(normalizeTakeProfitMode(dto.getPlaceExcessTakeProfit())) .build(); } @@ -141,4 +141,22 @@ private static <T> T nvl(T value, T fallback) { return value != null ? value : fallback; } /** * 归一化止盈挂单模式,兼容旧版 boolean 配置(旧 JSON 的 true/false 会被 Jackson 反序列化为字符串)。 */ private static String normalizeTakeProfitMode(String raw) { if (raw == null || raw.trim().isEmpty()) { return GateConfig.EXCESS_TP_OFF; } switch (raw.trim()) { case "true": case "concentrated": return GateConfig.EXCESS_TP_CONCENTRATED; case "dispersed": return GateConfig.EXCESS_TP_DISPERSED; default: return GateConfig.EXCESS_TP_OFF; } } } src/main/java/com/xcong/excoin/modules/gateApi/GateGridTradeService.java
@@ -757,8 +757,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 +799,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); @@ -2189,6 +2193,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) { src/main/resources/static/gate-config.html
@@ -132,7 +132,7 @@ <div class="form-group"><label>加仓数量(张)</label><input id="addPositionQuantity" type="number" placeholder="1"></div> <div class="form-group"><label>单边最大仓位量(张)</label><input id="maxPositionPerSide" type="number" placeholder="0(不限制)"></div> <div class="form-group"><label>加仓启动阈值(次)</label><input id="addPositionStartThreshold" type="number" placeholder="1(前N次止损不加仓)"></div> <div class="form-group"><label>超额止盈开关</label><select id="placeExcessTakeProfit"><option value="false" selected>关闭</option><option value="true">开启</option></select></div> <div class="form-group"><label>止盈挂单</label><select id="placeExcessTakeProfit"><option value="off" selected>关闭</option><option value="concentrated">集中止盈</option><option value="dispersed">分散止盈</option></select></div> </form> </div> @@ -209,7 +209,7 @@ // 文件不存在 → 用默认值填充,用户可编辑后保存 fillForm({ gridRate:0.005, expectedProfit:0.15, maxLoss:1.5, baseQuantity:'2', quantity:'2', maxPositionSize:4, stopLossCount:0, takeProfitGridSpan:2, priceDriveEnabled: true, rounds: 0, stopLossCountMode:'dual', addPositionInterval:3, addPositionQuantity:1, maxPositionPerSide:0, addPositionStartThreshold:1, placeExcessTakeProfit: false }); stopLossCountMode:'dual', addPositionInterval:3, addPositionQuantity:1, maxPositionPerSide:0, addPositionStartThreshold:1, placeExcessTakeProfit: 'off' }); toast('未找到配置,已加载默认值,编辑后请保存', 'success'); } currentApiKey = key; @@ -234,7 +234,7 @@ byId('addPositionQuantity').value = d.addPositionQuantity ?? 1; byId('maxPositionPerSide').value = d.maxPositionPerSide ?? 0; byId('addPositionStartThreshold').value = d.addPositionStartThreshold ?? 1; byId('placeExcessTakeProfit').value = (d.placeExcessTakeProfit === true || d.placeExcessTakeProfit === 'true') ? 'true' : 'false'; byId('placeExcessTakeProfit').value = normalizeTpMode(d.placeExcessTakeProfit); } function collectForm() { @@ -255,13 +255,18 @@ addPositionQuantity: intVal(byId('addPositionQuantity').value, 1), maxPositionPerSide: intVal(byId('maxPositionPerSide').value, 0), addPositionStartThreshold: intVal(byId('addPositionStartThreshold').value, 1), placeExcessTakeProfit: byId('placeExcessTakeProfit').value === 'true' placeExcessTakeProfit: byId('placeExcessTakeProfit').value }; } function intVal(str, fallback) { const v = parseInt(str); return isNaN(v) ? fallback : v; } function normalizeTpMode(v) { if (v === true || v === 'true') return 'concentrated'; if (v === 'concentrated' || v === 'dispersed') return v; return 'off'; } async function saveConfig() { if (!currentApiKey) return toast('请先输入 API Key', 'error');