Compare commits
No commits in common. "900c1a2ab8ea8fac1fb422f5eea3e5d674e6c9b4" and "61dd0073d53c19eae1502721fee47a0e5b7437d3" have entirely different histories.
900c1a2ab8
...
61dd0073d5
@ -7,8 +7,6 @@ import com.yangwale.backtestify.common.BaseEntity;
|
|||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import lombok.Setter;
|
import lombok.Setter;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 合约字典表
|
* 合约字典表
|
||||||
*/
|
*/
|
||||||
@ -32,9 +30,6 @@ public class InstrumentDictionary extends BaseEntity {
|
|||||||
/** 价格放大倍数 */
|
/** 价格放大倍数 */
|
||||||
private Integer priceScale;
|
private Integer priceScale;
|
||||||
|
|
||||||
/** 合约最小变动价位(不参与行情价格缩放) */
|
|
||||||
private BigDecimal priceTick;
|
|
||||||
|
|
||||||
/** 是否当前主力合约:0-否,1-是 */
|
/** 是否当前主力合约:0-否,1-是 */
|
||||||
private Integer isMain;
|
private Integer isMain;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,49 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -15,7 +15,7 @@ public class KLineRecord {
|
|||||||
private Integer instrumentId;
|
private Integer instrumentId;
|
||||||
|
|
||||||
/** Unix时间戳,秒级 */
|
/** Unix时间戳,秒级 */
|
||||||
private Long kTime;
|
private Long timestamp;
|
||||||
|
|
||||||
private Integer open;
|
private Integer open;
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
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;
|
||||||
@ -23,7 +22,6 @@ public class MarketDataSyncLog {
|
|||||||
|
|
||||||
private String syncType;
|
private String syncType;
|
||||||
|
|
||||||
@TableField("f_period")
|
|
||||||
private String period;
|
private String period;
|
||||||
|
|
||||||
private String contractCode;
|
private String contractCode;
|
||||||
|
|||||||
@ -1,77 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@ -3,33 +3,7 @@ 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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,69 +12,25 @@ import java.util.List;
|
|||||||
public interface KLineMapper {
|
public interface KLineMapper {
|
||||||
|
|
||||||
@Select("""
|
@Select("""
|
||||||
SELECT instrument_id, k_time, open, high, low, close,
|
SELECT instrument_id, timestamp, 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 k_time BETWEEN #{startTimestamp} AND #{endTimestamp}
|
AND timestamp BETWEEN #{startTimestamp} AND #{endTimestamp}
|
||||||
ORDER BY k_time ASC
|
ORDER BY timestamp 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, k_time, open, high, low, close, volume, turnover, open_interest)
|
(instrument_id, timestamp, 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.kTime}, #{item.open}, #{item.high}, #{item.low}, #{item.close},
|
(#{item.instrumentId}, #{item.timestamp}, #{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
|
||||||
|
|||||||
@ -2,7 +2,6 @@ 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;
|
||||||
|
|
||||||
@ -26,9 +25,4 @@ public interface MarketDataService {
|
|||||||
* 验证合约是否存在
|
* 验证合约是否存在
|
||||||
*/
|
*/
|
||||||
void validateContract(String contractCode);
|
void validateContract(String contractCode);
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取合约最小变动价位
|
|
||||||
*/
|
|
||||||
BigDecimal getPriceTick(String contractCode);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,6 @@ 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;
|
||||||
@ -69,7 +68,6 @@ 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();
|
||||||
@ -84,17 +82,16 @@ 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. 创建回测上下文
|
||||||
BigDecimal initialCapitalAmount = money(initialCapital);
|
BacktestContext ctx = new BacktestContext(initialCapital, marginRatio, feeRate);
|
||||||
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, direction, priceTick)) {
|
if (ctx.hasPosition() && checkStopCondition(ctx, kline, request)) {
|
||||||
closePosition(ctx, kline, direction);
|
closePosition(ctx, kline, direction);
|
||||||
ctx.recordEquity(kline, direction);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -104,32 +101,27 @@ 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, request.getOpenVolume());
|
openPosition(ctx, kline, direction);
|
||||||
} else if (direction == Direction.SHORT && allSell) {
|
} else if (direction == Direction.SHORT && allSell) {
|
||||||
openPosition(ctx, kline, direction, request.getOpenVolume());
|
openPosition(ctx, kline, direction);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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 = money(ctx.getTotalEquity());
|
BigDecimal finalCapital = ctx.getTotalEquity();
|
||||||
BigDecimal maxEquity = money(ctx.maxEquity);
|
BigDecimal totalYield = calcTotalYield(finalCapital);
|
||||||
BigDecimal minEquity = money(ctx.minEquity);
|
BigDecimal profitAmount = finalCapital.subtract(initialCapital);
|
||||||
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);
|
||||||
@ -137,23 +129,24 @@ public class BacktestEngineImpl implements BacktestEngine {
|
|||||||
|
|
||||||
// 8. 持久化
|
// 8. 持久化
|
||||||
StrategyConfig config = saveStrategyConfig(request);
|
StrategyConfig config = saveStrategyConfig(request);
|
||||||
saveStrategyResult(config.getId(), initialCapitalAmount, finalCapital,
|
saveStrategyResult(config.getId(), initialCapital, finalCapital,
|
||||||
maxEquity, minEquity, totalYield, profitAmount, annualizedYield,
|
ctx.maxEquity, ctx.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(), initialCapitalAmount, finalCapital,
|
return buildResponse(request, config.getId(), initialCapital, finalCapital,
|
||||||
maxEquity, minEquity, totalYield, profitAmount, annualizedYield,
|
ctx.maxEquity, ctx.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, int volume) {
|
private void openPosition(BacktestContext ctx, KLineData kline, Direction direction) {
|
||||||
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);
|
||||||
@ -167,11 +160,10 @@ 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, null);
|
ctx.addRecord(action, price, volume, turnover, kline.getTime(), signal);
|
||||||
log.debug("开仓: {} {}手 @ {}, 保证金={}, 手续费={}", action.getLabel(), volume, price, margin, fee);
|
log.debug("开仓: {} {}手 @ {}, 保证金={}, 手续费={}", action.getLabel(), volume, price, margin, fee);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -186,95 +178,61 @@ 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, netProfit);
|
ctx.addRecord(action, price, volume, turnover, kline.getTime(), signal);
|
||||||
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) {
|
||||||
BigDecimal stopDistance = calculateStopDistance(
|
if ("PERCENT".equalsIgnoreCase(request.getStopLossUnit())) {
|
||||||
cost, request.getStopLossValue(), StopUnit.of(request.getStopLossUnit()), priceTick);
|
BigDecimal stopPct = request.getStopLossValue().divide(BigDecimal.valueOf(100), 6, RoundingMode.HALF_UP);
|
||||||
BigDecimal stopPrice = direction == Direction.LONG
|
BigDecimal lossRatio = BigDecimal.ONE.subtract(stopPct);
|
||||||
? cost.subtract(stopDistance)
|
// 做多:止损价 = 成本价 × (1 - 止损%)
|
||||||
: cost.add(stopDistance);
|
// 做空:止损价 = 成本价 × (1 + 止损%)
|
||||||
boolean triggered = direction == Direction.LONG
|
BigDecimal stopPrice = cost.multiply(lossRatio);
|
||||||
? price.compareTo(stopPrice) <= 0
|
if (price.compareTo(stopPrice) <= 0) {
|
||||||
: price.compareTo(stopPrice) >= 0;
|
log.info("触发止损: 价格={}, 止损价={}", price, stopPrice);
|
||||||
if (triggered) {
|
return true;
|
||||||
log.info("触发止损: 价格={}, 止损价={}", price, stopPrice);
|
}
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.getTakeProfitValue() != null) {
|
if (request.getTakeProfitValue() != null) {
|
||||||
BigDecimal takeProfitDistance = calculateStopDistance(
|
if ("PERCENT".equalsIgnoreCase(request.getTakeProfitUnit())) {
|
||||||
cost, request.getTakeProfitValue(), StopUnit.of(request.getTakeProfitUnit()), priceTick);
|
BigDecimal tpPct = request.getTakeProfitValue().divide(BigDecimal.valueOf(100), 6, RoundingMode.HALF_UP);
|
||||||
BigDecimal takeProfitPrice = direction == Direction.LONG
|
BigDecimal gainRatio = BigDecimal.ONE.add(tpPct);
|
||||||
? cost.add(takeProfitDistance)
|
BigDecimal tpPrice = cost.multiply(gainRatio);
|
||||||
: cost.subtract(takeProfitDistance);
|
if (price.compareTo(tpPrice) >= 0) {
|
||||||
boolean triggered = direction == Direction.LONG
|
log.info("触发止盈: 价格={}, 止盈价={}", price, tpPrice);
|
||||||
? price.compareTo(takeProfitPrice) >= 0
|
return true;
|
||||||
: 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, BigDecimal initialCapitalAmount) {
|
private BigDecimal calcTotalYield(BigDecimal finalCapital) {
|
||||||
return finalCapital.subtract(initialCapitalAmount)
|
return finalCapital.subtract(initialCapital)
|
||||||
.divide(initialCapitalAmount, 8, RoundingMode.HALF_UP)
|
.divide(initialCapital, 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)
|
||||||
@ -283,21 +241,12 @@ public class BacktestEngineImpl implements BacktestEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private BigDecimal calcMaxDrawdown(BacktestContext ctx) {
|
private BigDecimal calcMaxDrawdown(BacktestContext ctx) {
|
||||||
BigDecimal peak = BigDecimal.ZERO;
|
if (ctx.maxEquity.compareTo(BigDecimal.ZERO) == 0) return BigDecimal.ZERO;
|
||||||
BigDecimal maximum = BigDecimal.ZERO;
|
BigDecimal minAfterMax = ctx.minEquity;
|
||||||
for (BigDecimal equity : ctx.equityHistory) {
|
return ctx.maxEquity.subtract(minAfterMax)
|
||||||
if (equity.compareTo(peak) > 0) {
|
.divide(ctx.maxEquity, 8, RoundingMode.HALF_UP)
|
||||||
peak = equity;
|
.multiply(BigDecimal.valueOf(100))
|
||||||
}
|
.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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -325,11 +274,18 @@ 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 (BacktestContext.TradeRecord r : records) {
|
for (int i = 0; i < records.size(); i++) {
|
||||||
|
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) {
|
// 寻找对应的开仓记录计算盈亏
|
||||||
winCount++;
|
for (int j = i - 1; j >= 0; j--) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -500,23 +456,20 @@ 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<>();
|
||||||
List<BigDecimal> equityHistory = new ArrayList<>();
|
BigDecimal dayStartEquity;
|
||||||
|
|
||||||
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.equityHistory.add(capital);
|
this.dayStartEquity = capital;
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean hasPosition() {
|
boolean hasPosition() {
|
||||||
@ -527,52 +480,15 @@ public class BacktestEngineImpl implements BacktestEngine {
|
|||||||
return availableCapital.add(marginLocked);
|
return availableCapital.add(marginLocked);
|
||||||
}
|
}
|
||||||
|
|
||||||
void recordEquity(KLineData kline, Direction direction) {
|
void updateEquity() {
|
||||||
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, BigDecimal netProfit) {
|
LocalDateTime klineTime, String signal) {
|
||||||
tradeRecords.add(new TradeRecord(
|
tradeRecords.add(new TradeRecord(action, price, volume, turnover, LocalDateTime.now(), klineTime, signal));
|
||||||
action, price, volume, turnover, klineTime, klineTime, signal, netProfit));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static class TradeRecord {
|
static class TradeRecord {
|
||||||
@ -583,11 +499,9 @@ 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;
|
||||||
@ -595,7 +509,6 @@ 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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,6 @@ 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;
|
||||||
@ -77,17 +76,6 @@ 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();
|
||||||
|
|||||||
@ -59,11 +59,6 @@ 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 数据生成 ====================
|
// ==================== 数据生成 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -2,7 +2,6 @@ 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;
|
||||||
|
|
||||||
@ -34,9 +33,4 @@ public interface MarketDataProvider {
|
|||||||
* 检查是否支持该合约
|
* 检查是否支持该合约
|
||||||
*/
|
*/
|
||||||
boolean supportsContract(String contractCode);
|
boolean supportsContract(String contractCode);
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取合约最小变动价位
|
|
||||||
*/
|
|
||||||
BigDecimal getPriceTick(String contractCode);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -75,12 +75,6 @@ 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)
|
||||||
@ -90,7 +84,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.getKTime()), ZONE_ID))
|
.time(LocalDateTime.ofInstant(Instant.ofEpochSecond(record.getTimestamp()), 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))
|
||||||
|
|||||||
@ -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.JSONObject;
|
import com.alibaba.fastjson2.TypeReference;
|
||||||
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,10 +33,11 @@ public class CnQuotationClient {
|
|||||||
String body = get(urlBuilder("goods/list")
|
String body = get(urlBuilder("goods/list")
|
||||||
.addQueryParameter("pageSize", "-1")
|
.addQueryParameter("pageSize", "-1")
|
||||||
.build());
|
.build());
|
||||||
JSONObject data = parseData(body);
|
CnQuotationModels.ResultModel<CnQuotationModels.GoodsPage> result = JSON.parseObject(body,
|
||||||
return data == null || data.getJSONArray("list") == null
|
new TypeReference<CnQuotationModels.ResultModel<CnQuotationModels.GoodsPage>>() {
|
||||||
? List.of()
|
});
|
||||||
: data.getJSONArray("list").toJavaList(CnQuotationModels.GoodsItem.class);
|
CnQuotationModels.GoodsPage data = unwrap(result);
|
||||||
|
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) {
|
||||||
@ -46,7 +47,11 @@ public class CnQuotationClient {
|
|||||||
.addQueryParameter("code", code)
|
.addQueryParameter("code", code)
|
||||||
.addQueryParameter("type", String.valueOf(type))
|
.addQueryParameter("type", String.valueOf(type))
|
||||||
.build());
|
.build());
|
||||||
return parseKChartItems(body);
|
CnQuotationModels.ResultModel<CnQuotationModels.KChartResult> result = JSON.parseObject(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,
|
||||||
@ -59,7 +64,11 @@ public class CnQuotationClient {
|
|||||||
.addQueryParameter("date", String.valueOf(date))
|
.addQueryParameter("date", String.valueOf(date))
|
||||||
.addQueryParameter("direction", direction)
|
.addQueryParameter("direction", direction)
|
||||||
.build());
|
.build());
|
||||||
return parseKChartItems(body);
|
CnQuotationModels.ResultModel<CnQuotationModels.KChartResult> result = JSON.parseObject(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) {
|
||||||
@ -77,25 +86,15 @@ public class CnQuotationClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<CnQuotationModels.KChartItem> parseKChartItems(String body) {
|
private <T> T unwrap(CnQuotationModels.ResultModel<T> result) {
|
||||||
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.getBoolean("success"))) {
|
if (!Boolean.TRUE.equals(result.success())) {
|
||||||
throw new BusinessException(ErrorCode.MARKET_DATA_UNAVAILABLE,
|
throw new BusinessException(ErrorCode.MARKET_DATA_UNAVAILABLE,
|
||||||
result.getString("errorInfo") != null
|
result.errorInfo() != null ? result.errorInfo() : "行情接口调用失败: " + result.errorCode());
|
||||||
? result.getString("errorInfo")
|
|
||||||
: "行情接口调用失败: " + result.getString("errorCode"));
|
|
||||||
}
|
}
|
||||||
return result.getJSONObject("data");
|
return result.data();
|
||||||
}
|
}
|
||||||
|
|
||||||
private HttpUrl.Builder urlBuilder(String path) {
|
private HttpUrl.Builder urlBuilder(String path) {
|
||||||
|
|||||||
@ -19,7 +19,8 @@ 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) {
|
||||||
|
|||||||
@ -33,8 +33,16 @@ public final class PriceScaleConverter {
|
|||||||
if (value == null || value.isBlank() || "-".equals(value.trim())) {
|
if (value == null || value.isBlank() || "-".equals(value.trim())) {
|
||||||
return 0L;
|
return 0L;
|
||||||
}
|
}
|
||||||
long parsed = new BigDecimal(value.trim()).setScale(0, RoundingMode.HALF_UP).longValue();
|
return 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) {
|
||||||
|
|||||||
@ -1,21 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@ -1,275 +0,0 @@
|
|||||||
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;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MA、BOLL、EMA、MACD、RSI、KDJ 的统一计算器。
|
|
||||||
*
|
|
||||||
* <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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,92 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -15,15 +15,13 @@ 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) {
|
||||||
@ -35,11 +33,6 @@ 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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,19 +12,17 @@ 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.support.TransactionTemplate;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
@ -37,16 +35,12 @@ 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() {
|
||||||
@ -57,40 +51,18 @@ 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();
|
LocalDate syncDate = now.toLocalDate().minusDays(1);
|
||||||
long fallbackStartTimestamp = syncDate.minusDays(1).atStartOfDay(zoneId).toEpochSecond();
|
long startTimestamp = syncDate.atStartOfDay(zoneId).toEpochSecond();
|
||||||
long endTimestamp = now.atZone(zoneId).toEpochSecond();
|
long endTimestamp = now.toLocalDate()
|
||||||
syncMainContracts(syncDate, fallbackStartTimestamp, endTimestamp, null, false);
|
.atTime(properties.getSync().getIncrementalWindowEndHour(), 0)
|
||||||
}
|
.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)
|
||||||
@ -98,8 +70,7 @@ 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, fallbackStartTimestamp, endTimestamp,
|
syncOnePeriod(instrument, period, syncDate, startTimestamp, endTimestamp);
|
||||||
forcedCursor, rebuildIndicators);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -109,18 +80,16 @@ public class MarketDataSyncService {
|
|||||||
int count = 0;
|
int count = 0;
|
||||||
try {
|
try {
|
||||||
List<CnQuotationModels.GoodsItem> goodsItems = cnQuotationClient.listMainContracts();
|
List<CnQuotationModels.GoodsItem> goodsItems = cnQuotationClient.listMainContracts();
|
||||||
List<CnQuotationModels.GoodsItem> mainContracts = goodsItems.stream()
|
instrumentDictionaryMapper.update(null, new LambdaUpdateWrapper<InstrumentDictionary>()
|
||||||
.filter(item -> item.mainContractCode() != null && !item.mainContractCode().isBlank())
|
.set(InstrumentDictionary::getIsMain, 0)
|
||||||
.toList();
|
.eq(InstrumentDictionary::getIsDeleted, 0));
|
||||||
transactionTemplate.executeWithoutResult(status -> {
|
for (CnQuotationModels.GoodsItem item : goodsItems) {
|
||||||
instrumentDictionaryMapper.update(null, new LambdaUpdateWrapper<InstrumentDictionary>()
|
if (item.mainContractCode() == null || item.mainContractCode().isBlank()) {
|
||||||
.set(InstrumentDictionary::getIsMain, 0)
|
continue;
|
||||||
.eq(InstrumentDictionary::getIsDeleted, 0));
|
|
||||||
for (CnQuotationModels.GoodsItem item : mainContracts) {
|
|
||||||
upsertInstrument(item);
|
|
||||||
}
|
}
|
||||||
});
|
upsertInstrument(item);
|
||||||
count = mainContracts.size();
|
count++;
|
||||||
|
}
|
||||||
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);
|
||||||
@ -129,90 +98,57 @@ public class MarketDataSyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void syncOnePeriod(InstrumentDictionary instrument, String period, LocalDate syncDate,
|
private void syncOnePeriod(InstrumentDictionary instrument, String period, LocalDate syncDate,
|
||||||
long fallbackStartTimestamp, long endTimestamp,
|
long startTimestamp, 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 {
|
||||||
String tableName = tableResolver.resolve(period);
|
List<CnQuotationModels.KChartItem> items = cnQuotationClient.getKChartByDate(
|
||||||
Long latestTimestamp = kLineMapper.selectLatestTimestamp(tableName, instrument.getId());
|
instrument.getExchangeId(), instrument.getContractCode(), period, startTimestamp, "after");
|
||||||
long cursor = forcedCursor != null
|
List<KLineRecord> records = items.stream()
|
||||||
? forcedCursor
|
.filter(item -> item.u() != null && item.u() >= startTimestamp && item.u() <= endTimestamp)
|
||||||
: latestTimestamp == null ? fallbackStartTimestamp : latestTimestamp;
|
.map(item -> toRecord(instrument, item))
|
||||||
while (cursor < endTimestamp) {
|
.filter(Objects::nonNull)
|
||||||
long pageCursor = cursor;
|
.toList();
|
||||||
List<CnQuotationModels.KChartItem> items = cnQuotationClient.getKChartByDate(
|
if (!records.isEmpty()) {
|
||||||
instrument.getExchangeId(), instrument.getContractCode(), period, pageCursor, "after");
|
count = kLineMapper.upsertBatch(tableResolver.resolve(period), records);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
if (rebuildIndicators) {
|
saveLog("KLINE", period, instrument.getContractCode(), syncDate, "SUCCESS", count, null, start);
|
||||||
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(syncType, period, instrument.getContractCode(), syncDate, "FAILED", count, e.getMessage(), start);
|
saveLog("KLINE", 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();
|
||||||
List<InstrumentDictionary> matches =
|
int priceScale = PriceScaleConverter.scaleFromPrecision(item.decimalPrecision());
|
||||||
instrumentDictionaryMapper.selectByExchangeAndContractCodeIgnoreCase(
|
InstrumentDictionary existing = instrumentDictionaryMapper.selectOne(
|
||||||
item.excode(), contractCode);
|
new LambdaQueryWrapper<InstrumentDictionary>()
|
||||||
InstrumentDictionary existing = matches.isEmpty()
|
.eq(InstrumentDictionary::getContractCode, contractCode)
|
||||||
? null
|
.last("LIMIT 1"));
|
||||||
: 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(DEFAULT_PRICE_SCALE);
|
instrument.setPriceScale(priceScale);
|
||||||
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.setContractCode(contractCode);
|
existing.setPriceScale(priceScale);
|
||||||
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())
|
||||||
.kTime(item.u())
|
.timestamp(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()))
|
||||||
|
|||||||
@ -8,585 +8,198 @@ CREATE DATABASE IF NOT EXISTS backtestify
|
|||||||
|
|
||||||
USE backtestify;
|
USE backtestify;
|
||||||
|
|
||||||
-- ----------------------------
|
-- ---------------------------------------------------
|
||||||
-- Table structure for bt_strategy_config
|
-- 合约字典表(不分区)
|
||||||
-- ----------------------------
|
-- ---------------------------------------------------
|
||||||
CREATE TABLE IF NOT EXISTS `bt_strategy_config` (
|
DROP TABLE IF EXISTS t_instrument_dictionary;
|
||||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
CREATE TABLE t_instrument_dictionary (
|
||||||
`user_id` bigint NOT NULL COMMENT '用户ID',
|
id SMALLINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '合约自增ID',
|
||||||
`contract_code` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '合约代码',
|
exchange_id VARCHAR(16) DEFAULT NULL COMMENT '交易所代码 (如 SHFE)',
|
||||||
`contract_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '合约名称',
|
symbol VARCHAR(10) NOT NULL COMMENT '期货品种 (如 rb)',
|
||||||
`direction` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '交易方向: LONG/SHORT',
|
contract_code VARCHAR(20) NOT NULL COMMENT '具体合约代码 (如 rb2610)',
|
||||||
`kline_period` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'K线周期: 1m/3m/5m/15m/30m/1h/4h/1d/1w',
|
price_scale INT UNSIGNED NOT NULL DEFAULT 100 COMMENT '价格放大倍数 (100表示保留2位小数)',
|
||||||
`indicators` json NOT NULL COMMENT '技术指标列表, 如[\"MACD\",\"KDJ\"]',
|
is_main TINYINT NOT NULL DEFAULT 0 COMMENT '是否当前主力合约: 0-否, 1-是',
|
||||||
`open_volume` int NOT NULL DEFAULT 1 COMMENT '开仓数量',
|
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
`volume_unit` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'LOT' COMMENT '数量单位: LOT/POSITION',
|
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
`stop_loss_value` decimal(18, 4) NULL DEFAULT NULL COMMENT '止损值',
|
is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除: 0-未删除, 1-已删除',
|
||||||
`stop_loss_unit` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '止损单位: TICK/PERCENT',
|
PRIMARY KEY (id),
|
||||||
`take_profit_value` decimal(18, 4) NULL DEFAULT NULL COMMENT '止盈值',
|
UNIQUE KEY uk_contract (contract_code),
|
||||||
`take_profit_unit` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '止盈单位: TICK/PERCENT',
|
KEY idx_symbol_main (symbol, is_main),
|
||||||
`backtest_period` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '回测区间: 1m/3m/6m/1y',
|
KEY idx_exchange_contract (exchange_id, contract_code)
|
||||||
`status` tinyint NOT NULL DEFAULT 0 COMMENT '状态: 0-已保存, 1-已启用信号',
|
) ENGINE=InnoDB DEFAULT CHARSET=ascii COMMENT='期货合约字典表';
|
||||||
`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;
|
|
||||||
|
|
||||||
-- ----------------------------
|
-- ---------------------------------------------------
|
||||||
-- Table structure for bt_strategy_result
|
-- K线数据表(每种周期一张表,按年分区;真实行情不包含3m)
|
||||||
-- ----------------------------
|
-- ---------------------------------------------------
|
||||||
CREATE TABLE IF NOT EXISTS `bt_strategy_result` (
|
DROP TABLE IF EXISTS t_kline_1m;
|
||||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
CREATE TABLE t_kline_1m (
|
||||||
`strategy_id` bigint NOT NULL COMMENT '关联策略ID',
|
instrument_id SMALLINT UNSIGNED NOT NULL COMMENT '合约字典ID',
|
||||||
`initial_capital` decimal(18, 2) NOT NULL COMMENT '初始资金',
|
timestamp INT UNSIGNED NOT NULL COMMENT 'Unix时间戳 (秒级,每根K线起始点)',
|
||||||
`final_capital` decimal(18, 2) NOT NULL COMMENT '期末总资产',
|
open INT NOT NULL COMMENT '开盘价 (实际价格 * price_scale)',
|
||||||
`max_equity` decimal(18, 2) NOT NULL COMMENT '回测期间最高净值',
|
high INT NOT NULL COMMENT '最高价 (实际价格 * price_scale)',
|
||||||
`min_equity` decimal(18, 2) NOT NULL COMMENT '回测期间最低净值',
|
low INT NOT NULL COMMENT '最低价 (实际价格 * price_scale)',
|
||||||
`total_yield` decimal(10, 4) NOT NULL COMMENT '总收益率(%)',
|
close INT NOT NULL COMMENT '收盘价 (实际价格 * price_scale)',
|
||||||
`profit_amount` decimal(18, 2) NOT NULL COMMENT '收益金额',
|
volume INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '成交量 (手)',
|
||||||
`annualized_yield` decimal(10, 4) NOT NULL COMMENT '年化收益率(%)',
|
turnover BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '成交额 (元)',
|
||||||
`trade_count` int NOT NULL DEFAULT 0 COMMENT '交易次数',
|
open_interest INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '持仓量',
|
||||||
`max_drawdown` decimal(10, 4) NOT NULL COMMENT '最大回撤(%)',
|
PRIMARY KEY (instrument_id, timestamp)
|
||||||
`sharpe_ratio` decimal(10, 4) NOT NULL COMMENT '夏普比率',
|
) ENGINE=InnoDB DEFAULT CHARSET=ascii COMMENT='1分钟K线数据表'
|
||||||
`win_rate` decimal(10, 4) NOT NULL COMMENT '胜率(%)',
|
PARTITION BY RANGE (timestamp) (
|
||||||
`start_date` date NOT NULL COMMENT '回测开始日期',
|
PARTITION p2020 VALUES LESS THAN (1609459200),
|
||||||
`end_date` date NOT NULL COMMENT '回测结束日期',
|
PARTITION p2021 VALUES LESS THAN (1640995200),
|
||||||
`daily_equity_curve` json NULL COMMENT '每日净值曲线 [{date,equity,yield}]',
|
PARTITION p2022 VALUES LESS THAN (1672531200),
|
||||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
PARTITION p2023 VALUES LESS THAN (1704067200),
|
||||||
PRIMARY KEY (`id`) USING BTREE,
|
PARTITION p2024 VALUES LESS THAN (1735689600),
|
||||||
UNIQUE INDEX `uk_strategy_id`(`strategy_id` ASC) USING BTREE
|
PARTITION p2025 VALUES LESS THAN (1767225600),
|
||||||
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '回测结果表' ROW_FORMAT = Dynamic;
|
PARTITION p2026 VALUES LESS THAN (1798761600),
|
||||||
|
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;
|
||||||
-- Table structure for bt_trade_detail
|
CREATE TABLE t_kline_5m LIKE t_kline_1m;
|
||||||
-- ----------------------------
|
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;
|
||||||
-- Table structure for bt_user_signal
|
CREATE TABLE t_kline_15m LIKE t_kline_1m;
|
||||||
-- ----------------------------
|
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;
|
||||||
-- Table structure for t_indicator_15m
|
CREATE TABLE t_kline_30m LIKE t_kline_1m;
|
||||||
-- ----------------------------
|
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;
|
||||||
-- Table structure for t_indicator_1d
|
CREATE TABLE t_kline_1h LIKE t_kline_1m;
|
||||||
-- ----------------------------
|
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;
|
||||||
-- Table structure for t_indicator_1h
|
CREATE TABLE t_kline_4h LIKE t_kline_1m;
|
||||||
-- ----------------------------
|
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;
|
||||||
-- Table structure for t_indicator_1m
|
CREATE TABLE t_kline_1d LIKE t_kline_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;
|
||||||
-- Table structure for t_indicator_1mo
|
CREATE TABLE t_kline_1w LIKE t_kline_1m;
|
||||||
-- ----------------------------
|
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
|
-- 行情同步日志表
|
||||||
-- ----------------------------
|
-- ---------------------------------------------------
|
||||||
CREATE TABLE IF NOT EXISTS `t_indicator_1w` (
|
DROP TABLE IF EXISTS t_market_data_sync_log;
|
||||||
`instrument_id` smallint UNSIGNED NOT NULL COMMENT '合约字典ID',
|
CREATE TABLE t_market_data_sync_log (
|
||||||
`k_time` int UNSIGNED NOT NULL COMMENT 'Unix时间戳 (秒级,对应K线起始点)',
|
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
`ma5` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA5',
|
sync_type VARCHAR(32) NOT NULL COMMENT '同步类型: CONTRACT/KLINE',
|
||||||
`ma10` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA10',
|
period VARCHAR(10) DEFAULT NULL COMMENT 'K线周期',
|
||||||
`ma20` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA20',
|
contract_code VARCHAR(20) DEFAULT NULL COMMENT '合约代码',
|
||||||
`ma60` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA60',
|
sync_date DATE DEFAULT NULL COMMENT '同步日期',
|
||||||
`boll_mb` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线中轨 (MA20)',
|
status VARCHAR(16) NOT NULL COMMENT '状态: SUCCESS/FAILED',
|
||||||
`boll_up` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线上轨',
|
success_count INT NOT NULL DEFAULT 0 COMMENT '成功条数',
|
||||||
`boll_dn` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线下轨',
|
error_message TEXT DEFAULT NULL COMMENT '错误信息',
|
||||||
`ema6` decimal(18, 6) NULL DEFAULT NULL COMMENT 'EMA6',
|
start_time DATETIME NOT NULL COMMENT '开始时间',
|
||||||
`ema12` decimal(18, 6) NULL DEFAULT NULL COMMENT 'EMA12',
|
end_time DATETIME DEFAULT NULL COMMENT '结束时间',
|
||||||
`ema20` decimal(18, 6) NULL DEFAULT NULL COMMENT 'EMA20',
|
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
`macd_dif` decimal(18, 6) NULL DEFAULT NULL COMMENT '快线 DIF (EMA12 - EMA26)',
|
PRIMARY KEY (id),
|
||||||
`macd_dea` decimal(18, 6) NULL DEFAULT NULL COMMENT '慢线 DEA',
|
KEY idx_sync_date (sync_date),
|
||||||
`macd_bar` decimal(18, 6) NULL DEFAULT NULL COMMENT '柱状图 BAR',
|
KEY idx_contract_period (contract_code, period),
|
||||||
`rsi6` decimal(10, 4) NULL DEFAULT NULL COMMENT '短期 RSI6',
|
KEY idx_status (status)
|
||||||
`rsi12` decimal(10, 4) NULL DEFAULT NULL COMMENT '长期 RSI12',
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='行情同步日志表';
|
||||||
`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
|
-- 策略配置表
|
||||||
-- ----------------------------
|
-- ---------------------------------------------------
|
||||||
CREATE TABLE IF NOT EXISTS `t_indicator_30m` (
|
DROP TABLE IF EXISTS bt_strategy_config;
|
||||||
`instrument_id` smallint UNSIGNED NOT NULL COMMENT '合约字典ID',
|
CREATE TABLE bt_strategy_config (
|
||||||
`k_time` int UNSIGNED NOT NULL COMMENT 'Unix时间戳 (秒级,对应K线起始点)',
|
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
`ma5` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA5',
|
user_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',
|
contract_name VARCHAR(64) NOT NULL COMMENT '合约名称',
|
||||||
`ma60` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA60',
|
direction VARCHAR(10) NOT NULL COMMENT '交易方向: LONG/SHORT',
|
||||||
`boll_mb` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线中轨 (MA20)',
|
kline_period VARCHAR(10) NOT NULL COMMENT 'K线周期: 1m/3m/5m/15m/30m/1h/4h/1d/1w',
|
||||||
`boll_up` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线上轨',
|
indicators JSON NOT NULL COMMENT '技术指标列表, 如["MACD","KDJ"]',
|
||||||
`boll_dn` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线下轨',
|
open_volume INT NOT NULL DEFAULT 1 COMMENT '开仓数量',
|
||||||
`ema6` decimal(18, 6) NULL DEFAULT NULL COMMENT 'EMA6',
|
volume_unit VARCHAR(10) NOT NULL DEFAULT 'LOT' COMMENT '数量单位: LOT/POSITION',
|
||||||
`ema12` decimal(18, 6) NULL DEFAULT NULL COMMENT 'EMA12',
|
stop_loss_value DECIMAL(18,4) DEFAULT NULL COMMENT '止损值',
|
||||||
`ema20` decimal(18, 6) NULL DEFAULT NULL COMMENT 'EMA20',
|
stop_loss_unit VARCHAR(10) DEFAULT NULL COMMENT '止损单位: TICK/PERCENT',
|
||||||
`macd_dif` decimal(18, 6) NULL DEFAULT NULL COMMENT '快线 DIF (EMA12 - EMA26)',
|
take_profit_value DECIMAL(18,4) DEFAULT NULL COMMENT '止盈值',
|
||||||
`macd_dea` decimal(18, 6) NULL DEFAULT NULL COMMENT '慢线 DEA',
|
take_profit_unit VARCHAR(10) DEFAULT NULL COMMENT '止盈单位: TICK/PERCENT',
|
||||||
`macd_bar` decimal(18, 6) NULL DEFAULT NULL COMMENT '柱状图 BAR',
|
backtest_period VARCHAR(10) NOT NULL COMMENT '回测区间: 1m/3m/6m/1y',
|
||||||
`rsi6` decimal(10, 4) NULL DEFAULT NULL COMMENT '短期 RSI6',
|
status TINYINT NOT NULL DEFAULT 0 COMMENT '状态: 0-已保存, 1-已启用信号',
|
||||||
`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值',
|
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
`kdj_d` decimal(10, 4) NULL DEFAULT NULL COMMENT 'D值',
|
is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除: 0-未删除, 1-已删除',
|
||||||
`kdj_j` decimal(10, 4) NULL DEFAULT NULL COMMENT 'J值',
|
PRIMARY KEY (id),
|
||||||
`create_at` int UNSIGNED NULL DEFAULT NULL COMMENT '记录创建时间',
|
INDEX idx_user_id (user_id),
|
||||||
PRIMARY KEY (`instrument_id`, `k_time`) USING BTREE
|
INDEX idx_contract_code (contract_code),
|
||||||
) ENGINE = InnoDB CHARACTER SET = ascii COLLATE = ascii_general_ci COMMENT = '30分钟指标表' ROW_FORMAT = DYNAMIC;
|
INDEX idx_create_time (create_time)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='策略配置表';
|
||||||
|
|
||||||
-- ----------------------------
|
-- ---------------------------------------------------
|
||||||
-- Table structure for t_indicator_3m
|
-- 回测结果表
|
||||||
-- ----------------------------
|
-- ---------------------------------------------------
|
||||||
CREATE TABLE IF NOT EXISTS `t_indicator_3m` (
|
DROP TABLE IF EXISTS bt_strategy_result;
|
||||||
`instrument_id` smallint UNSIGNED NOT NULL COMMENT '合约字典ID',
|
CREATE TABLE bt_strategy_result (
|
||||||
`k_time` int UNSIGNED NOT NULL COMMENT 'Unix时间戳 (秒级,对应K线起始点)',
|
id BIGINT NOT NULL AUTO_INCREMENT 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',
|
initial_capital DECIMAL(18,2) NOT NULL COMMENT '初始资金',
|
||||||
`ma20` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA20',
|
final_capital DECIMAL(18,2) NOT NULL COMMENT '期末总资产',
|
||||||
`ma60` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA60',
|
max_equity DECIMAL(18,2) NOT NULL COMMENT '回测期间最高净值',
|
||||||
`boll_mb` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线中轨 (MA20)',
|
min_equity DECIMAL(18,2) NOT NULL COMMENT '回测期间最低净值',
|
||||||
`boll_up` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线上轨',
|
total_yield DECIMAL(10,4) NOT NULL COMMENT '总收益率(%)',
|
||||||
`boll_dn` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线下轨',
|
profit_amount DECIMAL(18,2) NOT NULL COMMENT '收益金额',
|
||||||
`ema6` decimal(18, 6) NULL DEFAULT NULL COMMENT 'EMA6',
|
annualized_yield DECIMAL(10,4) NOT NULL COMMENT '年化收益率(%)',
|
||||||
`ema12` decimal(18, 6) NULL DEFAULT NULL COMMENT 'EMA12',
|
trade_count INT NOT NULL DEFAULT 0 COMMENT '交易次数',
|
||||||
`ema20` decimal(18, 6) NULL DEFAULT NULL COMMENT 'EMA20',
|
max_drawdown DECIMAL(10,4) NOT NULL COMMENT '最大回撤(%)',
|
||||||
`macd_dif` decimal(18, 6) NULL DEFAULT NULL COMMENT '快线 DIF (EMA12 - EMA26)',
|
sharpe_ratio DECIMAL(10,4) NOT NULL COMMENT '夏普比率',
|
||||||
`macd_dea` decimal(18, 6) NULL DEFAULT NULL COMMENT '慢线 DEA',
|
win_rate DECIMAL(10,4) NOT NULL COMMENT '胜率(%)',
|
||||||
`macd_bar` decimal(18, 6) NULL DEFAULT NULL COMMENT '柱状图 BAR',
|
start_date DATE NOT NULL COMMENT '回测开始日期',
|
||||||
`rsi6` decimal(10, 4) NULL DEFAULT NULL COMMENT '短期 RSI6',
|
end_date DATE NOT NULL COMMENT '回测结束日期',
|
||||||
`rsi12` decimal(10, 4) NULL DEFAULT NULL COMMENT '长期 RSI12',
|
daily_equity_curve JSON DEFAULT NULL COMMENT '每日净值曲线 [{date,equity,yield}]',
|
||||||
`kdj_k` decimal(10, 4) NULL DEFAULT NULL COMMENT 'K值',
|
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
`kdj_d` decimal(10, 4) NULL DEFAULT NULL COMMENT 'D值',
|
PRIMARY KEY (id),
|
||||||
`kdj_j` decimal(10, 4) NULL DEFAULT NULL COMMENT 'J值',
|
UNIQUE INDEX uk_strategy_id (strategy_id)
|
||||||
`create_at` int UNSIGNED NULL DEFAULT NULL COMMENT '记录创建时间',
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci 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
|
-- 交易明细表
|
||||||
-- ----------------------------
|
-- ---------------------------------------------------
|
||||||
CREATE TABLE IF NOT EXISTS `t_indicator_4h` (
|
DROP TABLE IF EXISTS bt_trade_detail;
|
||||||
`instrument_id` smallint UNSIGNED NOT NULL COMMENT '合约字典ID',
|
CREATE TABLE bt_trade_detail (
|
||||||
`k_time` int UNSIGNED NOT NULL COMMENT 'Unix时间戳 (秒级,对应K线起始点)',
|
id BIGINT NOT NULL AUTO_INCREMENT 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',
|
action VARCHAR(20) NOT NULL COMMENT '操作: BUY_OPEN/SELL_CLOSE/SELL_OPEN/BUY_CLOSE',
|
||||||
`ma20` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA20',
|
price DECIMAL(18,4) NOT NULL COMMENT '成交价',
|
||||||
`ma60` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA60',
|
volume INT NOT NULL COMMENT '成交数量',
|
||||||
`boll_mb` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线中轨 (MA20)',
|
turnover DECIMAL(18,2) NOT NULL COMMENT '成交金额',
|
||||||
`boll_up` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线上轨',
|
trade_time DATETIME NOT NULL COMMENT '成交时间',
|
||||||
`boll_dn` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线下轨',
|
kline_time DATETIME NOT NULL COMMENT '对应K线时间',
|
||||||
`ema6` decimal(18, 6) NULL DEFAULT NULL COMMENT 'EMA6',
|
signal_type VARCHAR(5) NOT NULL COMMENT '信号类型: B/S',
|
||||||
`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)',
|
INDEX idx_strategy_id (strategy_id),
|
||||||
`macd_dea` decimal(18, 6) NULL DEFAULT NULL COMMENT '慢线 DEA',
|
INDEX idx_trade_time (trade_time)
|
||||||
`macd_bar` decimal(18, 6) NULL DEFAULT NULL COMMENT '柱状图 BAR',
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='交易明细表';
|
||||||
`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
|
-- 用户信号标记表
|
||||||
-- ----------------------------
|
-- ---------------------------------------------------
|
||||||
CREATE TABLE IF NOT EXISTS `t_indicator_5m` (
|
DROP TABLE IF EXISTS bt_user_signal;
|
||||||
`instrument_id` smallint UNSIGNED NOT NULL COMMENT '合约字典ID',
|
CREATE TABLE bt_user_signal (
|
||||||
`k_time` int UNSIGNED NOT NULL COMMENT 'Unix时间戳 (秒级,对应K线起始点)',
|
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
`ma5` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA5',
|
user_id BIGINT NOT NULL COMMENT '用户ID',
|
||||||
`ma10` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA10',
|
strategy_id BIGINT NOT NULL COMMENT '策略ID',
|
||||||
`ma20` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA20',
|
contract_code VARCHAR(32) NOT NULL COMMENT '合约代码',
|
||||||
`ma60` decimal(18, 6) NULL DEFAULT NULL COMMENT 'MA60',
|
kline_period VARCHAR(10) NOT NULL COMMENT 'K线周期',
|
||||||
`boll_mb` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线中轨 (MA20)',
|
is_active TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用: 0-停用, 1-启用',
|
||||||
`boll_up` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线上轨',
|
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
`boll_dn` decimal(18, 6) NULL DEFAULT NULL COMMENT '布林线下轨',
|
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
`ema6` decimal(18, 6) NULL DEFAULT NULL COMMENT 'EMA6',
|
is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除: 0-未删除, 1-已删除',
|
||||||
`ema12` decimal(18, 6) NULL DEFAULT NULL COMMENT 'EMA12',
|
PRIMARY KEY (id),
|
||||||
`ema20` decimal(18, 6) NULL DEFAULT NULL COMMENT 'EMA20',
|
INDEX idx_user_contract_period (user_id, contract_code, kline_period),
|
||||||
`macd_dif` decimal(18, 6) NULL DEFAULT NULL COMMENT '快线 DIF (EMA12 - EMA26)',
|
INDEX idx_strategy_id (strategy_id)
|
||||||
`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 = '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;
|
|
||||||
|
|||||||
@ -1,220 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,255 +0,0 @@
|
|||||||
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) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -27,10 +27,4 @@ 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"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,125 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,82 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,159 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -13,7 +13,6 @@ 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"));
|
||||||
@ -21,15 +20,10 @@ 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 rejectsUnknownPeriod() {
|
void rejects3mBecauseRealMarketDoesNotProvideIt() {
|
||||||
assertThrows(BusinessException.class, () -> resolver.resolve("2h"));
|
assertThrows(BusinessException.class, () -> resolver.resolve("3m"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,427 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -4,18 +4,17 @@ 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 1000,
|
price_scale INT NOT NULL DEFAULT 100,
|
||||||
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 (exchange_id, contract_code)
|
UNIQUE (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,
|
||||||
k_time INT NOT NULL,
|
timestamp 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,
|
||||||
@ -23,11 +22,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, k_time)
|
PRIMARY KEY (instrument_id, timestamp)
|
||||||
);
|
);
|
||||||
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,
|
||||||
k_time INT NOT NULL,
|
timestamp 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,
|
||||||
@ -35,11 +34,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, k_time)
|
PRIMARY KEY (instrument_id, timestamp)
|
||||||
);
|
);
|
||||||
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,
|
||||||
k_time INT NOT NULL,
|
timestamp 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,
|
||||||
@ -47,11 +46,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, k_time)
|
PRIMARY KEY (instrument_id, timestamp)
|
||||||
);
|
);
|
||||||
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,
|
||||||
k_time INT NOT NULL,
|
timestamp 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,
|
||||||
@ -59,11 +58,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, k_time)
|
PRIMARY KEY (instrument_id, timestamp)
|
||||||
);
|
);
|
||||||
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,
|
||||||
k_time INT NOT NULL,
|
timestamp 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,
|
||||||
@ -71,11 +70,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, k_time)
|
PRIMARY KEY (instrument_id, timestamp)
|
||||||
);
|
);
|
||||||
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,
|
||||||
k_time INT NOT NULL,
|
timestamp 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,
|
||||||
@ -83,11 +82,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, k_time)
|
PRIMARY KEY (instrument_id, timestamp)
|
||||||
);
|
);
|
||||||
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,
|
||||||
k_time INT NOT NULL,
|
timestamp 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,
|
||||||
@ -95,11 +94,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, k_time)
|
PRIMARY KEY (instrument_id, timestamp)
|
||||||
);
|
);
|
||||||
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,
|
||||||
k_time INT NOT NULL,
|
timestamp 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,
|
||||||
@ -107,41 +106,9 @@ 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, k_time)
|
PRIMARY KEY (instrument_id, timestamp)
|
||||||
);
|
);
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user