분석레포트 승률 로직 수정

This commit is contained in:
Macbook
2026-06-08 09:29:42 +09:00
parent d4f0105b5a
commit 7c9e1843ea
2 changed files with 121 additions and 12 deletions
@@ -277,9 +277,19 @@ public class BacktestingService {
entryPrice, lastMarkPrice, tradeSizePct, partialDone, partialPct, direction, entryPrice, lastMarkPrice, tradeSizePct, partialDone, partialPct, direction,
simGrossProfit, simGrossLoss); simGrossProfit, simGrossLoss);
// 체결(ledger) 기준 거래 통계 — Ta4j TradingRecord와 분리
PortfolioLedger.TradeStats executedStats = ledger != null ? ledger.tradeStats() : null;
if (executedStats != null && executedStats.closedCount > 0) {
int ta4jWins = countTa4jWinningPositions(record);
if (ta4jWins != executedStats.winning) {
log.debug("[Backtest] 승률: ledger 순손익 {}승 (Ta4j 가격기준 {}승) — ledger 기준 적용",
executedStats.winning, ta4jWins);
}
}
// ── AnalysisCriterion 전체 계산 ─────────────────────────────────────── // ── AnalysisCriterion 전체 계산 ───────────────────────────────────────
BacktestAnalysisDto analysis = calcAnalysis(series, record, cfg, initCap, finalEquity, BacktestAnalysisDto analysis = calcAnalysis(series, record, cfg, initCap, finalEquity,
ledger, lastMarkPrice, simGrossProfit[0], simGrossLoss[0]); ledger, executedStats, lastMarkPrice, simGrossProfit[0], simGrossLoss[0]);
// ── Stats (하위 호환) ───────────────────────────────────────────────── // ── Stats (하위 호환) ─────────────────────────────────────────────────
Stats stats = toStats(analysis, signals); Stats stats = toStats(analysis, signals);
@@ -299,7 +309,8 @@ 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, PortfolioLedger ledger, PortfolioLedger.TradeStats executedStats,
double lastMarkPrice,
double simGrossProfit, double simGrossLoss) { double simGrossProfit, double simGrossLoss) {
String analysisMethod = ledger != null String analysisMethod = ledger != null
? ledger.analysisMethod ? ledger.analysisMethod
@@ -335,26 +346,38 @@ public class BacktestingService {
double sortino; double sortino;
double calmar; double calmar;
if (ledger != null) { if (executedStats != null && executedStats.closedCount > 0) {
PortfolioLedger.TradeStats ts = ledger.tradeStats(); // LONG PortfolioLedger: 수수료·슬리피지 반영 실현손익 기준 (실매매와 동일)
positions = ts.closedCount; positions = executedStats.closedCount;
winning = ts.winning; winning = executedStats.winning;
losing = ts.losing; losing = executedStats.losing;
breakEven = ts.breakEven; breakEven = executedStats.breakEven;
winRate = ts.winRate; winRate = executedStats.winRate;
avgReturnPct = ts.avgReturnPct; avgReturnPct = executedStats.avgReturnPct;
maxDrawdown = ledger.maxDrawdownPct(); maxDrawdown = normalizeDrawdownPct(ledger.maxDrawdownPct());
sharpe = ledger.sharpeFromEquityCurve(); sharpe = ledger.sharpeFromEquityCurve();
sortino = safeCalc(() -> calcCriterion( sortino = safeCalc(() -> calcCriterion(
"org.ta4j.core.criteria.SortinoRatioCriterion", series, record)); "org.ta4j.core.criteria.SortinoRatioCriterion", series, record));
calmar = (maxDrawdown != 0) ? totalReturnPct / Math.abs(maxDrawdown) : 0.0; calmar = (maxDrawdown != 0) ? totalReturnPct / Math.abs(maxDrawdown) : 0.0;
} else if (ledger != null) {
// ledger 있으나 청산 0건 — Ta4j 승률 사용하지 않음
positions = 0;
winning = 0;
losing = 0;
breakEven = 0;
winRate = 0.0;
avgReturnPct = 0.0;
maxDrawdown = normalizeDrawdownPct(ledger.maxDrawdownPct());
sharpe = ledger.sharpeFromEquityCurve();
sortino = 0.0;
calmar = 0.0;
} else { } else {
positions = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfPositionsCriterion", series, record)); positions = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfPositionsCriterion", series, record));
winning = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfWinningPositionsCriterion", series, record)); winning = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfWinningPositionsCriterion", series, record));
losing = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfLosingPositionsCriterion", series, record)); losing = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfLosingPositionsCriterion", series, record));
breakEven = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfBreakEvenPositionsCriterion", series, record)); breakEven = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfBreakEvenPositionsCriterion", series, record));
winRate = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.WinningPositionsRatioCriterion", series, record)); winRate = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.WinningPositionsRatioCriterion", series, record));
maxDrawdown = safeCalc(() -> calcMaxDrawdown(series, record)); maxDrawdown = normalizeDrawdownPct(safeCalc(() -> calcMaxDrawdown(series, record)));
sharpe = safeCalc(() -> calcSharpeRatio(series, record)); sharpe = safeCalc(() -> calcSharpeRatio(series, record));
sortino = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.SortinoRatioCriterion", series, record)); sortino = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.SortinoRatioCriterion", series, record));
calmar = (maxDrawdown != 0) ? totalReturnPct / Math.abs(maxDrawdown) : 0.0; calmar = (maxDrawdown != 0) ? totalReturnPct / Math.abs(maxDrawdown) : 0.0;
@@ -415,6 +438,20 @@ public class BacktestingService {
.build(); .build();
} }
/** MDD는 음수(낙폭)로 통일 */
private static double normalizeDrawdownPct(double dd) {
if (dd == 0.0) return 0.0;
return dd > 0 ? -dd : dd;
}
/** Ta4j 가격 기준 승수 (디버그·비교용, 레포트에는 미사용) */
private static int countTa4jWinningPositions(TradingRecord record) {
if (record == null) return 0;
return (int) record.getPositions().stream()
.filter(p -> p.isClosed() && p.getProfit().isPositive())
.count();
}
private static Signal buildFillSignal(long time, String type, double price, int barIndex, double quantity) { private static Signal buildFillSignal(long time, String type, double price, int barIndex, double quantity) {
return Signal.builder() return Signal.builder()
.time(time) .time(time)
@@ -0,0 +1,72 @@
package com.goldenchart.service;
import com.goldenchart.dto.BacktestSettingsDto;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* 체결(ledger) 순손익 기준 승률·승패 집계 검증.
* 분석레포트_수정.pdf 28시그널 / 14라운드트립 시나리오.
*/
class PortfolioLedgerTradeStatsTest {
private static final double INIT = 10_000_000.0;
private PortfolioLedger newLedger() {
BacktestSettingsDto cfg = BacktestSettingsDto.builder()
.initialCapital(BigDecimal.valueOf(INIT))
.commissionType("LINEAR")
.commissionRate(new BigDecimal("0.00150"))
.slippageRate(new BigDecimal("0.00050"))
.tradeSizeType("CAPITAL_PCT")
.tradeSizeValue(new BigDecimal("100"))
.analysisMethod("MARK_TO_MARKET")
.build();
return new PortfolioLedger(INIT, cfg);
}
@Test
void pdfScenario_netPnlWinLoss_afterFees() {
double[][] pairs = {
{93254604, 92806574},
{92876415, 92203875},
{91648802, 92324815},
{92723339, 93019467},
{92258106, 92293830},
{92507231, 92150902},
{92463209, 91909023},
{92117036, 92075939},
{92298126, 92062946},
{92253104, 92280837},
{92306130, 92359797},
{93446700, 93045454},
{93458706, 93657148},
{94142048, 93835059},
};
PortfolioLedger ledger = newLedger();
long t = 1_700_000_000L;
int bar = 0;
for (double[] pair : pairs) {
// PDF 시그널 가격 = 슬리피지 반영 체결가
ledger.executeBuy(pair[0], pair[0], t + bar, bar);
bar += 8;
ledger.executeSell(pair[1], 1.0, t + bar, bar);
bar += 8;
}
PortfolioLedger.TradeStats ts = ledger.tradeStats();
assertEquals(14, ts.closedCount);
assertEquals(2, ts.winning, "수수료 반영 순손익 기준 2승");
assertEquals(12, ts.losing, "수수료 반영 순손익 기준 12패");
assertEquals(0, ts.breakEven);
assertEquals(2.0 / 14.0, ts.winRate, 1e-9);
double finalEq = ledger.resolveFinalEquity(pairs[pairs.length - 1][1]);
assertEquals(-0.0588, (finalEq - INIT) / INIT, 0.002);
assertEquals(ledger.grossProfit + ledger.grossLoss, ledger.realizedPnl, 1.0);
}
}