투자분석 레포트 로직 수정
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user