Files
goldenChart/backend/src/main/java/com/goldenchart/service/PortfolioLedger.java
T
2026-06-08 09:10:17 +09:00

328 lines
11 KiB
Java

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;
double cash;
double shares;
/** 현재 보유분 취득원가(수수료 포함) */
double costBasis;
double realizedPnl;
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) {
return REALIZED_ONLY.equalsIgnoreCase(raw) ? REALIZED_ONLY : MARK_TO_MARKET;
}
boolean hasPosition() {
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;
}
double resolveFinalEquity(double lastMarkPrice) {
if (REALIZED_ONLY.equals(analysisMethod)) {
return initialCapital + realizedPnl;
}
return cash + shares * lastMarkPrice;
}
/**
* 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 0.0;
double maxSpend = cash;
orderAmount = Math.min(orderAmount, maxSpend / (1 + commRate));
if (orderAmount <= 0) return 0.0;
double sharesToBuy = orderAmount / effEntry;
double totalCost = sharesToBuy * effEntry * (1 + commRate);
if (totalCost > cash + 1e-6) {
sharesToBuy = cash / (effEntry * (1 + commRate));
totalCost = sharesToBuy * effEntry * (1 + commRate);
}
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)
* @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 0.0;
double commRate = commissionRate();
double proceeds = sharesToSell * effExit * (1 - commRate);
double costPortion = costBasis * (sharesToSell / shares);
double pnl = proceeds - costPortion;
cash += proceeds;
realizedPnl += pnl;
if (pnl >= 0) grossProfit += pnl;
else grossLoss += pnl;
openRoundTripPnl += pnl;
shares -= sharesToSell;
costBasis -= costPortion;
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) {
double tradeSizePct = cfg.getTradeSizeValue() != null
? cfg.getTradeSizeValue().doubleValue() / 100.0 : 1.0;
if ("FIXED_AMOUNT".equals(cfg.getTradeSizeType())) {
double fixed = cfg.getTradeSizeValue() != null ? cfg.getTradeSizeValue().doubleValue() : 0;
return Math.min(fixed, cash);
}
double equity = portfolioValue(markPrice);
return equity * tradeSizePct;
}
private double commissionRate() {
if ("ZERO".equals(cfg.getCommissionType())) return 0.0;
return cfg.getCommissionRate() != null ? cfg.getCommissionRate().doubleValue() : 0.0015;
}
}