feat(gate): 添加策略远程控制和监控功能
- 新增指令确认消息模型 CmdAckMsg 用于响应 Station 指令
- 实现 CommandQueueInitializer 动态创建专属命令队列 QUEUE_GATE_CMD_{apiKeyMd5}
- 添加 GateCommand 模型定义 Station 下发的启停和参数更新指令
- 创建 GateCommandConsumer 消费远程指令并执行相应操作
- 扩展 GateConfigDTO 添加参数快照和默认值工厂方法
- 在 GateGridTradeService 中集成埋点功能发送策略事件统计
- 新增 GateStatsEvent 模型定义 JAR 上报的策略事件格式
- 更新 GateWebSocketClientManager 支持远程配置更新和重载启动
- 添加 HeartbeatMsg 模型和 HeartbeatScheduler 定时发送心跳
- 创建 InstanceInfo 模型存储 JAR 实例注册信息
- 配置 RabbitMQ 添加 Gate 管理交换机和相关队列绑定
- 实现 StatsEventProducer 统一消息发送器处理心跳和事件上报
10 files added
4 files modified
| New file |
| | |
| | | { |
| | | "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 |
| | | } |
| | |
| | | // 平仓路由 |
| | | 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; |
| | | |
| | |
| | | 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); |
| | | } |
| | | |
| | | } |
| New file |
| | |
| | | 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()); |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | 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()); |
| | | } |
| | | } |
| | | } |
| | |
| | | import lombok.NoArgsConstructor; |
| | | |
| | | import java.math.BigDecimal; |
| | | import java.util.LinkedHashMap; |
| | | import java.util.Map; |
| | | |
| | | /** |
| | | * Gate 策略配置 DTO,用于 Web 控制面板参数传递。 |
| | |
| | | .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(); |
| | | } |
| | | } |
| | |
| | | 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"; |
| | |
| | | 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()); |
| | |
| | | } |
| | | } |
| | | |
| | | // ---- 埋点 ---- |
| | | |
| | | 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; |
| | | } |
| | | |
| | | // ---- 启动/停止 ---- |
| | | |
| | | /** |
| | |
| | | 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); |
| | | } |
| | | |
| | | /** |
| | |
| | | */ |
| | | public void stopGrid() { |
| | | state = StrategyState.STOPPED; |
| | | |
| | | // 埋点: STRATEGY_STOP |
| | | emitStats("STRATEGY_STOP", mapOf( |
| | | "reason", "manual", |
| | | "rounds", currentRound, |
| | | "pnl", cumulativePnl.toPlainString() |
| | | )); |
| | | |
| | | executor.cancelAllPriceTriggeredOrders(); |
| | | closeExistingPositions(); |
| | | executor.shutdown(); |
| | |
| | | .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())), |
| | |
| | | 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()) { |
| | |
| | | |
| | | 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,延展完成后自动用最新仓位重挂一次。 |
| | |
| | | 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); |
| | |
| | | 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); |
| | |
| | | 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) */ |
| | |
| | | @Autowired |
| | | private GateLogBuffer logBuffer; |
| | | |
| | | @Autowired |
| | | private StatsEventProducer statsEventProducer; |
| | | |
| | | /** WebSocket 连接管理器 */ |
| | | private GateKlineWebSocketClient wsClient; |
| | | /** 网格交易策略服务 */ |
| | |
| | | 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("[管理器] 已创建默认配置文件"); |
| | |
| | | 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) { |
| | |
| | | } |
| | | // 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()); |
| New file |
| | |
| | | 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()); |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | 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(); |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |