init
This commit is contained in:
parent
48079cea72
commit
900c1a2ab8
@ -2,6 +2,7 @@ package com.yangwale.backtestify.service;
|
|||||||
|
|
||||||
import com.yangwale.backtestify.model.dto.KLineData;
|
import com.yangwale.backtestify.model.dto.KLineData;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@ -25,4 +26,9 @@ public interface MarketDataService {
|
|||||||
* 验证合约是否存在
|
* 验证合约是否存在
|
||||||
*/
|
*/
|
||||||
void validateContract(String contractCode);
|
void validateContract(String contractCode);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取合约最小变动价位
|
||||||
|
*/
|
||||||
|
BigDecimal getPriceTick(String contractCode);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import com.yangwale.backtestify.entity.StrategyResult;
|
|||||||
import com.yangwale.backtestify.entity.TradeDetail;
|
import com.yangwale.backtestify.entity.TradeDetail;
|
||||||
import com.yangwale.backtestify.enums.Direction;
|
import com.yangwale.backtestify.enums.Direction;
|
||||||
import com.yangwale.backtestify.enums.KLinePeriod;
|
import com.yangwale.backtestify.enums.KLinePeriod;
|
||||||
|
import com.yangwale.backtestify.enums.StopUnit;
|
||||||
import com.yangwale.backtestify.enums.TradeAction;
|
import com.yangwale.backtestify.enums.TradeAction;
|
||||||
import com.yangwale.backtestify.mapper.StrategyConfigMapper;
|
import com.yangwale.backtestify.mapper.StrategyConfigMapper;
|
||||||
import com.yangwale.backtestify.mapper.StrategyResultMapper;
|
import com.yangwale.backtestify.mapper.StrategyResultMapper;
|
||||||
@ -68,6 +69,7 @@ public class BacktestEngineImpl implements BacktestEngine {
|
|||||||
Direction direction = Direction.of(request.getDirection());
|
Direction direction = Direction.of(request.getDirection());
|
||||||
KLinePeriod period = KLinePeriod.of(request.getKlinePeriod());
|
KLinePeriod period = KLinePeriod.of(request.getKlinePeriod());
|
||||||
List<SignalStrategy> strategies = signalStrategyFactory.get(request.getIndicators());
|
List<SignalStrategy> strategies = signalStrategyFactory.get(request.getIndicators());
|
||||||
|
BigDecimal priceTick = resolvePriceTick(request);
|
||||||
|
|
||||||
// 2. 计算回测时间范围
|
// 2. 计算回测时间范围
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
@ -82,16 +84,17 @@ public class BacktestEngineImpl implements BacktestEngine {
|
|||||||
log.info("回测区间: {} ~ {}, K线数量: {}", kLines.getFirst().getTime(), kLines.getLast().getTime(), kLines.size());
|
log.info("回测区间: {} ~ {}, K线数量: {}", kLines.getFirst().getTime(), kLines.getLast().getTime(), kLines.size());
|
||||||
|
|
||||||
// 4. 创建回测上下文
|
// 4. 创建回测上下文
|
||||||
BacktestContext ctx = new BacktestContext(initialCapital, marginRatio, feeRate);
|
BigDecimal initialCapitalAmount = money(initialCapital);
|
||||||
|
BacktestContext ctx = new BacktestContext(initialCapitalAmount, marginRatio, feeRate);
|
||||||
|
|
||||||
// 5. 逐根K线遍历
|
// 5. 逐根K线遍历
|
||||||
for (int i = 0; i < kLines.size(); i++) {
|
for (int i = 0; i < kLines.size(); i++) {
|
||||||
KLineData kline = kLines.get(i);
|
KLineData kline = kLines.get(i);
|
||||||
ctx.updateEquity();
|
|
||||||
|
|
||||||
// 5a. 检查止盈/止损
|
// 5a. 检查止盈/止损
|
||||||
if (ctx.hasPosition() && checkStopCondition(ctx, kline, request)) {
|
if (ctx.hasPosition() && checkStopCondition(ctx, kline, request, direction, priceTick)) {
|
||||||
closePosition(ctx, kline, direction);
|
closePosition(ctx, kline, direction);
|
||||||
|
ctx.recordEquity(kline, direction);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,27 +104,32 @@ public class BacktestEngineImpl implements BacktestEngine {
|
|||||||
boolean allSell = strategies.stream().allMatch(s -> s.isSellSignal(kline, kLines));
|
boolean allSell = strategies.stream().allMatch(s -> s.isSellSignal(kline, kLines));
|
||||||
|
|
||||||
if (direction == Direction.LONG && allBuy) {
|
if (direction == Direction.LONG && allBuy) {
|
||||||
openPosition(ctx, kline, direction);
|
openPosition(ctx, kline, direction, request.getOpenVolume());
|
||||||
} else if (direction == Direction.SHORT && allSell) {
|
} else if (direction == Direction.SHORT && allSell) {
|
||||||
openPosition(ctx, kline, direction);
|
openPosition(ctx, kline, direction, request.getOpenVolume());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ctx.recordEquity(kline, direction);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. 遍历结束,强制平仓
|
// 6. 遍历结束,强制平仓
|
||||||
if (ctx.hasPosition()) {
|
if (ctx.hasPosition()) {
|
||||||
KLineData lastKline = kLines.getLast();
|
KLineData lastKline = kLines.getLast();
|
||||||
closePosition(ctx, lastKline, direction);
|
closePosition(ctx, lastKline, direction);
|
||||||
|
ctx.recordEquity(lastKline, direction);
|
||||||
}
|
}
|
||||||
|
ctx.calculateDailyYields();
|
||||||
|
|
||||||
// 7. 计算指标
|
// 7. 计算指标
|
||||||
LocalDate startDate = kLines.getFirst().getTime().toLocalDate();
|
LocalDate startDate = kLines.getFirst().getTime().toLocalDate();
|
||||||
LocalDate endDate = kLines.getLast().getTime().toLocalDate();
|
LocalDate endDate = kLines.getLast().getTime().toLocalDate();
|
||||||
long totalDays = ChronoUnit.DAYS.between(startDate, endDate) + 1;
|
long totalDays = ChronoUnit.DAYS.between(startDate, endDate) + 1;
|
||||||
|
|
||||||
BigDecimal finalCapital = ctx.getTotalEquity();
|
BigDecimal finalCapital = money(ctx.getTotalEquity());
|
||||||
BigDecimal totalYield = calcTotalYield(finalCapital);
|
BigDecimal maxEquity = money(ctx.maxEquity);
|
||||||
BigDecimal profitAmount = finalCapital.subtract(initialCapital);
|
BigDecimal minEquity = money(ctx.minEquity);
|
||||||
|
BigDecimal totalYield = calcTotalYield(finalCapital, initialCapitalAmount);
|
||||||
|
BigDecimal profitAmount = money(finalCapital.subtract(initialCapitalAmount));
|
||||||
BigDecimal annualizedYield = calcAnnualizedYield(totalYield, totalDays);
|
BigDecimal annualizedYield = calcAnnualizedYield(totalYield, totalDays);
|
||||||
BigDecimal maxDrawdown = calcMaxDrawdown(ctx);
|
BigDecimal maxDrawdown = calcMaxDrawdown(ctx);
|
||||||
BigDecimal sharpeRatio = calcSharpeRatio(ctx.dailyYields);
|
BigDecimal sharpeRatio = calcSharpeRatio(ctx.dailyYields);
|
||||||
@ -129,24 +137,23 @@ public class BacktestEngineImpl implements BacktestEngine {
|
|||||||
|
|
||||||
// 8. 持久化
|
// 8. 持久化
|
||||||
StrategyConfig config = saveStrategyConfig(request);
|
StrategyConfig config = saveStrategyConfig(request);
|
||||||
saveStrategyResult(config.getId(), initialCapital, finalCapital,
|
saveStrategyResult(config.getId(), initialCapitalAmount, finalCapital,
|
||||||
ctx.maxEquity, ctx.minEquity, totalYield, profitAmount, annualizedYield,
|
maxEquity, minEquity, totalYield, profitAmount, annualizedYield,
|
||||||
ctx.tradeRecords.size(), maxDrawdown, sharpeRatio, winRate,
|
ctx.tradeRecords.size(), maxDrawdown, sharpeRatio, winRate,
|
||||||
startDate, endDate, ctx.dailyEquityCurve);
|
startDate, endDate, ctx.dailyEquityCurve);
|
||||||
List<TradeDetail> tradeDetails = saveTradeDetails(config.getId(), ctx.tradeRecords);
|
List<TradeDetail> tradeDetails = saveTradeDetails(config.getId(), ctx.tradeRecords);
|
||||||
|
|
||||||
// 9. 构建响应
|
// 9. 构建响应
|
||||||
return buildResponse(request, config.getId(), initialCapital, finalCapital,
|
return buildResponse(request, config.getId(), initialCapitalAmount, finalCapital,
|
||||||
ctx.maxEquity, ctx.minEquity, totalYield, profitAmount, annualizedYield,
|
maxEquity, minEquity, totalYield, profitAmount, annualizedYield,
|
||||||
ctx.tradeRecords.size(), maxDrawdown, sharpeRatio, winRate,
|
ctx.tradeRecords.size(), maxDrawdown, sharpeRatio, winRate,
|
||||||
startDate, endDate, ctx, tradeDetails);
|
startDate, endDate, ctx, tradeDetails);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 交易操作 ====================
|
// ==================== 交易操作 ====================
|
||||||
|
|
||||||
private void openPosition(BacktestContext ctx, KLineData kline, Direction direction) {
|
private void openPosition(BacktestContext ctx, KLineData kline, Direction direction, int volume) {
|
||||||
BigDecimal price = kline.getClose();
|
BigDecimal price = kline.getClose();
|
||||||
int volume = 1; // 简化:每次开仓1手
|
|
||||||
BigDecimal turnover = price.multiply(BigDecimal.valueOf(volume));
|
BigDecimal turnover = price.multiply(BigDecimal.valueOf(volume));
|
||||||
BigDecimal margin = turnover.multiply(marginRatio);
|
BigDecimal margin = turnover.multiply(marginRatio);
|
||||||
BigDecimal fee = turnover.multiply(feeRate);
|
BigDecimal fee = turnover.multiply(feeRate);
|
||||||
@ -160,10 +167,11 @@ public class BacktestEngineImpl implements BacktestEngine {
|
|||||||
ctx.position = volume;
|
ctx.position = volume;
|
||||||
ctx.avgCostPrice = price;
|
ctx.avgCostPrice = price;
|
||||||
ctx.marginLocked = margin;
|
ctx.marginLocked = margin;
|
||||||
|
ctx.openFee = fee;
|
||||||
|
|
||||||
TradeAction action = direction == Direction.LONG ? TradeAction.BUY_OPEN : TradeAction.SELL_OPEN;
|
TradeAction action = direction == Direction.LONG ? TradeAction.BUY_OPEN : TradeAction.SELL_OPEN;
|
||||||
String signal = direction == Direction.LONG ? "B" : "S";
|
String signal = direction == Direction.LONG ? "B" : "S";
|
||||||
ctx.addRecord(action, price, volume, turnover, kline.getTime(), signal);
|
ctx.addRecord(action, price, volume, turnover, kline.getTime(), signal, null);
|
||||||
log.debug("开仓: {} {}手 @ {}, 保证金={}, 手续费={}", action.getLabel(), volume, price, margin, fee);
|
log.debug("开仓: {} {}手 @ {}, 保证金={}, 手续费={}", action.getLabel(), volume, price, margin, fee);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -178,61 +186,95 @@ public class BacktestEngineImpl implements BacktestEngine {
|
|||||||
if (direction == Direction.SHORT) {
|
if (direction == Direction.SHORT) {
|
||||||
profit = BigDecimal.ZERO.subtract(profit); // 做空盈亏反向
|
profit = BigDecimal.ZERO.subtract(profit); // 做空盈亏反向
|
||||||
}
|
}
|
||||||
|
BigDecimal netProfit = profit.subtract(ctx.openFee).subtract(fee);
|
||||||
ctx.availableCapital = ctx.availableCapital.add(ctx.marginLocked).add(profit).subtract(fee);
|
ctx.availableCapital = ctx.availableCapital.add(ctx.marginLocked).add(profit).subtract(fee);
|
||||||
ctx.position = 0;
|
ctx.position = 0;
|
||||||
ctx.marginLocked = BigDecimal.ZERO;
|
ctx.marginLocked = BigDecimal.ZERO;
|
||||||
ctx.avgCostPrice = BigDecimal.ZERO;
|
ctx.avgCostPrice = BigDecimal.ZERO;
|
||||||
|
ctx.openFee = BigDecimal.ZERO;
|
||||||
|
|
||||||
TradeAction action = direction == Direction.LONG ? TradeAction.SELL_CLOSE : TradeAction.BUY_CLOSE;
|
TradeAction action = direction == Direction.LONG ? TradeAction.SELL_CLOSE : TradeAction.BUY_CLOSE;
|
||||||
String signal = direction == Direction.LONG ? "S" : "B";
|
String signal = direction == Direction.LONG ? "S" : "B";
|
||||||
ctx.addRecord(action, price, volume, turnover, kline.getTime(), signal);
|
ctx.addRecord(action, price, volume, turnover, kline.getTime(), signal, netProfit);
|
||||||
log.debug("平仓: {} {}手 @ {}, 盈亏={}, 手续费={}", action.getLabel(), volume, price, profit, fee);
|
log.debug("平仓: {} {}手 @ {}, 盈亏={}, 手续费={}", action.getLabel(), volume, price, profit, fee);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 止盈止损检查 ====================
|
// ==================== 止盈止损检查 ====================
|
||||||
|
|
||||||
private boolean checkStopCondition(BacktestContext ctx, KLineData kline, BacktestRequest request) {
|
private boolean checkStopCondition(BacktestContext ctx, KLineData kline, BacktestRequest request,
|
||||||
|
Direction direction, BigDecimal priceTick) {
|
||||||
BigDecimal price = kline.getClose();
|
BigDecimal price = kline.getClose();
|
||||||
BigDecimal cost = ctx.avgCostPrice;
|
BigDecimal cost = ctx.avgCostPrice;
|
||||||
|
|
||||||
if (request.getStopLossValue() != null) {
|
if (request.getStopLossValue() != null) {
|
||||||
if ("PERCENT".equalsIgnoreCase(request.getStopLossUnit())) {
|
BigDecimal stopDistance = calculateStopDistance(
|
||||||
BigDecimal stopPct = request.getStopLossValue().divide(BigDecimal.valueOf(100), 6, RoundingMode.HALF_UP);
|
cost, request.getStopLossValue(), StopUnit.of(request.getStopLossUnit()), priceTick);
|
||||||
BigDecimal lossRatio = BigDecimal.ONE.subtract(stopPct);
|
BigDecimal stopPrice = direction == Direction.LONG
|
||||||
// 做多:止损价 = 成本价 × (1 - 止损%)
|
? cost.subtract(stopDistance)
|
||||||
// 做空:止损价 = 成本价 × (1 + 止损%)
|
: cost.add(stopDistance);
|
||||||
BigDecimal stopPrice = cost.multiply(lossRatio);
|
boolean triggered = direction == Direction.LONG
|
||||||
if (price.compareTo(stopPrice) <= 0) {
|
? price.compareTo(stopPrice) <= 0
|
||||||
|
: price.compareTo(stopPrice) >= 0;
|
||||||
|
if (triggered) {
|
||||||
log.info("触发止损: 价格={}, 止损价={}", price, stopPrice);
|
log.info("触发止损: 价格={}, 止损价={}", price, stopPrice);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (request.getTakeProfitValue() != null) {
|
if (request.getTakeProfitValue() != null) {
|
||||||
if ("PERCENT".equalsIgnoreCase(request.getTakeProfitUnit())) {
|
BigDecimal takeProfitDistance = calculateStopDistance(
|
||||||
BigDecimal tpPct = request.getTakeProfitValue().divide(BigDecimal.valueOf(100), 6, RoundingMode.HALF_UP);
|
cost, request.getTakeProfitValue(), StopUnit.of(request.getTakeProfitUnit()), priceTick);
|
||||||
BigDecimal gainRatio = BigDecimal.ONE.add(tpPct);
|
BigDecimal takeProfitPrice = direction == Direction.LONG
|
||||||
BigDecimal tpPrice = cost.multiply(gainRatio);
|
? cost.add(takeProfitDistance)
|
||||||
if (price.compareTo(tpPrice) >= 0) {
|
: cost.subtract(takeProfitDistance);
|
||||||
log.info("触发止盈: 价格={}, 止盈价={}", price, tpPrice);
|
boolean triggered = direction == Direction.LONG
|
||||||
|
? price.compareTo(takeProfitPrice) >= 0
|
||||||
|
: price.compareTo(takeProfitPrice) <= 0;
|
||||||
|
if (triggered) {
|
||||||
|
log.info("触发止盈: 价格={}, 止盈价={}", price, takeProfitPrice);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private BigDecimal resolvePriceTick(BacktestRequest request) {
|
||||||
|
boolean tickStopLoss = request.getStopLossValue() != null
|
||||||
|
&& StopUnit.TICK == StopUnit.of(request.getStopLossUnit());
|
||||||
|
boolean tickTakeProfit = request.getTakeProfitValue() != null
|
||||||
|
&& StopUnit.TICK == StopUnit.of(request.getTakeProfitUnit());
|
||||||
|
return tickStopLoss || tickTakeProfit
|
||||||
|
? marketDataService.getPriceTick(request.getContractCode())
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal calculateStopDistance(BigDecimal cost, BigDecimal value,
|
||||||
|
StopUnit unit, BigDecimal priceTick) {
|
||||||
|
if (unit == StopUnit.TICK) {
|
||||||
|
if (priceTick == null || priceTick.signum() <= 0) {
|
||||||
|
throw new BusinessException(ErrorCode.MARKET_DATA_UNAVAILABLE,
|
||||||
|
"TICK止盈止损缺少有效的最小变动价位");
|
||||||
|
}
|
||||||
|
return value.multiply(priceTick);
|
||||||
|
}
|
||||||
|
BigDecimal percentage = value.divide(BigDecimal.valueOf(100), 8, RoundingMode.HALF_UP);
|
||||||
|
return cost.multiply(percentage);
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 指标计算 ====================
|
// ==================== 指标计算 ====================
|
||||||
|
|
||||||
private BigDecimal calcTotalYield(BigDecimal finalCapital) {
|
private BigDecimal calcTotalYield(BigDecimal finalCapital, BigDecimal initialCapitalAmount) {
|
||||||
return finalCapital.subtract(initialCapital)
|
return finalCapital.subtract(initialCapitalAmount)
|
||||||
.divide(initialCapital, 8, RoundingMode.HALF_UP)
|
.divide(initialCapitalAmount, 8, RoundingMode.HALF_UP)
|
||||||
.multiply(BigDecimal.valueOf(100))
|
.multiply(BigDecimal.valueOf(100))
|
||||||
.setScale(4, RoundingMode.HALF_UP);
|
.setScale(4, RoundingMode.HALF_UP);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private BigDecimal money(BigDecimal value) {
|
||||||
|
return value.setScale(2, RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
|
||||||
private BigDecimal calcAnnualizedYield(BigDecimal totalYield, long totalDays) {
|
private BigDecimal calcAnnualizedYield(BigDecimal totalYield, long totalDays) {
|
||||||
if (totalDays <= 0) return BigDecimal.ZERO;
|
if (totalDays <= 0) return BigDecimal.ZERO;
|
||||||
return totalYield.divide(BigDecimal.valueOf(totalDays), 8, RoundingMode.HALF_UP)
|
return totalYield.divide(BigDecimal.valueOf(totalDays), 8, RoundingMode.HALF_UP)
|
||||||
@ -241,12 +283,21 @@ public class BacktestEngineImpl implements BacktestEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private BigDecimal calcMaxDrawdown(BacktestContext ctx) {
|
private BigDecimal calcMaxDrawdown(BacktestContext ctx) {
|
||||||
if (ctx.maxEquity.compareTo(BigDecimal.ZERO) == 0) return BigDecimal.ZERO;
|
BigDecimal peak = BigDecimal.ZERO;
|
||||||
BigDecimal minAfterMax = ctx.minEquity;
|
BigDecimal maximum = BigDecimal.ZERO;
|
||||||
return ctx.maxEquity.subtract(minAfterMax)
|
for (BigDecimal equity : ctx.equityHistory) {
|
||||||
.divide(ctx.maxEquity, 8, RoundingMode.HALF_UP)
|
if (equity.compareTo(peak) > 0) {
|
||||||
.multiply(BigDecimal.valueOf(100))
|
peak = equity;
|
||||||
.abs()
|
}
|
||||||
|
if (peak.compareTo(BigDecimal.ZERO) > 0) {
|
||||||
|
BigDecimal drawdown = peak.subtract(equity)
|
||||||
|
.divide(peak, 8, RoundingMode.HALF_UP);
|
||||||
|
if (drawdown.compareTo(maximum) > 0) {
|
||||||
|
maximum = drawdown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maximum.multiply(BigDecimal.valueOf(100))
|
||||||
.setScale(4, RoundingMode.HALF_UP);
|
.setScale(4, RoundingMode.HALF_UP);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -274,18 +325,11 @@ public class BacktestEngineImpl implements BacktestEngine {
|
|||||||
if (records.isEmpty()) return BigDecimal.ZERO;
|
if (records.isEmpty()) return BigDecimal.ZERO;
|
||||||
long winCount = 0;
|
long winCount = 0;
|
||||||
long totalClose = 0;
|
long totalClose = 0;
|
||||||
for (int i = 0; i < records.size(); i++) {
|
for (BacktestContext.TradeRecord r : records) {
|
||||||
BacktestContext.TradeRecord r = records.get(i);
|
|
||||||
if (r.action == TradeAction.SELL_CLOSE || r.action == TradeAction.BUY_CLOSE) {
|
if (r.action == TradeAction.SELL_CLOSE || r.action == TradeAction.BUY_CLOSE) {
|
||||||
totalClose++;
|
totalClose++;
|
||||||
// 寻找对应的开仓记录计算盈亏
|
if (r.netProfit != null && r.netProfit.compareTo(BigDecimal.ZERO) > 0) {
|
||||||
for (int j = i - 1; j >= 0; j--) {
|
winCount++;
|
||||||
BacktestContext.TradeRecord open = records.get(j);
|
|
||||||
if ((r.action == TradeAction.SELL_CLOSE && open.action == TradeAction.BUY_OPEN)
|
|
||||||
|| (r.action == TradeAction.BUY_CLOSE && open.action == TradeAction.SELL_OPEN)) {
|
|
||||||
if (r.turnover.compareTo(open.turnover) > 0) winCount++;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -456,20 +500,23 @@ public class BacktestEngineImpl implements BacktestEngine {
|
|||||||
int position = 0;
|
int position = 0;
|
||||||
BigDecimal avgCostPrice = BigDecimal.ZERO;
|
BigDecimal avgCostPrice = BigDecimal.ZERO;
|
||||||
BigDecimal marginLocked = BigDecimal.ZERO;
|
BigDecimal marginLocked = BigDecimal.ZERO;
|
||||||
|
BigDecimal openFee = BigDecimal.ZERO;
|
||||||
|
BigDecimal initialCapital;
|
||||||
BigDecimal maxEquity;
|
BigDecimal maxEquity;
|
||||||
BigDecimal minEquity;
|
BigDecimal minEquity;
|
||||||
List<TradeRecord> tradeRecords = new ArrayList<>();
|
List<TradeRecord> tradeRecords = new ArrayList<>();
|
||||||
List<DailyEquity> dailyEquityCurve = new ArrayList<>();
|
List<DailyEquity> dailyEquityCurve = new ArrayList<>();
|
||||||
List<BigDecimal> dailyYields = new ArrayList<>();
|
List<BigDecimal> dailyYields = new ArrayList<>();
|
||||||
BigDecimal dayStartEquity;
|
List<BigDecimal> equityHistory = new ArrayList<>();
|
||||||
|
|
||||||
BacktestContext(BigDecimal capital, BigDecimal marginRatio, BigDecimal feeRate) {
|
BacktestContext(BigDecimal capital, BigDecimal marginRatio, BigDecimal feeRate) {
|
||||||
|
this.initialCapital = capital;
|
||||||
this.availableCapital = capital;
|
this.availableCapital = capital;
|
||||||
this.marginRatio = marginRatio;
|
this.marginRatio = marginRatio;
|
||||||
this.feeRate = feeRate;
|
this.feeRate = feeRate;
|
||||||
this.maxEquity = capital;
|
this.maxEquity = capital;
|
||||||
this.minEquity = capital;
|
this.minEquity = capital;
|
||||||
this.dayStartEquity = capital;
|
this.equityHistory.add(capital);
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean hasPosition() {
|
boolean hasPosition() {
|
||||||
@ -480,15 +527,52 @@ public class BacktestEngineImpl implements BacktestEngine {
|
|||||||
return availableCapital.add(marginLocked);
|
return availableCapital.add(marginLocked);
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateEquity() {
|
void recordEquity(KLineData kline, Direction direction) {
|
||||||
BigDecimal equity = getTotalEquity();
|
BigDecimal equity = getTotalEquity();
|
||||||
|
if (hasPosition()) {
|
||||||
|
BigDecimal unrealized = kline.getClose().subtract(avgCostPrice)
|
||||||
|
.multiply(BigDecimal.valueOf(position));
|
||||||
|
if (direction == Direction.SHORT) {
|
||||||
|
unrealized = unrealized.negate();
|
||||||
|
}
|
||||||
|
equity = equity.add(unrealized);
|
||||||
|
}
|
||||||
|
equityHistory.add(equity);
|
||||||
if (equity.compareTo(maxEquity) > 0) maxEquity = equity;
|
if (equity.compareTo(maxEquity) > 0) maxEquity = equity;
|
||||||
if (equity.compareTo(minEquity) < 0) minEquity = equity;
|
if (equity.compareTo(minEquity) < 0) minEquity = equity;
|
||||||
|
|
||||||
|
BigDecimal displayEquity = equity.setScale(2, RoundingMode.HALF_UP);
|
||||||
|
BigDecimal cumulativeYield = displayEquity.subtract(initialCapital)
|
||||||
|
.divide(initialCapital, 8, RoundingMode.HALF_UP)
|
||||||
|
.multiply(BigDecimal.valueOf(100))
|
||||||
|
.setScale(4, RoundingMode.HALF_UP);
|
||||||
|
DailyEquity point = new DailyEquity(
|
||||||
|
kline.getTime().toLocalDate(), displayEquity, cumulativeYield);
|
||||||
|
if (!dailyEquityCurve.isEmpty()
|
||||||
|
&& dailyEquityCurve.getLast().date.equals(point.date)) {
|
||||||
|
dailyEquityCurve.set(dailyEquityCurve.size() - 1, point);
|
||||||
|
} else {
|
||||||
|
dailyEquityCurve.add(point);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void calculateDailyYields() {
|
||||||
|
dailyYields.clear();
|
||||||
|
BigDecimal previousEquity = initialCapital;
|
||||||
|
for (DailyEquity point : dailyEquityCurve) {
|
||||||
|
BigDecimal dailyYield = previousEquity.compareTo(BigDecimal.ZERO) == 0
|
||||||
|
? BigDecimal.ZERO
|
||||||
|
: point.equity.subtract(previousEquity)
|
||||||
|
.divide(previousEquity, 8, RoundingMode.HALF_UP);
|
||||||
|
dailyYields.add(dailyYield);
|
||||||
|
previousEquity = point.equity;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void addRecord(TradeAction action, BigDecimal price, int volume, BigDecimal turnover,
|
void addRecord(TradeAction action, BigDecimal price, int volume, BigDecimal turnover,
|
||||||
LocalDateTime klineTime, String signal) {
|
LocalDateTime klineTime, String signal, BigDecimal netProfit) {
|
||||||
tradeRecords.add(new TradeRecord(action, price, volume, turnover, LocalDateTime.now(), klineTime, signal));
|
tradeRecords.add(new TradeRecord(
|
||||||
|
action, price, volume, turnover, klineTime, klineTime, signal, netProfit));
|
||||||
}
|
}
|
||||||
|
|
||||||
static class TradeRecord {
|
static class TradeRecord {
|
||||||
@ -499,9 +583,11 @@ public class BacktestEngineImpl implements BacktestEngine {
|
|||||||
LocalDateTime tradeTime;
|
LocalDateTime tradeTime;
|
||||||
LocalDateTime klineTime;
|
LocalDateTime klineTime;
|
||||||
String signal;
|
String signal;
|
||||||
|
BigDecimal netProfit;
|
||||||
|
|
||||||
TradeRecord(TradeAction action, BigDecimal price, int volume, BigDecimal turnover,
|
TradeRecord(TradeAction action, BigDecimal price, int volume, BigDecimal turnover,
|
||||||
LocalDateTime tradeTime, LocalDateTime klineTime, String signal) {
|
LocalDateTime tradeTime, LocalDateTime klineTime, String signal,
|
||||||
|
BigDecimal netProfit) {
|
||||||
this.action = action;
|
this.action = action;
|
||||||
this.price = price;
|
this.price = price;
|
||||||
this.volume = volume;
|
this.volume = volume;
|
||||||
@ -509,6 +595,7 @@ public class BacktestEngineImpl implements BacktestEngine {
|
|||||||
this.tradeTime = tradeTime;
|
this.tradeTime = tradeTime;
|
||||||
this.klineTime = klineTime;
|
this.klineTime = klineTime;
|
||||||
this.signal = signal;
|
this.signal = signal;
|
||||||
|
this.netProfit = netProfit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import org.springframework.beans.factory.annotation.Autowired;
|
|||||||
import org.springframework.data.redis.core.RedisTemplate;
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
@ -76,6 +77,17 @@ public class MarketDataServiceImpl implements MarketDataService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public BigDecimal getPriceTick(String contractCode) {
|
||||||
|
validateContract(contractCode);
|
||||||
|
BigDecimal priceTick = marketDataProvider.getPriceTick(contractCode);
|
||||||
|
if (priceTick == null || priceTick.signum() <= 0) {
|
||||||
|
throw new BusinessException(ErrorCode.MARKET_DATA_UNAVAILABLE,
|
||||||
|
"合约缺少有效的最小变动价位: " + contractCode);
|
||||||
|
}
|
||||||
|
return priceTick;
|
||||||
|
}
|
||||||
|
|
||||||
private String buildCacheKey(String contractCode, String period,
|
private String buildCacheKey(String contractCode, String period,
|
||||||
LocalDateTime startTime, LocalDateTime endTime) {
|
LocalDateTime startTime, LocalDateTime endTime) {
|
||||||
long startEpoch = startTime.atZone(ZONE_ID).toEpochSecond();
|
long startEpoch = startTime.atZone(ZONE_ID).toEpochSecond();
|
||||||
|
|||||||
@ -59,6 +59,11 @@ public class FakeMarketDataProvider implements MarketDataProvider {
|
|||||||
return CONTRACT.equals(contractCode);
|
return CONTRACT.equals(contractCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public BigDecimal getPriceTick(String contractCode) {
|
||||||
|
return supportsContract(contractCode) ? BigDecimal.ONE : null;
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 数据生成 ====================
|
// ==================== 数据生成 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package com.yangwale.backtestify.service.market;
|
|||||||
|
|
||||||
import com.yangwale.backtestify.model.dto.KLineData;
|
import com.yangwale.backtestify.model.dto.KLineData;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@ -33,4 +34,9 @@ public interface MarketDataProvider {
|
|||||||
* 检查是否支持该合约
|
* 检查是否支持该合约
|
||||||
*/
|
*/
|
||||||
boolean supportsContract(String contractCode);
|
boolean supportsContract(String contractCode);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取合约最小变动价位
|
||||||
|
*/
|
||||||
|
BigDecimal getPriceTick(String contractCode);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -75,6 +75,12 @@ public class MysqlMarketDataProvider implements MarketDataProvider {
|
|||||||
return findInstrument(contractCode) != null;
|
return findInstrument(contractCode) != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public BigDecimal getPriceTick(String contractCode) {
|
||||||
|
InstrumentDictionary instrument = findInstrument(contractCode);
|
||||||
|
return instrument == null ? null : instrument.getPriceTick();
|
||||||
|
}
|
||||||
|
|
||||||
private InstrumentDictionary findInstrument(String contractCode) {
|
private InstrumentDictionary findInstrument(String contractCode) {
|
||||||
return instrumentDictionaryMapper.selectOne(new LambdaQueryWrapper<InstrumentDictionary>()
|
return instrumentDictionaryMapper.selectOne(new LambdaQueryWrapper<InstrumentDictionary>()
|
||||||
.eq(InstrumentDictionary::getContractCode, contractCode)
|
.eq(InstrumentDictionary::getContractCode, contractCode)
|
||||||
|
|||||||
@ -0,0 +1,220 @@
|
|||||||
|
package com.yangwale.backtestify.service;
|
||||||
|
|
||||||
|
import com.yangwale.backtestify.entity.StrategyConfig;
|
||||||
|
import com.yangwale.backtestify.mapper.StrategyConfigMapper;
|
||||||
|
import com.yangwale.backtestify.mapper.StrategyResultMapper;
|
||||||
|
import com.yangwale.backtestify.mapper.TradeDetailMapper;
|
||||||
|
import com.yangwale.backtestify.model.dto.KLineData;
|
||||||
|
import com.yangwale.backtestify.model.request.BacktestRequest;
|
||||||
|
import com.yangwale.backtestify.model.response.BacktestResponse;
|
||||||
|
import com.yangwale.backtestify.service.impl.BacktestEngineImpl;
|
||||||
|
import com.yangwale.backtestify.service.signal.SignalStrategy;
|
||||||
|
import com.yangwale.backtestify.service.signal.SignalStrategyFactory;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class BacktestEngineCalculationTest {
|
||||||
|
|
||||||
|
private MarketDataService marketDataService;
|
||||||
|
private TestSignalStrategy signalStrategy;
|
||||||
|
private BacktestEngineImpl engine;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
marketDataService = mock(MarketDataService.class);
|
||||||
|
StrategyConfigMapper strategyConfigMapper = mock(StrategyConfigMapper.class);
|
||||||
|
StrategyResultMapper strategyResultMapper = mock(StrategyResultMapper.class);
|
||||||
|
TradeDetailMapper tradeDetailMapper = mock(TradeDetailMapper.class);
|
||||||
|
signalStrategy = new TestSignalStrategy();
|
||||||
|
SignalStrategyFactory signalStrategyFactory =
|
||||||
|
new SignalStrategyFactory(List.of(signalStrategy));
|
||||||
|
|
||||||
|
when(strategyConfigMapper.insert(any(StrategyConfig.class))).thenAnswer(invocation -> {
|
||||||
|
invocation.<StrategyConfig>getArgument(0).setId(1L);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
engine = new BacktestEngineImpl(
|
||||||
|
marketDataService,
|
||||||
|
signalStrategyFactory,
|
||||||
|
strategyConfigMapper,
|
||||||
|
strategyResultMapper,
|
||||||
|
tradeDetailMapper);
|
||||||
|
ReflectionTestUtils.setField(engine, "initialCapital", new BigDecimal("1000000"));
|
||||||
|
ReflectionTestUtils.setField(engine, "marginRatio", new BigDecimal("0.10"));
|
||||||
|
ReflectionTestUtils.setField(engine, "feeRate", new BigDecimal("0.00005"));
|
||||||
|
ReflectionTestUtils.setField(engine, "riskFreeRate", new BigDecimal("0.025"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void longResultUsesRequestedVolumeAndDeductsBothFees() {
|
||||||
|
List<KLineData> data = List.of(
|
||||||
|
kline("2026-07-20T00:00:00", "100"),
|
||||||
|
kline("2026-07-21T00:00:00", "110"));
|
||||||
|
stubMarketData(data);
|
||||||
|
signalStrategy.buyTime = data.getFirst().getTime();
|
||||||
|
|
||||||
|
BacktestResponse result = engine.execute(request("LONG", 2));
|
||||||
|
|
||||||
|
assertEquals(new BigDecimal("1000019.98"), result.getFinalCapital());
|
||||||
|
assertEquals(new BigDecimal("19.98"), result.getProfitAmount());
|
||||||
|
assertEquals(2, result.getTradeCount());
|
||||||
|
assertEquals(2, result.getTradeDetails().getFirst().getVolume());
|
||||||
|
assertEquals(new BigDecimal("1000019.99"), result.getMaxEquity());
|
||||||
|
assertEquals(new BigDecimal("999999.99"), result.getMinEquity());
|
||||||
|
assertEquals(new BigDecimal("100.0000"), result.getWinRate());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shortTakeProfitClosesWhenPriceFalls() {
|
||||||
|
List<KLineData> data = List.of(
|
||||||
|
kline("2026-07-20T00:00:00", "100"),
|
||||||
|
kline("2026-07-21T00:00:00", "95"),
|
||||||
|
kline("2026-07-22T00:00:00", "110"));
|
||||||
|
stubMarketData(data);
|
||||||
|
signalStrategy.sellTime = data.getFirst().getTime();
|
||||||
|
BacktestRequest request = request("SHORT", 2);
|
||||||
|
request.setTakeProfitValue(new BigDecimal("4"));
|
||||||
|
request.setTakeProfitUnit("PERCENT");
|
||||||
|
|
||||||
|
BacktestResponse result = engine.execute(request);
|
||||||
|
|
||||||
|
assertEquals(new BigDecimal("1000009.98"), result.getFinalCapital());
|
||||||
|
assertEquals("2026-07-21T00:00", result.getTradeDetails().getLast().getTradeTime());
|
||||||
|
assertEquals(new BigDecimal("100.0000"), result.getWinRate());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shortStopLossClosesWhenPriceRises() {
|
||||||
|
List<KLineData> data = List.of(
|
||||||
|
kline("2026-07-20T00:00:00", "100"),
|
||||||
|
kline("2026-07-21T00:00:00", "105"),
|
||||||
|
kline("2026-07-22T00:00:00", "90"));
|
||||||
|
stubMarketData(data);
|
||||||
|
signalStrategy.sellTime = data.getFirst().getTime();
|
||||||
|
BacktestRequest request = request("SHORT", 2);
|
||||||
|
request.setStopLossValue(new BigDecimal("4"));
|
||||||
|
request.setStopLossUnit("PERCENT");
|
||||||
|
|
||||||
|
BacktestResponse result = engine.execute(request);
|
||||||
|
|
||||||
|
assertEquals(new BigDecimal("999989.98"), result.getFinalCapital());
|
||||||
|
assertEquals("2026-07-21T00:00", result.getTradeDetails().getLast().getTradeTime());
|
||||||
|
assertEquals(new BigDecimal("0.0000"), result.getWinRate());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void longTickStopLossUsesInstrumentMinimumPriceTick() {
|
||||||
|
List<KLineData> data = List.of(
|
||||||
|
kline("2026-07-20T00:00:00", "100"),
|
||||||
|
kline("2026-07-21T00:00:00", "99"),
|
||||||
|
kline("2026-07-22T00:00:00", "110"));
|
||||||
|
stubMarketData(data);
|
||||||
|
when(marketDataService.getPriceTick("TEST")).thenReturn(new BigDecimal("0.5"));
|
||||||
|
signalStrategy.buyTime = data.getFirst().getTime();
|
||||||
|
BacktestRequest request = request("LONG", 1);
|
||||||
|
request.setStopLossValue(new BigDecimal("2"));
|
||||||
|
request.setStopLossUnit("TICK");
|
||||||
|
|
||||||
|
BacktestResponse result = engine.execute(request);
|
||||||
|
|
||||||
|
assertEquals(new BigDecimal("999998.99"), result.getFinalCapital());
|
||||||
|
assertEquals("2026-07-21T00:00", result.getTradeDetails().getLast().getTradeTime());
|
||||||
|
assertEquals(new BigDecimal("0.0000"), result.getWinRate());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shortTickTakeProfitUsesInstrumentMinimumPriceTick() {
|
||||||
|
List<KLineData> data = List.of(
|
||||||
|
kline("2026-07-20T00:00:00", "100"),
|
||||||
|
kline("2026-07-21T00:00:00", "98.5"),
|
||||||
|
kline("2026-07-22T00:00:00", "110"));
|
||||||
|
stubMarketData(data);
|
||||||
|
when(marketDataService.getPriceTick("TEST")).thenReturn(new BigDecimal("0.5"));
|
||||||
|
signalStrategy.sellTime = data.getFirst().getTime();
|
||||||
|
BacktestRequest request = request("SHORT", 1);
|
||||||
|
request.setTakeProfitValue(new BigDecimal("3"));
|
||||||
|
request.setTakeProfitUnit("TICK");
|
||||||
|
|
||||||
|
BacktestResponse result = engine.execute(request);
|
||||||
|
|
||||||
|
assertEquals(new BigDecimal("1000001.49"), result.getFinalCapital());
|
||||||
|
assertEquals("2026-07-21T00:00", result.getTradeDetails().getLast().getTradeTime());
|
||||||
|
assertEquals(new BigDecimal("100.0000"), result.getWinRate());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void maximumDrawdownUsesPeakBeforeTroughInTimeOrder() {
|
||||||
|
List<KLineData> data = List.of(
|
||||||
|
kline("2026-07-20T00:00:00", "100"),
|
||||||
|
kline("2026-07-21T00:00:00", "120"),
|
||||||
|
kline("2026-07-22T00:00:00", "110"));
|
||||||
|
stubMarketData(data);
|
||||||
|
signalStrategy.buyTime = data.getFirst().getTime();
|
||||||
|
|
||||||
|
BacktestResponse result = engine.execute(request("LONG", 1));
|
||||||
|
|
||||||
|
assertEquals(new BigDecimal("0.0010"), result.getMaxDrawdown());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stubMarketData(List<KLineData> data) {
|
||||||
|
when(marketDataService.getKLineData(any(), any(), any(), any())).thenReturn(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private BacktestRequest request(String direction, int volume) {
|
||||||
|
BacktestRequest request = new BacktestRequest();
|
||||||
|
request.setUserId(1L);
|
||||||
|
request.setContractCode("TEST");
|
||||||
|
request.setContractName("测试合约");
|
||||||
|
request.setDirection(direction);
|
||||||
|
request.setKlinePeriod("1d");
|
||||||
|
request.setIndicators(List.of("TEST"));
|
||||||
|
request.setOpenVolume(volume);
|
||||||
|
request.setVolumeUnit("LOT");
|
||||||
|
request.setBacktestPeriod("1m");
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private KLineData kline(String time, String close) {
|
||||||
|
BigDecimal price = new BigDecimal(close);
|
||||||
|
return KLineData.builder()
|
||||||
|
.time(LocalDateTime.parse(time))
|
||||||
|
.open(price)
|
||||||
|
.high(price)
|
||||||
|
.low(price)
|
||||||
|
.close(price)
|
||||||
|
.volume(BigDecimal.ONE)
|
||||||
|
.amount(BigDecimal.ONE)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class TestSignalStrategy implements SignalStrategy {
|
||||||
|
private LocalDateTime buyTime;
|
||||||
|
private LocalDateTime sellTime;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() {
|
||||||
|
return "TEST";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isBuySignal(KLineData current, List<KLineData> history) {
|
||||||
|
return current.getTime().equals(buyTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isSellSignal(KLineData current, List<KLineData> history) {
|
||||||
|
return current.getTime().equals(sellTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,255 @@
|
|||||||
|
package com.yangwale.backtestify.service;
|
||||||
|
|
||||||
|
import com.yangwale.backtestify.common.BusinessException;
|
||||||
|
import com.yangwale.backtestify.config.WebMvcConfig;
|
||||||
|
import com.yangwale.backtestify.model.dto.KLineData;
|
||||||
|
import com.yangwale.backtestify.model.request.AdminStrategyPageRequest;
|
||||||
|
import com.yangwale.backtestify.model.request.BacktestRequest;
|
||||||
|
import com.yangwale.backtestify.model.request.SignalQueryRequest;
|
||||||
|
import com.yangwale.backtestify.model.response.BacktestResponse;
|
||||||
|
import com.yangwale.backtestify.model.response.StrategyDetailResponse;
|
||||||
|
import com.yangwale.backtestify.service.market.MarketDataProvider;
|
||||||
|
import com.yangwale.backtestify.service.signal.SignalStrategy;
|
||||||
|
import com.yangwale.backtestify.service.signal.SignalStrategyFactory;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
@SpringBootTest(properties = {
|
||||||
|
"spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver",
|
||||||
|
"spring.datasource.url=jdbc:mysql://192.168.2.5:3306/backtestify?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&useSSL=false",
|
||||||
|
"spring.datasource.username=root",
|
||||||
|
"spring.datasource.password=root",
|
||||||
|
"spring.sql.init.mode=never",
|
||||||
|
"market-data.provider=mysql",
|
||||||
|
"market-data.sync.enabled=false"
|
||||||
|
})
|
||||||
|
@Transactional
|
||||||
|
@EnabledIfSystemProperty(named = "realDataTest", matches = "true")
|
||||||
|
class RealDataBusinessIntegrationTest {
|
||||||
|
|
||||||
|
private static final long TEST_USER_ID = 9_900_001L;
|
||||||
|
private static final DateTimeFormatter DATE_TIME_FORMATTER =
|
||||||
|
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MarketDataProvider marketDataProvider;
|
||||||
|
@Autowired
|
||||||
|
private SignalStrategyFactory signalStrategyFactory;
|
||||||
|
@Autowired
|
||||||
|
private StrategyService strategyService;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allBusinessFunctionsWorkWithRealMarketDataAndResultsAreInternallyConsistent() {
|
||||||
|
SelectedScenario scenario = selectScenario();
|
||||||
|
assertTrue(marketDataProvider.getPriceTick(scenario.contractCode()).signum() > 0,
|
||||||
|
"真实合约必须具有有效最小变动价位");
|
||||||
|
WebMvcConfig.RequestContextHolder.setUserId(TEST_USER_ID);
|
||||||
|
try {
|
||||||
|
BacktestRequest request = requestFor(scenario);
|
||||||
|
BacktestResponse result = strategyService.backtest(request);
|
||||||
|
|
||||||
|
assertNotNull(result.getStrategyId());
|
||||||
|
assertTrue(result.getTradeCount() >= 2, "所选真实数据场景应至少完成一笔开平仓");
|
||||||
|
assertEquals(result.getTradeCount(), result.getTradeDetails().size());
|
||||||
|
assertTrue(result.getTradeDetails().stream()
|
||||||
|
.allMatch(trade -> request.getOpenVolume().equals(trade.getVolume())),
|
||||||
|
"成交明细必须使用请求中的开仓数量");
|
||||||
|
assertFalse(result.getDailyEquityCurve().isEmpty(), "真实回测必须产生每日净值曲线");
|
||||||
|
|
||||||
|
BigDecimal expectedProfit = result.getFinalCapital().subtract(result.getInitialCapital());
|
||||||
|
assertEquals(0, expectedProfit.compareTo(result.getProfitAmount()));
|
||||||
|
BigDecimal expectedYield = expectedProfit
|
||||||
|
.divide(result.getInitialCapital(), 8, RoundingMode.HALF_UP)
|
||||||
|
.multiply(BigDecimal.valueOf(100))
|
||||||
|
.setScale(4, RoundingMode.HALF_UP);
|
||||||
|
assertEquals(0, expectedYield.compareTo(result.getTotalYield()));
|
||||||
|
assertEquals(0, calculateProfitFromTrades(result)
|
||||||
|
.compareTo(result.getProfitAmount()));
|
||||||
|
assertEquals(0, calculateWinRateFromTrades(result)
|
||||||
|
.compareTo(result.getWinRate()));
|
||||||
|
assertEquals(0, calculateDrawdownFromDailyCurve(result)
|
||||||
|
.compareTo(result.getMaxDrawdown()));
|
||||||
|
|
||||||
|
assertTrue(strategyService.myList(TEST_USER_ID, 1, 20).getList().stream()
|
||||||
|
.anyMatch(item -> result.getStrategyId().equals(item.getId())));
|
||||||
|
|
||||||
|
StrategyDetailResponse detail = strategyService.detail(result.getStrategyId());
|
||||||
|
assertEquals(result.getFinalCapital(), detail.getFinalCapital());
|
||||||
|
assertEquals(result.getTradeDetails().size(), detail.getTradeDetails().size());
|
||||||
|
assertEquals(result.getDailyEquityCurve().size(), detail.getDailyEquityCurve().size());
|
||||||
|
|
||||||
|
AdminStrategyPageRequest adminRequest = new AdminStrategyPageRequest();
|
||||||
|
adminRequest.setPageNum(1);
|
||||||
|
adminRequest.setPageSize(20);
|
||||||
|
adminRequest.setUserId(TEST_USER_ID);
|
||||||
|
assertTrue(strategyService.adminList(adminRequest).getList().stream()
|
||||||
|
.anyMatch(item -> result.getStrategyId().equals(item.getId())));
|
||||||
|
|
||||||
|
strategyService.toggleSignal(result.getStrategyId(), TEST_USER_ID);
|
||||||
|
SignalQueryRequest signalRequest = new SignalQueryRequest();
|
||||||
|
signalRequest.setUserId(TEST_USER_ID);
|
||||||
|
signalRequest.setContractCode(scenario.contractCode());
|
||||||
|
signalRequest.setPeriod("1d");
|
||||||
|
signalRequest.setStartTime(result.getStartDate().atStartOfDay().minusDays(1)
|
||||||
|
.format(DATE_TIME_FORMATTER));
|
||||||
|
signalRequest.setEndTime(result.getEndDate().plusDays(1).atStartOfDay()
|
||||||
|
.format(DATE_TIME_FORMATTER));
|
||||||
|
assertEquals(result.getTradeCount(), strategyService.getSignals(signalRequest).size());
|
||||||
|
|
||||||
|
strategyService.toggleSignal(result.getStrategyId(), TEST_USER_ID);
|
||||||
|
assertTrue(strategyService.getSignals(signalRequest).isEmpty());
|
||||||
|
|
||||||
|
strategyService.delete(result.getStrategyId(), TEST_USER_ID);
|
||||||
|
assertThrows(BusinessException.class, () -> strategyService.detail(result.getStrategyId()));
|
||||||
|
} finally {
|
||||||
|
WebMvcConfig.RequestContextHolder.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private SelectedScenario selectScenario() {
|
||||||
|
LocalDateTime end = LocalDateTime.now();
|
||||||
|
LocalDateTime start = end.minusYears(1);
|
||||||
|
List<String> contracts = marketDataProvider.getAvailableContracts();
|
||||||
|
assertFalse(contracts.isEmpty(), "真实数据库中必须存在合约字典数据");
|
||||||
|
|
||||||
|
for (String contract : contracts) {
|
||||||
|
List<KLineData> kLines = marketDataProvider.getKLineData(contract, "1d", start, end);
|
||||||
|
if (kLines.size() < 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
validateRealKLines(kLines);
|
||||||
|
for (String indicator : signalStrategyFactory.getAllIndicatorNames()) {
|
||||||
|
SignalStrategy strategy = signalStrategyFactory.get(indicator);
|
||||||
|
boolean hasBuy = kLines.stream().anyMatch(kLine -> strategy.isBuySignal(kLine, kLines));
|
||||||
|
if (hasBuy) {
|
||||||
|
return new SelectedScenario(contract, indicator, "LONG");
|
||||||
|
}
|
||||||
|
boolean hasSell = kLines.stream().anyMatch(kLine -> strategy.isSellSignal(kLine, kLines));
|
||||||
|
if (hasSell) {
|
||||||
|
return new SelectedScenario(contract, indicator, "SHORT");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new AssertionError("真实数据库近一年日线中未找到可产生交易信号的合约");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateRealKLines(List<KLineData> kLines) {
|
||||||
|
for (int index = 0; index < kLines.size(); index++) {
|
||||||
|
KLineData current = kLines.get(index);
|
||||||
|
assertNotNull(current.getIndicators(), "真实K线必须附带指标");
|
||||||
|
assertTrue(current.getClose().compareTo(BigDecimal.ZERO) > 0);
|
||||||
|
assertTrue(current.getHigh().compareTo(current.getOpen()) >= 0);
|
||||||
|
assertTrue(current.getHigh().compareTo(current.getClose()) >= 0);
|
||||||
|
assertTrue(current.getLow().compareTo(current.getOpen()) <= 0);
|
||||||
|
assertTrue(current.getLow().compareTo(current.getClose()) <= 0);
|
||||||
|
if (index > 0) {
|
||||||
|
assertTrue(current.getTime().isAfter(kLines.get(index - 1).getTime()),
|
||||||
|
"真实K线必须严格按时间升序");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (String indicator : signalStrategyFactory.getAllIndicatorNames()) {
|
||||||
|
SignalStrategy strategy = signalStrategyFactory.get(indicator);
|
||||||
|
for (KLineData kLine : kLines) {
|
||||||
|
strategy.isBuySignal(kLine, kLines);
|
||||||
|
strategy.isSellSignal(kLine, kLines);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal calculateProfitFromTrades(BacktestResponse result) {
|
||||||
|
BigDecimal profit = BigDecimal.ZERO;
|
||||||
|
BigDecimal feeRate = new BigDecimal("0.00005");
|
||||||
|
for (int index = 0; index < result.getTradeDetails().size(); index += 2) {
|
||||||
|
BacktestResponse.TradeDetailItem open = result.getTradeDetails().get(index);
|
||||||
|
BacktestResponse.TradeDetailItem close = result.getTradeDetails().get(index + 1);
|
||||||
|
BigDecimal priceChange = close.getPrice().subtract(open.getPrice())
|
||||||
|
.multiply(BigDecimal.valueOf(open.getVolume()));
|
||||||
|
BigDecimal grossProfit = "SHORT".equalsIgnoreCase(result.getDirection())
|
||||||
|
? priceChange.negate()
|
||||||
|
: priceChange;
|
||||||
|
BigDecimal fees = open.getTurnover().add(close.getTurnover()).multiply(feeRate);
|
||||||
|
profit = profit.add(grossProfit).subtract(fees);
|
||||||
|
}
|
||||||
|
return profit.setScale(2, RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal calculateWinRateFromTrades(BacktestResponse result) {
|
||||||
|
int wins = 0;
|
||||||
|
int closedTrades = result.getTradeDetails().size() / 2;
|
||||||
|
BigDecimal feeRate = new BigDecimal("0.00005");
|
||||||
|
for (int index = 0; index < result.getTradeDetails().size(); index += 2) {
|
||||||
|
BacktestResponse.TradeDetailItem open = result.getTradeDetails().get(index);
|
||||||
|
BacktestResponse.TradeDetailItem close = result.getTradeDetails().get(index + 1);
|
||||||
|
BigDecimal priceChange = close.getPrice().subtract(open.getPrice())
|
||||||
|
.multiply(BigDecimal.valueOf(open.getVolume()));
|
||||||
|
BigDecimal grossProfit = "SHORT".equalsIgnoreCase(result.getDirection())
|
||||||
|
? priceChange.negate()
|
||||||
|
: priceChange;
|
||||||
|
BigDecimal netProfit = grossProfit.subtract(
|
||||||
|
open.getTurnover().add(close.getTurnover()).multiply(feeRate));
|
||||||
|
if (netProfit.compareTo(BigDecimal.ZERO) > 0) {
|
||||||
|
wins++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return closedTrades == 0
|
||||||
|
? BigDecimal.ZERO.setScale(4)
|
||||||
|
: BigDecimal.valueOf(wins)
|
||||||
|
.divide(BigDecimal.valueOf(closedTrades), 8, RoundingMode.HALF_UP)
|
||||||
|
.multiply(BigDecimal.valueOf(100))
|
||||||
|
.setScale(4, RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal calculateDrawdownFromDailyCurve(BacktestResponse result) {
|
||||||
|
BigDecimal peak = result.getInitialCapital();
|
||||||
|
BigDecimal maximum = BigDecimal.ZERO;
|
||||||
|
for (BacktestResponse.DailyEquityPoint point : result.getDailyEquityCurve()) {
|
||||||
|
if (point.getEquity().compareTo(peak) > 0) {
|
||||||
|
peak = point.getEquity();
|
||||||
|
}
|
||||||
|
BigDecimal drawdown = peak.subtract(point.getEquity())
|
||||||
|
.divide(peak, 8, RoundingMode.HALF_UP);
|
||||||
|
if (drawdown.compareTo(maximum) > 0) {
|
||||||
|
maximum = drawdown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maximum.multiply(BigDecimal.valueOf(100))
|
||||||
|
.setScale(4, RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
|
||||||
|
private BacktestRequest requestFor(SelectedScenario scenario) {
|
||||||
|
BacktestRequest request = new BacktestRequest();
|
||||||
|
request.setUserId(TEST_USER_ID);
|
||||||
|
request.setContractCode(scenario.contractCode());
|
||||||
|
request.setContractName(scenario.contractCode() + "-真实数据验证");
|
||||||
|
request.setDirection(scenario.direction());
|
||||||
|
request.setKlinePeriod("1d");
|
||||||
|
request.setIndicators(List.of(scenario.indicator()));
|
||||||
|
request.setOpenVolume(2);
|
||||||
|
request.setVolumeUnit("LOT");
|
||||||
|
request.setStopLossValue(new BigDecimal("3"));
|
||||||
|
request.setStopLossUnit("TICK");
|
||||||
|
request.setTakeProfitValue(new BigDecimal("5"));
|
||||||
|
request.setTakeProfitUnit("TICK");
|
||||||
|
request.setBacktestPeriod("1y");
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private record SelectedScenario(String contractCode, String indicator, String direction) {
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user