diff --git a/backend/src/main/java/com/goldenchart/dto/BacktestAnalysisDto.java b/backend/src/main/java/com/goldenchart/dto/BacktestAnalysisDto.java index 349c3a8..7420665 100644 --- a/backend/src/main/java/com/goldenchart/dto/BacktestAnalysisDto.java +++ b/backend/src/main/java/com/goldenchart/dto/BacktestAnalysisDto.java @@ -11,6 +11,16 @@ public class BacktestAnalysisDto { // ── 기본 자본 ───────────────────────────────────────────────────────────── double initialCapital; double finalEquity; + /** 적용된 투자분석 방식 */ + String analysisMethod; + /** 기말 예수금 */ + double cashBalance; + /** 기말 보유주식 평가액 */ + double holdingsValue; + /** 실현 손익 (청산 완료) */ + double realizedPnl; + /** 평가 손익 (미청산 보유분) */ + double unrealizedPnl; // ── 수익성 지표 ────────────────────────────────────────────────────────── /** 총 수익률 (소수, e.g. 0.152 = +15.2%) */ diff --git a/backend/src/main/java/com/goldenchart/dto/BacktestSettingsDto.java b/backend/src/main/java/com/goldenchart/dto/BacktestSettingsDto.java index 4648755..0eac027 100644 --- a/backend/src/main/java/com/goldenchart/dto/BacktestSettingsDto.java +++ b/backend/src/main/java/com/goldenchart/dto/BacktestSettingsDto.java @@ -99,4 +99,12 @@ public class BacktestSettingsDto { /** 분할 청산 비율 (%) */ @Builder.Default private BigDecimal partialExitPct = new BigDecimal("50.000"); + + // ── 투자분석 방식 ────────────────────────────────────────────────────────── + /** + * MARK_TO_MARKET: 예수금 + 보유주식 시가평가 (표준 HTS 방식, 기본값) + * REALIZED_ONLY: 청산 완료 실현손익만 반영 + */ + @Builder.Default + private String analysisMethod = "MARK_TO_MARKET"; } diff --git a/backend/src/main/java/com/goldenchart/entity/GcBacktestSettings.java b/backend/src/main/java/com/goldenchart/entity/GcBacktestSettings.java index 1865fcd..c51214e 100644 --- a/backend/src/main/java/com/goldenchart/entity/GcBacktestSettings.java +++ b/backend/src/main/java/com/goldenchart/entity/GcBacktestSettings.java @@ -119,6 +119,11 @@ public class GcBacktestSettings { @Builder.Default private BigDecimal partialExitPct = new BigDecimal("50.000"); + /** MARK_TO_MARKET | REALIZED_ONLY */ + @Column(name = "analysis_method", nullable = false, length = 32) + @Builder.Default + private String analysisMethod = "MARK_TO_MARKET"; + @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; diff --git a/backend/src/main/java/com/goldenchart/service/BacktestSettingsService.java b/backend/src/main/java/com/goldenchart/service/BacktestSettingsService.java index 8a6b9f5..3f76c70 100644 --- a/backend/src/main/java/com/goldenchart/service/BacktestSettingsService.java +++ b/backend/src/main/java/com/goldenchart/service/BacktestSettingsService.java @@ -50,6 +50,10 @@ public class BacktestSettingsService { entity.setPartialExitPct(dto.getPartialExitPct()); entity.setPositionMode( "SIGNAL_ONLY".equals(dto.getPositionMode()) ? "SIGNAL_ONLY" : "LONG_ONLY"); + entity.setAnalysisMethod( + PortfolioLedger.REALIZED_ONLY.equals(dto.getAnalysisMethod()) + ? PortfolioLedger.REALIZED_ONLY + : PortfolioLedger.MARK_TO_MARKET); return toDto(repo.save(entity)); } @@ -79,6 +83,7 @@ public class BacktestSettingsService { .partialExitEnabled(e.getPartialExitEnabled()) .partialExitPct(e.getPartialExitPct()) .positionMode(e.getPositionMode() != null ? e.getPositionMode() : "LONG_ONLY") + .analysisMethod(e.getAnalysisMethod() != null ? e.getAnalysisMethod() : PortfolioLedger.MARK_TO_MARKET) .build(); } } diff --git a/backend/src/main/java/com/goldenchart/service/BacktestingService.java b/backend/src/main/java/com/goldenchart/service/BacktestingService.java index 6936a86..a8e91ed 100644 --- a/backend/src/main/java/com/goldenchart/service/BacktestingService.java +++ b/backend/src/main/java/com/goldenchart/service/BacktestingService.java @@ -145,6 +145,8 @@ public class BacktestingService { double partialPct = cfg.getPartialExitPct() != null ? cfg.getPartialExitPct().doubleValue() / 100.0 : 0.5; boolean partialDone = false; + boolean useLedger = "LONG".equals(direction); + PortfolioLedger ledger = useLedger ? new PortfolioLedger(initCap, cfg) : null; double equity = initCap; final double[] simGrossProfit = {0.0}; final double[] simGrossLoss = {0.0}; @@ -168,12 +170,15 @@ public class BacktestingService { .time(time).type(sigType).price(effEntry).barIndex(i).build()); // 실제 포지션 추적은 LONG_ONLY 모드와 동일하게 유지 (수익 계산용) if (!inPosition) { - double shares = (equity * tradeSizePct) / effEntry; - record.enter(i, series.numFactory().numOf(effEntry), series.numFactory().numOf(shares)); - entryPrice = effEntry; - entryBarIdx = i; - inPosition = true; - partialDone = false; + double shares = enterPosition(ledger, useLedger, equity, tradeSizePct, + effEntry, closePrice, record, i, series); + if (shares > 0) { + entryPrice = effEntry; + entryBarIdx = i; + inPosition = true; + partialDone = false; + if (useLedger) equity = ledger.portfolioValue(closePrice); + } } } else if (exitOk) { double effExit = applySlippage(exitPrice, cfg, false); @@ -182,13 +187,9 @@ public class BacktestingService { .time(time).type(sigType).price(effExit).barIndex(i).build()); // 수익 계산: 실제 포지션이 있을 때만 if (inPosition) { - double commission = calcCommissionRate(cfg) * 2; - double rawReturn = "SHORT".equals(direction) - ? (entryPrice - effExit) / entryPrice - : (effExit - entryPrice) / entryPrice; double size = partialDone ? (1.0 - partialPct) : 1.0; - equity = applyEquityPnl(equity, tradeSizePct, size, rawReturn - commission, - simGrossProfit, simGrossLoss); + 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); @@ -210,33 +211,25 @@ public class BacktestingService { if (doEnter) { double effEntry = applySlippage(closePrice, cfg, true); - double shares = (equity * tradeSizePct) / effEntry; - - Num numPrice = series.numFactory().numOf(effEntry); - Num numShares = series.numFactory().numOf(shares); - record.enter(i, numPrice, numShares); - - String sigType = "SHORT".equals(direction) ? "SHORT_ENTRY" : "BUY"; - signals.add(Signal.builder() - .time(time).type(sigType).price(effEntry).barIndex(i).build()); - - entryPrice = effEntry; - entryBarIdx = i; - inPosition = true; - partialDone = false; + double shares = enterPosition(ledger, useLedger, equity, tradeSizePct, + effEntry, closePrice, record, i, series); + if (shares > 0) { + String sigType = "SHORT".equals(direction) ? "SHORT_ENTRY" : "BUY"; + signals.add(Signal.builder() + .time(time).type(sigType).price(effEntry).barIndex(i).build()); + entryPrice = effEntry; + entryBarIdx = i; + inPosition = true; + partialDone = false; + if (useLedger) equity = ledger.portfolioValue(closePrice); + } } } else { // 분할 청산: exit 조건 처음 충족 시 일부만 청산 if (partialExit && !partialDone && exitRule.isSatisfied(i, record)) { - double effExit = applySlippage(exitPrice, cfg, false); - double partShares = record.getCurrentPosition().getEntry().getAmount().doubleValue() * partialPct; - double partReturn = "SHORT".equals(direction) - ? (entryPrice - effExit) / entryPrice - : (effExit - entryPrice) / entryPrice; - double commission = calcCommissionRate(cfg) * 2; - equity = applyEquityPnl(equity, tradeSizePct, partialPct, partReturn - commission, - simGrossProfit, simGrossLoss); - + 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; @@ -244,16 +237,10 @@ public class BacktestingService { } if (exitRule.isSatisfied(i, record)) { - double effExit = applySlippage(exitPrice, cfg, false); - double commission = calcCommissionRate(cfg) * 2; - double rawReturn = "SHORT".equals(direction) - ? (entryPrice - effExit) / entryPrice - : (effExit - entryPrice) / entryPrice; - double netReturn = rawReturn - commission; - + double effExit = applySlippage(exitPrice, cfg, false); double size = partialDone ? (1.0 - partialPct) : 1.0; - equity = applyEquityPnl(equity, tradeSizePct, size, netReturn, - simGrossProfit, simGrossLoss); + 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(); @@ -270,9 +257,15 @@ public class BacktestingService { } } + double lastMarkPrice = barCount > 0 + ? series.getBar(barCount - 1).getClosePrice().doubleValue() : 0.0; + double finalEquity = resolveFinalEquity(ledger, useLedger, cfg, initCap, equity, inPosition, + entryPrice, lastMarkPrice, tradeSizePct, partialDone, partialPct, direction, + simGrossProfit, simGrossLoss); + // ── AnalysisCriterion 전체 계산 ─────────────────────────────────────── - BacktestAnalysisDto analysis = calcAnalysis(series, record, cfg, initCap, equity, - simGrossProfit[0], simGrossLoss[0]); + BacktestAnalysisDto analysis = calcAnalysis(series, record, cfg, initCap, finalEquity, + ledger, lastMarkPrice, simGrossProfit[0], simGrossLoss[0]); // ── Stats (하위 호환) ───────────────────────────────────────────────── Stats stats = toStats(analysis, signals); @@ -292,15 +285,28 @@ public class BacktestingService { private BacktestAnalysisDto calcAnalysis(BarSeries series, TradingRecord record, BacktestSettingsDto cfg, double initCap, double finalEquity, + PortfolioLedger ledger, double lastMarkPrice, double simGrossProfit, double simGrossLoss) { + String analysisMethod = ledger != null + ? ledger.analysisMethod + : PortfolioLedger.normalizeMethod(cfg.getAnalysisMethod()); + double cashBalance = ledger != null ? ledger.cash : 0.0; + double holdingsValue = ledger != null ? ledger.shares * lastMarkPrice : 0.0; + double realizedPnl = ledger != null ? ledger.realizedPnl : (simGrossProfit + simGrossLoss); + double unrealizedPnl = ledger != null ? ledger.unrealizedPnl(lastMarkPrice) : 0.0; + double grossProfit = ledger != null ? ledger.grossProfit : simGrossProfit; + double grossLoss = ledger != null ? ledger.grossLoss : simGrossLoss; + BacktestAnalysisDto.BacktestAnalysisDtoBuilder b = BacktestAnalysisDto.builder() .initialCapital(initCap) - .finalEquity(finalEquity); + .finalEquity(finalEquity) + .analysisMethod(analysisMethod) + .cashBalance(cashBalance) + .holdingsValue(holdingsValue) + .realizedPnl(realizedPnl) + .unrealizedPnl(unrealizedPnl); - // equity 시뮬레이션 기준 총 수익률 — Ta4j criterion/fallback 은 개별 거래 수익률 합산으로 부정확 double totalReturnPct = initCap > 0 ? (finalEquity - initCap) / initCap : 0.0; - double grossProfit = simGrossProfit; - double grossLoss = simGrossLoss; double avgReturnPct = 0.0; double profitLossRatio = 0.0; @@ -372,10 +378,66 @@ public class BacktestingService { // ── 개별 Criterion 계산 헬퍼 ───────────────────────────────────────────── - /** - * equity 시뮬레이션에 체결 손익을 반영하고, 총 이익/총 손실 누적값을 갱신한다. - * @return 갱신된 equity - */ + private double enterPosition(PortfolioLedger ledger, boolean useLedger, + double equity, double tradeSizePct, double effEntry, double markPrice, + BaseTradingRecord record, int barIndex, BarSeries series) { + if (useLedger) { + double before = ledger.shares; + ledger.executeBuy(effEntry, markPrice); + double bought = ledger.shares - before; + if (bought <= 1e-12) return 0.0; + record.enter(barIndex, series.numFactory().numOf(effEntry), + series.numFactory().numOf(bought)); + return bought; + } + double shares = (equity * tradeSizePct) / effEntry; + if (shares <= 1e-12) return 0.0; + record.enter(barIndex, series.numFactory().numOf(effEntry), + series.numFactory().numOf(shares)); + return shares; + } + + 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) { + if (useLedger) { + ledger.executeSell(effExit, sellFraction); + return ledger.portfolioValue(effExit); + } + double commission = calcCommissionRate(cfg) * 2; + double rawReturn = "SHORT".equals(direction) + ? (entryPrice - effExit) / entryPrice + : (effExit - entryPrice) / entryPrice; + return applyEquityPnl(equity, tradeSizePct, sellFraction, rawReturn - commission, + grossProfitAcc, grossLossAcc); + } + + private double resolveFinalEquity(PortfolioLedger ledger, boolean useLedger, BacktestSettingsDto cfg, + double initCap, double equity, boolean inPosition, double entryPrice, + double lastMarkPrice, double tradeSizePct, boolean partialDone, + double partialPct, String direction, + double[] grossProfitAcc, double[] grossLossAcc) { + if (useLedger && ledger != null) { + return ledger.resolveFinalEquity(lastMarkPrice); + } + String method = PortfolioLedger.normalizeMethod(cfg.getAnalysisMethod()); + if (inPosition && lastMarkPrice > 0 && entryPrice > 0 + && PortfolioLedger.MARK_TO_MARKET.equals(method)) { + double commission = calcCommissionRate(cfg) * 2; + double rawReturn = "SHORT".equals(direction) + ? (entryPrice - lastMarkPrice) / entryPrice + : (lastMarkPrice - entryPrice) / entryPrice; + double size = partialDone ? (1.0 - partialPct) : 1.0; + return applyEquityPnl(equity, tradeSizePct, size, rawReturn - commission, + grossProfitAcc, grossLossAcc); + } + if (PortfolioLedger.REALIZED_ONLY.equals(method)) { + return initCap + grossProfitAcc[0] + grossLossAcc[0]; + } + return equity; + } + + /** SHORT/레거시: 비율 기반 equity 갱신 */ private double applyEquityPnl(double equity, double tradeSizePct, double size, double netReturn, double[] grossProfitAcc, double[] grossLossAcc) { double eqBefore = equity; diff --git a/backend/src/main/java/com/goldenchart/service/PortfolioLedger.java b/backend/src/main/java/com/goldenchart/service/PortfolioLedger.java new file mode 100644 index 0000000..060ebcf --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/PortfolioLedger.java @@ -0,0 +1,125 @@ +package com.goldenchart.service; + +import com.goldenchart.dto.BacktestSettingsDto; + +/** + * 표준 주식 프로그램 방식의 포트폴리오 회계. + *