This commit is contained in:
Lee 2026-08-03 11:45:30 +08:00
parent 48079cea72
commit 900c1a2ab8
8 changed files with 657 additions and 60 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -75,6 +75,12 @@ public class MysqlMarketDataProvider implements MarketDataProvider {
return findInstrument(contractCode) != null; return findInstrument(contractCode) != null;
} }
@Override
public BigDecimal getPriceTick(String contractCode) {
InstrumentDictionary instrument = findInstrument(contractCode);
return instrument == null ? null : instrument.getPriceTick();
}
private InstrumentDictionary findInstrument(String contractCode) { private InstrumentDictionary findInstrument(String contractCode) {
return instrumentDictionaryMapper.selectOne(new LambdaQueryWrapper<InstrumentDictionary>() return instrumentDictionaryMapper.selectOne(new LambdaQueryWrapper<InstrumentDictionary>()
.eq(InstrumentDictionary::getContractCode, contractCode) .eq(InstrumentDictionary::getContractCode, contractCode)

View File

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

View File

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