Compare commits

...

4 Commits

Author SHA1 Message Date
Lee
900c1a2ab8 init 2026-08-03 11:45:30 +08:00
Lee
48079cea72 Harden main contract refresh 2026-07-27 23:35:02 +08:00
Lee
10c434013b Normalize market contract synchronization 2026-07-27 23:24:27 +08:00
Lee
b5e34b6d18 Add persisted technical indicator calculation 2026-07-24 12:56:20 +08:00
32 changed files with 2835 additions and 340 deletions

View File

@ -7,6 +7,8 @@ import com.yangwale.backtestify.common.BaseEntity;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
import java.math.BigDecimal;
/** /**
* 合约字典表 * 合约字典表
*/ */
@ -30,6 +32,9 @@ public class InstrumentDictionary extends BaseEntity {
/** 价格放大倍数 */ /** 价格放大倍数 */
private Integer priceScale; private Integer priceScale;
/** 合约最小变动价位(不参与行情价格缩放) */
private BigDecimal priceTick;
/** 是否当前主力合约0-否1-是 */ /** 是否当前主力合约0-否1-是 */
private Integer isMain; private Integer isMain;
} }

View File

@ -0,0 +1,49 @@
package com.yangwale.backtestify.entity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.math.BigDecimal;
/**
* K线指标记录映射各周期同结构的 t_indicator_*
*/
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class KLineIndicator {
private Integer instrumentId;
private Long kTime;
private BigDecimal ma5;
private BigDecimal ma10;
private BigDecimal ma20;
private BigDecimal ma60;
private BigDecimal bollMb;
private BigDecimal bollUp;
private BigDecimal bollDn;
private BigDecimal ema6;
private BigDecimal ema12;
private BigDecimal ema20;
private BigDecimal macdDif;
private BigDecimal macdDea;
private BigDecimal macdBar;
private BigDecimal rsi6;
private BigDecimal rsi12;
private BigDecimal kdjK;
private BigDecimal kdjD;
private BigDecimal kdjJ;
private Long createAt;
}

View File

@ -15,7 +15,7 @@ public class KLineRecord {
private Integer instrumentId; private Integer instrumentId;
/** Unix时间戳秒级 */ /** Unix时间戳秒级 */
private Long timestamp; private Long kTime;
private Integer open; private Integer open;

View File

@ -1,6 +1,7 @@
package com.yangwale.backtestify.entity; package com.yangwale.backtestify.entity;
import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter; import lombok.Getter;
@ -22,6 +23,7 @@ public class MarketDataSyncLog {
private String syncType; private String syncType;
@TableField("f_period")
private String period; private String period;
private String contractCode; private String contractCode;

View File

@ -0,0 +1,77 @@
package com.yangwale.backtestify.mapper;
import com.yangwale.backtestify.entity.KLineIndicator;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface IndicatorMapper {
@Select("""
SELECT instrument_id, k_time,
ma5, ma10, ma20, ma60,
boll_mb, boll_up, boll_dn,
ema6, ema12, ema20,
macd_dif, macd_dea, macd_bar,
rsi6, rsi12,
kdj_k, kdj_d, kdj_j,
create_at
FROM ${tableName}
WHERE instrument_id = #{instrumentId}
ORDER BY k_time DESC
LIMIT 1
""")
KLineIndicator selectLatest(@Param("tableName") String tableName,
@Param("instrumentId") Integer instrumentId);
@Insert("""
<script>
INSERT INTO ${tableName}
(instrument_id, k_time,
ma5, ma10, ma20, ma60,
boll_mb, boll_up, boll_dn,
ema6, ema12, ema20,
macd_dif, macd_dea, macd_bar,
rsi6, rsi12,
kdj_k, kdj_d, kdj_j,
create_at)
VALUES
<foreach collection="records" item="item" separator=",">
(#{item.instrumentId}, #{item.kTime},
#{item.ma5}, #{item.ma10}, #{item.ma20}, #{item.ma60},
#{item.bollMb}, #{item.bollUp}, #{item.bollDn},
#{item.ema6}, #{item.ema12}, #{item.ema20},
#{item.macdDif}, #{item.macdDea}, #{item.macdBar},
#{item.rsi6}, #{item.rsi12},
#{item.kdjK}, #{item.kdjD}, #{item.kdjJ},
#{item.createAt})
</foreach>
ON DUPLICATE KEY UPDATE
ma5 = VALUES(ma5),
ma10 = VALUES(ma10),
ma20 = VALUES(ma20),
ma60 = VALUES(ma60),
boll_mb = VALUES(boll_mb),
boll_up = VALUES(boll_up),
boll_dn = VALUES(boll_dn),
ema6 = VALUES(ema6),
ema12 = VALUES(ema12),
ema20 = VALUES(ema20),
macd_dif = VALUES(macd_dif),
macd_dea = VALUES(macd_dea),
macd_bar = VALUES(macd_bar),
rsi6 = VALUES(rsi6),
rsi12 = VALUES(rsi12),
kdj_k = VALUES(kdj_k),
kdj_d = VALUES(kdj_d),
kdj_j = VALUES(kdj_j),
create_at = VALUES(create_at)
</script>
""")
int upsertBatch(@Param("tableName") String tableName,
@Param("records") List<KLineIndicator> records);
}

View File

@ -3,7 +3,33 @@ package com.yangwale.backtestify.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yangwale.backtestify.entity.InstrumentDictionary; import com.yangwale.backtestify.entity.InstrumentDictionary;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper @Mapper
public interface InstrumentDictionaryMapper extends BaseMapper<InstrumentDictionary> { public interface InstrumentDictionaryMapper extends BaseMapper<InstrumentDictionary> {
@Select("""
SELECT *
FROM t_instrument_dictionary
WHERE exchange_id = #{exchangeId}
AND LOWER(contract_code) = LOWER(#{contractCode})
ORDER BY id
""")
List<InstrumentDictionary> selectByExchangeAndContractCodeIgnoreCase(
@Param("exchangeId") String exchangeId,
@Param("contractCode") String contractCode);
@Select("""
SELECT *
FROM t_instrument_dictionary
WHERE LOWER(contract_code) = LOWER(#{contractCode})
AND is_deleted = 0
AND is_main = 1
ORDER BY id
""")
List<InstrumentDictionary> selectMainByContractCodeIgnoreCase(
@Param("contractCode") String contractCode);
} }

View File

@ -12,25 +12,69 @@ import java.util.List;
public interface KLineMapper { public interface KLineMapper {
@Select(""" @Select("""
SELECT instrument_id, timestamp, open, high, low, close, SELECT instrument_id, k_time, open, high, low, close,
volume, turnover, open_interest volume, turnover, open_interest
FROM ${tableName} FROM ${tableName}
WHERE instrument_id = #{instrumentId} WHERE instrument_id = #{instrumentId}
AND timestamp BETWEEN #{startTimestamp} AND #{endTimestamp} AND k_time BETWEEN #{startTimestamp} AND #{endTimestamp}
ORDER BY timestamp ASC ORDER BY k_time ASC
""") """)
List<KLineRecord> selectRange(@Param("tableName") String tableName, List<KLineRecord> selectRange(@Param("tableName") String tableName,
@Param("instrumentId") Integer instrumentId, @Param("instrumentId") Integer instrumentId,
@Param("startTimestamp") long startTimestamp, @Param("startTimestamp") long startTimestamp,
@Param("endTimestamp") long endTimestamp); @Param("endTimestamp") long endTimestamp);
@Select("""
SELECT MAX(k_time)
FROM ${tableName}
WHERE instrument_id = #{instrumentId}
""")
Long selectLatestTimestamp(@Param("tableName") String tableName,
@Param("instrumentId") Integer instrumentId);
@Select("""
SELECT instrument_id, k_time, open, high, low, close,
volume, turnover, open_interest
FROM ${tableName}
WHERE instrument_id = #{instrumentId}
ORDER BY k_time ASC
""")
List<KLineRecord> selectAll(@Param("tableName") String tableName,
@Param("instrumentId") Integer instrumentId);
@Select("""
SELECT instrument_id, k_time, open, high, low, close,
volume, turnover, open_interest
FROM ${tableName}
WHERE instrument_id = #{instrumentId}
AND k_time > #{afterTimestamp}
ORDER BY k_time ASC
""")
List<KLineRecord> selectAfter(@Param("tableName") String tableName,
@Param("instrumentId") Integer instrumentId,
@Param("afterTimestamp") long afterTimestamp);
@Select("""
SELECT instrument_id, k_time, open, high, low, close,
volume, turnover, open_interest
FROM ${tableName}
WHERE instrument_id = #{instrumentId}
AND k_time < #{beforeTimestamp}
ORDER BY k_time DESC
LIMIT #{limit}
""")
List<KLineRecord> selectBefore(@Param("tableName") String tableName,
@Param("instrumentId") Integer instrumentId,
@Param("beforeTimestamp") long beforeTimestamp,
@Param("limit") int limit);
@Insert(""" @Insert("""
<script> <script>
INSERT INTO ${tableName} INSERT INTO ${tableName}
(instrument_id, timestamp, open, high, low, close, volume, turnover, open_interest) (instrument_id, k_time, open, high, low, close, volume, turnover, open_interest)
VALUES VALUES
<foreach collection="records" item="item" separator=","> <foreach collection="records" item="item" separator=",">
(#{item.instrumentId}, #{item.timestamp}, #{item.open}, #{item.high}, #{item.low}, #{item.close}, (#{item.instrumentId}, #{item.kTime}, #{item.open}, #{item.high}, #{item.low}, #{item.close},
#{item.volume}, #{item.turnover}, #{item.openInterest}) #{item.volume}, #{item.turnover}, #{item.openInterest})
</foreach> </foreach>
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE

View File

@ -2,6 +2,7 @@ package com.yangwale.backtestify.service;
import com.yangwale.backtestify.model.dto.KLineData; import com.yangwale.backtestify.model.dto.KLineData;
import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
@ -25,4 +26,9 @@ public interface MarketDataService {
* 验证合约是否存在 * 验证合约是否存在
*/ */
void validateContract(String contractCode); void validateContract(String contractCode);
/**
* 获取合约最小变动价位
*/
BigDecimal getPriceTick(String contractCode);
} }

View File

@ -9,6 +9,7 @@ import com.yangwale.backtestify.entity.StrategyResult;
import com.yangwale.backtestify.entity.TradeDetail; import com.yangwale.backtestify.entity.TradeDetail;
import com.yangwale.backtestify.enums.Direction; import com.yangwale.backtestify.enums.Direction;
import com.yangwale.backtestify.enums.KLinePeriod; import com.yangwale.backtestify.enums.KLinePeriod;
import com.yangwale.backtestify.enums.StopUnit;
import com.yangwale.backtestify.enums.TradeAction; import com.yangwale.backtestify.enums.TradeAction;
import com.yangwale.backtestify.mapper.StrategyConfigMapper; import com.yangwale.backtestify.mapper.StrategyConfigMapper;
import com.yangwale.backtestify.mapper.StrategyResultMapper; import com.yangwale.backtestify.mapper.StrategyResultMapper;
@ -68,6 +69,7 @@ public class BacktestEngineImpl implements BacktestEngine {
Direction direction = Direction.of(request.getDirection()); Direction direction = Direction.of(request.getDirection());
KLinePeriod period = KLinePeriod.of(request.getKlinePeriod()); KLinePeriod period = KLinePeriod.of(request.getKlinePeriod());
List<SignalStrategy> strategies = signalStrategyFactory.get(request.getIndicators()); List<SignalStrategy> strategies = signalStrategyFactory.get(request.getIndicators());
BigDecimal priceTick = resolvePriceTick(request);
// 2. 计算回测时间范围 // 2. 计算回测时间范围
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
@ -82,16 +84,17 @@ public class BacktestEngineImpl implements BacktestEngine {
log.info("回测区间: {} ~ {}, K线数量: {}", kLines.getFirst().getTime(), kLines.getLast().getTime(), kLines.size()); log.info("回测区间: {} ~ {}, K线数量: {}", kLines.getFirst().getTime(), kLines.getLast().getTime(), kLines.size());
// 4. 创建回测上下文 // 4. 创建回测上下文
BacktestContext ctx = new BacktestContext(initialCapital, marginRatio, feeRate); BigDecimal initialCapitalAmount = money(initialCapital);
BacktestContext ctx = new BacktestContext(initialCapitalAmount, marginRatio, feeRate);
// 5. 逐根K线遍历 // 5. 逐根K线遍历
for (int i = 0; i < kLines.size(); i++) { for (int i = 0; i < kLines.size(); i++) {
KLineData kline = kLines.get(i); KLineData kline = kLines.get(i);
ctx.updateEquity();
// 5a. 检查止盈/止损 // 5a. 检查止盈/止损
if (ctx.hasPosition() && checkStopCondition(ctx, kline, request)) { if (ctx.hasPosition() && checkStopCondition(ctx, kline, request, direction, priceTick)) {
closePosition(ctx, kline, direction); closePosition(ctx, kline, direction);
ctx.recordEquity(kline, direction);
continue; continue;
} }
@ -101,27 +104,32 @@ public class BacktestEngineImpl implements BacktestEngine {
boolean allSell = strategies.stream().allMatch(s -> s.isSellSignal(kline, kLines)); boolean allSell = strategies.stream().allMatch(s -> s.isSellSignal(kline, kLines));
if (direction == Direction.LONG && allBuy) { if (direction == Direction.LONG && allBuy) {
openPosition(ctx, kline, direction); openPosition(ctx, kline, direction, request.getOpenVolume());
} else if (direction == Direction.SHORT && allSell) { } else if (direction == Direction.SHORT && allSell) {
openPosition(ctx, kline, direction); openPosition(ctx, kline, direction, request.getOpenVolume());
} }
} }
ctx.recordEquity(kline, direction);
} }
// 6. 遍历结束强制平仓 // 6. 遍历结束强制平仓
if (ctx.hasPosition()) { if (ctx.hasPosition()) {
KLineData lastKline = kLines.getLast(); KLineData lastKline = kLines.getLast();
closePosition(ctx, lastKline, direction); closePosition(ctx, lastKline, direction);
ctx.recordEquity(lastKline, direction);
} }
ctx.calculateDailyYields();
// 7. 计算指标 // 7. 计算指标
LocalDate startDate = kLines.getFirst().getTime().toLocalDate(); LocalDate startDate = kLines.getFirst().getTime().toLocalDate();
LocalDate endDate = kLines.getLast().getTime().toLocalDate(); LocalDate endDate = kLines.getLast().getTime().toLocalDate();
long totalDays = ChronoUnit.DAYS.between(startDate, endDate) + 1; long totalDays = ChronoUnit.DAYS.between(startDate, endDate) + 1;
BigDecimal finalCapital = ctx.getTotalEquity(); BigDecimal finalCapital = money(ctx.getTotalEquity());
BigDecimal totalYield = calcTotalYield(finalCapital); BigDecimal maxEquity = money(ctx.maxEquity);
BigDecimal profitAmount = finalCapital.subtract(initialCapital); BigDecimal minEquity = money(ctx.minEquity);
BigDecimal totalYield = calcTotalYield(finalCapital, initialCapitalAmount);
BigDecimal profitAmount = money(finalCapital.subtract(initialCapitalAmount));
BigDecimal annualizedYield = calcAnnualizedYield(totalYield, totalDays); BigDecimal annualizedYield = calcAnnualizedYield(totalYield, totalDays);
BigDecimal maxDrawdown = calcMaxDrawdown(ctx); BigDecimal maxDrawdown = calcMaxDrawdown(ctx);
BigDecimal sharpeRatio = calcSharpeRatio(ctx.dailyYields); BigDecimal sharpeRatio = calcSharpeRatio(ctx.dailyYields);
@ -129,24 +137,23 @@ public class BacktestEngineImpl implements BacktestEngine {
// 8. 持久化 // 8. 持久化
StrategyConfig config = saveStrategyConfig(request); StrategyConfig config = saveStrategyConfig(request);
saveStrategyResult(config.getId(), initialCapital, finalCapital, saveStrategyResult(config.getId(), initialCapitalAmount, finalCapital,
ctx.maxEquity, ctx.minEquity, totalYield, profitAmount, annualizedYield, maxEquity, minEquity, totalYield, profitAmount, annualizedYield,
ctx.tradeRecords.size(), maxDrawdown, sharpeRatio, winRate, ctx.tradeRecords.size(), maxDrawdown, sharpeRatio, winRate,
startDate, endDate, ctx.dailyEquityCurve); startDate, endDate, ctx.dailyEquityCurve);
List<TradeDetail> tradeDetails = saveTradeDetails(config.getId(), ctx.tradeRecords); List<TradeDetail> tradeDetails = saveTradeDetails(config.getId(), ctx.tradeRecords);
// 9. 构建响应 // 9. 构建响应
return buildResponse(request, config.getId(), initialCapital, finalCapital, return buildResponse(request, config.getId(), initialCapitalAmount, finalCapital,
ctx.maxEquity, ctx.minEquity, totalYield, profitAmount, annualizedYield, maxEquity, minEquity, totalYield, profitAmount, annualizedYield,
ctx.tradeRecords.size(), maxDrawdown, sharpeRatio, winRate, ctx.tradeRecords.size(), maxDrawdown, sharpeRatio, winRate,
startDate, endDate, ctx, tradeDetails); startDate, endDate, ctx, tradeDetails);
} }
// ==================== 交易操作 ==================== // ==================== 交易操作 ====================
private void openPosition(BacktestContext ctx, KLineData kline, Direction direction) { private void openPosition(BacktestContext ctx, KLineData kline, Direction direction, int volume) {
BigDecimal price = kline.getClose(); BigDecimal price = kline.getClose();
int volume = 1; // 简化每次开仓1手
BigDecimal turnover = price.multiply(BigDecimal.valueOf(volume)); BigDecimal turnover = price.multiply(BigDecimal.valueOf(volume));
BigDecimal margin = turnover.multiply(marginRatio); BigDecimal margin = turnover.multiply(marginRatio);
BigDecimal fee = turnover.multiply(feeRate); BigDecimal fee = turnover.multiply(feeRate);
@ -160,10 +167,11 @@ public class BacktestEngineImpl implements BacktestEngine {
ctx.position = volume; ctx.position = volume;
ctx.avgCostPrice = price; ctx.avgCostPrice = price;
ctx.marginLocked = margin; ctx.marginLocked = margin;
ctx.openFee = fee;
TradeAction action = direction == Direction.LONG ? TradeAction.BUY_OPEN : TradeAction.SELL_OPEN; TradeAction action = direction == Direction.LONG ? TradeAction.BUY_OPEN : TradeAction.SELL_OPEN;
String signal = direction == Direction.LONG ? "B" : "S"; String signal = direction == Direction.LONG ? "B" : "S";
ctx.addRecord(action, price, volume, turnover, kline.getTime(), signal); ctx.addRecord(action, price, volume, turnover, kline.getTime(), signal, null);
log.debug("开仓: {} {}手 @ {}, 保证金={}, 手续费={}", action.getLabel(), volume, price, margin, fee); log.debug("开仓: {} {}手 @ {}, 保证金={}, 手续费={}", action.getLabel(), volume, price, margin, fee);
} }
@ -178,61 +186,95 @@ public class BacktestEngineImpl implements BacktestEngine {
if (direction == Direction.SHORT) { if (direction == Direction.SHORT) {
profit = BigDecimal.ZERO.subtract(profit); // 做空盈亏反向 profit = BigDecimal.ZERO.subtract(profit); // 做空盈亏反向
} }
BigDecimal netProfit = profit.subtract(ctx.openFee).subtract(fee);
ctx.availableCapital = ctx.availableCapital.add(ctx.marginLocked).add(profit).subtract(fee); ctx.availableCapital = ctx.availableCapital.add(ctx.marginLocked).add(profit).subtract(fee);
ctx.position = 0; ctx.position = 0;
ctx.marginLocked = BigDecimal.ZERO; ctx.marginLocked = BigDecimal.ZERO;
ctx.avgCostPrice = BigDecimal.ZERO; ctx.avgCostPrice = BigDecimal.ZERO;
ctx.openFee = BigDecimal.ZERO;
TradeAction action = direction == Direction.LONG ? TradeAction.SELL_CLOSE : TradeAction.BUY_CLOSE; TradeAction action = direction == Direction.LONG ? TradeAction.SELL_CLOSE : TradeAction.BUY_CLOSE;
String signal = direction == Direction.LONG ? "S" : "B"; String signal = direction == Direction.LONG ? "S" : "B";
ctx.addRecord(action, price, volume, turnover, kline.getTime(), signal); ctx.addRecord(action, price, volume, turnover, kline.getTime(), signal, netProfit);
log.debug("平仓: {} {}手 @ {}, 盈亏={}, 手续费={}", action.getLabel(), volume, price, profit, fee); log.debug("平仓: {} {}手 @ {}, 盈亏={}, 手续费={}", action.getLabel(), volume, price, profit, fee);
} }
// ==================== 止盈止损检查 ==================== // ==================== 止盈止损检查 ====================
private boolean checkStopCondition(BacktestContext ctx, KLineData kline, BacktestRequest request) { private boolean checkStopCondition(BacktestContext ctx, KLineData kline, BacktestRequest request,
Direction direction, BigDecimal priceTick) {
BigDecimal price = kline.getClose(); BigDecimal price = kline.getClose();
BigDecimal cost = ctx.avgCostPrice; BigDecimal cost = ctx.avgCostPrice;
if (request.getStopLossValue() != null) { if (request.getStopLossValue() != null) {
if ("PERCENT".equalsIgnoreCase(request.getStopLossUnit())) { BigDecimal stopDistance = calculateStopDistance(
BigDecimal stopPct = request.getStopLossValue().divide(BigDecimal.valueOf(100), 6, RoundingMode.HALF_UP); cost, request.getStopLossValue(), StopUnit.of(request.getStopLossUnit()), priceTick);
BigDecimal lossRatio = BigDecimal.ONE.subtract(stopPct); BigDecimal stopPrice = direction == Direction.LONG
// 做多止损价 = 成本价 × (1 - 止损%) ? cost.subtract(stopDistance)
// 做空止损价 = 成本价 × (1 + 止损%) : cost.add(stopDistance);
BigDecimal stopPrice = cost.multiply(lossRatio); boolean triggered = direction == Direction.LONG
if (price.compareTo(stopPrice) <= 0) { ? price.compareTo(stopPrice) <= 0
log.info("触发止损: 价格={}, 止损价={}", price, stopPrice); : price.compareTo(stopPrice) >= 0;
return true; if (triggered) {
} log.info("触发止损: 价格={}, 止损价={}", price, stopPrice);
return true;
} }
} }
if (request.getTakeProfitValue() != null) { if (request.getTakeProfitValue() != null) {
if ("PERCENT".equalsIgnoreCase(request.getTakeProfitUnit())) { BigDecimal takeProfitDistance = calculateStopDistance(
BigDecimal tpPct = request.getTakeProfitValue().divide(BigDecimal.valueOf(100), 6, RoundingMode.HALF_UP); cost, request.getTakeProfitValue(), StopUnit.of(request.getTakeProfitUnit()), priceTick);
BigDecimal gainRatio = BigDecimal.ONE.add(tpPct); BigDecimal takeProfitPrice = direction == Direction.LONG
BigDecimal tpPrice = cost.multiply(gainRatio); ? cost.add(takeProfitDistance)
if (price.compareTo(tpPrice) >= 0) { : cost.subtract(takeProfitDistance);
log.info("触发止盈: 价格={}, 止盈价={}", price, tpPrice); boolean triggered = direction == Direction.LONG
return true; ? price.compareTo(takeProfitPrice) >= 0
} : price.compareTo(takeProfitPrice) <= 0;
if (triggered) {
log.info("触发止盈: 价格={}, 止盈价={}", price, takeProfitPrice);
return true;
} }
} }
return false; return false;
} }
private BigDecimal resolvePriceTick(BacktestRequest request) {
boolean tickStopLoss = request.getStopLossValue() != null
&& StopUnit.TICK == StopUnit.of(request.getStopLossUnit());
boolean tickTakeProfit = request.getTakeProfitValue() != null
&& StopUnit.TICK == StopUnit.of(request.getTakeProfitUnit());
return tickStopLoss || tickTakeProfit
? marketDataService.getPriceTick(request.getContractCode())
: null;
}
private BigDecimal calculateStopDistance(BigDecimal cost, BigDecimal value,
StopUnit unit, BigDecimal priceTick) {
if (unit == StopUnit.TICK) {
if (priceTick == null || priceTick.signum() <= 0) {
throw new BusinessException(ErrorCode.MARKET_DATA_UNAVAILABLE,
"TICK止盈止损缺少有效的最小变动价位");
}
return value.multiply(priceTick);
}
BigDecimal percentage = value.divide(BigDecimal.valueOf(100), 8, RoundingMode.HALF_UP);
return cost.multiply(percentage);
}
// ==================== 指标计算 ==================== // ==================== 指标计算 ====================
private BigDecimal calcTotalYield(BigDecimal finalCapital) { private BigDecimal calcTotalYield(BigDecimal finalCapital, BigDecimal initialCapitalAmount) {
return finalCapital.subtract(initialCapital) return finalCapital.subtract(initialCapitalAmount)
.divide(initialCapital, 8, RoundingMode.HALF_UP) .divide(initialCapitalAmount, 8, RoundingMode.HALF_UP)
.multiply(BigDecimal.valueOf(100)) .multiply(BigDecimal.valueOf(100))
.setScale(4, RoundingMode.HALF_UP); .setScale(4, RoundingMode.HALF_UP);
} }
private BigDecimal money(BigDecimal value) {
return value.setScale(2, RoundingMode.HALF_UP);
}
private BigDecimal calcAnnualizedYield(BigDecimal totalYield, long totalDays) { private BigDecimal calcAnnualizedYield(BigDecimal totalYield, long totalDays) {
if (totalDays <= 0) return BigDecimal.ZERO; if (totalDays <= 0) return BigDecimal.ZERO;
return totalYield.divide(BigDecimal.valueOf(totalDays), 8, RoundingMode.HALF_UP) return totalYield.divide(BigDecimal.valueOf(totalDays), 8, RoundingMode.HALF_UP)
@ -241,12 +283,21 @@ public class BacktestEngineImpl implements BacktestEngine {
} }
private BigDecimal calcMaxDrawdown(BacktestContext ctx) { private BigDecimal calcMaxDrawdown(BacktestContext ctx) {
if (ctx.maxEquity.compareTo(BigDecimal.ZERO) == 0) return BigDecimal.ZERO; BigDecimal peak = BigDecimal.ZERO;
BigDecimal minAfterMax = ctx.minEquity; BigDecimal maximum = BigDecimal.ZERO;
return ctx.maxEquity.subtract(minAfterMax) for (BigDecimal equity : ctx.equityHistory) {
.divide(ctx.maxEquity, 8, RoundingMode.HALF_UP) if (equity.compareTo(peak) > 0) {
.multiply(BigDecimal.valueOf(100)) peak = equity;
.abs() }
if (peak.compareTo(BigDecimal.ZERO) > 0) {
BigDecimal drawdown = peak.subtract(equity)
.divide(peak, 8, RoundingMode.HALF_UP);
if (drawdown.compareTo(maximum) > 0) {
maximum = drawdown;
}
}
}
return maximum.multiply(BigDecimal.valueOf(100))
.setScale(4, RoundingMode.HALF_UP); .setScale(4, RoundingMode.HALF_UP);
} }
@ -274,18 +325,11 @@ public class BacktestEngineImpl implements BacktestEngine {
if (records.isEmpty()) return BigDecimal.ZERO; if (records.isEmpty()) return BigDecimal.ZERO;
long winCount = 0; long winCount = 0;
long totalClose = 0; long totalClose = 0;
for (int i = 0; i < records.size(); i++) { for (BacktestContext.TradeRecord r : records) {
BacktestContext.TradeRecord r = records.get(i);
if (r.action == TradeAction.SELL_CLOSE || r.action == TradeAction.BUY_CLOSE) { if (r.action == TradeAction.SELL_CLOSE || r.action == TradeAction.BUY_CLOSE) {
totalClose++; totalClose++;
// 寻找对应的开仓记录计算盈亏 if (r.netProfit != null && r.netProfit.compareTo(BigDecimal.ZERO) > 0) {
for (int j = i - 1; j >= 0; j--) { winCount++;
BacktestContext.TradeRecord open = records.get(j);
if ((r.action == TradeAction.SELL_CLOSE && open.action == TradeAction.BUY_OPEN)
|| (r.action == TradeAction.BUY_CLOSE && open.action == TradeAction.SELL_OPEN)) {
if (r.turnover.compareTo(open.turnover) > 0) winCount++;
break;
}
} }
} }
} }
@ -456,20 +500,23 @@ public class BacktestEngineImpl implements BacktestEngine {
int position = 0; int position = 0;
BigDecimal avgCostPrice = BigDecimal.ZERO; BigDecimal avgCostPrice = BigDecimal.ZERO;
BigDecimal marginLocked = BigDecimal.ZERO; BigDecimal marginLocked = BigDecimal.ZERO;
BigDecimal openFee = BigDecimal.ZERO;
BigDecimal initialCapital;
BigDecimal maxEquity; BigDecimal maxEquity;
BigDecimal minEquity; BigDecimal minEquity;
List<TradeRecord> tradeRecords = new ArrayList<>(); List<TradeRecord> tradeRecords = new ArrayList<>();
List<DailyEquity> dailyEquityCurve = new ArrayList<>(); List<DailyEquity> dailyEquityCurve = new ArrayList<>();
List<BigDecimal> dailyYields = new ArrayList<>(); List<BigDecimal> dailyYields = new ArrayList<>();
BigDecimal dayStartEquity; List<BigDecimal> equityHistory = new ArrayList<>();
BacktestContext(BigDecimal capital, BigDecimal marginRatio, BigDecimal feeRate) { BacktestContext(BigDecimal capital, BigDecimal marginRatio, BigDecimal feeRate) {
this.initialCapital = capital;
this.availableCapital = capital; this.availableCapital = capital;
this.marginRatio = marginRatio; this.marginRatio = marginRatio;
this.feeRate = feeRate; this.feeRate = feeRate;
this.maxEquity = capital; this.maxEquity = capital;
this.minEquity = capital; this.minEquity = capital;
this.dayStartEquity = capital; this.equityHistory.add(capital);
} }
boolean hasPosition() { boolean hasPosition() {
@ -480,15 +527,52 @@ public class BacktestEngineImpl implements BacktestEngine {
return availableCapital.add(marginLocked); return availableCapital.add(marginLocked);
} }
void updateEquity() { void recordEquity(KLineData kline, Direction direction) {
BigDecimal equity = getTotalEquity(); BigDecimal equity = getTotalEquity();
if (hasPosition()) {
BigDecimal unrealized = kline.getClose().subtract(avgCostPrice)
.multiply(BigDecimal.valueOf(position));
if (direction == Direction.SHORT) {
unrealized = unrealized.negate();
}
equity = equity.add(unrealized);
}
equityHistory.add(equity);
if (equity.compareTo(maxEquity) > 0) maxEquity = equity; if (equity.compareTo(maxEquity) > 0) maxEquity = equity;
if (equity.compareTo(minEquity) < 0) minEquity = equity; if (equity.compareTo(minEquity) < 0) minEquity = equity;
BigDecimal displayEquity = equity.setScale(2, RoundingMode.HALF_UP);
BigDecimal cumulativeYield = displayEquity.subtract(initialCapital)
.divide(initialCapital, 8, RoundingMode.HALF_UP)
.multiply(BigDecimal.valueOf(100))
.setScale(4, RoundingMode.HALF_UP);
DailyEquity point = new DailyEquity(
kline.getTime().toLocalDate(), displayEquity, cumulativeYield);
if (!dailyEquityCurve.isEmpty()
&& dailyEquityCurve.getLast().date.equals(point.date)) {
dailyEquityCurve.set(dailyEquityCurve.size() - 1, point);
} else {
dailyEquityCurve.add(point);
}
}
void calculateDailyYields() {
dailyYields.clear();
BigDecimal previousEquity = initialCapital;
for (DailyEquity point : dailyEquityCurve) {
BigDecimal dailyYield = previousEquity.compareTo(BigDecimal.ZERO) == 0
? BigDecimal.ZERO
: point.equity.subtract(previousEquity)
.divide(previousEquity, 8, RoundingMode.HALF_UP);
dailyYields.add(dailyYield);
previousEquity = point.equity;
}
} }
void addRecord(TradeAction action, BigDecimal price, int volume, BigDecimal turnover, void addRecord(TradeAction action, BigDecimal price, int volume, BigDecimal turnover,
LocalDateTime klineTime, String signal) { LocalDateTime klineTime, String signal, BigDecimal netProfit) {
tradeRecords.add(new TradeRecord(action, price, volume, turnover, LocalDateTime.now(), klineTime, signal)); tradeRecords.add(new TradeRecord(
action, price, volume, turnover, klineTime, klineTime, signal, netProfit));
} }
static class TradeRecord { static class TradeRecord {
@ -499,9 +583,11 @@ public class BacktestEngineImpl implements BacktestEngine {
LocalDateTime tradeTime; LocalDateTime tradeTime;
LocalDateTime klineTime; LocalDateTime klineTime;
String signal; String signal;
BigDecimal netProfit;
TradeRecord(TradeAction action, BigDecimal price, int volume, BigDecimal turnover, TradeRecord(TradeAction action, BigDecimal price, int volume, BigDecimal turnover,
LocalDateTime tradeTime, LocalDateTime klineTime, String signal) { LocalDateTime tradeTime, LocalDateTime klineTime, String signal,
BigDecimal netProfit) {
this.action = action; this.action = action;
this.price = price; this.price = price;
this.volume = volume; this.volume = volume;
@ -509,6 +595,7 @@ public class BacktestEngineImpl implements BacktestEngine {
this.tradeTime = tradeTime; this.tradeTime = tradeTime;
this.klineTime = klineTime; this.klineTime = klineTime;
this.signal = signal; this.signal = signal;
this.netProfit = netProfit;
} }
} }

View File

@ -11,6 +11,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.Duration; import java.time.Duration;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId; import java.time.ZoneId;
@ -76,6 +77,17 @@ public class MarketDataServiceImpl implements MarketDataService {
} }
} }
@Override
public BigDecimal getPriceTick(String contractCode) {
validateContract(contractCode);
BigDecimal priceTick = marketDataProvider.getPriceTick(contractCode);
if (priceTick == null || priceTick.signum() <= 0) {
throw new BusinessException(ErrorCode.MARKET_DATA_UNAVAILABLE,
"合约缺少有效的最小变动价位: " + contractCode);
}
return priceTick;
}
private String buildCacheKey(String contractCode, String period, private String buildCacheKey(String contractCode, String period,
LocalDateTime startTime, LocalDateTime endTime) { LocalDateTime startTime, LocalDateTime endTime) {
long startEpoch = startTime.atZone(ZONE_ID).toEpochSecond(); long startEpoch = startTime.atZone(ZONE_ID).toEpochSecond();

View File

@ -59,6 +59,11 @@ public class FakeMarketDataProvider implements MarketDataProvider {
return CONTRACT.equals(contractCode); return CONTRACT.equals(contractCode);
} }
@Override
public BigDecimal getPriceTick(String contractCode) {
return supportsContract(contractCode) ? BigDecimal.ONE : null;
}
// ==================== 数据生成 ==================== // ==================== 数据生成 ====================
/** /**

View File

@ -2,6 +2,7 @@ package com.yangwale.backtestify.service.market;
import com.yangwale.backtestify.model.dto.KLineData; import com.yangwale.backtestify.model.dto.KLineData;
import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
@ -33,4 +34,9 @@ public interface MarketDataProvider {
* 检查是否支持该合约 * 检查是否支持该合约
*/ */
boolean supportsContract(String contractCode); boolean supportsContract(String contractCode);
/**
* 获取合约最小变动价位
*/
BigDecimal getPriceTick(String contractCode);
} }

View File

@ -75,6 +75,12 @@ public class MysqlMarketDataProvider implements MarketDataProvider {
return findInstrument(contractCode) != null; return findInstrument(contractCode) != null;
} }
@Override
public BigDecimal getPriceTick(String contractCode) {
InstrumentDictionary instrument = findInstrument(contractCode);
return instrument == null ? null : instrument.getPriceTick();
}
private InstrumentDictionary findInstrument(String contractCode) { private InstrumentDictionary findInstrument(String contractCode) {
return instrumentDictionaryMapper.selectOne(new LambdaQueryWrapper<InstrumentDictionary>() return instrumentDictionaryMapper.selectOne(new LambdaQueryWrapper<InstrumentDictionary>()
.eq(InstrumentDictionary::getContractCode, contractCode) .eq(InstrumentDictionary::getContractCode, contractCode)
@ -84,7 +90,7 @@ public class MysqlMarketDataProvider implements MarketDataProvider {
private KLineData toKLineData(KLineRecord record, int priceScale) { private KLineData toKLineData(KLineRecord record, int priceScale) {
return KLineData.builder() return KLineData.builder()
.time(LocalDateTime.ofInstant(Instant.ofEpochSecond(record.getTimestamp()), ZONE_ID)) .time(LocalDateTime.ofInstant(Instant.ofEpochSecond(record.getKTime()), ZONE_ID))
.open(PriceScaleConverter.toRaw(record.getOpen(), priceScale)) .open(PriceScaleConverter.toRaw(record.getOpen(), priceScale))
.high(PriceScaleConverter.toRaw(record.getHigh(), priceScale)) .high(PriceScaleConverter.toRaw(record.getHigh(), priceScale))
.low(PriceScaleConverter.toRaw(record.getLow(), priceScale)) .low(PriceScaleConverter.toRaw(record.getLow(), priceScale))

View File

@ -1,7 +1,7 @@
package com.yangwale.backtestify.service.market.client; package com.yangwale.backtestify.service.market.client;
import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.TypeReference; import com.alibaba.fastjson2.JSONObject;
import com.yangwale.backtestify.common.BusinessException; import com.yangwale.backtestify.common.BusinessException;
import com.yangwale.backtestify.common.ErrorCode; import com.yangwale.backtestify.common.ErrorCode;
import com.yangwale.backtestify.config.MarketDataProperties; import com.yangwale.backtestify.config.MarketDataProperties;
@ -33,11 +33,10 @@ public class CnQuotationClient {
String body = get(urlBuilder("goods/list") String body = get(urlBuilder("goods/list")
.addQueryParameter("pageSize", "-1") .addQueryParameter("pageSize", "-1")
.build()); .build());
CnQuotationModels.ResultModel<CnQuotationModels.GoodsPage> result = JSON.parseObject(body, JSONObject data = parseData(body);
new TypeReference<CnQuotationModels.ResultModel<CnQuotationModels.GoodsPage>>() { return data == null || data.getJSONArray("list") == null
}); ? List.of()
CnQuotationModels.GoodsPage data = unwrap(result); : data.getJSONArray("list").toJavaList(CnQuotationModels.GoodsItem.class);
return data == null || data.list() == null ? List.of() : data.list();
} }
public List<CnQuotationModels.KChartItem> getKChart(String excode, String code, String period) { public List<CnQuotationModels.KChartItem> getKChart(String excode, String code, String period) {
@ -47,11 +46,7 @@ public class CnQuotationClient {
.addQueryParameter("code", code) .addQueryParameter("code", code)
.addQueryParameter("type", String.valueOf(type)) .addQueryParameter("type", String.valueOf(type))
.build()); .build());
CnQuotationModels.ResultModel<CnQuotationModels.KChartResult> result = JSON.parseObject(body, return parseKChartItems(body);
new TypeReference<CnQuotationModels.ResultModel<CnQuotationModels.KChartResult>>() {
});
CnQuotationModels.KChartResult data = unwrap(result);
return data == null || data.chats() == null ? List.of() : data.chats();
} }
public List<CnQuotationModels.KChartItem> getKChartByDate(String excode, String code, String period, public List<CnQuotationModels.KChartItem> getKChartByDate(String excode, String code, String period,
@ -64,11 +59,7 @@ public class CnQuotationClient {
.addQueryParameter("date", String.valueOf(date)) .addQueryParameter("date", String.valueOf(date))
.addQueryParameter("direction", direction) .addQueryParameter("direction", direction)
.build()); .build());
CnQuotationModels.ResultModel<CnQuotationModels.KChartResult> result = JSON.parseObject(body, return parseKChartItems(body);
new TypeReference<CnQuotationModels.ResultModel<CnQuotationModels.KChartResult>>() {
});
CnQuotationModels.KChartResult data = unwrap(result);
return data == null || data.chats() == null ? List.of() : data.chats();
} }
public Integer typeOf(String period) { public Integer typeOf(String period) {
@ -86,15 +77,25 @@ public class CnQuotationClient {
}; };
} }
private <T> T unwrap(CnQuotationModels.ResultModel<T> result) { private List<CnQuotationModels.KChartItem> parseKChartItems(String body) {
JSONObject data = parseData(body);
return data == null || data.getJSONArray("chats") == null
? List.of()
: data.getJSONArray("chats").toJavaList(CnQuotationModels.KChartItem.class);
}
private JSONObject parseData(String body) {
JSONObject result = JSON.parseObject(body);
if (result == null) { if (result == null) {
throw new BusinessException(ErrorCode.MARKET_DATA_UNAVAILABLE, "行情接口返回为空"); throw new BusinessException(ErrorCode.MARKET_DATA_UNAVAILABLE, "行情接口返回为空");
} }
if (!Boolean.TRUE.equals(result.success())) { if (!Boolean.TRUE.equals(result.getBoolean("success"))) {
throw new BusinessException(ErrorCode.MARKET_DATA_UNAVAILABLE, throw new BusinessException(ErrorCode.MARKET_DATA_UNAVAILABLE,
result.errorInfo() != null ? result.errorInfo() : "行情接口调用失败: " + result.errorCode()); result.getString("errorInfo") != null
? result.getString("errorInfo")
: "行情接口调用失败: " + result.getString("errorCode"));
} }
return result.data(); return result.getJSONObject("data");
} }
private HttpUrl.Builder urlBuilder(String path) { private HttpUrl.Builder urlBuilder(String path) {

View File

@ -19,8 +19,7 @@ public class CnQuotationModels {
String productId, String productId,
String goodsName, String goodsName,
String mainContractCode, String mainContractCode,
Integer isPrincipal, Integer isPrincipal) {
Integer decimalPrecision) {
} }
public record KChartResult(List<KChartItem> chats) { public record KChartResult(List<KChartItem> chats) {

View File

@ -33,16 +33,8 @@ public final class PriceScaleConverter {
if (value == null || value.isBlank() || "-".equals(value.trim())) { if (value == null || value.isBlank() || "-".equals(value.trim())) {
return 0L; return 0L;
} }
return new BigDecimal(value.trim()).setScale(0, RoundingMode.HALF_UP).longValue(); long parsed = new BigDecimal(value.trim()).setScale(0, RoundingMode.HALF_UP).longValue();
} return Math.max(parsed, 0L);
public static int scaleFromPrecision(Integer decimalPrecision) {
int precision = decimalPrecision == null ? 0 : Math.max(decimalPrecision, 0);
int scale = 1;
for (int i = 0; i < precision; i++) {
scale *= 10;
}
return scale;
} }
private static int scaleDigits(int priceScale) { private static int scaleDigits(int priceScale) {

View File

@ -0,0 +1,21 @@
package com.yangwale.backtestify.service.market.indicator;
/**
* 指标历史重建及增量更新入口
*/
public interface IndicatorCalculationService {
/**
* 计算指定合约周期的全部历史K线指标并批量写入
*
* @return 数据库受影响行数
*/
int rebuild(String period, Integer instrumentId);
/**
* 从指标表最新时间点之后计算新增K线只写入新增指标
*
* @return 数据库受影响行数
*/
int updateIncremental(String period, Integer instrumentId);
}

View File

@ -0,0 +1,19 @@
package com.yangwale.backtestify.service.market.indicator;
import com.yangwale.backtestify.entity.KLineIndicator;
import com.yangwale.backtestify.entity.KLineRecord;
import java.util.List;
/**
* K线技术指标计算边界
*/
public interface IndicatorCalculator {
List<KLineIndicator> calculate(List<KLineRecord> kLines, int priceScale);
List<KLineIndicator> calculateIncremental(List<KLineRecord> history,
List<KLineRecord> additions,
KLineIndicator previousIndicator,
int priceScale);
}

View File

@ -0,0 +1,275 @@
package com.yangwale.backtestify.service.market.indicator;
import com.yangwale.backtestify.entity.KLineIndicator;
import com.yangwale.backtestify.entity.KLineRecord;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/**
* MABOLLEMAMACDRSIKDJ 的统一计算器
*
* <p>价格类指标保留 6 位小数摆动类指标保留 4 位小数与指标表字段精度一致</p>
*/
@Component
public class TechnicalIndicatorCalculator implements IndicatorCalculator {
private static final MathContext MC = MathContext.DECIMAL128;
private static final BigDecimal TWO = BigDecimal.valueOf(2);
private static final BigDecimal FIFTY = BigDecimal.valueOf(50);
private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
@Override
public List<KLineIndicator> calculate(List<KLineRecord> kLines, int priceScale) {
validate(kLines, priceScale);
return calculateRows(List.of(), kLines, new State(), priceScale);
}
@Override
public List<KLineIndicator> calculateIncremental(List<KLineRecord> history,
List<KLineRecord> additions,
KLineIndicator previousIndicator,
int priceScale) {
validate(history, priceScale);
validate(additions, priceScale);
if (additions.isEmpty()) {
return List.of();
}
if (previousIndicator == null) {
List<KLineRecord> all = new ArrayList<>(history.size() + additions.size());
all.addAll(history);
all.addAll(additions);
List<KLineIndicator> calculated = calculate(all, priceScale);
return List.copyOf(calculated.subList(calculated.size() - additions.size(), calculated.size()));
}
State state = State.from(previousIndicator);
return calculateRows(history, additions, state, priceScale);
}
private List<KLineIndicator> calculateRows(List<KLineRecord> history,
List<KLineRecord> rows,
State state,
int priceScale) {
List<PriceBar> window = new ArrayList<>(history.size() + rows.size());
for (KLineRecord record : history) {
window.add(toPriceBar(record, priceScale));
}
List<KLineIndicator> result = new ArrayList<>(rows.size());
long createAt = Instant.now().getEpochSecond();
for (KLineRecord record : rows) {
PriceBar current = toPriceBar(record, priceScale);
window.add(current);
BigDecimal ma5 = movingAverage(window, 5);
BigDecimal ma10 = movingAverage(window, 10);
BigDecimal ma20 = movingAverage(window, 20);
BigDecimal ma60 = movingAverage(window, 60);
BigDecimal bollMb = null;
BigDecimal bollUp = null;
BigDecimal bollDn = null;
if (window.size() >= 20) {
bollMb = price(movingAverageRaw(window, 20));
BigDecimal standardDeviation = standardDeviation(window, 20);
bollUp = price(bollMb.add(standardDeviation.multiply(TWO, MC), MC));
bollDn = price(bollMb.subtract(standardDeviation.multiply(TWO, MC), MC));
}
state.ema6 = ema(current.close(), state.ema6, 6);
state.ema12 = ema(current.close(), state.ema12, 12);
state.ema20 = ema(current.close(), state.ema20, 20);
state.ema26 = ema(current.close(), state.ema26, 26);
BigDecimal dif = state.ema12.subtract(state.ema26, MC);
state.dea = state.dea == null
? dif
: state.dea.multiply(BigDecimal.valueOf(0.8), MC)
.add(dif.multiply(BigDecimal.valueOf(0.2), MC), MC);
BigDecimal macdBar = price(dif.subtract(state.dea, MC).multiply(TWO, MC));
BigDecimal rsi6 = rsi(window, 6);
BigDecimal rsi12 = rsi(window, 12);
BigDecimal rsv = rsv(window, 9);
state.k = state.k.multiply(BigDecimal.valueOf(2), MC)
.add(rsv, MC).divide(BigDecimal.valueOf(3), MC);
state.d = state.d.multiply(BigDecimal.valueOf(2), MC)
.add(state.k, MC).divide(BigDecimal.valueOf(3), MC);
BigDecimal j = oscillator(state.k.multiply(BigDecimal.valueOf(3), MC)
.subtract(state.d.multiply(BigDecimal.valueOf(2), MC), MC));
result.add(KLineIndicator.builder()
.instrumentId(record.getInstrumentId())
.kTime(record.getKTime())
.ma5(ma5)
.ma10(ma10)
.ma20(ma20)
.ma60(ma60)
.bollMb(bollMb)
.bollUp(bollUp)
.bollDn(bollDn)
.ema6(price(state.ema6))
.ema12(price(state.ema12))
.ema20(price(state.ema20))
.macdDif(price(dif))
.macdDea(price(state.dea))
.macdBar(macdBar)
.rsi6(rsi6)
.rsi12(rsi12)
.kdjK(oscillator(state.k))
.kdjD(oscillator(state.d))
.kdjJ(j)
.createAt(createAt)
.build());
}
return result;
}
private BigDecimal movingAverage(List<PriceBar> values, int period) {
return values.size() < period ? null : price(movingAverageRaw(values, period));
}
private BigDecimal movingAverageRaw(List<PriceBar> values, int period) {
BigDecimal sum = BigDecimal.ZERO;
for (int i = values.size() - period; i < values.size(); i++) {
sum = sum.add(values.get(i).close(), MC);
}
return sum.divide(BigDecimal.valueOf(period), MC);
}
private BigDecimal standardDeviation(List<PriceBar> values, int period) {
BigDecimal mean = movingAverageRaw(values, period);
BigDecimal squareSum = BigDecimal.ZERO;
for (int i = values.size() - period; i < values.size(); i++) {
BigDecimal difference = values.get(i).close().subtract(mean, MC);
squareSum = squareSum.add(difference.multiply(difference, MC), MC);
}
return squareSum.divide(BigDecimal.valueOf(period), MC).sqrt(MC);
}
private BigDecimal ema(BigDecimal close, BigDecimal previous, int period) {
if (previous == null) {
return close;
}
BigDecimal multiplier = TWO.divide(BigDecimal.valueOf(period + 1L), MC);
return close.subtract(previous, MC).multiply(multiplier, MC).add(previous, MC);
}
private BigDecimal rsi(List<PriceBar> values, int period) {
if (values.size() <= period) {
return null;
}
BigDecimal gains = BigDecimal.ZERO;
BigDecimal losses = BigDecimal.ZERO;
int start = values.size() - period;
for (int i = start; i < values.size(); i++) {
BigDecimal change = values.get(i).close().subtract(values.get(i - 1).close(), MC);
if (change.signum() > 0) {
gains = gains.add(change, MC);
} else {
losses = losses.add(change.abs(), MC);
}
}
if (gains.signum() == 0 && losses.signum() == 0) {
return oscillator(FIFTY);
}
if (losses.signum() == 0) {
return oscillator(HUNDRED);
}
return oscillator(gains.divide(gains.add(losses, MC), MC).multiply(HUNDRED, MC));
}
private BigDecimal rsv(List<PriceBar> values, int period) {
int start = Math.max(0, values.size() - period);
BigDecimal highest = values.get(start).high();
BigDecimal lowest = values.get(start).low();
for (int i = start + 1; i < values.size(); i++) {
highest = highest.max(values.get(i).high());
lowest = lowest.min(values.get(i).low());
}
BigDecimal range = highest.subtract(lowest, MC);
if (range.signum() == 0) {
return FIFTY;
}
return values.getLast().close().subtract(lowest, MC)
.divide(range, MC)
.multiply(HUNDRED, MC);
}
private PriceBar toPriceBar(KLineRecord record, int priceScale) {
BigDecimal divisor = BigDecimal.valueOf(priceScale);
return new PriceBar(
BigDecimal.valueOf(record.getHigh()).divide(divisor, MC),
BigDecimal.valueOf(record.getLow()).divide(divisor, MC),
BigDecimal.valueOf(record.getClose()).divide(divisor, MC));
}
private BigDecimal price(BigDecimal value) {
return value.setScale(6, RoundingMode.HALF_UP);
}
private BigDecimal oscillator(BigDecimal value) {
return value.setScale(4, RoundingMode.HALF_UP);
}
private void validate(List<KLineRecord> records, int priceScale) {
if (records == null) {
throw new IllegalArgumentException("K线列表不能为空");
}
if (priceScale <= 0) {
throw new IllegalArgumentException("价格缩放倍数必须大于0");
}
if (records.stream().anyMatch(record -> record == null
|| record.getKTime() == null
|| record.getHigh() == null
|| record.getLow() == null
|| record.getClose() == null)) {
throw new IllegalArgumentException("K线时间及高低收价格不能为空");
}
if (!records.equals(records.stream()
.sorted(Comparator.comparing(KLineRecord::getKTime))
.toList())) {
throw new IllegalArgumentException("K线必须按时间升序排列");
}
}
private record PriceBar(BigDecimal high, BigDecimal low, BigDecimal close) {
}
private static final class State {
private BigDecimal ema6;
private BigDecimal ema12;
private BigDecimal ema20;
private BigDecimal ema26;
private BigDecimal dea;
private BigDecimal k = oscillatorValue(FIFTY);
private BigDecimal d = oscillatorValue(FIFTY);
private static State from(KLineIndicator previous) {
State state = new State();
state.ema6 = previous.getEma6();
state.ema12 = previous.getEma12();
state.ema20 = previous.getEma20();
state.ema26 = previous.getEma12().subtract(previous.getMacdDif(), MC)
.setScale(6, RoundingMode.HALF_UP);
state.dea = previous.getMacdDea() != null
? previous.getMacdDea()
: previous.getMacdDif();
state.k = previous.getKdjK();
state.d = previous.getKdjD();
return state;
}
private static BigDecimal oscillatorValue(BigDecimal value) {
return value.setScale(4, RoundingMode.HALF_UP);
}
}
}

View File

@ -0,0 +1,92 @@
package com.yangwale.backtestify.service.market.indicator.impl;
import com.yangwale.backtestify.common.BusinessException;
import com.yangwale.backtestify.common.ErrorCode;
import com.yangwale.backtestify.entity.InstrumentDictionary;
import com.yangwale.backtestify.entity.KLineIndicator;
import com.yangwale.backtestify.entity.KLineRecord;
import com.yangwale.backtestify.mapper.IndicatorMapper;
import com.yangwale.backtestify.mapper.InstrumentDictionaryMapper;
import com.yangwale.backtestify.mapper.KLineMapper;
import com.yangwale.backtestify.service.market.indicator.IndicatorCalculationService;
import com.yangwale.backtestify.service.market.indicator.IndicatorCalculator;
import com.yangwale.backtestify.service.market.repository.KLineTableResolver;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@Service
@RequiredArgsConstructor
public class IndicatorCalculationServiceImpl implements IndicatorCalculationService {
private static final int CONTEXT_SIZE = 60;
private static final int WRITE_BATCH_SIZE = 500;
private final InstrumentDictionaryMapper instrumentMapper;
private final KLineMapper kLineMapper;
private final IndicatorMapper indicatorMapper;
private final KLineTableResolver tableResolver;
private final IndicatorCalculator calculator;
@Override
@Transactional(rollbackFor = Exception.class)
public int rebuild(String period, Integer instrumentId) {
InstrumentDictionary instrument = requireInstrument(instrumentId);
String kLineTable = tableResolver.resolve(period);
String indicatorTable = tableResolver.resolveIndicator(period);
List<KLineRecord> kLines = kLineMapper.selectAll(kLineTable, instrumentId);
if (kLines.isEmpty()) {
return 0;
}
List<KLineIndicator> indicators = calculator.calculate(kLines, instrument.getPriceScale());
return upsertBatches(indicatorTable, indicators);
}
@Override
@Transactional(rollbackFor = Exception.class)
public int updateIncremental(String period, Integer instrumentId) {
InstrumentDictionary instrument = requireInstrument(instrumentId);
String kLineTable = tableResolver.resolve(period);
String indicatorTable = tableResolver.resolveIndicator(period);
KLineIndicator previous = indicatorMapper.selectLatest(indicatorTable, instrumentId);
if (previous == null) {
return rebuild(period, instrumentId);
}
List<KLineRecord> additions = kLineMapper.selectAfter(
kLineTable, instrumentId, previous.getKTime());
if (additions.isEmpty()) {
return 0;
}
List<KLineRecord> history = new ArrayList<>(kLineMapper.selectBefore(
kLineTable, instrumentId, additions.getFirst().getKTime(), CONTEXT_SIZE));
Collections.reverse(history);
List<KLineIndicator> indicators = calculator.calculateIncremental(
history, additions, previous, instrument.getPriceScale());
return upsertBatches(indicatorTable, indicators);
}
private InstrumentDictionary requireInstrument(Integer instrumentId) {
InstrumentDictionary instrument = instrumentMapper.selectById(instrumentId);
if (instrument == null) {
throw new BusinessException(ErrorCode.CONTRACT_NOT_FOUND,
"合约不存在, instrumentId=" + instrumentId);
}
return instrument;
}
private int upsertBatches(String tableName, List<KLineIndicator> indicators) {
int affectedRows = 0;
for (int start = 0; start < indicators.size(); start += WRITE_BATCH_SIZE) {
int end = Math.min(start + WRITE_BATCH_SIZE, indicators.size());
affectedRows += indicatorMapper.upsertBatch(
tableName, List.copyOf(indicators.subList(start, end)));
}
return affectedRows;
}
}

View File

@ -15,13 +15,15 @@ public class KLineTableResolver {
private static final Map<String, String> TABLES = Map.of( private static final Map<String, String> TABLES = Map.of(
"1m", "t_kline_1m", "1m", "t_kline_1m",
"3m", "t_kline_3m",
"5m", "t_kline_5m", "5m", "t_kline_5m",
"15m", "t_kline_15m", "15m", "t_kline_15m",
"30m", "t_kline_30m", "30m", "t_kline_30m",
"1h", "t_kline_1h", "1h", "t_kline_1h",
"4h", "t_kline_4h", "4h", "t_kline_4h",
"1d", "t_kline_1d", "1d", "t_kline_1d",
"1w", "t_kline_1w" "1w", "t_kline_1w",
"1mo", "t_kline_1mo"
); );
public String resolve(String period) { public String resolve(String period) {
@ -33,6 +35,11 @@ public class KLineTableResolver {
return tableName; return tableName;
} }
public String resolveIndicator(String period) {
String kLineTable = resolve(period);
return kLineTable.replace("t_kline_", "t_indicator_");
}
public Set<String> supportedPeriods() { public Set<String> supportedPeriods() {
return TABLES.keySet(); return TABLES.keySet();
} }

View File

@ -12,17 +12,19 @@ import com.yangwale.backtestify.mapper.MarketDataSyncLogMapper;
import com.yangwale.backtestify.service.market.client.CnQuotationClient; import com.yangwale.backtestify.service.market.client.CnQuotationClient;
import com.yangwale.backtestify.service.market.client.CnQuotationModels; import com.yangwale.backtestify.service.market.client.CnQuotationModels;
import com.yangwale.backtestify.service.market.convert.PriceScaleConverter; import com.yangwale.backtestify.service.market.convert.PriceScaleConverter;
import com.yangwale.backtestify.service.market.indicator.IndicatorCalculationService;
import com.yangwale.backtestify.service.market.repository.KLineTableResolver; import com.yangwale.backtestify.service.market.repository.KLineTableResolver;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId; import java.time.ZoneId;
import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
@ -35,12 +37,16 @@ import java.util.Objects;
@ConditionalOnProperty(prefix = "market-data", name = "provider", havingValue = "mysql") @ConditionalOnProperty(prefix = "market-data", name = "provider", havingValue = "mysql")
public class MarketDataSyncService { public class MarketDataSyncService {
private static final int DEFAULT_PRICE_SCALE = 1000;
private final MarketDataProperties properties; private final MarketDataProperties properties;
private final CnQuotationClient cnQuotationClient; private final CnQuotationClient cnQuotationClient;
private final InstrumentDictionaryMapper instrumentDictionaryMapper; private final InstrumentDictionaryMapper instrumentDictionaryMapper;
private final KLineMapper kLineMapper; private final KLineMapper kLineMapper;
private final MarketDataSyncLogMapper syncLogMapper; private final MarketDataSyncLogMapper syncLogMapper;
private final KLineTableResolver tableResolver; private final KLineTableResolver tableResolver;
private final IndicatorCalculationService indicatorCalculationService;
private final TransactionTemplate transactionTemplate;
@Scheduled(cron = "${market-data.sync.cron:0 0 6 * * ?}", zone = "${market-data.sync.zone:Asia/Shanghai}") @Scheduled(cron = "${market-data.sync.cron:0 0 6 * * ?}", zone = "${market-data.sync.zone:Asia/Shanghai}")
public void syncYesterdayMainContracts() { public void syncYesterdayMainContracts() {
@ -51,18 +57,40 @@ public class MarketDataSyncService {
syncIncrementalForMainContracts(); syncIncrementalForMainContracts();
} }
@Transactional(rollbackFor = Exception.class)
public void syncIncrementalForMainContracts() { public void syncIncrementalForMainContracts() {
refreshMainContracts(); refreshMainContracts();
ZoneId zoneId = properties.getSync().zoneId(); ZoneId zoneId = properties.getSync().zoneId();
LocalDateTime now = LocalDateTime.now(zoneId); LocalDateTime now = LocalDateTime.now(zoneId);
LocalDate syncDate = now.toLocalDate().minusDays(1); LocalDate syncDate = now.toLocalDate();
long startTimestamp = syncDate.atStartOfDay(zoneId).toEpochSecond(); long fallbackStartTimestamp = syncDate.minusDays(1).atStartOfDay(zoneId).toEpochSecond();
long endTimestamp = now.toLocalDate() long endTimestamp = now.atZone(zoneId).toEpochSecond();
.atTime(properties.getSync().getIncrementalWindowEndHour(), 0) syncMainContracts(syncDate, fallbackStartTimestamp, endTimestamp, null, false);
.atZone(zoneId) }
.toEpochSecond();
public void repairMainContractsFrom(long startTimestamp) {
refreshMainContracts();
ZoneId zoneId = properties.getSync().zoneId();
LocalDateTime now = LocalDateTime.now(zoneId);
long forcedCursor = Math.max(0, startTimestamp - 1);
syncMainContracts(now.toLocalDate(), forcedCursor, now.atZone(zoneId).toEpochSecond(),
forcedCursor, true);
}
public void repairContractFrom(String contractCode, long startTimestamp) {
List<InstrumentDictionary> matches =
instrumentDictionaryMapper.selectMainByContractCodeIgnoreCase(contractCode);
InstrumentDictionary instrument = requireSingleContract(matches, contractCode);
ZoneId zoneId = properties.getSync().zoneId();
LocalDateTime now = LocalDateTime.now(zoneId);
long forcedCursor = Math.max(0, startTimestamp - 1);
for (String period : properties.getSync().getPeriods()) {
syncOnePeriod(instrument, period, now.toLocalDate(), forcedCursor,
now.atZone(zoneId).toEpochSecond(), forcedCursor, true);
}
}
private void syncMainContracts(LocalDate syncDate, long fallbackStartTimestamp, long endTimestamp,
Long forcedCursor, boolean rebuildIndicators) {
List<InstrumentDictionary> instruments = instrumentDictionaryMapper.selectList( List<InstrumentDictionary> instruments = instrumentDictionaryMapper.selectList(
new LambdaQueryWrapper<InstrumentDictionary>() new LambdaQueryWrapper<InstrumentDictionary>()
.eq(InstrumentDictionary::getIsDeleted, 0) .eq(InstrumentDictionary::getIsDeleted, 0)
@ -70,7 +98,8 @@ public class MarketDataSyncService {
for (InstrumentDictionary instrument : instruments) { for (InstrumentDictionary instrument : instruments) {
for (String period : properties.getSync().getPeriods()) { for (String period : properties.getSync().getPeriods()) {
syncOnePeriod(instrument, period, syncDate, startTimestamp, endTimestamp); syncOnePeriod(instrument, period, syncDate, fallbackStartTimestamp, endTimestamp,
forcedCursor, rebuildIndicators);
} }
} }
} }
@ -80,16 +109,18 @@ public class MarketDataSyncService {
int count = 0; int count = 0;
try { try {
List<CnQuotationModels.GoodsItem> goodsItems = cnQuotationClient.listMainContracts(); List<CnQuotationModels.GoodsItem> goodsItems = cnQuotationClient.listMainContracts();
instrumentDictionaryMapper.update(null, new LambdaUpdateWrapper<InstrumentDictionary>() List<CnQuotationModels.GoodsItem> mainContracts = goodsItems.stream()
.set(InstrumentDictionary::getIsMain, 0) .filter(item -> item.mainContractCode() != null && !item.mainContractCode().isBlank())
.eq(InstrumentDictionary::getIsDeleted, 0)); .toList();
for (CnQuotationModels.GoodsItem item : goodsItems) { transactionTemplate.executeWithoutResult(status -> {
if (item.mainContractCode() == null || item.mainContractCode().isBlank()) { instrumentDictionaryMapper.update(null, new LambdaUpdateWrapper<InstrumentDictionary>()
continue; .set(InstrumentDictionary::getIsMain, 0)
.eq(InstrumentDictionary::getIsDeleted, 0));
for (CnQuotationModels.GoodsItem item : mainContracts) {
upsertInstrument(item);
} }
upsertInstrument(item); });
count++; count = mainContracts.size();
}
saveLog("CONTRACT", null, null, LocalDate.now(), "SUCCESS", count, null, start); saveLog("CONTRACT", null, null, LocalDate.now(), "SUCCESS", count, null, start);
} catch (Exception e) { } catch (Exception e) {
saveLog("CONTRACT", null, null, LocalDate.now(), "FAILED", count, e.getMessage(), start); saveLog("CONTRACT", null, null, LocalDate.now(), "FAILED", count, e.getMessage(), start);
@ -98,57 +129,90 @@ public class MarketDataSyncService {
} }
private void syncOnePeriod(InstrumentDictionary instrument, String period, LocalDate syncDate, private void syncOnePeriod(InstrumentDictionary instrument, String period, LocalDate syncDate,
long startTimestamp, long endTimestamp) { long fallbackStartTimestamp, long endTimestamp,
Long forcedCursor, boolean rebuildIndicators) {
LocalDateTime start = LocalDateTime.now(); LocalDateTime start = LocalDateTime.now();
int count = 0; int count = 0;
String syncType = rebuildIndicators ? "KLINE_REPAIR" : "KLINE";
try { try {
List<CnQuotationModels.KChartItem> items = cnQuotationClient.getKChartByDate( String tableName = tableResolver.resolve(period);
instrument.getExchangeId(), instrument.getContractCode(), period, startTimestamp, "after"); Long latestTimestamp = kLineMapper.selectLatestTimestamp(tableName, instrument.getId());
List<KLineRecord> records = items.stream() long cursor = forcedCursor != null
.filter(item -> item.u() != null && item.u() >= startTimestamp && item.u() <= endTimestamp) ? forcedCursor
.map(item -> toRecord(instrument, item)) : latestTimestamp == null ? fallbackStartTimestamp : latestTimestamp;
.filter(Objects::nonNull) while (cursor < endTimestamp) {
.toList(); long pageCursor = cursor;
if (!records.isEmpty()) { List<CnQuotationModels.KChartItem> items = cnQuotationClient.getKChartByDate(
count = kLineMapper.upsertBatch(tableResolver.resolve(period), records); instrument.getExchangeId(), instrument.getContractCode(), period, pageCursor, "after");
List<KLineRecord> records = items.stream()
.filter(item -> item.u() != null && item.u() > pageCursor && item.u() <= endTimestamp)
.sorted(Comparator.comparing(CnQuotationModels.KChartItem::u))
.map(item -> toRecord(instrument, item))
.filter(Objects::nonNull)
.toList();
if (records.isEmpty()) {
break;
}
count += kLineMapper.upsertBatch(tableName, records);
long nextCursor = records.getLast().getKTime();
if (nextCursor <= cursor) {
break;
}
cursor = nextCursor;
} }
saveLog("KLINE", period, instrument.getContractCode(), syncDate, "SUCCESS", count, null, start); if (rebuildIndicators) {
indicatorCalculationService.rebuild(period, instrument.getId());
} else {
indicatorCalculationService.updateIncremental(period, instrument.getId());
}
saveLog(syncType, period, instrument.getContractCode(), syncDate, "SUCCESS", count, null, start);
} catch (Exception e) { } catch (Exception e) {
log.warn("同步行情失败: {} {}", instrument.getContractCode(), period, e); log.warn("同步行情失败: {} {}", instrument.getContractCode(), period, e);
saveLog("KLINE", period, instrument.getContractCode(), syncDate, "FAILED", count, e.getMessage(), start); saveLog(syncType, period, instrument.getContractCode(), syncDate, "FAILED", count, e.getMessage(), start);
} }
} }
private void upsertInstrument(CnQuotationModels.GoodsItem item) { private void upsertInstrument(CnQuotationModels.GoodsItem item) {
String contractCode = item.mainContractCode(); String contractCode = item.mainContractCode();
int priceScale = PriceScaleConverter.scaleFromPrecision(item.decimalPrecision()); List<InstrumentDictionary> matches =
InstrumentDictionary existing = instrumentDictionaryMapper.selectOne( instrumentDictionaryMapper.selectByExchangeAndContractCodeIgnoreCase(
new LambdaQueryWrapper<InstrumentDictionary>() item.excode(), contractCode);
.eq(InstrumentDictionary::getContractCode, contractCode) InstrumentDictionary existing = matches.isEmpty()
.last("LIMIT 1")); ? null
: requireSingleContract(matches, item.excode() + "/" + contractCode);
if (existing == null) { if (existing == null) {
InstrumentDictionary instrument = new InstrumentDictionary(); InstrumentDictionary instrument = new InstrumentDictionary();
instrument.setExchangeId(item.excode()); instrument.setExchangeId(item.excode());
instrument.setSymbol(firstNonBlank(item.productId(), item.goodsCode(), productPrefix(contractCode))); instrument.setSymbol(firstNonBlank(item.productId(), item.goodsCode(), productPrefix(contractCode)));
instrument.setContractCode(contractCode); instrument.setContractCode(contractCode);
instrument.setPriceScale(priceScale); instrument.setPriceScale(DEFAULT_PRICE_SCALE);
instrument.setIsMain(1); instrument.setIsMain(1);
instrumentDictionaryMapper.insert(instrument); instrumentDictionaryMapper.insert(instrument);
} else { } else {
existing.setExchangeId(item.excode()); existing.setExchangeId(item.excode());
existing.setSymbol(firstNonBlank(item.productId(), item.goodsCode(), productPrefix(contractCode))); existing.setSymbol(firstNonBlank(item.productId(), item.goodsCode(), productPrefix(contractCode)));
existing.setPriceScale(priceScale); existing.setContractCode(contractCode);
existing.setIsMain(1); existing.setIsMain(1);
existing.setIsDeleted(0); existing.setIsDeleted(0);
instrumentDictionaryMapper.updateById(existing); instrumentDictionaryMapper.updateById(existing);
} }
} }
private InstrumentDictionary requireSingleContract(List<InstrumentDictionary> matches, String contractCode) {
if (matches == null || matches.isEmpty()) {
throw new IllegalArgumentException("合约不存在: " + contractCode);
}
if (matches.size() > 1) {
throw new IllegalStateException("存在仅大小写不同的重复合约: " + contractCode);
}
return matches.getFirst();
}
private KLineRecord toRecord(InstrumentDictionary instrument, CnQuotationModels.KChartItem item) { private KLineRecord toRecord(InstrumentDictionary instrument, CnQuotationModels.KChartItem item) {
try { try {
return KLineRecord.builder() return KLineRecord.builder()
.instrumentId(instrument.getId()) .instrumentId(instrument.getId())
.timestamp(item.u()) .kTime(item.u())
.open(PriceScaleConverter.toScaled(item.o(), instrument.getPriceScale())) .open(PriceScaleConverter.toScaled(item.o(), instrument.getPriceScale()))
.high(PriceScaleConverter.toScaled(item.h(), instrument.getPriceScale())) .high(PriceScaleConverter.toScaled(item.h(), instrument.getPriceScale()))
.low(PriceScaleConverter.toScaled(item.l(), instrument.getPriceScale())) .low(PriceScaleConverter.toScaled(item.l(), instrument.getPriceScale()))

View File

@ -8,198 +8,585 @@ CREATE DATABASE IF NOT EXISTS backtestify
USE backtestify; USE backtestify;
-- --------------------------------------------------- -- ----------------------------
-- 合约字典表(不分区) -- Table structure for bt_strategy_config
-- --------------------------------------------------- -- ----------------------------
DROP TABLE IF EXISTS t_instrument_dictionary; CREATE TABLE IF NOT EXISTS `bt_strategy_config` (
CREATE TABLE t_instrument_dictionary ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
id SMALLINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '合约自增ID', `user_id` bigint NOT NULL COMMENT '用户ID',
exchange_id VARCHAR(16) DEFAULT NULL COMMENT '交易所代码 (如 SHFE)', `contract_code` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '合约代码',
symbol VARCHAR(10) NOT NULL COMMENT '期货品种 (如 rb)', `contract_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '合约名称',
contract_code VARCHAR(20) NOT NULL COMMENT '具体合约代码 (如 rb2610)', `direction` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '交易方向: LONG/SHORT',
price_scale INT UNSIGNED NOT NULL DEFAULT 100 COMMENT '价格放大倍数 (100表示保留2位小数)', `kline_period` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'K线周期: 1m/3m/5m/15m/30m/1h/4h/1d/1w',
is_main TINYINT NOT NULL DEFAULT 0 COMMENT '是否当前主力合约: 0-否, 1-是', `indicators` json NOT NULL COMMENT '技术指标列表, 如[\"MACD\",\"KDJ\"]',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `open_volume` int NOT NULL DEFAULT 1 COMMENT '开仓数量',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `volume_unit` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'LOT' COMMENT '数量单位: LOT/POSITION',
is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除: 0-未删除, 1-已删除', `stop_loss_value` decimal(18, 4) NULL DEFAULT NULL COMMENT '止损值',
PRIMARY KEY (id), `stop_loss_unit` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '止损单位: TICK/PERCENT',
UNIQUE KEY uk_contract (contract_code), `take_profit_value` decimal(18, 4) NULL DEFAULT NULL COMMENT '止盈值',
KEY idx_symbol_main (symbol, is_main), `take_profit_unit` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '止盈单位: TICK/PERCENT',
KEY idx_exchange_contract (exchange_id, contract_code) `backtest_period` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '回测区间: 1m/3m/6m/1y',
) ENGINE=InnoDB DEFAULT CHARSET=ascii COMMENT='期货合约字典表'; `status` tinyint NOT NULL DEFAULT 0 COMMENT '状态: 0-已保存, 1-已启用信号',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`is_deleted` tinyint NOT NULL DEFAULT 0 COMMENT '逻辑删除: 0-未删除, 1-已删除',
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_user_id`(`user_id` ASC) USING BTREE,
INDEX `idx_contract_code`(`contract_code` ASC) USING BTREE,
INDEX `idx_create_time`(`create_time` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '策略配置表' ROW_FORMAT = Dynamic;
-- --------------------------------------------------- -- ----------------------------
-- K线数据表每种周期一张表按年分区真实行情不包含3m -- Table structure for bt_strategy_result
-- --------------------------------------------------- -- ----------------------------
DROP TABLE IF EXISTS t_kline_1m; CREATE TABLE IF NOT EXISTS `bt_strategy_result` (
CREATE TABLE t_kline_1m ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
instrument_id SMALLINT UNSIGNED NOT NULL COMMENT '合约字典ID', `strategy_id` bigint NOT NULL COMMENT '关联策略ID',
timestamp INT UNSIGNED NOT NULL COMMENT 'Unix时间戳 (秒级每根K线起始点)', `initial_capital` decimal(18, 2) NOT NULL COMMENT '初始资金',
open INT NOT NULL COMMENT '开盘价 (实际价格 * price_scale)', `final_capital` decimal(18, 2) NOT NULL COMMENT '期末总资产',
high INT NOT NULL COMMENT '最高价 (实际价格 * price_scale)', `max_equity` decimal(18, 2) NOT NULL COMMENT '回测期间最高净值',
low INT NOT NULL COMMENT '最低价 (实际价格 * price_scale)', `min_equity` decimal(18, 2) NOT NULL COMMENT '回测期间最低净值',
close INT NOT NULL COMMENT '收盘价 (实际价格 * price_scale)', `total_yield` decimal(10, 4) NOT NULL COMMENT '总收益率(%)',
volume INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '成交量 (手)', `profit_amount` decimal(18, 2) NOT NULL COMMENT '收益金额',
turnover BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '成交额 (元)', `annualized_yield` decimal(10, 4) NOT NULL COMMENT '年化收益率(%)',
open_interest INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '持仓量', `trade_count` int NOT NULL DEFAULT 0 COMMENT '交易次数',
PRIMARY KEY (instrument_id, timestamp) `max_drawdown` decimal(10, 4) NOT NULL COMMENT '最大回撤(%)',
) ENGINE=InnoDB DEFAULT CHARSET=ascii COMMENT='1分钟K线数据表' `sharpe_ratio` decimal(10, 4) NOT NULL COMMENT '夏普比率',
PARTITION BY RANGE (timestamp) ( `win_rate` decimal(10, 4) NOT NULL COMMENT '胜率(%)',
PARTITION p2020 VALUES LESS THAN (1609459200), `start_date` date NOT NULL COMMENT '回测开始日期',
PARTITION p2021 VALUES LESS THAN (1640995200), `end_date` date NOT NULL COMMENT '回测结束日期',
PARTITION p2022 VALUES LESS THAN (1672531200), `daily_equity_curve` json NULL COMMENT '每日净值曲线 [{date,equity,yield}]',
PARTITION p2023 VALUES LESS THAN (1704067200), `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PARTITION p2024 VALUES LESS THAN (1735689600), PRIMARY KEY (`id`) USING BTREE,
PARTITION p2025 VALUES LESS THAN (1767225600), UNIQUE INDEX `uk_strategy_id`(`strategy_id` ASC) USING BTREE
PARTITION p2026 VALUES LESS THAN (1798761600), ) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '回测结果表' ROW_FORMAT = Dynamic;
PARTITION p2027 VALUES LESS THAN (1830297600),
PARTITION p2028 VALUES LESS THAN (1861920000),
PARTITION p2029 VALUES LESS THAN (1893456000),
PARTITION p2030 VALUES LESS THAN (1924992000),
PARTITION p2031 VALUES LESS THAN (1956528000),
PARTITION pmax VALUES LESS THAN MAXVALUE
);
DROP TABLE IF EXISTS t_kline_5m; -- ----------------------------
CREATE TABLE t_kline_5m LIKE t_kline_1m; -- Table structure for bt_trade_detail
ALTER TABLE t_kline_5m COMMENT='5分钟K线数据表'; -- ----------------------------
CREATE TABLE IF NOT EXISTS `bt_trade_detail` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`strategy_id` bigint NOT NULL COMMENT '关联策略ID',
`action` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作: BUY_OPEN/SELL_CLOSE/SELL_OPEN/BUY_CLOSE',
`price` decimal(18, 4) NOT NULL COMMENT '成交价',
`volume` int NOT NULL COMMENT '成交数量',
`turnover` decimal(18, 2) NOT NULL COMMENT '成交金额',
`trade_time` datetime NOT NULL COMMENT '成交时间',
`kline_time` datetime NOT NULL COMMENT '对应K线时间',
`signal_type` varchar(5) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '信号类型: B/S',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_strategy_id`(`strategy_id` ASC) USING BTREE,
INDEX `idx_trade_time`(`trade_time` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '交易明细表' ROW_FORMAT = Dynamic;
DROP TABLE IF EXISTS t_kline_15m; -- ----------------------------
CREATE TABLE t_kline_15m LIKE t_kline_1m; -- Table structure for bt_user_signal
ALTER TABLE t_kline_15m COMMENT='15分钟K线数据表'; -- ----------------------------
CREATE TABLE IF NOT EXISTS `bt_user_signal` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user_id` bigint NOT NULL COMMENT '用户ID',
`strategy_id` bigint NOT NULL COMMENT '策略ID',
`contract_code` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '合约代码',
`kline_period` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'K线周期',
`is_active` tinyint NOT NULL DEFAULT 1 COMMENT '是否启用: 0-停用, 1-启用',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`is_deleted` tinyint NOT NULL DEFAULT 0 COMMENT '逻辑删除: 0-未删除, 1-已删除',
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_user_contract_period`(`user_id` ASC, `contract_code` ASC, `kline_period` ASC) USING BTREE,
INDEX `idx_strategy_id`(`strategy_id` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '用户信号标记表' ROW_FORMAT = Dynamic;
DROP TABLE IF EXISTS t_kline_30m; -- ----------------------------
CREATE TABLE t_kline_30m LIKE t_kline_1m; -- Table structure for t_indicator_15m
ALTER TABLE t_kline_30m COMMENT='30分钟K线数据表'; -- ----------------------------
CREATE TABLE IF NOT EXISTS `t_indicator_15m` (
`instrument_id` smallint UNSIGNED NOT NULL COMMENT '合约字典ID',
`k_time` int UNSIGNED NOT NULL COMMENT 'Unix时间戳 (秒级对应K线起始点)',
`ma5` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA5',
`ma10` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA10',
`ma20` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA20',
`ma60` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA60',
`boll_mb` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线中轨 (MA20)',
`boll_up` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线上轨',
`boll_dn` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线下轨',
`ema6` decimal(18, 6) NULL DEFAULT NULL COMMENT 'EMA6',
`ema12` decimal(18, 6) NULL DEFAULT NULL COMMENT 'EMA12',
`ema20` decimal(18, 6) NULL DEFAULT NULL COMMENT 'EMA20',
`macd_dif` decimal(18, 6) NULL DEFAULT NULL COMMENT '快线 DIF (EMA12 - EMA26)',
`macd_dea` decimal(18, 6) NULL DEFAULT NULL COMMENT '慢线 DEA',
`macd_bar` decimal(18, 6) NULL DEFAULT NULL COMMENT '柱状图 BAR',
`rsi6` decimal(10, 4) NULL DEFAULT NULL COMMENT '短期 RSI6',
`rsi12` decimal(10, 4) NULL DEFAULT NULL COMMENT '长期 RSI12',
`kdj_k` decimal(10, 4) NULL DEFAULT NULL COMMENT 'K值',
`kdj_d` decimal(10, 4) NULL DEFAULT NULL COMMENT 'D值',
`kdj_j` decimal(10, 4) NULL DEFAULT NULL COMMENT 'J值',
`create_at` int UNSIGNED NULL DEFAULT NULL COMMENT '记录创建时间',
PRIMARY KEY (`instrument_id`, `k_time`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = ascii COLLATE = ascii_general_ci COMMENT = '15分钟指标表' ROW_FORMAT = DYNAMIC;
DROP TABLE IF EXISTS t_kline_1h; -- ----------------------------
CREATE TABLE t_kline_1h LIKE t_kline_1m; -- Table structure for t_indicator_1d
ALTER TABLE t_kline_1h COMMENT='1小时K线数据表'; -- ----------------------------
CREATE TABLE IF NOT EXISTS `t_indicator_1d` (
`instrument_id` smallint UNSIGNED NOT NULL COMMENT '合约字典ID',
`k_time` int UNSIGNED NOT NULL COMMENT 'Unix时间戳 (秒级对应K线起始点)',
`ma5` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA5',
`ma10` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA10',
`ma20` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA20',
`ma60` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA60',
`boll_mb` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线中轨 (MA20)',
`boll_up` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线上轨',
`boll_dn` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线下轨',
`ema6` decimal(18, 6) NULL DEFAULT NULL COMMENT 'EMA6',
`ema12` decimal(18, 6) NULL DEFAULT NULL COMMENT 'EMA12',
`ema20` decimal(18, 6) NULL DEFAULT NULL COMMENT 'EMA20',
`macd_dif` decimal(18, 6) NULL DEFAULT NULL COMMENT '快线 DIF (EMA12 - EMA26)',
`macd_dea` decimal(18, 6) NULL DEFAULT NULL COMMENT '慢线 DEA',
`macd_bar` decimal(18, 6) NULL DEFAULT NULL COMMENT '柱状图 BAR',
`rsi6` decimal(10, 4) NULL DEFAULT NULL COMMENT '短期 RSI6',
`rsi12` decimal(10, 4) NULL DEFAULT NULL COMMENT '长期 RSI12',
`kdj_k` decimal(10, 4) NULL DEFAULT NULL COMMENT 'K值',
`kdj_d` decimal(10, 4) NULL DEFAULT NULL COMMENT 'D值',
`kdj_j` decimal(10, 4) NULL DEFAULT NULL COMMENT 'J值',
`create_at` int UNSIGNED NULL DEFAULT NULL COMMENT '记录创建时间',
PRIMARY KEY (`instrument_id`, `k_time`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = ascii COLLATE = ascii_general_ci COMMENT = '日指标表' ROW_FORMAT = DYNAMIC;
DROP TABLE IF EXISTS t_kline_4h; -- ----------------------------
CREATE TABLE t_kline_4h LIKE t_kline_1m; -- Table structure for t_indicator_1h
ALTER TABLE t_kline_4h COMMENT='4小时K线数据表'; -- ----------------------------
CREATE TABLE IF NOT EXISTS `t_indicator_1h` (
`instrument_id` smallint UNSIGNED NOT NULL COMMENT '合约字典ID',
`k_time` int UNSIGNED NOT NULL COMMENT 'Unix时间戳 (秒级对应K线起始点)',
`ma5` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA5',
`ma10` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA10',
`ma20` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA20',
`ma60` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA60',
`boll_mb` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线中轨 (MA20)',
`boll_up` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线上轨',
`boll_dn` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线下轨',
`ema6` decimal(18, 6) NULL DEFAULT NULL COMMENT 'EMA6',
`ema12` decimal(18, 6) NULL DEFAULT NULL COMMENT 'EMA12',
`ema20` decimal(18, 6) NULL DEFAULT NULL COMMENT 'EMA20',
`macd_dif` decimal(18, 6) NULL DEFAULT NULL COMMENT '快线 DIF (EMA12 - EMA26)',
`macd_dea` decimal(18, 6) NULL DEFAULT NULL COMMENT '慢线 DEA',
`macd_bar` decimal(18, 6) NULL DEFAULT NULL COMMENT '柱状图 BAR',
`rsi6` decimal(10, 4) NULL DEFAULT NULL COMMENT '短期 RSI6',
`rsi12` decimal(10, 4) NULL DEFAULT NULL COMMENT '长期 RSI12',
`kdj_k` decimal(10, 4) NULL DEFAULT NULL COMMENT 'K值',
`kdj_d` decimal(10, 4) NULL DEFAULT NULL COMMENT 'D值',
`kdj_j` decimal(10, 4) NULL DEFAULT NULL COMMENT 'J值',
`create_at` int UNSIGNED NULL DEFAULT NULL COMMENT '记录创建时间',
PRIMARY KEY (`instrument_id`, `k_time`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = ascii COLLATE = ascii_general_ci COMMENT = '1小时指标数据表' ROW_FORMAT = DYNAMIC;
DROP TABLE IF EXISTS t_kline_1d; -- ----------------------------
CREATE TABLE t_kline_1d LIKE t_kline_1m; -- Table structure for t_indicator_1m
ALTER TABLE t_kline_1d COMMENT='日K线数据表'; -- ----------------------------
CREATE TABLE IF NOT EXISTS `t_indicator_1m` (
`instrument_id` smallint UNSIGNED NOT NULL COMMENT '合约字典ID',
`k_time` int UNSIGNED NOT NULL COMMENT 'Unix时间戳 (秒级)',
`ma5` decimal(18, 6) NULL DEFAULT NULL,
`ma10` decimal(18, 6) NULL DEFAULT NULL,
`ma20` decimal(18, 6) NULL DEFAULT NULL,
`ma60` decimal(18, 6) NULL DEFAULT NULL,
`boll_mb` decimal(18, 6) NULL DEFAULT NULL,
`boll_up` decimal(18, 6) NULL DEFAULT NULL,
`boll_dn` decimal(18, 6) NULL DEFAULT NULL,
`ema6` decimal(18, 6) NULL DEFAULT NULL,
`ema12` decimal(18, 6) NULL DEFAULT NULL,
`ema20` decimal(18, 6) NULL DEFAULT NULL,
`macd_dif` decimal(18, 6) NULL DEFAULT NULL,
`macd_dea` decimal(18, 6) NULL DEFAULT NULL,
`macd_bar` decimal(18, 6) NULL DEFAULT NULL,
`rsi6` decimal(10, 4) NULL DEFAULT NULL,
`rsi12` decimal(10, 4) NULL DEFAULT NULL,
`kdj_k` decimal(10, 4) NULL DEFAULT NULL,
`kdj_d` decimal(10, 4) NULL DEFAULT NULL,
`kdj_j` decimal(10, 4) NULL DEFAULT NULL,
`create_at` int UNSIGNED NULL DEFAULT NULL,
PRIMARY KEY (`instrument_id`, `k_time`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = ascii COLLATE = ascii_general_ci COMMENT = '1分钟指标数据表(按季度分区)' ROW_FORMAT = DYNAMIC;
DROP TABLE IF EXISTS t_kline_1w; -- ----------------------------
CREATE TABLE t_kline_1w LIKE t_kline_1m; -- Table structure for t_indicator_1mo
ALTER TABLE t_kline_1w COMMENT='周K线数据表'; -- ----------------------------
CREATE TABLE IF NOT EXISTS `t_indicator_1mo` (
`instrument_id` smallint UNSIGNED NOT NULL COMMENT '合约字典ID',
`k_time` int UNSIGNED NOT NULL COMMENT 'Unix时间戳 (秒级对应K线起始点)',
`ma5` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA5',
`ma10` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA10',
`ma20` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA20',
`ma60` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA60',
`boll_mb` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线中轨 (MA20)',
`boll_up` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线上轨',
`boll_dn` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线下轨',
`ema6` decimal(18, 6) NULL DEFAULT NULL COMMENT 'EMA6',
`ema12` decimal(18, 6) NULL DEFAULT NULL COMMENT 'EMA12',
`ema20` decimal(18, 6) NULL DEFAULT NULL COMMENT 'EMA20',
`macd_dif` decimal(18, 6) NULL DEFAULT NULL COMMENT '快线 DIF (EMA12 - EMA26)',
`macd_dea` decimal(18, 6) NULL DEFAULT NULL COMMENT '慢线 DEA',
`macd_bar` decimal(18, 6) NULL DEFAULT NULL COMMENT '柱状图 BAR',
`rsi6` decimal(10, 4) NULL DEFAULT NULL COMMENT '短期 RSI6',
`rsi12` decimal(10, 4) NULL DEFAULT NULL COMMENT '长期 RSI12',
`kdj_k` decimal(10, 4) NULL DEFAULT NULL COMMENT 'K值',
`kdj_d` decimal(10, 4) NULL DEFAULT NULL COMMENT 'D值',
`kdj_j` decimal(10, 4) NULL DEFAULT NULL COMMENT 'J值',
`create_at` int UNSIGNED NULL DEFAULT NULL COMMENT '记录创建时间',
PRIMARY KEY (`instrument_id`, `k_time`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = ascii COLLATE = ascii_general_ci COMMENT = '月指标表' ROW_FORMAT = DYNAMIC;
-- --------------------------------------------------- -- ----------------------------
-- 行情同步日志表 -- Table structure for t_indicator_1w
-- --------------------------------------------------- -- ----------------------------
DROP TABLE IF EXISTS t_market_data_sync_log; CREATE TABLE IF NOT EXISTS `t_indicator_1w` (
CREATE TABLE t_market_data_sync_log ( `instrument_id` smallint UNSIGNED NOT NULL COMMENT '合约字典ID',
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', `k_time` int UNSIGNED NOT NULL COMMENT 'Unix时间戳 (秒级对应K线起始点)',
sync_type VARCHAR(32) NOT NULL COMMENT '同步类型: CONTRACT/KLINE', `ma5` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA5',
period VARCHAR(10) DEFAULT NULL COMMENT 'K线周期', `ma10` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA10',
contract_code VARCHAR(20) DEFAULT NULL COMMENT '合约代码', `ma20` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA20',
sync_date DATE DEFAULT NULL COMMENT '同步日期', `ma60` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA60',
status VARCHAR(16) NOT NULL COMMENT '状态: SUCCESS/FAILED', `boll_mb` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线中轨 (MA20)',
success_count INT NOT NULL DEFAULT 0 COMMENT '成功条数', `boll_up` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线上轨',
error_message TEXT DEFAULT NULL COMMENT '错误信息', `boll_dn` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线下轨',
start_time DATETIME NOT NULL COMMENT '开始时间', `ema6` decimal(18, 6) NULL DEFAULT NULL COMMENT 'EMA6',
end_time DATETIME DEFAULT NULL COMMENT '结束时间', `ema12` decimal(18, 6) NULL DEFAULT NULL COMMENT 'EMA12',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `ema20` decimal(18, 6) NULL DEFAULT NULL COMMENT 'EMA20',
PRIMARY KEY (id), `macd_dif` decimal(18, 6) NULL DEFAULT NULL COMMENT '快线 DIF (EMA12 - EMA26)',
KEY idx_sync_date (sync_date), `macd_dea` decimal(18, 6) NULL DEFAULT NULL COMMENT '慢线 DEA',
KEY idx_contract_period (contract_code, period), `macd_bar` decimal(18, 6) NULL DEFAULT NULL COMMENT '柱状图 BAR',
KEY idx_status (status) `rsi6` decimal(10, 4) NULL DEFAULT NULL COMMENT '短期 RSI6',
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='行情同步日志表'; `rsi12` decimal(10, 4) NULL DEFAULT NULL COMMENT '长期 RSI12',
`kdj_k` decimal(10, 4) NULL DEFAULT NULL COMMENT 'K值',
`kdj_d` decimal(10, 4) NULL DEFAULT NULL COMMENT 'D值',
`kdj_j` decimal(10, 4) NULL DEFAULT NULL COMMENT 'J值',
`create_at` int UNSIGNED NULL DEFAULT NULL COMMENT '记录创建时间',
PRIMARY KEY (`instrument_id`, `k_time`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = ascii COLLATE = ascii_general_ci COMMENT = '周指标表' ROW_FORMAT = DYNAMIC;
-- --------------------------------------------------- -- ----------------------------
-- 策略配置表 -- Table structure for t_indicator_30m
-- --------------------------------------------------- -- ----------------------------
DROP TABLE IF EXISTS bt_strategy_config; CREATE TABLE IF NOT EXISTS `t_indicator_30m` (
CREATE TABLE bt_strategy_config ( `instrument_id` smallint UNSIGNED NOT NULL COMMENT '合约字典ID',
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', `k_time` int UNSIGNED NOT NULL COMMENT 'Unix时间戳 (秒级对应K线起始点)',
user_id BIGINT NOT NULL COMMENT '用户ID', `ma5` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA5',
contract_code VARCHAR(32) NOT NULL COMMENT '合约代码', `ma10` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA10',
contract_name VARCHAR(64) NOT NULL COMMENT '合约名称', `ma20` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA20',
direction VARCHAR(10) NOT NULL COMMENT '交易方向: LONG/SHORT', `ma60` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA60',
kline_period VARCHAR(10) NOT NULL COMMENT 'K线周期: 1m/3m/5m/15m/30m/1h/4h/1d/1w', `boll_mb` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线中轨 (MA20)',
indicators JSON NOT NULL COMMENT '技术指标列表, 如["MACD","KDJ"]', `boll_up` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线上轨',
open_volume INT NOT NULL DEFAULT 1 COMMENT '开仓数量', `boll_dn` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线下轨',
volume_unit VARCHAR(10) NOT NULL DEFAULT 'LOT' COMMENT '数量单位: LOT/POSITION', `ema6` decimal(18, 6) NULL DEFAULT NULL COMMENT 'EMA6',
stop_loss_value DECIMAL(18,4) DEFAULT NULL COMMENT '止损值', `ema12` decimal(18, 6) NULL DEFAULT NULL COMMENT 'EMA12',
stop_loss_unit VARCHAR(10) DEFAULT NULL COMMENT '止损单位: TICK/PERCENT', `ema20` decimal(18, 6) NULL DEFAULT NULL COMMENT 'EMA20',
take_profit_value DECIMAL(18,4) DEFAULT NULL COMMENT '止盈值', `macd_dif` decimal(18, 6) NULL DEFAULT NULL COMMENT '快线 DIF (EMA12 - EMA26)',
take_profit_unit VARCHAR(10) DEFAULT NULL COMMENT '止盈单位: TICK/PERCENT', `macd_dea` decimal(18, 6) NULL DEFAULT NULL COMMENT '慢线 DEA',
backtest_period VARCHAR(10) NOT NULL COMMENT '回测区间: 1m/3m/6m/1y', `macd_bar` decimal(18, 6) NULL DEFAULT NULL COMMENT '柱状图 BAR',
status TINYINT NOT NULL DEFAULT 0 COMMENT '状态: 0-已保存, 1-已启用信号', `rsi6` decimal(10, 4) NULL DEFAULT NULL COMMENT '短期 RSI6',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `rsi12` decimal(10, 4) NULL DEFAULT NULL COMMENT '长期 RSI12',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `kdj_k` decimal(10, 4) NULL DEFAULT NULL COMMENT 'K值',
is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除: 0-未删除, 1-已删除', `kdj_d` decimal(10, 4) NULL DEFAULT NULL COMMENT 'D值',
PRIMARY KEY (id), `kdj_j` decimal(10, 4) NULL DEFAULT NULL COMMENT 'J值',
INDEX idx_user_id (user_id), `create_at` int UNSIGNED NULL DEFAULT NULL COMMENT '记录创建时间',
INDEX idx_contract_code (contract_code), PRIMARY KEY (`instrument_id`, `k_time`) USING BTREE
INDEX idx_create_time (create_time) ) ENGINE = InnoDB CHARACTER SET = ascii COLLATE = ascii_general_ci COMMENT = '30分钟指标表' ROW_FORMAT = DYNAMIC;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='策略配置表';
-- --------------------------------------------------- -- ----------------------------
-- 回测结果表 -- Table structure for t_indicator_3m
-- --------------------------------------------------- -- ----------------------------
DROP TABLE IF EXISTS bt_strategy_result; CREATE TABLE IF NOT EXISTS `t_indicator_3m` (
CREATE TABLE bt_strategy_result ( `instrument_id` smallint UNSIGNED NOT NULL COMMENT '合约字典ID',
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', `k_time` int UNSIGNED NOT NULL COMMENT 'Unix时间戳 (秒级对应K线起始点)',
strategy_id BIGINT NOT NULL COMMENT '关联策略ID', `ma5` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA5',
initial_capital DECIMAL(18,2) NOT NULL COMMENT '初始资金', `ma10` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA10',
final_capital DECIMAL(18,2) NOT NULL COMMENT '期末总资产', `ma20` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA20',
max_equity DECIMAL(18,2) NOT NULL COMMENT '回测期间最高净值', `ma60` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA60',
min_equity DECIMAL(18,2) NOT NULL COMMENT '回测期间最低净值', `boll_mb` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线中轨 (MA20)',
total_yield DECIMAL(10,4) NOT NULL COMMENT '总收益率(%)', `boll_up` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线上轨',
profit_amount DECIMAL(18,2) NOT NULL COMMENT '收益金额', `boll_dn` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线下轨',
annualized_yield DECIMAL(10,4) NOT NULL COMMENT '年化收益率(%)', `ema6` decimal(18, 6) NULL DEFAULT NULL COMMENT 'EMA6',
trade_count INT NOT NULL DEFAULT 0 COMMENT '交易次数', `ema12` decimal(18, 6) NULL DEFAULT NULL COMMENT 'EMA12',
max_drawdown DECIMAL(10,4) NOT NULL COMMENT '最大回撤(%)', `ema20` decimal(18, 6) NULL DEFAULT NULL COMMENT 'EMA20',
sharpe_ratio DECIMAL(10,4) NOT NULL COMMENT '夏普比率', `macd_dif` decimal(18, 6) NULL DEFAULT NULL COMMENT '快线 DIF (EMA12 - EMA26)',
win_rate DECIMAL(10,4) NOT NULL COMMENT '胜率(%)', `macd_dea` decimal(18, 6) NULL DEFAULT NULL COMMENT '慢线 DEA',
start_date DATE NOT NULL COMMENT '回测开始日期', `macd_bar` decimal(18, 6) NULL DEFAULT NULL COMMENT '柱状图 BAR',
end_date DATE NOT NULL COMMENT '回测结束日期', `rsi6` decimal(10, 4) NULL DEFAULT NULL COMMENT '短期 RSI6',
daily_equity_curve JSON DEFAULT NULL COMMENT '每日净值曲线 [{date,equity,yield}]', `rsi12` decimal(10, 4) NULL DEFAULT NULL COMMENT '长期 RSI12',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `kdj_k` decimal(10, 4) NULL DEFAULT NULL COMMENT 'K值',
PRIMARY KEY (id), `kdj_d` decimal(10, 4) NULL DEFAULT NULL COMMENT 'D值',
UNIQUE INDEX uk_strategy_id (strategy_id) `kdj_j` decimal(10, 4) NULL DEFAULT NULL COMMENT 'J值',
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='回测结果表'; `create_at` int UNSIGNED NULL DEFAULT NULL COMMENT '记录创建时间',
PRIMARY KEY (`instrument_id`, `k_time`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = ascii COLLATE = ascii_general_ci COMMENT = '3分钟指标表' ROW_FORMAT = DYNAMIC;
-- --------------------------------------------------- -- ----------------------------
-- 交易明细表 -- Table structure for t_indicator_4h
-- --------------------------------------------------- -- ----------------------------
DROP TABLE IF EXISTS bt_trade_detail; CREATE TABLE IF NOT EXISTS `t_indicator_4h` (
CREATE TABLE bt_trade_detail ( `instrument_id` smallint UNSIGNED NOT NULL COMMENT '合约字典ID',
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', `k_time` int UNSIGNED NOT NULL COMMENT 'Unix时间戳 (秒级对应K线起始点)',
strategy_id BIGINT NOT NULL COMMENT '关联策略ID', `ma5` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA5',
action VARCHAR(20) NOT NULL COMMENT '操作: BUY_OPEN/SELL_CLOSE/SELL_OPEN/BUY_CLOSE', `ma10` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA10',
price DECIMAL(18,4) NOT NULL COMMENT '成交价', `ma20` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA20',
volume INT NOT NULL COMMENT '成交数量', `ma60` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA60',
turnover DECIMAL(18,2) NOT NULL COMMENT '成交金额', `boll_mb` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线中轨 (MA20)',
trade_time DATETIME NOT NULL COMMENT '成交时间', `boll_up` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线上轨',
kline_time DATETIME NOT NULL COMMENT '对应K线时间', `boll_dn` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线下轨',
signal_type VARCHAR(5) NOT NULL COMMENT '信号类型: B/S', `ema6` decimal(18, 6) NULL DEFAULT NULL COMMENT 'EMA6',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `ema12` decimal(18, 6) NULL DEFAULT NULL COMMENT 'EMA12',
PRIMARY KEY (id), `ema20` decimal(18, 6) NULL DEFAULT NULL COMMENT 'EMA20',
INDEX idx_strategy_id (strategy_id), `macd_dif` decimal(18, 6) NULL DEFAULT NULL COMMENT '快线 DIF (EMA12 - EMA26)',
INDEX idx_trade_time (trade_time) `macd_dea` decimal(18, 6) NULL DEFAULT NULL COMMENT '慢线 DEA',
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='交易明细表'; `macd_bar` decimal(18, 6) NULL DEFAULT NULL COMMENT '柱状图 BAR',
`rsi6` decimal(10, 4) NULL DEFAULT NULL COMMENT '短期 RSI6',
`rsi12` decimal(10, 4) NULL DEFAULT NULL COMMENT '长期 RSI12',
`kdj_k` decimal(10, 4) NULL DEFAULT NULL COMMENT 'K值',
`kdj_d` decimal(10, 4) NULL DEFAULT NULL COMMENT 'D值',
`kdj_j` decimal(10, 4) NULL DEFAULT NULL COMMENT 'J值',
`create_at` int UNSIGNED NULL DEFAULT NULL COMMENT '记录创建时间',
PRIMARY KEY (`instrument_id`, `k_time`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = ascii COLLATE = ascii_general_ci COMMENT = '4小时指标表' ROW_FORMAT = DYNAMIC;
-- --------------------------------------------------- -- ----------------------------
-- 用户信号标记表 -- Table structure for t_indicator_5m
-- --------------------------------------------------- -- ----------------------------
DROP TABLE IF EXISTS bt_user_signal; CREATE TABLE IF NOT EXISTS `t_indicator_5m` (
CREATE TABLE bt_user_signal ( `instrument_id` smallint UNSIGNED NOT NULL COMMENT '合约字典ID',
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', `k_time` int UNSIGNED NOT NULL COMMENT 'Unix时间戳 (秒级对应K线起始点)',
user_id BIGINT NOT NULL COMMENT '用户ID', `ma5` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA5',
strategy_id BIGINT NOT NULL COMMENT '策略ID', `ma10` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA10',
contract_code VARCHAR(32) NOT NULL COMMENT '合约代码', `ma20` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA20',
kline_period VARCHAR(10) NOT NULL COMMENT 'K线周期', `ma60` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA60',
is_active TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用: 0-停用, 1-启用', `boll_mb` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线中轨 (MA20)',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `boll_up` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线上轨',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `boll_dn` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线下轨',
is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除: 0-未删除, 1-已删除', `ema6` decimal(18, 6) NULL DEFAULT NULL COMMENT 'EMA6',
PRIMARY KEY (id), `ema12` decimal(18, 6) NULL DEFAULT NULL COMMENT 'EMA12',
INDEX idx_user_contract_period (user_id, contract_code, kline_period), `ema20` decimal(18, 6) NULL DEFAULT NULL COMMENT 'EMA20',
INDEX idx_strategy_id (strategy_id) `macd_dif` decimal(18, 6) NULL DEFAULT NULL COMMENT '快线 DIF (EMA12 - EMA26)',
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户信号标记表'; `macd_dea` decimal(18, 6) NULL DEFAULT NULL COMMENT '慢线 DEA',
`macd_bar` decimal(18, 6) NULL DEFAULT NULL COMMENT '柱状图 BAR',
`rsi6` decimal(10, 4) NULL DEFAULT NULL COMMENT '短期 RSI6',
`rsi12` decimal(10, 4) NULL DEFAULT NULL COMMENT '长期 RSI12',
`kdj_k` decimal(10, 4) NULL DEFAULT NULL COMMENT 'K值',
`kdj_d` decimal(10, 4) NULL DEFAULT NULL COMMENT 'D值',
`kdj_j` decimal(10, 4) NULL DEFAULT NULL COMMENT 'J值',
`create_at` int UNSIGNED NULL DEFAULT NULL COMMENT '记录创建时间',
PRIMARY KEY (`instrument_id`, `k_time`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = ascii COLLATE = ascii_general_ci COMMENT = '5分钟指标表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Table structure for t_instrument_dictionary
-- ----------------------------
CREATE TABLE IF NOT EXISTS `t_instrument_dictionary` (
`id` smallint UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '合约自增ID',
`exchange_id` varchar(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`symbol` varchar(10) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`contract_code` varchar(20) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`contract_code_normalized` varchar(20) CHARACTER SET ascii COLLATE ascii_general_ci GENERATED ALWAYS AS (lower(`contract_code`)) STORED,
`price_scale` int UNSIGNED NOT NULL DEFAULT 1000 COMMENT '价格放大倍数暂统一使用1000',
`price_tick` decimal(18, 6) NOT NULL DEFAULT 1 COMMENT '合约最小变动价位,不参与行情价格缩放',
`is_main` tinyint NOT NULL DEFAULT 0 COMMENT '是否当前主力合约: 0-否, 1-是',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`is_deleted` tinyint NOT NULL DEFAULT 0 COMMENT '逻辑删除: 0-未删除, 1-已删除',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `uk_exchange_contract`(`exchange_id` ASC, `contract_code_normalized` ASC) USING BTREE,
INDEX `idx_symbol_main`(`symbol` ASC, `is_main` ASC) USING BTREE,
INDEX `idx_contract_code`(`contract_code` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2048 CHARACTER SET = ascii COLLATE = ascii_general_ci COMMENT = '期货合约字典表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for t_kline_15m
-- ----------------------------
CREATE TABLE IF NOT EXISTS `t_kline_15m` (
`instrument_id` smallint UNSIGNED NOT NULL COMMENT '合约字典ID',
`k_time` int UNSIGNED NOT NULL COMMENT 'Unix时间戳 (秒级每根K线起始点)',
`open` int NOT NULL COMMENT '开盘价 (实际价格 * price_scale)',
`high` int NOT NULL COMMENT '最高价 (实际价格 * price_scale)',
`low` int NOT NULL COMMENT '最低价 (实际价格 * price_scale)',
`close` int NOT NULL COMMENT '收盘价 (实际价格 * price_scale)',
`volume` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '成交量 (手)',
`turnover` bigint UNSIGNED NOT NULL DEFAULT 0 COMMENT '成交额 (元)',
`open_interest` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '持仓量',
`create_at` int UNSIGNED NULL DEFAULT NULL,
PRIMARY KEY (`instrument_id`, `k_time`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = ascii COLLATE = ascii_general_ci COMMENT = '15分钟K线数据表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for t_kline_1d
-- ----------------------------
CREATE TABLE IF NOT EXISTS `t_kline_1d` (
`instrument_id` smallint UNSIGNED NOT NULL COMMENT '合约字典ID',
`k_time` int UNSIGNED NOT NULL COMMENT 'Unix时间戳 (秒级每根K线起始点)',
`open` int NOT NULL COMMENT '开盘价 (实际价格 * price_scale)',
`high` int NOT NULL COMMENT '最高价 (实际价格 * price_scale)',
`low` int NOT NULL COMMENT '最低价 (实际价格 * price_scale)',
`close` int NOT NULL COMMENT '收盘价 (实际价格 * price_scale)',
`settle` int NULL DEFAULT NULL,
`volume` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '成交量 (手)',
`turnover` bigint UNSIGNED NOT NULL DEFAULT 0 COMMENT '成交额 (元)',
`open_interest` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '持仓量',
`create_at` int UNSIGNED NULL DEFAULT NULL,
PRIMARY KEY (`instrument_id`, `k_time`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = ascii COLLATE = ascii_general_ci COMMENT = '日K线数据表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for t_kline_1h
-- ----------------------------
CREATE TABLE IF NOT EXISTS `t_kline_1h` (
`instrument_id` smallint UNSIGNED NOT NULL COMMENT '合约字典ID',
`k_time` int UNSIGNED NOT NULL COMMENT 'Unix时间戳 (秒级每根K线起始点)',
`open` int NOT NULL COMMENT '开盘价 (实际价格 * price_scale)',
`high` int NOT NULL COMMENT '最高价 (实际价格 * price_scale)',
`low` int NOT NULL COMMENT '最低价 (实际价格 * price_scale)',
`close` int NOT NULL COMMENT '收盘价 (实际价格 * price_scale)',
`volume` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '成交量 (手)',
`turnover` bigint UNSIGNED NOT NULL DEFAULT 0 COMMENT '成交额 (元)',
`open_interest` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '持仓量',
`create_at` int UNSIGNED NULL DEFAULT NULL,
PRIMARY KEY (`instrument_id`, `k_time`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = ascii COLLATE = ascii_general_ci COMMENT = '1小时K线数据表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for t_kline_1m
-- ----------------------------
CREATE TABLE IF NOT EXISTS `t_kline_1m` (
`instrument_id` smallint UNSIGNED NOT NULL COMMENT '合约字典ID',
`k_time` int UNSIGNED NOT NULL COMMENT 'Unix时间戳 (秒级每根K线起始点)',
`open` int NOT NULL COMMENT '开盘价 (实际价格 * price_scale)',
`high` int NOT NULL COMMENT '最高价 (实际价格 * price_scale)',
`low` int NOT NULL COMMENT '最低价 (实际价格 * price_scale)',
`close` int NOT NULL COMMENT '收盘价 (实际价格 * price_scale)',
`volume` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '成交量 (手)',
`turnover` bigint UNSIGNED NOT NULL DEFAULT 0 COMMENT '成交额 (元)',
`open_interest` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '持仓量',
`create_at` int UNSIGNED NULL DEFAULT NULL,
PRIMARY KEY (`instrument_id`, `k_time`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = ascii COLLATE = ascii_general_ci COMMENT = '1分钟K线数据表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for t_kline_1mo
-- ----------------------------
CREATE TABLE IF NOT EXISTS `t_kline_1mo` (
`instrument_id` smallint UNSIGNED NOT NULL COMMENT '合约字典ID',
`k_time` int UNSIGNED NOT NULL COMMENT 'Unix时间戳 (秒级每根K线起始点)',
`open` int NOT NULL COMMENT '开盘价 (实际价格 * price_scale)',
`high` int NOT NULL COMMENT '最高价 (实际价格 * price_scale)',
`low` int NOT NULL COMMENT '最低价 (实际价格 * price_scale)',
`close` int NOT NULL COMMENT '收盘价 (实际价格 * price_scale)',
`volume` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '成交量 (手)',
`turnover` bigint UNSIGNED NOT NULL DEFAULT 0 COMMENT '成交额 (元)',
`open_interest` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '持仓量',
`create_at` int UNSIGNED NULL DEFAULT NULL,
PRIMARY KEY (`instrument_id`, `k_time`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = ascii COLLATE = ascii_general_ci COMMENT = '月K线数据表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for t_kline_1w
-- ----------------------------
CREATE TABLE IF NOT EXISTS `t_kline_1w` (
`instrument_id` smallint UNSIGNED NOT NULL COMMENT '合约字典ID',
`k_time` int UNSIGNED NOT NULL COMMENT 'Unix时间戳 (秒级每根K线起始点)',
`open` int NOT NULL COMMENT '开盘价 (实际价格 * price_scale)',
`high` int NOT NULL COMMENT '最高价 (实际价格 * price_scale)',
`low` int NOT NULL COMMENT '最低价 (实际价格 * price_scale)',
`close` int NOT NULL COMMENT '收盘价 (实际价格 * price_scale)',
`volume` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '成交量 (手)',
`turnover` bigint UNSIGNED NOT NULL DEFAULT 0 COMMENT '成交额 (元)',
`open_interest` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '持仓量',
`create_at` int UNSIGNED NULL DEFAULT NULL,
PRIMARY KEY (`instrument_id`, `k_time`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = ascii COLLATE = ascii_general_ci COMMENT = '周K线数据表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for t_kline_30m
-- ----------------------------
CREATE TABLE IF NOT EXISTS `t_kline_30m` (
`instrument_id` smallint UNSIGNED NOT NULL COMMENT '合约字典ID',
`k_time` int UNSIGNED NOT NULL COMMENT 'Unix时间戳 (秒级每根K线起始点)',
`open` int NOT NULL COMMENT '开盘价 (实际价格 * price_scale)',
`high` int NOT NULL COMMENT '最高价 (实际价格 * price_scale)',
`low` int NOT NULL COMMENT '最低价 (实际价格 * price_scale)',
`close` int NOT NULL COMMENT '收盘价 (实际价格 * price_scale)',
`volume` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '成交量 (手)',
`turnover` bigint UNSIGNED NOT NULL DEFAULT 0 COMMENT '成交额 (元)',
`open_interest` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '持仓量',
`create_at` int UNSIGNED NULL DEFAULT NULL,
PRIMARY KEY (`instrument_id`, `k_time`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = ascii COLLATE = ascii_general_ci COMMENT = '30分钟K线数据表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for t_kline_3m
-- ----------------------------
CREATE TABLE IF NOT EXISTS `t_kline_3m` (
`instrument_id` smallint UNSIGNED NOT NULL COMMENT '合约字典ID',
`k_time` int UNSIGNED NOT NULL COMMENT 'Unix时间戳 (秒级每根K线起始点)',
`open` int NOT NULL COMMENT '开盘价 (实际价格 * price_scale)',
`high` int NOT NULL COMMENT '最高价 (实际价格 * price_scale)',
`low` int NOT NULL COMMENT '最低价 (实际价格 * price_scale)',
`close` int NOT NULL COMMENT '收盘价 (实际价格 * price_scale)',
`volume` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '成交量 (手)',
`turnover` bigint UNSIGNED NOT NULL DEFAULT 0 COMMENT '成交额 (元)',
`open_interest` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '持仓量',
`create_at` int UNSIGNED NULL DEFAULT NULL,
PRIMARY KEY (`instrument_id`, `k_time`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = ascii COLLATE = ascii_general_ci COMMENT = '3分钟K线数据表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for t_kline_4h
-- ----------------------------
CREATE TABLE IF NOT EXISTS `t_kline_4h` (
`instrument_id` smallint UNSIGNED NOT NULL COMMENT '合约字典ID',
`k_time` int UNSIGNED NOT NULL COMMENT 'Unix时间戳 (秒级每根K线起始点)',
`open` int NOT NULL COMMENT '开盘价 (实际价格 * price_scale)',
`high` int NOT NULL COMMENT '最高价 (实际价格 * price_scale)',
`low` int NOT NULL COMMENT '最低价 (实际价格 * price_scale)',
`close` int NOT NULL COMMENT '收盘价 (实际价格 * price_scale)',
`volume` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '成交量 (手)',
`turnover` bigint UNSIGNED NOT NULL DEFAULT 0 COMMENT '成交额 (元)',
`open_interest` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '持仓量',
`create_at` int UNSIGNED NULL DEFAULT NULL,
PRIMARY KEY (`instrument_id`, `k_time`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = ascii COLLATE = ascii_general_ci COMMENT = '4小时K线数据表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for t_kline_5m
-- ----------------------------
CREATE TABLE IF NOT EXISTS `t_kline_5m` (
`instrument_id` smallint UNSIGNED NOT NULL COMMENT '合约字典ID',
`k_time` int UNSIGNED NOT NULL COMMENT 'Unix时间戳 (秒级每根K线起始点)',
`open` int NOT NULL COMMENT '开盘价 (实际价格 * price_scale)',
`high` int NOT NULL COMMENT '最高价 (实际价格 * price_scale)',
`low` int NOT NULL COMMENT '最低价 (实际价格 * price_scale)',
`close` int NOT NULL COMMENT '收盘价 (实际价格 * price_scale)',
`volume` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '成交量 (手)',
`turnover` bigint UNSIGNED NOT NULL DEFAULT 0 COMMENT '成交额 (元)',
`open_interest` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '持仓量',
`create_at` int UNSIGNED NULL DEFAULT NULL,
PRIMARY KEY (`instrument_id`, `k_time`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = ascii COLLATE = ascii_general_ci COMMENT = '5分钟K线数据表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for t_market_data_sync_log
-- ----------------------------
CREATE TABLE IF NOT EXISTS `t_market_data_sync_log` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`sync_type` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '同步类型: CONTRACT/KLINE',
`f_period` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT 'K线周期',
`contract_code` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '合约代码',
`sync_date` date NULL DEFAULT NULL COMMENT '同步日期',
`status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '状态: SUCCESS/FAILED',
`success_count` int NOT NULL DEFAULT 0 COMMENT '成功条数',
`error_message` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '错误信息',
`start_time` datetime NOT NULL COMMENT '开始时间',
`end_time` datetime NULL DEFAULT NULL COMMENT '结束时间',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_sync_date`(`sync_date` ASC) USING BTREE,
INDEX `idx_contract_period`(`contract_code` ASC, `f_period` ASC) USING BTREE,
INDEX `idx_status`(`status` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '行情同步日志表' ROW_FORMAT = Dynamic;

View File

@ -0,0 +1,220 @@
package com.yangwale.backtestify.service;
import com.yangwale.backtestify.entity.StrategyConfig;
import com.yangwale.backtestify.mapper.StrategyConfigMapper;
import com.yangwale.backtestify.mapper.StrategyResultMapper;
import com.yangwale.backtestify.mapper.TradeDetailMapper;
import com.yangwale.backtestify.model.dto.KLineData;
import com.yangwale.backtestify.model.request.BacktestRequest;
import com.yangwale.backtestify.model.response.BacktestResponse;
import com.yangwale.backtestify.service.impl.BacktestEngineImpl;
import com.yangwale.backtestify.service.signal.SignalStrategy;
import com.yangwale.backtestify.service.signal.SignalStrategyFactory;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class BacktestEngineCalculationTest {
private MarketDataService marketDataService;
private TestSignalStrategy signalStrategy;
private BacktestEngineImpl engine;
@BeforeEach
void setUp() {
marketDataService = mock(MarketDataService.class);
StrategyConfigMapper strategyConfigMapper = mock(StrategyConfigMapper.class);
StrategyResultMapper strategyResultMapper = mock(StrategyResultMapper.class);
TradeDetailMapper tradeDetailMapper = mock(TradeDetailMapper.class);
signalStrategy = new TestSignalStrategy();
SignalStrategyFactory signalStrategyFactory =
new SignalStrategyFactory(List.of(signalStrategy));
when(strategyConfigMapper.insert(any(StrategyConfig.class))).thenAnswer(invocation -> {
invocation.<StrategyConfig>getArgument(0).setId(1L);
return 1;
});
engine = new BacktestEngineImpl(
marketDataService,
signalStrategyFactory,
strategyConfigMapper,
strategyResultMapper,
tradeDetailMapper);
ReflectionTestUtils.setField(engine, "initialCapital", new BigDecimal("1000000"));
ReflectionTestUtils.setField(engine, "marginRatio", new BigDecimal("0.10"));
ReflectionTestUtils.setField(engine, "feeRate", new BigDecimal("0.00005"));
ReflectionTestUtils.setField(engine, "riskFreeRate", new BigDecimal("0.025"));
}
@Test
void longResultUsesRequestedVolumeAndDeductsBothFees() {
List<KLineData> data = List.of(
kline("2026-07-20T00:00:00", "100"),
kline("2026-07-21T00:00:00", "110"));
stubMarketData(data);
signalStrategy.buyTime = data.getFirst().getTime();
BacktestResponse result = engine.execute(request("LONG", 2));
assertEquals(new BigDecimal("1000019.98"), result.getFinalCapital());
assertEquals(new BigDecimal("19.98"), result.getProfitAmount());
assertEquals(2, result.getTradeCount());
assertEquals(2, result.getTradeDetails().getFirst().getVolume());
assertEquals(new BigDecimal("1000019.99"), result.getMaxEquity());
assertEquals(new BigDecimal("999999.99"), result.getMinEquity());
assertEquals(new BigDecimal("100.0000"), result.getWinRate());
}
@Test
void shortTakeProfitClosesWhenPriceFalls() {
List<KLineData> data = List.of(
kline("2026-07-20T00:00:00", "100"),
kline("2026-07-21T00:00:00", "95"),
kline("2026-07-22T00:00:00", "110"));
stubMarketData(data);
signalStrategy.sellTime = data.getFirst().getTime();
BacktestRequest request = request("SHORT", 2);
request.setTakeProfitValue(new BigDecimal("4"));
request.setTakeProfitUnit("PERCENT");
BacktestResponse result = engine.execute(request);
assertEquals(new BigDecimal("1000009.98"), result.getFinalCapital());
assertEquals("2026-07-21T00:00", result.getTradeDetails().getLast().getTradeTime());
assertEquals(new BigDecimal("100.0000"), result.getWinRate());
}
@Test
void shortStopLossClosesWhenPriceRises() {
List<KLineData> data = List.of(
kline("2026-07-20T00:00:00", "100"),
kline("2026-07-21T00:00:00", "105"),
kline("2026-07-22T00:00:00", "90"));
stubMarketData(data);
signalStrategy.sellTime = data.getFirst().getTime();
BacktestRequest request = request("SHORT", 2);
request.setStopLossValue(new BigDecimal("4"));
request.setStopLossUnit("PERCENT");
BacktestResponse result = engine.execute(request);
assertEquals(new BigDecimal("999989.98"), result.getFinalCapital());
assertEquals("2026-07-21T00:00", result.getTradeDetails().getLast().getTradeTime());
assertEquals(new BigDecimal("0.0000"), result.getWinRate());
}
@Test
void longTickStopLossUsesInstrumentMinimumPriceTick() {
List<KLineData> data = List.of(
kline("2026-07-20T00:00:00", "100"),
kline("2026-07-21T00:00:00", "99"),
kline("2026-07-22T00:00:00", "110"));
stubMarketData(data);
when(marketDataService.getPriceTick("TEST")).thenReturn(new BigDecimal("0.5"));
signalStrategy.buyTime = data.getFirst().getTime();
BacktestRequest request = request("LONG", 1);
request.setStopLossValue(new BigDecimal("2"));
request.setStopLossUnit("TICK");
BacktestResponse result = engine.execute(request);
assertEquals(new BigDecimal("999998.99"), result.getFinalCapital());
assertEquals("2026-07-21T00:00", result.getTradeDetails().getLast().getTradeTime());
assertEquals(new BigDecimal("0.0000"), result.getWinRate());
}
@Test
void shortTickTakeProfitUsesInstrumentMinimumPriceTick() {
List<KLineData> data = List.of(
kline("2026-07-20T00:00:00", "100"),
kline("2026-07-21T00:00:00", "98.5"),
kline("2026-07-22T00:00:00", "110"));
stubMarketData(data);
when(marketDataService.getPriceTick("TEST")).thenReturn(new BigDecimal("0.5"));
signalStrategy.sellTime = data.getFirst().getTime();
BacktestRequest request = request("SHORT", 1);
request.setTakeProfitValue(new BigDecimal("3"));
request.setTakeProfitUnit("TICK");
BacktestResponse result = engine.execute(request);
assertEquals(new BigDecimal("1000001.49"), result.getFinalCapital());
assertEquals("2026-07-21T00:00", result.getTradeDetails().getLast().getTradeTime());
assertEquals(new BigDecimal("100.0000"), result.getWinRate());
}
@Test
void maximumDrawdownUsesPeakBeforeTroughInTimeOrder() {
List<KLineData> data = List.of(
kline("2026-07-20T00:00:00", "100"),
kline("2026-07-21T00:00:00", "120"),
kline("2026-07-22T00:00:00", "110"));
stubMarketData(data);
signalStrategy.buyTime = data.getFirst().getTime();
BacktestResponse result = engine.execute(request("LONG", 1));
assertEquals(new BigDecimal("0.0010"), result.getMaxDrawdown());
}
private void stubMarketData(List<KLineData> data) {
when(marketDataService.getKLineData(any(), any(), any(), any())).thenReturn(data);
}
private BacktestRequest request(String direction, int volume) {
BacktestRequest request = new BacktestRequest();
request.setUserId(1L);
request.setContractCode("TEST");
request.setContractName("测试合约");
request.setDirection(direction);
request.setKlinePeriod("1d");
request.setIndicators(List.of("TEST"));
request.setOpenVolume(volume);
request.setVolumeUnit("LOT");
request.setBacktestPeriod("1m");
return request;
}
private KLineData kline(String time, String close) {
BigDecimal price = new BigDecimal(close);
return KLineData.builder()
.time(LocalDateTime.parse(time))
.open(price)
.high(price)
.low(price)
.close(price)
.volume(BigDecimal.ONE)
.amount(BigDecimal.ONE)
.build();
}
private static class TestSignalStrategy implements SignalStrategy {
private LocalDateTime buyTime;
private LocalDateTime sellTime;
@Override
public String name() {
return "TEST";
}
@Override
public boolean isBuySignal(KLineData current, List<KLineData> history) {
return current.getTime().equals(buyTime);
}
@Override
public boolean isSellSignal(KLineData current, List<KLineData> history) {
return current.getTime().equals(sellTime);
}
}
}

View File

@ -0,0 +1,255 @@
package com.yangwale.backtestify.service;
import com.yangwale.backtestify.common.BusinessException;
import com.yangwale.backtestify.config.WebMvcConfig;
import com.yangwale.backtestify.model.dto.KLineData;
import com.yangwale.backtestify.model.request.AdminStrategyPageRequest;
import com.yangwale.backtestify.model.request.BacktestRequest;
import com.yangwale.backtestify.model.request.SignalQueryRequest;
import com.yangwale.backtestify.model.response.BacktestResponse;
import com.yangwale.backtestify.model.response.StrategyDetailResponse;
import com.yangwale.backtestify.service.market.MarketDataProvider;
import com.yangwale.backtestify.service.signal.SignalStrategy;
import com.yangwale.backtestify.service.signal.SignalStrategyFactory;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@SpringBootTest(properties = {
"spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver",
"spring.datasource.url=jdbc:mysql://192.168.2.5:3306/backtestify?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&useSSL=false",
"spring.datasource.username=root",
"spring.datasource.password=root",
"spring.sql.init.mode=never",
"market-data.provider=mysql",
"market-data.sync.enabled=false"
})
@Transactional
@EnabledIfSystemProperty(named = "realDataTest", matches = "true")
class RealDataBusinessIntegrationTest {
private static final long TEST_USER_ID = 9_900_001L;
private static final DateTimeFormatter DATE_TIME_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@Autowired
private MarketDataProvider marketDataProvider;
@Autowired
private SignalStrategyFactory signalStrategyFactory;
@Autowired
private StrategyService strategyService;
@Test
void allBusinessFunctionsWorkWithRealMarketDataAndResultsAreInternallyConsistent() {
SelectedScenario scenario = selectScenario();
assertTrue(marketDataProvider.getPriceTick(scenario.contractCode()).signum() > 0,
"真实合约必须具有有效最小变动价位");
WebMvcConfig.RequestContextHolder.setUserId(TEST_USER_ID);
try {
BacktestRequest request = requestFor(scenario);
BacktestResponse result = strategyService.backtest(request);
assertNotNull(result.getStrategyId());
assertTrue(result.getTradeCount() >= 2, "所选真实数据场景应至少完成一笔开平仓");
assertEquals(result.getTradeCount(), result.getTradeDetails().size());
assertTrue(result.getTradeDetails().stream()
.allMatch(trade -> request.getOpenVolume().equals(trade.getVolume())),
"成交明细必须使用请求中的开仓数量");
assertFalse(result.getDailyEquityCurve().isEmpty(), "真实回测必须产生每日净值曲线");
BigDecimal expectedProfit = result.getFinalCapital().subtract(result.getInitialCapital());
assertEquals(0, expectedProfit.compareTo(result.getProfitAmount()));
BigDecimal expectedYield = expectedProfit
.divide(result.getInitialCapital(), 8, RoundingMode.HALF_UP)
.multiply(BigDecimal.valueOf(100))
.setScale(4, RoundingMode.HALF_UP);
assertEquals(0, expectedYield.compareTo(result.getTotalYield()));
assertEquals(0, calculateProfitFromTrades(result)
.compareTo(result.getProfitAmount()));
assertEquals(0, calculateWinRateFromTrades(result)
.compareTo(result.getWinRate()));
assertEquals(0, calculateDrawdownFromDailyCurve(result)
.compareTo(result.getMaxDrawdown()));
assertTrue(strategyService.myList(TEST_USER_ID, 1, 20).getList().stream()
.anyMatch(item -> result.getStrategyId().equals(item.getId())));
StrategyDetailResponse detail = strategyService.detail(result.getStrategyId());
assertEquals(result.getFinalCapital(), detail.getFinalCapital());
assertEquals(result.getTradeDetails().size(), detail.getTradeDetails().size());
assertEquals(result.getDailyEquityCurve().size(), detail.getDailyEquityCurve().size());
AdminStrategyPageRequest adminRequest = new AdminStrategyPageRequest();
adminRequest.setPageNum(1);
adminRequest.setPageSize(20);
adminRequest.setUserId(TEST_USER_ID);
assertTrue(strategyService.adminList(adminRequest).getList().stream()
.anyMatch(item -> result.getStrategyId().equals(item.getId())));
strategyService.toggleSignal(result.getStrategyId(), TEST_USER_ID);
SignalQueryRequest signalRequest = new SignalQueryRequest();
signalRequest.setUserId(TEST_USER_ID);
signalRequest.setContractCode(scenario.contractCode());
signalRequest.setPeriod("1d");
signalRequest.setStartTime(result.getStartDate().atStartOfDay().minusDays(1)
.format(DATE_TIME_FORMATTER));
signalRequest.setEndTime(result.getEndDate().plusDays(1).atStartOfDay()
.format(DATE_TIME_FORMATTER));
assertEquals(result.getTradeCount(), strategyService.getSignals(signalRequest).size());
strategyService.toggleSignal(result.getStrategyId(), TEST_USER_ID);
assertTrue(strategyService.getSignals(signalRequest).isEmpty());
strategyService.delete(result.getStrategyId(), TEST_USER_ID);
assertThrows(BusinessException.class, () -> strategyService.detail(result.getStrategyId()));
} finally {
WebMvcConfig.RequestContextHolder.clear();
}
}
private SelectedScenario selectScenario() {
LocalDateTime end = LocalDateTime.now();
LocalDateTime start = end.minusYears(1);
List<String> contracts = marketDataProvider.getAvailableContracts();
assertFalse(contracts.isEmpty(), "真实数据库中必须存在合约字典数据");
for (String contract : contracts) {
List<KLineData> kLines = marketDataProvider.getKLineData(contract, "1d", start, end);
if (kLines.size() < 2) {
continue;
}
validateRealKLines(kLines);
for (String indicator : signalStrategyFactory.getAllIndicatorNames()) {
SignalStrategy strategy = signalStrategyFactory.get(indicator);
boolean hasBuy = kLines.stream().anyMatch(kLine -> strategy.isBuySignal(kLine, kLines));
if (hasBuy) {
return new SelectedScenario(contract, indicator, "LONG");
}
boolean hasSell = kLines.stream().anyMatch(kLine -> strategy.isSellSignal(kLine, kLines));
if (hasSell) {
return new SelectedScenario(contract, indicator, "SHORT");
}
}
}
throw new AssertionError("真实数据库近一年日线中未找到可产生交易信号的合约");
}
private void validateRealKLines(List<KLineData> kLines) {
for (int index = 0; index < kLines.size(); index++) {
KLineData current = kLines.get(index);
assertNotNull(current.getIndicators(), "真实K线必须附带指标");
assertTrue(current.getClose().compareTo(BigDecimal.ZERO) > 0);
assertTrue(current.getHigh().compareTo(current.getOpen()) >= 0);
assertTrue(current.getHigh().compareTo(current.getClose()) >= 0);
assertTrue(current.getLow().compareTo(current.getOpen()) <= 0);
assertTrue(current.getLow().compareTo(current.getClose()) <= 0);
if (index > 0) {
assertTrue(current.getTime().isAfter(kLines.get(index - 1).getTime()),
"真实K线必须严格按时间升序");
}
}
for (String indicator : signalStrategyFactory.getAllIndicatorNames()) {
SignalStrategy strategy = signalStrategyFactory.get(indicator);
for (KLineData kLine : kLines) {
strategy.isBuySignal(kLine, kLines);
strategy.isSellSignal(kLine, kLines);
}
}
}
private BigDecimal calculateProfitFromTrades(BacktestResponse result) {
BigDecimal profit = BigDecimal.ZERO;
BigDecimal feeRate = new BigDecimal("0.00005");
for (int index = 0; index < result.getTradeDetails().size(); index += 2) {
BacktestResponse.TradeDetailItem open = result.getTradeDetails().get(index);
BacktestResponse.TradeDetailItem close = result.getTradeDetails().get(index + 1);
BigDecimal priceChange = close.getPrice().subtract(open.getPrice())
.multiply(BigDecimal.valueOf(open.getVolume()));
BigDecimal grossProfit = "SHORT".equalsIgnoreCase(result.getDirection())
? priceChange.negate()
: priceChange;
BigDecimal fees = open.getTurnover().add(close.getTurnover()).multiply(feeRate);
profit = profit.add(grossProfit).subtract(fees);
}
return profit.setScale(2, RoundingMode.HALF_UP);
}
private BigDecimal calculateWinRateFromTrades(BacktestResponse result) {
int wins = 0;
int closedTrades = result.getTradeDetails().size() / 2;
BigDecimal feeRate = new BigDecimal("0.00005");
for (int index = 0; index < result.getTradeDetails().size(); index += 2) {
BacktestResponse.TradeDetailItem open = result.getTradeDetails().get(index);
BacktestResponse.TradeDetailItem close = result.getTradeDetails().get(index + 1);
BigDecimal priceChange = close.getPrice().subtract(open.getPrice())
.multiply(BigDecimal.valueOf(open.getVolume()));
BigDecimal grossProfit = "SHORT".equalsIgnoreCase(result.getDirection())
? priceChange.negate()
: priceChange;
BigDecimal netProfit = grossProfit.subtract(
open.getTurnover().add(close.getTurnover()).multiply(feeRate));
if (netProfit.compareTo(BigDecimal.ZERO) > 0) {
wins++;
}
}
return closedTrades == 0
? BigDecimal.ZERO.setScale(4)
: BigDecimal.valueOf(wins)
.divide(BigDecimal.valueOf(closedTrades), 8, RoundingMode.HALF_UP)
.multiply(BigDecimal.valueOf(100))
.setScale(4, RoundingMode.HALF_UP);
}
private BigDecimal calculateDrawdownFromDailyCurve(BacktestResponse result) {
BigDecimal peak = result.getInitialCapital();
BigDecimal maximum = BigDecimal.ZERO;
for (BacktestResponse.DailyEquityPoint point : result.getDailyEquityCurve()) {
if (point.getEquity().compareTo(peak) > 0) {
peak = point.getEquity();
}
BigDecimal drawdown = peak.subtract(point.getEquity())
.divide(peak, 8, RoundingMode.HALF_UP);
if (drawdown.compareTo(maximum) > 0) {
maximum = drawdown;
}
}
return maximum.multiply(BigDecimal.valueOf(100))
.setScale(4, RoundingMode.HALF_UP);
}
private BacktestRequest requestFor(SelectedScenario scenario) {
BacktestRequest request = new BacktestRequest();
request.setUserId(TEST_USER_ID);
request.setContractCode(scenario.contractCode());
request.setContractName(scenario.contractCode() + "-真实数据验证");
request.setDirection(scenario.direction());
request.setKlinePeriod("1d");
request.setIndicators(List.of(scenario.indicator()));
request.setOpenVolume(2);
request.setVolumeUnit("LOT");
request.setStopLossValue(new BigDecimal("3"));
request.setStopLossUnit("TICK");
request.setTakeProfitValue(new BigDecimal("5"));
request.setTakeProfitUnit("TICK");
request.setBacktestPeriod("1y");
return request;
}
private record SelectedScenario(String contractCode, String indicator, String direction) {
}
}

View File

@ -27,4 +27,10 @@ class PriceScaleConverterTest {
void rejectsBlankPrice() { void rejectsBlankPrice() {
assertThrows(IllegalArgumentException.class, () -> PriceScaleConverter.toScaled("", 100)); assertThrows(IllegalArgumentException.class, () -> PriceScaleConverter.toScaled("", 100));
} }
@Test
void normalizesNegativeUnsignedMarketQuantityToZero() {
assertEquals(0L, PriceScaleConverter.toLong("-1"));
assertEquals(12L, PriceScaleConverter.toLong("12"));
}
} }

View File

@ -0,0 +1,125 @@
package com.yangwale.backtestify.service.market.indicator;
import com.yangwale.backtestify.entity.InstrumentDictionary;
import com.yangwale.backtestify.entity.KLineIndicator;
import com.yangwale.backtestify.entity.KLineRecord;
import com.yangwale.backtestify.mapper.IndicatorMapper;
import com.yangwale.backtestify.mapper.InstrumentDictionaryMapper;
import com.yangwale.backtestify.mapper.KLineMapper;
import com.yangwale.backtestify.service.market.indicator.impl.IndicatorCalculationServiceImpl;
import com.yangwale.backtestify.service.market.repository.KLineTableResolver;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class IndicatorCalculationServiceImplTest {
@Mock
private InstrumentDictionaryMapper instrumentMapper;
@Mock
private KLineMapper kLineMapper;
@Mock
private IndicatorMapper indicatorMapper;
@Mock
private IndicatorCalculator calculator;
private IndicatorCalculationService service;
@BeforeEach
void setUp() {
service = new IndicatorCalculationServiceImpl(
instrumentMapper,
kLineMapper,
indicatorMapper,
new KLineTableResolver(),
calculator);
}
@Test
void rebuildCalculatesAndUpsertsAllHistoricalRows() {
InstrumentDictionary instrument = instrument(7, 100);
List<KLineRecord> kLines = List.of(kLine(1), kLine(2));
List<KLineIndicator> indicators = List.of(indicator(1), indicator(2));
when(instrumentMapper.selectById(7)).thenReturn(instrument);
when(kLineMapper.selectAll("t_kline_1d", 7)).thenReturn(kLines);
when(calculator.calculate(kLines, 100)).thenReturn(indicators);
when(indicatorMapper.upsertBatch("t_indicator_1d", indicators)).thenReturn(2);
int count = service.rebuild("1d", 7);
assertEquals(2, count);
verify(indicatorMapper).upsertBatch("t_indicator_1d", indicators);
}
@Test
void incrementalLoadsContextButOnlyUpsertsRowsAfterLatestIndicator() {
InstrumentDictionary instrument = instrument(7, 100);
KLineIndicator previous = indicator(100);
List<KLineRecord> additions = List.of(kLine(101), kLine(102));
List<KLineRecord> descendingContext = List.of(kLine(100), kLine(99));
List<KLineIndicator> calculated = List.of(indicator(101), indicator(102));
when(instrumentMapper.selectById(7)).thenReturn(instrument);
when(indicatorMapper.selectLatest("t_indicator_1d", 7)).thenReturn(previous);
when(kLineMapper.selectAfter("t_kline_1d", 7, 100L)).thenReturn(additions);
when(kLineMapper.selectBefore("t_kline_1d", 7, 101L, 60)).thenReturn(descendingContext);
when(calculator.calculateIncremental(
List.of(descendingContext.get(1), descendingContext.get(0)),
additions,
previous,
100)).thenReturn(calculated);
when(indicatorMapper.upsertBatch("t_indicator_1d", calculated)).thenReturn(2);
int count = service.updateIncremental("1d", 7);
assertEquals(2, count);
verify(indicatorMapper).upsertBatch("t_indicator_1d", calculated);
}
@Test
void incrementalDoesNothingWhenNoNewKLinesExist() {
when(instrumentMapper.selectById(7)).thenReturn(instrument(7, 100));
when(indicatorMapper.selectLatest("t_indicator_1d", 7)).thenReturn(indicator(100));
when(kLineMapper.selectAfter("t_kline_1d", 7, 100L)).thenReturn(List.of());
assertEquals(0, service.updateIncremental("1d", 7));
verify(calculator, never()).calculateIncremental(anyList(), anyList(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyInt());
verify(indicatorMapper, never()).upsertBatch(org.mockito.ArgumentMatchers.anyString(), anyList());
}
private InstrumentDictionary instrument(int id, int priceScale) {
InstrumentDictionary instrument = new InstrumentDictionary();
instrument.setId(id);
instrument.setPriceScale(priceScale);
return instrument;
}
private KLineRecord kLine(long time) {
return KLineRecord.builder()
.instrumentId(7)
.kTime(time)
.open(100)
.high(101)
.low(99)
.close(100)
.build();
}
private KLineIndicator indicator(long time) {
return KLineIndicator.builder()
.instrumentId(7)
.kTime(time)
.build();
}
}

View File

@ -0,0 +1,82 @@
package com.yangwale.backtestify.service.market.indicator;
import com.yangwale.backtestify.entity.InstrumentDictionary;
import com.yangwale.backtestify.entity.KLineIndicator;
import com.yangwale.backtestify.entity.KLineRecord;
import com.yangwale.backtestify.mapper.IndicatorMapper;
import com.yangwale.backtestify.mapper.InstrumentDictionaryMapper;
import com.yangwale.backtestify.mapper.KLineMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@SpringBootTest
@Transactional
class IndicatorPersistenceIntegrationTest {
@Autowired
private InstrumentDictionaryMapper instrumentMapper;
@Autowired
private KLineMapper kLineMapper;
@Autowired
private IndicatorMapper indicatorMapper;
@Autowired
private IndicatorCalculationService indicatorService;
@Test
void rebuildAndIncrementalUpdatePersistCalculatedIndicators() {
InstrumentDictionary instrument = new InstrumentDictionary();
instrument.setExchangeId("TEST");
instrument.setSymbol("ti");
instrument.setContractCode("ti2601");
instrument.setPriceScale(100);
instrument.setIsMain(0);
instrumentMapper.insert(instrument);
List<KLineRecord> history = records(instrument.getId(), 1, 20);
assertTrue(kLineMapper.upsertBatch("t_kline_1m", history) > 0);
assertTrue(indicatorService.rebuild("1m", instrument.getId()) > 0);
KLineIndicator historicalLatest = indicatorMapper.selectLatest(
"t_indicator_1m", instrument.getId());
assertEquals(20L, historicalLatest.getKTime());
assertEquals(new BigDecimal("10.500000"), historicalLatest.getMa20());
assertEquals(new BigDecimal("100.0000"), historicalLatest.getRsi12());
assertTrue(kLineMapper.upsertBatch(
"t_kline_1m", records(instrument.getId(), 21, 22)) > 0);
assertTrue(indicatorService.updateIncremental("1m", instrument.getId()) > 0);
KLineIndicator incrementalLatest = indicatorMapper.selectLatest(
"t_indicator_1m", instrument.getId());
assertEquals(22L, incrementalLatest.getKTime());
assertEquals(new BigDecimal("12.500000"), incrementalLatest.getMa20());
}
private List<KLineRecord> records(int instrumentId, int from, int to) {
List<KLineRecord> result = new ArrayList<>();
for (int value = from; value <= to; value++) {
int scaledClose = value * 100;
result.add(KLineRecord.builder()
.instrumentId(instrumentId)
.kTime((long) value)
.open(scaledClose)
.high(scaledClose + 100)
.low(scaledClose - 100)
.close(scaledClose)
.volume(100L)
.turnover(1000L)
.openInterest(50L)
.build());
}
return result;
}
}

View File

@ -0,0 +1,159 @@
package com.yangwale.backtestify.service.market.indicator;
import com.yangwale.backtestify.entity.KLineIndicator;
import com.yangwale.backtestify.entity.KLineRecord;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class TechnicalIndicatorCalculatorTest {
private final TechnicalIndicatorCalculator calculator = new TechnicalIndicatorCalculator();
@Test
void calculatesMaBollEmaAndMacdUsingConfiguredIndustryPeriods() {
List<KLineIndicator> values = calculator.calculate(sequence(1, 60), 1);
assertNull(values.get(3).getMa5());
assertDecimal("3.000000", values.get(4).getMa5());
assertDecimal("5.500000", values.get(9).getMa10());
assertDecimal("10.500000", values.get(19).getMa20());
assertDecimal("30.500000", values.get(59).getMa60());
assertNull(values.get(18).getBollMb());
assertDecimal("10.500000", values.get(19).getBollMb());
assertDecimal("22.032563", values.get(19).getBollUp());
assertDecimal("-1.032563", values.get(19).getBollDn());
assertDecimal("1.000000", values.getFirst().getEma6());
assertDecimal("1.285714", values.get(1).getEma6());
assertDecimal("1.153846", values.get(1).getEma12());
assertDecimal("1.095238", values.get(1).getEma20());
assertDecimal("0.000000", values.getFirst().getMacdDif());
assertDecimal("0.000000", values.getFirst().getMacdDea());
assertDecimal("0.000000", values.getFirst().getMacdBar());
assertDecimal("0.079772", values.get(1).getMacdDif());
assertDecimal("0.015954", values.get(1).getMacdDea());
assertDecimal("0.127635", values.get(1).getMacdBar());
}
@Test
void calculatesSimpleMovingAverageRsiAndHandlesFlatPrices() {
List<KLineIndicator> mixed = calculator.calculate(records(
1, 2, 1, 3, 2, 4, 3, 5, 4, 6, 5, 7, 6), 1);
assertNull(mixed.get(5).getRsi6());
assertDecimal("62.5000", mixed.get(6).getRsi6());
assertNull(mixed.get(11).getRsi12());
assertDecimal("64.7059", mixed.get(12).getRsi12());
List<KLineIndicator> flat = calculator.calculate(records(
10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10), 1);
assertDecimal("50.0000", flat.get(6).getRsi6());
assertDecimal("50.0000", flat.get(12).getRsi12());
}
@Test
void initializesAndSmoothsKdjFromFifty() {
List<KLineIndicator> values = calculator.calculate(records(1, 2), 1);
assertDecimal("50.0000", values.getFirst().getKdjK());
assertDecimal("50.0000", values.getFirst().getKdjD());
assertDecimal("50.0000", values.getFirst().getKdjJ());
assertDecimal("55.5556", values.get(1).getKdjK());
assertDecimal("51.8519", values.get(1).getKdjD());
assertDecimal("62.9630", values.get(1).getKdjJ());
}
@Test
void incrementalCalculationMatchesFullHistoryAndOnlyReturnsNewRows() {
List<KLineRecord> all = sequence(1, 70);
List<KLineIndicator> full = calculator.calculate(all, 1);
List<KLineRecord> history = all.subList(0, 60);
List<KLineRecord> additions = all.subList(60, 70);
List<KLineIndicator> incremental = calculator.calculateIncremental(
history.subList(history.size() - 60, history.size()),
additions,
full.get(59),
1);
assertEquals(10, incremental.size());
for (int i = 0; i < incremental.size(); i++) {
KLineIndicator expected = full.get(60 + i);
KLineIndicator actual = incremental.get(i);
assertEquals(expected.getKTime(), actual.getKTime());
assertEquals(expected.getMa60(), actual.getMa60());
assertEquals(expected.getBollUp(), actual.getBollUp());
assertClose(expected.getEma20(), actual.getEma20(), new BigDecimal("0.000002"));
assertClose(expected.getMacdBar(), actual.getMacdBar(), new BigDecimal("0.000002"));
assertEquals(expected.getRsi12(), actual.getRsi12());
assertClose(expected.getKdjJ(), actual.getKdjJ(), new BigDecimal("0.0002"));
}
}
@Test
void incrementalMacdUsesPreviousDifWhenPreviousDeaIsMissing() {
KLineIndicator previous = KLineIndicator.builder()
.instrumentId(7)
.kTime(1L)
.ema6(new BigDecimal("10"))
.ema12(new BigDecimal("10"))
.ema20(new BigDecimal("10"))
.macdDif(new BigDecimal("2"))
.macdDea(null)
.kdjK(new BigDecimal("50"))
.kdjD(new BigDecimal("50"))
.build();
KLineIndicator actual = calculator.calculateIncremental(
List.of(),
records(13),
previous,
1).getFirst();
assertDecimal("2.018234", actual.getMacdDea());
}
private List<KLineRecord> sequence(int from, int to) {
List<Integer> closes = new ArrayList<>();
for (int value = from; value <= to; value++) {
closes.add(value);
}
return records(closes.stream().mapToInt(Integer::intValue).toArray());
}
private List<KLineRecord> records(int... closes) {
List<KLineRecord> records = new ArrayList<>();
for (int i = 0; i < closes.length; i++) {
int close = closes[i];
records.add(KLineRecord.builder()
.instrumentId(7)
.kTime((long) i + 1)
.open(close)
.high(close + 1)
.low(close - 1)
.close(close)
.volume(100L)
.turnover(1000L)
.openInterest(50L)
.build());
}
return records;
}
private void assertDecimal(String expected, BigDecimal actual) {
assertEquals(new BigDecimal(expected), actual);
}
private void assertClose(BigDecimal expected, BigDecimal actual, BigDecimal tolerance) {
assertTrue(expected.subtract(actual).abs().compareTo(tolerance) <= 0,
() -> "expected " + expected + " but was " + actual);
}
}

View File

@ -13,6 +13,7 @@ class KLineTableResolverTest {
@Test @Test
void resolvesSupportedRealMarketPeriods() { void resolvesSupportedRealMarketPeriods() {
assertEquals("t_kline_1m", resolver.resolve("1m")); assertEquals("t_kline_1m", resolver.resolve("1m"));
assertEquals("t_kline_3m", resolver.resolve("3m"));
assertEquals("t_kline_5m", resolver.resolve("5m")); assertEquals("t_kline_5m", resolver.resolve("5m"));
assertEquals("t_kline_15m", resolver.resolve("15m")); assertEquals("t_kline_15m", resolver.resolve("15m"));
assertEquals("t_kline_30m", resolver.resolve("30m")); assertEquals("t_kline_30m", resolver.resolve("30m"));
@ -20,10 +21,15 @@ class KLineTableResolverTest {
assertEquals("t_kline_4h", resolver.resolve("4h")); assertEquals("t_kline_4h", resolver.resolve("4h"));
assertEquals("t_kline_1d", resolver.resolve("1d")); assertEquals("t_kline_1d", resolver.resolve("1d"));
assertEquals("t_kline_1w", resolver.resolve("1w")); assertEquals("t_kline_1w", resolver.resolve("1w"));
assertEquals("t_kline_1mo", resolver.resolve("1mo"));
assertEquals("t_indicator_1m", resolver.resolveIndicator("1m"));
assertEquals("t_indicator_3m", resolver.resolveIndicator("3m"));
assertEquals("t_indicator_1d", resolver.resolveIndicator("1d"));
assertEquals("t_indicator_1mo", resolver.resolveIndicator("1mo"));
} }
@Test @Test
void rejects3mBecauseRealMarketDoesNotProvideIt() { void rejectsUnknownPeriod() {
assertThrows(BusinessException.class, () -> resolver.resolve("3m")); assertThrows(BusinessException.class, () -> resolver.resolve("2h"));
} }
} }

View File

@ -0,0 +1,427 @@
package com.yangwale.backtestify.service.market.sync;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.yangwale.backtestify.config.MarketDataProperties;
import com.yangwale.backtestify.entity.InstrumentDictionary;
import com.yangwale.backtestify.entity.KLineRecord;
import com.yangwale.backtestify.entity.MarketDataSyncLog;
import com.yangwale.backtestify.mapper.InstrumentDictionaryMapper;
import com.yangwale.backtestify.mapper.KLineMapper;
import com.yangwale.backtestify.mapper.MarketDataSyncLogMapper;
import com.yangwale.backtestify.service.market.client.CnQuotationClient;
import com.yangwale.backtestify.service.market.client.CnQuotationModels;
import com.yangwale.backtestify.service.market.indicator.IndicatorCalculationService;
import com.yangwale.backtestify.service.market.repository.KLineTableResolver;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.SimpleTransactionStatus;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class MarketDataSyncServiceTest {
@Test
void refreshAdoptsApiContractCodeCasingWithoutCreatingDuplicate() {
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), ""),
InstrumentDictionary.class);
MarketDataProperties properties = new MarketDataProperties();
InstrumentDictionaryMapper instrumentMapper = mock(InstrumentDictionaryMapper.class);
KLineMapper kLineMapper = mock(KLineMapper.class);
MarketDataSyncLogMapper syncLogMapper = mock(MarketDataSyncLogMapper.class);
IndicatorCalculationService indicatorService = mock(IndicatorCalculationService.class);
InstrumentDictionary existing = new InstrumentDictionary();
existing.setId(7);
existing.setExchangeId("CZCE");
existing.setContractCode("ap610");
existing.setPriceScale(1000);
when(instrumentMapper.selectByExchangeAndContractCodeIgnoreCase("CZCE", "AP610"))
.thenReturn(List.of(existing));
MarketDataSyncService service = service(properties,
new CanonicalQuotationClient(properties), instrumentMapper, kLineMapper,
syncLogMapper, indicatorService);
service.refreshMainContracts();
ArgumentCaptor<InstrumentDictionary> updated = ArgumentCaptor.forClass(InstrumentDictionary.class);
verify(instrumentMapper).updateById(updated.capture());
assertEquals("AP610", updated.getValue().getContractCode());
verify(instrumentMapper, never()).insert(any(InstrumentDictionary.class));
}
@Test
void repairFindsContractIgnoringCallerCasingAndUsesStoredCanonicalCode() {
MarketDataProperties properties = new MarketDataProperties();
properties.getSync().setPeriods(List.of("1d"));
InstrumentDictionaryMapper instrumentMapper = mock(InstrumentDictionaryMapper.class);
KLineMapper kLineMapper = mock(KLineMapper.class);
MarketDataSyncLogMapper syncLogMapper = mock(MarketDataSyncLogMapper.class);
IndicatorCalculationService indicatorService = mock(IndicatorCalculationService.class);
InstrumentDictionary instrument = new InstrumentDictionary();
instrument.setId(7);
instrument.setExchangeId("CZCE");
instrument.setContractCode("AP610");
instrument.setPriceScale(1000);
instrument.setIsMain(1);
when(instrumentMapper.selectMainByContractCodeIgnoreCase("ap610"))
.thenReturn(List.of(instrument));
CapturingRepairQuotationClient quotationClient = new CapturingRepairQuotationClient(properties);
MarketDataSyncService service = service(properties, quotationClient,
instrumentMapper, kLineMapper, syncLogMapper, indicatorService);
service.repairContractFrom("ap610", 100L);
assertEquals(List.of("AP610"), quotationClient.requestedCodes);
verify(indicatorService).rebuild("1d", 7);
}
@Test
void repairRejectsContractThatIsNotCurrentMain() {
MarketDataProperties properties = new MarketDataProperties();
InstrumentDictionaryMapper instrumentMapper = mock(InstrumentDictionaryMapper.class);
when(instrumentMapper.selectMainByContractCodeIgnoreCase("AP610"))
.thenReturn(List.of());
MarketDataSyncService service = service(properties,
new EmptyQuotationClient(properties), instrumentMapper, mock(KLineMapper.class),
mock(MarketDataSyncLogMapper.class), mock(IndicatorCalculationService.class));
assertThrows(IllegalArgumentException.class,
() -> service.repairContractFrom("AP610", 100L));
}
@Test
void refreshPreservesExistingPriceScale() {
MarketDataProperties properties = new MarketDataProperties();
InstrumentDictionaryMapper instrumentMapper = mock(InstrumentDictionaryMapper.class);
KLineMapper kLineMapper = mock(KLineMapper.class);
MarketDataSyncLogMapper syncLogMapper = mock(MarketDataSyncLogMapper.class);
IndicatorCalculationService indicatorService = mock(IndicatorCalculationService.class);
InstrumentDictionary existing = new InstrumentDictionary();
existing.setId(7);
existing.setContractCode("rb2610");
existing.setPriceScale(500);
when(instrumentMapper.selectByExchangeAndContractCodeIgnoreCase("SHFE", "rb2610"))
.thenReturn(List.of(existing));
MarketDataSyncService service = service(properties,
new EmptyQuotationClient(properties), instrumentMapper, kLineMapper,
syncLogMapper, indicatorService);
ReflectionTestUtils.invokeMethod(service, "upsertInstrument", goodsItem());
ArgumentCaptor<InstrumentDictionary> updated = ArgumentCaptor.forClass(InstrumentDictionary.class);
verify(instrumentMapper).updateById(updated.capture());
assertEquals(500, updated.getValue().getPriceScale());
}
@Test
void refreshUsesDefaultScaleForNewContract() {
MarketDataProperties properties = new MarketDataProperties();
InstrumentDictionaryMapper instrumentMapper = mock(InstrumentDictionaryMapper.class);
KLineMapper kLineMapper = mock(KLineMapper.class);
MarketDataSyncLogMapper syncLogMapper = mock(MarketDataSyncLogMapper.class);
IndicatorCalculationService indicatorService = mock(IndicatorCalculationService.class);
MarketDataSyncService service = service(properties,
new EmptyQuotationClient(properties), instrumentMapper, kLineMapper,
syncLogMapper, indicatorService);
ReflectionTestUtils.invokeMethod(service, "upsertInstrument", goodsItem());
ArgumentCaptor<InstrumentDictionary> inserted = ArgumentCaptor.forClass(InstrumentDictionary.class);
verify(instrumentMapper).insert(inserted.capture());
assertEquals(1000, inserted.getValue().getPriceScale());
}
@Test
void catchesUpFromLatestDatabaseTimestampAcrossMultiplePages() {
MarketDataProperties properties = new MarketDataProperties();
properties.getSync().setPeriods(List.of("1d"));
InstrumentDictionaryMapper instrumentMapper = mock(InstrumentDictionaryMapper.class);
KLineMapper kLineMapper = mock(KLineMapper.class);
MarketDataSyncLogMapper syncLogMapper = mock(MarketDataSyncLogMapper.class);
IndicatorCalculationService indicatorService = mock(IndicatorCalculationService.class);
InstrumentDictionary instrument = new InstrumentDictionary();
instrument.setId(7);
instrument.setExchangeId("SHFE");
instrument.setContractCode("rb2610");
instrument.setPriceScale(100);
instrument.setIsMain(1);
when(instrumentMapper.selectList(any(Wrapper.class))).thenReturn(List.of(instrument));
when(kLineMapper.selectLatestTimestamp("t_kline_1d", 7)).thenReturn(100L);
when(kLineMapper.upsertBatch(eq("t_kline_1d"), anyList()))
.thenAnswer(invocation -> invocation.<List<KLineRecord>>getArgument(1).size());
FakeQuotationClient quotationClient = new FakeQuotationClient(properties);
MarketDataSyncService service = new MarketDataSyncService(
properties,
quotationClient,
instrumentMapper,
kLineMapper,
syncLogMapper,
new KLineTableResolver(),
indicatorService,
transactionTemplate()) {
@Override
public void refreshMainContracts() {
// Contract refresh is outside this test's synchronization seam.
}
};
service.syncIncrementalForMainContracts();
ArgumentCaptor<List<KLineRecord>> batches = ArgumentCaptor.forClass(List.class);
verify(kLineMapper, times(2)).upsertBatch(eq("t_kline_1d"), batches.capture());
assertEquals(List.of(101L, 102L), batches.getAllValues().get(0).stream()
.map(KLineRecord::getKTime).toList());
assertEquals(List.of(103L), batches.getAllValues().get(1).stream()
.map(KLineRecord::getKTime).toList());
assertEquals(List.of(100L, 102L, 103L), quotationClient.requestedCursors);
verify(indicatorService).updateIncremental("1d", 7);
verify(syncLogMapper, org.mockito.Mockito.atLeastOnce()).insert(any(MarketDataSyncLog.class));
}
@Test
void initializesIndicatorsEvenWhenMarketApiHasNoNewBars() {
MarketDataProperties properties = new MarketDataProperties();
properties.getSync().setPeriods(List.of("1d"));
InstrumentDictionaryMapper instrumentMapper = mock(InstrumentDictionaryMapper.class);
KLineMapper kLineMapper = mock(KLineMapper.class);
MarketDataSyncLogMapper syncLogMapper = mock(MarketDataSyncLogMapper.class);
IndicatorCalculationService indicatorService = mock(IndicatorCalculationService.class);
CnQuotationClient quotationClient = new EmptyQuotationClient(properties);
InstrumentDictionary instrument = new InstrumentDictionary();
instrument.setId(7);
instrument.setExchangeId("SHFE");
instrument.setContractCode("rb2610");
instrument.setPriceScale(100);
instrument.setIsMain(1);
when(instrumentMapper.selectList(any(Wrapper.class))).thenReturn(List.of(instrument));
when(kLineMapper.selectLatestTimestamp("t_kline_1d", 7)).thenReturn(100L);
MarketDataSyncService service = new MarketDataSyncService(
properties, quotationClient, instrumentMapper, kLineMapper, syncLogMapper,
new KLineTableResolver(), indicatorService, transactionTemplate()) {
@Override
public void refreshMainContracts() {
// Contract refresh is outside this test's synchronization seam.
}
};
service.syncIncrementalForMainContracts();
verify(kLineMapper, never()).upsertBatch(eq("t_kline_1d"), anyList());
verify(indicatorService).updateIncremental("1d", 7);
}
@Test
void repairRewritesExistingTimestampUsingContractScaleAndRebuildsIndicators() {
MarketDataProperties properties = new MarketDataProperties();
properties.getSync().setPeriods(List.of("1d"));
InstrumentDictionaryMapper instrumentMapper = mock(InstrumentDictionaryMapper.class);
KLineMapper kLineMapper = mock(KLineMapper.class);
MarketDataSyncLogMapper syncLogMapper = mock(MarketDataSyncLogMapper.class);
IndicatorCalculationService indicatorService = mock(IndicatorCalculationService.class);
InstrumentDictionary instrument = new InstrumentDictionary();
instrument.setId(7);
instrument.setExchangeId("SHFE");
instrument.setContractCode("rb2610");
instrument.setPriceScale(1000);
instrument.setIsMain(1);
when(instrumentMapper.selectList(any(Wrapper.class))).thenReturn(List.of(instrument));
MarketDataSyncService service = new MarketDataSyncService(
properties, new RepairQuotationClient(properties), instrumentMapper,
kLineMapper, syncLogMapper, new KLineTableResolver(), indicatorService,
transactionTemplate()) {
@Override
public void refreshMainContracts() {
// Contract refresh is outside this test's synchronization seam.
}
};
service.repairMainContractsFrom(100L);
ArgumentCaptor<List<KLineRecord>> records = ArgumentCaptor.forClass(List.class);
verify(kLineMapper).upsertBatch(eq("t_kline_1d"), records.capture());
assertEquals(100L, records.getValue().getFirst().getKTime());
assertEquals(100500, records.getValue().getFirst().getClose());
verify(indicatorService).rebuild("1d", 7);
verify(indicatorService, never()).updateIncremental("1d", 7);
}
@Test
void repairsOnlyRequestedContractFromForcedCursor() {
MarketDataProperties properties = new MarketDataProperties();
properties.getSync().setPeriods(List.of("1d"));
InstrumentDictionaryMapper instrumentMapper = mock(InstrumentDictionaryMapper.class);
KLineMapper kLineMapper = mock(KLineMapper.class);
MarketDataSyncLogMapper syncLogMapper = mock(MarketDataSyncLogMapper.class);
IndicatorCalculationService indicatorService = mock(IndicatorCalculationService.class);
InstrumentDictionary instrument = new InstrumentDictionary();
instrument.setId(7);
instrument.setExchangeId("SHFE");
instrument.setContractCode("rb2610");
instrument.setPriceScale(1000);
instrument.setIsMain(1);
when(instrumentMapper.selectMainByContractCodeIgnoreCase("rb2610"))
.thenReturn(List.of(instrument));
MarketDataSyncService service = service(properties, new RepairQuotationClient(properties),
instrumentMapper, kLineMapper, syncLogMapper, indicatorService);
service.repairContractFrom("rb2610", 100L);
verify(kLineMapper).upsertBatch(eq("t_kline_1d"), anyList());
verify(indicatorService).rebuild("1d", 7);
verify(instrumentMapper, never()).selectList(any(Wrapper.class));
}
private MarketDataSyncService service(MarketDataProperties properties,
CnQuotationClient quotationClient,
InstrumentDictionaryMapper instrumentMapper,
KLineMapper kLineMapper,
MarketDataSyncLogMapper syncLogMapper,
IndicatorCalculationService indicatorService) {
return new MarketDataSyncService(
properties, quotationClient, instrumentMapper, kLineMapper, syncLogMapper,
new KLineTableResolver(), indicatorService, transactionTemplate());
}
private TransactionTemplate transactionTemplate() {
return new TransactionTemplate(new PlatformTransactionManager() {
@Override
public TransactionStatus getTransaction(TransactionDefinition definition) {
return new SimpleTransactionStatus();
}
@Override
public void commit(TransactionStatus status) {
}
@Override
public void rollback(TransactionStatus status) {
}
});
}
private CnQuotationModels.GoodsItem goodsItem() {
return new CnQuotationModels.GoodsItem(
"SHFE", "rb", "rb", "螺纹钢", "rb2610", 1);
}
private static final class FakeQuotationClient extends CnQuotationClient {
private final List<Long> requestedCursors = new ArrayList<>();
private FakeQuotationClient(MarketDataProperties properties) {
super(properties);
}
@Override
public List<CnQuotationModels.GoodsItem> listMainContracts() {
return List.of();
}
@Override
public List<CnQuotationModels.KChartItem> getKChartByDate(
String excode, String code, String period, long date, String direction) {
requestedCursors.add(date);
if (date == 100L) {
return List.of(item(101L), item(102L));
}
if (date == 102L) {
return List.of(item(103L));
}
return List.of();
}
private CnQuotationModels.KChartItem item(long timestamp) {
return new CnQuotationModels.KChartItem(
null, "100.00", "101.00", "99.00", "100.50",
"10", "1000", timestamp, "20", null);
}
}
private static class EmptyQuotationClient extends CnQuotationClient {
protected EmptyQuotationClient(MarketDataProperties properties) {
super(properties);
}
@Override
public List<CnQuotationModels.KChartItem> getKChartByDate(
String excode, String code, String period, long date, String direction) {
return List.of();
}
}
private static final class RepairQuotationClient extends EmptyQuotationClient {
private RepairQuotationClient(MarketDataProperties properties) {
super(properties);
}
@Override
public List<CnQuotationModels.KChartItem> getKChartByDate(
String excode, String code, String period, long date, String direction) {
if (date == 99L) {
return List.of(new CnQuotationModels.KChartItem(
null, "100.00", "101.00", "99.00", "100.50",
"10", "1000", 100L, "20", null));
}
return List.of();
}
}
private static final class CanonicalQuotationClient extends EmptyQuotationClient {
private CanonicalQuotationClient(MarketDataProperties properties) {
super(properties);
}
@Override
public List<CnQuotationModels.GoodsItem> listMainContracts() {
return List.of(new CnQuotationModels.GoodsItem(
"CZCE", "AP", "AP", "苹果", "AP610", 1));
}
}
private static final class CapturingRepairQuotationClient extends EmptyQuotationClient {
private final List<String> requestedCodes = new ArrayList<>();
private CapturingRepairQuotationClient(MarketDataProperties properties) {
super(properties);
}
@Override
public List<CnQuotationModels.KChartItem> getKChartByDate(
String excode, String code, String period, long date, String direction) {
requestedCodes.add(code);
return List.of();
}
}
}

View File

@ -4,17 +4,18 @@ CREATE TABLE IF NOT EXISTS t_instrument_dictionary (
exchange_id VARCHAR(16), exchange_id VARCHAR(16),
symbol VARCHAR(10) NOT NULL, symbol VARCHAR(10) NOT NULL,
contract_code VARCHAR(20) NOT NULL, contract_code VARCHAR(20) NOT NULL,
price_scale INT NOT NULL DEFAULT 100, price_scale INT NOT NULL DEFAULT 1000,
price_tick DECIMAL(18, 6) NOT NULL DEFAULT 1,
is_main TINYINT NOT NULL DEFAULT 0, is_main TINYINT NOT NULL DEFAULT 0,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_deleted TINYINT NOT NULL DEFAULT 0, is_deleted TINYINT NOT NULL DEFAULT 0,
UNIQUE (contract_code) UNIQUE (exchange_id, contract_code)
); );
CREATE TABLE IF NOT EXISTS t_kline_1m ( CREATE TABLE IF NOT EXISTS t_kline_1m (
instrument_id SMALLINT NOT NULL, instrument_id SMALLINT NOT NULL,
timestamp INT NOT NULL, k_time INT NOT NULL,
open INT NOT NULL, open INT NOT NULL,
high INT NOT NULL, high INT NOT NULL,
low INT NOT NULL, low INT NOT NULL,
@ -22,11 +23,11 @@ CREATE TABLE IF NOT EXISTS t_kline_1m (
volume INT NOT NULL DEFAULT 0, volume INT NOT NULL DEFAULT 0,
turnover BIGINT NOT NULL DEFAULT 0, turnover BIGINT NOT NULL DEFAULT 0,
open_interest INT NOT NULL DEFAULT 0, open_interest INT NOT NULL DEFAULT 0,
PRIMARY KEY (instrument_id, timestamp) PRIMARY KEY (instrument_id, k_time)
); );
CREATE TABLE IF NOT EXISTS t_kline_5m ( CREATE TABLE IF NOT EXISTS t_kline_5m (
instrument_id SMALLINT NOT NULL, instrument_id SMALLINT NOT NULL,
timestamp INT NOT NULL, k_time INT NOT NULL,
open INT NOT NULL, open INT NOT NULL,
high INT NOT NULL, high INT NOT NULL,
low INT NOT NULL, low INT NOT NULL,
@ -34,11 +35,11 @@ CREATE TABLE IF NOT EXISTS t_kline_5m (
volume INT NOT NULL DEFAULT 0, volume INT NOT NULL DEFAULT 0,
turnover BIGINT NOT NULL DEFAULT 0, turnover BIGINT NOT NULL DEFAULT 0,
open_interest INT NOT NULL DEFAULT 0, open_interest INT NOT NULL DEFAULT 0,
PRIMARY KEY (instrument_id, timestamp) PRIMARY KEY (instrument_id, k_time)
); );
CREATE TABLE IF NOT EXISTS t_kline_15m ( CREATE TABLE IF NOT EXISTS t_kline_15m (
instrument_id SMALLINT NOT NULL, instrument_id SMALLINT NOT NULL,
timestamp INT NOT NULL, k_time INT NOT NULL,
open INT NOT NULL, open INT NOT NULL,
high INT NOT NULL, high INT NOT NULL,
low INT NOT NULL, low INT NOT NULL,
@ -46,11 +47,11 @@ CREATE TABLE IF NOT EXISTS t_kline_15m (
volume INT NOT NULL DEFAULT 0, volume INT NOT NULL DEFAULT 0,
turnover BIGINT NOT NULL DEFAULT 0, turnover BIGINT NOT NULL DEFAULT 0,
open_interest INT NOT NULL DEFAULT 0, open_interest INT NOT NULL DEFAULT 0,
PRIMARY KEY (instrument_id, timestamp) PRIMARY KEY (instrument_id, k_time)
); );
CREATE TABLE IF NOT EXISTS t_kline_30m ( CREATE TABLE IF NOT EXISTS t_kline_30m (
instrument_id SMALLINT NOT NULL, instrument_id SMALLINT NOT NULL,
timestamp INT NOT NULL, k_time INT NOT NULL,
open INT NOT NULL, open INT NOT NULL,
high INT NOT NULL, high INT NOT NULL,
low INT NOT NULL, low INT NOT NULL,
@ -58,11 +59,11 @@ CREATE TABLE IF NOT EXISTS t_kline_30m (
volume INT NOT NULL DEFAULT 0, volume INT NOT NULL DEFAULT 0,
turnover BIGINT NOT NULL DEFAULT 0, turnover BIGINT NOT NULL DEFAULT 0,
open_interest INT NOT NULL DEFAULT 0, open_interest INT NOT NULL DEFAULT 0,
PRIMARY KEY (instrument_id, timestamp) PRIMARY KEY (instrument_id, k_time)
); );
CREATE TABLE IF NOT EXISTS t_kline_1h ( CREATE TABLE IF NOT EXISTS t_kline_1h (
instrument_id SMALLINT NOT NULL, instrument_id SMALLINT NOT NULL,
timestamp INT NOT NULL, k_time INT NOT NULL,
open INT NOT NULL, open INT NOT NULL,
high INT NOT NULL, high INT NOT NULL,
low INT NOT NULL, low INT NOT NULL,
@ -70,11 +71,11 @@ CREATE TABLE IF NOT EXISTS t_kline_1h (
volume INT NOT NULL DEFAULT 0, volume INT NOT NULL DEFAULT 0,
turnover BIGINT NOT NULL DEFAULT 0, turnover BIGINT NOT NULL DEFAULT 0,
open_interest INT NOT NULL DEFAULT 0, open_interest INT NOT NULL DEFAULT 0,
PRIMARY KEY (instrument_id, timestamp) PRIMARY KEY (instrument_id, k_time)
); );
CREATE TABLE IF NOT EXISTS t_kline_4h ( CREATE TABLE IF NOT EXISTS t_kline_4h (
instrument_id SMALLINT NOT NULL, instrument_id SMALLINT NOT NULL,
timestamp INT NOT NULL, k_time INT NOT NULL,
open INT NOT NULL, open INT NOT NULL,
high INT NOT NULL, high INT NOT NULL,
low INT NOT NULL, low INT NOT NULL,
@ -82,11 +83,11 @@ CREATE TABLE IF NOT EXISTS t_kline_4h (
volume INT NOT NULL DEFAULT 0, volume INT NOT NULL DEFAULT 0,
turnover BIGINT NOT NULL DEFAULT 0, turnover BIGINT NOT NULL DEFAULT 0,
open_interest INT NOT NULL DEFAULT 0, open_interest INT NOT NULL DEFAULT 0,
PRIMARY KEY (instrument_id, timestamp) PRIMARY KEY (instrument_id, k_time)
); );
CREATE TABLE IF NOT EXISTS t_kline_1d ( CREATE TABLE IF NOT EXISTS t_kline_1d (
instrument_id SMALLINT NOT NULL, instrument_id SMALLINT NOT NULL,
timestamp INT NOT NULL, k_time INT NOT NULL,
open INT NOT NULL, open INT NOT NULL,
high INT NOT NULL, high INT NOT NULL,
low INT NOT NULL, low INT NOT NULL,
@ -94,11 +95,11 @@ CREATE TABLE IF NOT EXISTS t_kline_1d (
volume INT NOT NULL DEFAULT 0, volume INT NOT NULL DEFAULT 0,
turnover BIGINT NOT NULL DEFAULT 0, turnover BIGINT NOT NULL DEFAULT 0,
open_interest INT NOT NULL DEFAULT 0, open_interest INT NOT NULL DEFAULT 0,
PRIMARY KEY (instrument_id, timestamp) PRIMARY KEY (instrument_id, k_time)
); );
CREATE TABLE IF NOT EXISTS t_kline_1w ( CREATE TABLE IF NOT EXISTS t_kline_1w (
instrument_id SMALLINT NOT NULL, instrument_id SMALLINT NOT NULL,
timestamp INT NOT NULL, k_time INT NOT NULL,
open INT NOT NULL, open INT NOT NULL,
high INT NOT NULL, high INT NOT NULL,
low INT NOT NULL, low INT NOT NULL,
@ -106,9 +107,41 @@ CREATE TABLE IF NOT EXISTS t_kline_1w (
volume INT NOT NULL DEFAULT 0, volume INT NOT NULL DEFAULT 0,
turnover BIGINT NOT NULL DEFAULT 0, turnover BIGINT NOT NULL DEFAULT 0,
open_interest INT NOT NULL DEFAULT 0, open_interest INT NOT NULL DEFAULT 0,
PRIMARY KEY (instrument_id, timestamp) PRIMARY KEY (instrument_id, k_time)
); );
CREATE TABLE IF NOT EXISTS t_indicator_1m (
instrument_id SMALLINT NOT NULL,
k_time INT NOT NULL,
ma5 DECIMAL(18,6),
ma10 DECIMAL(18,6),
ma20 DECIMAL(18,6),
ma60 DECIMAL(18,6),
boll_mb DECIMAL(18,6),
boll_up DECIMAL(18,6),
boll_dn DECIMAL(18,6),
ema6 DECIMAL(18,6),
ema12 DECIMAL(18,6),
ema20 DECIMAL(18,6),
macd_dif DECIMAL(18,6),
macd_dea DECIMAL(18,6),
macd_bar DECIMAL(18,6),
rsi6 DECIMAL(10,4),
rsi12 DECIMAL(10,4),
kdj_k DECIMAL(10,4),
kdj_d DECIMAL(10,4),
kdj_j DECIMAL(10,4),
create_at INT,
PRIMARY KEY (instrument_id, k_time)
);
CREATE TABLE IF NOT EXISTS t_indicator_5m AS SELECT * FROM t_indicator_1m WHERE 1 = 0;
CREATE TABLE IF NOT EXISTS t_indicator_15m AS SELECT * FROM t_indicator_1m WHERE 1 = 0;
CREATE TABLE IF NOT EXISTS t_indicator_30m AS SELECT * FROM t_indicator_1m WHERE 1 = 0;
CREATE TABLE IF NOT EXISTS t_indicator_1h AS SELECT * FROM t_indicator_1m WHERE 1 = 0;
CREATE TABLE IF NOT EXISTS t_indicator_4h AS SELECT * FROM t_indicator_1m WHERE 1 = 0;
CREATE TABLE IF NOT EXISTS t_indicator_1d AS SELECT * FROM t_indicator_1m WHERE 1 = 0;
CREATE TABLE IF NOT EXISTS t_indicator_1w AS SELECT * FROM t_indicator_1m WHERE 1 = 0;
CREATE TABLE IF NOT EXISTS t_market_data_sync_log ( CREATE TABLE IF NOT EXISTS t_market_data_sync_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY, id BIGINT AUTO_INCREMENT PRIMARY KEY,
sync_type VARCHAR(32) NOT NULL, sync_type VARCHAR(32) NOT NULL,