상세투자분석 계산로직 수정

This commit is contained in:
Macbook
2026-06-07 15:12:42 +09:00
parent c4365283cf
commit e1cf2ea46d
14 changed files with 366 additions and 58 deletions
@@ -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%) */
@@ -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";
}
@@ -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;
@@ -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();
}
}
@@ -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;
@@ -0,0 +1,125 @@
package com.goldenchart.service;
import com.goldenchart.dto.BacktestSettingsDto;
/**
* 표준 주식 프로그램 방식의 포트폴리오 회계.
* <ul>
* <li>MARK_TO_MARKET: 예수금 + 보유주식 시가평가 (평가손익 포함)</li>
* <li>REALIZED_ONLY: 청산 완료 실현손익만 반영 (기말 미청산 평가 제외)</li>
* </ul>
*/
final class PortfolioLedger {
static final String MARK_TO_MARKET = "MARK_TO_MARKET";
static final String REALIZED_ONLY = "REALIZED_ONLY";
final double initialCapital;
final String analysisMethod;
final BacktestSettingsDto cfg;
double cash;
double shares;
/** 현재 보유분 취득원가(수수료 포함) */
double costBasis;
double realizedPnl;
double grossProfit;
double grossLoss;
PortfolioLedger(double initialCapital, BacktestSettingsDto cfg) {
this.initialCapital = initialCapital;
this.cfg = cfg;
this.analysisMethod = normalizeMethod(cfg.getAnalysisMethod());
this.cash = initialCapital;
}
static String normalizeMethod(String raw) {
return REALIZED_ONLY.equalsIgnoreCase(raw) ? REALIZED_ONLY : MARK_TO_MARKET;
}
boolean hasPosition() {
return shares > 1e-12;
}
/** 평가금액 = 예수금 + 보유주식 평가액 */
double portfolioValue(double markPrice) {
return cash + shares * markPrice;
}
double unrealizedPnl(double markPrice) {
if (shares <= 1e-12) return 0.0;
return shares * markPrice - costBasis;
}
double resolveFinalEquity(double lastMarkPrice) {
if (REALIZED_ONLY.equals(analysisMethod)) {
return initialCapital + realizedPnl;
}
return cash + shares * lastMarkPrice;
}
/** LONG 매수 체결 */
void executeBuy(double effEntry, double markPriceForSizing) {
if (effEntry <= 0 || cash <= 0) return;
double commRate = commissionRate();
double orderAmount = computeOrderAmount(markPriceForSizing);
if (orderAmount <= 0) return;
double maxSpend = cash;
orderAmount = Math.min(orderAmount, maxSpend / (1 + commRate));
if (orderAmount <= 0) return;
double sharesToBuy = orderAmount / effEntry;
double totalCost = sharesToBuy * effEntry * (1 + commRate);
if (totalCost > cash + 1e-6) {
sharesToBuy = cash / (effEntry * (1 + commRate));
totalCost = sharesToBuy * effEntry * (1 + commRate);
}
if (sharesToBuy <= 1e-12) return;
cash -= totalCost;
shares += sharesToBuy;
costBasis += totalCost;
}
/** LONG 매도 체결 — sellFraction: 0~1 (전량=1) */
void executeSell(double effExit, double sellFraction) {
if (effExit <= 0 || shares <= 1e-12) return;
double fraction = Math.max(0.0, Math.min(1.0, sellFraction));
double sharesToSell = shares * fraction;
if (sharesToSell <= 1e-12) return;
double commRate = commissionRate();
double proceeds = sharesToSell * effExit * (1 - commRate);
double costPortion = costBasis * (sharesToSell / shares);
double pnl = proceeds - costPortion;
cash += proceeds;
realizedPnl += pnl;
if (pnl >= 0) grossProfit += pnl;
else grossLoss += pnl;
shares -= sharesToSell;
costBasis -= costPortion;
if (shares <= 1e-12) {
shares = 0;
costBasis = 0;
}
}
private double computeOrderAmount(double markPrice) {
double tradeSizePct = cfg.getTradeSizeValue() != null
? cfg.getTradeSizeValue().doubleValue() / 100.0 : 1.0;
if ("FIXED_AMOUNT".equals(cfg.getTradeSizeType())) {
double fixed = cfg.getTradeSizeValue() != null ? cfg.getTradeSizeValue().doubleValue() : 0;
return Math.min(fixed, cash);
}
double equity = portfolioValue(markPrice);
return equity * tradeSizePct;
}
private double commissionRate() {
if ("ZERO".equals(cfg.getCommissionType())) return 0.0;
return cfg.getCommissionRate() != null ? cfg.getCommissionRate().doubleValue() : 0.0015;
}
}
@@ -0,0 +1,4 @@
-- 투자분석 방식 설정 (평가손익 포함 / 실현손익만)
ALTER TABLE gc_backtest_settings
ADD COLUMN analysis_method VARCHAR(32) NOT NULL DEFAULT 'MARK_TO_MARKET'
COMMENT '투자분석 방식 (MARK_TO_MARKET | REALIZED_ONLY)';