This commit is contained in:
sion
2026-04-06 18:45:04 +08:00
parent 2e34072f45
commit ee8979f471
37 changed files with 5 additions and 2500 deletions

View File

@@ -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 {

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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 推送 DTOWebSocket 广播用)
*/
@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;
}
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}
}
}
/**
* 清除所有缓存(币种数据变更时调用)
*/

View File

@@ -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());
}
}
}
}

View File

@@ -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("交易价格已变化,请刷新后重试");
}
}
}
}

View File

@@ -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