투자분석 레포트 로직 수정

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 {
/** 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
@@ -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,
@@ -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;
/**
* 표준 주식 프로그램 방식의 포트폴리오 회계.
* <ul>
* <li>MARK_TO_MARKET: 예수금 + 보유주식 시가평가 (평가손익 포함)</li>
* <li>REALIZED_ONLY: 청산 완료 실현손익만 반영 (기말 미청산 평가 제외)</li>
* </ul>
* 실제 매매(체결) 단위로 라운드트립·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<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) {
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<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) {
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<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) {