From a23f23570935850192099133509e90c33769d26c Mon Sep 17 00:00:00 2001
From: Administrator <15274802129@163.com>
Date: Thu, 13 Aug 2026 15:08:51 +0800
Subject: [PATCH] feat(station): 添加策略监控中心功能
---
src/main/resources/static/station-dashboard.html | 1146 ++++++++++++++++++++++++++++++++++
src/main/java/com/xcong/excoin/modules/station/entity/StrategyStatus.java | 93 ++
src/main/resources/mapper/station/StrategyEventLogDao.xml | 16
src/main/java/com/xcong/excoin/modules/station/producer/CmdProducer.java | 61 +
src/main/resources/mapper/station/StrategyStatusDao.xml | 9
src/main/java/com/xcong/excoin/modules/station/service/impl/StationServiceImpl.java | 228 ++++++
src/main/java/com/xcong/excoin/modules/station/registry/InstanceRegistry.java | 85 ++
src/main/java/com/xcong/excoin/modules/station/service/StationService.java | 36 +
src/main/resources/db/station-schema.sql | 71 ++
src/main/java/com/xcong/excoin/configurations/security/WebSecurityConfig.java | 1
src/main/java/com/xcong/excoin/modules/station/controller/StationController.java | 72 ++
src/main/java/com/xcong/excoin/modules/station/consumer/GateMessageConsumer.java | 69 ++
src/main/java/com/xcong/excoin/modules/station/dao/StrategyStatusDao.java | 16
src/main/java/com/xcong/excoin/modules/station/entity/StrategyEventLog.java | 33 +
src/main/java/com/xcong/excoin/modules/station/dao/StrategyEventLogDao.java | 23
15 files changed, 1,959 insertions(+), 0 deletions(-)
diff --git a/src/main/java/com/xcong/excoin/configurations/security/WebSecurityConfig.java b/src/main/java/com/xcong/excoin/configurations/security/WebSecurityConfig.java
index 68196a0..f8ba049 100644
--- a/src/main/java/com/xcong/excoin/configurations/security/WebSecurityConfig.java
+++ b/src/main/java/com/xcong/excoin/configurations/security/WebSecurityConfig.java
@@ -55,6 +55,7 @@
.antMatchers("/api/orderCoin/findCollect").permitAll()
.antMatchers("/api/gate/**").permitAll()
.antMatchers("/gate-config.html").permitAll()
+ .antMatchers("/station-dashboard.html").permitAll()
.anyRequest().authenticated()
.and().apply(securityConfiguereAdapter());
}
diff --git a/src/main/java/com/xcong/excoin/modules/station/consumer/GateMessageConsumer.java b/src/main/java/com/xcong/excoin/modules/station/consumer/GateMessageConsumer.java
new file mode 100644
index 0000000..5fa4ce8
--- /dev/null
+++ b/src/main/java/com/xcong/excoin/modules/station/consumer/GateMessageConsumer.java
@@ -0,0 +1,69 @@
+package com.xcong.excoin.modules.station.consumer;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.xcong.excoin.configurations.RabbitMqConfig;
+import com.xcong.excoin.modules.station.model.CmdAckMsg;
+import com.xcong.excoin.modules.station.model.GateStatsEvent;
+import com.xcong.excoin.modules.station.model.HeartbeatMsg;
+import com.xcong.excoin.modules.station.registry.InstanceRegistry;
+import com.xcong.excoin.modules.station.service.StationService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.amqp.rabbit.annotation.RabbitListener;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+
+/**
+ * Station 消费心跳 + ACK + 策略事件 — 均复用到 QUEUE_GATE_HEARTBEAT 和 QUEUE_GATE_STATS
+ */
+@Slf4j
+@Component
+public class GateMessageConsumer {
+
+ @Resource
+ private InstanceRegistry instanceRegistry;
+
+ @Resource
+ private StationService stationService;
+
+ /**
+ * 消费心跳 + ACK(都路由到 heartbeat 队列)
+ */
+ @RabbitListener(queues = RabbitMqConfig.QUEUE_GATE_HEARTBEAT)
+ public void onHeartbeat(String msg) {
+ try {
+ JSONObject json = JSON.parseObject(msg);
+ String type = json.getString("type");
+ String apiKeyMd5 = json.getString("apiKeyMd5");
+
+ if ("HEARTBEAT".equals(type)) {
+ HeartbeatMsg hb = JSON.parseObject(json.getString("payload"), HeartbeatMsg.class);
+ instanceRegistry.update(apiKeyMd5, hb);
+ log.debug("[Station] 心跳: {}, state={}", apiKeyMd5, hb.getState());
+
+ } else if ("CMD_ACK".equals(type)) {
+ CmdAckMsg ack = JSON.parseObject(json.getString("payload"), CmdAckMsg.class);
+ instanceRegistry.updateState(apiKeyMd5, ack.getNewState());
+ log.info("[Station] 指令确认: cmdId={}, success={}, newState={}",
+ ack.getCommandId(), ack.isSuccess(), ack.getNewState());
+ }
+
+ } catch (Exception e) {
+ log.error("[Station] 心跳消费异常", e);
+ }
+ }
+
+ /**
+ * 消费策略事件(stats 队列,落库)
+ */
+ @RabbitListener(queues = RabbitMqConfig.QUEUE_GATE_STATS)
+ public void onStats(String msg) {
+ try {
+ GateStatsEvent event = JSON.parseObject(msg, GateStatsEvent.class);
+ stationService.saveEvent(event);
+ } catch (Exception e) {
+ log.error("[Station] 策略事件消费异常", e);
+ }
+ }
+}
diff --git a/src/main/java/com/xcong/excoin/modules/station/controller/StationController.java b/src/main/java/com/xcong/excoin/modules/station/controller/StationController.java
new file mode 100644
index 0000000..b6c2a9b
--- /dev/null
+++ b/src/main/java/com/xcong/excoin/modules/station/controller/StationController.java
@@ -0,0 +1,72 @@
+package com.xcong.excoin.modules.station.controller;
+
+import com.xcong.excoin.common.response.Result;
+import com.xcong.excoin.modules.station.registry.InstanceRegistry;
+import com.xcong.excoin.modules.station.service.StationService;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+
+/**
+ * Station 管理接口 — 实例列表 / 启停 / 状态 / 事件查询
+ */
+@Slf4j
+@Api(value = "Gate 策略管理", tags = "Gate 策略管理")
+@RestController
+@RequestMapping("/api/gate/station")
+public class StationController {
+
+ @Resource
+ private InstanceRegistry instanceRegistry;
+
+ @Resource
+ private StationService stationService;
+
+ @ApiOperation("获取所有在线 JAR 实例")
+ @GetMapping("/list")
+ public Result list() {
+ return Result.ok(instanceRegistry.list());
+ }
+
+ @ApiOperation("获取单个实例状态")
+ @GetMapping("/status")
+ public Result status(@RequestParam String apiKeyMd5) {
+ return Result.ok(instanceRegistry.get(apiKeyMd5));
+ }
+
+ @ApiOperation("启动指定 apiKeyMd5 的策略")
+ @PostMapping("/start")
+ public Result start(@RequestParam String apiKeyMd5) {
+ stationService.startByMd5(apiKeyMd5);
+ return Result.ok("指令已发送");
+ }
+
+ @ApiOperation("停止指定 apiKeyMd5 的策略")
+ @PostMapping("/stop")
+ public Result stop(@RequestParam String apiKeyMd5) {
+ stationService.stopByMd5(apiKeyMd5);
+ return Result.ok("指令已发送");
+ }
+
+ @ApiOperation("查询策略事件日志")
+ @GetMapping("/events")
+ public Result events(@RequestParam String apiKeyMd5) {
+ return Result.ok(stationService.getEvents(apiKeyMd5));
+ }
+
+ @ApiOperation("查询策略实时状态")
+ @GetMapping("/strategy-status")
+ public Result strategyStatus(@RequestParam String apiKeyMd5) {
+ return Result.ok(stationService.getStrategyStatus(apiKeyMd5));
+ }
+
+ @ApiOperation("更新策略参数(仅持久化,需手动启动生效)")
+ @PostMapping("/config")
+ public Result updateConfig(@RequestParam String apiKeyMd5, @RequestBody java.util.Map<String, Object> params) {
+ stationService.updateConfig(apiKeyMd5, params);
+ return Result.ok("参数已保存,请手动点击「启动」使新配置生效");
+ }
+}
diff --git a/src/main/java/com/xcong/excoin/modules/station/dao/StrategyEventLogDao.java b/src/main/java/com/xcong/excoin/modules/station/dao/StrategyEventLogDao.java
new file mode 100644
index 0000000..4e37e98
--- /dev/null
+++ b/src/main/java/com/xcong/excoin/modules/station/dao/StrategyEventLogDao.java
@@ -0,0 +1,23 @@
+package com.xcong.excoin.modules.station.dao;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.xcong.excoin.modules.station.entity.StrategyEventLog;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 策略事件日志 DAO
+ */
+public interface StrategyEventLogDao extends BaseMapper<StrategyEventLog> {
+
+ /**
+ * 按 apiKeyMd5 分页查询事件(最近 200 条)
+ */
+ List<StrategyEventLog> selectByApiKeyMd5(@Param("apiKeyMd5") String apiKeyMd5);
+
+ /**
+ * eventId 是否存在(幂等去重)
+ */
+ int countByEventId(@Param("eventId") String eventId);
+}
diff --git a/src/main/java/com/xcong/excoin/modules/station/dao/StrategyStatusDao.java b/src/main/java/com/xcong/excoin/modules/station/dao/StrategyStatusDao.java
new file mode 100644
index 0000000..a8c8746
--- /dev/null
+++ b/src/main/java/com/xcong/excoin/modules/station/dao/StrategyStatusDao.java
@@ -0,0 +1,16 @@
+package com.xcong.excoin.modules.station.dao;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.xcong.excoin.modules.station.entity.StrategyStatus;
+import org.apache.ibatis.annotations.Param;
+
+/**
+ * 策略状态 DAO
+ */
+public interface StrategyStatusDao extends BaseMapper<StrategyStatus> {
+
+ /**
+ * 按 apiKeyMd5 查询唯一状态记录
+ */
+ StrategyStatus selectByApiKeyMd5(@Param("apiKeyMd5") String apiKeyMd5);
+}
diff --git a/src/main/java/com/xcong/excoin/modules/station/entity/StrategyEventLog.java b/src/main/java/com/xcong/excoin/modules/station/entity/StrategyEventLog.java
new file mode 100644
index 0000000..43eda23
--- /dev/null
+++ b/src/main/java/com/xcong/excoin/modules/station/entity/StrategyEventLog.java
@@ -0,0 +1,33 @@
+package com.xcong.excoin.modules.station.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.xcong.excoin.common.system.base.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 策略事件日志表(只追加,eventId 唯一)
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("strategy_event_log")
+public class StrategyEventLog extends BaseEntity {
+
+ /** 事件唯一 ID(幂等去重) */
+ private String eventId;
+
+ /** 事件类型:HEARTBEAT / STRATEGY_START / STOP_LOSS_TRIGGERED ... */
+ private String eventType;
+
+ /** apiKey 的 MD5 */
+ private String apiKeyMd5;
+
+ /** 合约名称 */
+ private String contract;
+
+ /** 事件时间戳 */
+ private Long eventTime;
+
+ /** JSON payload */
+ private String payloadJson;
+}
diff --git a/src/main/java/com/xcong/excoin/modules/station/entity/StrategyStatus.java b/src/main/java/com/xcong/excoin/modules/station/entity/StrategyStatus.java
new file mode 100644
index 0000000..1410787
--- /dev/null
+++ b/src/main/java/com/xcong/excoin/modules/station/entity/StrategyStatus.java
@@ -0,0 +1,93 @@
+package com.xcong.excoin.modules.station.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.xcong.excoin.common.system.base.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 策略实时状态表(apiKeyMd5 维度,UPSERT)
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("strategy_status")
+public class StrategyStatus extends BaseEntity {
+
+ /** apiKey 的 MD5 */
+ private String apiKeyMd5;
+
+ /** 合约名称 */
+ private String contract;
+
+ /** 策略状态:ACTIVE / STOPPED / WAITING_KLINE */
+ private String state;
+
+ /** 杠杆倍数 */
+ private String leverage;
+
+ /** 网格间距比例 */
+ private String gridRate;
+
+ /** 预期收益(USDT) */
+ private String expectedProfit;
+
+ /** 最大亏损(USDT) */
+ private String maxLoss;
+
+ /** 基底开仓张数 */
+ private String baseQuantity;
+
+ /** 每次下单张数 */
+ private String quantity;
+
+ /** 最大持仓张数(单方向),0=不限制 */
+ private Integer maxPositionSize;
+
+ /** 止损阶梯次数,0=禁用 */
+ private Integer stopLossCount;
+
+ /** 止盈网格跨度 */
+ private Integer takeProfitGridSpan;
+
+ /** 止损统计方式 */
+ private String stopLossCountMode;
+
+ /** 加仓间隔 */
+ private Integer addPositionInterval;
+
+ /** 加仓数量 */
+ private Integer addPositionQuantity;
+
+ /** 单边最大仓位 */
+ private Integer maxPositionPerSide;
+
+ /** 加仓启动阈值 */
+ private Integer addPositionStartThreshold;
+
+ /** 超额止盈开关 */
+ private Boolean placeExcessTakeProfit;
+
+ /** 价格驱动开关 */
+ private Boolean priceDriveEnabled;
+
+ /** 当前轮次 */
+ private Integer currentRound;
+
+ /** 总轮数上限 */
+ private Integer totalRounds;
+
+ /** 多头累计止损次数 */
+ private Integer accumulatedLongLoss;
+
+ /** 空头累计止损次数 */
+ private Integer accumulatedShortLoss;
+
+ /** 累计已实现盈亏 */
+ private String cumulativePnl;
+
+ /** 未实现盈亏 */
+ private String unrealizedPnl;
+
+ /** 初始本金 */
+ private String principal;
+}
diff --git a/src/main/java/com/xcong/excoin/modules/station/producer/CmdProducer.java b/src/main/java/com/xcong/excoin/modules/station/producer/CmdProducer.java
new file mode 100644
index 0000000..8f4323a
--- /dev/null
+++ b/src/main/java/com/xcong/excoin/modules/station/producer/CmdProducer.java
@@ -0,0 +1,61 @@
+package com.xcong.excoin.modules.station.producer;
+
+import com.alibaba.fastjson.JSON;
+import com.xcong.excoin.configurations.RabbitMqConfig;
+import com.xcong.excoin.modules.station.model.GateCommand;
+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;
+
+/**
+ * Station → JAR 指令发送器(精准路由到 QUEUE_GATE_CMD_{apiKeyMd5})
+ */
+@Slf4j
+@Component
+public class CmdProducer {
+
+ private final RabbitTemplate rabbitTemplate;
+
+ /** RabbitTemplate 是 prototype,必须用构造器注入 */
+ @Autowired
+ public CmdProducer(RabbitTemplate rabbitTemplate) {
+ this.rabbitTemplate = rabbitTemplate;
+ }
+
+ /**
+ * 发送指令到目标 JAR
+ *
+ * @param apiKeyMd5 目标 apiKey MD5
+ * @param type START / STOP
+ */
+ public void sendCommand(String apiKeyMd5, String type) {
+ sendCommand(apiKeyMd5, type, null);
+ }
+
+ /**
+ * 发送带 payload 的指令到目标 JAR
+ *
+ * @param apiKeyMd5 目标 apiKey MD5
+ * @param type START / STOP / UPDATE_CONFIG
+ * @param payload 指令附加数据(JSON),可为 null
+ */
+ public void sendCommand(String apiKeyMd5, String type, String payload) {
+ GateCommand cmd = GateCommand.builder()
+ .commandId(UUID.randomUUID().toString())
+ .commandType(type)
+ .apiKeyMd5(apiKeyMd5)
+ .timestamp(System.currentTimeMillis())
+ .payload(payload)
+ .build();
+
+ String routingKey = "cmd." + apiKeyMd5;
+ CorrelationData cd = new CorrelationData(cmd.getCommandId());
+ rabbitTemplate.convertAndSend(RabbitMqConfig.EXCHANGE_GATE, routingKey, JSON.toJSONString(cmd), cd);
+ log.info("[Station] 发送指令: type={}, apiKeyMd5={}, cmdId={}, routingKey={}",
+ type, apiKeyMd5, cmd.getCommandId(), routingKey);
+ }
+}
diff --git a/src/main/java/com/xcong/excoin/modules/station/registry/InstanceRegistry.java b/src/main/java/com/xcong/excoin/modules/station/registry/InstanceRegistry.java
new file mode 100644
index 0000000..3e42edc
--- /dev/null
+++ b/src/main/java/com/xcong/excoin/modules/station/registry/InstanceRegistry.java
@@ -0,0 +1,85 @@
+package com.xcong.excoin.modules.station.registry;
+
+import com.xcong.excoin.modules.station.model.HeartbeatMsg;
+import com.xcong.excoin.modules.station.model.InstanceInfo;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.util.Collection;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * JAR 实例注册表 — 心跳更新,超时自动清理。
+ */
+@Slf4j
+@Component
+public class InstanceRegistry {
+
+ /** 30s 心跳 × 2 = 60s 超时下线 */
+ private static final long TIMEOUT_MS = 60_000;
+
+ private final ConcurrentHashMap<String, InstanceInfo> registry = new ConcurrentHashMap<>();
+
+ /**
+ * 心跳更新(不存在则新增)
+ */
+ public void update(String apiKeyMd5, HeartbeatMsg msg) {
+ InstanceInfo info = InstanceInfo.builder()
+ .apiKeyMd5(apiKeyMd5)
+ .contract(msg.getContract())
+ .state(msg.getState())
+ .leverage(msg.getLeverage())
+ .currentRound(msg.getCurrentRound())
+ .cumulativePnl(msg.getCumulativePnl())
+ .principal(msg.getPrincipal())
+ .hostPort(msg.getHostPort())
+ .lastSeen(System.currentTimeMillis())
+ .build();
+ InstanceInfo old = registry.put(apiKeyMd5, info);
+ if (old == null) {
+ log.info("[Station] 实例上线, apiKeyMd5={}, contract={}", apiKeyMd5, msg.getContract());
+ }
+ }
+
+ /**
+ * 更新状态(用于 ACK)
+ */
+ public void updateState(String apiKeyMd5, String newState) {
+ InstanceInfo info = registry.get(apiKeyMd5);
+ if (info != null) {
+ info.setState(newState);
+ info.setLastSeen(System.currentTimeMillis());
+ }
+ }
+
+ /**
+ * 获取单个实例
+ */
+ public InstanceInfo get(String apiKeyMd5) {
+ return registry.get(apiKeyMd5);
+ }
+
+ /**
+ * 所有在线实例
+ */
+ public Collection<InstanceInfo> list() {
+ return registry.values();
+ }
+
+ /**
+ * 每 30s 清理超时实例
+ */
+ @Scheduled(fixedRate = 30_000)
+ public void cleanDead() {
+ long now = System.currentTimeMillis();
+ registry.entrySet().removeIf(entry -> {
+ boolean dead = now - entry.getValue().getLastSeen() > TIMEOUT_MS;
+ if (dead) {
+ log.info("[Station] 实例离线, apiKeyMd5={}, 最后心跳:{}ms前",
+ entry.getKey(), now - entry.getValue().getLastSeen());
+ }
+ return dead;
+ });
+ }
+}
diff --git a/src/main/java/com/xcong/excoin/modules/station/service/StationService.java b/src/main/java/com/xcong/excoin/modules/station/service/StationService.java
new file mode 100644
index 0000000..6915344
--- /dev/null
+++ b/src/main/java/com/xcong/excoin/modules/station/service/StationService.java
@@ -0,0 +1,36 @@
+package com.xcong.excoin.modules.station.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.xcong.excoin.modules.station.entity.StrategyEventLog;
+import com.xcong.excoin.modules.station.entity.StrategyStatus;
+import com.xcong.excoin.modules.station.model.GateStatsEvent;
+
+import java.util.List;
+import java.util.Map;
+
+public interface StationService extends IService<StrategyEventLog> {
+
+ /** 启动策略(通过 MQ 发送 START 到目标 JAR) */
+ void startStrategy(String apiKey);
+
+ /** 停止策略(通过 MQ 发送 STOP 到目标 JAR) */
+ void stopStrategy(String apiKey);
+
+ /** 启动策略(直接使用 apiKeyMd5,跳过内部 md5 计算) */
+ void startByMd5(String apiKeyMd5);
+
+ /** 停止策略(直接使用 apiKeyMd5,跳过内部 md5 计算) */
+ void stopByMd5(String apiKeyMd5);
+
+ /** 更新策略参数(通过 MQ 发送 UPDATE_CONFIG 到目标 JAR,仅持久化,需手动启动生效) */
+ void updateConfig(String apiKeyMd5, Map<String, Object> params);
+
+ /** 保存策略事件(幂等) */
+ void saveEvent(GateStatsEvent event);
+
+ /** 查询事件日志 */
+ List<StrategyEventLog> getEvents(String apiKeyMd5);
+
+ /** 查询策略实时状态 */
+ StrategyStatus getStrategyStatus(String apiKeyMd5);
+}
diff --git a/src/main/java/com/xcong/excoin/modules/station/service/impl/StationServiceImpl.java b/src/main/java/com/xcong/excoin/modules/station/service/impl/StationServiceImpl.java
new file mode 100644
index 0000000..86a8b68
--- /dev/null
+++ b/src/main/java/com/xcong/excoin/modules/station/service/impl/StationServiceImpl.java
@@ -0,0 +1,228 @@
+package com.xcong.excoin.modules.station.service.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.xcong.excoin.modules.station.dao.StrategyEventLogDao;
+import com.xcong.excoin.modules.station.dao.StrategyStatusDao;
+import com.xcong.excoin.modules.station.entity.StrategyEventLog;
+import com.xcong.excoin.modules.station.entity.StrategyStatus;
+import com.xcong.excoin.modules.station.model.GateStatsEvent;
+import com.xcong.excoin.modules.station.producer.CmdProducer;
+import com.xcong.excoin.modules.station.service.StationService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.nio.charset.StandardCharsets;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+@Slf4j
+@Service
+public class StationServiceImpl extends ServiceImpl<StrategyEventLogDao, StrategyEventLog> implements StationService {
+
+ @Resource
+ private CmdProducer cmdProducer;
+
+ @Resource
+ private StrategyStatusDao strategyStatusDao;
+
+ @Resource
+ private StrategyEventLogDao strategyEventLogDao;
+
+ // ==================== 启停 ====================
+
+ @Override
+ public void startStrategy(String apiKey) {
+ cmdProducer.sendCommand(md5(apiKey), "START");
+ }
+
+ @Override
+ public void stopStrategy(String apiKey) {
+ cmdProducer.sendCommand(md5(apiKey), "STOP");
+ }
+
+ @Override
+ public void startByMd5(String apiKeyMd5) {
+ cmdProducer.sendCommand(apiKeyMd5, "START");
+ }
+
+ @Override
+ public void stopByMd5(String apiKeyMd5) {
+ cmdProducer.sendCommand(apiKeyMd5, "STOP");
+ }
+
+ @Override
+ public void updateConfig(String apiKeyMd5, Map<String, Object> params) {
+ // 过滤空字符串,避免 Fastjson 反序列化到 BigDecimal/int 时抛异常
+ params.entrySet().removeIf(e -> e.getValue() == null || "".equals(e.getValue()));
+ // 1. 持久化配置到 strategy_status,保证保存后刷新页面可回显
+ persistConfig(apiKeyMd5, params);
+ // 2. 发送 MQ 命令,通知 JAR(需手动点击「启动」才生效)
+ cmdProducer.sendCommand(apiKeyMd5, "UPDATE_CONFIG", JSON.toJSONString(params));
+ }
+
+ // ==================== 事件落库 ====================
+
+ @Override
+ public void saveEvent(GateStatsEvent event) {
+ // 幂等去重
+ if (strategyEventLogDao.countByEventId(event.getEventId()) > 0) {
+ return;
+ }
+
+ StrategyEventLog log = new StrategyEventLog();
+ log.setEventId(event.getEventId());
+ log.setEventType(event.getType());
+ log.setApiKeyMd5(event.getApiKeyMd5());
+ log.setEventTime(event.getTimestamp());
+ log.setPayloadJson(event.getPayload());
+ log.setCreateTime(new Date());
+
+ // 从 payload 提取 contract(仅 STRATEGY_START 时有)
+ String contract = extractContract(event);
+ log.setContract(contract);
+
+ strategyEventLogDao.insert(log);
+
+ // STRATEGY_START / STRATEGY_STOP 事件同步更新实时状态表
+ if ("STRATEGY_START".equals(event.getType())) {
+ upsertStrategyStatus(event, contract);
+ } else if ("STRATEGY_STOP".equals(event.getType())) {
+ updateStrategyStopped(event);
+ }
+ }
+
+ @Override
+ public List<StrategyEventLog> getEvents(String apiKeyMd5) {
+ return strategyEventLogDao.selectByApiKeyMd5(apiKeyMd5);
+ }
+
+ @Override
+ public StrategyStatus getStrategyStatus(String apiKeyMd5) {
+ return strategyStatusDao.selectByApiKeyMd5(apiKeyMd5);
+ }
+
+ // ==================== 辅助方法 ====================
+
+ private void upsertStrategyStatus(GateStatsEvent event, String contract) {
+ JSONObject payload = JSON.parseObject(event.getPayload());
+ StrategyStatus existing = strategyStatusDao.selectByApiKeyMd5(event.getApiKeyMd5());
+
+ StrategyStatus status = (existing != null) ? existing : new StrategyStatus();
+ status.setApiKeyMd5(event.getApiKeyMd5());
+ status.setContract(contract);
+ status.setState("ACTIVE");
+ // 复用统一的配置字段映射,保证 START 事件落库字段与 updateConfig 一致
+ applyConfigFields(status, payload);
+
+ if (existing != null) {
+ strategyStatusDao.updateById(status);
+ } else {
+ status.setCreateTime(new Date());
+ strategyStatusDao.insert(status);
+ }
+ }
+
+ private void updateStrategyStopped(GateStatsEvent event) {
+ JSONObject payload = JSON.parseObject(event.getPayload());
+ StrategyStatus status = strategyStatusDao.selectByApiKeyMd5(event.getApiKeyMd5());
+ if (status != null) {
+ status.setState("STOPPED");
+ status.setCumulativePnl(payload.getString("pnl"));
+ strategyStatusDao.updateById(status);
+ }
+ }
+
+ /**
+ * 持久化配置到 strategy_status(UPSERT)。
+ * 保存配置但策略未启动时,DB 里只有配置字段,state/contract 保持原值或为空,
+ * 前端刷新即可回显已保存参数。
+ */
+ private void persistConfig(String apiKeyMd5, Map<String, Object> params) {
+ StrategyStatus existing = strategyStatusDao.selectByApiKeyMd5(apiKeyMd5);
+ StrategyStatus status = (existing != null) ? existing : new StrategyStatus();
+ status.setApiKeyMd5(apiKeyMd5);
+ applyConfigFields(status, params);
+
+ if (existing != null) {
+ strategyStatusDao.updateById(status);
+ } else {
+ status.setCreateTime(new Date());
+ strategyStatusDao.insert(status);
+ }
+ }
+
+ /**
+ * 将配置 Map 的字段映射到 StrategyStatus 实体。
+ * 前端提交的 key(gridRate/quantity/expectedProfit/rounds/priceDriveEnabled...)
+ * 与 STRATEGY_START payload 的 key 一致,rounds 映射到实体字段 totalRounds。
+ */
+ private void applyConfigFields(StrategyStatus status, Map<String, Object> params) {
+ if (params.containsKey("leverage")) status.setLeverage(str(params.get("leverage")));
+ if (params.containsKey("contract")) status.setContract(str(params.get("contract")));
+ if (params.containsKey("gridRate")) status.setGridRate(str(params.get("gridRate")));
+ if (params.containsKey("expectedProfit")) status.setExpectedProfit(str(params.get("expectedProfit")));
+ if (params.containsKey("maxLoss")) status.setMaxLoss(str(params.get("maxLoss")));
+ if (params.containsKey("baseQuantity")) status.setBaseQuantity(str(params.get("baseQuantity")));
+ if (params.containsKey("quantity")) status.setQuantity(str(params.get("quantity")));
+ if (params.containsKey("maxPositionSize")) status.setMaxPositionSize(intVal(params.get("maxPositionSize")));
+ if (params.containsKey("stopLossCount")) status.setStopLossCount(intVal(params.get("stopLossCount")));
+ if (params.containsKey("takeProfitGridSpan")) status.setTakeProfitGridSpan(intVal(params.get("takeProfitGridSpan")));
+ if (params.containsKey("stopLossCountMode")) status.setStopLossCountMode(str(params.get("stopLossCountMode")));
+ if (params.containsKey("addPositionInterval")) status.setAddPositionInterval(intVal(params.get("addPositionInterval")));
+ if (params.containsKey("addPositionQuantity")) status.setAddPositionQuantity(intVal(params.get("addPositionQuantity")));
+ if (params.containsKey("maxPositionPerSide")) status.setMaxPositionPerSide(intVal(params.get("maxPositionPerSide")));
+ 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("rounds")) status.setTotalRounds(intVal(params.get("rounds")));
+ if (params.containsKey("principal")) status.setPrincipal(str(params.get("principal")));
+ }
+
+ private static String str(Object o) {
+ return o == null ? null : String.valueOf(o);
+ }
+
+ private static Integer intVal(Object o) {
+ if (o == null) return null;
+ if (o instanceof Number) return ((Number) o).intValue();
+ try {
+ return Integer.parseInt(String.valueOf(o).trim());
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+
+ private static Boolean boolVal(Object o) {
+ if (o == null) return null;
+ if (o instanceof Boolean) return (Boolean) o;
+ String s = String.valueOf(o).trim();
+ return "true".equalsIgnoreCase(s) || "1".equals(s);
+ }
+
+ private String extractContract(GateStatsEvent event) {
+ try {
+ JSONObject payload = JSON.parseObject(event.getPayload());
+ return payload.getString("contract");
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ 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/resources/db/station-schema.sql b/src/main/resources/db/station-schema.sql
new file mode 100644
index 0000000..f6ae3fa
--- /dev/null
+++ b/src/main/resources/db/station-schema.sql
@@ -0,0 +1,71 @@
+-- Gate 策略管理 — Station 数据库表
+
+-- 策略实时状态(apiKeyMd5 唯一,UPSERT)
+CREATE TABLE IF NOT EXISTS `strategy_status` (
+ `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
+ `api_key_md5` VARCHAR(64) NOT NULL COMMENT 'apiKey MD5',
+ `contract` VARCHAR(32) DEFAULT NULL COMMENT '合约名称',
+ `state` VARCHAR(32) DEFAULT NULL COMMENT '策略状态:ACTIVE / STOPPED / WAITING_KLINE',
+ `leverage` VARCHAR(16) DEFAULT NULL COMMENT '杠杆倍数',
+ `grid_rate` VARCHAR(16) DEFAULT NULL COMMENT '网格间距比例',
+ `expected_profit` VARCHAR(32) DEFAULT NULL COMMENT '预期收益(USDT)',
+ `max_loss` VARCHAR(32) DEFAULT NULL COMMENT '最大亏损(USDT)',
+ `base_quantity` VARCHAR(16) DEFAULT NULL COMMENT '基底开仓张数',
+ `quantity` VARCHAR(16) DEFAULT NULL COMMENT '每次下单张数',
+ `max_position_size` INT(11) DEFAULT 0 COMMENT '最大持仓张数(单方向)',
+ `stop_loss_count` INT(11) DEFAULT 0 COMMENT '止损阶梯次数',
+ `take_profit_grid_span` INT(11) DEFAULT 2 COMMENT '止盈网格跨度',
+ `stop_loss_count_mode` VARCHAR(16) DEFAULT NULL COMMENT '止损统计方式:single / dual',
+ `add_position_interval` INT(11) DEFAULT 0 COMMENT '加仓间隔',
+ `add_position_quantity` INT(11) DEFAULT 0 COMMENT '加仓数量',
+ `max_position_per_side` INT(11) DEFAULT 0 COMMENT '单边最大仓位',
+ `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 '价格驱动开关',
+ `current_round` INT(11) DEFAULT 0 COMMENT '当前轮次',
+ `total_rounds` INT(11) DEFAULT 0 COMMENT '总轮数上限',
+ `accumulated_long_loss` INT(11) DEFAULT 0 COMMENT '多头累计止损次数',
+ `accumulated_short_loss` INT(11) DEFAULT 0 COMMENT '空头累计止损次数',
+ `cumulative_pnl` VARCHAR(32) DEFAULT '0' COMMENT '累计已实现盈亏',
+ `unrealized_pnl` VARCHAR(32) DEFAULT '0' COMMENT '未实现盈亏',
+ `principal` VARCHAR(32) DEFAULT NULL COMMENT '初始本金',
+ `create_by` VARCHAR(64) DEFAULT NULL,
+ `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
+ `update_by` VARCHAR(64) DEFAULT NULL,
+ `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ `version` INT(11) DEFAULT 0,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_api_key_md5` (`api_key_md5`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='策略实时状态';
+
+-- 策略事件日志(只追加,eventId 唯一去重)
+CREATE TABLE IF NOT EXISTS `strategy_event_log` (
+ `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
+ `event_id` VARCHAR(64) NOT NULL COMMENT '事件唯一 ID(幂等去重)',
+ `event_type` VARCHAR(64) NOT NULL COMMENT '事件类型:HEARTBEAT / STRATEGY_START / STRATEGY_STOP / ROUND_COMPLETE / STOP_LOSS_TRIGGERED / ENTRY_FILLED / PNL_SNAPSHOT',
+ `api_key_md5` VARCHAR(64) NOT NULL COMMENT 'apiKey MD5',
+ `contract` VARCHAR(32) DEFAULT NULL COMMENT '合约名称',
+ `event_time` BIGINT(20) DEFAULT NULL COMMENT '事件时间戳(ms)',
+ `payload_json` TEXT DEFAULT NULL COMMENT 'JSON payload',
+ `create_by` VARCHAR(64) DEFAULT NULL,
+ `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
+ `update_by` VARCHAR(64) DEFAULT NULL,
+ `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ `version` INT(11) DEFAULT 0,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_event_id` (`event_id`),
+ KEY `idx_api_key_md5_time` (`api_key_md5`, `event_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='策略事件日志';
+
+-- ============================================================
+-- 升级脚本:为已存在的 strategy_status 表补齐配置回显字段
+-- (新增字段与上方建表语句保持一致;若已执行过可忽略 Duplicate column 报错)
+-- ============================================================
+ALTER TABLE `strategy_status`
+ ADD COLUMN `expected_profit` VARCHAR(32) DEFAULT NULL COMMENT '预期收益(USDT)' AFTER `grid_rate`,
+ ADD COLUMN `max_loss` VARCHAR(32) DEFAULT NULL COMMENT '最大亏损(USDT)' AFTER `grid_rate`,
+ ADD COLUMN `quantity` VARCHAR(16) DEFAULT NULL COMMENT '每次下单张数' AFTER `base_quantity`,
+ ADD COLUMN `max_position_size` INT(11) DEFAULT 0 COMMENT '最大持仓张数(单方向)' AFTER `base_quantity`,
+ ADD COLUMN `stop_loss_count` INT(11) DEFAULT 0 COMMENT '止损阶梯次数' AFTER `base_quantity`,
+ ADD COLUMN `take_profit_grid_span` INT(11) DEFAULT 2 COMMENT '止盈网格跨度' AFTER `base_quantity`,
+ ADD COLUMN `price_drive_enabled` TINYINT(1) DEFAULT 1 COMMENT '价格驱动开关' AFTER `place_excess_take_profit`;
diff --git a/src/main/resources/mapper/station/StrategyEventLogDao.xml b/src/main/resources/mapper/station/StrategyEventLogDao.xml
new file mode 100644
index 0000000..2713a99
--- /dev/null
+++ b/src/main/resources/mapper/station/StrategyEventLogDao.xml
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.xcong.excoin.modules.station.dao.StrategyEventLogDao">
+
+ <select id="selectByApiKeyMd5" resultType="com.xcong.excoin.modules.station.entity.StrategyEventLog">
+ SELECT * FROM strategy_event_log
+ WHERE api_key_md5 = #{apiKeyMd5}
+ ORDER BY event_time DESC
+ LIMIT 200
+ </select>
+
+ <select id="countByEventId" resultType="int">
+ SELECT COUNT(1) FROM strategy_event_log WHERE event_id = #{eventId}
+ </select>
+
+</mapper>
diff --git a/src/main/resources/mapper/station/StrategyStatusDao.xml b/src/main/resources/mapper/station/StrategyStatusDao.xml
new file mode 100644
index 0000000..77d21a3
--- /dev/null
+++ b/src/main/resources/mapper/station/StrategyStatusDao.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.xcong.excoin.modules.station.dao.StrategyStatusDao">
+
+ <select id="selectByApiKeyMd5" resultType="com.xcong.excoin.modules.station.entity.StrategyStatus">
+ SELECT * FROM strategy_status WHERE api_key_md5 = #{apiKeyMd5} LIMIT 1
+ </select>
+
+</mapper>
diff --git a/src/main/resources/static/station-dashboard.html b/src/main/resources/static/station-dashboard.html
new file mode 100644
index 0000000..31e91ff
--- /dev/null
+++ b/src/main/resources/static/station-dashboard.html
@@ -0,0 +1,1146 @@
+<!DOCTYPE html>
+<html lang="zh">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Station Dashboard — 策略监控中心</title>
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
+ <style>
+ :root {
+ --bg: #eaf2ff; --card: #ffffff; --border: #e3e8f0;
+ --text: #1f2937; --dim: #6b7280; --accent: #3b82f6;
+ --danger: #dc2626; --green: #16a34a; --warn: #d97706;
+ --input-bg: #f9fafb; --sidebar-w: 280px;
+ --purple: #7c3aed; --teal: #0d9488;
+ }
+ * { margin: 0; padding: 0; box-sizing: border-box; }
+ body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ background: var(--bg); color: var(--text); min-height: 100vh;
+ display: flex; overflow: hidden;
+ }
+
+ /* ===== 侧边栏 ===== */
+ .sidebar {
+ width: var(--sidebar-w); min-width: var(--sidebar-w);
+ background: var(--card); border-right: 1px solid var(--border);
+ display: flex; flex-direction: column; height: 100vh;
+ }
+ .sidebar-header {
+ padding: 16px 16px 12px; border-bottom: 1px solid var(--border);
+ display: flex; justify-content: space-between; align-items: center;
+ }
+ .sidebar-header h1 {
+ font-size: 16px; display: flex; align-items: center; gap: 8px;
+ }
+ .refresh-badge {
+ font-size: 10px; color: var(--dim); border: 1px solid var(--border);
+ border-radius: 10px; padding: 2px 8px;
+ }
+ .instance-list {
+ flex: 1; overflow-y: auto; padding: 8px;
+ }
+ .instance-card {
+ background: var(--bg); border: 1px solid var(--border);
+ border-radius: 6px; padding: 12px; margin-bottom: 6px;
+ cursor: pointer; transition: all .15s;
+ }
+ .instance-card:hover { border-color: var(--accent); }
+ .instance-card.selected { border-color: var(--accent); background: #eef4ff; }
+ .instance-card .top-row {
+ display: flex; justify-content: space-between; align-items: center;
+ }
+ .instance-card .key {
+ font-size: 11px; color: var(--dim); font-family: "SF Mono", "Consolas", monospace;
+ }
+ .instance-card .contract {
+ font-size: 14px; font-weight: 600; margin-top: 2px;
+ }
+ .instance-card .meta {
+ display: flex; gap: 10px; margin-top: 6px; font-size: 11px; color: var(--dim);
+ }
+ .instance-card .pnl { font-weight: 600; }
+ .instance-card .pnl.positive { color: var(--green); }
+ .instance-card .pnl.negative { color: var(--danger); }
+ .status-dot {
+ width: 8px; height: 8px; border-radius: 50%; display: inline-block;
+ flex-shrink: 0;
+ }
+ .status-dot.active { background: var(--green); box-shadow: 0 0 6px var(--green); }
+ .status-dot.stopped { background: var(--danger); }
+ .status-dot.offline { background: var(--dim); }
+
+ .sidebar-footer {
+ padding: 10px 16px; border-top: 1px solid var(--border);
+ font-size: 11px; color: var(--dim); display: flex; justify-content: space-between;
+ }
+
+ /* ===== 主区域 ===== */
+ .main {
+ flex: 1; display: flex; flex-direction: column; height: 100vh;
+ overflow: hidden;
+ }
+ .main-header {
+ padding: 14px 24px; border-bottom: 1px solid var(--border);
+ background: var(--card);
+ display: flex; justify-content: space-between; align-items: center;
+ }
+ .main-header .instance-title {
+ display: flex; align-items: center; gap: 10px;
+ }
+ .main-header h2 { font-size: 17px; }
+ .main-header .state-badge {
+ font-size: 11px; padding: 2px 10px; border-radius: 10px;
+ font-weight: 600;
+ }
+ .state-badge.ACTIVE { background: #dcfce7; color: #15803d; }
+ .state-badge.STOPPED { background: #fee2e2; color: #b91c1c; }
+ .state-badge.WAITING_KLINE { background: #fef3c7; color: #b45309; }
+ .state-badge.OPENING { background: #dbeafe; color: #1d4ed8; }
+
+ .btn-group { display: flex; gap: 6px; }
+ .btn {
+ padding: 6px 14px; border-radius: 5px; border: none;
+ font-size: 12px; cursor: pointer; font-weight: 500;
+ transition: opacity .15s;
+ }
+ .btn:hover { opacity: .85; }
+ .btn-success { background: var(--green); color: #fff; }
+ .btn-danger { background: var(--danger); color: #fff; }
+ .btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text); }
+ .btn:disabled { opacity: .4; cursor: not-allowed; }
+
+ /* ===== 内容区(Tabs) ===== */
+ .content { flex: 1; overflow-y: auto; padding: 16px 24px; }
+ .tabs {
+ display: flex; gap: 0; margin-bottom: 16px;
+ border-bottom: 1px solid var(--border);
+ }
+ .tab {
+ padding: 8px 20px; font-size: 13px; cursor: pointer;
+ border-bottom: 2px solid transparent; color: var(--dim);
+ transition: all .15s;
+ }
+ .tab:hover { color: var(--text); }
+ .tab.active { color: var(--accent); border-bottom-color: var(--accent); }
+
+ .tab-content { display: none; }
+ .tab-content.active { display: block; }
+
+ /* ===== 统计卡片 ===== */
+ .stats-grid {
+ display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 12px; margin-bottom: 20px;
+ }
+ .stat-card {
+ background: var(--card); border: 1px solid var(--border);
+ border-radius: 8px; padding: 14px 16px;
+ }
+ .stat-card .label { font-size: 11px; color: var(--dim); margin-bottom: 4px; }
+ .stat-card .value { font-size: 22px; font-weight: 600; }
+ .stat-card .sub { font-size: 11px; color: var(--dim); margin-top: 2px; }
+
+ /* ===== 图表容器 ===== */
+ .chart-container {
+ background: var(--card); border: 1px solid var(--border);
+ border-radius: 8px; padding: 16px; margin-bottom: 20px;
+ }
+ .chart-container h3 {
+ font-size: 13px; color: var(--dim); margin-bottom: 12px;
+ }
+ .pnl-summary {
+ display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
+ gap: 10px; margin-bottom: 16px;
+ }
+ .pnl-summary-item {
+ background: var(--bg); border: 1px solid var(--border);
+ border-radius: 8px; padding: 10px 14px;
+ }
+ .pnl-summary-item .s-label { font-size: 11px; color: var(--dim); margin-bottom: 4px; }
+ .pnl-summary-item .s-value { font-size: 17px; font-weight: 600; color: var(--text); }
+ .pnl-summary-item .s-delta { font-size: 11px; margin-top: 3px; font-weight: 500; color: var(--dim); }
+ .pnl-summary-item .s-value.positive { color: var(--green); }
+ .pnl-summary-item .s-value.negative { color: var(--danger); }
+ .pnl-summary-item .s-delta.positive { color: var(--green); }
+ .pnl-summary-item .s-delta.negative { color: var(--danger); }
+ .chart-wrap { position: relative; height: 300px; }
+ .chart-wrap canvas { width: 100% !important; height: 100% !important; }
+
+ /* ===== 事件日志 ===== */
+ .events-panel {
+ background: var(--card); border: 1px solid var(--border);
+ border-radius: 8px; overflow: hidden;
+ }
+ .events-header {
+ padding: 12px 16px; border-bottom: 1px solid var(--border);
+ display: flex; justify-content: space-between; align-items: center;
+ }
+ .events-header h3 { font-size: 13px; color: var(--dim); }
+ .events-table {
+ width: 100%; font-size: 12px; border-collapse: collapse;
+ }
+ .events-table th {
+ text-align: left; padding: 8px 16px; font-weight: 500;
+ color: var(--dim); border-bottom: 1px solid var(--border);
+ background: var(--bg);
+ }
+ .events-table td {
+ padding: 7px 16px; border-bottom: 1px solid rgba(0,0,0,0.06);
+ vertical-align: middle;
+ }
+ .events-table tr:hover { background: rgba(59,130,246,0.05); }
+ .event-tag {
+ font-size: 10px; padding: 1px 8px; border-radius: 8px;
+ font-weight: 600; white-space: nowrap;
+ }
+ .event-tag.STRATEGY_START { background: #dcfce7; color: #15803d; }
+ .event-tag.STRATEGY_STOP { background: #fee2e2; color: #b91c1c; }
+ .event-tag.ROUND_COMPLETE { background: #dbeafe; color: #1d4ed8; }
+ .event-tag.STOP_LOSS_TRIGGERED { background: #fef3c7; color: #b45309; }
+ .event-tag.ENTRY_FILLED { background: #ede9fe; color: #6d28d9; }
+ .event-tag.PNL_SNAPSHOT { background: #ccfbf1; color: #0f766e; }
+ .event-tag.CMD_ACK { background: #e5e7eb; color: #4b5563; }
+ .payload-preview {
+ max-width: 260px; overflow: hidden; text-overflow: ellipsis;
+ white-space: nowrap; color: var(--dim); font-family: "SF Mono", "Consolas", monospace;
+ }
+ .relative-time { color: var(--dim); white-space: nowrap; font-size: 11px; }
+
+ /* ===== 配置面板 ===== */
+ .config-status-bar {
+ display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 16px;
+ }
+ .config-status-item {
+ flex: 1; min-width: 120px;
+ background: var(--card); border: 1px solid var(--border);
+ border-radius: 8px; padding: 10px 14px;
+ }
+ .config-status-item .label { font-size: 11px; color: var(--dim); margin-bottom: 3px; }
+ .config-status-item .value { font-size: 16px; font-weight: 600; }
+ .config-status-item .value.positive { color: var(--green); }
+ .config-status-item .value.negative { color: var(--danger); }
+ .config-status-item .value.dim { color: var(--dim); }
+
+ .config-section { margin-bottom: 18px; }
+ .config-section-title {
+ font-size: 13px; font-weight: 600; color: var(--text);
+ margin-bottom: 10px; padding-bottom: 7px;
+ border-bottom: 1px solid var(--border);
+ display: flex; align-items: center; gap: 7px;
+ }
+ .config-section-title .count {
+ font-size: 10px; font-weight: 400; color: var(--dim);
+ background: var(--bg); border: 1px solid var(--border);
+ border-radius: 9px; padding: 0 7px; line-height: 16px;
+ }
+ .form-grid {
+ display: grid; grid-template-columns: 1fr 1fr; gap: 12px 16px;
+ }
+ .form-group { display: flex; flex-direction: column; gap: 5px; }
+ .form-group label { font-size: 11px; color: var(--dim); font-weight: 500; }
+ .form-group input, .form-group select {
+ background: var(--input-bg); border: 1px solid var(--border);
+ border-radius: 6px; color: var(--text); padding: 8px 10px;
+ font-size: 13px; outline: none; width: 100%;
+ transition: border-color .15s;
+ }
+ .form-group input:focus, .form-group select:focus { border-color: var(--accent); }
+ .form-group input::placeholder { color: #9ca3af; }
+ .form-group .field-tip { font-size: 10px; color: #9ca3af; line-height: 1.3; }
+ .form-group.modified label::after {
+ content: ' • 已修改'; color: var(--warn); font-weight: 600;
+ }
+ .form-group.modified input, .form-group.modified select { border-color: var(--warn); }
+ .config-actions {
+ display: flex; justify-content: flex-end; gap: 8px;
+ margin-bottom: 18px; padding-bottom: 14px;
+ border-bottom: 1px solid var(--border);
+ }
+
+ /* ===== 空状态 ===== */
+ .empty-state {
+ text-align: center; padding: 60px 20px; color: var(--dim);
+ }
+ .empty-state .icon { font-size: 48px; margin-bottom: 12px; }
+ .empty-state p { font-size: 14px; }
+
+ /* ===== 确认弹窗 ===== */
+ .modal-overlay {
+ position: fixed; inset: 0; background: rgba(15,23,42,0.4);
+ display: flex; align-items: center; justify-content: center; z-index: 1000;
+ display: none;
+ }
+ .modal-overlay.show { display: flex; }
+ .modal {
+ background: var(--card); border: 1px solid var(--border);
+ border-radius: 10px; padding: 24px; width: 380px;
+ }
+ .modal h3 { font-size: 16px; margin-bottom: 12px; }
+ .modal p { font-size: 13px; color: var(--dim); margin-bottom: 20px; }
+ .modal .modal-btns { display: flex; justify-content: flex-end; gap: 8px; }
+
+ /* Toast — 页面正中间,3s 后彻底隐藏 */
+ .toast {
+ position: fixed; top: 50%; left: 50%;
+ transform: translate(-50%, -50%);
+ padding: 12px 22px; border-radius: 8px; font-size: 14px;
+ z-index: 2000; display: none;
+ max-width: 80vw; text-align: center;
+ }
+ .toast.show { display: block; animation: toastIn .25s ease; }
+ .toast.success { background: #dcfce7; border:1px solid #86efac; color:#15803d; }
+ .toast.error { background: #fee2e2; border:1px solid #fca5a5; color:#b91c1c; }
+ @keyframes toastIn {
+ from { opacity: 0; transform: translate(-50%, -50%) scale(0.9); }
+ to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
+ }
+
+ /* Scrollbar */
+ ::-webkit-scrollbar { width: 6px; }
+ ::-webkit-scrollbar-track { background: transparent; }
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
+
+ @media (max-width: 900px) {
+ body { flex-direction: column; }
+ .sidebar { width: 100%; min-width: 100%; height: auto; max-height: 40vh; }
+ .main { height: auto; }
+ .stats-grid { grid-template-columns: 1fr 1fr; }
+ .form-grid { grid-template-columns: 1fr; }
+ }
+ </style>
+</head>
+<body>
+
+<!-- ===== 侧边栏:实例列表 ===== -->
+<div class="sidebar">
+ <div class="sidebar-header">
+ <h1>📡 Station</h1>
+ <span class="refresh-badge" id="refreshBadge">⏳ 5s</span>
+ </div>
+ <div class="instance-list" id="instanceList">
+ <div class="empty-state">
+ <div class="icon">📭</div>
+ <p>暂无在线实例</p>
+ <p style="font-size:11px;margin-top:4px">等待 JAR 心跳注册...</p>
+ </div>
+ </div>
+ <div class="sidebar-footer">
+ <span id="connStatus">🟢 已连接</span>
+ <span id="instanceCount">0 实例</span>
+ </div>
+</div>
+
+<!-- ===== 主区域 ===== -->
+<div class="main">
+ <!-- 顶栏 -->
+ <div class="main-header" id="mainHeader">
+ <div class="instance-title">
+ <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>
+ </div>
+ </div>
+
+ <!-- 内容区 -->
+ <div class="content" id="mainContent">
+ <div class="empty-state">
+ <div class="icon">👈</div>
+ <p>从左侧列表选择一个实例查看详情</p>
+ </div>
+ </div>
+</div>
+
+<!-- 详情模板(初始隐藏) -->
+<div id="detailTemplate" style="display:none">
+ <!-- Tabs -->
+ <div class="tabs">
+ <div class="tab active" data-tab="overview">概览</div>
+ <div class="tab" data-tab="events">事件日志</div>
+ <div class="tab" data-tab="config">策略参数</div>
+ </div>
+
+ <!-- 概览 Tab -->
+ <div class="tab-content active" data-tab="overview">
+ <div class="stats-grid" id="statsGrid"></div>
+ <div class="chart-container">
+ <h3>📈 盈亏趋势(最近 PNL_SNAPSHOT 事件)</h3>
+ <div class="pnl-summary" id="pnlSummary"></div>
+ <div class="chart-wrap"><canvas id="pnlChart"></canvas></div>
+ </div>
+ </div>
+
+ <!-- 事件日志 Tab -->
+ <div class="tab-content" data-tab="events">
+ <div class="events-panel">
+ <div class="events-header">
+ <h3>📋 最近 100 条事件</h3>
+ <span style="font-size:11px;color:var(--dim)" id="eventCount">—</span>
+ </div>
+ <div style="max-height:500px;overflow-y:auto">
+ <table class="events-table">
+ <thead>
+ <tr>
+ <th style="width:120px">时间</th>
+ <th style="width:140px">类型</th>
+ <th>Payload</th>
+ </tr>
+ </thead>
+ <tbody id="eventsBody"></tbody>
+ </table>
+ </div>
+ </div>
+ </div>
+
+ <!-- 策略参数 Tab(可编辑) -->
+ <div class="tab-content" data-tab="config">
+ <!-- 只读状态条 -->
+ <div class="config-status-bar" id="configStatusBar"></div>
+
+ <div class="chart-container">
+ <div class="config-actions">
+ <span style="flex:1;font-size:12px;color:var(--dim);align-self:center">💡 修改后需手动点击顶部「▶ 启动」使新配置生效</span>
+ <button class="btn btn-outline" id="btnResetConfig" onclick="resetConfig()">↺ 重置</button>
+ <button class="btn btn-success" id="btnSaveConfig" onclick="saveConfig()">💾 保存</button>
+ </div>
+ <div id="configForm"></div>
+ </div>
+ </div>
+</div>
+
+<!-- 确认弹窗 -->
+<div class="modal-overlay" id="modalOverlay">
+ <div class="modal">
+ <h3 id="modalTitle">确认操作</h3>
+ <p id="modalMsg"></p>
+ <div class="modal-btns">
+ <button class="btn btn-outline" onclick="closeModal()">取消</button>
+ <button class="btn" id="modalConfirmBtn" onclick="confirmAction()">确认</button>
+ </div>
+ </div>
+</div>
+
+<div id="toast"></div>
+
+<script>
+const API = '/api/gate/station';
+let selectedMd5 = null;
+let instances = {};
+let eventsCache = {};
+let pnlChart = null;
+let pollingTimer = null;
+let pendingAction = null;
+let pnlData = [];
+
+// ==================== 工具 ====================
+function $(id) { return document.getElementById(id); }
+function toast(msg, type) {
+ const t = $('toast');
+ t.textContent = msg;
+ t.className = 'toast ' + type + ' show';
+ // 清除上一次未消失的定时器,避免重复点击时闪烁
+ clearTimeout(t._toastTimer);
+ t._toastTimer = setTimeout(() => {
+ t.className = 'toast'; // 移除 show → display:none,彻底隐藏不留空白框
+ }, 3000);
+}
+function fmtNum(v) {
+ if (v == null || v === '') return '—';
+ const n = parseFloat(v);
+ if (isNaN(n)) return v;
+ return n.toFixed(n < 1 ? 6 : 2);
+}
+function fmtPnl(v) {
+ if (v == null || v === '') return { text: '—', cls: '' };
+ const n = parseFloat(v);
+ if (isNaN(n)) return { text: v, cls: '' };
+ return {
+ text: (n >= 0 ? '+' : '') + n.toFixed(4),
+ cls: n > 0 ? 'positive' : n < 0 ? 'negative' : ''
+ };
+}
+function relativeTime(ts) {
+ if (!ts) return '—';
+ const diff = Date.now() - ts;
+ if (diff < 5000) return '刚刚';
+ if (diff < 60000) return Math.floor(diff / 1000) + 's 前';
+ if (diff < 3600000) return Math.floor(diff / 60000) + 'm 前';
+ if (diff < 86400000) return Math.floor(diff / 3600000) + 'h 前';
+ return new Date(ts).toLocaleDateString();
+}
+function formatTime(ts) {
+ if (!ts) return '—';
+ const d = new Date(ts);
+ return d.toLocaleTimeString('zh-CN', { hour12: false }) + '.' + String(d.getMilliseconds()).padStart(3, '0');
+}
+
+// ==================== API 请求 ====================
+async function api(url, opts) {
+ try {
+ const res = await fetch(url, opts);
+ const data = await res.json();
+ if (data.code !== 0 && data.code !== 200) throw new Error(data.msg || '请求失败');
+ return data.data !== undefined ? data.data : data.msg;
+ } catch (e) {
+ if (e.message.includes('Failed to fetch') || e.message.includes('NetworkError')) {
+ $('connStatus').innerHTML = '🔴 离线';
+ }
+ throw e;
+ }
+}
+
+// ==================== 实例列表 ====================
+async function refreshInstanceList() {
+ try {
+ const list = await api(API + '/list');
+ $('connStatus').innerHTML = '🟢 已连接';
+ const newInstances = {};
+ for (const inst of (list || [])) {
+ newInstances[inst.apiKeyMd5] = inst;
+ }
+ instances = newInstances;
+ renderInstanceList();
+ } catch (e) {
+ // 静默失败,保留旧数据
+ }
+}
+
+function renderInstanceList() {
+ const container = $('instanceList');
+ const keys = Object.keys(instances);
+
+ if (keys.length === 0) {
+ container.innerHTML = `
+ <div class="empty-state">
+ <div class="icon">📭</div>
+ <p>暂无在线实例</p>
+ <p style="font-size:11px;margin-top:4px">等待 JAR 心跳注册...</p>
+ </div>`;
+ } else {
+ container.innerHTML = keys.map(k => {
+ const i = instances[k];
+ const isActive = i.state === 'ACTIVE' || i.state === 'OPENING';
+ const dotClass = i.state === 'STOPPED' ? 'stopped' : isActive ? 'active' : 'offline';
+ const pnl = fmtPnl(i.cumulativePnl);
+ const selected = k === selectedMd5 ? ' selected' : '';
+ return `
+ <div class="instance-card${selected}" onclick="selectInstance('${k}')" data-md5="${k}">
+ <div class="top-row">
+ <span class="key">${i.contract || '—'}</span>
+ <span class="status-dot ${dotClass}" title="${i.state || 'UNKNOWN'}"></span>
+ </div>
+ <div class="contract">${k.substring(0, 16)}...</div>
+ <div class="meta">
+ <span>轮次 ${i.currentRound ?? 0}</span>
+ <span class="pnl ${pnl.cls}">${pnl.text}</span>
+ <span>${relativeTime(i.lastSeen)}</span>
+ </div>
+ </div>`;
+ }).join('');
+ }
+ $('instanceCount').textContent = keys.length + ' 实例';
+}
+
+function selectInstance(md5) {
+ selectedMd5 = md5;
+ eventsCache[md5] = null; // 强制刷新
+ pnlData = [];
+ renderInstanceList();
+ loadInstanceDetail(md5, true);
+}
+
+async function loadInstanceDetail(md5, full) {
+ const inst = instances[md5];
+ if (!inst) return;
+
+ // 显示详情模板,隐藏空状态
+ const content = $('mainContent');
+ const tmpl = $('detailTemplate');
+ if (!content.querySelector('.tabs')) {
+ content.innerHTML = tmpl.innerHTML;
+ // 绑定 tab 切换
+ content.querySelectorAll('.tab').forEach(t => {
+ t.addEventListener('click', () => switchTab(t.dataset.tab));
+ });
+ }
+
+ // 更新 header
+ const header = $('mainHeader');
+ header.querySelector('.instance-title').innerHTML = `
+ <h2>${inst.contract || '—'} <span style="font-size:12px;color:var(--dim);font-weight:400">${md5.substring(0,12)}...</span></h2>
+ <span class="state-badge ${sanitizeState(inst.state)}">${inst.state || 'UNKNOWN'}</span>
+ `;
+ $('btnStart').disabled = false;
+ $('btnStop').disabled = false;
+
+ // 更新概览统计
+ renderStats(inst);
+
+ // 加载事件(内部会渲染事件表格 + PNL 图表)
+ await loadEvents(md5);
+
+ // 策略参数表单:仅全量加载(切换实例)时渲染,避免轮询清空用户正在编辑的内容
+ if (full) {
+ loadConfig(md5);
+ }
+}
+
+function sanitizeState(s) {
+ if (!s) return '';
+ const valid = ['ACTIVE', 'STOPPED', 'WAITING_KLINE', 'OPENING'];
+ return valid.includes(s) ? s : '';
+}
+
+function switchTab(name) {
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
+ document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
+ document.querySelector(`.tab[data-tab="${name}"]`).classList.add('active');
+ document.querySelector(`.tab-content[data-tab="${name}"]`).classList.add('active');
+}
+
+// ==================== 统计卡片 ====================
+function renderStats(inst) {
+ const pnl = fmtPnl(inst.cumulativePnl);
+ const principal = inst.principal ? parseFloat(inst.principal) : 0;
+ const cumpnl = inst.cumulativePnl ? parseFloat(inst.cumulativePnl) : 0;
+ const totalEquity = principal + cumpnl;
+ const roi = principal > 0 ? ((cumpnl / principal) * 100) : 0;
+
+ $('statsGrid').innerHTML = `
+ <div class="stat-card">
+ <div class="label">累计已实现盈亏</div>
+ <div class="value ${pnl.cls}">${pnl.text}</div>
+ <div class="sub">USDT</div>
+ </div>
+ <div class="stat-card">
+ <div class="label">初始本金</div>
+ <div class="value">${fmtNum(inst.principal)}</div>
+ <div class="sub">USDT</div>
+ </div>
+ <div class="stat-card">
+ <div class="label">估算总权益</div>
+ <div class="value">${fmtNum(totalEquity)}</div>
+ <div class="sub">本金 + 已实现盈亏</div>
+ </div>
+ <div class="stat-card">
+ <div class="label">收益率 (ROI)</div>
+ <div class="value ${roi >= 0 ? 'positive' : 'negative'}" style="color:${roi >= 0 ? 'var(--green)' : 'var(--danger)'}">${roi >= 0 ? '+' : ''}${roi.toFixed(4)}%</div>
+ <div class="sub">累计</div>
+ </div>
+ <div class="stat-card">
+ <div class="label">当前轮次</div>
+ <div class="value">${inst.currentRound ?? 0}</div>
+ <div class="sub">杠杆 ${inst.leverage || '—'}x</div>
+ </div>
+ <div class="stat-card">
+ <div class="label">最后心跳</div>
+ <div class="value" style="font-size:16px">${relativeTime(inst.lastSeen)}</div>
+ <div class="sub">${inst.hostPort || '—'}</div>
+ </div>
+ `;
+}
+
+// ==================== 事件日志 ====================
+async function loadEvents(md5) {
+ try {
+ const events = await api(API + '/events?apiKeyMd5=' + encodeURIComponent(md5));
+ eventsCache[md5] = events || [];
+ renderEvents(events || []);
+ } catch (e) {
+ eventsCache[md5] = [];
+ renderEvents([]);
+ }
+}
+
+function renderEvents(events) {
+ const tbody = $('eventsBody');
+ $('eventCount').textContent = events.length + ' 条';
+
+ if (events.length === 0) {
+ tbody.innerHTML = `<tr><td colspan="3" style="text-align:center;padding:30px;color:var(--dim)">暂无事件记录</td></tr>`;
+ pnlData = [];
+ renderPnlChart();
+ return;
+ }
+
+ // 收集 PNL_SNAPSHOT 数据用于图表
+ pnlData = events
+ .filter(e => e.eventType === 'PNL_SNAPSHOT')
+ .map(e => {
+ let payload = {};
+ try { payload = JSON.parse(e.payloadJson || '{}'); } catch (_) {}
+ return {
+ time: e.eventTime || e.createTime,
+ cumulativePnl: parseFloat(payload.cumulativePnl || 0),
+ unrealizedPnl: parseFloat(payload.unrealizedPnl || 0),
+ totalEquity: parseFloat(payload.totalEquity || 0),
+ markPrice: parseFloat(payload.markPrice || 0)
+ };
+ })
+ .slice(-50);
+
+ tbody.innerHTML = events.slice(0, 100).map(e => {
+ let payloadStr = e.payloadJson || '';
+ // 截断显示
+ let shortPayload = payloadStr;
+ try {
+ const obj = JSON.parse(payloadStr);
+ const keys = Object.keys(obj);
+ shortPayload = keys.map(k => `${k}: ${obj[k]}`).join(', ');
+ } catch (_) {}
+ if (shortPayload.length > 80) shortPayload = shortPayload.substring(0, 80) + '...';
+
+ const ts = e.eventTime || (e.createTime ? new Date(e.createTime).getTime() : null);
+ return `
+ <tr>
+ <td class="relative-time" title="${new Date(ts).toLocaleString()}">${formatTime(ts)}</td>
+ <td><span class="event-tag ${e.eventType || ''}">${e.eventType || '—'}</span></td>
+ <td class="payload-preview" title="${payloadStr}">${shortPayload || '—'}</td>
+ </tr>`;
+ }).join('');
+
+ renderPnlChart();
+}
+
+// ==================== PNL 图表 ====================
+function renderPnlChart() {
+ const canvas = document.getElementById('pnlChart');
+ if (!canvas) return;
+
+ // 顶部摘要指标(当前值 + 较上个快照涨跌)
+ renderPnlSummary();
+
+ if (pnlChart) pnlChart.destroy();
+
+ if (pnlData.length === 0) {
+ const ctx = canvas.getContext('2d');
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.fillStyle = '#6b7280';
+ ctx.font = '14px sans-serif';
+ ctx.textAlign = 'center';
+ ctx.fillText('暂无 PNL 数据(等待 PNL_SNAPSHOT 事件上报)', canvas.width / 2, canvas.height / 2);
+ return;
+ }
+
+ const labels = pnlData.map(d => formatTime(d.time));
+ const cumulative = pnlData.map(d => d.cumulativePnl);
+ const unrealized = pnlData.map(d => d.unrealizedPnl);
+ const total = pnlData.map(d => d.totalEquity);
+
+ // 面积渐变填充:顶部半透明 → 底部透明
+ function gradient(rgb, topAlpha) {
+ return (context) => {
+ const { ctx, chartArea } = context.chart;
+ if (!chartArea) return `rgba(${rgb},${topAlpha})`;
+ const g = ctx.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
+ g.addColorStop(0, `rgba(${rgb},${topAlpha})`);
+ g.addColorStop(1, `rgba(${rgb},0)`);
+ return g;
+ };
+ }
+
+ pnlChart = new Chart(canvas, {
+ type: 'line',
+ data: {
+ labels,
+ datasets: [
+ {
+ label: '已实现盈亏',
+ data: cumulative,
+ borderColor: '#16a34a',
+ backgroundColor: gradient('22,163,74', 0.22),
+ fill: true,
+ tension: 0.3,
+ pointRadius: 2,
+ pointBackgroundColor: '#16a34a',
+ pointBorderColor: '#ffffff',
+ pointBorderWidth: 1,
+ borderWidth: 1.8
+ },
+ {
+ label: '未实现盈亏',
+ data: unrealized,
+ borderColor: '#d97706',
+ backgroundColor: gradient('217,119,6', 0.16),
+ fill: true,
+ tension: 0.3,
+ pointRadius: 0,
+ borderWidth: 1.5,
+ borderDash: [4, 3]
+ },
+ {
+ label: '总权益',
+ data: total,
+ borderColor: '#3b82f6',
+ backgroundColor: gradient('59,130,246', 0.10),
+ fill: true,
+ tension: 0.3,
+ pointRadius: 0,
+ borderWidth: 2,
+ yAxisID: 'y1'
+ }
+ ]
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ interaction: { intersect: false, mode: 'index' },
+ plugins: {
+ legend: {
+ labels: { color: '#6b7280', font: { size: 11 }, usePointStyle: true, padding: 15, boxWidth: 8 }
+ },
+ tooltip: {
+ backgroundColor: '#ffffff',
+ borderColor: '#e3e8f0',
+ borderWidth: 1,
+ titleColor: '#1f2937',
+ bodyColor: '#1f2937',
+ titleFont: { size: 12 },
+ bodyFont: { size: 12 },
+ padding: 10,
+ callbacks: {
+ label: (ctx) => {
+ const isEquity = ctx.dataset.label === '总权益';
+ const v = ctx.parsed.y;
+ const txt = isEquity
+ ? v.toFixed(2)
+ : (v >= 0 ? '+' : '') + v.toFixed(4);
+ return ctx.dataset.label + ': ' + txt;
+ }
+ }
+ }
+ },
+ scales: {
+ x: {
+ ticks: { color: '#6b7280', font: { size: 9 }, maxTicksLimit: 10, maxRotation: 0 },
+ grid: { color: 'rgba(0,0,0,0.06)' }
+ },
+ y: {
+ type: 'linear',
+ display: true,
+ position: 'left',
+ ticks: { color: '#6b7280', font: { size: 10 }, callback: v => v.toFixed(4) },
+ grid: { color: 'rgba(0,0,0,0.06)' }
+ },
+ y1: {
+ type: 'linear',
+ display: true,
+ position: 'right',
+ ticks: { color: '#3b82f6', font: { size: 10 }, callback: v => v.toFixed(2) },
+ grid: { drawOnChartArea: false }
+ }
+ }
+ }
+ });
+}
+
+// 盈亏趋势顶部摘要:当前值 + 较上个快照涨跌
+function renderPnlSummary() {
+ const box = document.getElementById('pnlSummary');
+ if (!box) return;
+ if (pnlData.length === 0) {
+ box.innerHTML = '';
+ return;
+ }
+ const cur = pnlData[pnlData.length - 1];
+ const prev = pnlData.length > 1 ? pnlData[pnlData.length - 2] : null;
+
+ const items = [
+ { label: '已实现盈亏', key: 'cumulativePnl', signed: true, digits: 4, fmt: v => (v >= 0 ? '+' : '') + v.toFixed(4) },
+ { label: '未实现盈亏', key: 'unrealizedPnl', signed: true, digits: 4, fmt: v => (v >= 0 ? '+' : '') + v.toFixed(4) },
+ { label: '总权益', key: 'totalEquity', signed: false, digits: 2, fmt: v => v.toFixed(2) },
+ { label: '标记价', key: 'markPrice', signed: false, digits: 4, fmt: v => v.toFixed(4) }
+ ];
+
+ box.innerHTML = items.map(it => {
+ const v = cur[it.key];
+ const cls = (it.signed && v !== 0) ? (v > 0 ? 'positive' : 'negative') : '';
+ let deltaHtml = '<div class="s-delta">—</div>';
+ if (prev != null) {
+ const d = v - prev[it.key];
+ if (d !== 0) {
+ const dCls = d > 0 ? 'positive' : 'negative';
+ deltaHtml = `<div class="s-delta ${dCls}">${d > 0 ? '▲' : '▼'} ${Math.abs(d).toFixed(it.digits)}</div>`;
+ } else {
+ deltaHtml = '<div class="s-delta">— 持平</div>';
+ }
+ }
+ return `<div class="pnl-summary-item">
+ <div class="s-label">${it.label}</div>
+ <div class="s-value ${cls}">${it.fmt(v)}</div>
+ ${deltaHtml}
+ </div>`;
+ }).join('');
+}
+
+// ==================== 策略参数 ====================
+// 从事件流提取最近一次 STRATEGY_START 的完整参数快照(toParamsMap 含全部 18 个字段)
+function extractConfigFromEvents(events) {
+ for (let i = events.length - 1; i >= 0; i--) {
+ if (events[i].eventType === 'STRATEGY_START') {
+ try {
+ return JSON.parse(events[i].payloadJson || '{}');
+ } catch (_) {
+ return null;
+ }
+ }
+ }
+ return null;
+}
+
+// 默认参数(与后端 GateConfigDTO.defaultsFor() 保持一致)
+const DEFAULT_CONFIG = {
+ gridRate: 0.005,
+ expectedProfit: 0.15,
+ maxLoss: 1.5,
+ baseQuantity: '2',
+ quantity: '2',
+ maxPositionSize: 4,
+ stopLossCount: 0,
+ takeProfitGridSpan: 2,
+ rounds: 0,
+ stopLossCountMode: 'dual',
+ addPositionInterval: 3,
+ addPositionQuantity: 1,
+ maxPositionPerSide: 0,
+ addPositionStartThreshold: 1,
+ placeExcessTakeProfit: false,
+ priceDriveEnabled: true,
+};
+
+async function loadConfig(md5) {
+ // 优先:DB strategy_status(保存的配置已持久化,STRATEGY_START 也会写入这里,始终是最新值)
+ try {
+ const status = await api(API + '/strategy-status?apiKeyMd5=' + encodeURIComponent(md5));
+ if (status) {
+ // DB 字段 totalRounds 映射为前端 key rounds
+ if (status.totalRounds != null && status.rounds == null) status.rounds = status.totalRounds;
+ renderConfig(status);
+ return;
+ }
+ } catch (e) {
+ // 请求失败 → 回退事件流 / 默认值
+ }
+ // 回退:最近 STRATEGY_START 事件 payload(字段最全,含 rounds/expectedProfit/maxLoss 等)
+ const config = extractConfigFromEvents(eventsCache[md5] || []);
+ if (config) {
+ renderConfig(config);
+ return;
+ }
+ // 兜底:默认值填充,方便用户预填后启动
+ renderConfig(DEFAULT_CONFIG);
+}
+
+// 可编辑的策略参数字段(jtype 标记 Java 类型用于正确序列化,group 用于分组渲染)
+const EDITABLE_FIELDS = [
+ { key: 'gridRate', label: '网格间距比例', type: 'number', step: '0.0001', placeholder: '0.005', jtype: 'decimal', group: 'grid', tip: '短基价 × 该比例 = 绝对步长' },
+ { key: 'baseQuantity', label: '基底开仓张数', type: 'number', placeholder: '2', jtype: 'string', group: 'grid' },
+ { key: 'quantity', label: '每次下单张数', type: 'number', placeholder: '2', jtype: 'string', group: 'grid' },
+ { key: 'maxPositionSize', label: '最大持仓张数', type: 'number', placeholder: '4', jtype: 'int', group: 'grid' },
+ { key: 'takeProfitGridSpan', label: '止盈网格跨度', type: 'number', placeholder: '2', jtype: 'int', group: 'grid' },
+
+ { key: 'expectedProfit', label: '预期收益 (USDT)', type: 'number', step: '0.01', placeholder: '0.15', jtype: 'decimal', group: 'risk' },
+ { key: 'maxLoss', label: '最大亏损 (USDT)', type: 'number', step: '0.01', placeholder: '1.5', jtype: 'decimal', group: 'risk' },
+ { key: 'stopLossCount', label: '止损阶梯次数', type: 'number', placeholder: '0', jtype: 'int', group: 'risk', tip: '0 = 禁用阶梯止损' },
+
+ { key: 'stopLossCountMode', label: '止损统计方式', type: 'select', options: [{ v: 'dual', l: '双向统一统计' }, { v: 'single', l: '单向分别统计' }], group: 'addon' },
+ { key: 'addPositionInterval', label: '加仓间隔 (次)', type: 'number', placeholder: '3', jtype: 'int', group: 'addon', tip: '每隔 N 次止损触发一次加仓' },
+ { key: 'addPositionQuantity', label: '加仓数量 (张)', type: 'number', placeholder: '1', jtype: 'int', group: 'addon' },
+ { key: 'maxPositionPerSide', label: '单边最大仓位 (0=不限)', type: 'number', placeholder: '0', jtype: 'int', group: 'addon' },
+ { key: 'addPositionStartThreshold', label: '加仓启动阈值 (次)', type: 'number', placeholder: '1', jtype: 'int', group: 'addon', tip: '前 N 次止损不触发加仓' },
+
+ { 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' },
+];
+
+// 分组定义(渲染顺序)
+const CONFIG_GROUPS = [
+ { id: 'grid', name: '📐 网格参数' },
+ { id: 'risk', name: '🛡️ 风控参数' },
+ { id: 'addon', name: '📈 加仓 & 止损' },
+ { id: 'run', name: '⚡ 运行控制' },
+];
+
+// 字段是否处于"已修改"状态(用于高亮)
+const modifiedFields = new Set();
+
+function fieldHtml(f) {
+ const inputId = 'cfg_' + f.key;
+ let inner;
+ if (f.type === 'select') {
+ inner = `<select id="${inputId}" onchange="markModified('${f.key}')">${f.options.map(o => `<option value="${o.v}">${o.l}</option>`).join('')}</select>`;
+ } else {
+ inner = `<input id="${inputId}" type="${f.type}" placeholder="${f.placeholder || ''}" ${f.step ? 'step="' + f.step + '"' : ''} oninput="markModified('${f.key}')">`;
+ }
+ const tip = f.tip ? `<div class="field-tip">${f.tip}</div>` : '';
+ return `<div class="form-group" id="group_${f.key}"><label>${f.label}</label>${inner}${tip}</div>`;
+}
+
+function renderConfig(status) {
+ const form = $('configForm');
+ if (!status) {
+ form.innerHTML = `<div style="padding:20px;color:var(--dim);text-align:center">暂无策略参数</div>`;
+ return;
+ }
+ // 1. 只读状态条
+ renderConfigStatusBar();
+
+ // 2. 分组渲染表单
+ let html = '';
+ for (const g of CONFIG_GROUPS) {
+ const fields = EDITABLE_FIELDS.filter(f => f.group === g.id);
+ if (fields.length === 0) continue;
+ html += `<div class="config-section">
+ <div class="config-section-title">${g.name}<span class="count">${fields.length} 项</span></div>
+ <div class="form-grid">${fields.map(fieldHtml).join('')}</div>
+ </div>`;
+ }
+ form.innerHTML = html;
+
+ // 3. 填充当前值
+ for (const f of EDITABLE_FIELDS) {
+ const el = document.getElementById('cfg_' + f.key);
+ if (!el) continue;
+ let val = status[f.key];
+ if (val === true) val = 'true';
+ else if (val === false) val = 'false';
+ else if (val == null || val === '') val = '';
+ else val = String(val);
+ el.value = val;
+ }
+ modifiedFields.clear();
+}
+
+function renderConfigStatusBar() {
+ const bar = $('configStatusBar');
+ const inst = instances[selectedMd5];
+ if (!inst) {
+ bar.innerHTML = '';
+ return;
+ }
+ const pnl = fmtPnl(inst.cumulativePnl);
+ const items = [
+ { label: '合约', value: inst.contract || '—', cls: '' },
+ { label: '状态', value: inst.state || '—', cls: 'dim' },
+ { label: '杠杆', value: (inst.leverage || '—') + 'x', cls: '' },
+ { label: '当前轮次', value: inst.currentRound ?? 0, cls: '' },
+ { label: '累计盈亏', value: pnl.text, cls: pnl.cls },
+ { label: '初始本金', value: fmtNum(inst.principal), cls: '' },
+ ];
+ bar.innerHTML = items.map(i =>
+ `<div class="config-status-item"><div class="label">${i.label}</div><div class="value ${i.cls}">${i.value}</div></div>`
+ ).join('');
+}
+
+function markModified(key) {
+ modifiedFields.add(key);
+ const g = document.getElementById('group_' + key);
+ if (g) g.classList.add('modified');
+}
+
+function resetConfig() {
+ if (!selectedMd5) return;
+ // 重新从当前数据源加载,覆盖用户未保存的编辑
+ loadConfig(selectedMd5);
+ toast('已重置为当前生效参数', 'success');
+}
+
+async function saveConfig() {
+ if (!selectedMd5) return;
+ const params = {};
+ for (const f of EDITABLE_FIELDS) {
+ const el = document.getElementById('cfg_' + f.key);
+ if (!el) continue;
+ const raw = el.value;
+ // 跳过空值:缺失字段在 DTO 中为 null,buildFromDTO 会用 nvl() 填充默认值
+ if (raw === '' || raw == null) continue;
+
+ if (f.jtype === 'string') {
+ params[f.key] = raw;
+ } else if (f.jtype === 'int') {
+ params[f.key] = parseInt(raw, 10);
+ } else if (f.jtype === 'decimal') {
+ params[f.key] = parseFloat(raw);
+ } else {
+ // select 等保持不变
+ params[f.key] = raw;
+ }
+ }
+ try {
+ const msg = await api(API + '/config?apiKeyMd5=' + encodeURIComponent(selectedMd5), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(params)
+ });
+ toast(msg || '参数已保存,点击「启动」使新配置生效', 'success');
+ // 保存成功后清除修改高亮
+ modifiedFields.clear();
+ document.querySelectorAll('.form-group.modified').forEach(g => g.classList.remove('modified'));
+ } catch (e) {
+ toast(e.message, 'error');
+ }
+}
+
+// ==================== 启停操作 ====================
+function showConfirm(action) {
+ if (!selectedMd5) return;
+ pendingAction = action;
+ const verb = action === 'start' ? '启动' : '停止';
+ $('modalTitle').textContent = `确认${verb}`;
+ $('modalMsg').textContent = `确定要${verb}实例 ${selectedMd5.substring(0, 12)}... 的策略吗?`;
+ $('modalConfirmBtn').textContent = verb;
+ $('modalConfirmBtn').className = 'btn ' + (action === 'start' ? 'btn-success' : 'btn-danger');
+ $('modalOverlay').classList.add('show');
+}
+
+function closeModal() {
+ $('modalOverlay').classList.remove('show');
+ pendingAction = null;
+}
+
+async function confirmAction() {
+ if (!pendingAction || !selectedMd5) return;
+ const action = pendingAction;
+ closeModal();
+
+ try {
+ const msg = await api(API + '/' + action + '?apiKeyMd5=' + encodeURIComponent(selectedMd5), { method: 'POST' });
+ toast(msg || '指令已发送', 'success');
+ // 延迟刷新等待 ACK
+ setTimeout(refreshInstanceList, 2000);
+ } catch (e) {
+ toast(e.message, 'error');
+ }
+}
+
+// ==================== 轮询 ====================
+async function poll() {
+ await refreshInstanceList();
+ // 如果有选中实例,刷新详情(full=false,不重渲染参数表单,避免清空用户编辑)
+ if (selectedMd5 && instances[selectedMd5]) {
+ await loadInstanceDetail(selectedMd5, false);
+ }
+}
+
+// ==================== 初始化 ====================
+function startPolling() {
+ poll(); // 立即执行一次
+ pollingTimer = setInterval(poll, 5000);
+}
+
+// 倒计时显示
+setInterval(() => {
+ const badge = $('refreshBadge');
+ if (badge) badge.textContent = '⏳ 5s';
+ setTimeout(() => { if (badge) badge.textContent = '⏳ 4s'; }, 1000);
+ setTimeout(() => { if (badge) badge.textContent = '⏳ 3s'; }, 2000);
+ setTimeout(() => { if (badge) badge.textContent = '⏳ 2s'; }, 3000);
+ setTimeout(() => { if (badge) badge.textContent = '⏳ 1s'; }, 4000);
+}, 5000);
+
+startPolling();
+</script>
+</body>
+</html>
--
Gitblit v1.9.1