328 lines
11 KiB
Java
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;
|
|
}
|
|
}
|