투자분석 레포트 로직 수정
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user