diff --git a/backend/src/main/java/com/goldenchart/service/BacktestingService.java b/backend/src/main/java/com/goldenchart/service/BacktestingService.java index 1128d3e..b8813a9 100644 --- a/backend/src/main/java/com/goldenchart/service/BacktestingService.java +++ b/backend/src/main/java/com/goldenchart/service/BacktestingService.java @@ -277,9 +277,19 @@ public class BacktestingService { entryPrice, lastMarkPrice, tradeSizePct, partialDone, partialPct, direction, simGrossProfit, simGrossLoss); + // 체결(ledger) 기준 거래 통계 — Ta4j TradingRecord와 분리 + PortfolioLedger.TradeStats executedStats = ledger != null ? ledger.tradeStats() : null; + if (executedStats != null && executedStats.closedCount > 0) { + int ta4jWins = countTa4jWinningPositions(record); + if (ta4jWins != executedStats.winning) { + log.debug("[Backtest] 승률: ledger 순손익 {}승 (Ta4j 가격기준 {}승) — ledger 기준 적용", + executedStats.winning, ta4jWins); + } + } + // ── AnalysisCriterion 전체 계산 ─────────────────────────────────────── BacktestAnalysisDto analysis = calcAnalysis(series, record, cfg, initCap, finalEquity, - ledger, lastMarkPrice, simGrossProfit[0], simGrossLoss[0]); + ledger, executedStats, lastMarkPrice, simGrossProfit[0], simGrossLoss[0]); // ── Stats (하위 호환) ───────────────────────────────────────────────── Stats stats = toStats(analysis, signals); @@ -299,7 +309,8 @@ public class BacktestingService { private BacktestAnalysisDto calcAnalysis(BarSeries series, TradingRecord record, BacktestSettingsDto cfg, double initCap, double finalEquity, - PortfolioLedger ledger, double lastMarkPrice, + PortfolioLedger ledger, PortfolioLedger.TradeStats executedStats, + double lastMarkPrice, double simGrossProfit, double simGrossLoss) { String analysisMethod = ledger != null ? ledger.analysisMethod @@ -335,26 +346,38 @@ public class BacktestingService { 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(); + if (executedStats != null && executedStats.closedCount > 0) { + // LONG PortfolioLedger: 수수료·슬리피지 반영 실현손익 기준 (실매매와 동일) + positions = executedStats.closedCount; + winning = executedStats.winning; + losing = executedStats.losing; + breakEven = executedStats.breakEven; + winRate = executedStats.winRate; + avgReturnPct = executedStats.avgReturnPct; + maxDrawdown = normalizeDrawdownPct(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 if (ledger != null) { + // ledger 있으나 청산 0건 — Ta4j 승률 사용하지 않음 + positions = 0; + winning = 0; + losing = 0; + breakEven = 0; + winRate = 0.0; + avgReturnPct = 0.0; + maxDrawdown = normalizeDrawdownPct(ledger.maxDrawdownPct()); + sharpe = ledger.sharpeFromEquityCurve(); + sortino = 0.0; + calmar = 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)); + maxDrawdown = normalizeDrawdownPct(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; @@ -415,6 +438,20 @@ public class BacktestingService { .build(); } + /** MDD는 음수(낙폭)로 통일 */ + private static double normalizeDrawdownPct(double dd) { + if (dd == 0.0) return 0.0; + return dd > 0 ? -dd : dd; + } + + /** Ta4j 가격 기준 승수 (디버그·비교용, 레포트에는 미사용) */ + private static int countTa4jWinningPositions(TradingRecord record) { + if (record == null) return 0; + return (int) record.getPositions().stream() + .filter(p -> p.isClosed() && p.getProfit().isPositive()) + .count(); + } + private static Signal buildFillSignal(long time, String type, double price, int barIndex, double quantity) { return Signal.builder() .time(time) diff --git a/backend/src/test/java/com/goldenchart/service/PortfolioLedgerTradeStatsTest.java b/backend/src/test/java/com/goldenchart/service/PortfolioLedgerTradeStatsTest.java new file mode 100644 index 0000000..83d372d --- /dev/null +++ b/backend/src/test/java/com/goldenchart/service/PortfolioLedgerTradeStatsTest.java @@ -0,0 +1,72 @@ +package com.goldenchart.service; + +import com.goldenchart.dto.BacktestSettingsDto; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * 체결(ledger) 순손익 기준 승률·승패 집계 검증. + * 분석레포트_수정.pdf 28시그널 / 14라운드트립 시나리오. + */ +class PortfolioLedgerTradeStatsTest { + + private static final double INIT = 10_000_000.0; + + private PortfolioLedger newLedger() { + BacktestSettingsDto cfg = BacktestSettingsDto.builder() + .initialCapital(BigDecimal.valueOf(INIT)) + .commissionType("LINEAR") + .commissionRate(new BigDecimal("0.00150")) + .slippageRate(new BigDecimal("0.00050")) + .tradeSizeType("CAPITAL_PCT") + .tradeSizeValue(new BigDecimal("100")) + .analysisMethod("MARK_TO_MARKET") + .build(); + return new PortfolioLedger(INIT, cfg); + } + + @Test + void pdfScenario_netPnlWinLoss_afterFees() { + double[][] pairs = { + {93254604, 92806574}, + {92876415, 92203875}, + {91648802, 92324815}, + {92723339, 93019467}, + {92258106, 92293830}, + {92507231, 92150902}, + {92463209, 91909023}, + {92117036, 92075939}, + {92298126, 92062946}, + {92253104, 92280837}, + {92306130, 92359797}, + {93446700, 93045454}, + {93458706, 93657148}, + {94142048, 93835059}, + }; + + PortfolioLedger ledger = newLedger(); + long t = 1_700_000_000L; + int bar = 0; + for (double[] pair : pairs) { + // PDF 시그널 가격 = 슬리피지 반영 체결가 + ledger.executeBuy(pair[0], pair[0], t + bar, bar); + bar += 8; + ledger.executeSell(pair[1], 1.0, t + bar, bar); + bar += 8; + } + + PortfolioLedger.TradeStats ts = ledger.tradeStats(); + assertEquals(14, ts.closedCount); + assertEquals(2, ts.winning, "수수료 반영 순손익 기준 2승"); + assertEquals(12, ts.losing, "수수료 반영 순손익 기준 12패"); + assertEquals(0, ts.breakEven); + assertEquals(2.0 / 14.0, ts.winRate, 1e-9); + + double finalEq = ledger.resolveFinalEquity(pairs[pairs.length - 1][1]); + assertEquals(-0.0588, (finalEq - INIT) / INIT, 0.002); + assertEquals(ledger.grossProfit + ledger.grossLoss, ledger.realizedPnl, 1.0); + } +}