From 900c1a2ab8ea8fac1fb422f5eea3e5d674e6c9b4 Mon Sep 17 00:00:00 2001 From: Lee <1633292@qq.com> Date: Mon, 3 Aug 2026 11:45:30 +0800 Subject: [PATCH] init --- .../service/MarketDataService.java | 6 + .../service/impl/BacktestEngineImpl.java | 207 +++++++++----- .../service/impl/MarketDataServiceImpl.java | 12 + .../market/FakeMarketDataProvider.java | 5 + .../service/market/MarketDataProvider.java | 6 + .../market/MysqlMarketDataProvider.java | 6 + .../BacktestEngineCalculationTest.java | 220 +++++++++++++++ .../RealDataBusinessIntegrationTest.java | 255 ++++++++++++++++++ 8 files changed, 657 insertions(+), 60 deletions(-) create mode 100644 src/test/java/com/yangwale/backtestify/service/BacktestEngineCalculationTest.java create mode 100644 src/test/java/com/yangwale/backtestify/service/RealDataBusinessIntegrationTest.java diff --git a/src/main/java/com/yangwale/backtestify/service/MarketDataService.java b/src/main/java/com/yangwale/backtestify/service/MarketDataService.java index 22e6124..6e9cec8 100644 --- a/src/main/java/com/yangwale/backtestify/service/MarketDataService.java +++ b/src/main/java/com/yangwale/backtestify/service/MarketDataService.java @@ -2,6 +2,7 @@ package com.yangwale.backtestify.service; import com.yangwale.backtestify.model.dto.KLineData; +import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.List; @@ -25,4 +26,9 @@ public interface MarketDataService { * 验证合约是否存在 */ void validateContract(String contractCode); + + /** + * 获取合约最小变动价位 + */ + BigDecimal getPriceTick(String contractCode); } diff --git a/src/main/java/com/yangwale/backtestify/service/impl/BacktestEngineImpl.java b/src/main/java/com/yangwale/backtestify/service/impl/BacktestEngineImpl.java index 08f5b92..1747ae4 100644 --- a/src/main/java/com/yangwale/backtestify/service/impl/BacktestEngineImpl.java +++ b/src/main/java/com/yangwale/backtestify/service/impl/BacktestEngineImpl.java @@ -9,6 +9,7 @@ import com.yangwale.backtestify.entity.StrategyResult; import com.yangwale.backtestify.entity.TradeDetail; import com.yangwale.backtestify.enums.Direction; import com.yangwale.backtestify.enums.KLinePeriod; +import com.yangwale.backtestify.enums.StopUnit; import com.yangwale.backtestify.enums.TradeAction; import com.yangwale.backtestify.mapper.StrategyConfigMapper; import com.yangwale.backtestify.mapper.StrategyResultMapper; @@ -68,6 +69,7 @@ public class BacktestEngineImpl implements BacktestEngine { Direction direction = Direction.of(request.getDirection()); KLinePeriod period = KLinePeriod.of(request.getKlinePeriod()); List strategies = signalStrategyFactory.get(request.getIndicators()); + BigDecimal priceTick = resolvePriceTick(request); // 2. 计算回测时间范围 LocalDateTime now = LocalDateTime.now(); @@ -82,16 +84,17 @@ public class BacktestEngineImpl implements BacktestEngine { log.info("回测区间: {} ~ {}, K线数量: {}", kLines.getFirst().getTime(), kLines.getLast().getTime(), kLines.size()); // 4. 创建回测上下文 - BacktestContext ctx = new BacktestContext(initialCapital, marginRatio, feeRate); + BigDecimal initialCapitalAmount = money(initialCapital); + BacktestContext ctx = new BacktestContext(initialCapitalAmount, marginRatio, feeRate); // 5. 逐根K线遍历 for (int i = 0; i < kLines.size(); i++) { KLineData kline = kLines.get(i); - ctx.updateEquity(); // 5a. 检查止盈/止损 - if (ctx.hasPosition() && checkStopCondition(ctx, kline, request)) { + if (ctx.hasPosition() && checkStopCondition(ctx, kline, request, direction, priceTick)) { closePosition(ctx, kline, direction); + ctx.recordEquity(kline, direction); continue; } @@ -101,27 +104,32 @@ public class BacktestEngineImpl implements BacktestEngine { boolean allSell = strategies.stream().allMatch(s -> s.isSellSignal(kline, kLines)); if (direction == Direction.LONG && allBuy) { - openPosition(ctx, kline, direction); + openPosition(ctx, kline, direction, request.getOpenVolume()); } else if (direction == Direction.SHORT && allSell) { - openPosition(ctx, kline, direction); + openPosition(ctx, kline, direction, request.getOpenVolume()); } } + ctx.recordEquity(kline, direction); } // 6. 遍历结束,强制平仓 if (ctx.hasPosition()) { KLineData lastKline = kLines.getLast(); closePosition(ctx, lastKline, direction); + ctx.recordEquity(lastKline, direction); } + ctx.calculateDailyYields(); // 7. 计算指标 LocalDate startDate = kLines.getFirst().getTime().toLocalDate(); LocalDate endDate = kLines.getLast().getTime().toLocalDate(); long totalDays = ChronoUnit.DAYS.between(startDate, endDate) + 1; - BigDecimal finalCapital = ctx.getTotalEquity(); - BigDecimal totalYield = calcTotalYield(finalCapital); - BigDecimal profitAmount = finalCapital.subtract(initialCapital); + BigDecimal finalCapital = money(ctx.getTotalEquity()); + BigDecimal maxEquity = money(ctx.maxEquity); + BigDecimal minEquity = money(ctx.minEquity); + BigDecimal totalYield = calcTotalYield(finalCapital, initialCapitalAmount); + BigDecimal profitAmount = money(finalCapital.subtract(initialCapitalAmount)); BigDecimal annualizedYield = calcAnnualizedYield(totalYield, totalDays); BigDecimal maxDrawdown = calcMaxDrawdown(ctx); BigDecimal sharpeRatio = calcSharpeRatio(ctx.dailyYields); @@ -129,24 +137,23 @@ public class BacktestEngineImpl implements BacktestEngine { // 8. 持久化 StrategyConfig config = saveStrategyConfig(request); - saveStrategyResult(config.getId(), initialCapital, finalCapital, - ctx.maxEquity, ctx.minEquity, totalYield, profitAmount, annualizedYield, + saveStrategyResult(config.getId(), initialCapitalAmount, finalCapital, + maxEquity, minEquity, totalYield, profitAmount, annualizedYield, ctx.tradeRecords.size(), maxDrawdown, sharpeRatio, winRate, startDate, endDate, ctx.dailyEquityCurve); List tradeDetails = saveTradeDetails(config.getId(), ctx.tradeRecords); // 9. 构建响应 - return buildResponse(request, config.getId(), initialCapital, finalCapital, - ctx.maxEquity, ctx.minEquity, totalYield, profitAmount, annualizedYield, + return buildResponse(request, config.getId(), initialCapitalAmount, finalCapital, + maxEquity, minEquity, totalYield, profitAmount, annualizedYield, ctx.tradeRecords.size(), maxDrawdown, sharpeRatio, winRate, 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(); - int volume = 1; // 简化:每次开仓1手 BigDecimal turnover = price.multiply(BigDecimal.valueOf(volume)); BigDecimal margin = turnover.multiply(marginRatio); BigDecimal fee = turnover.multiply(feeRate); @@ -160,10 +167,11 @@ public class BacktestEngineImpl implements BacktestEngine { ctx.position = volume; ctx.avgCostPrice = price; ctx.marginLocked = margin; + ctx.openFee = fee; TradeAction action = direction == Direction.LONG ? TradeAction.BUY_OPEN : TradeAction.SELL_OPEN; 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); } @@ -178,61 +186,95 @@ public class BacktestEngineImpl implements BacktestEngine { if (direction == Direction.SHORT) { 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.position = 0; ctx.marginLocked = BigDecimal.ZERO; ctx.avgCostPrice = BigDecimal.ZERO; + ctx.openFee = BigDecimal.ZERO; TradeAction action = direction == Direction.LONG ? TradeAction.SELL_CLOSE : TradeAction.BUY_CLOSE; 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); } // ==================== 止盈止损检查 ==================== - 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 cost = ctx.avgCostPrice; if (request.getStopLossValue() != null) { - if ("PERCENT".equalsIgnoreCase(request.getStopLossUnit())) { - BigDecimal stopPct = request.getStopLossValue().divide(BigDecimal.valueOf(100), 6, RoundingMode.HALF_UP); - BigDecimal lossRatio = BigDecimal.ONE.subtract(stopPct); - // 做多:止损价 = 成本价 × (1 - 止损%) - // 做空:止损价 = 成本价 × (1 + 止损%) - BigDecimal stopPrice = cost.multiply(lossRatio); - if (price.compareTo(stopPrice) <= 0) { - log.info("触发止损: 价格={}, 止损价={}", price, stopPrice); - return true; - } + BigDecimal stopDistance = calculateStopDistance( + cost, request.getStopLossValue(), StopUnit.of(request.getStopLossUnit()), priceTick); + BigDecimal stopPrice = direction == Direction.LONG + ? cost.subtract(stopDistance) + : cost.add(stopDistance); + boolean triggered = direction == Direction.LONG + ? price.compareTo(stopPrice) <= 0 + : price.compareTo(stopPrice) >= 0; + if (triggered) { + log.info("触发止损: 价格={}, 止损价={}", price, stopPrice); + return true; } } if (request.getTakeProfitValue() != null) { - if ("PERCENT".equalsIgnoreCase(request.getTakeProfitUnit())) { - BigDecimal tpPct = request.getTakeProfitValue().divide(BigDecimal.valueOf(100), 6, RoundingMode.HALF_UP); - BigDecimal gainRatio = BigDecimal.ONE.add(tpPct); - BigDecimal tpPrice = cost.multiply(gainRatio); - if (price.compareTo(tpPrice) >= 0) { - log.info("触发止盈: 价格={}, 止盈价={}", price, tpPrice); - return true; - } + BigDecimal takeProfitDistance = calculateStopDistance( + cost, request.getTakeProfitValue(), StopUnit.of(request.getTakeProfitUnit()), priceTick); + BigDecimal takeProfitPrice = direction == Direction.LONG + ? cost.add(takeProfitDistance) + : cost.subtract(takeProfitDistance); + boolean triggered = direction == Direction.LONG + ? price.compareTo(takeProfitPrice) >= 0 + : price.compareTo(takeProfitPrice) <= 0; + if (triggered) { + log.info("触发止盈: 价格={}, 止盈价={}", price, takeProfitPrice); + return true; } } 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) { - return finalCapital.subtract(initialCapital) - .divide(initialCapital, 8, RoundingMode.HALF_UP) + private BigDecimal calcTotalYield(BigDecimal finalCapital, BigDecimal initialCapitalAmount) { + return finalCapital.subtract(initialCapitalAmount) + .divide(initialCapitalAmount, 8, RoundingMode.HALF_UP) .multiply(BigDecimal.valueOf(100)) .setScale(4, RoundingMode.HALF_UP); } + private BigDecimal money(BigDecimal value) { + return value.setScale(2, RoundingMode.HALF_UP); + } + private BigDecimal calcAnnualizedYield(BigDecimal totalYield, long totalDays) { if (totalDays <= 0) return BigDecimal.ZERO; return totalYield.divide(BigDecimal.valueOf(totalDays), 8, RoundingMode.HALF_UP) @@ -241,12 +283,21 @@ public class BacktestEngineImpl implements BacktestEngine { } private BigDecimal calcMaxDrawdown(BacktestContext ctx) { - if (ctx.maxEquity.compareTo(BigDecimal.ZERO) == 0) return BigDecimal.ZERO; - BigDecimal minAfterMax = ctx.minEquity; - return ctx.maxEquity.subtract(minAfterMax) - .divide(ctx.maxEquity, 8, RoundingMode.HALF_UP) - .multiply(BigDecimal.valueOf(100)) - .abs() + BigDecimal peak = BigDecimal.ZERO; + BigDecimal maximum = BigDecimal.ZERO; + for (BigDecimal equity : ctx.equityHistory) { + if (equity.compareTo(peak) > 0) { + peak = equity; + } + 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); } @@ -274,18 +325,11 @@ public class BacktestEngineImpl implements BacktestEngine { if (records.isEmpty()) return BigDecimal.ZERO; long winCount = 0; long totalClose = 0; - for (int i = 0; i < records.size(); i++) { - BacktestContext.TradeRecord r = records.get(i); + for (BacktestContext.TradeRecord r : records) { if (r.action == TradeAction.SELL_CLOSE || r.action == TradeAction.BUY_CLOSE) { totalClose++; - // 寻找对应的开仓记录计算盈亏 - for (int j = i - 1; j >= 0; j--) { - BacktestContext.TradeRecord open = records.get(j); - if ((r.action == TradeAction.SELL_CLOSE && open.action == TradeAction.BUY_OPEN) - || (r.action == TradeAction.BUY_CLOSE && open.action == TradeAction.SELL_OPEN)) { - if (r.turnover.compareTo(open.turnover) > 0) winCount++; - break; - } + if (r.netProfit != null && r.netProfit.compareTo(BigDecimal.ZERO) > 0) { + winCount++; } } } @@ -456,20 +500,23 @@ public class BacktestEngineImpl implements BacktestEngine { int position = 0; BigDecimal avgCostPrice = BigDecimal.ZERO; BigDecimal marginLocked = BigDecimal.ZERO; + BigDecimal openFee = BigDecimal.ZERO; + BigDecimal initialCapital; BigDecimal maxEquity; BigDecimal minEquity; List tradeRecords = new ArrayList<>(); List dailyEquityCurve = new ArrayList<>(); List dailyYields = new ArrayList<>(); - BigDecimal dayStartEquity; + List equityHistory = new ArrayList<>(); BacktestContext(BigDecimal capital, BigDecimal marginRatio, BigDecimal feeRate) { + this.initialCapital = capital; this.availableCapital = capital; this.marginRatio = marginRatio; this.feeRate = feeRate; this.maxEquity = capital; this.minEquity = capital; - this.dayStartEquity = capital; + this.equityHistory.add(capital); } boolean hasPosition() { @@ -480,15 +527,52 @@ public class BacktestEngineImpl implements BacktestEngine { return availableCapital.add(marginLocked); } - void updateEquity() { + void recordEquity(KLineData kline, Direction direction) { 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(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, - LocalDateTime klineTime, String signal) { - tradeRecords.add(new TradeRecord(action, price, volume, turnover, LocalDateTime.now(), klineTime, signal)); + LocalDateTime klineTime, String signal, BigDecimal netProfit) { + tradeRecords.add(new TradeRecord( + action, price, volume, turnover, klineTime, klineTime, signal, netProfit)); } static class TradeRecord { @@ -499,9 +583,11 @@ public class BacktestEngineImpl implements BacktestEngine { LocalDateTime tradeTime; LocalDateTime klineTime; String signal; + BigDecimal netProfit; 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.price = price; this.volume = volume; @@ -509,6 +595,7 @@ public class BacktestEngineImpl implements BacktestEngine { this.tradeTime = tradeTime; this.klineTime = klineTime; this.signal = signal; + this.netProfit = netProfit; } } diff --git a/src/main/java/com/yangwale/backtestify/service/impl/MarketDataServiceImpl.java b/src/main/java/com/yangwale/backtestify/service/impl/MarketDataServiceImpl.java index 5c1f218..6099bdb 100644 --- a/src/main/java/com/yangwale/backtestify/service/impl/MarketDataServiceImpl.java +++ b/src/main/java/com/yangwale/backtestify/service/impl/MarketDataServiceImpl.java @@ -11,6 +11,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; +import java.math.BigDecimal; import java.time.Duration; import java.time.LocalDateTime; 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, LocalDateTime startTime, LocalDateTime endTime) { long startEpoch = startTime.atZone(ZONE_ID).toEpochSecond(); diff --git a/src/main/java/com/yangwale/backtestify/service/market/FakeMarketDataProvider.java b/src/main/java/com/yangwale/backtestify/service/market/FakeMarketDataProvider.java index 2b9febb..fcbf33c 100644 --- a/src/main/java/com/yangwale/backtestify/service/market/FakeMarketDataProvider.java +++ b/src/main/java/com/yangwale/backtestify/service/market/FakeMarketDataProvider.java @@ -59,6 +59,11 @@ public class FakeMarketDataProvider implements MarketDataProvider { return CONTRACT.equals(contractCode); } + @Override + public BigDecimal getPriceTick(String contractCode) { + return supportsContract(contractCode) ? BigDecimal.ONE : null; + } + // ==================== 数据生成 ==================== /** diff --git a/src/main/java/com/yangwale/backtestify/service/market/MarketDataProvider.java b/src/main/java/com/yangwale/backtestify/service/market/MarketDataProvider.java index df20b84..a745d61 100644 --- a/src/main/java/com/yangwale/backtestify/service/market/MarketDataProvider.java +++ b/src/main/java/com/yangwale/backtestify/service/market/MarketDataProvider.java @@ -2,6 +2,7 @@ package com.yangwale.backtestify.service.market; import com.yangwale.backtestify.model.dto.KLineData; +import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.List; @@ -33,4 +34,9 @@ public interface MarketDataProvider { * 检查是否支持该合约 */ boolean supportsContract(String contractCode); + + /** + * 获取合约最小变动价位 + */ + BigDecimal getPriceTick(String contractCode); } diff --git a/src/main/java/com/yangwale/backtestify/service/market/MysqlMarketDataProvider.java b/src/main/java/com/yangwale/backtestify/service/market/MysqlMarketDataProvider.java index 2ac8d87..720aec7 100644 --- a/src/main/java/com/yangwale/backtestify/service/market/MysqlMarketDataProvider.java +++ b/src/main/java/com/yangwale/backtestify/service/market/MysqlMarketDataProvider.java @@ -75,6 +75,12 @@ public class MysqlMarketDataProvider implements MarketDataProvider { 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) { return instrumentDictionaryMapper.selectOne(new LambdaQueryWrapper() .eq(InstrumentDictionary::getContractCode, contractCode) diff --git a/src/test/java/com/yangwale/backtestify/service/BacktestEngineCalculationTest.java b/src/test/java/com/yangwale/backtestify/service/BacktestEngineCalculationTest.java new file mode 100644 index 0000000..3b4dbe8 --- /dev/null +++ b/src/test/java/com/yangwale/backtestify/service/BacktestEngineCalculationTest.java @@ -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.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 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 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 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 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 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 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 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 history) { + return current.getTime().equals(buyTime); + } + + @Override + public boolean isSellSignal(KLineData current, List history) { + return current.getTime().equals(sellTime); + } + } +} diff --git a/src/test/java/com/yangwale/backtestify/service/RealDataBusinessIntegrationTest.java b/src/test/java/com/yangwale/backtestify/service/RealDataBusinessIntegrationTest.java new file mode 100644 index 0000000..5b3b4d0 --- /dev/null +++ b/src/test/java/com/yangwale/backtestify/service/RealDataBusinessIntegrationTest.java @@ -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 contracts = marketDataProvider.getAvailableContracts(); + assertFalse(contracts.isEmpty(), "真实数据库中必须存在合约字典数据"); + + for (String contract : contracts) { + List 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 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) { + } +}