투자분석 레포트 로직 수정
This commit is contained in:
@@ -31,12 +31,14 @@ public class BacktestResponse {
|
|||||||
public static class Signal {
|
public static class Signal {
|
||||||
/** Unix timestamp (초) */
|
/** Unix timestamp (초) */
|
||||||
private long time;
|
private long time;
|
||||||
/** BUY | SELL */
|
/** BUY | SELL | SHORT_ENTRY | SHORT_EXIT | PARTIAL_SELL */
|
||||||
private String type;
|
private String type;
|
||||||
/** 해당 봉 종가 */
|
/** 체결 가격 (슬리피지 반영) */
|
||||||
private double price;
|
private double price;
|
||||||
/** 진입/청산 인덱스 (0-based) */
|
/** 진입/청산 인덱스 (0-based) */
|
||||||
private int barIndex;
|
private int barIndex;
|
||||||
|
/** 체결 수량 (코인/주식 단위, 미체결 시그널 없음) */
|
||||||
|
private double quantity;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Data @Builder @NoArgsConstructor @AllArgsConstructor
|
@Data @Builder @NoArgsConstructor @AllArgsConstructor
|
||||||
|
|||||||
@@ -158,21 +158,20 @@ public class BacktestingService {
|
|||||||
double exitPrice = getPrice(series, req.getBars(), i, cfg.getExitPriceType());
|
double exitPrice = getPrice(series, req.getBars(), i, cfg.getExitPriceType());
|
||||||
long time = series.getBar(i).getEndTime().getEpochSecond();
|
long time = series.getBar(i).getEndTime().getEpochSecond();
|
||||||
|
|
||||||
// ── SIGNAL_ONLY 모드: 포지션 상태와 무관하게 순수 지표 규칙 충족 여부만 판정 ──
|
// ── SIGNAL_ONLY 모드: 지표 시그널은 포지션 무관, 체결·레포트는 LONG_ONLY와 동일 ──
|
||||||
if (signalOnly) {
|
if (signalOnly) {
|
||||||
boolean enterOk = entryRule.isSatisfied(i);
|
if (!inPosition) {
|
||||||
boolean exitOk = exitRule.isSatisfied(i);
|
if (i - lastExitBar <= reentryWait && lastExitBar >= 0) {
|
||||||
|
if (useLedger) ledger.markToMarket(closePrice);
|
||||||
if (enterOk) {
|
continue;
|
||||||
double effEntry = applySlippage(closePrice, cfg, true);
|
}
|
||||||
String sigType = "SHORT".equals(direction) ? "SHORT_ENTRY" : "BUY";
|
if (entryRule.isSatisfied(i)) {
|
||||||
signals.add(Signal.builder()
|
double effEntry = applySlippage(closePrice, cfg, true);
|
||||||
.time(time).type(sigType).price(effEntry).barIndex(i).build());
|
double qty = enterPosition(ledger, useLedger, equity, tradeSizePct,
|
||||||
// 실제 포지션 추적은 LONG_ONLY 모드와 동일하게 유지 (수익 계산용)
|
effEntry, closePrice, record, i, series, time);
|
||||||
if (!inPosition) {
|
if (qty > 0) {
|
||||||
double shares = enterPosition(ledger, useLedger, equity, tradeSizePct,
|
String sigType = "SHORT".equals(direction) ? "SHORT_ENTRY" : "BUY";
|
||||||
effEntry, closePrice, record, i, series);
|
signals.add(buildFillSignal(time, sigType, effEntry, i, qty));
|
||||||
if (shares > 0) {
|
|
||||||
entryPrice = effEntry;
|
entryPrice = effEntry;
|
||||||
entryBarIdx = i;
|
entryBarIdx = i;
|
||||||
inPosition = true;
|
inPosition = true;
|
||||||
@@ -180,25 +179,36 @@ public class BacktestingService {
|
|||||||
if (useLedger) equity = ledger.portfolioValue(closePrice);
|
if (useLedger) equity = ledger.portfolioValue(closePrice);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (exitOk) {
|
} else {
|
||||||
double effExit = applySlippage(exitPrice, cfg, false);
|
if (partialExit && !partialDone && exitRule.isSatisfied(i)) {
|
||||||
String sigType = "SHORT".equals(direction) ? "SHORT_EXIT" : "SELL";
|
double effExit = applySlippage(exitPrice, cfg, false);
|
||||||
signals.add(Signal.builder()
|
double qty = applyExit(ledger, useLedger, equity, tradeSizePct, entryPrice,
|
||||||
.time(time).type(sigType).price(effExit).barIndex(i).build());
|
effExit, partialPct, direction, cfg, simGrossProfit, simGrossLoss, time, i);
|
||||||
// 수익 계산: 실제 포지션이 있을 때만
|
if (qty > 0) {
|
||||||
if (inPosition) {
|
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;
|
double size = partialDone ? (1.0 - partialPct) : 1.0;
|
||||||
equity = applyExit(ledger, useLedger, equity, tradeSizePct, entryPrice, effExit,
|
double qty = applyExit(ledger, useLedger, equity, tradeSizePct, entryPrice, effExit,
|
||||||
size, direction, cfg, simGrossProfit, simGrossLoss);
|
size, direction, cfg, simGrossProfit, simGrossLoss, time, i);
|
||||||
Num numExitPrice = series.numFactory().numOf(effExit);
|
if (qty > 0) {
|
||||||
Num numShares = record.getCurrentPosition().getEntry().getAmount();
|
Num numExitPrice = series.numFactory().numOf(effExit);
|
||||||
record.exit(i, numExitPrice, numShares);
|
Num numShares = record.getCurrentPosition().getEntry().getAmount();
|
||||||
inPosition = false;
|
record.exit(i, numExitPrice, numShares);
|
||||||
lastExitBar = i;
|
String sigType = "SHORT".equals(direction) ? "SHORT_EXIT" : "SELL";
|
||||||
partialDone = false;
|
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 모드: 표준 포지션 제약 로직 ────────────────────────────
|
// ── LONG_ONLY 모드: 표준 포지션 제약 로직 ────────────────────────────
|
||||||
@@ -211,12 +221,11 @@ public class BacktestingService {
|
|||||||
|
|
||||||
if (doEnter) {
|
if (doEnter) {
|
||||||
double effEntry = applySlippage(closePrice, cfg, true);
|
double effEntry = applySlippage(closePrice, cfg, true);
|
||||||
double shares = enterPosition(ledger, useLedger, equity, tradeSizePct,
|
double qty = enterPosition(ledger, useLedger, equity, tradeSizePct,
|
||||||
effEntry, closePrice, record, i, series);
|
effEntry, closePrice, record, i, series, time);
|
||||||
if (shares > 0) {
|
if (qty > 0) {
|
||||||
String sigType = "SHORT".equals(direction) ? "SHORT_ENTRY" : "BUY";
|
String sigType = "SHORT".equals(direction) ? "SHORT_ENTRY" : "BUY";
|
||||||
signals.add(Signal.builder()
|
signals.add(buildFillSignal(time, sigType, effEntry, i, qty));
|
||||||
.time(time).type(sigType).price(effEntry).barIndex(i).build());
|
|
||||||
entryPrice = effEntry;
|
entryPrice = effEntry;
|
||||||
entryBarIdx = i;
|
entryBarIdx = i;
|
||||||
inPosition = true;
|
inPosition = true;
|
||||||
@@ -228,33 +237,38 @@ public class BacktestingService {
|
|||||||
// 분할 청산: 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);
|
||||||
equity = applyExit(ledger, useLedger, equity, tradeSizePct, entryPrice, effExit,
|
double qty = applyExit(ledger, useLedger, equity, tradeSizePct, entryPrice, effExit,
|
||||||
partialPct, direction, cfg, simGrossProfit, simGrossLoss);
|
partialPct, direction, cfg, simGrossProfit, simGrossLoss, time, i);
|
||||||
signals.add(Signal.builder()
|
if (qty > 0) {
|
||||||
.time(time).type("PARTIAL_SELL").price(effExit).barIndex(i).build());
|
signals.add(buildFillSignal(time, "PARTIAL_SELL", effExit, i, qty));
|
||||||
partialDone = true;
|
partialDone = true;
|
||||||
|
if (useLedger) equity = ledger.portfolioValue(closePrice);
|
||||||
|
}
|
||||||
|
if (useLedger) ledger.markToMarket(closePrice);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exitRule.isSatisfied(i, record)) {
|
if (exitRule.isSatisfied(i, record)) {
|
||||||
double effExit = applySlippage(exitPrice, cfg, false);
|
double effExit = applySlippage(exitPrice, cfg, false);
|
||||||
double size = partialDone ? (1.0 - partialPct) : 1.0;
|
double size = partialDone ? (1.0 - partialPct) : 1.0;
|
||||||
equity = applyExit(ledger, useLedger, equity, tradeSizePct, entryPrice, effExit,
|
double qty = applyExit(ledger, useLedger, equity, tradeSizePct, entryPrice, effExit,
|
||||||
size, direction, cfg, simGrossProfit, simGrossLoss);
|
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);
|
String sigType = "SHORT".equals(direction) ? "SHORT_EXIT" : "SELL";
|
||||||
Num numShares = record.getCurrentPosition().getEntry().getAmount();
|
signals.add(buildFillSignal(time, sigType, effExit, i, qty));
|
||||||
record.exit(i, numExitPrice, numShares);
|
|
||||||
|
|
||||||
String sigType = "SHORT".equals(direction) ? "SHORT_EXIT" : "SELL";
|
inPosition = false;
|
||||||
signals.add(Signal.builder()
|
lastExitBar = i;
|
||||||
.time(time).type(sigType).price(effExit).barIndex(i).build());
|
partialDone = false;
|
||||||
|
if (useLedger) equity = ledger.portfolioValue(closePrice);
|
||||||
inPosition = false;
|
}
|
||||||
lastExitBar = i;
|
|
||||||
partialDone = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (useLedger) ledger.markToMarket(closePrice);
|
||||||
}
|
}
|
||||||
|
|
||||||
double lastMarkPrice = barCount > 0
|
double lastMarkPrice = barCount > 0
|
||||||
@@ -307,20 +321,59 @@ public class BacktestingService {
|
|||||||
.unrealizedPnl(unrealizedPnl);
|
.unrealizedPnl(unrealizedPnl);
|
||||||
|
|
||||||
double totalReturnPct = initCap > 0 ? (finalEquity - initCap) / initCap : 0.0;
|
double totalReturnPct = initCap > 0 ? (finalEquity - initCap) / initCap : 0.0;
|
||||||
double avgReturnPct = 0.0;
|
|
||||||
double profitLossRatio = 0.0;
|
double profitLossRatio = 0.0;
|
||||||
|
|
||||||
int positions = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfPositionsCriterion", series, record));
|
int positions = 0;
|
||||||
int winning = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfWinningPositionsCriterion", series, record));
|
int winning = 0;
|
||||||
int losing = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfLosingPositionsCriterion", series, record));
|
int losing = 0;
|
||||||
int breakEven = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfBreakEvenPositionsCriterion", series, record));
|
int breakEven = 0;
|
||||||
double winRate = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.WinningPositionsRatioCriterion", series, record));
|
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 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 var95 = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.ValueAtRiskCriterion", series, record));
|
||||||
double es = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.ExpectedShortfallCriterion", series, record));
|
double es = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.ExpectedShortfallCriterion", series, record));
|
||||||
|
|
||||||
@@ -330,21 +383,7 @@ public class BacktestingService {
|
|||||||
// 금액 기준 총 손익 재계산
|
// 금액 기준 총 손익 재계산
|
||||||
double totalPnl = finalEquity - initCap;
|
double totalPnl = finalEquity - initCap;
|
||||||
|
|
||||||
// positions = 0 이면 record에서 직접 추출 시도
|
double profitLossRatio = 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));
|
|
||||||
}
|
|
||||||
if (grossLoss != 0) {
|
if (grossLoss != 0) {
|
||||||
profitLossRatio = grossProfit / Math.abs(grossLoss);
|
profitLossRatio = grossProfit / Math.abs(grossLoss);
|
||||||
} else if (grossProfit > 0) {
|
} else if (grossProfit > 0) {
|
||||||
@@ -376,15 +415,23 @@ public class BacktestingService {
|
|||||||
.build();
|
.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 계산 헬퍼 ─────────────────────────────────────────────
|
// ── 개별 Criterion 계산 헬퍼 ─────────────────────────────────────────────
|
||||||
|
|
||||||
private double enterPosition(PortfolioLedger ledger, boolean useLedger,
|
private double enterPosition(PortfolioLedger ledger, boolean useLedger,
|
||||||
double equity, double tradeSizePct, double effEntry, double markPrice,
|
double equity, double tradeSizePct, double effEntry, double markPrice,
|
||||||
BaseTradingRecord record, int barIndex, BarSeries series) {
|
BaseTradingRecord record, int barIndex, BarSeries series, long time) {
|
||||||
if (useLedger) {
|
if (useLedger) {
|
||||||
double before = ledger.shares;
|
double bought = ledger.executeBuy(effEntry, markPrice, time, barIndex);
|
||||||
ledger.executeBuy(effEntry, markPrice);
|
|
||||||
double bought = ledger.shares - before;
|
|
||||||
if (bought <= 1e-12) return 0.0;
|
if (bought <= 1e-12) return 0.0;
|
||||||
record.enter(barIndex, series.numFactory().numOf(effEntry),
|
record.enter(barIndex, series.numFactory().numOf(effEntry),
|
||||||
series.numFactory().numOf(bought));
|
series.numFactory().numOf(bought));
|
||||||
@@ -397,19 +444,22 @@ public class BacktestingService {
|
|||||||
return shares;
|
return shares;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return 체결 수량 (ledger·LONG 경로), 레거시 비율 모델은 환산 수량 */
|
||||||
private double applyExit(PortfolioLedger ledger, boolean useLedger, double equity, double tradeSizePct,
|
private double applyExit(PortfolioLedger ledger, boolean useLedger, double equity, double tradeSizePct,
|
||||||
double entryPrice, double effExit, double sellFraction, String direction,
|
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) {
|
if (useLedger) {
|
||||||
ledger.executeSell(effExit, sellFraction);
|
return ledger.executeSell(effExit, sellFraction, time, barIndex);
|
||||||
return ledger.portfolioValue(effExit);
|
|
||||||
}
|
}
|
||||||
double commission = calcCommissionRate(cfg) * 2;
|
double commission = calcCommissionRate(cfg) * 2;
|
||||||
double rawReturn = "SHORT".equals(direction)
|
double rawReturn = "SHORT".equals(direction)
|
||||||
? (entryPrice - effExit) / entryPrice
|
? (entryPrice - effExit) / entryPrice
|
||||||
: (effExit - entryPrice) / entryPrice;
|
: (effExit - entryPrice) / entryPrice;
|
||||||
return applyEquityPnl(equity, tradeSizePct, sellFraction, rawReturn - commission,
|
applyEquityPnl(equity, tradeSizePct, sellFraction, rawReturn - commission,
|
||||||
grossProfitAcc, grossLossAcc);
|
grossProfitAcc, grossLossAcc);
|
||||||
|
return sellFraction > 0 && entryPrice > 0
|
||||||
|
? (equity * tradeSizePct * sellFraction) / entryPrice : 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private double resolveFinalEquity(PortfolioLedger ledger, boolean useLedger, BacktestSettingsDto cfg,
|
private double resolveFinalEquity(PortfolioLedger ledger, boolean useLedger, BacktestSettingsDto cfg,
|
||||||
|
|||||||
@@ -2,18 +2,94 @@ package com.goldenchart.service;
|
|||||||
|
|
||||||
import com.goldenchart.dto.BacktestSettingsDto;
|
import com.goldenchart.dto.BacktestSettingsDto;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 표준 주식 프로그램 방식의 포트폴리오 회계.
|
* 표준 주식 프로그램 방식의 포트폴리오 회계.
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>MARK_TO_MARKET: 예수금 + 보유주식 시가평가 (평가손익 포함)</li>
|
* <li>MARK_TO_MARKET: 예수금 + 보유주식 시가평가 (평가손익 포함)</li>
|
||||||
* <li>REALIZED_ONLY: 청산 완료 실현손익만 반영 (기말 미청산 평가 제외)</li>
|
* <li>REALIZED_ONLY: 청산 완료 실현손익만 반영 (기말 미청산 평가 제외)</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
|
* 실제 매매(체결) 단위로 라운드트립·MDD·승률을 추적한다.
|
||||||
*/
|
*/
|
||||||
final class PortfolioLedger {
|
final class PortfolioLedger {
|
||||||
|
|
||||||
static final String MARK_TO_MARKET = "MARK_TO_MARKET";
|
static final String MARK_TO_MARKET = "MARK_TO_MARKET";
|
||||||
static final String REALIZED_ONLY = "REALIZED_ONLY";
|
static final String REALIZED_ONLY = "REALIZED_ONLY";
|
||||||
|
|
||||||
|
/** 체결 1건 (매수/매도) */
|
||||||
|
static final class TradeFill {
|
||||||
|
final long time;
|
||||||
|
final int barIndex;
|
||||||
|
final String side;
|
||||||
|
final double price;
|
||||||
|
final double quantity;
|
||||||
|
|
||||||
|
TradeFill(long time, int barIndex, String side, double price, double quantity) {
|
||||||
|
this.time = time;
|
||||||
|
this.barIndex = barIndex;
|
||||||
|
this.side = side;
|
||||||
|
this.price = price;
|
||||||
|
this.quantity = quantity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 청산 완료 라운드트립 1건 */
|
||||||
|
static final class ClosedRoundTrip {
|
||||||
|
final long entryTime;
|
||||||
|
final long exitTime;
|
||||||
|
final int entryBar;
|
||||||
|
final int exitBar;
|
||||||
|
final double entryPrice;
|
||||||
|
final double exitPrice;
|
||||||
|
final double quantity;
|
||||||
|
final double equityAtEntry;
|
||||||
|
final double pnl;
|
||||||
|
/** 해당 거래의 포트폴리오 대비 수익률 (pnl / equityAtEntry) */
|
||||||
|
final double returnPct;
|
||||||
|
|
||||||
|
ClosedRoundTrip(long entryTime, long exitTime, int entryBar, int exitBar,
|
||||||
|
double entryPrice, double exitPrice, double quantity,
|
||||||
|
double equityAtEntry, double pnl) {
|
||||||
|
this.entryTime = entryTime;
|
||||||
|
this.exitTime = exitTime;
|
||||||
|
this.entryBar = entryBar;
|
||||||
|
this.exitBar = exitBar;
|
||||||
|
this.entryPrice = entryPrice;
|
||||||
|
this.exitPrice = exitPrice;
|
||||||
|
this.quantity = quantity;
|
||||||
|
this.equityAtEntry = equityAtEntry;
|
||||||
|
this.pnl = pnl;
|
||||||
|
this.returnPct = equityAtEntry > 0 ? pnl / equityAtEntry : 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 체결 기준 거래 통계 */
|
||||||
|
static final class TradeStats {
|
||||||
|
final int closedCount;
|
||||||
|
final int winning;
|
||||||
|
final int losing;
|
||||||
|
final int breakEven;
|
||||||
|
final double winRate;
|
||||||
|
final double avgReturnPct;
|
||||||
|
|
||||||
|
TradeStats(int closedCount, int winning, int losing, int breakEven,
|
||||||
|
double winRate, double avgReturnPct) {
|
||||||
|
this.closedCount = closedCount;
|
||||||
|
this.winning = winning;
|
||||||
|
this.losing = losing;
|
||||||
|
this.breakEven = breakEven;
|
||||||
|
this.winRate = winRate;
|
||||||
|
this.avgReturnPct = avgReturnPct;
|
||||||
|
}
|
||||||
|
|
||||||
|
static TradeStats empty() {
|
||||||
|
return new TradeStats(0, 0, 0, 0, 0.0, 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final double initialCapital;
|
final double initialCapital;
|
||||||
final String analysisMethod;
|
final String analysisMethod;
|
||||||
final BacktestSettingsDto cfg;
|
final BacktestSettingsDto cfg;
|
||||||
@@ -26,11 +102,29 @@ final class PortfolioLedger {
|
|||||||
double grossProfit;
|
double grossProfit;
|
||||||
double grossLoss;
|
double grossLoss;
|
||||||
|
|
||||||
|
private final List<TradeFill> fills = new ArrayList<>();
|
||||||
|
private final List<ClosedRoundTrip> closedTrips = new ArrayList<>();
|
||||||
|
private final List<Double> equityCurve = new ArrayList<>();
|
||||||
|
|
||||||
|
/** 미청산 포지션 진입 시점 추적 */
|
||||||
|
private long openEntryTime;
|
||||||
|
private int openEntryBar;
|
||||||
|
private double openEntryPrice;
|
||||||
|
private double openEntryEquity;
|
||||||
|
private double openEntryQty;
|
||||||
|
/** 분할 청산 포함 — 라운드트립 누적 실현손익 */
|
||||||
|
private double openRoundTripPnl;
|
||||||
|
|
||||||
|
private double peakEquity;
|
||||||
|
private double maxDrawdownPct;
|
||||||
|
|
||||||
PortfolioLedger(double initialCapital, BacktestSettingsDto cfg) {
|
PortfolioLedger(double initialCapital, BacktestSettingsDto cfg) {
|
||||||
this.initialCapital = initialCapital;
|
this.initialCapital = initialCapital;
|
||||||
this.cfg = cfg;
|
this.cfg = cfg;
|
||||||
this.analysisMethod = normalizeMethod(cfg.getAnalysisMethod());
|
this.analysisMethod = normalizeMethod(cfg.getAnalysisMethod());
|
||||||
this.cash = initialCapital;
|
this.cash = initialCapital;
|
||||||
|
this.peakEquity = initialCapital;
|
||||||
|
this.equityCurve.add(initialCapital);
|
||||||
}
|
}
|
||||||
|
|
||||||
static String normalizeMethod(String raw) {
|
static String normalizeMethod(String raw) {
|
||||||
@@ -41,11 +135,43 @@ final class PortfolioLedger {
|
|||||||
return shares > 1e-12;
|
return shares > 1e-12;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<TradeFill> getFills() {
|
||||||
|
return Collections.unmodifiableList(fills);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ClosedRoundTrip> getClosedTrips() {
|
||||||
|
return Collections.unmodifiableList(closedTrips);
|
||||||
|
}
|
||||||
|
|
||||||
|
double maxDrawdownPct() {
|
||||||
|
return maxDrawdownPct;
|
||||||
|
}
|
||||||
|
|
||||||
|
TradeStats tradeStats() {
|
||||||
|
if (closedTrips.isEmpty()) return TradeStats.empty();
|
||||||
|
int winning = 0, losing = 0, breakEven = 0;
|
||||||
|
double sumReturnPct = 0.0;
|
||||||
|
for (ClosedRoundTrip t : closedTrips) {
|
||||||
|
if (t.pnl > 1e-6) winning++;
|
||||||
|
else if (t.pnl < -1e-6) losing++;
|
||||||
|
else breakEven++;
|
||||||
|
sumReturnPct += t.returnPct;
|
||||||
|
}
|
||||||
|
int n = closedTrips.size();
|
||||||
|
return new TradeStats(n, winning, losing, breakEven,
|
||||||
|
(double) winning / n, sumReturnPct / n);
|
||||||
|
}
|
||||||
|
|
||||||
/** 평가금액 = 예수금 + 보유주식 평가액 */
|
/** 평가금액 = 예수금 + 보유주식 평가액 */
|
||||||
double portfolioValue(double markPrice) {
|
double portfolioValue(double markPrice) {
|
||||||
return cash + shares * markPrice;
|
return cash + shares * markPrice;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 봉 종료 시 평가금액으로 MDD·에쿼티 커브 갱신 */
|
||||||
|
void markToMarket(double markPrice) {
|
||||||
|
updateEquityCurve(portfolioValue(markPrice));
|
||||||
|
}
|
||||||
|
|
||||||
double unrealizedPnl(double markPrice) {
|
double unrealizedPnl(double markPrice) {
|
||||||
if (shares <= 1e-12) return 0.0;
|
if (shares <= 1e-12) return 0.0;
|
||||||
return shares * markPrice - costBasis;
|
return shares * markPrice - costBasis;
|
||||||
@@ -58,16 +184,19 @@ final class PortfolioLedger {
|
|||||||
return cash + shares * lastMarkPrice;
|
return cash + shares * lastMarkPrice;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** LONG 매수 체결 */
|
/**
|
||||||
void executeBuy(double effEntry, double markPriceForSizing) {
|
* LONG 매수 체결.
|
||||||
if (effEntry <= 0 || cash <= 0) return;
|
* @return 체결 수량 (0 = 미체결)
|
||||||
|
*/
|
||||||
|
double executeBuy(double effEntry, double markPriceForSizing, long time, int barIndex) {
|
||||||
|
if (effEntry <= 0 || cash <= 0) return 0.0;
|
||||||
double commRate = commissionRate();
|
double commRate = commissionRate();
|
||||||
double orderAmount = computeOrderAmount(markPriceForSizing);
|
double orderAmount = computeOrderAmount(markPriceForSizing);
|
||||||
if (orderAmount <= 0) return;
|
if (orderAmount <= 0) return 0.0;
|
||||||
|
|
||||||
double maxSpend = cash;
|
double maxSpend = cash;
|
||||||
orderAmount = Math.min(orderAmount, maxSpend / (1 + commRate));
|
orderAmount = Math.min(orderAmount, maxSpend / (1 + commRate));
|
||||||
if (orderAmount <= 0) return;
|
if (orderAmount <= 0) return 0.0;
|
||||||
|
|
||||||
double sharesToBuy = orderAmount / effEntry;
|
double sharesToBuy = orderAmount / effEntry;
|
||||||
double totalCost = sharesToBuy * effEntry * (1 + commRate);
|
double totalCost = sharesToBuy * effEntry * (1 + commRate);
|
||||||
@@ -75,19 +204,41 @@ final class PortfolioLedger {
|
|||||||
sharesToBuy = cash / (effEntry * (1 + commRate));
|
sharesToBuy = cash / (effEntry * (1 + commRate));
|
||||||
totalCost = sharesToBuy * effEntry * (1 + commRate);
|
totalCost = sharesToBuy * effEntry * (1 + commRate);
|
||||||
}
|
}
|
||||||
if (sharesToBuy <= 1e-12) return;
|
if (sharesToBuy <= 1e-12) return 0.0;
|
||||||
|
|
||||||
|
boolean wasFlat = !hasPosition();
|
||||||
|
double equityBefore = portfolioValue(markPriceForSizing);
|
||||||
|
|
||||||
cash -= totalCost;
|
cash -= totalCost;
|
||||||
shares += sharesToBuy;
|
shares += sharesToBuy;
|
||||||
costBasis += totalCost;
|
costBasis += totalCost;
|
||||||
|
|
||||||
|
fills.add(new TradeFill(time, barIndex, "BUY", effEntry, sharesToBuy));
|
||||||
|
|
||||||
|
if (wasFlat) {
|
||||||
|
openEntryTime = time;
|
||||||
|
openEntryBar = barIndex;
|
||||||
|
openEntryPrice = effEntry;
|
||||||
|
openEntryEquity = equityBefore;
|
||||||
|
openEntryQty = sharesToBuy;
|
||||||
|
openRoundTripPnl = 0.0;
|
||||||
|
} else {
|
||||||
|
openEntryQty += sharesToBuy;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateEquityCurve(portfolioValue(markPriceForSizing));
|
||||||
|
return sharesToBuy;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** LONG 매도 체결 — sellFraction: 0~1 (전량=1) */
|
/**
|
||||||
void executeSell(double effExit, double sellFraction) {
|
* LONG 매도 체결 — sellFraction: 0~1 (전량=1)
|
||||||
if (effExit <= 0 || shares <= 1e-12) return;
|
* @return 체결 수량 (0 = 미체결)
|
||||||
|
*/
|
||||||
|
double executeSell(double effExit, double sellFraction, long time, int barIndex) {
|
||||||
|
if (effExit <= 0 || shares <= 1e-12) return 0.0;
|
||||||
double fraction = Math.max(0.0, Math.min(1.0, sellFraction));
|
double fraction = Math.max(0.0, Math.min(1.0, sellFraction));
|
||||||
double sharesToSell = shares * fraction;
|
double sharesToSell = shares * fraction;
|
||||||
if (sharesToSell <= 1e-12) return;
|
if (sharesToSell <= 1e-12) return 0.0;
|
||||||
|
|
||||||
double commRate = commissionRate();
|
double commRate = commissionRate();
|
||||||
double proceeds = sharesToSell * effExit * (1 - commRate);
|
double proceeds = sharesToSell * effExit * (1 - commRate);
|
||||||
@@ -98,13 +249,64 @@ final class PortfolioLedger {
|
|||||||
realizedPnl += pnl;
|
realizedPnl += pnl;
|
||||||
if (pnl >= 0) grossProfit += pnl;
|
if (pnl >= 0) grossProfit += pnl;
|
||||||
else grossLoss += pnl;
|
else grossLoss += pnl;
|
||||||
|
openRoundTripPnl += pnl;
|
||||||
|
|
||||||
shares -= sharesToSell;
|
shares -= sharesToSell;
|
||||||
costBasis -= costPortion;
|
costBasis -= costPortion;
|
||||||
if (shares <= 1e-12) {
|
|
||||||
|
fills.add(new TradeFill(time, barIndex, "SELL", effExit, sharesToSell));
|
||||||
|
|
||||||
|
boolean fullyClosed = shares <= 1e-12;
|
||||||
|
if (fullyClosed) {
|
||||||
shares = 0;
|
shares = 0;
|
||||||
costBasis = 0;
|
costBasis = 0;
|
||||||
|
closedTrips.add(new ClosedRoundTrip(
|
||||||
|
openEntryTime, time, openEntryBar, barIndex,
|
||||||
|
openEntryPrice, effExit, openEntryQty,
|
||||||
|
openEntryEquity, openRoundTripPnl));
|
||||||
|
openEntryQty = 0;
|
||||||
|
openRoundTripPnl = 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateEquityCurve(portfolioValue(effExit));
|
||||||
|
return sharesToSell;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 레거시 호환 — barIndex/time 없이 호출 (테스트·SHORT 경로) */
|
||||||
|
void executeBuy(double effEntry, double markPriceForSizing) {
|
||||||
|
executeBuy(effEntry, markPriceForSizing, 0L, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void executeSell(double effExit, double sellFraction) {
|
||||||
|
executeSell(effExit, sellFraction, 0L, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateEquityCurve(double equity) {
|
||||||
|
equityCurve.add(equity);
|
||||||
|
if (equity > peakEquity) peakEquity = equity;
|
||||||
|
if (peakEquity > 0) {
|
||||||
|
double dd = (equity - peakEquity) / peakEquity;
|
||||||
|
if (dd < maxDrawdownPct) maxDrawdownPct = dd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 에쿼티 커브 기반 샤프 (봉 단위 수익률) */
|
||||||
|
double sharpeFromEquityCurve() {
|
||||||
|
if (equityCurve.size() < 3) return 0.0;
|
||||||
|
List<Double> returns = new ArrayList<>();
|
||||||
|
for (int i = 1; i < equityCurve.size(); i++) {
|
||||||
|
double prev = equityCurve.get(i - 1);
|
||||||
|
double cur = equityCurve.get(i);
|
||||||
|
if (prev > 0) returns.add((cur - prev) / prev);
|
||||||
|
}
|
||||||
|
if (returns.isEmpty()) return 0.0;
|
||||||
|
double mean = returns.stream().mapToDouble(d -> d).average().orElse(0.0);
|
||||||
|
double var = 0.0;
|
||||||
|
for (double r : returns) var += (r - mean) * (r - mean);
|
||||||
|
var /= returns.size();
|
||||||
|
double std = Math.sqrt(var);
|
||||||
|
if (std < 1e-12) return 0.0;
|
||||||
|
return (mean / std) * Math.sqrt(returns.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
private double computeOrderAmount(double markPrice) {
|
private double computeOrderAmount(double markPrice) {
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export default function BacktestSignalTable({ signals, expanded = false, classNa
|
|||||||
<th>날짜</th>
|
<th>날짜</th>
|
||||||
<th>구분</th>
|
<th>구분</th>
|
||||||
<th style={{ textAlign: 'right' }}>가격</th>
|
<th style={{ textAlign: 'right' }}>가격</th>
|
||||||
|
<th style={{ textAlign: 'right' }}>수량</th>
|
||||||
<th style={{ textAlign: 'center', width: 50 }}>봉#</th>
|
<th style={{ textAlign: 'center', width: 50 }}>봉#</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -73,6 +74,11 @@ export default function BacktestSignalTable({ signals, expanded = false, classNa
|
|||||||
<td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums', fontWeight: 600 }}>
|
<td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums', fontWeight: 600 }}>
|
||||||
{Math.round(s.price).toLocaleString()}
|
{Math.round(s.price).toLocaleString()}
|
||||||
</td>
|
</td>
|
||||||
|
<td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums', color: 'var(--text2)' }}>
|
||||||
|
{s.quantity != null && s.quantity > 0
|
||||||
|
? s.quantity.toFixed(6).replace(/\.?0+$/, '')
|
||||||
|
: '–'}
|
||||||
|
</td>
|
||||||
<td style={{ textAlign: 'center', color: 'var(--text3)' }}>{s.barIndex}</td>
|
<td style={{ textAlign: 'center', color: 'var(--text3)' }}>{s.barIndex}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1141,6 +1141,8 @@ export interface BacktestSignal {
|
|||||||
type: 'BUY' | 'SELL' | 'SHORT_ENTRY' | 'SHORT_EXIT' | 'PARTIAL_SELL';
|
type: 'BUY' | 'SELL' | 'SHORT_ENTRY' | 'SHORT_EXIT' | 'PARTIAL_SELL';
|
||||||
price: number;
|
price: number;
|
||||||
barIndex: number;
|
barIndex: number;
|
||||||
|
/** 체결 수량 (코인/주식 단위) */
|
||||||
|
quantity?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BacktestStats {
|
export interface BacktestStats {
|
||||||
|
|||||||
@@ -61,8 +61,9 @@ export function buildEquityFromSignals(
|
|||||||
pushCurve(sorted[0].time, sorted[0].price);
|
pushCurve(sorted[0].time, sorted[0].price);
|
||||||
|
|
||||||
for (const s of sorted) {
|
for (const s of sorted) {
|
||||||
|
const qtyHint = s.quantity != null && s.quantity > 0 ? s.quantity : undefined;
|
||||||
if (BUY_TYPES.has(s.type) && qty === 0 && cash > 0) {
|
if (BUY_TYPES.has(s.type) && qty === 0 && cash > 0) {
|
||||||
qty = cash / s.price;
|
qty = qtyHint ?? cash / s.price;
|
||||||
entryPrice = s.price;
|
entryPrice = s.price;
|
||||||
cash = 0;
|
cash = 0;
|
||||||
tradeId += 1;
|
tradeId += 1;
|
||||||
@@ -78,8 +79,9 @@ export function buildEquityFromSignals(
|
|||||||
markers.push({ time: s.time, equity: eq, type: 'buy', price: s.price });
|
markers.push({ time: s.time, equity: eq, type: 'buy', price: s.price });
|
||||||
pushCurve(s.time, s.price);
|
pushCurve(s.time, s.price);
|
||||||
} else if (SELL_TYPES.has(s.type) && qty > 0) {
|
} else if (SELL_TYPES.has(s.type) && qty > 0) {
|
||||||
|
const sellQty = qtyHint ?? qty;
|
||||||
const pnlPct = entryPrice > 0 ? (s.price - entryPrice) / entryPrice : 0;
|
const pnlPct = entryPrice > 0 ? (s.price - entryPrice) / entryPrice : 0;
|
||||||
cash = qty * s.price;
|
cash = sellQty * s.price;
|
||||||
tradeId += 1;
|
tradeId += 1;
|
||||||
trades.push({
|
trades.push({
|
||||||
id: tradeId,
|
id: tradeId,
|
||||||
@@ -87,7 +89,7 @@ export function buildEquityFromSignals(
|
|||||||
symbol,
|
symbol,
|
||||||
side: 'sell',
|
side: 'sell',
|
||||||
price: s.price,
|
price: s.price,
|
||||||
quantity: qty,
|
quantity: sellQty,
|
||||||
pnlPct,
|
pnlPct,
|
||||||
});
|
});
|
||||||
markers.push({ time: s.time, equity: cash, type: 'sell', price: s.price, pnlPct });
|
markers.push({ time: s.time, equity: cash, type: 'sell', price: s.price, pnlPct });
|
||||||
|
|||||||
@@ -991,6 +991,8 @@ export interface BacktestSignal {
|
|||||||
type: 'BUY' | 'SELL' | 'SHORT_ENTRY' | 'SHORT_EXIT' | 'PARTIAL_SELL';
|
type: 'BUY' | 'SELL' | 'SHORT_ENTRY' | 'SHORT_EXIT' | 'PARTIAL_SELL';
|
||||||
price: number;
|
price: number;
|
||||||
barIndex: number;
|
barIndex: number;
|
||||||
|
/** 체결 수량 (코인/주식 단위) */
|
||||||
|
quantity?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BacktestStats {
|
export interface BacktestStats {
|
||||||
|
|||||||
Reference in New Issue
Block a user