111
This commit is contained in:
@@ -7,7 +7,6 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
import org.springframework.boot.web.servlet.ServletComponentScan;
|
||||
import org.springframework.context.annotation.ComponentScan;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
import org.springframework.transaction.annotation.EnableTransactionManagement;
|
||||
|
||||
|
||||
@@ -15,7 +14,6 @@ import org.springframework.transaction.annotation.EnableTransactionManagement;
|
||||
@ServletComponentScan(basePackages ={"com.it.rattan"})
|
||||
@ComponentScan(basePackages ={"com.it.rattan"})
|
||||
@EnableTransactionManagement
|
||||
@EnableScheduling
|
||||
/*@EnableAsync
|
||||
@EnableAspectJAutoProxy*/
|
||||
public class SpcCloudApplication {
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
package com.it.rattan.monisuo.config;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAutoDetect;
|
||||
import com.fasterxml.jackson.annotation.PropertyAccessor;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
|
||||
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||
|
||||
/**
|
||||
* Redis 配置
|
||||
*/
|
||||
@Configuration
|
||||
public class RedisConfig {
|
||||
|
||||
@Bean
|
||||
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
|
||||
RedisTemplate<String, Object> template = new RedisTemplate<>();
|
||||
template.setConnectionFactory(factory);
|
||||
|
||||
ObjectMapper om = new ObjectMapper();
|
||||
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
|
||||
om.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL);
|
||||
|
||||
GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(om);
|
||||
StringRedisSerializer stringSerializer = new StringRedisSerializer();
|
||||
|
||||
// key 用 String
|
||||
template.setKeySerializer(stringSerializer);
|
||||
template.setHashKeySerializer(stringSerializer);
|
||||
// value 用 JSON
|
||||
template.setValueSerializer(jsonSerializer);
|
||||
template.setHashValueSerializer(jsonSerializer);
|
||||
|
||||
template.afterPropertiesSet();
|
||||
return template;
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package com.it.rattan.monisuo.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
|
||||
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
|
||||
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
|
||||
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
|
||||
|
||||
/**
|
||||
* WebSocket 配置 — K线实时推送
|
||||
*
|
||||
* 端点: /ws/kline
|
||||
* 主题: /topic/kline/{coinCode}
|
||||
*
|
||||
* Flutter 使用原生 STOMP 连接,浏览器可用 SockJS 降级
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSocketMessageBroker
|
||||
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
|
||||
|
||||
@Override
|
||||
public void configureMessageBroker(MessageBrokerRegistry config) {
|
||||
// 服务端推送主题前缀
|
||||
config.enableSimpleBroker("/topic");
|
||||
// 客户端发送前缀(本场景不需要客户端主动发消息,预留)
|
||||
config.setApplicationDestinationPrefixes("/app");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerStompEndpoints(StompEndpointRegistry registry) {
|
||||
// 原生 WebSocket 端点(Flutter 用)
|
||||
registry.addEndpoint("/ws/kline")
|
||||
.setAllowedOrigins("*");
|
||||
// SockJS 降级端点(浏览器用)
|
||||
registry.addEndpoint("/ws/kline")
|
||||
.setAllowedOrigins("*")
|
||||
.withSockJS();
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package com.it.rattan.monisuo.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.it.rattan.monisuo.common.Result;
|
||||
import com.it.rattan.monisuo.dto.KlineConfigUpdate;
|
||||
import com.it.rattan.monisuo.entity.Coin;
|
||||
import com.it.rattan.monisuo.entity.CoinKline;
|
||||
import com.it.rattan.monisuo.mapper.CoinMapper;
|
||||
import com.it.rattan.monisuo.service.CoinKlineService;
|
||||
import com.it.rattan.monisuo.service.CoinService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 管理端K线配置 API
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
public class AdminKlineController {
|
||||
|
||||
@Autowired
|
||||
private CoinKlineService coinKlineService;
|
||||
|
||||
@Autowired
|
||||
private CoinMapper coinMapper;
|
||||
|
||||
/**
|
||||
* 获取所有币种K线配置
|
||||
*/
|
||||
@GetMapping("/admin/kline/config")
|
||||
public Result<List<Coin>> getKlineConfigs() {
|
||||
LambdaQueryWrapper<Coin> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.orderByDesc(Coin::getSort);
|
||||
List<Coin> coins = coinMapper.selectList(wrapper);
|
||||
return Result.success(coins);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存币种K线配置
|
||||
*/
|
||||
@PostMapping("/admin/kline/config")
|
||||
public Result<Void> saveKlineConfig(@RequestBody KlineConfigUpdate config) {
|
||||
try {
|
||||
coinKlineService.updateConfig(config);
|
||||
return Result.success("配置已保存", null);
|
||||
} catch (Exception e) {
|
||||
return Result.fail(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取K线预览数据(用于 admin echarts 图表)
|
||||
*/
|
||||
@GetMapping("/admin/kline/preview")
|
||||
public Result<List<CoinKline>> getKlinePreview(
|
||||
@RequestParam String coinCode,
|
||||
@RequestParam(defaultValue = "1h") String interval,
|
||||
@RequestParam(defaultValue = "100") int limit) {
|
||||
List<CoinKline> candles = coinKlineService.getHistory(
|
||||
coinCode.toUpperCase(), interval, limit, null);
|
||||
return Result.success(candles);
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package com.it.rattan.monisuo.controller;
|
||||
|
||||
import com.it.rattan.monisuo.common.Result;
|
||||
import com.it.rattan.monisuo.entity.CoinKline;
|
||||
import com.it.rattan.monisuo.service.CoinKlineService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.HashMap;
|
||||
|
||||
/**
|
||||
* 用户端K线 API
|
||||
*/
|
||||
@RestController
|
||||
public class KlineController {
|
||||
|
||||
@Autowired
|
||||
private CoinKlineService coinKlineService;
|
||||
|
||||
/**
|
||||
* 获取支持的K线周期列表
|
||||
*/
|
||||
@GetMapping("/api/kline/intervals")
|
||||
public Result<List<String>> getIntervals() {
|
||||
return Result.success(coinKlineService.getIntervals());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取历史K线数据(分页)
|
||||
*
|
||||
* @param coinCode 币种代码
|
||||
* @param interval 周期 (15m/1h/4h/1d/1M)
|
||||
* @param limit 数量 (默认200, 最大500)
|
||||
* @param before 查询此时间戳(ms)之前的K线(分页用)
|
||||
*/
|
||||
@GetMapping("/api/kline/history")
|
||||
public Result<Map<String, Object>> getHistory(
|
||||
@RequestParam String coinCode,
|
||||
@RequestParam(defaultValue = "1h") String interval,
|
||||
@RequestParam(defaultValue = "200") int limit,
|
||||
@RequestParam(required = false) Long before) {
|
||||
|
||||
List<CoinKline> candles = coinKlineService.getHistory(
|
||||
coinCode.toUpperCase(), interval, limit, before);
|
||||
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("list", candles);
|
||||
data.put("coinCode", coinCode.toUpperCase());
|
||||
data.put("interval", interval);
|
||||
return Result.success(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前进行中的K线
|
||||
*/
|
||||
@GetMapping("/api/kline/current")
|
||||
public Result<CoinKline> getCurrentCandle(
|
||||
@RequestParam String coinCode,
|
||||
@RequestParam(defaultValue = "1h") String interval) {
|
||||
CoinKline candle = coinKlineService.getCurrentCandle(
|
||||
coinCode.toUpperCase(), interval);
|
||||
if (candle == null) {
|
||||
return Result.fail("暂无K线数据");
|
||||
}
|
||||
return Result.success(candle);
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package com.it.rattan.monisuo.dto;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 管理员K线配置更新 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
public class KlineConfigUpdate implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** 币种代码 */
|
||||
private String coinCode;
|
||||
|
||||
/** 交易开始时间 HH:mm */
|
||||
private String tradeStartTime;
|
||||
|
||||
/** 交易结束时间 HH:mm */
|
||||
private String tradeEndTime;
|
||||
|
||||
/** 每日最大涨跌幅(%) */
|
||||
private BigDecimal maxChangePercent;
|
||||
|
||||
/** K线模拟最低价 */
|
||||
private BigDecimal priceMin;
|
||||
|
||||
/** K线模拟最高价 */
|
||||
private BigDecimal priceMax;
|
||||
|
||||
/** 1=启用模拟 */
|
||||
private Integer simulationEnabled;
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package com.it.rattan.monisuo.dto;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* K线 Tick 推送 DTO(WebSocket 广播用)
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
public class KlineTick implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** 币种代码 */
|
||||
private String coinCode;
|
||||
|
||||
/** 周期 */
|
||||
private String interval;
|
||||
|
||||
/** 开盘时间戳(ms) */
|
||||
private Long openTime;
|
||||
|
||||
/** 开盘价 */
|
||||
private BigDecimal openPrice;
|
||||
|
||||
/** 最高价 */
|
||||
private BigDecimal highPrice;
|
||||
|
||||
/** 最低价 */
|
||||
private BigDecimal lowPrice;
|
||||
|
||||
/** 收盘价(最新价) */
|
||||
private BigDecimal closePrice;
|
||||
|
||||
/** 成交量 */
|
||||
private BigDecimal volume;
|
||||
|
||||
/** 收盘时间戳(ms) */
|
||||
private Long closeTime;
|
||||
|
||||
/** 是否已收盘 */
|
||||
private Boolean isClosed;
|
||||
|
||||
/** 当前时间戳 */
|
||||
private Long timestamp;
|
||||
|
||||
public KlineTick() {}
|
||||
|
||||
public static KlineTick fromEntity(com.it.rattan.monisuo.entity.CoinKline kline, boolean isClosed) {
|
||||
KlineTick tick = new KlineTick();
|
||||
tick.setCoinCode(kline.getCoinCode());
|
||||
tick.setInterval(kline.getInterval());
|
||||
tick.setOpenTime(kline.getOpenTime());
|
||||
tick.setOpenPrice(kline.getOpenPrice());
|
||||
tick.setHighPrice(kline.getHighPrice());
|
||||
tick.setLowPrice(kline.getLowPrice());
|
||||
tick.setClosePrice(kline.getClosePrice());
|
||||
tick.setVolume(kline.getVolume());
|
||||
tick.setCloseTime(kline.getCloseTime());
|
||||
tick.setIsClosed(isClosed);
|
||||
tick.setTimestamp(System.currentTimeMillis());
|
||||
return tick;
|
||||
}
|
||||
}
|
||||
@@ -99,15 +99,6 @@ public class Coin implements Serializable {
|
||||
/** 每日最大涨跌幅(%) */
|
||||
private BigDecimal maxChangePercent;
|
||||
|
||||
/** K线模拟最低价 */
|
||||
private BigDecimal priceMin;
|
||||
|
||||
/** K线模拟最高价 */
|
||||
private BigDecimal priceMax;
|
||||
|
||||
/** 1=启用K线模拟 */
|
||||
private Integer simulationEnabled;
|
||||
|
||||
/** 创建时间 */
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
package com.it.rattan.monisuo.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* K线蜡烛数据实体
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("coin_kline")
|
||||
public class CoinKline implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 币种代码 */
|
||||
private String coinCode;
|
||||
|
||||
/** 周期: 15m/1h/4h/1d/1M */
|
||||
private String interval;
|
||||
|
||||
/** 开盘时间戳(ms) */
|
||||
private Long openTime;
|
||||
|
||||
/** 开盘价 */
|
||||
private BigDecimal openPrice;
|
||||
|
||||
/** 最高价 */
|
||||
private BigDecimal highPrice;
|
||||
|
||||
/** 最低价 */
|
||||
private BigDecimal lowPrice;
|
||||
|
||||
/** 收盘价 */
|
||||
private BigDecimal closePrice;
|
||||
|
||||
/** 模拟成交量 */
|
||||
private BigDecimal volume;
|
||||
|
||||
/** 收盘时间戳(ms) */
|
||||
private Long closeTime;
|
||||
|
||||
/** 创建时间 */
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package com.it.rattan.monisuo.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.it.rattan.monisuo.entity.CoinKline;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* K线数据 Mapper
|
||||
*/
|
||||
@Mapper
|
||||
public interface CoinKlineMapper extends BaseMapper<CoinKline> {
|
||||
|
||||
/**
|
||||
* 查询最近的K线数据(按时间倒序)
|
||||
*/
|
||||
@Select("SELECT * FROM coin_kline WHERE coin_code = #{coinCode} " +
|
||||
"AND `interval` = #{interval} " +
|
||||
"AND close_time < #{beforeCloseTime} " +
|
||||
"ORDER BY open_time DESC LIMIT #{limit}")
|
||||
List<CoinKline> selectRecentCandles(@Param("coinCode") String coinCode,
|
||||
@Param("interval") String interval,
|
||||
@Param("beforeCloseTime") long beforeCloseTime,
|
||||
@Param("limit") int limit);
|
||||
|
||||
/**
|
||||
* 查询最近的K线数据(不限 before)
|
||||
*/
|
||||
@Select("SELECT * FROM coin_kline WHERE coin_code = #{coinCode} " +
|
||||
"AND `interval` = #{interval} " +
|
||||
"ORDER BY open_time DESC LIMIT #{limit}")
|
||||
List<CoinKline> selectLatestCandles(@Param("coinCode") String coinCode,
|
||||
@Param("interval") String interval,
|
||||
@Param("limit") int limit);
|
||||
}
|
||||
@@ -92,10 +92,8 @@ public class AssetService {
|
||||
result.put("tradeBalance", tradeBalance);
|
||||
BigDecimal totalAsset = fundBalance.add(tradeBalance);
|
||||
result.put("totalAsset", totalAsset);
|
||||
// 总盈亏 = 总资产 + 累计提现 - 累计充值(净投入 = 充值 - 提现,总资产超出净投入即为盈利)
|
||||
BigDecimal totalDeposit = fund.getTotalDeposit() != null ? fund.getTotalDeposit() : BigDecimal.ZERO;
|
||||
BigDecimal totalWithdraw = fund.getTotalWithdraw() != null ? fund.getTotalWithdraw() : BigDecimal.ZERO;
|
||||
result.put("totalProfit", totalAsset.add(totalWithdraw).subtract(totalDeposit));
|
||||
// 总盈亏 = 所有持仓的未实现盈亏之和(当前市值 - 持仓成本)
|
||||
result.put("totalProfit", totalValue.subtract(totalCost));
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,482 +0,0 @@
|
||||
package com.it.rattan.monisuo.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.it.rattan.monisuo.dto.KlineConfigUpdate;
|
||||
import com.it.rattan.monisuo.dto.KlineTick;
|
||||
import com.it.rattan.monisuo.entity.Coin;
|
||||
import com.it.rattan.monisuo.entity.CoinKline;
|
||||
import com.it.rattan.monisuo.mapper.CoinKlineMapper;
|
||||
import com.it.rattan.monisuo.mapper.CoinMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.annotation.PostConstruct;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.*;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
/**
|
||||
* K线模拟引擎
|
||||
*
|
||||
* 双写策略:ConcurrentHashMap(高性能读写) + Redis(持久化/重启恢复)
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class CoinKlineService {
|
||||
|
||||
private static final String REDIS_CANDLE_PREFIX = "kline:candle:";
|
||||
private static final String REDIS_PRICE_PREFIX = "kline:price:";
|
||||
|
||||
/** 本地内存 — tick 级别高频读写 */
|
||||
private final ConcurrentHashMap<String, CoinKline> currentCandles = new ConcurrentHashMap<>();
|
||||
|
||||
private static final List<String> INTERVALS = Arrays.asList("15m", "1h", "4h", "1d", "1M");
|
||||
private static final BigDecimal TICK_VOLUME_BASE = new BigDecimal("100");
|
||||
private static final double DAILY_TICKS = 9600.0;
|
||||
|
||||
@Autowired
|
||||
private CoinMapper coinMapper;
|
||||
|
||||
@Autowired
|
||||
private CoinKlineMapper coinKlineMapper;
|
||||
|
||||
@Autowired
|
||||
private CoinService coinService;
|
||||
|
||||
@Autowired(required = false)
|
||||
private SimpMessagingTemplate messagingTemplate;
|
||||
|
||||
@Autowired
|
||||
private RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
/**
|
||||
* 启动时从 Redis 恢复当前进行中的蜡烛
|
||||
*/
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
Set<String> keys = redisTemplate.keys(REDIS_CANDLE_PREFIX + "*");
|
||||
if (keys != null && !keys.isEmpty()) {
|
||||
for (String key : keys) {
|
||||
try {
|
||||
Object obj = redisTemplate.opsForValue().get(key);
|
||||
if (obj instanceof CoinKline) {
|
||||
CoinKline candle = (CoinKline) obj;
|
||||
String mapKey = candle.getCoinCode() + ":" + candle.getInterval();
|
||||
currentCandles.put(mapKey, candle);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("恢复K线数据失败: key={}, error={}", key, e.getMessage());
|
||||
}
|
||||
}
|
||||
log.info("从Redis恢复了 {} 个进行中的K线蜡烛", currentCandles.size());
|
||||
}
|
||||
|
||||
// 恢复模拟价格到 CoinService 缓存
|
||||
Set<String> priceKeys = redisTemplate.keys(REDIS_PRICE_PREFIX + "*");
|
||||
if (priceKeys != null && !priceKeys.isEmpty()) {
|
||||
for (String key : priceKeys) {
|
||||
try {
|
||||
String coinCode = key.substring(REDIS_PRICE_PREFIX.length());
|
||||
Object priceObj = redisTemplate.opsForValue().get(key);
|
||||
if (priceObj instanceof BigDecimal) {
|
||||
coinService.updateCachedPrice(coinCode, (BigDecimal) priceObj);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("恢复价格失败: key={}, error={}", key, e.getMessage());
|
||||
}
|
||||
}
|
||||
log.info("从Redis恢复了 {} 个币种的模拟价格", priceKeys.size());
|
||||
}
|
||||
}
|
||||
|
||||
// ============================ 公开方法 ============================
|
||||
|
||||
public List<String> getIntervals() {
|
||||
return INTERVALS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取历史K线(分页)
|
||||
*/
|
||||
public List<CoinKline> getHistory(String coinCode, String interval, int limit, Long beforeTime) {
|
||||
if (limit <= 0 || limit > 500) limit = 200;
|
||||
if (beforeTime == null || beforeTime <= 0) {
|
||||
beforeTime = System.currentTimeMillis();
|
||||
}
|
||||
List<CoinKline> candles = coinKlineMapper.selectRecentCandles(coinCode, interval, beforeTime, limit);
|
||||
Collections.reverse(candles);
|
||||
return candles;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前进行中的K线
|
||||
*/
|
||||
public CoinKline getCurrentCandle(String coinCode, String interval) {
|
||||
String key = coinCode.toUpperCase() + ":" + interval;
|
||||
CoinKline kline = currentCandles.get(key);
|
||||
if (kline != null) {
|
||||
return copyKline(kline);
|
||||
}
|
||||
List<CoinKline> latest = coinKlineMapper.selectLatestCandles(coinCode.toUpperCase(), interval, 1);
|
||||
if (!latest.isEmpty()) {
|
||||
return latest.get(0);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有启用模拟的币种
|
||||
*/
|
||||
public List<Coin> getSimulatedCoins() {
|
||||
LambdaQueryWrapper<Coin> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.eq(Coin::getSimulationEnabled, 1)
|
||||
.eq(Coin::getStatus, 1);
|
||||
return coinMapper.selectList(wrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员更新K线配置
|
||||
*/
|
||||
public void updateConfig(KlineConfigUpdate config) {
|
||||
Coin coin = coinService.getCoinByCode(config.getCoinCode());
|
||||
if (coin == null) throw new RuntimeException("币种不存在");
|
||||
|
||||
if (config.getTradeStartTime() != null) coin.setTradeStartTime(config.getTradeStartTime());
|
||||
if (config.getTradeEndTime() != null) coin.setTradeEndTime(config.getTradeEndTime());
|
||||
if (config.getPriceMin() != null) coin.setPriceMin(config.getPriceMin());
|
||||
if (config.getPriceMax() != null) coin.setPriceMax(config.getPriceMax());
|
||||
if (config.getSimulationEnabled() != null) coin.setSimulationEnabled(config.getSimulationEnabled());
|
||||
|
||||
if (coin.getPriceMin() != null && coin.getPriceMax() != null
|
||||
&& coin.getPriceMin().compareTo(coin.getPriceMax()) >= 0) {
|
||||
throw new RuntimeException("最低价必须小于最高价");
|
||||
}
|
||||
|
||||
coin.setUpdateTime(LocalDateTime.now());
|
||||
coinMapper.updateById(coin);
|
||||
coinService.clearCache(coin.getCode());
|
||||
|
||||
log.info("K线配置已更新: {} simulation={} priceMin={} priceMax={}",
|
||||
coin.getCode(), coin.getSimulationEnabled(), coin.getPriceMin(), coin.getPriceMax());
|
||||
}
|
||||
|
||||
// ============================ Tick 生成 ============================
|
||||
|
||||
/**
|
||||
* 每 3 秒调用:为所有启用模拟的币种生成一个价格 tick
|
||||
*/
|
||||
public List<KlineTick> generateTicks() {
|
||||
List<Coin> coins = getSimulatedCoins();
|
||||
if (coins.isEmpty()) return Collections.emptyList();
|
||||
|
||||
LocalTime now = LocalTime.now();
|
||||
List<KlineTick> ticks = new ArrayList<>();
|
||||
|
||||
for (Coin coin : coins) {
|
||||
if (!isInTradingHours(coin, now)) continue;
|
||||
if (coin.getPriceMin() == null || coin.getPriceMax() == null) continue;
|
||||
if (coin.getPriceMin().compareTo(coin.getPriceMax()) >= 0) continue;
|
||||
|
||||
BigDecimal currentPrice = coin.getPrice();
|
||||
if (currentPrice == null || currentPrice.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
currentPrice = coin.getPriceMin().add(coin.getPriceMax())
|
||||
.divide(new BigDecimal("2"), 8, RoundingMode.DOWN);
|
||||
}
|
||||
final BigDecimal tickOpenPrice = currentPrice;
|
||||
|
||||
BigDecimal newPrice = simulatePrice(coin, currentPrice);
|
||||
|
||||
// 更新内存价格
|
||||
coin.setPrice(newPrice);
|
||||
|
||||
// 同步到 CoinService 本地缓存 + Redis
|
||||
coinService.updateCachedPrice(coin.getCode(), newPrice);
|
||||
savePriceToRedis(coin.getCode(), newPrice);
|
||||
|
||||
// 为每个周期更新内存K线
|
||||
for (String interval : INTERVALS) {
|
||||
String key = coin.getCode() + ":" + interval;
|
||||
CoinKline candle = currentCandles.computeIfAbsent(key, k -> {
|
||||
long[] times = calculateIntervalTimes(interval);
|
||||
CoinKline c = new CoinKline();
|
||||
c.setCoinCode(coin.getCode());
|
||||
c.setInterval(interval);
|
||||
c.setOpenTime(times[0]);
|
||||
c.setCloseTime(times[1]);
|
||||
c.setOpenPrice(tickOpenPrice);
|
||||
c.setHighPrice(tickOpenPrice);
|
||||
c.setLowPrice(tickOpenPrice);
|
||||
c.setClosePrice(tickOpenPrice);
|
||||
c.setVolume(BigDecimal.ZERO);
|
||||
return c;
|
||||
});
|
||||
|
||||
candle.setClosePrice(newPrice);
|
||||
if (newPrice.compareTo(candle.getHighPrice()) > 0) {
|
||||
candle.setHighPrice(newPrice);
|
||||
}
|
||||
if (newPrice.compareTo(candle.getLowPrice()) < 0) {
|
||||
candle.setLowPrice(newPrice);
|
||||
}
|
||||
BigDecimal volIncrement = TICK_VOLUME_BASE.multiply(
|
||||
new BigDecimal(ThreadLocalRandom.current().nextDouble(0.5, 2.0))
|
||||
.setScale(4, RoundingMode.DOWN)
|
||||
);
|
||||
candle.setVolume(candle.getVolume().add(volIncrement));
|
||||
|
||||
// 每 tick 写入 Redis(异步安全)
|
||||
saveCandleToRedis(key, candle);
|
||||
}
|
||||
|
||||
// 为所有周期生成 tick 事件
|
||||
for (String interval : INTERVALS) {
|
||||
String tickKey = coin.getCode() + ":" + interval;
|
||||
CoinKline tickCandle = currentCandles.get(tickKey);
|
||||
if (tickCandle != null) {
|
||||
ticks.add(KlineTick.fromEntity(tickCandle, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ticks;
|
||||
}
|
||||
|
||||
// ============================ K线收盘 ============================
|
||||
|
||||
public List<KlineTick> closeCandlesForInterval(String closedInterval) {
|
||||
List<Coin> coins = getSimulatedCoins();
|
||||
List<KlineTick> closedTicks = new ArrayList<>();
|
||||
|
||||
for (Coin coin : coins) {
|
||||
String key = coin.getCode() + ":" + closedInterval;
|
||||
CoinKline candle = currentCandles.remove(key);
|
||||
if (candle == null) continue;
|
||||
|
||||
if (candle.getClosePrice() != null && candle.getClosePrice().compareTo(BigDecimal.ZERO) > 0) {
|
||||
coinKlineMapper.insert(candle);
|
||||
log.debug("K线已收盘: {} {} open={}", coin.getCode(), closedInterval, candle.getOpenPrice());
|
||||
}
|
||||
|
||||
// 从 Redis 移除已收盘的蜡烛
|
||||
removeCandleFromRedis(key);
|
||||
|
||||
closedTicks.add(KlineTick.fromEntity(candle, true));
|
||||
|
||||
if ("1d".equals(closedInterval)) {
|
||||
updateCoinDailyStats(coin, candle);
|
||||
}
|
||||
}
|
||||
|
||||
return closedTicks;
|
||||
}
|
||||
|
||||
public List<String> detectIntervalCrossings(long previousTickTime) {
|
||||
List<String> crossed = new ArrayList<>();
|
||||
long now = System.currentTimeMillis();
|
||||
|
||||
if (differentIntervalSlot(previousTickTime, now, 15 * 60 * 1000L)) crossed.add("15m");
|
||||
if (differentIntervalSlot(previousTickTime, now, 60 * 60 * 1000L)) crossed.add("1h");
|
||||
if (different4hSlot(previousTickTime, now)) crossed.add("4h");
|
||||
if (differentDaySlot(previousTickTime, now)) crossed.add("1d");
|
||||
if (differentMonthSlot(previousTickTime, now)) crossed.add("1M");
|
||||
|
||||
return crossed;
|
||||
}
|
||||
|
||||
// ============================ Redis 操作 ============================
|
||||
|
||||
private void saveCandleToRedis(String key, CoinKline candle) {
|
||||
try {
|
||||
redisTemplate.opsForValue().set(REDIS_CANDLE_PREFIX + key, candle);
|
||||
} catch (Exception e) {
|
||||
log.warn("Redis写入蜡烛失败: key={}, error={}", key, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void removeCandleFromRedis(String key) {
|
||||
try {
|
||||
redisTemplate.delete(REDIS_CANDLE_PREFIX + key);
|
||||
} catch (Exception e) {
|
||||
log.warn("Redis删除蜡烛失败: key={}, error={}", key, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void savePriceToRedis(String coinCode, BigDecimal price) {
|
||||
try {
|
||||
redisTemplate.opsForValue().set(REDIS_PRICE_PREFIX + coinCode.toUpperCase(), price);
|
||||
} catch (Exception e) {
|
||||
log.warn("Redis写入价格失败: coin={}, error={}", coinCode, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ============================ GBM 模拟算法 ============================
|
||||
|
||||
private BigDecimal simulatePrice(Coin coin, BigDecimal currentPrice) {
|
||||
double S = currentPrice.doubleValue();
|
||||
double priceMin = coin.getPriceMin().doubleValue();
|
||||
double priceMax = coin.getPriceMax().doubleValue();
|
||||
double midpoint = (priceMin + priceMax) / 2.0;
|
||||
double halfRange = (priceMax - priceMin) / 2.0;
|
||||
|
||||
double sigma = halfRange / Math.max(midpoint, 0.00000001) / Math.sqrt(DAILY_TICKS);
|
||||
double dt = 1.0 / DAILY_TICKS;
|
||||
|
||||
double u1 = ThreadLocalRandom.current().nextDouble();
|
||||
double u2 = ThreadLocalRandom.current().nextDouble();
|
||||
double Z = Math.sqrt(-2.0 * Math.log(Math.max(u1, 1e-10))) * Math.cos(2.0 * Math.PI * u2);
|
||||
|
||||
double mu = 0;
|
||||
double deviation = (S - midpoint) / halfRange;
|
||||
double absDeviation = Math.abs(deviation);
|
||||
if (absDeviation > 0.85) {
|
||||
mu = -deviation * 0.02;
|
||||
} else if (absDeviation > 0.65) {
|
||||
mu = -deviation * 0.005;
|
||||
}
|
||||
|
||||
double dS = mu * S * dt + sigma * S * Math.sqrt(dt) * Z;
|
||||
double newPrice = S + dS;
|
||||
|
||||
newPrice = Math.max(priceMin, Math.min(priceMax, newPrice));
|
||||
newPrice = Math.max(newPrice, 0.00000001);
|
||||
|
||||
return new BigDecimal(newPrice).setScale(8, RoundingMode.DOWN);
|
||||
}
|
||||
|
||||
// ============================ 辅助方法 ============================
|
||||
|
||||
private boolean isInTradingHours(Coin coin, LocalTime now) {
|
||||
String startStr = coin.getTradeStartTime();
|
||||
String endStr = coin.getTradeEndTime();
|
||||
if (startStr == null || endStr == null) return true;
|
||||
|
||||
try {
|
||||
LocalTime start = LocalTime.parse(startStr, DateTimeFormatter.ofPattern("HH:mm"));
|
||||
LocalTime end = LocalTime.parse(endStr, DateTimeFormatter.ofPattern("HH:mm"));
|
||||
if (end.isAfter(start)) {
|
||||
return !now.isBefore(start) && !now.isAfter(end);
|
||||
} else {
|
||||
return !now.isBefore(start) || !now.isAfter(end);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private long[] calculateIntervalTimes(String interval) {
|
||||
Instant now = Instant.now();
|
||||
ZoneId zone = ZoneId.of("Asia/Shanghai");
|
||||
ZonedDateTime zdt = now.atZone(zone);
|
||||
|
||||
long openTime, closeTime;
|
||||
switch (interval) {
|
||||
case "15m": {
|
||||
int minute = zdt.getMinute();
|
||||
int slot = (minute / 15) * 15;
|
||||
ZonedDateTime open = zdt.withMinute(slot).withSecond(0).withNano(0);
|
||||
openTime = open.toInstant().toEpochMilli();
|
||||
closeTime = open.plusMinutes(15).toInstant().toEpochMilli();
|
||||
break;
|
||||
}
|
||||
case "1h": {
|
||||
ZonedDateTime open = zdt.withMinute(0).withSecond(0).withNano(0);
|
||||
openTime = open.toInstant().toEpochMilli();
|
||||
closeTime = open.plusHours(1).toInstant().toEpochMilli();
|
||||
break;
|
||||
}
|
||||
case "4h": {
|
||||
int hour = zdt.getHour();
|
||||
int slot = (hour / 4) * 4;
|
||||
ZonedDateTime open = zdt.withHour(slot).withMinute(0).withSecond(0).withNano(0);
|
||||
openTime = open.toInstant().toEpochMilli();
|
||||
closeTime = open.plusHours(4).toInstant().toEpochMilli();
|
||||
break;
|
||||
}
|
||||
case "1d": {
|
||||
ZonedDateTime open = zdt.withHour(0).withMinute(0).withSecond(0).withNano(0);
|
||||
openTime = open.toInstant().toEpochMilli();
|
||||
closeTime = open.plusDays(1).toInstant().toEpochMilli();
|
||||
break;
|
||||
}
|
||||
case "1M": {
|
||||
ZonedDateTime open = zdt.withDayOfMonth(1).withHour(0).withMinute(0).withSecond(0).withNano(0);
|
||||
openTime = open.toInstant().toEpochMilli();
|
||||
closeTime = open.plusMonths(1).toInstant().toEpochMilli();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new IllegalArgumentException("不支持的周期: " + interval);
|
||||
}
|
||||
return new long[]{openTime, closeTime};
|
||||
}
|
||||
|
||||
private boolean differentIntervalSlot(long t1, long t2, long intervalMs) {
|
||||
return (t1 / intervalMs) != (t2 / intervalMs);
|
||||
}
|
||||
|
||||
private boolean different4hSlot(long t1, long t2) {
|
||||
ZoneId zone = ZoneId.of("Asia/Shanghai");
|
||||
int h1 = Instant.ofEpochMilli(t1).atZone(zone).getHour() / 4;
|
||||
int h2 = Instant.ofEpochMilli(t2).atZone(zone).getHour() / 4;
|
||||
int d1 = Instant.ofEpochMilli(t1).atZone(zone).getDayOfYear();
|
||||
int d2 = Instant.ofEpochMilli(t2).atZone(zone).getDayOfYear();
|
||||
return d1 != d2 || h1 != h2;
|
||||
}
|
||||
|
||||
private boolean differentDaySlot(long t1, long t2) {
|
||||
ZoneId zone = ZoneId.of("Asia/Shanghai");
|
||||
return Instant.ofEpochMilli(t1).atZone(zone).toLocalDate()
|
||||
.isBefore(Instant.ofEpochMilli(t2).atZone(zone).toLocalDate());
|
||||
}
|
||||
|
||||
private boolean differentMonthSlot(long t1, long t2) {
|
||||
ZoneId zone = ZoneId.of("Asia/Shanghai");
|
||||
ZonedDateTime z1 = Instant.ofEpochMilli(t1).atZone(zone);
|
||||
ZonedDateTime z2 = Instant.ofEpochMilli(t2).atZone(zone);
|
||||
return z1.getYear() != z2.getYear() || z1.getMonthValue() != z2.getMonthValue();
|
||||
}
|
||||
|
||||
private void updateCoinDailyStats(Coin coin, CoinKline dailyCandle) {
|
||||
coin.setPrice(dailyCandle.getClosePrice());
|
||||
BigDecimal change = dailyCandle.getClosePrice()
|
||||
.subtract(dailyCandle.getOpenPrice())
|
||||
.divide(dailyCandle.getOpenPrice(), 8, RoundingMode.DOWN)
|
||||
.multiply(new BigDecimal("100"));
|
||||
coin.setChange24h(change);
|
||||
coin.setHigh24h(dailyCandle.getHighPrice());
|
||||
coin.setLow24h(dailyCandle.getLowPrice());
|
||||
coin.setVolume24h(dailyCandle.getVolume());
|
||||
coin.setUpdateTime(LocalDateTime.now());
|
||||
coinMapper.updateById(coin);
|
||||
coinService.clearCache(coin.getCode());
|
||||
|
||||
// 日线收盘后清理 Redis 中的价格(下次 tick 会重新写入)
|
||||
try {
|
||||
redisTemplate.delete(REDIS_PRICE_PREFIX + coin.getCode().toUpperCase());
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
log.info("日线收盘更新: {} price={} change={}%", coin.getCode(), coin.getPrice(), change);
|
||||
}
|
||||
|
||||
private CoinKline copyKline(CoinKline source) {
|
||||
CoinKline copy = new CoinKline();
|
||||
copy.setId(source.getId());
|
||||
copy.setCoinCode(source.getCoinCode());
|
||||
copy.setInterval(source.getInterval());
|
||||
copy.setOpenTime(source.getOpenTime());
|
||||
copy.setOpenPrice(source.getOpenPrice());
|
||||
copy.setHighPrice(source.getHighPrice());
|
||||
copy.setLowPrice(source.getLowPrice());
|
||||
copy.setClosePrice(source.getClosePrice());
|
||||
copy.setVolume(source.getVolume());
|
||||
copy.setCloseTime(source.getCloseTime());
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
@@ -132,25 +132,6 @@ public class CoinService extends ServiceImpl<CoinMapper, Coin> {
|
||||
return list(wrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新缓存中的币种价格(K线模拟专用,不写DB)
|
||||
*/
|
||||
public void updateCachedPrice(String code, BigDecimal price) {
|
||||
String key = code.toUpperCase();
|
||||
Coin cached = coinCodeCache.get(key);
|
||||
if (cached != null) {
|
||||
cached.setPrice(price);
|
||||
}
|
||||
if (cachedActiveCoins != null) {
|
||||
for (Coin coin : cachedActiveCoins) {
|
||||
if (coin.getCode().equalsIgnoreCase(key)) {
|
||||
coin.setPrice(price);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有缓存(币种数据变更时调用)
|
||||
*/
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
package com.it.rattan.monisuo.service;
|
||||
|
||||
import com.it.rattan.monisuo.dto.KlineTick;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* K线调度器 — 定时生成 tick + 检测周期边界
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class KlineScheduler {
|
||||
|
||||
@Autowired
|
||||
private CoinKlineService coinKlineService;
|
||||
|
||||
@Autowired(required = false)
|
||||
private SimpMessagingTemplate messagingTemplate;
|
||||
|
||||
/** 上次 tick 的时间戳,用于检测周期边界跨越 */
|
||||
private long lastTickTime = System.currentTimeMillis();
|
||||
|
||||
/**
|
||||
* 每 3 秒执行一次:生成价格 tick + WebSocket 广播
|
||||
*/
|
||||
@Scheduled(fixedRate = 3000)
|
||||
public void tick() {
|
||||
try {
|
||||
long now = System.currentTimeMillis();
|
||||
|
||||
// 1. 检测周期边界跨越
|
||||
List<String> crossedIntervals = coinKlineService.detectIntervalCrossings(lastTickTime);
|
||||
if (!crossedIntervals.isEmpty()) {
|
||||
for (String interval : crossedIntervals) {
|
||||
log.info("K线周期跨越: {}", interval);
|
||||
List<KlineTick> closedTicks = coinKlineService.closeCandlesForInterval(interval);
|
||||
// 广播收盘 tick
|
||||
for (KlineTick tick : closedTicks) {
|
||||
broadcastTick(tick);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 生成新的价格 tick
|
||||
List<KlineTick> ticks = coinKlineService.generateTicks();
|
||||
|
||||
// 3. WebSocket 广播
|
||||
for (KlineTick tick : ticks) {
|
||||
broadcastTick(tick);
|
||||
}
|
||||
|
||||
lastTickTime = now;
|
||||
} catch (Exception e) {
|
||||
log.error("K线 tick 调度异常", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void broadcastTick(KlineTick tick) {
|
||||
if (messagingTemplate != null) {
|
||||
try {
|
||||
messagingTemplate.convertAndSend("/topic/kline/" + tick.getCoinCode(), tick);
|
||||
} catch (Exception e) {
|
||||
log.warn("WebSocket 广播失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,6 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
@@ -50,9 +48,6 @@ public class TradeService {
|
||||
throw new RuntimeException("该币种已下架");
|
||||
}
|
||||
|
||||
// 模拟币种交易校验
|
||||
validateSimulationTrade(coin, price);
|
||||
|
||||
// 计算金额
|
||||
BigDecimal amount = price.multiply(quantity).setScale(8, RoundingMode.DOWN);
|
||||
|
||||
@@ -133,9 +128,6 @@ public class TradeService {
|
||||
throw new RuntimeException("该币种已下架");
|
||||
}
|
||||
|
||||
// 模拟币种交易校验
|
||||
validateSimulationTrade(coin, price);
|
||||
|
||||
// 检查持仓
|
||||
AccountTrade coinAccount = assetService.getOrCreateTradeAccount(userId, coinCode);
|
||||
if (coinAccount.getQuantity().compareTo(quantity) < 0) {
|
||||
@@ -222,43 +214,4 @@ public class TradeService {
|
||||
.eq(OrderTrade::getOrderNo, orderNo);
|
||||
return orderTradeMapper.selectOne(wrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟币种交易校验:交易时段 + 价格一致性
|
||||
*/
|
||||
private void validateSimulationTrade(Coin coin, BigDecimal tradePrice) {
|
||||
if (coin.getSimulationEnabled() == null || coin.getSimulationEnabled() != 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 校验交易时段
|
||||
if (coin.getTradeStartTime() != null && coin.getTradeEndTime() != null) {
|
||||
try {
|
||||
LocalTime now = LocalTime.now();
|
||||
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("HH:mm");
|
||||
LocalTime start = LocalTime.parse(coin.getTradeStartTime(), fmt);
|
||||
LocalTime end = LocalTime.parse(coin.getTradeEndTime(), fmt);
|
||||
boolean inRange;
|
||||
if (end.isAfter(start)) {
|
||||
inRange = !now.isBefore(start) && !now.isAfter(end);
|
||||
} else {
|
||||
inRange = !now.isBefore(start) || !now.isAfter(end);
|
||||
}
|
||||
if (!inRange) {
|
||||
throw new RuntimeException("当前不在交易时段内(" + coin.getTradeStartTime() + " - " + coin.getTradeEndTime() + ")");
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
throw e;
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
// 校验价格一致性(允许 0.1% 滑点)
|
||||
if (coin.getPrice() != null && coin.getPrice().compareTo(BigDecimal.ZERO) > 0) {
|
||||
BigDecimal diff = tradePrice.subtract(coin.getPrice()).abs();
|
||||
BigDecimal threshold = coin.getPrice().multiply(new BigDecimal("0.001"));
|
||||
if (diff.compareTo(threshold) > 0) {
|
||||
throw new RuntimeException("交易价格已变化,请刷新后重试");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,17 +7,6 @@ spring:
|
||||
enabled: true
|
||||
max-file-size: 5MB
|
||||
max-request-size: 10MB
|
||||
redis:
|
||||
host: 8.155.172.147
|
||||
port: 6379
|
||||
password: sion+Rui!$
|
||||
database: 1
|
||||
timeout: 3000ms
|
||||
lettuce:
|
||||
pool:
|
||||
max-active: 8
|
||||
max-idle: 8
|
||||
min-idle: 2
|
||||
datasource:
|
||||
username: monisuo
|
||||
password: JPJ8wYicSGC8aRnk
|
||||
|
||||
Reference in New Issue
Block a user