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; double cash; double shares; /** 현재 보유분 취득원가(수수료 포함) */ double costBasis; double realizedPnl; 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) { return REALIZED_ONLY.equalsIgnoreCase(raw) ? REALIZED_ONLY : MARK_TO_MARKET; } boolean hasPosition() { 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; } 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 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; } }