diff --git a/backend/src/main/java/com/goldenchart/dto/BacktestResponse.java b/backend/src/main/java/com/goldenchart/dto/BacktestResponse.java index e2098a8..bba7a2d 100644 --- a/backend/src/main/java/com/goldenchart/dto/BacktestResponse.java +++ b/backend/src/main/java/com/goldenchart/dto/BacktestResponse.java @@ -31,12 +31,14 @@ public class BacktestResponse { public static class Signal { /** Unix timestamp (초) */ private long time; - /** BUY | SELL */ + /** BUY | SELL | SHORT_ENTRY | SHORT_EXIT | PARTIAL_SELL */ private String type; - /** 해당 봉 종가 */ + /** 체결 가격 (슬리피지 반영) */ private double price; /** 진입/청산 인덱스 (0-based) */ private int barIndex; + /** 체결 수량 (코인/주식 단위, 미체결 시그널 없음) */ + private double quantity; } @Data @Builder @NoArgsConstructor @AllArgsConstructor diff --git a/backend/src/main/java/com/goldenchart/service/BacktestingService.java b/backend/src/main/java/com/goldenchart/service/BacktestingService.java index a8e91ed..1128d3e 100644 --- a/backend/src/main/java/com/goldenchart/service/BacktestingService.java +++ b/backend/src/main/java/com/goldenchart/service/BacktestingService.java @@ -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, diff --git a/backend/src/main/java/com/goldenchart/service/PortfolioLedger.java b/backend/src/main/java/com/goldenchart/service/PortfolioLedger.java index 060ebcf..0189bac 100644 --- a/backend/src/main/java/com/goldenchart/service/PortfolioLedger.java +++ b/backend/src/main/java/com/goldenchart/service/PortfolioLedger.java @@ -2,18 +2,94 @@ package com.goldenchart.service; import com.goldenchart.dto.BacktestSettingsDto; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + /** * 표준 주식 프로그램 방식의 포트폴리오 회계. * + * 실제 매매(체결) 단위로 라운드트립·MDD·승률을 추적한다. */ final class PortfolioLedger { static final String MARK_TO_MARKET = "MARK_TO_MARKET"; 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 String analysisMethod; final BacktestSettingsDto cfg; @@ -26,11 +102,29 @@ final class PortfolioLedger { double grossProfit; double grossLoss; + private final List fills = new ArrayList<>(); + private final List closedTrips = new ArrayList<>(); + private final List 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) { this.initialCapital = initialCapital; this.cfg = cfg; this.analysisMethod = normalizeMethod(cfg.getAnalysisMethod()); this.cash = initialCapital; + this.peakEquity = initialCapital; + this.equityCurve.add(initialCapital); } static String normalizeMethod(String raw) { @@ -41,11 +135,43 @@ final class PortfolioLedger { return shares > 1e-12; } + List getFills() { + return Collections.unmodifiableList(fills); + } + + List 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) { return cash + shares * markPrice; } + /** 봉 종료 시 평가금액으로 MDD·에쿼티 커브 갱신 */ + void markToMarket(double markPrice) { + updateEquityCurve(portfolioValue(markPrice)); + } + double unrealizedPnl(double markPrice) { if (shares <= 1e-12) return 0.0; return shares * markPrice - costBasis; @@ -58,16 +184,19 @@ final class PortfolioLedger { return cash + shares * lastMarkPrice; } - /** LONG 매수 체결 */ - void executeBuy(double effEntry, double markPriceForSizing) { - if (effEntry <= 0 || cash <= 0) return; + /** + * LONG 매수 체결. + * @return 체결 수량 (0 = 미체결) + */ + double executeBuy(double effEntry, double markPriceForSizing, long time, int barIndex) { + if (effEntry <= 0 || cash <= 0) return 0.0; double commRate = commissionRate(); double orderAmount = computeOrderAmount(markPriceForSizing); - if (orderAmount <= 0) return; + if (orderAmount <= 0) return 0.0; double maxSpend = cash; orderAmount = Math.min(orderAmount, maxSpend / (1 + commRate)); - if (orderAmount <= 0) return; + if (orderAmount <= 0) return 0.0; double sharesToBuy = orderAmount / effEntry; double totalCost = sharesToBuy * effEntry * (1 + commRate); @@ -75,19 +204,41 @@ final class PortfolioLedger { sharesToBuy = cash / (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; shares += sharesToBuy; 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) { - if (effExit <= 0 || shares <= 1e-12) return; + /** + * LONG 매도 체결 — sellFraction: 0~1 (전량=1) + * @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 sharesToSell = shares * fraction; - if (sharesToSell <= 1e-12) return; + if (sharesToSell <= 1e-12) return 0.0; double commRate = commissionRate(); double proceeds = sharesToSell * effExit * (1 - commRate); @@ -98,13 +249,64 @@ final class PortfolioLedger { realizedPnl += pnl; if (pnl >= 0) grossProfit += pnl; else grossLoss += pnl; + openRoundTripPnl += pnl; shares -= sharesToSell; costBasis -= costPortion; - if (shares <= 1e-12) { + + fills.add(new TradeFill(time, barIndex, "SELL", effExit, sharesToSell)); + + boolean fullyClosed = shares <= 1e-12; + if (fullyClosed) { shares = 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 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) { diff --git a/frontend/src/components/backtest/BacktestSignalTable.tsx b/frontend/src/components/backtest/BacktestSignalTable.tsx index 8ed6304..be3ae2d 100644 --- a/frontend/src/components/backtest/BacktestSignalTable.tsx +++ b/frontend/src/components/backtest/BacktestSignalTable.tsx @@ -57,6 +57,7 @@ export default function BacktestSignalTable({ signals, expanded = false, classNa 날짜 구분 가격 + 수량 봉# @@ -73,6 +74,11 @@ export default function BacktestSignalTable({ signals, expanded = false, classNa {Math.round(s.price).toLocaleString()} + + {s.quantity != null && s.quantity > 0 + ? s.quantity.toFixed(6).replace(/\.?0+$/, '') + : '–'} + {s.barIndex} ))} diff --git a/frontend/src/utils/backendApi.ts b/frontend/src/utils/backendApi.ts index eb68c4f..47da82f 100644 --- a/frontend/src/utils/backendApi.ts +++ b/frontend/src/utils/backendApi.ts @@ -1141,6 +1141,8 @@ export interface BacktestSignal { type: 'BUY' | 'SELL' | 'SHORT_ENTRY' | 'SHORT_EXIT' | 'PARTIAL_SELL'; price: number; barIndex: number; + /** 체결 수량 (코인/주식 단위) */ + quantity?: number; } export interface BacktestStats { diff --git a/frontend/src/utils/backtestEquity.ts b/frontend/src/utils/backtestEquity.ts index f402230..d50d383 100644 --- a/frontend/src/utils/backtestEquity.ts +++ b/frontend/src/utils/backtestEquity.ts @@ -61,8 +61,9 @@ export function buildEquityFromSignals( pushCurve(sorted[0].time, sorted[0].price); 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) { - qty = cash / s.price; + qty = qtyHint ?? cash / s.price; entryPrice = s.price; cash = 0; tradeId += 1; @@ -78,8 +79,9 @@ export function buildEquityFromSignals( markers.push({ time: s.time, equity: eq, type: 'buy', price: s.price }); pushCurve(s.time, s.price); } else if (SELL_TYPES.has(s.type) && qty > 0) { + const sellQty = qtyHint ?? qty; const pnlPct = entryPrice > 0 ? (s.price - entryPrice) / entryPrice : 0; - cash = qty * s.price; + cash = sellQty * s.price; tradeId += 1; trades.push({ id: tradeId, @@ -87,7 +89,7 @@ export function buildEquityFromSignals( symbol, side: 'sell', price: s.price, - quantity: qty, + quantity: sellQty, pnlPct, }); markers.push({ time: s.time, equity: cash, type: 'sell', price: s.price, pnlPct }); diff --git a/packages/shared/src/api/backendApi.ts b/packages/shared/src/api/backendApi.ts index 5d04458..357c544 100644 --- a/packages/shared/src/api/backendApi.ts +++ b/packages/shared/src/api/backendApi.ts @@ -991,6 +991,8 @@ export interface BacktestSignal { type: 'BUY' | 'SELL' | 'SHORT_ENTRY' | 'SHORT_EXIT' | 'PARTIAL_SELL'; price: number; barIndex: number; + /** 체결 수량 (코인/주식 단위) */ + quantity?: number; } export interface BacktestStats {