617 lines
27 KiB
Java
617 lines
27 KiB
Java
package com.yangwale.backtestify.service.impl;
|
|
|
|
import com.alibaba.fastjson2.JSON;
|
|
import com.yangwale.backtestify.common.BusinessException;
|
|
import com.yangwale.backtestify.common.ErrorCode;
|
|
import com.yangwale.backtestify.config.WebMvcConfig;
|
|
import com.yangwale.backtestify.entity.StrategyConfig;
|
|
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;
|
|
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.BacktestEngine;
|
|
import com.yangwale.backtestify.service.MarketDataService;
|
|
import com.yangwale.backtestify.service.signal.SignalStrategy;
|
|
import com.yangwale.backtestify.service.signal.SignalStrategyFactory;
|
|
import lombok.Getter;
|
|
import lombok.RequiredArgsConstructor;
|
|
import lombok.extern.slf4j.Slf4j;
|
|
import org.springframework.beans.factory.annotation.Value;
|
|
import org.springframework.stereotype.Component;
|
|
import org.springframework.transaction.annotation.Transactional;
|
|
|
|
import java.math.BigDecimal;
|
|
import java.math.RoundingMode;
|
|
import java.time.LocalDate;
|
|
import java.time.LocalDateTime;
|
|
import java.time.temporal.ChronoUnit;
|
|
import java.util.ArrayList;
|
|
import java.util.List;
|
|
|
|
/**
|
|
* 回测引擎核心实现
|
|
*/
|
|
@Slf4j
|
|
@Component
|
|
@RequiredArgsConstructor
|
|
public class BacktestEngineImpl implements BacktestEngine {
|
|
|
|
private final MarketDataService marketDataService;
|
|
private final SignalStrategyFactory signalStrategyFactory;
|
|
private final StrategyConfigMapper strategyConfigMapper;
|
|
private final StrategyResultMapper strategyResultMapper;
|
|
private final TradeDetailMapper tradeDetailMapper;
|
|
|
|
@Value("${backtest.initial-capital:1000000}")
|
|
private BigDecimal initialCapital;
|
|
|
|
@Value("${backtest.margin-ratio:0.10}")
|
|
private BigDecimal marginRatio;
|
|
|
|
@Value("${backtest.fee-rate:0.00005}")
|
|
private BigDecimal feeRate;
|
|
|
|
@Value("${backtest.risk-free-rate:0.025}")
|
|
private BigDecimal riskFreeRate;
|
|
|
|
@Override
|
|
@Transactional(rollbackFor = Exception.class)
|
|
public BacktestResponse execute(BacktestRequest request) {
|
|
// 1. 参数解析
|
|
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();
|
|
LocalDateTime startTime = calcStartTime(now, request.getBacktestPeriod());
|
|
|
|
// 3. 获取历史K线数据
|
|
List<KLineData> kLines = marketDataService.getKLineData(
|
|
request.getContractCode(), period.getCode(), startTime, now);
|
|
if (kLines.isEmpty()) {
|
|
throw new BusinessException(ErrorCode.MARKET_DATA_UNAVAILABLE, "回测区间内无可用K线数据");
|
|
}
|
|
log.info("回测区间: {} ~ {}, K线数量: {}", kLines.getFirst().getTime(), kLines.getLast().getTime(), kLines.size());
|
|
|
|
// 4. 创建回测上下文
|
|
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);
|
|
|
|
// 5a. 检查止盈/止损
|
|
if (ctx.hasPosition() && checkStopCondition(ctx, kline, request, direction, priceTick)) {
|
|
closePosition(ctx, kline, direction);
|
|
ctx.recordEquity(kline, direction);
|
|
continue;
|
|
}
|
|
|
|
// 5b. 检查信号策略
|
|
if (!ctx.hasPosition()) {
|
|
boolean allBuy = strategies.stream().allMatch(s -> s.isBuySignal(kline, kLines));
|
|
boolean allSell = strategies.stream().allMatch(s -> s.isSellSignal(kline, kLines));
|
|
|
|
if (direction == Direction.LONG && allBuy) {
|
|
openPosition(ctx, kline, direction, request.getOpenVolume());
|
|
} else if (direction == Direction.SHORT && allSell) {
|
|
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 = 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);
|
|
BigDecimal winRate = calcWinRate(ctx.tradeRecords);
|
|
|
|
// 8. 持久化
|
|
StrategyConfig config = saveStrategyConfig(request);
|
|
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(), 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, int volume) {
|
|
BigDecimal price = kline.getClose();
|
|
BigDecimal turnover = price.multiply(BigDecimal.valueOf(volume));
|
|
BigDecimal margin = turnover.multiply(marginRatio);
|
|
BigDecimal fee = turnover.multiply(feeRate);
|
|
|
|
if (ctx.availableCapital.compareTo(margin.add(fee)) < 0) {
|
|
log.debug("保证金不足,跳过开仓。可用: {}, 需要: {}", ctx.availableCapital, margin.add(fee));
|
|
return;
|
|
}
|
|
|
|
ctx.availableCapital = ctx.availableCapital.subtract(margin).subtract(fee);
|
|
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, null);
|
|
log.debug("开仓: {} {}手 @ {}, 保证金={}, 手续费={}", action.getLabel(), volume, price, margin, fee);
|
|
}
|
|
|
|
private void closePosition(BacktestContext ctx, KLineData kline, Direction direction) {
|
|
BigDecimal price = kline.getClose();
|
|
int volume = ctx.position;
|
|
BigDecimal turnover = price.multiply(BigDecimal.valueOf(volume));
|
|
BigDecimal fee = turnover.multiply(feeRate);
|
|
|
|
// 释放保证金,计算盈亏,更新可用资金
|
|
BigDecimal profit = turnover.subtract(ctx.avgCostPrice.multiply(BigDecimal.valueOf(volume)));
|
|
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, netProfit);
|
|
log.debug("平仓: {} {}手 @ {}, 盈亏={}, 手续费={}", action.getLabel(), volume, price, profit, fee);
|
|
}
|
|
|
|
// ==================== 止盈止损检查 ====================
|
|
|
|
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) {
|
|
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) {
|
|
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, 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)
|
|
.multiply(BigDecimal.valueOf(365))
|
|
.setScale(4, RoundingMode.HALF_UP);
|
|
}
|
|
|
|
private BigDecimal calcMaxDrawdown(BacktestContext ctx) {
|
|
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);
|
|
}
|
|
|
|
private BigDecimal calcSharpeRatio(List<BigDecimal> dailyYields) {
|
|
if (dailyYields == null || dailyYields.size() < 5) return BigDecimal.ZERO;
|
|
// 日收益率平均值
|
|
BigDecimal sum = dailyYields.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
|
|
BigDecimal avgDaily = sum.divide(BigDecimal.valueOf(dailyYields.size()), 8, RoundingMode.HALF_UP);
|
|
// 日收益率标准差
|
|
BigDecimal variance = dailyYields.stream()
|
|
.map(y -> y.subtract(avgDaily).pow(2))
|
|
.reduce(BigDecimal.ZERO, BigDecimal::add)
|
|
.divide(BigDecimal.valueOf(dailyYields.size()), 8, RoundingMode.HALF_UP);
|
|
BigDecimal stdDaily = BigDecimal.valueOf(Math.sqrt(variance.doubleValue()));
|
|
if (stdDaily.compareTo(BigDecimal.ZERO) == 0) return BigDecimal.ZERO;
|
|
// 年化夏普
|
|
BigDecimal annualizedReturn = avgDaily.multiply(BigDecimal.valueOf(252)); // 252交易日
|
|
BigDecimal annualizedStd = stdDaily.multiply(BigDecimal.valueOf(Math.sqrt(252)));
|
|
return annualizedReturn.subtract(riskFreeRate)
|
|
.divide(annualizedStd, 8, RoundingMode.HALF_UP)
|
|
.setScale(4, RoundingMode.HALF_UP);
|
|
}
|
|
|
|
private BigDecimal calcWinRate(List<BacktestContext.TradeRecord> records) {
|
|
if (records.isEmpty()) return BigDecimal.ZERO;
|
|
long winCount = 0;
|
|
long totalClose = 0;
|
|
for (BacktestContext.TradeRecord r : records) {
|
|
if (r.action == TradeAction.SELL_CLOSE || r.action == TradeAction.BUY_CLOSE) {
|
|
totalClose++;
|
|
if (r.netProfit != null && r.netProfit.compareTo(BigDecimal.ZERO) > 0) {
|
|
winCount++;
|
|
}
|
|
}
|
|
}
|
|
return totalClose == 0 ? BigDecimal.ZERO :
|
|
BigDecimal.valueOf(winCount).divide(BigDecimal.valueOf(totalClose), 8, RoundingMode.HALF_UP)
|
|
.multiply(BigDecimal.valueOf(100)).setScale(4, RoundingMode.HALF_UP);
|
|
}
|
|
|
|
// ==================== 时间范围计算 ====================
|
|
|
|
private LocalDateTime calcStartTime(LocalDateTime now, String backtestPeriod) {
|
|
return switch (backtestPeriod) {
|
|
case "1m" -> now.minusMonths(1);
|
|
case "6m" -> now.minusMonths(6);
|
|
case "1y" -> now.minusYears(1);
|
|
default -> now.minusMonths(3);
|
|
};
|
|
}
|
|
|
|
// ==================== 持久化 ====================
|
|
|
|
private StrategyConfig saveStrategyConfig(BacktestRequest request) {
|
|
StrategyConfig config = new StrategyConfig();
|
|
config.setUserId(WebMvcConfig.RequestContextHolder.getUserId());
|
|
config.setContractCode(request.getContractCode());
|
|
config.setContractName(request.getContractName());
|
|
config.setDirection(request.getDirection().toUpperCase());
|
|
config.setKlinePeriod(request.getKlinePeriod());
|
|
config.setIndicators(JSON.toJSONString(request.getIndicators()));
|
|
config.setOpenVolume(request.getOpenVolume());
|
|
config.setVolumeUnit(request.getVolumeUnit());
|
|
config.setStopLossValue(request.getStopLossValue());
|
|
config.setStopLossUnit(request.getStopLossUnit());
|
|
config.setTakeProfitValue(request.getTakeProfitValue());
|
|
config.setTakeProfitUnit(request.getTakeProfitUnit());
|
|
config.setBacktestPeriod(request.getBacktestPeriod());
|
|
config.setStatus(0); // 已保存
|
|
strategyConfigMapper.insert(config);
|
|
return config;
|
|
}
|
|
|
|
private void saveStrategyResult(Long strategyId, BigDecimal initialCapital,
|
|
BigDecimal finalCapital, BigDecimal maxEquity,
|
|
BigDecimal minEquity, BigDecimal totalYield,
|
|
BigDecimal profitAmount, BigDecimal annualizedYield,
|
|
int tradeCount, BigDecimal maxDrawdown,
|
|
BigDecimal sharpeRatio, BigDecimal winRate,
|
|
LocalDate startDate, LocalDate endDate,
|
|
List<BacktestContext.DailyEquity> dailyCurve) {
|
|
StrategyResult result = new StrategyResult();
|
|
result.setStrategyId(strategyId);
|
|
result.setInitialCapital(initialCapital);
|
|
result.setFinalCapital(finalCapital);
|
|
result.setMaxEquity(maxEquity);
|
|
result.setMinEquity(minEquity);
|
|
result.setTotalYield(totalYield);
|
|
result.setProfitAmount(profitAmount);
|
|
result.setAnnualizedYield(annualizedYield);
|
|
result.setTradeCount(tradeCount);
|
|
result.setMaxDrawdown(maxDrawdown);
|
|
result.setSharpeRatio(sharpeRatio);
|
|
result.setWinRate(winRate);
|
|
result.setStartDate(startDate);
|
|
result.setEndDate(endDate);
|
|
result.setDailyEquityCurve(JSON.toJSONString(dailyCurve));
|
|
strategyResultMapper.insert(result);
|
|
}
|
|
|
|
private List<TradeDetail> saveTradeDetails(Long strategyId,
|
|
List<BacktestContext.TradeRecord> records) {
|
|
List<TradeDetail> details = new ArrayList<>();
|
|
for (BacktestContext.TradeRecord r : records) {
|
|
TradeDetail detail = new TradeDetail();
|
|
detail.setStrategyId(strategyId);
|
|
detail.setAction(r.action.name());
|
|
detail.setPrice(r.price);
|
|
detail.setVolume(r.volume);
|
|
detail.setTurnover(r.turnover);
|
|
detail.setTradeTime(r.tradeTime);
|
|
detail.setKlineTime(r.klineTime);
|
|
detail.setSignalType(r.signal);
|
|
details.add(detail);
|
|
}
|
|
if (!details.isEmpty()) {
|
|
tradeDetailMapper.insert(details);
|
|
}
|
|
return details;
|
|
}
|
|
|
|
// ==================== 构建响应 ====================
|
|
|
|
private BacktestResponse buildResponse(BacktestRequest request, Long strategyId,
|
|
BigDecimal initialCapital, BigDecimal finalCapital,
|
|
BigDecimal maxEquity, BigDecimal minEquity,
|
|
BigDecimal totalYield, BigDecimal profitAmount,
|
|
BigDecimal annualizedYield, int tradeCount,
|
|
BigDecimal maxDrawdown, BigDecimal sharpeRatio,
|
|
BigDecimal winRate,
|
|
LocalDate startDate, LocalDate endDate,
|
|
BacktestContext ctx,
|
|
List<TradeDetail> tradeDetails) {
|
|
List<BacktestResponse.DailyEquityPoint> curve = ctx.dailyEquityCurve.stream()
|
|
.map(d -> BacktestResponse.DailyEquityPoint.builder()
|
|
.date(d.date)
|
|
.equity(d.equity)
|
|
.yield(d.yield)
|
|
.build())
|
|
.toList();
|
|
|
|
List<BacktestResponse.TradeDetailItem> trades = tradeDetails.stream()
|
|
.map(t -> BacktestResponse.TradeDetailItem.builder()
|
|
.action(t.getAction())
|
|
.price(t.getPrice())
|
|
.volume(t.getVolume())
|
|
.turnover(t.getTurnover())
|
|
.tradeTime(t.getTradeTime() != null ? t.getTradeTime().toString() : null)
|
|
.signalType(t.getSignalType())
|
|
.build())
|
|
.toList();
|
|
|
|
List<BacktestResponse.SignalPointItem> signals = ctx.tradeRecords.stream()
|
|
.map(r -> BacktestResponse.SignalPointItem.builder()
|
|
.time(r.klineTime.toString())
|
|
.type(r.signal)
|
|
.price(r.price)
|
|
.build())
|
|
.toList();
|
|
|
|
return BacktestResponse.builder()
|
|
.strategyId(strategyId)
|
|
.contractCode(request.getContractCode())
|
|
.contractName(request.getContractName())
|
|
.direction(request.getDirection())
|
|
.klinePeriod(request.getKlinePeriod())
|
|
.indicators(request.getIndicators())
|
|
.openVolume(request.getOpenVolume())
|
|
.volumeUnit(request.getVolumeUnit())
|
|
.stopLossValue(request.getStopLossValue())
|
|
.stopLossUnit(request.getStopLossUnit())
|
|
.takeProfitValue(request.getTakeProfitValue())
|
|
.takeProfitUnit(request.getTakeProfitUnit())
|
|
.backtestPeriod(request.getBacktestPeriod())
|
|
.initialCapital(initialCapital)
|
|
.finalCapital(finalCapital)
|
|
.maxEquity(maxEquity)
|
|
.minEquity(minEquity)
|
|
.totalYield(totalYield)
|
|
.profitAmount(profitAmount)
|
|
.annualizedYield(annualizedYield)
|
|
.tradeCount(tradeCount)
|
|
.maxDrawdown(maxDrawdown)
|
|
.sharpeRatio(sharpeRatio)
|
|
.winRate(winRate)
|
|
.startDate(startDate)
|
|
.endDate(endDate)
|
|
.dailyEquityCurve(curve)
|
|
.tradeDetails(trades)
|
|
.signalPoints(signals)
|
|
.build();
|
|
}
|
|
|
|
// ==================== 回测上下文(内部类) ====================
|
|
|
|
static class BacktestContext {
|
|
BigDecimal availableCapital;
|
|
BigDecimal marginRatio;
|
|
BigDecimal feeRate;
|
|
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<>();
|
|
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.equityHistory.add(capital);
|
|
}
|
|
|
|
boolean hasPosition() {
|
|
return position > 0;
|
|
}
|
|
|
|
BigDecimal getTotalEquity() {
|
|
return availableCapital.add(marginLocked);
|
|
}
|
|
|
|
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, BigDecimal netProfit) {
|
|
tradeRecords.add(new TradeRecord(
|
|
action, price, volume, turnover, klineTime, klineTime, signal, netProfit));
|
|
}
|
|
|
|
static class TradeRecord {
|
|
TradeAction action;
|
|
BigDecimal price;
|
|
int volume;
|
|
BigDecimal turnover;
|
|
LocalDateTime tradeTime;
|
|
LocalDateTime klineTime;
|
|
String signal;
|
|
BigDecimal netProfit;
|
|
|
|
TradeRecord(TradeAction action, BigDecimal price, int volume, BigDecimal turnover,
|
|
LocalDateTime tradeTime, LocalDateTime klineTime, String signal,
|
|
BigDecimal netProfit) {
|
|
this.action = action;
|
|
this.price = price;
|
|
this.volume = volume;
|
|
this.turnover = turnover;
|
|
this.tradeTime = tradeTime;
|
|
this.klineTime = klineTime;
|
|
this.signal = signal;
|
|
this.netProfit = netProfit;
|
|
}
|
|
}
|
|
|
|
@Getter
|
|
static class DailyEquity {
|
|
LocalDate date;
|
|
BigDecimal equity;
|
|
BigDecimal yield;
|
|
|
|
DailyEquity(LocalDate date, BigDecimal equity, BigDecimal yield) {
|
|
this.date = date;
|
|
this.equity = equity;
|
|
this.yield = yield;
|
|
}
|
|
|
|
}
|
|
}
|
|
}
|