투자분석 레포트 로직 수정

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
@@ -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);
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) { if (!inPosition) {
double shares = enterPosition(ledger, useLedger, equity, tradeSizePct, if (i - lastExitBar <= reentryWait && lastExitBar >= 0) {
effEntry, closePrice, record, i, series); if (useLedger) ledger.markToMarket(closePrice);
if (shares > 0) { 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; 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 {
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 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) {
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 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);
String sigType = "SHORT".equals(direction) ? "SHORT_EXIT" : "SELL";
signals.add(buildFillSignal(time, sigType, effExit, i, qty));
inPosition = false; inPosition = false;
lastExitBar = i; lastExitBar = i;
partialDone = false; 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,34 +237,39 @@ 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 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);
String sigType = "SHORT".equals(direction) ? "SHORT_EXIT" : "SELL"; String sigType = "SHORT".equals(direction) ? "SHORT_EXIT" : "SELL";
signals.add(Signal.builder() signals.add(buildFillSignal(time, sigType, effExit, i, qty));
.time(time).type(sigType).price(effExit).barIndex(i).build());
inPosition = false; inPosition = false;
lastExitBar = i; lastExitBar = i;
partialDone = false; partialDone = false;
if (useLedger) equity = ledger.portfolioValue(closePrice);
} }
} }
} }
if (useLedger) ledger.markToMarket(closePrice);
}
double lastMarkPrice = barCount > 0 double lastMarkPrice = barCount > 0
? series.getBar(barCount - 1).getClosePrice().doubleValue() : 0.0; ? series.getBar(barCount - 1).getClosePrice().doubleValue() : 0.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;
} }
/** LONG 매도 체결 — sellFraction: 0~1 (전량=1) */ updateEquityCurve(portfolioValue(markPriceForSizing));
void executeSell(double effExit, double sellFraction) { return sharesToBuy;
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 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>
))} ))}
+2
View File
@@ -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 {
+5 -3
View File
@@ -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 });
+2
View File
@@ -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 {