상세투자분석 계산로직 수정
This commit is contained in:
@@ -11,6 +11,16 @@ public class BacktestAnalysisDto {
|
|||||||
// ── 기본 자본 ─────────────────────────────────────────────────────────────
|
// ── 기본 자본 ─────────────────────────────────────────────────────────────
|
||||||
double initialCapital;
|
double initialCapital;
|
||||||
double finalEquity;
|
double finalEquity;
|
||||||
|
/** 적용된 투자분석 방식 */
|
||||||
|
String analysisMethod;
|
||||||
|
/** 기말 예수금 */
|
||||||
|
double cashBalance;
|
||||||
|
/** 기말 보유주식 평가액 */
|
||||||
|
double holdingsValue;
|
||||||
|
/** 실현 손익 (청산 완료) */
|
||||||
|
double realizedPnl;
|
||||||
|
/** 평가 손익 (미청산 보유분) */
|
||||||
|
double unrealizedPnl;
|
||||||
|
|
||||||
// ── 수익성 지표 ──────────────────────────────────────────────────────────
|
// ── 수익성 지표 ──────────────────────────────────────────────────────────
|
||||||
/** 총 수익률 (소수, e.g. 0.152 = +15.2%) */
|
/** 총 수익률 (소수, e.g. 0.152 = +15.2%) */
|
||||||
|
|||||||
@@ -99,4 +99,12 @@ public class BacktestSettingsDto {
|
|||||||
/** 분할 청산 비율 (%) */
|
/** 분할 청산 비율 (%) */
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private BigDecimal partialExitPct = new BigDecimal("50.000");
|
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
|
@Builder.Default
|
||||||
private BigDecimal partialExitPct = new BigDecimal("50.000");
|
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)
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ public class BacktestSettingsService {
|
|||||||
entity.setPartialExitPct(dto.getPartialExitPct());
|
entity.setPartialExitPct(dto.getPartialExitPct());
|
||||||
entity.setPositionMode(
|
entity.setPositionMode(
|
||||||
"SIGNAL_ONLY".equals(dto.getPositionMode()) ? "SIGNAL_ONLY" : "LONG_ONLY");
|
"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));
|
return toDto(repo.save(entity));
|
||||||
}
|
}
|
||||||
@@ -79,6 +83,7 @@ public class BacktestSettingsService {
|
|||||||
.partialExitEnabled(e.getPartialExitEnabled())
|
.partialExitEnabled(e.getPartialExitEnabled())
|
||||||
.partialExitPct(e.getPartialExitPct())
|
.partialExitPct(e.getPartialExitPct())
|
||||||
.positionMode(e.getPositionMode() != null ? e.getPositionMode() : "LONG_ONLY")
|
.positionMode(e.getPositionMode() != null ? e.getPositionMode() : "LONG_ONLY")
|
||||||
|
.analysisMethod(e.getAnalysisMethod() != null ? e.getAnalysisMethod() : PortfolioLedger.MARK_TO_MARKET)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,6 +145,8 @@ public class BacktestingService {
|
|||||||
double partialPct = cfg.getPartialExitPct() != null ? cfg.getPartialExitPct().doubleValue() / 100.0 : 0.5;
|
double partialPct = cfg.getPartialExitPct() != null ? cfg.getPartialExitPct().doubleValue() / 100.0 : 0.5;
|
||||||
boolean partialDone = false;
|
boolean partialDone = false;
|
||||||
|
|
||||||
|
boolean useLedger = "LONG".equals(direction);
|
||||||
|
PortfolioLedger ledger = useLedger ? new PortfolioLedger(initCap, cfg) : null;
|
||||||
double equity = initCap;
|
double equity = initCap;
|
||||||
final double[] simGrossProfit = {0.0};
|
final double[] simGrossProfit = {0.0};
|
||||||
final double[] simGrossLoss = {0.0};
|
final double[] simGrossLoss = {0.0};
|
||||||
@@ -168,12 +170,15 @@ public class BacktestingService {
|
|||||||
.time(time).type(sigType).price(effEntry).barIndex(i).build());
|
.time(time).type(sigType).price(effEntry).barIndex(i).build());
|
||||||
// 실제 포지션 추적은 LONG_ONLY 모드와 동일하게 유지 (수익 계산용)
|
// 실제 포지션 추적은 LONG_ONLY 모드와 동일하게 유지 (수익 계산용)
|
||||||
if (!inPosition) {
|
if (!inPosition) {
|
||||||
double shares = (equity * tradeSizePct) / effEntry;
|
double shares = enterPosition(ledger, useLedger, equity, tradeSizePct,
|
||||||
record.enter(i, series.numFactory().numOf(effEntry), series.numFactory().numOf(shares));
|
effEntry, closePrice, record, i, series);
|
||||||
|
if (shares > 0) {
|
||||||
entryPrice = effEntry;
|
entryPrice = effEntry;
|
||||||
entryBarIdx = i;
|
entryBarIdx = i;
|
||||||
inPosition = true;
|
inPosition = true;
|
||||||
partialDone = false;
|
partialDone = false;
|
||||||
|
if (useLedger) equity = ledger.portfolioValue(closePrice);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (exitOk) {
|
} else if (exitOk) {
|
||||||
double effExit = applySlippage(exitPrice, cfg, false);
|
double effExit = applySlippage(exitPrice, cfg, false);
|
||||||
@@ -182,13 +187,9 @@ public class BacktestingService {
|
|||||||
.time(time).type(sigType).price(effExit).barIndex(i).build());
|
.time(time).type(sigType).price(effExit).barIndex(i).build());
|
||||||
// 수익 계산: 실제 포지션이 있을 때만
|
// 수익 계산: 실제 포지션이 있을 때만
|
||||||
if (inPosition) {
|
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;
|
double size = partialDone ? (1.0 - partialPct) : 1.0;
|
||||||
equity = applyEquityPnl(equity, tradeSizePct, size, rawReturn - commission,
|
equity = applyExit(ledger, useLedger, equity, tradeSizePct, entryPrice, effExit,
|
||||||
simGrossProfit, simGrossLoss);
|
size, direction, cfg, simGrossProfit, simGrossLoss);
|
||||||
Num numExitPrice = series.numFactory().numOf(effExit);
|
Num numExitPrice = series.numFactory().numOf(effExit);
|
||||||
Num numShares = record.getCurrentPosition().getEntry().getAmount();
|
Num numShares = record.getCurrentPosition().getEntry().getAmount();
|
||||||
record.exit(i, numExitPrice, numShares);
|
record.exit(i, numExitPrice, numShares);
|
||||||
@@ -210,33 +211,25 @@ public class BacktestingService {
|
|||||||
|
|
||||||
if (doEnter) {
|
if (doEnter) {
|
||||||
double effEntry = applySlippage(closePrice, cfg, true);
|
double effEntry = applySlippage(closePrice, cfg, true);
|
||||||
double shares = (equity * tradeSizePct) / effEntry;
|
double shares = enterPosition(ledger, useLedger, equity, tradeSizePct,
|
||||||
|
effEntry, closePrice, record, i, series);
|
||||||
Num numPrice = series.numFactory().numOf(effEntry);
|
if (shares > 0) {
|
||||||
Num numShares = series.numFactory().numOf(shares);
|
|
||||||
record.enter(i, numPrice, numShares);
|
|
||||||
|
|
||||||
String sigType = "SHORT".equals(direction) ? "SHORT_ENTRY" : "BUY";
|
String sigType = "SHORT".equals(direction) ? "SHORT_ENTRY" : "BUY";
|
||||||
signals.add(Signal.builder()
|
signals.add(Signal.builder()
|
||||||
.time(time).type(sigType).price(effEntry).barIndex(i).build());
|
.time(time).type(sigType).price(effEntry).barIndex(i).build());
|
||||||
|
|
||||||
entryPrice = effEntry;
|
entryPrice = effEntry;
|
||||||
entryBarIdx = i;
|
entryBarIdx = i;
|
||||||
inPosition = true;
|
inPosition = true;
|
||||||
partialDone = false;
|
partialDone = false;
|
||||||
|
if (useLedger) equity = ledger.portfolioValue(closePrice);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 분할 청산: exit 조건 처음 충족 시 일부만 청산
|
// 분할 청산: exit 조건 처음 충족 시 일부만 청산
|
||||||
if (partialExit && !partialDone && exitRule.isSatisfied(i, record)) {
|
if (partialExit && !partialDone && exitRule.isSatisfied(i, record)) {
|
||||||
double effExit = applySlippage(exitPrice, cfg, false);
|
double effExit = applySlippage(exitPrice, cfg, false);
|
||||||
double partShares = record.getCurrentPosition().getEntry().getAmount().doubleValue() * partialPct;
|
equity = applyExit(ledger, useLedger, equity, tradeSizePct, entryPrice, effExit,
|
||||||
double partReturn = "SHORT".equals(direction)
|
partialPct, direction, cfg, simGrossProfit, simGrossLoss);
|
||||||
? (entryPrice - effExit) / entryPrice
|
|
||||||
: (effExit - entryPrice) / entryPrice;
|
|
||||||
double commission = calcCommissionRate(cfg) * 2;
|
|
||||||
equity = applyEquityPnl(equity, tradeSizePct, partialPct, partReturn - commission,
|
|
||||||
simGrossProfit, simGrossLoss);
|
|
||||||
|
|
||||||
signals.add(Signal.builder()
|
signals.add(Signal.builder()
|
||||||
.time(time).type("PARTIAL_SELL").price(effExit).barIndex(i).build());
|
.time(time).type("PARTIAL_SELL").price(effExit).barIndex(i).build());
|
||||||
partialDone = true;
|
partialDone = true;
|
||||||
@@ -245,15 +238,9 @@ public class BacktestingService {
|
|||||||
|
|
||||||
if (exitRule.isSatisfied(i, record)) {
|
if (exitRule.isSatisfied(i, record)) {
|
||||||
double effExit = applySlippage(exitPrice, cfg, false);
|
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 size = partialDone ? (1.0 - partialPct) : 1.0;
|
double size = partialDone ? (1.0 - partialPct) : 1.0;
|
||||||
equity = applyEquityPnl(equity, tradeSizePct, size, netReturn,
|
equity = applyExit(ledger, useLedger, equity, tradeSizePct, entryPrice, effExit,
|
||||||
simGrossProfit, simGrossLoss);
|
size, direction, cfg, simGrossProfit, simGrossLoss);
|
||||||
|
|
||||||
Num numExitPrice = series.numFactory().numOf(effExit);
|
Num numExitPrice = series.numFactory().numOf(effExit);
|
||||||
Num numShares = record.getCurrentPosition().getEntry().getAmount();
|
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 전체 계산 ───────────────────────────────────────
|
// ── AnalysisCriterion 전체 계산 ───────────────────────────────────────
|
||||||
BacktestAnalysisDto analysis = calcAnalysis(series, record, cfg, initCap, equity,
|
BacktestAnalysisDto analysis = calcAnalysis(series, record, cfg, initCap, finalEquity,
|
||||||
simGrossProfit[0], simGrossLoss[0]);
|
ledger, lastMarkPrice, simGrossProfit[0], simGrossLoss[0]);
|
||||||
|
|
||||||
// ── Stats (하위 호환) ─────────────────────────────────────────────────
|
// ── Stats (하위 호환) ─────────────────────────────────────────────────
|
||||||
Stats stats = toStats(analysis, signals);
|
Stats stats = toStats(analysis, signals);
|
||||||
@@ -292,15 +285,28 @@ public class BacktestingService {
|
|||||||
|
|
||||||
private BacktestAnalysisDto calcAnalysis(BarSeries series, TradingRecord record,
|
private BacktestAnalysisDto calcAnalysis(BarSeries series, TradingRecord record,
|
||||||
BacktestSettingsDto cfg, double initCap, double finalEquity,
|
BacktestSettingsDto cfg, double initCap, double finalEquity,
|
||||||
|
PortfolioLedger ledger, double lastMarkPrice,
|
||||||
double simGrossProfit, double simGrossLoss) {
|
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()
|
BacktestAnalysisDto.BacktestAnalysisDtoBuilder b = BacktestAnalysisDto.builder()
|
||||||
.initialCapital(initCap)
|
.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 totalReturnPct = initCap > 0 ? (finalEquity - initCap) / initCap : 0.0;
|
||||||
double grossProfit = simGrossProfit;
|
|
||||||
double grossLoss = simGrossLoss;
|
|
||||||
double avgReturnPct = 0.0;
|
double avgReturnPct = 0.0;
|
||||||
double profitLossRatio = 0.0;
|
double profitLossRatio = 0.0;
|
||||||
|
|
||||||
@@ -372,10 +378,66 @@ public class BacktestingService {
|
|||||||
|
|
||||||
// ── 개별 Criterion 계산 헬퍼 ─────────────────────────────────────────────
|
// ── 개별 Criterion 계산 헬퍼 ─────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
private double enterPosition(PortfolioLedger ledger, boolean useLedger,
|
||||||
* equity 시뮬레이션에 체결 손익을 반영하고, 총 이익/총 손실 누적값을 갱신한다.
|
double equity, double tradeSizePct, double effEntry, double markPrice,
|
||||||
* @return 갱신된 equity
|
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,
|
private double applyEquityPnl(double equity, double tradeSizePct, double size, double netReturn,
|
||||||
double[] grossProfitAcc, double[] grossLossAcc) {
|
double[] grossProfitAcc, double[] grossLossAcc) {
|
||||||
double eqBefore = equity;
|
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)';
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
WinRateCircle,
|
WinRateCircle,
|
||||||
CompareBar,
|
CompareBar,
|
||||||
} from './shared/analysisDashboardUi';
|
} from './shared/analysisDashboardUi';
|
||||||
|
import { analysisMethodLabel } from '../utils/analysisMethodLabels';
|
||||||
|
|
||||||
const fmtDate = (ts: number) => {
|
const fmtDate = (ts: number) => {
|
||||||
const d = new Date(ts * 1000);
|
const d = new Date(ts * 1000);
|
||||||
@@ -104,6 +105,11 @@ export function BacktestDashboard({
|
|||||||
<span className="brd-dash-badge">{timeframe}</span>
|
<span className="brd-dash-badge">{timeframe}</span>
|
||||||
<span className="brd-dash-badge">{periodStr}</span>
|
<span className="brd-dash-badge">{periodStr}</span>
|
||||||
{createdAt && <span className="brd-dash-badge brd-dash-badge--time">실행 {fmtDateStr(createdAt)}</span>}
|
{createdAt && <span className="brd-dash-badge brd-dash-badge--time">실행 {fmtDateStr(createdAt)}</span>}
|
||||||
|
{a.analysisMethod && (
|
||||||
|
<span className="brd-dash-badge brd-dash-badge--method">
|
||||||
|
{analysisMethodLabel(a.analysisMethod)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={`brd-dash-header-kpi${reportMode ? ' brd-dash-header-kpi--report' : ''}`}>
|
<div className={`brd-dash-header-kpi${reportMode ? ' brd-dash-header-kpi--report' : ''}`}>
|
||||||
@@ -141,6 +147,18 @@ export function BacktestDashboard({
|
|||||||
<div className="brd-card-body">
|
<div className="brd-card-body">
|
||||||
<MetricRow label="총 수익률" value={pct(a.totalReturnPct)} cls={colorCls(a.totalReturnPct)}/>
|
<MetricRow label="총 수익률" value={pct(a.totalReturnPct)} cls={colorCls(a.totalReturnPct)}/>
|
||||||
<MetricRow label="총 손익 (금액)" value={wonFmt(a.totalProfitLoss)} cls={colorCls(a.totalProfitLoss)}/>
|
<MetricRow label="총 손익 (금액)" value={wonFmt(a.totalProfitLoss)} cls={colorCls(a.totalProfitLoss)}/>
|
||||||
|
{a.realizedPnl != null && (
|
||||||
|
<MetricRow label="실현 손익" value={wonFmt(a.realizedPnl)} cls={colorCls(a.realizedPnl)} />
|
||||||
|
)}
|
||||||
|
{a.unrealizedPnl != null && a.unrealizedPnl !== 0 && (
|
||||||
|
<MetricRow label="평가 손익" value={wonFmt(a.unrealizedPnl)} cls={colorCls(a.unrealizedPnl)} />
|
||||||
|
)}
|
||||||
|
{a.cashBalance != null && a.cashBalance > 0 && (
|
||||||
|
<MetricRow label="기말 예수금" value={wonFmt(a.cashBalance)} />
|
||||||
|
)}
|
||||||
|
{a.holdingsValue != null && a.holdingsValue > 0 && (
|
||||||
|
<MetricRow label="보유 평가액" value={wonFmt(a.holdingsValue)} />
|
||||||
|
)}
|
||||||
<MetricRow label="총 이익" value={wonFmt(a.grossProfit)} cls="brd-pos"/>
|
<MetricRow label="총 이익" value={wonFmt(a.grossProfit)} cls="brd-pos"/>
|
||||||
<MetricRow label="총 손실" value={wonFmt(a.grossLoss)} cls="brd-neg"/>
|
<MetricRow label="총 손실" value={wonFmt(a.grossLoss)} cls="brd-neg"/>
|
||||||
<MetricRow label="평균 수익률/거래" value={pct(a.avgReturnPct)} cls={colorCls(a.avgReturnPct)}/>
|
<MetricRow label="평균 수익률/거래" value={pct(a.avgReturnPct)} cls={colorCls(a.avgReturnPct)}/>
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ interface FieldMeta {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FIELD_META: Partial<Record<keyof BacktestSettingsDto, FieldMeta>> = {
|
const FIELD_META: Partial<Record<keyof BacktestSettingsDto, FieldMeta>> = {
|
||||||
|
analysisMethod: {
|
||||||
|
label: '투자분석 방식',
|
||||||
|
description:
|
||||||
|
'수익률·손익 산정 방식을 선택합니다.\n• 평가손익 포함(표준): 예수금 + 보유주식 시가평가. HTS 평가금액과 동일합니다.\n• 실현손익만: 청산 완료 거래의 실현손익만 반영합니다.',
|
||||||
|
},
|
||||||
initialCapital: {
|
initialCapital: {
|
||||||
label: '초기 자본',
|
label: '초기 자본',
|
||||||
description:
|
description:
|
||||||
@@ -129,6 +134,18 @@ interface SettingSection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SECTIONS: SettingSection[] = [
|
const SECTIONS: SettingSection[] = [
|
||||||
|
{
|
||||||
|
title: '📊 투자분석 방식',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'analysisMethod', type: 'select',
|
||||||
|
opts: [
|
||||||
|
{ value: 'MARK_TO_MARKET', label: '평가손익 포함 (표준)' },
|
||||||
|
{ value: 'REALIZED_ONLY', label: '실현손익만' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '💰 자본 설정',
|
title: '💰 자본 설정',
|
||||||
fields: [
|
fields: [
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
loadBacktestSettings,
|
loadBacktestSettings,
|
||||||
saveBacktestSettings,
|
saveBacktestSettings,
|
||||||
} from '../utils/backendApi';
|
} from '../utils/backendApi';
|
||||||
|
import { ANALYSIS_METHOD_OPTIONS } from '../utils/analysisMethodLabels';
|
||||||
|
|
||||||
// ── 백테스팅 전용 UI 컴포넌트 ───────────────────────────────────────────────
|
// ── 백테스팅 전용 UI 컴포넌트 ───────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -106,6 +107,26 @@ export const BacktestSettingsPanel: React.FC<BacktestSettingsPanelProps> = ({
|
|||||||
</BtGrid>
|
</BtGrid>
|
||||||
</BtSection>
|
</BtSection>
|
||||||
|
|
||||||
|
<BtSection title="투자분석 방식">
|
||||||
|
<BtGrid>
|
||||||
|
<BtField
|
||||||
|
label="수익률 · 손익 산정"
|
||||||
|
desc="백테스팅, 알림 레포트, 분석레포트 등 모든 투자분석 결과에 동일하게 적용됩니다."
|
||||||
|
full
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
className="stg-select stg-select--wide"
|
||||||
|
value={cfg.analysisMethod ?? 'MARK_TO_MARKET'}
|
||||||
|
onChange={e => field('analysisMethod', e.target.value as 'MARK_TO_MARKET' | 'REALIZED_ONLY')}
|
||||||
|
>
|
||||||
|
{ANALYSIS_METHOD_OPTIONS.map(o => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</BtField>
|
||||||
|
</BtGrid>
|
||||||
|
</BtSection>
|
||||||
|
|
||||||
<BtSection title="자본 · 거래 규모">
|
<BtSection title="자본 · 거래 규모">
|
||||||
<BtGrid>
|
<BtGrid>
|
||||||
<BtField
|
<BtField
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { Theme, TradeOrderFillRequest } from '../types';
|
|||||||
import type { TickerData } from '../hooks/useMarketTicker';
|
import type { TickerData } from '../hooks/useMarketTicker';
|
||||||
import { useTradeNotification, type TradeNotificationItem } from '../contexts/TradeNotificationContext';
|
import { useTradeNotification, type TradeNotificationItem } from '../contexts/TradeNotificationContext';
|
||||||
import {
|
import {
|
||||||
DEFAULT_BACKTEST_SETTINGS,
|
loadBacktestSettings,
|
||||||
loadPaperSummary,
|
loadPaperSummary,
|
||||||
loadStrategy,
|
loadStrategy,
|
||||||
runBacktest,
|
runBacktest,
|
||||||
@@ -271,6 +271,7 @@ export const TradeNotificationListPage: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const strategyName = strategy.name || item.strategyName || '전략';
|
const strategyName = strategy.name || item.strategyName || '전략';
|
||||||
|
const btSettings = await loadBacktestSettings();
|
||||||
const res = await runBacktest({
|
const res = await runBacktest({
|
||||||
strategyId: item.strategyId,
|
strategyId: item.strategyId,
|
||||||
bars: bars.map(b => ({
|
bars: bars.map(b => ({
|
||||||
@@ -284,7 +285,7 @@ export const TradeNotificationListPage: React.FC<Props> = ({
|
|||||||
timeframe,
|
timeframe,
|
||||||
symbol: market,
|
symbol: market,
|
||||||
strategyName,
|
strategyName,
|
||||||
settings: DEFAULT_BACKTEST_SETTINGS,
|
settings: btSettings,
|
||||||
indicatorParams: Object.fromEntries(
|
indicatorParams: Object.fromEntries(
|
||||||
REPORT_INDICATOR_TYPES.map(t => [t, getParams(t) as Record<string, unknown>]),
|
REPORT_INDICATOR_TYPES.map(t => [t, getParams(t) as Record<string, unknown>]),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import BacktestAnalysisChart from '../backtest/BacktestAnalysisChart';
|
import BacktestAnalysisChart from '../backtest/BacktestAnalysisChart';
|
||||||
import {
|
import {
|
||||||
DEFAULT_BACKTEST_SETTINGS,
|
loadBacktestSettings,
|
||||||
loadStrategy,
|
loadStrategy,
|
||||||
runBacktest,
|
runBacktest,
|
||||||
type BacktestSignal,
|
type BacktestSignal,
|
||||||
@@ -82,6 +82,7 @@ const PaperAnalysisChart: React.FC<Props> = ({ market, strategyId, theme = 'dark
|
|||||||
setRunning(true);
|
setRunning(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
const btSettings = await loadBacktestSettings();
|
||||||
const res = await runBacktest({
|
const res = await runBacktest({
|
||||||
strategyId: sid,
|
strategyId: sid,
|
||||||
bars: bars.map(b => ({
|
bars: bars.map(b => ({
|
||||||
@@ -95,7 +96,7 @@ const PaperAnalysisChart: React.FC<Props> = ({ market, strategyId, theme = 'dark
|
|||||||
timeframe: tf,
|
timeframe: tf,
|
||||||
symbol: sym,
|
symbol: sym,
|
||||||
strategyName: strat.name,
|
strategyName: strat.name,
|
||||||
settings: DEFAULT_BACKTEST_SETTINGS,
|
settings: btSettings,
|
||||||
indicatorParams: Object.fromEntries(
|
indicatorParams: Object.fromEntries(
|
||||||
['RSI', 'MACD', 'CCI', 'SMA', 'EMA', 'IchimokuCloud', 'Stochastic', 'ADX', 'MFI', 'NewPsychological'].map(t => [
|
['RSI', 'MACD', 'CCI', 'SMA', 'EMA', 'IchimokuCloud', 'Stochastic', 'ADX', 'MFI', 'NewPsychological'].map(t => [
|
||||||
t,
|
t,
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export type AnalysisMethod = 'MARK_TO_MARKET' | 'REALIZED_ONLY';
|
||||||
|
|
||||||
|
export const ANALYSIS_METHOD_OPTIONS: { value: AnalysisMethod; label: string; desc: string }[] = [
|
||||||
|
{
|
||||||
|
value: 'MARK_TO_MARKET',
|
||||||
|
label: '평가손익 포함 (표준)',
|
||||||
|
desc: '예수금 + 보유주식 시가평가. HTS·MTS 평가금액과 동일한 방식으로 수익률을 계산합니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'REALIZED_ONLY',
|
||||||
|
label: '실현손익만',
|
||||||
|
desc: '청산 완료된 거래의 실현손익만 반영합니다. 기말 미청산 포지션은 수익률에 포함하지 않습니다.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function normalizeAnalysisMethod(raw?: string | null): AnalysisMethod {
|
||||||
|
return raw === 'REALIZED_ONLY' ? 'REALIZED_ONLY' : 'MARK_TO_MARKET';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function analysisMethodLabel(method?: string | null): string {
|
||||||
|
const m = normalizeAnalysisMethod(method);
|
||||||
|
return ANALYSIS_METHOD_OPTIONS.find(o => o.value === m)?.label ?? '평가손익 포함 (표준)';
|
||||||
|
}
|
||||||
@@ -1151,6 +1151,11 @@ export interface BacktestStats {
|
|||||||
export interface BacktestAnalysis {
|
export interface BacktestAnalysis {
|
||||||
initialCapital: number;
|
initialCapital: number;
|
||||||
finalEquity: number;
|
finalEquity: number;
|
||||||
|
analysisMethod?: 'MARK_TO_MARKET' | 'REALIZED_ONLY';
|
||||||
|
cashBalance?: number;
|
||||||
|
holdingsValue?: number;
|
||||||
|
realizedPnl?: number;
|
||||||
|
unrealizedPnl?: number;
|
||||||
totalReturnPct: number;
|
totalReturnPct: number;
|
||||||
totalProfitLoss: number;
|
totalProfitLoss: number;
|
||||||
grossProfit: number;
|
grossProfit: number;
|
||||||
@@ -1230,6 +1235,8 @@ export interface BacktestSettingsDto {
|
|||||||
partialExitPct: number;
|
partialExitPct: number;
|
||||||
/** "LONG_ONLY" | "SIGNAL_ONLY" — 매도 시그널 포지션 종속성 모드 */
|
/** "LONG_ONLY" | "SIGNAL_ONLY" — 매도 시그널 포지션 종속성 모드 */
|
||||||
positionMode?: 'LONG_ONLY' | 'SIGNAL_ONLY';
|
positionMode?: 'LONG_ONLY' | 'SIGNAL_ONLY';
|
||||||
|
/** MARK_TO_MARKET | REALIZED_ONLY — 투자분석 방식 */
|
||||||
|
analysisMethod?: 'MARK_TO_MARKET' | 'REALIZED_ONLY';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_BACKTEST_SETTINGS: BacktestSettingsDto = {
|
export const DEFAULT_BACKTEST_SETTINGS: BacktestSettingsDto = {
|
||||||
@@ -1253,6 +1260,7 @@ export const DEFAULT_BACKTEST_SETTINGS: BacktestSettingsDto = {
|
|||||||
partialExitEnabled: false,
|
partialExitEnabled: false,
|
||||||
partialExitPct: 50,
|
partialExitPct: 50,
|
||||||
positionMode: 'LONG_ONLY',
|
positionMode: 'LONG_ONLY',
|
||||||
|
analysisMethod: 'MARK_TO_MARKET',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 백테스팅 설정 로드 */
|
/** 백테스팅 설정 로드 */
|
||||||
|
|||||||
Reference in New Issue
Block a user