126 lines
4.1 KiB
Java
126 lines
4.1 KiB
Java
package com.goldenchart.service;
|
|
|
|
import com.goldenchart.dto.BacktestSettingsDto;
|
|
|
|
/**
|
|
* 표준 주식 프로그램 방식의 포트폴리오 회계.
|
|
* <ul>
|
|
* <li>MARK_TO_MARKET: 예수금 + 보유주식 시가평가 (평가손익 포함)</li>
|
|
* <li>REALIZED_ONLY: 청산 완료 실현손익만 반영 (기말 미청산 평가 제외)</li>
|
|
* </ul>
|
|
*/
|
|
final class PortfolioLedger {
|
|
|
|
static final String MARK_TO_MARKET = "MARK_TO_MARKET";
|
|
static final String REALIZED_ONLY = "REALIZED_ONLY";
|
|
|
|
final double initialCapital;
|
|
final String analysisMethod;
|
|
final BacktestSettingsDto cfg;
|
|
|
|
double cash;
|
|
double shares;
|
|
/** 현재 보유분 취득원가(수수료 포함) */
|
|
double costBasis;
|
|
double realizedPnl;
|
|
double grossProfit;
|
|
double grossLoss;
|
|
|
|
PortfolioLedger(double initialCapital, BacktestSettingsDto cfg) {
|
|
this.initialCapital = initialCapital;
|
|
this.cfg = cfg;
|
|
this.analysisMethod = normalizeMethod(cfg.getAnalysisMethod());
|
|
this.cash = initialCapital;
|
|
}
|
|
|
|
static String normalizeMethod(String raw) {
|
|
return REALIZED_ONLY.equalsIgnoreCase(raw) ? REALIZED_ONLY : MARK_TO_MARKET;
|
|
}
|
|
|
|
boolean hasPosition() {
|
|
return shares > 1e-12;
|
|
}
|
|
|
|
/** 평가금액 = 예수금 + 보유주식 평가액 */
|
|
double portfolioValue(double markPrice) {
|
|
return cash + shares * 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 매수 체결 */
|
|
void executeBuy(double effEntry, double markPriceForSizing) {
|
|
if (effEntry <= 0 || cash <= 0) return;
|
|
double commRate = commissionRate();
|
|
double orderAmount = computeOrderAmount(markPriceForSizing);
|
|
if (orderAmount <= 0) return;
|
|
|
|
double maxSpend = cash;
|
|
orderAmount = Math.min(orderAmount, maxSpend / (1 + commRate));
|
|
if (orderAmount <= 0) return;
|
|
|
|
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;
|
|
|
|
cash -= totalCost;
|
|
shares += sharesToBuy;
|
|
costBasis += totalCost;
|
|
}
|
|
|
|
/** LONG 매도 체결 — sellFraction: 0~1 (전량=1) */
|
|
void executeSell(double effExit, double sellFraction) {
|
|
if (effExit <= 0 || shares <= 1e-12) return;
|
|
double fraction = Math.max(0.0, Math.min(1.0, sellFraction));
|
|
double sharesToSell = shares * fraction;
|
|
if (sharesToSell <= 1e-12) return;
|
|
|
|
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;
|
|
|
|
shares -= sharesToSell;
|
|
costBasis -= costPortion;
|
|
if (shares <= 1e-12) {
|
|
shares = 0;
|
|
costBasis = 0;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|