From e70e04de37946bbb516b40aca0b1c086528398cd Mon Sep 17 00:00:00 2001
From: Administrator <15274802129@163.com>
Date: Mon, 24 Aug 2026 14:04:37 +0800
Subject: [PATCH] feat(gate): 添加策略远程控制和监控功能

---
 src/main/java/com/xcong/excoin/configurations/RabbitMqConfig.java              |   39 +++
 src/main/java/com/xcong/excoin/modules/gateApi/GateWebSocketClientManager.java |   50 ++-
 src/main/java/com/xcong/excoin/modules/gateApi/GateCommandConsumer.java        |  111 ++++++++
 src/main/java/com/xcong/excoin/modules/station/model/CmdAckMsg.java            |   24 +
 config/gate-config-7332fa3f94276d3dcc44fcd3bfff2379.json                       |   28 ++
 src/main/java/com/xcong/excoin/modules/station/model/InstanceInfo.java         |   34 ++
 src/main/java/com/xcong/excoin/modules/gateApi/GateGridTradeService.java       |  112 ++++++++
 src/main/java/com/xcong/excoin/modules/gateApi/StatsEventProducer.java         |   72 +++++
 src/main/java/com/xcong/excoin/modules/station/model/HeartbeatMsg.java         |   30 ++
 src/main/java/com/xcong/excoin/modules/gateApi/HeartbeatScheduler.java         |   92 +++++++
 src/main/java/com/xcong/excoin/modules/gateApi/GateConfigDTO.java              |   63 ++++
 src/main/java/com/xcong/excoin/modules/gateApi/CommandQueueInitializer.java    |   70 +++++
 src/main/java/com/xcong/excoin/modules/station/model/GateStatsEvent.java       |   26 ++
 src/main/java/com/xcong/excoin/modules/station/model/GateCommand.java          |   29 ++
 14 files changed, 764 insertions(+), 16 deletions(-)

diff --git a/config/gate-config-7332fa3f94276d3dcc44fcd3bfff2379.json b/config/gate-config-7332fa3f94276d3dcc44fcd3bfff2379.json
new file mode 100644
index 0000000..34db0fd
--- /dev/null
+++ b/config/gate-config-7332fa3f94276d3dcc44fcd3bfff2379.json
@@ -0,0 +1,28 @@
+{
+  "apiKey" : null,
+  "contract" : null,
+  "leverage" : null,
+  "marginMode" : null,
+  "positionMode" : null,
+  "gridRate" : 0.005,
+  "expectedProfit" : 0.15,
+  "maxLoss" : 1.5,
+  "baseQuantity" : "2",
+  "quantity" : "2",
+  "maxPositionSize" : 8,
+  "stopLossCount" : 0,
+  "takeProfitGridSpan" : 2,
+  "restartGridSpan" : 0,
+  "priceScale" : 0,
+  "contractMultiplier" : null,
+  "unrealizedPnlPriceMode" : null,
+  "priceDriveEnabled" : true,
+  "rounds" : 0,
+  "stopLossCountMode" : "dual",
+  "addPositionInterval" : 3,
+  "addPositionQuantity" : 1,
+  "maxPositionPerSide" : 0,
+  "addPositionStartThreshold" : 1,
+  "placeExcessTakeProfit" : false,
+  "production" : false
+}
\ No newline at end of file
diff --git a/src/main/java/com/xcong/excoin/configurations/RabbitMqConfig.java b/src/main/java/com/xcong/excoin/configurations/RabbitMqConfig.java
index 0a791bf..0c12c52 100644
--- a/src/main/java/com/xcong/excoin/configurations/RabbitMqConfig.java
+++ b/src/main/java/com/xcong/excoin/configurations/RabbitMqConfig.java
@@ -72,6 +72,18 @@
     // 平仓路由
     public static final String ROUTINGKEY_CLOSETRADE = "ROUTINGKEY_CLOSETRADE";
 
+    // ==================== Gate 策略管理 ====================
+    /** Gate 管理交换机 */
+    public static final String EXCHANGE_GATE = "biue-exchange-gate";
+    /** 心跳+事件+确认 队列(Station 独占消费) */
+    public static final String QUEUE_GATE_HEARTBEAT = "QUEUE_GATE_HEARTBEAT";
+    /** 心跳+事件+确认 路由键 */
+    public static final String ROUTINGKEY_GATE_HEARTBEAT = "ROUTINGKEY_GATE_HEARTBEAT";
+    /** 策略事件队列(Station 消费落库) */
+    public static final String QUEUE_GATE_STATS = "QUEUE_GATE_STATS";
+    /** 策略事件路由键 */
+    public static final String ROUTINGKEY_GATE_STATS = "ROUTINGKEY_GATE_STATS";
+
     @Resource
     private ConnectionFactory connectionFactory;
 
@@ -286,4 +298,31 @@
         return BindingBuilder.bind(queueCloseTrade()).to(orderExchange()).with(RabbitMqConfig.ROUTINGKEY_CLOSETRADE);
     }
 
+    // ==================== Gate 管理队列 ====================
+
+    @Bean
+    public DirectExchange gateExchange() {
+        return new DirectExchange(EXCHANGE_GATE);
+    }
+
+    @Bean
+    public Queue gateHeartbeatQueue() {
+        return new Queue(QUEUE_GATE_HEARTBEAT, true);
+    }
+
+    @Bean
+    public Binding bindingGateHeartbeat() {
+        return BindingBuilder.bind(gateHeartbeatQueue()).to(gateExchange()).with(ROUTINGKEY_GATE_HEARTBEAT);
+    }
+
+    @Bean
+    public Queue gateStatsQueue() {
+        return new Queue(QUEUE_GATE_STATS, true);
+    }
+
+    @Bean
+    public Binding bindingGateStats() {
+        return BindingBuilder.bind(gateStatsQueue()).to(gateExchange()).with(ROUTINGKEY_GATE_STATS);
+    }
+
 }
diff --git a/src/main/java/com/xcong/excoin/modules/gateApi/CommandQueueInitializer.java b/src/main/java/com/xcong/excoin/modules/gateApi/CommandQueueInitializer.java
new file mode 100644
index 0000000..b310d5d
--- /dev/null
+++ b/src/main/java/com/xcong/excoin/modules/gateApi/CommandQueueInitializer.java
@@ -0,0 +1,70 @@
+package com.xcong.excoin.modules.gateApi;
+
+import com.xcong.excoin.configurations.RabbitMqConfig;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.amqp.core.*;
+import org.springframework.context.annotation.DependsOn;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.PostConstruct;
+import javax.annotation.Resource;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * JAR 启动时动态创建专属命令队列 QUEUE_GATE_CMD_{apiKeyMd5}
+ */
+@Slf4j
+@Component
+@DependsOn("gateWebSocketClientManager")
+public class CommandQueueInitializer {
+
+    @Resource
+    private AmqpAdmin amqpAdmin;
+
+    @Resource
+    private GateWebSocketClientManager manager;
+
+    private volatile String queueName;
+
+    @PostConstruct
+    public void init() {
+        try {
+            // manager.config 此时已由 Manager 的 @PostConstruct 加载完成
+            String apiKey = manager.getConfig().getApiKey();
+            String apiKeyMd5 = md5(apiKey);
+            String routingKey = "cmd." + apiKeyMd5;
+            queueName = "QUEUE_GATE_CMD_" + apiKeyMd5;
+
+            DirectExchange exchange = new DirectExchange(RabbitMqConfig.EXCHANGE_GATE);
+            Queue queue = new Queue(queueName, true, false, true); // durable, non-exclusive, auto-delete
+            Binding binding = BindingBuilder.bind(queue).to(exchange).with(routingKey);
+
+            amqpAdmin.declareQueue(queue);
+            amqpAdmin.declareBinding(binding);
+
+            log.info("[Gate] 命令队列已注册, queue={}, routingKey={}", queueName, routingKey);
+        } catch (Exception e) {
+            log.error("[Gate] 命令队列注册失败, 策略启停指令将无法接收", e);
+            queueName = null;
+        }
+    }
+
+    /** 返回队列名,供 @RabbitListener 引用 */
+    public String getQueueName() {
+        return queueName;
+    }
+
+    private static String md5(String input) {
+        try {
+            MessageDigest md = MessageDigest.getInstance("MD5");
+            byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8));
+            StringBuilder sb = new StringBuilder();
+            for (byte b : digest) sb.append(String.format("%02x", b));
+            return sb.toString();
+        } catch (NoSuchAlgorithmException e) {
+            return Integer.toHexString(input.hashCode());
+        }
+    }
+}
diff --git a/src/main/java/com/xcong/excoin/modules/gateApi/GateCommandConsumer.java b/src/main/java/com/xcong/excoin/modules/gateApi/GateCommandConsumer.java
new file mode 100644
index 0000000..89907d8
--- /dev/null
+++ b/src/main/java/com/xcong/excoin/modules/gateApi/GateCommandConsumer.java
@@ -0,0 +1,111 @@
+package com.xcong.excoin.modules.gateApi;
+
+import com.alibaba.fastjson.JSON;
+import com.xcong.excoin.modules.station.model.CmdAckMsg;
+import com.xcong.excoin.modules.station.model.GateCommand;
+import com.xcong.excoin.modules.station.model.GateStatsEvent;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.amqp.rabbit.annotation.RabbitListener;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+import javax.annotation.Resource;
+
+/**
+ * JAR 侧 — 消费 Station 下发的启停指令
+ */
+@Slf4j
+@Component
+public class GateCommandConsumer {
+
+    @Resource
+    private StatsEventProducer statsProducer;
+
+    @Resource
+    private GateWebSocketClientManager manager;
+
+    /**
+     * 监听专属命令队列 QUEUE_GATE_CMD_{apiKeyMd5}
+     */
+    @RabbitListener(queues = "#{commandQueueInitializer.queueName}")
+    public void onCommand(String msg) {
+        GateCommand cmd;
+        try {
+            cmd = JSON.parseObject(msg, GateCommand.class);
+        } catch (Exception e) {
+            log.error("[Gate] 指令解析失败: {}", msg, e);
+            return;
+        }
+
+        String apiKeyMd5 = md5(manager.getConfig().getApiKey());
+        boolean success = true;
+        String newState = "";
+        String message = "";
+
+        GateGridTradeService strategy = manager.getGridTradeService();
+        try {
+            switch (cmd.getCommandType()) {
+                case "START":
+                    manager.reloadAndStart();
+                    newState = "ACTIVE";
+                    message = "策略已启动(已加载最新配置)";
+                    log.info("[Gate] 远程启动, 已重载配置");
+                    break;
+
+                case "STOP":
+                    strategy.stopGrid();
+                    newState = strategy.getState().name();
+                    message = "策略已停止";
+                    log.info("[Gate] 远程停止, state={}", newState);
+                    break;
+
+                case "UPDATE_CONFIG":
+                    if (!StringUtils.hasText(cmd.getPayload())) {
+                        success = false;
+                        message = "UPDATE_CONFIG 缺少 payload";
+                        log.warn("[Gate] UPDATE_CONFIG 指令缺少 payload");
+                        break;
+                    }
+                    GateConfigDTO dto = JSON.parseObject(cmd.getPayload(), GateConfigDTO.class);
+                    manager.updateConfig(dto);
+                    newState = "ACTIVE";
+                    message = "参数已持久化,待下次启动生效";
+                    log.info("[Gate] 远程参数已持久化");
+                    break;
+
+                default:
+                    success = false;
+                    message = "未知指令类型: " + cmd.getCommandType();
+                    log.warn("[Gate] 未知指令: {}", cmd.getCommandType());
+            }
+        } catch (Exception e) {
+            success = false;
+            newState = "ERROR";
+            message = e.getMessage();
+            log.error("[Gate] 指令执行失败", e);
+        }
+
+        // 发送 ACK 到 Station
+        CmdAckMsg ack = CmdAckMsg.builder()
+                .commandId(cmd.getCommandId())
+                .success(success)
+                .newState(newState)
+                .message(message)
+                .build();
+        GateStatsEvent event = statsProducer.newCmdAck(apiKeyMd5, ack);
+        statsProducer.sendHeartbeat(event);
+        log.debug("[Gate] ACK 已发送, cmdId={}, success={}", cmd.getCommandId(), success);
+    }
+
+    private static String md5(String input) {
+        try {
+            java.security.MessageDigest md = java.security.MessageDigest.getInstance("MD5");
+            byte[] digest = md.digest(input.getBytes(java.nio.charset.StandardCharsets.UTF_8));
+            StringBuilder sb = new StringBuilder();
+            for (byte b : digest) sb.append(String.format("%02x", b));
+            return sb.toString();
+        } catch (Exception e) {
+            return Integer.toHexString(input.hashCode());
+        }
+    }
+}
diff --git a/src/main/java/com/xcong/excoin/modules/gateApi/GateConfigDTO.java b/src/main/java/com/xcong/excoin/modules/gateApi/GateConfigDTO.java
index 851b7dc..97be386 100644
--- a/src/main/java/com/xcong/excoin/modules/gateApi/GateConfigDTO.java
+++ b/src/main/java/com/xcong/excoin/modules/gateApi/GateConfigDTO.java
@@ -6,6 +6,8 @@
 import lombok.NoArgsConstructor;
 
 import java.math.BigDecimal;
+import java.util.LinkedHashMap;
+import java.util.Map;
 
 /**
  * Gate 策略配置 DTO,用于 Web 控制面板参数传递。
@@ -104,4 +106,65 @@
                 .placeExcessTakeProfit(config.isPlaceExcessTakeProfit())
                 .build();
     }
+
+    /**
+     * 所有可调参数的扁平快照 — stats 埋点 + STRATEGY_START payload 用。
+     * 新增参数只需在此方法加一行,无需修改 stats 消费者。
+     */
+    public Map<String, Object> toParamsMap() {
+        Map<String, Object> m = new LinkedHashMap<>();
+        m.put("contract", contract);
+        m.put("leverage", leverage);
+        m.put("gridRate", gridRate);
+        m.put("expectedProfit", expectedProfit);
+        m.put("maxLoss", maxLoss);
+        m.put("baseQuantity", baseQuantity);
+        m.put("quantity", quantity);
+        m.put("maxPositionSize", maxPositionSize);
+        m.put("stopLossCount", stopLossCount);
+        m.put("takeProfitGridSpan", takeProfitGridSpan);
+        m.put("priceDriveEnabled", priceDriveEnabled);
+        m.put("rounds", rounds);
+        m.put("stopLossCountMode", stopLossCountMode);
+        m.put("addPositionInterval", addPositionInterval);
+        m.put("addPositionQuantity", addPositionQuantity);
+        m.put("maxPositionPerSide", maxPositionPerSide);
+        m.put("addPositionStartThreshold", addPositionStartThreshold);
+        m.put("placeExcessTakeProfit", placeExcessTakeProfit);
+        return m;
+    }
+
+    /**
+     * 带默认值的工厂方法 — 统一 Manager 和 HTML 的默认值入口。
+     */
+    public static GateConfigDTO defaultsFor(String apiKey) {
+        return GateConfigDTO.builder()
+                .apiKey(apiKey)
+                .contract("ETH_USDT")
+                .leverage("100")
+                .marginMode("cross")
+                .positionMode("dual")
+                .gridRate(new BigDecimal("0.005"))
+                .expectedProfit(new BigDecimal("0.15"))
+                .maxLoss(new BigDecimal("1.5"))
+                .baseQuantity("2")
+                .quantity("2")
+                .maxPositionSize(4)
+                .stopLossCount(0)
+                .takeProfitGridSpan(2)
+                .restartGridSpan(0)
+                .priceScale(1)
+                .contractMultiplier(new BigDecimal("0.001"))
+                .unrealizedPnlPriceMode("LAST_PRICE")
+                .isProduction(true)
+                .priceDriveEnabled(true)
+                .rounds(0)
+                .stopLossCountMode("dual")
+                .addPositionInterval(3)
+                .addPositionQuantity(1)
+                .maxPositionPerSide(0)
+                .addPositionStartThreshold(1)
+                .placeExcessTakeProfit(false)
+                .build();
+    }
 }
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 8c90d42..e262510 100644
--- a/src/main/java/com/xcong/excoin/modules/gateApi/GateGridTradeService.java
+++ b/src/main/java/com/xcong/excoin/modules/gateApi/GateGridTradeService.java
@@ -103,6 +103,8 @@
     private static final String ORDER_TYPE_CLOSE_SHORT = "plan-close-short-position";
 
     private final GateConfig config;
+    private final StatsEventProducer statsProducer;
+    private String apiKeyMd5;
     private final GateTradeExecutor executor;
     private final FuturesApi futuresApi;
     private static final String SETTLE = "usdt";
@@ -157,10 +159,13 @@
     private volatile BigDecimal shortPositionSize = BigDecimal.ZERO;
     private Long userId;
     private volatile BigDecimal initialPrincipal = BigDecimal.ZERO;
+    /** 上次 PNL 快照时间(毫秒),用于控制 PNL_SNAPSHOT 埋点频率 */
+    private volatile long lastPnlSnapshotTime = 0;
     private volatile GateKlineWebSocketClient wsClient;
 
-    public GateGridTradeService(GateConfig config) {
+    public GateGridTradeService(GateConfig config, StatsEventProducer statsProducer) {
         this.config = config;
+        this.statsProducer = statsProducer;
         ApiClient apiClient = new ApiClient();
         apiClient.setBasePath(config.getRestBasePath());
         apiClient.setApiKeySecret(config.getApiKey(), config.getApiSecret());
@@ -290,6 +295,44 @@
         }
     }
 
+    // ---- 埋点 ----
+
+    private String apiKeyMd5() {
+        if (apiKeyMd5 == null) {
+            try {
+                java.security.MessageDigest md = java.security.MessageDigest.getInstance("MD5");
+                byte[] digest = md.digest(config.getApiKey().getBytes(java.nio.charset.StandardCharsets.UTF_8));
+                StringBuilder sb = new StringBuilder();
+                for (byte b : digest) sb.append(String.format("%02x", b));
+                apiKeyMd5 = sb.toString();
+            } catch (Exception e) {
+                apiKeyMd5 = Integer.toHexString(config.getApiKey().hashCode());
+            }
+        }
+        return apiKeyMd5;
+    }
+
+    private void emitStats(String type, Object payload) {
+        if (statsProducer == null) return;
+        try {
+            statsProducer.sendStats(statsProducer.newStats(type, apiKeyMd5(), payload));
+        } catch (Exception e) {
+            log.warn("[Gate] 埋点发送失败, type={}", type, e);
+        }
+    }
+
+    /**
+     * Java 8 兼容的 Map 构造工具(Map.of 为 Java 9 API,此处手动实现)。
+     * 接受偶数个参数:key1, value1, key2, value2, ...
+     */
+    private static Map<String, Object> mapOf(Object... kv) {
+        Map<String, Object> m = new LinkedHashMap<>();
+        for (int i = 0; i < kv.length; i += 2) {
+            m.put((String) kv[i], kv[i + 1]);
+        }
+        return m;
+    }
+
     // ---- 启动/停止 ----
 
     /**
@@ -328,6 +371,13 @@
         currentRound = 0;
 
         log.info("[Gate] 网格策略已启动, 当前本金: {} USDT", initialPrincipal);
+
+        // 埋点: STRATEGY_START — 附全量配置快照
+        GateConfigDTO snapshot = GateConfigDTO.from(config);
+        Map<String, Object> params = snapshot.toParamsMap();
+        params.put("principal", initialPrincipal.toPlainString());
+        params.put("contract", config.getContract());
+        emitStats("STRATEGY_START", params);
     }
 
     /**
@@ -348,6 +398,14 @@
      */
     public void stopGrid() {
         state = StrategyState.STOPPED;
+
+        // 埋点: STRATEGY_STOP
+        emitStats("STRATEGY_STOP", mapOf(
+                "reason", "manual",
+                "rounds", currentRound,
+                "pnl", cumulativePnl.toPlainString()
+        ));
+
         executor.cancelAllPriceTriggeredOrders();
         closeExistingPositions();
         executor.shutdown();
@@ -451,8 +509,28 @@
                     .add(new BigDecimal(account.getUnrealisedPnl()))
                     .subtract(estimatedCloseFee);
 
+            // 埋点: PNL_SNAPSHOT — 每60秒发射一次
+            long now = System.currentTimeMillis();
+            if (now - lastPnlSnapshotTime >= 60_000) {
+                lastPnlSnapshotTime = now;
+                BigDecimal total = new BigDecimal(account.getTotal());
+                emitStats("PNL_SNAPSHOT", mapOf(
+                        "cumulativePnl", cumulativePnl.toPlainString(),
+                        "unrealizedPnl", new BigDecimal(account.getUnrealisedPnl()).toPlainString(),
+                        "totalEquity", total.toPlainString(),
+                        "markPrice", markPrice.toPlainString()
+                ));
+            }
+
             if (totalEquity.compareTo(target) > 0) {
                 currentRound++;
+
+                // 埋点: ROUND_COMPLETE
+                emitStats("ROUND_COMPLETE", mapOf(
+                        "roundNum", currentRound,
+                        "totalEquity", totalEquity.toPlainString()
+                ));
+
                 int maxRounds = config.getRounds();
                 log.info("[Gate] 盈亏达标(净权益{}→含手续费-{}=实际{}>目标{}),第{}轮完成",
                         new BigDecimal(account.getTotal()).add(new BigDecimal(account.getUnrealisedPnl())),
@@ -655,6 +733,13 @@
                 int filledQty = Integer.parseInt(shortGridElement.getShortTraderParam().getQuantity());
                 shortEntryTraderIdParam(shortGridElement, orderId, false);
 
+                // 埋点: ENTRY_FILLED — 空仓加仓成交
+                emitStats("ENTRY_FILLED", mapOf(
+                        "direction", "short",
+                        "gridId", shortGridElement.getId(),
+                        "filledQty", filledQty
+                ));
+
                 // 防重入:同一网格存在多个入场单且相近时间成交时,只处理第一次 extend,
                 // 后续成交打标 pendingReExtend,延展完成后自动用最新仓位重挂一次。
                 if (shortGridElement.isExtendStopLossInProgress()) {
@@ -687,6 +772,13 @@
 
                 int filledQty = Integer.parseInt(longGridElement.getLongTraderParam().getQuantity());
                 longEntryTraderIdParam(longGridElement, orderId, false);
+
+                // 埋点: ENTRY_FILLED — 多仓加仓成交
+                emitStats("ENTRY_FILLED", mapOf(
+                        "direction", "long",
+                        "gridId", longGridElement.getId(),
+                        "filledQty", filledQty
+                ));
 
                 // 防重入:同一网格存在多个入场单且相近时间成交时,只处理第一次 extend,
                 // 后续成交打标 pendingReExtend,延展完成后自动用最新仓位重挂一次。
@@ -1347,6 +1439,13 @@
         accumulatedLongLossCount++;
         log.info("[Gate] 多仓止损触发 gridId:{}, 止损次数:{}{}, 开始追单",
                 gridId, accumulatedLongLossCount, sameGrid ? "(同网格)" : "");
+
+        // 埋点: STOP_LOSS_TRIGGERED
+        emitStats("STOP_LOSS_TRIGGERED", mapOf(
+                "direction", "long",
+                "gridId", gridId,
+                "lossCount", accumulatedLongLossCount
+        ));
         int newEntryGridId = gridId + 1;
 
         GridElement newEntryGrid = GridElement.findById(newEntryGridId);
@@ -1392,6 +1491,13 @@
         accumulatedShortLossCount++;
         log.info("[Gate] 空仓止损触发 gridId:{}, 止损次数:{}{}, 开始追单",
                 gridId, accumulatedShortLossCount, sameGrid ? "(同网格)" : "");
+
+        // 埋点: STOP_LOSS_TRIGGERED
+        emitStats("STOP_LOSS_TRIGGERED", mapOf(
+                "direction", "short",
+                "gridId", gridId,
+                "lossCount", accumulatedShortLossCount
+        ));
         int newEntryGridId = gridId - 1;
 
         GridElement newEntryGrid = GridElement.findById(newEntryGridId);
@@ -1960,8 +2066,12 @@
     public void setMarkPrice(BigDecimal markPrice) { this.markPrice = markPrice; }
     /** @return 策略是否处于活跃状态(非 STOPPED 且非 WAITING_KLINE) */
     public boolean isStrategyActive() { return state != StrategyState.STOPPED && state != StrategyState.WAITING_KLINE; }
+    /** @return 当前已完成轮数 */
+    public int getCurrentRound() { return currentRound; }
     /** @return 累计已实现盈亏(平仓推送驱动累加) */
     public BigDecimal getCumulativePnl() { return cumulativePnl; }
+    /** @return 初始本金 */
+    public BigDecimal getInitialPrincipal() { return initialPrincipal; }
     /** @return 当前未实现盈亏(每根 K 线实时计算) */
     public BigDecimal getUnrealizedPnl() { return unrealizedPnl; }
     /** @return Gate 用户 ID(用于私有频道订阅 payload) */
diff --git a/src/main/java/com/xcong/excoin/modules/gateApi/GateWebSocketClientManager.java b/src/main/java/com/xcong/excoin/modules/gateApi/GateWebSocketClientManager.java
index 199c466..6f96e47 100644
--- a/src/main/java/com/xcong/excoin/modules/gateApi/GateWebSocketClientManager.java
+++ b/src/main/java/com/xcong/excoin/modules/gateApi/GateWebSocketClientManager.java
@@ -66,6 +66,9 @@
     @Autowired
     private GateLogBuffer logBuffer;
 
+    @Autowired
+    private StatsEventProducer statsEventProducer;
+
     /** WebSocket 连接管理器 */
     private GateKlineWebSocketClient wsClient;
     /** 网格交易策略服务 */
@@ -84,20 +87,7 @@
                 config = persistenceService.buildFromDTO(saved, DEFAULT_API_KEY);
                 logBuffer.info("[管理器] 已加载持久化配置, apiKey=" + mask(DEFAULT_API_KEY));
             } else {
-                // 构建默认配置 DTO(只有 7 项可调参数)
-                GateConfigDTO defaults = GateConfigDTO.builder()
-                        .apiKey(DEFAULT_API_KEY)
-                        .gridRate(new BigDecimal("0.005"))
-                        .expectedProfit(new BigDecimal("0.15"))
-                        .maxLoss(new BigDecimal("1.5"))
-                        .baseQuantity("2")
-                        .quantity("2")
-                        .maxPositionSize(4)
-                        .stopLossCount(0)
-                        .priceDriveEnabled(true)
-                        .rounds(0)
-                        .build();
-                // 确保配置文件存在
+                GateConfigDTO defaults = GateConfigDTO.defaultsFor(DEFAULT_API_KEY);
                 persistenceService.ensureExists(DEFAULT_API_KEY, defaults);
                 config = persistenceService.buildFromDTO(defaults, DEFAULT_API_KEY);
                 logBuffer.info("[管理器] 已创建默认配置文件");
@@ -138,6 +128,36 @@
     public GateConfig getConfig() { return config; }
 
     /**
+     * 远程更新策略参数 — 仅持久化到 JSON 文件,不自动重启。
+     * 下次「启动」指令会从文件读取最新配置后重建策略。
+     *
+     * @param dto 从 Station 下发的可调参数(不含 apiSecret)
+     */
+    public void updateConfig(GateConfigDTO dto) {
+        logBuffer.info("[管理器] 收到远程参数更新指令(仅持久化)");
+        persistenceService.save(config.getApiKey(), dto);
+        logBuffer.info("[管理器] 参数已保存,待下次启动生效");
+    }
+
+    /**
+     * 从持久化文件重载配置并重启策略。
+     * 用于远程「启动」指令:先读最新配置 → 重建服务 → 启动。
+     */
+    public void reloadAndStart() {
+        logBuffer.info("[管理器] 从文件重载配置并启动...");
+        GateConfigDTO saved = persistenceService.load(config.getApiKey());
+        if (saved == null) {
+            logBuffer.info("[管理器] 无持久化配置,使用当前内存配置启动");
+            if (gridTradeService != null) {
+                gridTradeService.startGrid();
+            }
+            return;
+        }
+        GateConfig newConfig = persistenceService.buildFromDTO(saved, config.getApiKey());
+        restartWithConfig(newConfig);
+    }
+
+    /**
      * 使用新配置重启策略:停止旧策略 → 重建 WS → 重新 init + startGrid。
      */
     public void restartWithConfig(GateConfig newConfig) {
@@ -151,7 +171,7 @@
         }
         // 2. 使用新配置重建
         this.config = newConfig;
-        this.gridTradeService = new GateGridTradeService(config);
+        this.gridTradeService = new GateGridTradeService(config, statsEventProducer);
         gridTradeService.init();
         // 3. 重建 WS 客户端并重新注册 Handler
         this.wsClient = new GateKlineWebSocketClient(config.getWsUrl());
diff --git a/src/main/java/com/xcong/excoin/modules/gateApi/HeartbeatScheduler.java b/src/main/java/com/xcong/excoin/modules/gateApi/HeartbeatScheduler.java
new file mode 100644
index 0000000..26cbd5c
--- /dev/null
+++ b/src/main/java/com/xcong/excoin/modules/gateApi/HeartbeatScheduler.java
@@ -0,0 +1,92 @@
+package com.xcong.excoin.modules.gateApi;
+
+import com.xcong.excoin.modules.station.model.GateStatsEvent;
+import com.xcong.excoin.modules.station.model.HeartbeatMsg;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.EnableScheduling;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import java.net.InetAddress;
+import java.security.MessageDigest;
+
+/**
+ * JAR 侧 — 心跳定时发送(每 30s)
+ */
+@Slf4j
+@Component
+@EnableScheduling
+public class HeartbeatScheduler {
+
+    @Resource
+    private StatsEventProducer statsProducer;
+
+    @Resource
+    private GateWebSocketClientManager manager;
+
+    private String apiKeyMd5;
+    private String hostPort;
+
+    // initialDelay = 0:启动后立即发第一次心跳,避免 Station 要等 30s 才能发现实例
+    @Scheduled(fixedRate = 30_000, initialDelay = 1_000)
+    public void heartbeat() {
+        try {
+            if (apiKeyMd5 == null) {
+                apiKeyMd5 = md5(manager.getConfig().getApiKey());
+                hostPort = resolveHostPort();
+            }
+
+            // 策略未启动(gridTradeService == null)时也发送心跳,
+            // 否则 Station 无法发现该 JAR 实例,也就无法对它下发 START 指令(死锁)。
+            GateGridTradeService strategy = manager.getGridTradeService();
+            String state = (strategy != null && strategy.getState() != null)
+                    ? strategy.getState().name() : "STOPPED";
+            int currentRound = strategy != null ? strategy.getCurrentRound() : 0;
+            String cumulativePnl = (strategy != null && strategy.getCumulativePnl() != null)
+                    ? strategy.getCumulativePnl().toPlainString() : "0";
+            String principal = (strategy != null && strategy.getInitialPrincipal() != null)
+                    ? strategy.getInitialPrincipal().toPlainString() : "0";
+
+            HeartbeatMsg hb = HeartbeatMsg.builder()
+                    .contract(manager.getConfig().getContract())
+                    .state(state)
+                    .leverage(manager.getConfig().getLeverage())
+                    .currentRound(currentRound)
+                    .cumulativePnl(cumulativePnl)
+                    .principal(principal)
+                    .hostPort(hostPort)
+                    .build();
+
+            GateStatsEvent event = statsProducer.newHeartbeat(apiKeyMd5, hb);
+            statsProducer.sendHeartbeat(event);
+
+            log.info("[Gate] 心跳已发送, state={}, apiKeyMd5={}", state, apiKeyMd5);
+        } catch (Exception e) {
+            log.warn("[Gate] 心跳发送失败", e);
+        }
+    }
+
+    private String resolveHostPort() {
+        try {
+            String host = InetAddress.getLocalHost().getHostAddress();
+            // port 从 Spring 环境变量获取,默认 8888
+            String port = System.getProperty("server.port", "8888");
+            return host + ":" + port;
+        } catch (Exception e) {
+            return "unknown";
+        }
+    }
+
+    private static String md5(String input) {
+        try {
+            MessageDigest md = MessageDigest.getInstance("MD5");
+            byte[] digest = md.digest(input.getBytes(java.nio.charset.StandardCharsets.UTF_8));
+            StringBuilder sb = new StringBuilder();
+            for (byte b : digest) sb.append(String.format("%02x", b));
+            return sb.toString();
+        } catch (Exception e) {
+            return Integer.toHexString(input.hashCode());
+        }
+    }
+}
diff --git a/src/main/java/com/xcong/excoin/modules/gateApi/StatsEventProducer.java b/src/main/java/com/xcong/excoin/modules/gateApi/StatsEventProducer.java
new file mode 100644
index 0000000..6d38665
--- /dev/null
+++ b/src/main/java/com/xcong/excoin/modules/gateApi/StatsEventProducer.java
@@ -0,0 +1,72 @@
+package com.xcong.excoin.modules.gateApi;
+
+import com.alibaba.fastjson.JSON;
+import com.xcong.excoin.configurations.RabbitMqConfig;
+import com.xcong.excoin.modules.station.model.GateStatsEvent;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.amqp.rabbit.connection.CorrelationData;
+import org.springframework.amqp.rabbit.core.RabbitTemplate;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import java.util.UUID;
+
+/**
+ * JAR 侧 — 统一消息发送器(心跳 / ACK / 策略事件 / Stats)
+ */
+@Slf4j
+@Component
+public class StatsEventProducer {
+
+    private final RabbitTemplate rabbitTemplate;
+
+    /** RabbitTemplate 是 prototype,必须用构造器注入(参考 OrderProducer) */
+    @Autowired
+    public StatsEventProducer(RabbitTemplate rabbitTemplate) {
+        this.rabbitTemplate = rabbitTemplate;
+    }
+
+    /**
+     * 发送心跳 / ACK 到 heartbeat 路由
+     */
+    public void sendHeartbeat(GateStatsEvent event) {
+        send(RabbitMqConfig.EXCHANGE_GATE, RabbitMqConfig.ROUTINGKEY_GATE_HEARTBEAT, event);
+    }
+
+    /**
+     * 发送策略事件到 stats 路由
+     */
+    public void sendStats(GateStatsEvent event) {
+        send(RabbitMqConfig.EXCHANGE_GATE, RabbitMqConfig.ROUTINGKEY_GATE_STATS, event);
+    }
+
+    private void send(String exchange, String routingKey, GateStatsEvent event) {
+        CorrelationData cd = new CorrelationData(event.getEventId());
+        rabbitTemplate.convertAndSend(exchange, routingKey, JSON.toJSONString(event), cd);
+        log.debug("[StatsProducer] 发送: type={}, apiKeyMd5={}", event.getType(), event.getApiKeyMd5());
+    }
+
+    // ==================== 便捷工厂方法 ====================
+
+    public GateStatsEvent newHeartbeat(String apiKeyMd5, Object payload) {
+        return build("HEARTBEAT", apiKeyMd5, payload);
+    }
+
+    public GateStatsEvent newCmdAck(String apiKeyMd5, Object payload) {
+        return build("CMD_ACK", apiKeyMd5, payload);
+    }
+
+    public GateStatsEvent newStats(String type, String apiKeyMd5, Object payload) {
+        return build(type, apiKeyMd5, payload);
+    }
+
+    private GateStatsEvent build(String type, String apiKeyMd5, Object payload) {
+        return GateStatsEvent.builder()
+                .eventId(UUID.randomUUID().toString())
+                .type(type)
+                .apiKeyMd5(apiKeyMd5)
+                .timestamp(System.currentTimeMillis())
+                .payload(JSON.toJSONString(payload))
+                .build();
+    }
+}
diff --git a/src/main/java/com/xcong/excoin/modules/station/model/CmdAckMsg.java b/src/main/java/com/xcong/excoin/modules/station/model/CmdAckMsg.java
new file mode 100644
index 0000000..0a8ed23
--- /dev/null
+++ b/src/main/java/com/xcong/excoin/modules/station/model/CmdAckMsg.java
@@ -0,0 +1,24 @@
+package com.xcong.excoin.modules.station.model;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 指令确认消息 payload
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class CmdAckMsg {
+    /** 对应指令的 commandId */
+    private String commandId;
+    /** 是否成功 */
+    private boolean success;
+    /** 执行后的新状态 */
+    private String newState;
+    /** 描述信息 */
+    private String message;
+}
diff --git a/src/main/java/com/xcong/excoin/modules/station/model/GateCommand.java b/src/main/java/com/xcong/excoin/modules/station/model/GateCommand.java
new file mode 100644
index 0000000..59f795a
--- /dev/null
+++ b/src/main/java/com/xcong/excoin/modules/station/model/GateCommand.java
@@ -0,0 +1,29 @@
+package com.xcong.excoin.modules.station.model;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * Station 下发给 JAR 的启停 / 参数更新指令
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class GateCommand {
+    /** 指令唯一 ID(用于 ACK 对应) */
+    private String commandId;
+    /** START / STOP / UPDATE_CONFIG */
+    private String commandType;
+    /** 目标 apiKey 的 MD5 */
+    private String apiKeyMd5;
+    /** 发令时间戳 */
+    private Long timestamp;
+    /**
+     * 指令附加数据(JSON 字符串)。
+     * UPDATE_CONFIG 时携带 GateConfigDTO 的 JSON,包含要更新的策略参数。
+     */
+    private String payload;
+}
diff --git a/src/main/java/com/xcong/excoin/modules/station/model/GateStatsEvent.java b/src/main/java/com/xcong/excoin/modules/station/model/GateStatsEvent.java
new file mode 100644
index 0000000..67886d3
--- /dev/null
+++ b/src/main/java/com/xcong/excoin/modules/station/model/GateStatsEvent.java
@@ -0,0 +1,26 @@
+package com.xcong.excoin.modules.station.model;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * JAR 上报给 Station 的策略事件
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class GateStatsEvent {
+    /** 事件唯一 ID */
+    private String eventId;
+    /** 事件类型:HEARTBEAT / CMD_ACK / STRATEGY_START / STRATEGY_STOP / ROUND_COMPLETE / STOP_LOSS_TRIGGERED / ENTRY_FILLED / PNL_SNAPSHOT */
+    private String type;
+    /** 目标 apiKey 的 MD5 */
+    private String apiKeyMd5;
+    /** 事件时间戳 */
+    private Long timestamp;
+    /** 纯字符串内容(JSON payload,Station 消费时反序列化) */
+    private String payload;
+}
diff --git a/src/main/java/com/xcong/excoin/modules/station/model/HeartbeatMsg.java b/src/main/java/com/xcong/excoin/modules/station/model/HeartbeatMsg.java
new file mode 100644
index 0000000..897c5cc
--- /dev/null
+++ b/src/main/java/com/xcong/excoin/modules/station/model/HeartbeatMsg.java
@@ -0,0 +1,30 @@
+package com.xcong.excoin.modules.station.model;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 心跳消息 payload
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class HeartbeatMsg {
+    /** 合约名称 */
+    private String contract;
+    /** 策略状态 */
+    private String state;
+    /** 杠杆 */
+    private String leverage;
+    /** 当前轮次 */
+    private int currentRound;
+    /** 累计已实现盈亏 */
+    private String cumulativePnl;
+    /** 初始本金 */
+    private String principal;
+    /** host:port (用于 Station 直接 HTTP 调用,可选) */
+    private String hostPort;
+}
diff --git a/src/main/java/com/xcong/excoin/modules/station/model/InstanceInfo.java b/src/main/java/com/xcong/excoin/modules/station/model/InstanceInfo.java
new file mode 100644
index 0000000..371faac
--- /dev/null
+++ b/src/main/java/com/xcong/excoin/modules/station/model/InstanceInfo.java
@@ -0,0 +1,34 @@
+package com.xcong.excoin.modules.station.model;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * JAR 实例注册信息(Station 内存 + Dashboard 展示)
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class InstanceInfo {
+    /** apiKey 的 MD5 */
+    private String apiKeyMd5;
+    /** 合约名称 */
+    private String contract;
+    /** 策略状态 */
+    private String state;
+    /** 杠杆 */
+    private String leverage;
+    /** 当前轮次 */
+    private int currentRound;
+    /** 累计已实现盈亏 */
+    private String cumulativePnl;
+    /** 初始本金 */
+    private String principal;
+    /** host:port */
+    private String hostPort;
+    /** 最后心跳时间 */
+    private long lastSeen;
+}

--
Gitblit v1.9.1