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