init
This commit is contained in:
parent
48079cea72
commit
900c1a2ab8
@ -2,6 +2,7 @@ package com.yangwale.backtestify.service;
|
||||
|
||||
import com.yangwale.backtestify.model.dto.KLineData;
|
||||
|
||||
import 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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
// ==================== 数据生成 ====================
|
||||
|
||||
/**
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -0,0 +1,220 @@
|
||||
package com.yangwale.backtestify.service;
|
||||
|
||||
import com.yangwale.backtestify.entity.StrategyConfig;
|
||||
import com.yangwale.backtestify.mapper.StrategyConfigMapper;
|
||||
import com.yangwale.backtestify.mapper.StrategyResultMapper;
|
||||
import com.yangwale.backtestify.mapper.TradeDetailMapper;
|
||||
import com.yangwale.backtestify.model.dto.KLineData;
|
||||
import com.yangwale.backtestify.model.request.BacktestRequest;
|
||||
import com.yangwale.backtestify.model.response.BacktestResponse;
|
||||
import com.yangwale.backtestify.service.impl.BacktestEngineImpl;
|
||||
import com.yangwale.backtestify.service.signal.SignalStrategy;
|
||||
import com.yangwale.backtestify.service.signal.SignalStrategyFactory;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class BacktestEngineCalculationTest {
|
||||
|
||||
private MarketDataService marketDataService;
|
||||
private TestSignalStrategy signalStrategy;
|
||||
private BacktestEngineImpl engine;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
marketDataService = mock(MarketDataService.class);
|
||||
StrategyConfigMapper strategyConfigMapper = mock(StrategyConfigMapper.class);
|
||||
StrategyResultMapper strategyResultMapper = mock(StrategyResultMapper.class);
|
||||
TradeDetailMapper tradeDetailMapper = mock(TradeDetailMapper.class);
|
||||
signalStrategy = new TestSignalStrategy();
|
||||
SignalStrategyFactory signalStrategyFactory =
|
||||
new SignalStrategyFactory(List.of(signalStrategy));
|
||||
|
||||
when(strategyConfigMapper.insert(any(StrategyConfig.class))).thenAnswer(invocation -> {
|
||||
invocation.<StrategyConfig>getArgument(0).setId(1L);
|
||||
return 1;
|
||||
});
|
||||
|
||||
engine = new BacktestEngineImpl(
|
||||
marketDataService,
|
||||
signalStrategyFactory,
|
||||
strategyConfigMapper,
|
||||
strategyResultMapper,
|
||||
tradeDetailMapper);
|
||||
ReflectionTestUtils.setField(engine, "initialCapital", new BigDecimal("1000000"));
|
||||
ReflectionTestUtils.setField(engine, "marginRatio", new BigDecimal("0.10"));
|
||||
ReflectionTestUtils.setField(engine, "feeRate", new BigDecimal("0.00005"));
|
||||
ReflectionTestUtils.setField(engine, "riskFreeRate", new BigDecimal("0.025"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void longResultUsesRequestedVolumeAndDeductsBothFees() {
|
||||
List<KLineData> data = List.of(
|
||||
kline("2026-07-20T00:00:00", "100"),
|
||||
kline("2026-07-21T00:00:00", "110"));
|
||||
stubMarketData(data);
|
||||
signalStrategy.buyTime = data.getFirst().getTime();
|
||||
|
||||
BacktestResponse result = engine.execute(request("LONG", 2));
|
||||
|
||||
assertEquals(new BigDecimal("1000019.98"), result.getFinalCapital());
|
||||
assertEquals(new BigDecimal("19.98"), result.getProfitAmount());
|
||||
assertEquals(2, result.getTradeCount());
|
||||
assertEquals(2, result.getTradeDetails().getFirst().getVolume());
|
||||
assertEquals(new BigDecimal("1000019.99"), result.getMaxEquity());
|
||||
assertEquals(new BigDecimal("999999.99"), result.getMinEquity());
|
||||
assertEquals(new BigDecimal("100.0000"), result.getWinRate());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shortTakeProfitClosesWhenPriceFalls() {
|
||||
List<KLineData> data = List.of(
|
||||
kline("2026-07-20T00:00:00", "100"),
|
||||
kline("2026-07-21T00:00:00", "95"),
|
||||
kline("2026-07-22T00:00:00", "110"));
|
||||
stubMarketData(data);
|
||||
signalStrategy.sellTime = data.getFirst().getTime();
|
||||
BacktestRequest request = request("SHORT", 2);
|
||||
request.setTakeProfitValue(new BigDecimal("4"));
|
||||
request.setTakeProfitUnit("PERCENT");
|
||||
|
||||
BacktestResponse result = engine.execute(request);
|
||||
|
||||
assertEquals(new BigDecimal("1000009.98"), result.getFinalCapital());
|
||||
assertEquals("2026-07-21T00:00", result.getTradeDetails().getLast().getTradeTime());
|
||||
assertEquals(new BigDecimal("100.0000"), result.getWinRate());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shortStopLossClosesWhenPriceRises() {
|
||||
List<KLineData> data = List.of(
|
||||
kline("2026-07-20T00:00:00", "100"),
|
||||
kline("2026-07-21T00:00:00", "105"),
|
||||
kline("2026-07-22T00:00:00", "90"));
|
||||
stubMarketData(data);
|
||||
signalStrategy.sellTime = data.getFirst().getTime();
|
||||
BacktestRequest request = request("SHORT", 2);
|
||||
request.setStopLossValue(new BigDecimal("4"));
|
||||
request.setStopLossUnit("PERCENT");
|
||||
|
||||
BacktestResponse result = engine.execute(request);
|
||||
|
||||
assertEquals(new BigDecimal("999989.98"), result.getFinalCapital());
|
||||
assertEquals("2026-07-21T00:00", result.getTradeDetails().getLast().getTradeTime());
|
||||
assertEquals(new BigDecimal("0.0000"), result.getWinRate());
|
||||
}
|
||||
|
||||
@Test
|
||||
void longTickStopLossUsesInstrumentMinimumPriceTick() {
|
||||
List<KLineData> data = List.of(
|
||||
kline("2026-07-20T00:00:00", "100"),
|
||||
kline("2026-07-21T00:00:00", "99"),
|
||||
kline("2026-07-22T00:00:00", "110"));
|
||||
stubMarketData(data);
|
||||
when(marketDataService.getPriceTick("TEST")).thenReturn(new BigDecimal("0.5"));
|
||||
signalStrategy.buyTime = data.getFirst().getTime();
|
||||
BacktestRequest request = request("LONG", 1);
|
||||
request.setStopLossValue(new BigDecimal("2"));
|
||||
request.setStopLossUnit("TICK");
|
||||
|
||||
BacktestResponse result = engine.execute(request);
|
||||
|
||||
assertEquals(new BigDecimal("999998.99"), result.getFinalCapital());
|
||||
assertEquals("2026-07-21T00:00", result.getTradeDetails().getLast().getTradeTime());
|
||||
assertEquals(new BigDecimal("0.0000"), result.getWinRate());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shortTickTakeProfitUsesInstrumentMinimumPriceTick() {
|
||||
List<KLineData> data = List.of(
|
||||
kline("2026-07-20T00:00:00", "100"),
|
||||
kline("2026-07-21T00:00:00", "98.5"),
|
||||
kline("2026-07-22T00:00:00", "110"));
|
||||
stubMarketData(data);
|
||||
when(marketDataService.getPriceTick("TEST")).thenReturn(new BigDecimal("0.5"));
|
||||
signalStrategy.sellTime = data.getFirst().getTime();
|
||||
BacktestRequest request = request("SHORT", 1);
|
||||
request.setTakeProfitValue(new BigDecimal("3"));
|
||||
request.setTakeProfitUnit("TICK");
|
||||
|
||||
BacktestResponse result = engine.execute(request);
|
||||
|
||||
assertEquals(new BigDecimal("1000001.49"), result.getFinalCapital());
|
||||
assertEquals("2026-07-21T00:00", result.getTradeDetails().getLast().getTradeTime());
|
||||
assertEquals(new BigDecimal("100.0000"), result.getWinRate());
|
||||
}
|
||||
|
||||
@Test
|
||||
void maximumDrawdownUsesPeakBeforeTroughInTimeOrder() {
|
||||
List<KLineData> data = List.of(
|
||||
kline("2026-07-20T00:00:00", "100"),
|
||||
kline("2026-07-21T00:00:00", "120"),
|
||||
kline("2026-07-22T00:00:00", "110"));
|
||||
stubMarketData(data);
|
||||
signalStrategy.buyTime = data.getFirst().getTime();
|
||||
|
||||
BacktestResponse result = engine.execute(request("LONG", 1));
|
||||
|
||||
assertEquals(new BigDecimal("0.0010"), result.getMaxDrawdown());
|
||||
}
|
||||
|
||||
private void stubMarketData(List<KLineData> data) {
|
||||
when(marketDataService.getKLineData(any(), any(), any(), any())).thenReturn(data);
|
||||
}
|
||||
|
||||
private BacktestRequest request(String direction, int volume) {
|
||||
BacktestRequest request = new BacktestRequest();
|
||||
request.setUserId(1L);
|
||||
request.setContractCode("TEST");
|
||||
request.setContractName("测试合约");
|
||||
request.setDirection(direction);
|
||||
request.setKlinePeriod("1d");
|
||||
request.setIndicators(List.of("TEST"));
|
||||
request.setOpenVolume(volume);
|
||||
request.setVolumeUnit("LOT");
|
||||
request.setBacktestPeriod("1m");
|
||||
return request;
|
||||
}
|
||||
|
||||
private KLineData kline(String time, String close) {
|
||||
BigDecimal price = new BigDecimal(close);
|
||||
return KLineData.builder()
|
||||
.time(LocalDateTime.parse(time))
|
||||
.open(price)
|
||||
.high(price)
|
||||
.low(price)
|
||||
.close(price)
|
||||
.volume(BigDecimal.ONE)
|
||||
.amount(BigDecimal.ONE)
|
||||
.build();
|
||||
}
|
||||
|
||||
private static class TestSignalStrategy implements SignalStrategy {
|
||||
private LocalDateTime buyTime;
|
||||
private LocalDateTime sellTime;
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "TEST";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isBuySignal(KLineData current, List<KLineData> history) {
|
||||
return current.getTime().equals(buyTime);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isSellSignal(KLineData current, List<KLineData> history) {
|
||||
return current.getTime().equals(sellTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,255 @@
|
||||
package com.yangwale.backtestify.service;
|
||||
|
||||
import com.yangwale.backtestify.common.BusinessException;
|
||||
import com.yangwale.backtestify.config.WebMvcConfig;
|
||||
import com.yangwale.backtestify.model.dto.KLineData;
|
||||
import com.yangwale.backtestify.model.request.AdminStrategyPageRequest;
|
||||
import com.yangwale.backtestify.model.request.BacktestRequest;
|
||||
import com.yangwale.backtestify.model.request.SignalQueryRequest;
|
||||
import com.yangwale.backtestify.model.response.BacktestResponse;
|
||||
import com.yangwale.backtestify.model.response.StrategyDetailResponse;
|
||||
import com.yangwale.backtestify.service.market.MarketDataProvider;
|
||||
import com.yangwale.backtestify.service.signal.SignalStrategy;
|
||||
import com.yangwale.backtestify.service.signal.SignalStrategyFactory;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
@SpringBootTest(properties = {
|
||||
"spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver",
|
||||
"spring.datasource.url=jdbc:mysql://192.168.2.5:3306/backtestify?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&useSSL=false",
|
||||
"spring.datasource.username=root",
|
||||
"spring.datasource.password=root",
|
||||
"spring.sql.init.mode=never",
|
||||
"market-data.provider=mysql",
|
||||
"market-data.sync.enabled=false"
|
||||
})
|
||||
@Transactional
|
||||
@EnabledIfSystemProperty(named = "realDataTest", matches = "true")
|
||||
class RealDataBusinessIntegrationTest {
|
||||
|
||||
private static final long TEST_USER_ID = 9_900_001L;
|
||||
private static final DateTimeFormatter DATE_TIME_FORMATTER =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
@Autowired
|
||||
private MarketDataProvider marketDataProvider;
|
||||
@Autowired
|
||||
private SignalStrategyFactory signalStrategyFactory;
|
||||
@Autowired
|
||||
private StrategyService strategyService;
|
||||
|
||||
@Test
|
||||
void allBusinessFunctionsWorkWithRealMarketDataAndResultsAreInternallyConsistent() {
|
||||
SelectedScenario scenario = selectScenario();
|
||||
assertTrue(marketDataProvider.getPriceTick(scenario.contractCode()).signum() > 0,
|
||||
"真实合约必须具有有效最小变动价位");
|
||||
WebMvcConfig.RequestContextHolder.setUserId(TEST_USER_ID);
|
||||
try {
|
||||
BacktestRequest request = requestFor(scenario);
|
||||
BacktestResponse result = strategyService.backtest(request);
|
||||
|
||||
assertNotNull(result.getStrategyId());
|
||||
assertTrue(result.getTradeCount() >= 2, "所选真实数据场景应至少完成一笔开平仓");
|
||||
assertEquals(result.getTradeCount(), result.getTradeDetails().size());
|
||||
assertTrue(result.getTradeDetails().stream()
|
||||
.allMatch(trade -> request.getOpenVolume().equals(trade.getVolume())),
|
||||
"成交明细必须使用请求中的开仓数量");
|
||||
assertFalse(result.getDailyEquityCurve().isEmpty(), "真实回测必须产生每日净值曲线");
|
||||
|
||||
BigDecimal expectedProfit = result.getFinalCapital().subtract(result.getInitialCapital());
|
||||
assertEquals(0, expectedProfit.compareTo(result.getProfitAmount()));
|
||||
BigDecimal expectedYield = expectedProfit
|
||||
.divide(result.getInitialCapital(), 8, RoundingMode.HALF_UP)
|
||||
.multiply(BigDecimal.valueOf(100))
|
||||
.setScale(4, RoundingMode.HALF_UP);
|
||||
assertEquals(0, expectedYield.compareTo(result.getTotalYield()));
|
||||
assertEquals(0, calculateProfitFromTrades(result)
|
||||
.compareTo(result.getProfitAmount()));
|
||||
assertEquals(0, calculateWinRateFromTrades(result)
|
||||
.compareTo(result.getWinRate()));
|
||||
assertEquals(0, calculateDrawdownFromDailyCurve(result)
|
||||
.compareTo(result.getMaxDrawdown()));
|
||||
|
||||
assertTrue(strategyService.myList(TEST_USER_ID, 1, 20).getList().stream()
|
||||
.anyMatch(item -> result.getStrategyId().equals(item.getId())));
|
||||
|
||||
StrategyDetailResponse detail = strategyService.detail(result.getStrategyId());
|
||||
assertEquals(result.getFinalCapital(), detail.getFinalCapital());
|
||||
assertEquals(result.getTradeDetails().size(), detail.getTradeDetails().size());
|
||||
assertEquals(result.getDailyEquityCurve().size(), detail.getDailyEquityCurve().size());
|
||||
|
||||
AdminStrategyPageRequest adminRequest = new AdminStrategyPageRequest();
|
||||
adminRequest.setPageNum(1);
|
||||
adminRequest.setPageSize(20);
|
||||
adminRequest.setUserId(TEST_USER_ID);
|
||||
assertTrue(strategyService.adminList(adminRequest).getList().stream()
|
||||
.anyMatch(item -> result.getStrategyId().equals(item.getId())));
|
||||
|
||||
strategyService.toggleSignal(result.getStrategyId(), TEST_USER_ID);
|
||||
SignalQueryRequest signalRequest = new SignalQueryRequest();
|
||||
signalRequest.setUserId(TEST_USER_ID);
|
||||
signalRequest.setContractCode(scenario.contractCode());
|
||||
signalRequest.setPeriod("1d");
|
||||
signalRequest.setStartTime(result.getStartDate().atStartOfDay().minusDays(1)
|
||||
.format(DATE_TIME_FORMATTER));
|
||||
signalRequest.setEndTime(result.getEndDate().plusDays(1).atStartOfDay()
|
||||
.format(DATE_TIME_FORMATTER));
|
||||
assertEquals(result.getTradeCount(), strategyService.getSignals(signalRequest).size());
|
||||
|
||||
strategyService.toggleSignal(result.getStrategyId(), TEST_USER_ID);
|
||||
assertTrue(strategyService.getSignals(signalRequest).isEmpty());
|
||||
|
||||
strategyService.delete(result.getStrategyId(), TEST_USER_ID);
|
||||
assertThrows(BusinessException.class, () -> strategyService.detail(result.getStrategyId()));
|
||||
} finally {
|
||||
WebMvcConfig.RequestContextHolder.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private SelectedScenario selectScenario() {
|
||||
LocalDateTime end = LocalDateTime.now();
|
||||
LocalDateTime start = end.minusYears(1);
|
||||
List<String> contracts = marketDataProvider.getAvailableContracts();
|
||||
assertFalse(contracts.isEmpty(), "真实数据库中必须存在合约字典数据");
|
||||
|
||||
for (String contract : contracts) {
|
||||
List<KLineData> kLines = marketDataProvider.getKLineData(contract, "1d", start, end);
|
||||
if (kLines.size() < 2) {
|
||||
continue;
|
||||
}
|
||||
validateRealKLines(kLines);
|
||||
for (String indicator : signalStrategyFactory.getAllIndicatorNames()) {
|
||||
SignalStrategy strategy = signalStrategyFactory.get(indicator);
|
||||
boolean hasBuy = kLines.stream().anyMatch(kLine -> strategy.isBuySignal(kLine, kLines));
|
||||
if (hasBuy) {
|
||||
return new SelectedScenario(contract, indicator, "LONG");
|
||||
}
|
||||
boolean hasSell = kLines.stream().anyMatch(kLine -> strategy.isSellSignal(kLine, kLines));
|
||||
if (hasSell) {
|
||||
return new SelectedScenario(contract, indicator, "SHORT");
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new AssertionError("真实数据库近一年日线中未找到可产生交易信号的合约");
|
||||
}
|
||||
|
||||
private void validateRealKLines(List<KLineData> kLines) {
|
||||
for (int index = 0; index < kLines.size(); index++) {
|
||||
KLineData current = kLines.get(index);
|
||||
assertNotNull(current.getIndicators(), "真实K线必须附带指标");
|
||||
assertTrue(current.getClose().compareTo(BigDecimal.ZERO) > 0);
|
||||
assertTrue(current.getHigh().compareTo(current.getOpen()) >= 0);
|
||||
assertTrue(current.getHigh().compareTo(current.getClose()) >= 0);
|
||||
assertTrue(current.getLow().compareTo(current.getOpen()) <= 0);
|
||||
assertTrue(current.getLow().compareTo(current.getClose()) <= 0);
|
||||
if (index > 0) {
|
||||
assertTrue(current.getTime().isAfter(kLines.get(index - 1).getTime()),
|
||||
"真实K线必须严格按时间升序");
|
||||
}
|
||||
}
|
||||
|
||||
for (String indicator : signalStrategyFactory.getAllIndicatorNames()) {
|
||||
SignalStrategy strategy = signalStrategyFactory.get(indicator);
|
||||
for (KLineData kLine : kLines) {
|
||||
strategy.isBuySignal(kLine, kLines);
|
||||
strategy.isSellSignal(kLine, kLines);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private BigDecimal calculateProfitFromTrades(BacktestResponse result) {
|
||||
BigDecimal profit = BigDecimal.ZERO;
|
||||
BigDecimal feeRate = new BigDecimal("0.00005");
|
||||
for (int index = 0; index < result.getTradeDetails().size(); index += 2) {
|
||||
BacktestResponse.TradeDetailItem open = result.getTradeDetails().get(index);
|
||||
BacktestResponse.TradeDetailItem close = result.getTradeDetails().get(index + 1);
|
||||
BigDecimal priceChange = close.getPrice().subtract(open.getPrice())
|
||||
.multiply(BigDecimal.valueOf(open.getVolume()));
|
||||
BigDecimal grossProfit = "SHORT".equalsIgnoreCase(result.getDirection())
|
||||
? priceChange.negate()
|
||||
: priceChange;
|
||||
BigDecimal fees = open.getTurnover().add(close.getTurnover()).multiply(feeRate);
|
||||
profit = profit.add(grossProfit).subtract(fees);
|
||||
}
|
||||
return profit.setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
private BigDecimal calculateWinRateFromTrades(BacktestResponse result) {
|
||||
int wins = 0;
|
||||
int closedTrades = result.getTradeDetails().size() / 2;
|
||||
BigDecimal feeRate = new BigDecimal("0.00005");
|
||||
for (int index = 0; index < result.getTradeDetails().size(); index += 2) {
|
||||
BacktestResponse.TradeDetailItem open = result.getTradeDetails().get(index);
|
||||
BacktestResponse.TradeDetailItem close = result.getTradeDetails().get(index + 1);
|
||||
BigDecimal priceChange = close.getPrice().subtract(open.getPrice())
|
||||
.multiply(BigDecimal.valueOf(open.getVolume()));
|
||||
BigDecimal grossProfit = "SHORT".equalsIgnoreCase(result.getDirection())
|
||||
? priceChange.negate()
|
||||
: priceChange;
|
||||
BigDecimal netProfit = grossProfit.subtract(
|
||||
open.getTurnover().add(close.getTurnover()).multiply(feeRate));
|
||||
if (netProfit.compareTo(BigDecimal.ZERO) > 0) {
|
||||
wins++;
|
||||
}
|
||||
}
|
||||
return closedTrades == 0
|
||||
? BigDecimal.ZERO.setScale(4)
|
||||
: BigDecimal.valueOf(wins)
|
||||
.divide(BigDecimal.valueOf(closedTrades), 8, RoundingMode.HALF_UP)
|
||||
.multiply(BigDecimal.valueOf(100))
|
||||
.setScale(4, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
private BigDecimal calculateDrawdownFromDailyCurve(BacktestResponse result) {
|
||||
BigDecimal peak = result.getInitialCapital();
|
||||
BigDecimal maximum = BigDecimal.ZERO;
|
||||
for (BacktestResponse.DailyEquityPoint point : result.getDailyEquityCurve()) {
|
||||
if (point.getEquity().compareTo(peak) > 0) {
|
||||
peak = point.getEquity();
|
||||
}
|
||||
BigDecimal drawdown = peak.subtract(point.getEquity())
|
||||
.divide(peak, 8, RoundingMode.HALF_UP);
|
||||
if (drawdown.compareTo(maximum) > 0) {
|
||||
maximum = drawdown;
|
||||
}
|
||||
}
|
||||
return maximum.multiply(BigDecimal.valueOf(100))
|
||||
.setScale(4, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
private BacktestRequest requestFor(SelectedScenario scenario) {
|
||||
BacktestRequest request = new BacktestRequest();
|
||||
request.setUserId(TEST_USER_ID);
|
||||
request.setContractCode(scenario.contractCode());
|
||||
request.setContractName(scenario.contractCode() + "-真实数据验证");
|
||||
request.setDirection(scenario.direction());
|
||||
request.setKlinePeriod("1d");
|
||||
request.setIndicators(List.of(scenario.indicator()));
|
||||
request.setOpenVolume(2);
|
||||
request.setVolumeUnit("LOT");
|
||||
request.setStopLossValue(new BigDecimal("3"));
|
||||
request.setStopLossUnit("TICK");
|
||||
request.setTakeProfitValue(new BigDecimal("5"));
|
||||
request.setTakeProfitUnit("TICK");
|
||||
request.setBacktestPeriod("1y");
|
||||
return request;
|
||||
}
|
||||
|
||||
private record SelectedScenario(String contractCode, String indicator, String direction) {
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user