diff --git a/backend/src/main/java/com/goldenchart/dto/BacktestResponse.java b/backend/src/main/java/com/goldenchart/dto/BacktestResponse.java index e2098a8..bba7a2d 100644 --- a/backend/src/main/java/com/goldenchart/dto/BacktestResponse.java +++ b/backend/src/main/java/com/goldenchart/dto/BacktestResponse.java @@ -31,12 +31,14 @@ public class BacktestResponse { public static class Signal { /** Unix timestamp (초) */ private long time; - /** BUY | SELL */ + /** BUY | SELL | SHORT_ENTRY | SHORT_EXIT | PARTIAL_SELL */ private String type; - /** 해당 봉 종가 */ + /** 체결 가격 (슬리피지 반영) */ private double price; /** 진입/청산 인덱스 (0-based) */ private int barIndex; + /** 체결 수량 (코인/주식 단위, 미체결 시그널 없음) */ + private double quantity; } @Data @Builder @NoArgsConstructor @AllArgsConstructor diff --git a/backend/src/main/java/com/goldenchart/service/BacktestingService.java b/backend/src/main/java/com/goldenchart/service/BacktestingService.java index a8e91ed..1128d3e 100644 --- a/backend/src/main/java/com/goldenchart/service/BacktestingService.java +++ b/backend/src/main/java/com/goldenchart/service/BacktestingService.java @@ -158,21 +158,20 @@ public class BacktestingService { double exitPrice = getPrice(series, req.getBars(), i, cfg.getExitPriceType()); long time = series.getBar(i).getEndTime().getEpochSecond(); - // ── SIGNAL_ONLY 모드: 포지션 상태와 무관하게 순수 지표 규칙 충족 여부만 판정 ── + // ── SIGNAL_ONLY 모드: 지표 시그널은 포지션 무관, 체결·레포트는 LONG_ONLY와 동일 ── if (signalOnly) { - boolean enterOk = entryRule.isSatisfied(i); - boolean exitOk = exitRule.isSatisfied(i); - - if (enterOk) { - double effEntry = applySlippage(closePrice, cfg, true); - String sigType = "SHORT".equals(direction) ? "SHORT_ENTRY" : "BUY"; - signals.add(Signal.builder() - .time(time).type(sigType).price(effEntry).barIndex(i).build()); - // 실제 포지션 추적은 LONG_ONLY 모드와 동일하게 유지 (수익 계산용) - if (!inPosition) { - double shares = enterPosition(ledger, useLedger, equity, tradeSizePct, - effEntry, closePrice, record, i, series); - if (shares > 0) { + if (!inPosition) { + if (i - lastExitBar <= reentryWait && lastExitBar >= 0) { + if (useLedger) ledger.markToMarket(closePrice); + continue; + } + if (entryRule.isSatisfied(i)) { + double effEntry = applySlippage(closePrice, cfg, true); + double qty = enterPosition(ledger, useLedger, equity, tradeSizePct, + effEntry, closePrice, record, i, series, time); + if (qty > 0) { + String sigType = "SHORT".equals(direction) ? "SHORT_ENTRY" : "BUY"; + signals.add(buildFillSignal(time, sigType, effEntry, i, qty)); entryPrice = effEntry; entryBarIdx = i; inPosition = true; @@ -180,25 +179,36 @@ public class BacktestingService { if (useLedger) equity = ledger.portfolioValue(closePrice); } } - } else if (exitOk) { - double effExit = applySlippage(exitPrice, cfg, false); - String sigType = "SHORT".equals(direction) ? "SHORT_EXIT" : "SELL"; - signals.add(Signal.builder() - .time(time).type(sigType).price(effExit).barIndex(i).build()); - // 수익 계산: 실제 포지션이 있을 때만 - if (inPosition) { + } else { + if (partialExit && !partialDone && exitRule.isSatisfied(i)) { + double effExit = applySlippage(exitPrice, cfg, false); + double qty = applyExit(ledger, useLedger, equity, tradeSizePct, entryPrice, + effExit, partialPct, direction, cfg, simGrossProfit, simGrossLoss, time, i); + if (qty > 0) { + signals.add(buildFillSignal(time, "PARTIAL_SELL", effExit, i, qty)); + partialDone = true; + if (useLedger) equity = ledger.portfolioValue(closePrice); + } + } else if (exitRule.isSatisfied(i)) { + double effExit = applySlippage(exitPrice, cfg, false); double size = partialDone ? (1.0 - partialPct) : 1.0; - equity = applyExit(ledger, useLedger, equity, tradeSizePct, entryPrice, effExit, - size, direction, cfg, simGrossProfit, simGrossLoss); - Num numExitPrice = series.numFactory().numOf(effExit); - Num numShares = record.getCurrentPosition().getEntry().getAmount(); - record.exit(i, numExitPrice, numShares); - inPosition = false; - lastExitBar = i; - partialDone = false; + double qty = applyExit(ledger, useLedger, equity, tradeSizePct, entryPrice, effExit, + size, direction, cfg, simGrossProfit, simGrossLoss, time, i); + if (qty > 0) { + Num numExitPrice = series.numFactory().numOf(effExit); + Num numShares = record.getCurrentPosition().getEntry().getAmount(); + record.exit(i, numExitPrice, numShares); + String sigType = "SHORT".equals(direction) ? "SHORT_EXIT" : "SELL"; + signals.add(buildFillSignal(time, sigType, effExit, i, qty)); + inPosition = false; + lastExitBar = i; + partialDone = false; + if (useLedger) equity = ledger.portfolioValue(closePrice); + } } } - continue; // SIGNAL_ONLY 처리 완료, 다음 봉으로 + if (useLedger) ledger.markToMarket(closePrice); + continue; } // ── LONG_ONLY 모드: 표준 포지션 제약 로직 ──────────────────────────── @@ -211,12 +221,11 @@ public class BacktestingService { if (doEnter) { double effEntry = applySlippage(closePrice, cfg, true); - double shares = enterPosition(ledger, useLedger, equity, tradeSizePct, - effEntry, closePrice, record, i, series); - if (shares > 0) { + double qty = enterPosition(ledger, useLedger, equity, tradeSizePct, + effEntry, closePrice, record, i, series, time); + if (qty > 0) { String sigType = "SHORT".equals(direction) ? "SHORT_ENTRY" : "BUY"; - signals.add(Signal.builder() - .time(time).type(sigType).price(effEntry).barIndex(i).build()); + signals.add(buildFillSignal(time, sigType, effEntry, i, qty)); entryPrice = effEntry; entryBarIdx = i; inPosition = true; @@ -228,33 +237,38 @@ public class BacktestingService { // 분할 청산: exit 조건 처음 충족 시 일부만 청산 if (partialExit && !partialDone && exitRule.isSatisfied(i, record)) { double effExit = applySlippage(exitPrice, cfg, false); - equity = applyExit(ledger, useLedger, equity, tradeSizePct, entryPrice, effExit, - partialPct, direction, cfg, simGrossProfit, simGrossLoss); - signals.add(Signal.builder() - .time(time).type("PARTIAL_SELL").price(effExit).barIndex(i).build()); - partialDone = true; + double qty = applyExit(ledger, useLedger, equity, tradeSizePct, entryPrice, effExit, + partialPct, direction, cfg, simGrossProfit, simGrossLoss, time, i); + if (qty > 0) { + signals.add(buildFillSignal(time, "PARTIAL_SELL", effExit, i, qty)); + partialDone = true; + if (useLedger) equity = ledger.portfolioValue(closePrice); + } + if (useLedger) ledger.markToMarket(closePrice); continue; } if (exitRule.isSatisfied(i, record)) { double effExit = applySlippage(exitPrice, cfg, false); double size = partialDone ? (1.0 - partialPct) : 1.0; - equity = applyExit(ledger, useLedger, equity, tradeSizePct, entryPrice, effExit, - size, direction, cfg, simGrossProfit, simGrossLoss); + double qty = applyExit(ledger, useLedger, equity, tradeSizePct, entryPrice, effExit, + size, direction, cfg, simGrossProfit, simGrossLoss, time, i); + if (qty > 0) { + Num numExitPrice = series.numFactory().numOf(effExit); + Num numShares = record.getCurrentPosition().getEntry().getAmount(); + record.exit(i, numExitPrice, numShares); - Num numExitPrice = series.numFactory().numOf(effExit); - Num numShares = record.getCurrentPosition().getEntry().getAmount(); - record.exit(i, numExitPrice, numShares); + String sigType = "SHORT".equals(direction) ? "SHORT_EXIT" : "SELL"; + signals.add(buildFillSignal(time, sigType, effExit, i, qty)); - String sigType = "SHORT".equals(direction) ? "SHORT_EXIT" : "SELL"; - signals.add(Signal.builder() - .time(time).type(sigType).price(effExit).barIndex(i).build()); - - inPosition = false; - lastExitBar = i; - partialDone = false; + inPosition = false; + lastExitBar = i; + partialDone = false; + if (useLedger) equity = ledger.portfolioValue(closePrice); + } } } + if (useLedger) ledger.markToMarket(closePrice); } double lastMarkPrice = barCount > 0 @@ -307,20 +321,59 @@ public class BacktestingService { .unrealizedPnl(unrealizedPnl); double totalReturnPct = initCap > 0 ? (finalEquity - initCap) / initCap : 0.0; - double avgReturnPct = 0.0; double profitLossRatio = 0.0; - int positions = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfPositionsCriterion", series, record)); - int winning = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfWinningPositionsCriterion", series, record)); - int losing = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfLosingPositionsCriterion", series, record)); - int breakEven = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfBreakEvenPositionsCriterion", series, record)); - double winRate = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.WinningPositionsRatioCriterion", series, record)); + int positions = 0; + int winning = 0; + int losing = 0; + int breakEven = 0; + double winRate = 0.0; + double avgReturnPct = 0.0; + + double maxDrawdown; + double sharpe; + double sortino; + double calmar; + + if (ledger != null) { + PortfolioLedger.TradeStats ts = ledger.tradeStats(); + positions = ts.closedCount; + winning = ts.winning; + losing = ts.losing; + breakEven = ts.breakEven; + winRate = ts.winRate; + avgReturnPct = ts.avgReturnPct; + maxDrawdown = ledger.maxDrawdownPct(); + sharpe = ledger.sharpeFromEquityCurve(); + sortino = safeCalc(() -> calcCriterion( + "org.ta4j.core.criteria.SortinoRatioCriterion", series, record)); + calmar = (maxDrawdown != 0) ? totalReturnPct / Math.abs(maxDrawdown) : 0.0; + } else { + positions = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfPositionsCriterion", series, record)); + winning = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfWinningPositionsCriterion", series, record)); + losing = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfLosingPositionsCriterion", series, record)); + breakEven = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfBreakEvenPositionsCriterion", series, record)); + winRate = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.WinningPositionsRatioCriterion", series, record)); + maxDrawdown = safeCalc(() -> calcMaxDrawdown(series, record)); + sharpe = safeCalc(() -> calcSharpeRatio(series, record)); + sortino = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.SortinoRatioCriterion", series, record)); + calmar = (maxDrawdown != 0) ? totalReturnPct / Math.abs(maxDrawdown) : 0.0; + if (positions == 0) positions = record.getPositionCount(); + if (winning == 0 && positions > 0) { + winning = (int) record.getPositions().stream().filter(p -> p.isClosed() && p.getProfit().isPositive()).count(); + losing = (int) record.getPositions().stream().filter(p -> p.isClosed() && p.getProfit().isNegative()).count(); + breakEven = positions - winning - losing; + } + if (winRate == 0 && positions > 0) winRate = (double) winning / positions; + if (positions > 0) { + avgReturnPct = totalReturnPct / positions; + } else { + avgReturnPct = safeCalc(() -> calcCriterion( + "org.ta4j.core.criteria.pnl.AverageProfitLossCriterion", series, record)); + } + } - double maxDrawdown = safeCalc(() -> calcMaxDrawdown(series, record)); double maxRunup = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.MaximumRunupCriterion", series, record)); - double sharpe = safeCalc(() -> calcSharpeRatio(series, record)); - double sortino = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.SortinoRatioCriterion", series, record)); - double calmar = (maxDrawdown != 0) ? totalReturnPct / Math.abs(maxDrawdown) : 0.0; double var95 = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.ValueAtRiskCriterion", series, record)); double es = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.ExpectedShortfallCriterion", series, record)); @@ -330,21 +383,7 @@ public class BacktestingService { // 금액 기준 총 손익 재계산 double totalPnl = finalEquity - initCap; - // positions = 0 이면 record에서 직접 추출 시도 - if (positions == 0) positions = record.getPositionCount(); - if (winning == 0 && positions > 0) { - winning = (int) record.getPositions().stream().filter(p -> p.isClosed() && p.getProfit().isPositive()).count(); - losing = (int) record.getPositions().stream().filter(p -> p.isClosed() && p.getProfit().isNegative()).count(); - breakEven = positions - winning - losing; - } - if (winRate == 0 && positions > 0) winRate = (double) winning / positions; - - if (positions > 0) { - avgReturnPct = totalReturnPct / positions; - } else { - avgReturnPct = safeCalc(() -> calcCriterion( - "org.ta4j.core.criteria.pnl.AverageProfitLossCriterion", series, record)); - } + double profitLossRatio = 0.0; if (grossLoss != 0) { profitLossRatio = grossProfit / Math.abs(grossLoss); } else if (grossProfit > 0) { @@ -376,15 +415,23 @@ public class BacktestingService { .build(); } + private static Signal buildFillSignal(long time, String type, double price, int barIndex, double quantity) { + return Signal.builder() + .time(time) + .type(type) + .price(price) + .barIndex(barIndex) + .quantity(quantity) + .build(); + } + // ── 개별 Criterion 계산 헬퍼 ───────────────────────────────────────────── private double enterPosition(PortfolioLedger ledger, boolean useLedger, double equity, double tradeSizePct, double effEntry, double markPrice, - BaseTradingRecord record, int barIndex, BarSeries series) { + BaseTradingRecord record, int barIndex, BarSeries series, long time) { if (useLedger) { - double before = ledger.shares; - ledger.executeBuy(effEntry, markPrice); - double bought = ledger.shares - before; + double bought = ledger.executeBuy(effEntry, markPrice, time, barIndex); if (bought <= 1e-12) return 0.0; record.enter(barIndex, series.numFactory().numOf(effEntry), series.numFactory().numOf(bought)); @@ -397,19 +444,22 @@ public class BacktestingService { return shares; } + /** @return 체결 수량 (ledger·LONG 경로), 레거시 비율 모델은 환산 수량 */ private double applyExit(PortfolioLedger ledger, boolean useLedger, double equity, double tradeSizePct, double entryPrice, double effExit, double sellFraction, String direction, - BacktestSettingsDto cfg, double[] grossProfitAcc, double[] grossLossAcc) { + BacktestSettingsDto cfg, double[] grossProfitAcc, double[] grossLossAcc, + long time, int barIndex) { if (useLedger) { - ledger.executeSell(effExit, sellFraction); - return ledger.portfolioValue(effExit); + return ledger.executeSell(effExit, sellFraction, time, barIndex); } double commission = calcCommissionRate(cfg) * 2; double rawReturn = "SHORT".equals(direction) ? (entryPrice - effExit) / entryPrice : (effExit - entryPrice) / entryPrice; - return applyEquityPnl(equity, tradeSizePct, sellFraction, rawReturn - commission, + applyEquityPnl(equity, tradeSizePct, sellFraction, rawReturn - commission, grossProfitAcc, grossLossAcc); + return sellFraction > 0 && entryPrice > 0 + ? (equity * tradeSizePct * sellFraction) / entryPrice : 0.0; } private double resolveFinalEquity(PortfolioLedger ledger, boolean useLedger, BacktestSettingsDto cfg, diff --git a/backend/src/main/java/com/goldenchart/service/PortfolioLedger.java b/backend/src/main/java/com/goldenchart/service/PortfolioLedger.java index 060ebcf..0189bac 100644 --- a/backend/src/main/java/com/goldenchart/service/PortfolioLedger.java +++ b/backend/src/main/java/com/goldenchart/service/PortfolioLedger.java @@ -2,18 +2,94 @@ package com.goldenchart.service; import com.goldenchart.dto.BacktestSettingsDto; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + /** * 표준 주식 프로그램 방식의 포트폴리오 회계. *