투자분석 레포트 로직 수정

This commit is contained in:
Macbook
2026-06-08 09:10:17 +09:00
parent bb3c14bd59
commit d4f0105b5a
7 changed files with 367 additions and 101 deletions
@@ -158,21 +158,20 @@ public class BacktestingService {
double exitPrice = getPrice(series, req.getBars(), i, cfg.getExitPriceType());
long time = series.getBar(i).getEndTime().getEpochSecond();
// ── SIGNAL_ONLY 모드: 포지션 상태와 무관하게 순수 지표 규칙 충족 여부만 판정 ──
// ── SIGNAL_ONLY 모드: 지표 시그널은 포지션 무관, 체결·레포트는 LONG_ONLY와 동일 ──
if (signalOnly) {
boolean enterOk = entryRule.isSatisfied(i);
boolean exitOk = exitRule.isSatisfied(i);
if (enterOk) {
double effEntry = applySlippage(closePrice, cfg, true);
String sigType = "SHORT".equals(direction) ? "SHORT_ENTRY" : "BUY";
signals.add(Signal.builder()
.time(time).type(sigType).price(effEntry).barIndex(i).build());
// 실제 포지션 추적은 LONG_ONLY 모드와 동일하게 유지 (수익 계산용)
if (!inPosition) {
double shares = enterPosition(ledger, useLedger, equity, tradeSizePct,
effEntry, closePrice, record, i, series);
if (shares > 0) {
if (!inPosition) {
if (i - lastExitBar <= reentryWait && lastExitBar >= 0) {
if (useLedger) ledger.markToMarket(closePrice);
continue;
}
if (entryRule.isSatisfied(i)) {
double effEntry = applySlippage(closePrice, cfg, true);
double qty = enterPosition(ledger, useLedger, equity, tradeSizePct,
effEntry, closePrice, record, i, series, time);
if (qty > 0) {
String sigType = "SHORT".equals(direction) ? "SHORT_ENTRY" : "BUY";
signals.add(buildFillSignal(time, sigType, effEntry, i, qty));
entryPrice = effEntry;
entryBarIdx = i;
inPosition = true;
@@ -180,25 +179,36 @@ public class BacktestingService {
if (useLedger) equity = ledger.portfolioValue(closePrice);
}
}
} else if (exitOk) {
double effExit = applySlippage(exitPrice, cfg, false);
String sigType = "SHORT".equals(direction) ? "SHORT_EXIT" : "SELL";
signals.add(Signal.builder()
.time(time).type(sigType).price(effExit).barIndex(i).build());
// 수익 계산: 실제 포지션이 있을 때만
if (inPosition) {
} else {
if (partialExit && !partialDone && exitRule.isSatisfied(i)) {
double effExit = applySlippage(exitPrice, cfg, false);
double qty = applyExit(ledger, useLedger, equity, tradeSizePct, entryPrice,
effExit, partialPct, direction, cfg, simGrossProfit, simGrossLoss, time, i);
if (qty > 0) {
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;
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);
inPosition = false;
lastExitBar = i;
partialDone = false;
double qty = applyExit(ledger, useLedger, equity, tradeSizePct, entryPrice, effExit,
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);
String sigType = "SHORT".equals(direction) ? "SHORT_EXIT" : "SELL";
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 모드: 표준 포지션 제약 로직 ────────────────────────────
@@ -211,12 +221,11 @@ public class BacktestingService {
if (doEnter) {
double effEntry = applySlippage(closePrice, cfg, true);
double shares = enterPosition(ledger, useLedger, equity, tradeSizePct,
effEntry, closePrice, record, i, series);
if (shares > 0) {
double qty = enterPosition(ledger, useLedger, equity, tradeSizePct,
effEntry, closePrice, record, i, series, time);
if (qty > 0) {
String sigType = "SHORT".equals(direction) ? "SHORT_ENTRY" : "BUY";
signals.add(Signal.builder()
.time(time).type(sigType).price(effEntry).barIndex(i).build());
signals.add(buildFillSignal(time, sigType, effEntry, i, qty));
entryPrice = effEntry;
entryBarIdx = i;
inPosition = true;
@@ -228,33 +237,38 @@ public class BacktestingService {
// 분할 청산: exit 조건 처음 충족 시 일부만 청산
if (partialExit && !partialDone && exitRule.isSatisfied(i, record)) {
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;
double qty = applyExit(ledger, useLedger, equity, tradeSizePct, entryPrice, effExit,
partialPct, direction, cfg, simGrossProfit, simGrossLoss, time, i);
if (qty > 0) {
signals.add(buildFillSignal(time, "PARTIAL_SELL", effExit, i, qty));
partialDone = true;
if (useLedger) equity = ledger.portfolioValue(closePrice);
}
if (useLedger) ledger.markToMarket(closePrice);
continue;
}
if (exitRule.isSatisfied(i, record)) {
double effExit = applySlippage(exitPrice, cfg, false);
double size = partialDone ? (1.0 - partialPct) : 1.0;
equity = applyExit(ledger, useLedger, equity, tradeSizePct, entryPrice, effExit,
size, direction, cfg, simGrossProfit, simGrossLoss);
double qty = applyExit(ledger, useLedger, equity, tradeSizePct, entryPrice, effExit,
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);
Num numShares = record.getCurrentPosition().getEntry().getAmount();
record.exit(i, numExitPrice, numShares);
String sigType = "SHORT".equals(direction) ? "SHORT_EXIT" : "SELL";
signals.add(buildFillSignal(time, sigType, effExit, i, qty));
String sigType = "SHORT".equals(direction) ? "SHORT_EXIT" : "SELL";
signals.add(Signal.builder()
.time(time).type(sigType).price(effExit).barIndex(i).build());
inPosition = false;
lastExitBar = i;
partialDone = false;
inPosition = false;
lastExitBar = i;
partialDone = false;
if (useLedger) equity = ledger.portfolioValue(closePrice);
}
}
}
if (useLedger) ledger.markToMarket(closePrice);
}
double lastMarkPrice = barCount > 0
@@ -307,20 +321,59 @@ public class BacktestingService {
.unrealizedPnl(unrealizedPnl);
double totalReturnPct = initCap > 0 ? (finalEquity - initCap) / initCap : 0.0;
double avgReturnPct = 0.0;
double profitLossRatio = 0.0;
int positions = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfPositionsCriterion", series, record));
int winning = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfWinningPositionsCriterion", series, record));
int losing = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfLosingPositionsCriterion", series, record));
int breakEven = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfBreakEvenPositionsCriterion", series, record));
double winRate = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.WinningPositionsRatioCriterion", series, record));
int positions = 0;
int winning = 0;
int losing = 0;
int breakEven = 0;
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 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 es = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.ExpectedShortfallCriterion", series, record));
@@ -330,21 +383,7 @@ public class BacktestingService {
// 금액 기준 총 손익 재계산
double totalPnl = finalEquity - initCap;
// positions = 0 이면 record에서 직접 추출 시도
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 profitLossRatio = 0.0;
if (grossLoss != 0) {
profitLossRatio = grossProfit / Math.abs(grossLoss);
} else if (grossProfit > 0) {
@@ -376,15 +415,23 @@ public class BacktestingService {
.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 계산 헬퍼 ─────────────────────────────────────────────
private double enterPosition(PortfolioLedger ledger, boolean useLedger,
double equity, double tradeSizePct, double effEntry, double markPrice,
BaseTradingRecord record, int barIndex, BarSeries series) {
BaseTradingRecord record, int barIndex, BarSeries series, long time) {
if (useLedger) {
double before = ledger.shares;
ledger.executeBuy(effEntry, markPrice);
double bought = ledger.shares - before;
double bought = ledger.executeBuy(effEntry, markPrice, time, barIndex);
if (bought <= 1e-12) return 0.0;
record.enter(barIndex, series.numFactory().numOf(effEntry),
series.numFactory().numOf(bought));
@@ -397,19 +444,22 @@ public class BacktestingService {
return shares;
}
/** @return 체결 수량 (ledger·LONG 경로), 레거시 비율 모델은 환산 수량 */
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) {
BacktestSettingsDto cfg, double[] grossProfitAcc, double[] grossLossAcc,
long time, int barIndex) {
if (useLedger) {
ledger.executeSell(effExit, sellFraction);
return ledger.portfolioValue(effExit);
return ledger.executeSell(effExit, sellFraction, time, barIndex);
}
double commission = calcCommissionRate(cfg) * 2;
double rawReturn = "SHORT".equals(direction)
? (entryPrice - effExit) / entryPrice
: (effExit - entryPrice) / entryPrice;
return applyEquityPnl(equity, tradeSizePct, sellFraction, rawReturn - commission,
applyEquityPnl(equity, tradeSizePct, sellFraction, rawReturn - commission,
grossProfitAcc, grossLossAcc);
return sellFraction > 0 && entryPrice > 0
? (equity * tradeSizePct * sellFraction) / entryPrice : 0.0;
}
private double resolveFinalEquity(PortfolioLedger ledger, boolean useLedger, BacktestSettingsDto cfg,