| | |
| | | private int addPositionStartThreshold; |
| | | /** 超额止盈开关:true=挂单成交后将超出基础仓位的部分挂止盈单,默认 false */ |
| | | private boolean placeExcessTakeProfit; |
| | | /** 双开止损触发开关:true=按多空止损触发价初始化双向止损单,false=沿用现有逻辑 */ |
| | | private Boolean dualStopLossEnabled; |
| | | /** 多头止损触发价 */ |
| | | private String longStopLossPrice; |
| | | /** 空头止损触发价 */ |
| | | private String shortStopLossPrice; |
| | | |
| | | /** |
| | | * 从 GateConfig 构建 DTO(不暴露 apiSecret)。 |
| | |
| | | m.put("maxPositionPerSide", maxPositionPerSide); |
| | | m.put("addPositionStartThreshold", addPositionStartThreshold); |
| | | m.put("placeExcessTakeProfit", placeExcessTakeProfit); |
| | | m.put("dualStopLossEnabled", dualStopLossEnabled); |
| | | m.put("longStopLossPrice", longStopLossPrice); |
| | | m.put("shortStopLossPrice", shortStopLossPrice); |
| | | return m; |
| | | } |
| | | |
| | |
| | | .maxPositionPerSide(0) |
| | | .addPositionStartThreshold(1) |
| | | .placeExcessTakeProfit(false) |
| | | .dualStopLossEnabled(false) |
| | | .longStopLossPrice(null) |
| | | .shortStopLossPrice(null) |
| | | .build(); |
| | | } |
| | | } |
| | |
| | | stationService.updateConfig(apiKeyMd5, params); |
| | | return Result.ok("参数已保存,请手动点击「启动」使新配置生效"); |
| | | } |
| | | |
| | | @ApiOperation("取消多仓止盈止损挂单") |
| | | @PostMapping("/cancel-long-tp-sl") |
| | | public Result cancelLongTpSl(@RequestParam String apiKeyMd5) { |
| | | stationService.cancelLongTpSl(apiKeyMd5); |
| | | return Result.ok("指令已发送"); |
| | | } |
| | | |
| | | @ApiOperation("取消空仓止盈止损挂单") |
| | | @PostMapping("/cancel-short-tp-sl") |
| | | public Result cancelShortTpSl(@RequestParam String apiKeyMd5) { |
| | | stationService.cancelShortTpSl(apiKeyMd5); |
| | | return Result.ok("指令已发送"); |
| | | } |
| | | |
| | | @ApiOperation("生成多仓止盈止损挂单") |
| | | @PostMapping("/rebuild-long-tp-sl") |
| | | public Result rebuildLongTpSl(@RequestParam String apiKeyMd5) { |
| | | stationService.rebuildLongTpSl(apiKeyMd5); |
| | | return Result.ok("指令已发送"); |
| | | } |
| | | |
| | | @ApiOperation("生成空仓止盈止损挂单") |
| | | @PostMapping("/rebuild-short-tp-sl") |
| | | public Result rebuildShortTpSl(@RequestParam String apiKeyMd5) { |
| | | stationService.rebuildShortTpSl(apiKeyMd5); |
| | | return Result.ok("指令已发送"); |
| | | } |
| | | |
| | | @ApiOperation("多单平仓") |
| | | @PostMapping("/close-long") |
| | | public Result closeLong(@RequestParam String apiKeyMd5) { |
| | | stationService.closeLong(apiKeyMd5); |
| | | return Result.ok("指令已发送"); |
| | | } |
| | | |
| | | @ApiOperation("空单平仓") |
| | | @PostMapping("/close-short") |
| | | public Result closeShort(@RequestParam String apiKeyMd5) { |
| | | stationService.closeShort(apiKeyMd5); |
| | | return Result.ok("指令已发送"); |
| | | } |
| | | } |
| | |
| | | /** 价格驱动开关 */ |
| | | private Boolean priceDriveEnabled; |
| | | |
| | | /** 双开止损触发开关:true=按多空止损触发价初始化双向止损单,false=沿用现有逻辑 */ |
| | | private Boolean dualStopLossEnabled; |
| | | |
| | | /** 多头止损触发价 */ |
| | | private String longStopLossPrice; |
| | | |
| | | /** 空头止损触发价 */ |
| | | private String shortStopLossPrice; |
| | | |
| | | /** 当前轮次 */ |
| | | private Integer currentRound; |
| | | |
| | |
| | | /** 更新策略参数(通过 MQ 发送 UPDATE_CONFIG 到目标 JAR,仅持久化,需手动启动生效) */ |
| | | void updateConfig(String apiKeyMd5, Map<String, Object> params); |
| | | |
| | | /** 取消多仓止盈止损(通过 MQ 发送 CANCEL_LONG_TP_SL 到目标 JAR) */ |
| | | void cancelLongTpSl(String apiKeyMd5); |
| | | |
| | | /** 取消空仓止盈止损(通过 MQ 发送 CANCEL_SHORT_TP_SL 到目标 JAR) */ |
| | | void cancelShortTpSl(String apiKeyMd5); |
| | | |
| | | /** 生成多仓止盈止损(通过 MQ 发送 REBUILD_LONG_TP_SL 到目标 JAR) */ |
| | | void rebuildLongTpSl(String apiKeyMd5); |
| | | |
| | | /** 生成空仓止盈止损(通过 MQ 发送 REBUILD_SHORT_TP_SL 到目标 JAR) */ |
| | | void rebuildShortTpSl(String apiKeyMd5); |
| | | |
| | | /** 多单平仓(通过 MQ 发送 CLOSE_LONG 到目标 JAR) */ |
| | | void closeLong(String apiKeyMd5); |
| | | |
| | | /** 空单平仓(通过 MQ 发送 CLOSE_SHORT 到目标 JAR) */ |
| | | void closeShort(String apiKeyMd5); |
| | | |
| | | /** 保存实例别名(空白/空串 = 清除别名),持久化到 strategy_status */ |
| | | void saveAlias(String apiKeyMd5, String alias); |
| | | |
| | |
| | | cmdProducer.sendCommand(apiKeyMd5, "UPDATE_CONFIG", JSON.toJSONString(params)); |
| | | } |
| | | |
| | | // ==================== 手动控制指令 ==================== |
| | | |
| | | @Override |
| | | public void cancelLongTpSl(String apiKeyMd5) { |
| | | cmdProducer.sendCommand(apiKeyMd5, "CANCEL_LONG_TP_SL"); |
| | | } |
| | | |
| | | @Override |
| | | public void cancelShortTpSl(String apiKeyMd5) { |
| | | cmdProducer.sendCommand(apiKeyMd5, "CANCEL_SHORT_TP_SL"); |
| | | } |
| | | |
| | | @Override |
| | | public void rebuildLongTpSl(String apiKeyMd5) { |
| | | cmdProducer.sendCommand(apiKeyMd5, "REBUILD_LONG_TP_SL"); |
| | | } |
| | | |
| | | @Override |
| | | public void rebuildShortTpSl(String apiKeyMd5) { |
| | | cmdProducer.sendCommand(apiKeyMd5, "REBUILD_SHORT_TP_SL"); |
| | | } |
| | | |
| | | @Override |
| | | public void closeLong(String apiKeyMd5) { |
| | | cmdProducer.sendCommand(apiKeyMd5, "CLOSE_LONG"); |
| | | } |
| | | |
| | | @Override |
| | | public void closeShort(String apiKeyMd5) { |
| | | cmdProducer.sendCommand(apiKeyMd5, "CLOSE_SHORT"); |
| | | } |
| | | |
| | | // ==================== 别名 ==================== |
| | | |
| | | @Override |
| | |
| | | if (params.containsKey("addPositionStartThreshold")) status.setAddPositionStartThreshold(intVal(params.get("addPositionStartThreshold"))); |
| | | if (params.containsKey("placeExcessTakeProfit")) status.setPlaceExcessTakeProfit(boolVal(params.get("placeExcessTakeProfit"))); |
| | | if (params.containsKey("priceDriveEnabled")) status.setPriceDriveEnabled(boolVal(params.get("priceDriveEnabled"))); |
| | | if (params.containsKey("dualStopLossEnabled")) status.setDualStopLossEnabled(boolVal(params.get("dualStopLossEnabled"))); |
| | | if (params.containsKey("longStopLossPrice")) status.setLongStopLossPrice(str(params.get("longStopLossPrice"))); |
| | | if (params.containsKey("shortStopLossPrice")) status.setShortStopLossPrice(str(params.get("shortStopLossPrice"))); |
| | | if (params.containsKey("rounds")) status.setTotalRounds(intVal(params.get("rounds"))); |
| | | if (params.containsKey("principal")) status.setPrincipal(str(params.get("principal"))); |
| | | } |
| | |
| | | `add_position_start_threshold` INT(11) DEFAULT 0 COMMENT '加仓启动阈值', |
| | | `place_excess_take_profit` TINYINT(1) DEFAULT 0 COMMENT '超额止盈开关', |
| | | `price_drive_enabled` TINYINT(1) DEFAULT 1 COMMENT '价格驱动开关', |
| | | `dual_stop_loss_enabled` TINYINT(1) DEFAULT 0 COMMENT '双开止损触发开关', |
| | | `long_stop_loss_price` VARCHAR(32) DEFAULT NULL COMMENT '多头止损触发价', |
| | | `short_stop_loss_price` VARCHAR(32) DEFAULT NULL COMMENT '空头止损触发价', |
| | | `current_round` INT(11) DEFAULT 0 COMMENT '当前轮次', |
| | | `total_rounds` INT(11) DEFAULT 0 COMMENT '总轮数上限', |
| | | `accumulated_long_loss` INT(11) DEFAULT 0 COMMENT '多头累计止损次数', |
| | |
| | | -- ============================================================ |
| | | ALTER TABLE `strategy_status` |
| | | ADD COLUMN `alias_name` VARCHAR(64) DEFAULT NULL COMMENT '实例别名(Station 端自定义显示名)' AFTER `api_key_md5`; |
| | | |
| | | -- ============================================================ |
| | | -- 升级脚本:双开止损触发价参数(2026-09 新增) |
| | | -- ============================================================ |
| | | ALTER TABLE `strategy_status` |
| | | ADD COLUMN `dual_stop_loss_enabled` TINYINT(1) DEFAULT 0 COMMENT '双开止损触发开关' AFTER `price_drive_enabled`, |
| | | ADD COLUMN `long_stop_loss_price` VARCHAR(32) DEFAULT NULL COMMENT '多头止损触发价' AFTER `dual_stop_loss_enabled`, |
| | | ADD COLUMN `short_stop_loss_price` VARCHAR(32) DEFAULT NULL COMMENT '空头止损触发价' AFTER `long_stop_loss_price`; |
| | |
| | | <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 full" style="margin-top:6px;border-top:1px solid var(--border);padding-top:8px"> |
| | | <label style="font-size:13px;font-weight:600;color:var(--text)">双开止损触发</label> |
| | | </div> |
| | | <div class="form-group"><label>双开止损触发开关</label><select id="dualStopLossEnabled"><option value="false" selected>关闭</option><option value="true">开启</option></select></div> |
| | | <div class="form-group"><label>多头止损触发价</label><input id="longStopLossPrice" placeholder="如 2540"></div> |
| | | <div class="form-group"><label>空头止损触发价</label><input id="shortStopLossPrice" placeholder="如 2650"></div> |
| | | </form> |
| | | </div> |
| | | |
| | |
| | | // 文件不存在 → 用默认值填充,用户可编辑后保存 |
| | | 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: false, |
| | | dualStopLossEnabled: false, longStopLossPrice: '', shortStopLossPrice: '' }); |
| | | toast('未找到配置,已加载默认值,编辑后请保存', 'success'); |
| | | } |
| | | currentApiKey = key; |
| | |
| | | byId('maxPositionPerSide').value = d.maxPositionPerSide ?? 0; |
| | | byId('addPositionStartThreshold').value = d.addPositionStartThreshold ?? 1; |
| | | byId('placeExcessTakeProfit').value = (d.placeExcessTakeProfit === true || d.placeExcessTakeProfit === 'true') ? 'true' : 'false'; |
| | | byId('dualStopLossEnabled').value = (d.dualStopLossEnabled === true || d.dualStopLossEnabled === 'true') ? 'true' : 'false'; |
| | | byId('longStopLossPrice').value = d.longStopLossPrice || ''; |
| | | byId('shortStopLossPrice').value = d.shortStopLossPrice || ''; |
| | | } |
| | | |
| | | function collectForm() { |
| | |
| | | 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 === 'true', |
| | | dualStopLossEnabled: byId('dualStopLossEnabled').value === 'true', |
| | | longStopLossPrice: byId('longStopLossPrice').value, |
| | | shortStopLossPrice: byId('shortStopLossPrice').value |
| | | }; |
| | | } |
| | | function intVal(str, fallback) { |
| | |
| | | .btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text); } |
| | | .btn:disabled { opacity: .4; cursor: not-allowed; } |
| | | |
| | | /* ===== 手动控制面板 ===== */ |
| | | .manual-actions { display: flex; flex-wrap: wrap; gap: 8px; } |
| | | .manual-actions .btn { padding: 7px 14px; } |
| | | |
| | | /* ===== 内容区(Tabs) ===== */ |
| | | .content { flex: 1; overflow-y: auto; padding: 16px 24px; } |
| | | .tabs { |
| | |
| | | <h2 style="color:var(--dim)">← 选择一个实例</h2> |
| | | </div> |
| | | <div class="btn-group"> |
| | | <button class="btn btn-success" id="btnStart" disabled onclick="showConfirm('start')">▶ 启动</button> |
| | | <button class="btn btn-danger" id="btnStop" disabled onclick="showConfirm('stop')">⏹ 停止</button> |
| | | <button class="btn btn-success" id="btnStart" disabled onclick="showConfirm('start','启动')">▶ 启动</button> |
| | | <button class="btn btn-danger" id="btnStop" disabled onclick="showConfirm('stop','停止')">⏹ 停止</button> |
| | | </div> |
| | | </div> |
| | | |
| | |
| | | <!-- 概览 Tab --> |
| | | <div class="tab-content active" data-tab="overview"> |
| | | <div class="stats-grid" id="statsGrid"></div> |
| | | <div class="chart-container"> |
| | | <h3>⚙️ 手动控制 <span style="font-size:11px;color:var(--dim);font-weight:400">(实时下发 MQ 指令,策略运行中可用)</span></h3> |
| | | <div class="manual-actions"> |
| | | <button class="btn btn-outline" id="btnCancelLongTpSl" disabled onclick="showConfirm('cancel-long-tp-sl','取消多仓止盈止损')">取消多仓止盈止损</button> |
| | | <button class="btn btn-outline" id="btnCancelShortTpSl" disabled onclick="showConfirm('cancel-short-tp-sl','取消空仓止盈止损')">取消空仓止盈止损</button> |
| | | <button class="btn btn-outline" id="btnRebuildLongTpSl" disabled onclick="showConfirm('rebuild-long-tp-sl','生成多仓止盈止损')">生成多仓止盈止损</button> |
| | | <button class="btn btn-outline" id="btnRebuildShortTpSl" disabled onclick="showConfirm('rebuild-short-tp-sl','生成空仓止盈止损')">生成空仓止盈止损</button> |
| | | <button class="btn btn-danger" id="btnCloseLong" disabled onclick="showConfirm('close-long','多单平仓')">多单平仓</button> |
| | | <button class="btn btn-danger" id="btnCloseShort" disabled onclick="showConfirm('close-short','空单平仓')">空单平仓</button> |
| | | </div> |
| | | </div> |
| | | <div class="chart-container"> |
| | | <h3>📈 盈亏趋势(最近 PNL_SNAPSHOT 事件)</h3> |
| | | <div class="pnl-summary" id="pnlSummary"></div> |
| | |
| | | addPositionStartThreshold: 1, |
| | | placeExcessTakeProfit: false, |
| | | priceDriveEnabled: true, |
| | | dualStopLossEnabled: false, |
| | | longStopLossPrice: '', |
| | | shortStopLossPrice: '', |
| | | }; |
| | | |
| | | async function loadConfig(md5) { |
| | |
| | | { key: 'rounds', label: '运行轮数 (0=不限)', type: 'number', placeholder: '0', jtype: 'int', group: 'run', tip: '盈利重启达此轮数后停止' }, |
| | | { key: 'placeExcessTakeProfit', label: '超额止盈', type: 'select', options: [{ v: 'false', l: '关闭' }, { v: 'true', l: '开启' }], group: 'run' }, |
| | | { key: 'priceDriveEnabled', label: '价格驱动', type: 'select', options: [{ v: 'true', l: '开启' }, { v: 'false', l: '关闭' }], group: 'run' }, |
| | | |
| | | { key: 'dualStopLossEnabled', label: '双开止损触发', type: 'select', options: [{ v: 'false', l: '关闭' }, { v: 'true', l: '开启' }], group: 'dual-sl', tip: '开启后按下方多空止损触发价初始化双向止损单,任一触发后重挂另一侧止损' }, |
| | | { key: 'longStopLossPrice', label: '多头止损触发价', type: 'number', step: 'any', placeholder: '如 2540', jtype: 'string', group: 'dual-sl', tip: '价格跌至该值时触发多头止损' }, |
| | | { key: 'shortStopLossPrice', label: '空头止损触发价', type: 'number', step: 'any', placeholder: '如 2650', jtype: 'string', group: 'dual-sl', tip: '价格涨至该值时触发空头止损' }, |
| | | ]; |
| | | |
| | | // 分组定义(渲染顺序) |
| | |
| | | { id: 'grid', name: '📐 网格参数' }, |
| | | { id: 'risk', name: '🛡️ 风控参数' }, |
| | | { id: 'addon', name: '📈 加仓 & 止损' }, |
| | | { id: 'dual-sl', name: '🎯 双开止损触发' }, |
| | | { id: 'run', name: '⚡ 运行控制' }, |
| | | ]; |
| | | |
| | |
| | | function updateActionButtons(inst) { |
| | | const startBtn = $('btnStart'); |
| | | const stopBtn = $('btnStop'); |
| | | // 手动控制按钮(止盈止损/平仓):需策略运行中才有持仓/挂单可操作 |
| | | const manualIds = ['btnCancelLongTpSl', 'btnCancelShortTpSl', 'btnRebuildLongTpSl', |
| | | 'btnRebuildShortTpSl', 'btnCloseLong', 'btnCloseShort']; |
| | | const setManual = (disabled) => { |
| | | manualIds.forEach(id => { const b = $(id); if (b) b.disabled = disabled; }); |
| | | }; |
| | | if (!inst) { |
| | | startBtn.disabled = true; |
| | | stopBtn.disabled = true; |
| | | setManual(true); |
| | | return; |
| | | } |
| | | const running = inst.state === 'ACTIVE' || inst.state === 'OPENING' || inst.state === 'WAITING_KLINE'; |
| | | startBtn.disabled = running; // 运行中不可重复启动 |
| | | stopBtn.disabled = !running; // 未运行不可停止 |
| | | setManual(!running); // 仅运行中可下发手动指令 |
| | | } |
| | | |
| | | function showConfirm(action) { |
| | | function showConfirm(action, label) { |
| | | if (!selectedMd5) return; |
| | | pendingAction = action; |
| | | const verb = action === 'start' ? '启动' : '停止'; |
| | | pendingAction = { path: action, label: label || action }; |
| | | const verb = pendingAction.label; |
| | | const isDanger = action === 'stop' || action === 'close-long' || action === 'close-short'; |
| | | $('modalTitle').textContent = `确认${verb}`; |
| | | $('modalMsg').textContent = `确定要${verb}实例 ${selectedMd5.substring(0, 12)}... 的策略吗?`; |
| | | $('modalMsg').textContent = `确定要对实例 ${selectedMd5.substring(0, 12)}... 执行「${verb}」吗?`; |
| | | $('modalConfirmBtn').textContent = verb; |
| | | $('modalConfirmBtn').className = 'btn ' + (action === 'start' ? 'btn-success' : 'btn-danger'); |
| | | $('modalConfirmBtn').className = 'btn ' + (isDanger ? 'btn-danger' : 'btn-success'); |
| | | $('modalOverlay').classList.add('show'); |
| | | } |
| | | |
| | |
| | | closeModal(); |
| | | |
| | | try { |
| | | const msg = await api(API + '/' + action + '?apiKeyMd5=' + encodeURIComponent(selectedMd5), { method: 'POST' }); |
| | | const msg = await api(API + '/' + action.path + '?apiKeyMd5=' + encodeURIComponent(selectedMd5), { method: 'POST' }); |
| | | toast(msg || '指令已发送', 'success'); |
| | | // 延迟刷新等待 ACK |
| | | setTimeout(refreshInstanceList, 2000); |