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 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);
}

View File

@ -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<SignalStrategy> 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<TradeDetail> 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<TradeRecord> tradeRecords = new ArrayList<>();
List<DailyEquity> dailyEquityCurve = new ArrayList<>();
List<BigDecimal> dailyYields = new ArrayList<>();
BigDecimal dayStartEquity;
List<BigDecimal> 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;
}
}

View File

@ -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();

View File

@ -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;
}
// ==================== 数据生成 ====================
/**

View File

@ -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);
}

View File

@ -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<InstrumentDictionary>()
.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) {
}
}