상세투자분석 계산로직 수정

This commit is contained in:
Macbook
2026-06-07 15:12:42 +09:00
parent c4365283cf
commit e1cf2ea46d
14 changed files with 366 additions and 58 deletions
@@ -11,6 +11,16 @@ public class BacktestAnalysisDto {
// ── 기본 자본 ───────────────────────────────────────────────────────────── // ── 기본 자본 ─────────────────────────────────────────────────────────────
double initialCapital; double initialCapital;
double finalEquity; double finalEquity;
/** 적용된 투자분석 방식 */
String analysisMethod;
/** 기말 예수금 */
double cashBalance;
/** 기말 보유주식 평가액 */
double holdingsValue;
/** 실현 손익 (청산 완료) */
double realizedPnl;
/** 평가 손익 (미청산 보유분) */
double unrealizedPnl;
// ── 수익성 지표 ────────────────────────────────────────────────────────── // ── 수익성 지표 ──────────────────────────────────────────────────────────
/** 총 수익률 (소수, e.g. 0.152 = +15.2%) */ /** 총 수익률 (소수, e.g. 0.152 = +15.2%) */
@@ -99,4 +99,12 @@ public class BacktestSettingsDto {
/** 분할 청산 비율 (%) */ /** 분할 청산 비율 (%) */
@Builder.Default @Builder.Default
private BigDecimal partialExitPct = new BigDecimal("50.000"); private BigDecimal partialExitPct = new BigDecimal("50.000");
// ── 투자분석 방식 ──────────────────────────────────────────────────────────
/**
* MARK_TO_MARKET: 예수금 + 보유주식 시가평가 (표준 HTS 방식, 기본값)
* REALIZED_ONLY: 청산 완료 실현손익만 반영
*/
@Builder.Default
private String analysisMethod = "MARK_TO_MARKET";
} }
@@ -119,6 +119,11 @@ public class GcBacktestSettings {
@Builder.Default @Builder.Default
private BigDecimal partialExitPct = new BigDecimal("50.000"); private BigDecimal partialExitPct = new BigDecimal("50.000");
/** MARK_TO_MARKET | REALIZED_ONLY */
@Column(name = "analysis_method", nullable = false, length = 32)
@Builder.Default
private String analysisMethod = "MARK_TO_MARKET";
@Column(name = "created_at", nullable = false, updatable = false) @Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;
@@ -50,6 +50,10 @@ public class BacktestSettingsService {
entity.setPartialExitPct(dto.getPartialExitPct()); entity.setPartialExitPct(dto.getPartialExitPct());
entity.setPositionMode( entity.setPositionMode(
"SIGNAL_ONLY".equals(dto.getPositionMode()) ? "SIGNAL_ONLY" : "LONG_ONLY"); "SIGNAL_ONLY".equals(dto.getPositionMode()) ? "SIGNAL_ONLY" : "LONG_ONLY");
entity.setAnalysisMethod(
PortfolioLedger.REALIZED_ONLY.equals(dto.getAnalysisMethod())
? PortfolioLedger.REALIZED_ONLY
: PortfolioLedger.MARK_TO_MARKET);
return toDto(repo.save(entity)); return toDto(repo.save(entity));
} }
@@ -79,6 +83,7 @@ public class BacktestSettingsService {
.partialExitEnabled(e.getPartialExitEnabled()) .partialExitEnabled(e.getPartialExitEnabled())
.partialExitPct(e.getPartialExitPct()) .partialExitPct(e.getPartialExitPct())
.positionMode(e.getPositionMode() != null ? e.getPositionMode() : "LONG_ONLY") .positionMode(e.getPositionMode() != null ? e.getPositionMode() : "LONG_ONLY")
.analysisMethod(e.getAnalysisMethod() != null ? e.getAnalysisMethod() : PortfolioLedger.MARK_TO_MARKET)
.build(); .build();
} }
} }
@@ -145,6 +145,8 @@ public class BacktestingService {
double partialPct = cfg.getPartialExitPct() != null ? cfg.getPartialExitPct().doubleValue() / 100.0 : 0.5; double partialPct = cfg.getPartialExitPct() != null ? cfg.getPartialExitPct().doubleValue() / 100.0 : 0.5;
boolean partialDone = false; boolean partialDone = false;
boolean useLedger = "LONG".equals(direction);
PortfolioLedger ledger = useLedger ? new PortfolioLedger(initCap, cfg) : null;
double equity = initCap; double equity = initCap;
final double[] simGrossProfit = {0.0}; final double[] simGrossProfit = {0.0};
final double[] simGrossLoss = {0.0}; final double[] simGrossLoss = {0.0};
@@ -168,12 +170,15 @@ public class BacktestingService {
.time(time).type(sigType).price(effEntry).barIndex(i).build()); .time(time).type(sigType).price(effEntry).barIndex(i).build());
// 실제 포지션 추적은 LONG_ONLY 모드와 동일하게 유지 (수익 계산용) // 실제 포지션 추적은 LONG_ONLY 모드와 동일하게 유지 (수익 계산용)
if (!inPosition) { if (!inPosition) {
double shares = (equity * tradeSizePct) / effEntry; double shares = enterPosition(ledger, useLedger, equity, tradeSizePct,
record.enter(i, series.numFactory().numOf(effEntry), series.numFactory().numOf(shares)); effEntry, closePrice, record, i, series);
entryPrice = effEntry; if (shares > 0) {
entryBarIdx = i; entryPrice = effEntry;
inPosition = true; entryBarIdx = i;
partialDone = false; inPosition = true;
partialDone = false;
if (useLedger) equity = ledger.portfolioValue(closePrice);
}
} }
} else if (exitOk) { } else if (exitOk) {
double effExit = applySlippage(exitPrice, cfg, false); double effExit = applySlippage(exitPrice, cfg, false);
@@ -182,13 +187,9 @@ public class BacktestingService {
.time(time).type(sigType).price(effExit).barIndex(i).build()); .time(time).type(sigType).price(effExit).barIndex(i).build());
// 수익 계산: 실제 포지션이 있을 때만 // 수익 계산: 실제 포지션이 있을 때만
if (inPosition) { if (inPosition) {
double commission = calcCommissionRate(cfg) * 2;
double rawReturn = "SHORT".equals(direction)
? (entryPrice - effExit) / entryPrice
: (effExit - entryPrice) / entryPrice;
double size = partialDone ? (1.0 - partialPct) : 1.0; double size = partialDone ? (1.0 - partialPct) : 1.0;
equity = applyEquityPnl(equity, tradeSizePct, size, rawReturn - commission, equity = applyExit(ledger, useLedger, equity, tradeSizePct, entryPrice, effExit,
simGrossProfit, simGrossLoss); size, direction, cfg, simGrossProfit, simGrossLoss);
Num numExitPrice = series.numFactory().numOf(effExit); Num numExitPrice = series.numFactory().numOf(effExit);
Num numShares = record.getCurrentPosition().getEntry().getAmount(); Num numShares = record.getCurrentPosition().getEntry().getAmount();
record.exit(i, numExitPrice, numShares); record.exit(i, numExitPrice, numShares);
@@ -210,33 +211,25 @@ public class BacktestingService {
if (doEnter) { if (doEnter) {
double effEntry = applySlippage(closePrice, cfg, true); double effEntry = applySlippage(closePrice, cfg, true);
double shares = (equity * tradeSizePct) / effEntry; double shares = enterPosition(ledger, useLedger, equity, tradeSizePct,
effEntry, closePrice, record, i, series);
Num numPrice = series.numFactory().numOf(effEntry); if (shares > 0) {
Num numShares = series.numFactory().numOf(shares); String sigType = "SHORT".equals(direction) ? "SHORT_ENTRY" : "BUY";
record.enter(i, numPrice, numShares); signals.add(Signal.builder()
.time(time).type(sigType).price(effEntry).barIndex(i).build());
String sigType = "SHORT".equals(direction) ? "SHORT_ENTRY" : "BUY"; entryPrice = effEntry;
signals.add(Signal.builder() entryBarIdx = i;
.time(time).type(sigType).price(effEntry).barIndex(i).build()); inPosition = true;
partialDone = false;
entryPrice = effEntry; if (useLedger) equity = ledger.portfolioValue(closePrice);
entryBarIdx = i; }
inPosition = true;
partialDone = false;
} }
} else { } else {
// 분할 청산: exit 조건 처음 충족 시 일부만 청산 // 분할 청산: exit 조건 처음 충족 시 일부만 청산
if (partialExit && !partialDone && exitRule.isSatisfied(i, record)) { if (partialExit && !partialDone && exitRule.isSatisfied(i, record)) {
double effExit = applySlippage(exitPrice, cfg, false); double effExit = applySlippage(exitPrice, cfg, false);
double partShares = record.getCurrentPosition().getEntry().getAmount().doubleValue() * partialPct; equity = applyExit(ledger, useLedger, equity, tradeSizePct, entryPrice, effExit,
double partReturn = "SHORT".equals(direction) partialPct, direction, cfg, simGrossProfit, simGrossLoss);
? (entryPrice - effExit) / entryPrice
: (effExit - entryPrice) / entryPrice;
double commission = calcCommissionRate(cfg) * 2;
equity = applyEquityPnl(equity, tradeSizePct, partialPct, partReturn - commission,
simGrossProfit, simGrossLoss);
signals.add(Signal.builder() signals.add(Signal.builder()
.time(time).type("PARTIAL_SELL").price(effExit).barIndex(i).build()); .time(time).type("PARTIAL_SELL").price(effExit).barIndex(i).build());
partialDone = true; partialDone = true;
@@ -244,16 +237,10 @@ public class BacktestingService {
} }
if (exitRule.isSatisfied(i, record)) { if (exitRule.isSatisfied(i, record)) {
double effExit = applySlippage(exitPrice, cfg, false); double effExit = applySlippage(exitPrice, cfg, false);
double commission = calcCommissionRate(cfg) * 2;
double rawReturn = "SHORT".equals(direction)
? (entryPrice - effExit) / entryPrice
: (effExit - entryPrice) / entryPrice;
double netReturn = rawReturn - commission;
double size = partialDone ? (1.0 - partialPct) : 1.0; double size = partialDone ? (1.0 - partialPct) : 1.0;
equity = applyEquityPnl(equity, tradeSizePct, size, netReturn, equity = applyExit(ledger, useLedger, equity, tradeSizePct, entryPrice, effExit,
simGrossProfit, simGrossLoss); size, direction, cfg, simGrossProfit, simGrossLoss);
Num numExitPrice = series.numFactory().numOf(effExit); Num numExitPrice = series.numFactory().numOf(effExit);
Num numShares = record.getCurrentPosition().getEntry().getAmount(); Num numShares = record.getCurrentPosition().getEntry().getAmount();
@@ -270,9 +257,15 @@ public class BacktestingService {
} }
} }
double lastMarkPrice = barCount > 0
? series.getBar(barCount - 1).getClosePrice().doubleValue() : 0.0;
double finalEquity = resolveFinalEquity(ledger, useLedger, cfg, initCap, equity, inPosition,
entryPrice, lastMarkPrice, tradeSizePct, partialDone, partialPct, direction,
simGrossProfit, simGrossLoss);
// ── AnalysisCriterion 전체 계산 ─────────────────────────────────────── // ── AnalysisCriterion 전체 계산 ───────────────────────────────────────
BacktestAnalysisDto analysis = calcAnalysis(series, record, cfg, initCap, equity, BacktestAnalysisDto analysis = calcAnalysis(series, record, cfg, initCap, finalEquity,
simGrossProfit[0], simGrossLoss[0]); ledger, lastMarkPrice, simGrossProfit[0], simGrossLoss[0]);
// ── Stats (하위 호환) ───────────────────────────────────────────────── // ── Stats (하위 호환) ─────────────────────────────────────────────────
Stats stats = toStats(analysis, signals); Stats stats = toStats(analysis, signals);
@@ -292,15 +285,28 @@ public class BacktestingService {
private BacktestAnalysisDto calcAnalysis(BarSeries series, TradingRecord record, private BacktestAnalysisDto calcAnalysis(BarSeries series, TradingRecord record,
BacktestSettingsDto cfg, double initCap, double finalEquity, BacktestSettingsDto cfg, double initCap, double finalEquity,
PortfolioLedger ledger, double lastMarkPrice,
double simGrossProfit, double simGrossLoss) { double simGrossProfit, double simGrossLoss) {
String analysisMethod = ledger != null
? ledger.analysisMethod
: PortfolioLedger.normalizeMethod(cfg.getAnalysisMethod());
double cashBalance = ledger != null ? ledger.cash : 0.0;
double holdingsValue = ledger != null ? ledger.shares * lastMarkPrice : 0.0;
double realizedPnl = ledger != null ? ledger.realizedPnl : (simGrossProfit + simGrossLoss);
double unrealizedPnl = ledger != null ? ledger.unrealizedPnl(lastMarkPrice) : 0.0;
double grossProfit = ledger != null ? ledger.grossProfit : simGrossProfit;
double grossLoss = ledger != null ? ledger.grossLoss : simGrossLoss;
BacktestAnalysisDto.BacktestAnalysisDtoBuilder b = BacktestAnalysisDto.builder() BacktestAnalysisDto.BacktestAnalysisDtoBuilder b = BacktestAnalysisDto.builder()
.initialCapital(initCap) .initialCapital(initCap)
.finalEquity(finalEquity); .finalEquity(finalEquity)
.analysisMethod(analysisMethod)
.cashBalance(cashBalance)
.holdingsValue(holdingsValue)
.realizedPnl(realizedPnl)
.unrealizedPnl(unrealizedPnl);
// equity 시뮬레이션 기준 총 수익률 — Ta4j criterion/fallback 은 개별 거래 수익률 합산으로 부정확
double totalReturnPct = initCap > 0 ? (finalEquity - initCap) / initCap : 0.0; double totalReturnPct = initCap > 0 ? (finalEquity - initCap) / initCap : 0.0;
double grossProfit = simGrossProfit;
double grossLoss = simGrossLoss;
double avgReturnPct = 0.0; double avgReturnPct = 0.0;
double profitLossRatio = 0.0; double profitLossRatio = 0.0;
@@ -372,10 +378,66 @@ public class BacktestingService {
// ── 개별 Criterion 계산 헬퍼 ───────────────────────────────────────────── // ── 개별 Criterion 계산 헬퍼 ─────────────────────────────────────────────
/** private double enterPosition(PortfolioLedger ledger, boolean useLedger,
* equity 시뮬레이션에 체결 손익을 반영하고, 총 이익/총 손실 누적값을 갱신한다. double equity, double tradeSizePct, double effEntry, double markPrice,
* @return 갱신된 equity BaseTradingRecord record, int barIndex, BarSeries series) {
*/ if (useLedger) {
double before = ledger.shares;
ledger.executeBuy(effEntry, markPrice);
double bought = ledger.shares - before;
if (bought <= 1e-12) return 0.0;
record.enter(barIndex, series.numFactory().numOf(effEntry),
series.numFactory().numOf(bought));
return bought;
}
double shares = (equity * tradeSizePct) / effEntry;
if (shares <= 1e-12) return 0.0;
record.enter(barIndex, series.numFactory().numOf(effEntry),
series.numFactory().numOf(shares));
return shares;
}
private double applyExit(PortfolioLedger ledger, boolean useLedger, double equity, double tradeSizePct,
double entryPrice, double effExit, double sellFraction, String direction,
BacktestSettingsDto cfg, double[] grossProfitAcc, double[] grossLossAcc) {
if (useLedger) {
ledger.executeSell(effExit, sellFraction);
return ledger.portfolioValue(effExit);
}
double commission = calcCommissionRate(cfg) * 2;
double rawReturn = "SHORT".equals(direction)
? (entryPrice - effExit) / entryPrice
: (effExit - entryPrice) / entryPrice;
return applyEquityPnl(equity, tradeSizePct, sellFraction, rawReturn - commission,
grossProfitAcc, grossLossAcc);
}
private double resolveFinalEquity(PortfolioLedger ledger, boolean useLedger, BacktestSettingsDto cfg,
double initCap, double equity, boolean inPosition, double entryPrice,
double lastMarkPrice, double tradeSizePct, boolean partialDone,
double partialPct, String direction,
double[] grossProfitAcc, double[] grossLossAcc) {
if (useLedger && ledger != null) {
return ledger.resolveFinalEquity(lastMarkPrice);
}
String method = PortfolioLedger.normalizeMethod(cfg.getAnalysisMethod());
if (inPosition && lastMarkPrice > 0 && entryPrice > 0
&& PortfolioLedger.MARK_TO_MARKET.equals(method)) {
double commission = calcCommissionRate(cfg) * 2;
double rawReturn = "SHORT".equals(direction)
? (entryPrice - lastMarkPrice) / entryPrice
: (lastMarkPrice - entryPrice) / entryPrice;
double size = partialDone ? (1.0 - partialPct) : 1.0;
return applyEquityPnl(equity, tradeSizePct, size, rawReturn - commission,
grossProfitAcc, grossLossAcc);
}
if (PortfolioLedger.REALIZED_ONLY.equals(method)) {
return initCap + grossProfitAcc[0] + grossLossAcc[0];
}
return equity;
}
/** SHORT/레거시: 비율 기반 equity 갱신 */
private double applyEquityPnl(double equity, double tradeSizePct, double size, double netReturn, private double applyEquityPnl(double equity, double tradeSizePct, double size, double netReturn,
double[] grossProfitAcc, double[] grossLossAcc) { double[] grossProfitAcc, double[] grossLossAcc) {
double eqBefore = equity; double eqBefore = equity;
@@ -0,0 +1,125 @@
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;
}
}
@@ -0,0 +1,4 @@
-- 투자분석 방식 설정 (평가손익 포함 / 실현손익만)
ALTER TABLE gc_backtest_settings
ADD COLUMN analysis_method VARCHAR(32) NOT NULL DEFAULT 'MARK_TO_MARKET'
COMMENT '투자분석 방식 (MARK_TO_MARKET | REALIZED_ONLY)';
@@ -20,6 +20,7 @@ import {
WinRateCircle, WinRateCircle,
CompareBar, CompareBar,
} from './shared/analysisDashboardUi'; } from './shared/analysisDashboardUi';
import { analysisMethodLabel } from '../utils/analysisMethodLabels';
const fmtDate = (ts: number) => { const fmtDate = (ts: number) => {
const d = new Date(ts * 1000); const d = new Date(ts * 1000);
@@ -104,6 +105,11 @@ export function BacktestDashboard({
<span className="brd-dash-badge">{timeframe}</span> <span className="brd-dash-badge">{timeframe}</span>
<span className="brd-dash-badge">{periodStr}</span> <span className="brd-dash-badge">{periodStr}</span>
{createdAt && <span className="brd-dash-badge brd-dash-badge--time"> {fmtDateStr(createdAt)}</span>} {createdAt && <span className="brd-dash-badge brd-dash-badge--time"> {fmtDateStr(createdAt)}</span>}
{a.analysisMethod && (
<span className="brd-dash-badge brd-dash-badge--method">
{analysisMethodLabel(a.analysisMethod)}
</span>
)}
</div> </div>
</div> </div>
<div className={`brd-dash-header-kpi${reportMode ? ' brd-dash-header-kpi--report' : ''}`}> <div className={`brd-dash-header-kpi${reportMode ? ' brd-dash-header-kpi--report' : ''}`}>
@@ -141,6 +147,18 @@ export function BacktestDashboard({
<div className="brd-card-body"> <div className="brd-card-body">
<MetricRow label="총 수익률" value={pct(a.totalReturnPct)} cls={colorCls(a.totalReturnPct)}/> <MetricRow label="총 수익률" value={pct(a.totalReturnPct)} cls={colorCls(a.totalReturnPct)}/>
<MetricRow label="총 손익 (금액)" value={wonFmt(a.totalProfitLoss)} cls={colorCls(a.totalProfitLoss)}/> <MetricRow label="총 손익 (금액)" value={wonFmt(a.totalProfitLoss)} cls={colorCls(a.totalProfitLoss)}/>
{a.realizedPnl != null && (
<MetricRow label="실현 손익" value={wonFmt(a.realizedPnl)} cls={colorCls(a.realizedPnl)} />
)}
{a.unrealizedPnl != null && a.unrealizedPnl !== 0 && (
<MetricRow label="평가 손익" value={wonFmt(a.unrealizedPnl)} cls={colorCls(a.unrealizedPnl)} />
)}
{a.cashBalance != null && a.cashBalance > 0 && (
<MetricRow label="기말 예수금" value={wonFmt(a.cashBalance)} />
)}
{a.holdingsValue != null && a.holdingsValue > 0 && (
<MetricRow label="보유 평가액" value={wonFmt(a.holdingsValue)} />
)}
<MetricRow label="총 이익" value={wonFmt(a.grossProfit)} cls="brd-pos"/> <MetricRow label="총 이익" value={wonFmt(a.grossProfit)} cls="brd-pos"/>
<MetricRow label="총 손실" value={wonFmt(a.grossLoss)} cls="brd-neg"/> <MetricRow label="총 손실" value={wonFmt(a.grossLoss)} cls="brd-neg"/>
<MetricRow label="평균 수익률/거래" value={pct(a.avgReturnPct)} cls={colorCls(a.avgReturnPct)}/> <MetricRow label="평균 수익률/거래" value={pct(a.avgReturnPct)} cls={colorCls(a.avgReturnPct)}/>
@@ -15,6 +15,11 @@ interface FieldMeta {
} }
const FIELD_META: Partial<Record<keyof BacktestSettingsDto, FieldMeta>> = { const FIELD_META: Partial<Record<keyof BacktestSettingsDto, FieldMeta>> = {
analysisMethod: {
label: '투자분석 방식',
description:
'수익률·손익 산정 방식을 선택합니다.\n• 평가손익 포함(표준): 예수금 + 보유주식 시가평가. HTS 평가금액과 동일합니다.\n• 실현손익만: 청산 완료 거래의 실현손익만 반영합니다.',
},
initialCapital: { initialCapital: {
label: '초기 자본', label: '초기 자본',
description: description:
@@ -129,6 +134,18 @@ interface SettingSection {
} }
const SECTIONS: SettingSection[] = [ const SECTIONS: SettingSection[] = [
{
title: '📊 투자분석 방식',
fields: [
{
key: 'analysisMethod', type: 'select',
opts: [
{ value: 'MARK_TO_MARKET', label: '평가손익 포함 (표준)' },
{ value: 'REALIZED_ONLY', label: '실현손익만' },
],
},
],
},
{ {
title: '💰 자본 설정', title: '💰 자본 설정',
fields: [ fields: [
@@ -8,6 +8,7 @@ import {
loadBacktestSettings, loadBacktestSettings,
saveBacktestSettings, saveBacktestSettings,
} from '../utils/backendApi'; } from '../utils/backendApi';
import { ANALYSIS_METHOD_OPTIONS } from '../utils/analysisMethodLabels';
// ── 백테스팅 전용 UI 컴포넌트 ─────────────────────────────────────────────── // ── 백테스팅 전용 UI 컴포넌트 ───────────────────────────────────────────────
@@ -106,6 +107,26 @@ export const BacktestSettingsPanel: React.FC<BacktestSettingsPanelProps> = ({
</BtGrid> </BtGrid>
</BtSection> </BtSection>
<BtSection title="투자분석 방식">
<BtGrid>
<BtField
label="수익률 · 손익 산정"
desc="백테스팅, 알림 레포트, 분석레포트 등 모든 투자분석 결과에 동일하게 적용됩니다."
full
>
<select
className="stg-select stg-select--wide"
value={cfg.analysisMethod ?? 'MARK_TO_MARKET'}
onChange={e => field('analysisMethod', e.target.value as 'MARK_TO_MARKET' | 'REALIZED_ONLY')}
>
{ANALYSIS_METHOD_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</BtField>
</BtGrid>
</BtSection>
<BtSection title="자본 · 거래 규모"> <BtSection title="자본 · 거래 규모">
<BtGrid> <BtGrid>
<BtField <BtField
@@ -6,7 +6,7 @@ import type { Theme, TradeOrderFillRequest } from '../types';
import type { TickerData } from '../hooks/useMarketTicker'; import type { TickerData } from '../hooks/useMarketTicker';
import { useTradeNotification, type TradeNotificationItem } from '../contexts/TradeNotificationContext'; import { useTradeNotification, type TradeNotificationItem } from '../contexts/TradeNotificationContext';
import { import {
DEFAULT_BACKTEST_SETTINGS, loadBacktestSettings,
loadPaperSummary, loadPaperSummary,
loadStrategy, loadStrategy,
runBacktest, runBacktest,
@@ -271,6 +271,7 @@ export const TradeNotificationListPage: React.FC<Props> = ({
} }
const strategyName = strategy.name || item.strategyName || '전략'; const strategyName = strategy.name || item.strategyName || '전략';
const btSettings = await loadBacktestSettings();
const res = await runBacktest({ const res = await runBacktest({
strategyId: item.strategyId, strategyId: item.strategyId,
bars: bars.map(b => ({ bars: bars.map(b => ({
@@ -284,7 +285,7 @@ export const TradeNotificationListPage: React.FC<Props> = ({
timeframe, timeframe,
symbol: market, symbol: market,
strategyName, strategyName,
settings: DEFAULT_BACKTEST_SETTINGS, settings: btSettings,
indicatorParams: Object.fromEntries( indicatorParams: Object.fromEntries(
REPORT_INDICATOR_TYPES.map(t => [t, getParams(t) as Record<string, unknown>]), REPORT_INDICATOR_TYPES.map(t => [t, getParams(t) as Record<string, unknown>]),
), ),
@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import BacktestAnalysisChart from '../backtest/BacktestAnalysisChart'; import BacktestAnalysisChart from '../backtest/BacktestAnalysisChart';
import { import {
DEFAULT_BACKTEST_SETTINGS, loadBacktestSettings,
loadStrategy, loadStrategy,
runBacktest, runBacktest,
type BacktestSignal, type BacktestSignal,
@@ -82,6 +82,7 @@ const PaperAnalysisChart: React.FC<Props> = ({ market, strategyId, theme = 'dark
setRunning(true); setRunning(true);
setError(null); setError(null);
try { try {
const btSettings = await loadBacktestSettings();
const res = await runBacktest({ const res = await runBacktest({
strategyId: sid, strategyId: sid,
bars: bars.map(b => ({ bars: bars.map(b => ({
@@ -95,7 +96,7 @@ const PaperAnalysisChart: React.FC<Props> = ({ market, strategyId, theme = 'dark
timeframe: tf, timeframe: tf,
symbol: sym, symbol: sym,
strategyName: strat.name, strategyName: strat.name,
settings: DEFAULT_BACKTEST_SETTINGS, settings: btSettings,
indicatorParams: Object.fromEntries( indicatorParams: Object.fromEntries(
['RSI', 'MACD', 'CCI', 'SMA', 'EMA', 'IchimokuCloud', 'Stochastic', 'ADX', 'MFI', 'NewPsychological'].map(t => [ ['RSI', 'MACD', 'CCI', 'SMA', 'EMA', 'IchimokuCloud', 'Stochastic', 'ADX', 'MFI', 'NewPsychological'].map(t => [
t, t,
@@ -0,0 +1,23 @@
export type AnalysisMethod = 'MARK_TO_MARKET' | 'REALIZED_ONLY';
export const ANALYSIS_METHOD_OPTIONS: { value: AnalysisMethod; label: string; desc: string }[] = [
{
value: 'MARK_TO_MARKET',
label: '평가손익 포함 (표준)',
desc: '예수금 + 보유주식 시가평가. HTS·MTS 평가금액과 동일한 방식으로 수익률을 계산합니다.',
},
{
value: 'REALIZED_ONLY',
label: '실현손익만',
desc: '청산 완료된 거래의 실현손익만 반영합니다. 기말 미청산 포지션은 수익률에 포함하지 않습니다.',
},
];
export function normalizeAnalysisMethod(raw?: string | null): AnalysisMethod {
return raw === 'REALIZED_ONLY' ? 'REALIZED_ONLY' : 'MARK_TO_MARKET';
}
export function analysisMethodLabel(method?: string | null): string {
const m = normalizeAnalysisMethod(method);
return ANALYSIS_METHOD_OPTIONS.find(o => o.value === m)?.label ?? '평가손익 포함 (표준)';
}
+8
View File
@@ -1151,6 +1151,11 @@ export interface BacktestStats {
export interface BacktestAnalysis { export interface BacktestAnalysis {
initialCapital: number; initialCapital: number;
finalEquity: number; finalEquity: number;
analysisMethod?: 'MARK_TO_MARKET' | 'REALIZED_ONLY';
cashBalance?: number;
holdingsValue?: number;
realizedPnl?: number;
unrealizedPnl?: number;
totalReturnPct: number; totalReturnPct: number;
totalProfitLoss: number; totalProfitLoss: number;
grossProfit: number; grossProfit: number;
@@ -1230,6 +1235,8 @@ export interface BacktestSettingsDto {
partialExitPct: number; partialExitPct: number;
/** "LONG_ONLY" | "SIGNAL_ONLY" — 매도 시그널 포지션 종속성 모드 */ /** "LONG_ONLY" | "SIGNAL_ONLY" — 매도 시그널 포지션 종속성 모드 */
positionMode?: 'LONG_ONLY' | 'SIGNAL_ONLY'; positionMode?: 'LONG_ONLY' | 'SIGNAL_ONLY';
/** MARK_TO_MARKET | REALIZED_ONLY — 투자분석 방식 */
analysisMethod?: 'MARK_TO_MARKET' | 'REALIZED_ONLY';
} }
export const DEFAULT_BACKTEST_SETTINGS: BacktestSettingsDto = { export const DEFAULT_BACKTEST_SETTINGS: BacktestSettingsDto = {
@@ -1253,6 +1260,7 @@ export const DEFAULT_BACKTEST_SETTINGS: BacktestSettingsDto = {
partialExitEnabled: false, partialExitEnabled: false,
partialExitPct: 50, partialExitPct: 50,
positionMode: 'LONG_ONLY', positionMode: 'LONG_ONLY',
analysisMethod: 'MARK_TO_MARKET',
}; };
/** 백테스팅 설정 로드 */ /** 백테스팅 설정 로드 */