diff --git a/backend/src/main/java/com/goldenchart/dto/BacktestAnalysisDto.java b/backend/src/main/java/com/goldenchart/dto/BacktestAnalysisDto.java index 349c3a8..7420665 100644 --- a/backend/src/main/java/com/goldenchart/dto/BacktestAnalysisDto.java +++ b/backend/src/main/java/com/goldenchart/dto/BacktestAnalysisDto.java @@ -11,6 +11,16 @@ public class BacktestAnalysisDto { // ── 기본 자본 ───────────────────────────────────────────────────────────── double initialCapital; double finalEquity; + /** 적용된 투자분석 방식 */ + String analysisMethod; + /** 기말 예수금 */ + double cashBalance; + /** 기말 보유주식 평가액 */ + double holdingsValue; + /** 실현 손익 (청산 완료) */ + double realizedPnl; + /** 평가 손익 (미청산 보유분) */ + double unrealizedPnl; // ── 수익성 지표 ────────────────────────────────────────────────────────── /** 총 수익률 (소수, e.g. 0.152 = +15.2%) */ diff --git a/backend/src/main/java/com/goldenchart/dto/BacktestSettingsDto.java b/backend/src/main/java/com/goldenchart/dto/BacktestSettingsDto.java index 4648755..0eac027 100644 --- a/backend/src/main/java/com/goldenchart/dto/BacktestSettingsDto.java +++ b/backend/src/main/java/com/goldenchart/dto/BacktestSettingsDto.java @@ -99,4 +99,12 @@ public class BacktestSettingsDto { /** 분할 청산 비율 (%) */ @Builder.Default private BigDecimal partialExitPct = new BigDecimal("50.000"); + + // ── 투자분석 방식 ────────────────────────────────────────────────────────── + /** + * MARK_TO_MARKET: 예수금 + 보유주식 시가평가 (표준 HTS 방식, 기본값) + * REALIZED_ONLY: 청산 완료 실현손익만 반영 + */ + @Builder.Default + private String analysisMethod = "MARK_TO_MARKET"; } diff --git a/backend/src/main/java/com/goldenchart/entity/GcBacktestSettings.java b/backend/src/main/java/com/goldenchart/entity/GcBacktestSettings.java index 1865fcd..c51214e 100644 --- a/backend/src/main/java/com/goldenchart/entity/GcBacktestSettings.java +++ b/backend/src/main/java/com/goldenchart/entity/GcBacktestSettings.java @@ -119,6 +119,11 @@ public class GcBacktestSettings { @Builder.Default 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) private LocalDateTime createdAt; diff --git a/backend/src/main/java/com/goldenchart/service/BacktestSettingsService.java b/backend/src/main/java/com/goldenchart/service/BacktestSettingsService.java index 8a6b9f5..3f76c70 100644 --- a/backend/src/main/java/com/goldenchart/service/BacktestSettingsService.java +++ b/backend/src/main/java/com/goldenchart/service/BacktestSettingsService.java @@ -50,6 +50,10 @@ public class BacktestSettingsService { entity.setPartialExitPct(dto.getPartialExitPct()); entity.setPositionMode( "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)); } @@ -79,6 +83,7 @@ public class BacktestSettingsService { .partialExitEnabled(e.getPartialExitEnabled()) .partialExitPct(e.getPartialExitPct()) .positionMode(e.getPositionMode() != null ? e.getPositionMode() : "LONG_ONLY") + .analysisMethod(e.getAnalysisMethod() != null ? e.getAnalysisMethod() : PortfolioLedger.MARK_TO_MARKET) .build(); } } diff --git a/backend/src/main/java/com/goldenchart/service/BacktestingService.java b/backend/src/main/java/com/goldenchart/service/BacktestingService.java index 6936a86..a8e91ed 100644 --- a/backend/src/main/java/com/goldenchart/service/BacktestingService.java +++ b/backend/src/main/java/com/goldenchart/service/BacktestingService.java @@ -145,6 +145,8 @@ public class BacktestingService { double partialPct = cfg.getPartialExitPct() != null ? cfg.getPartialExitPct().doubleValue() / 100.0 : 0.5; boolean partialDone = false; + boolean useLedger = "LONG".equals(direction); + PortfolioLedger ledger = useLedger ? new PortfolioLedger(initCap, cfg) : null; double equity = initCap; final double[] simGrossProfit = {0.0}; final double[] simGrossLoss = {0.0}; @@ -168,12 +170,15 @@ public class BacktestingService { .time(time).type(sigType).price(effEntry).barIndex(i).build()); // 실제 포지션 추적은 LONG_ONLY 모드와 동일하게 유지 (수익 계산용) if (!inPosition) { - double shares = (equity * tradeSizePct) / effEntry; - record.enter(i, series.numFactory().numOf(effEntry), series.numFactory().numOf(shares)); - entryPrice = effEntry; - entryBarIdx = i; - inPosition = true; - partialDone = false; + double shares = enterPosition(ledger, useLedger, equity, tradeSizePct, + effEntry, closePrice, record, i, series); + if (shares > 0) { + entryPrice = effEntry; + entryBarIdx = i; + inPosition = true; + partialDone = false; + if (useLedger) equity = ledger.portfolioValue(closePrice); + } } } else if (exitOk) { double effExit = applySlippage(exitPrice, cfg, false); @@ -182,13 +187,9 @@ public class BacktestingService { .time(time).type(sigType).price(effExit).barIndex(i).build()); // 수익 계산: 실제 포지션이 있을 때만 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; - equity = applyEquityPnl(equity, tradeSizePct, size, rawReturn - commission, - simGrossProfit, simGrossLoss); + equity = applyExit(ledger, useLedger, equity, tradeSizePct, entryPrice, effExit, + size, direction, cfg, simGrossProfit, simGrossLoss); Num numExitPrice = series.numFactory().numOf(effExit); Num numShares = record.getCurrentPosition().getEntry().getAmount(); record.exit(i, numExitPrice, numShares); @@ -210,33 +211,25 @@ public class BacktestingService { if (doEnter) { double effEntry = applySlippage(closePrice, cfg, true); - double shares = (equity * tradeSizePct) / effEntry; - - Num numPrice = series.numFactory().numOf(effEntry); - Num numShares = series.numFactory().numOf(shares); - record.enter(i, numPrice, numShares); - - String sigType = "SHORT".equals(direction) ? "SHORT_ENTRY" : "BUY"; - signals.add(Signal.builder() - .time(time).type(sigType).price(effEntry).barIndex(i).build()); - - entryPrice = effEntry; - entryBarIdx = i; - inPosition = true; - partialDone = false; + double shares = enterPosition(ledger, useLedger, equity, tradeSizePct, + effEntry, closePrice, record, i, series); + if (shares > 0) { + String sigType = "SHORT".equals(direction) ? "SHORT_ENTRY" : "BUY"; + signals.add(Signal.builder() + .time(time).type(sigType).price(effEntry).barIndex(i).build()); + entryPrice = effEntry; + entryBarIdx = i; + inPosition = true; + partialDone = false; + if (useLedger) equity = ledger.portfolioValue(closePrice); + } } } else { // 분할 청산: exit 조건 처음 충족 시 일부만 청산 if (partialExit && !partialDone && exitRule.isSatisfied(i, record)) { - double effExit = applySlippage(exitPrice, cfg, false); - double partShares = record.getCurrentPosition().getEntry().getAmount().doubleValue() * partialPct; - double partReturn = "SHORT".equals(direction) - ? (entryPrice - effExit) / entryPrice - : (effExit - entryPrice) / entryPrice; - double commission = calcCommissionRate(cfg) * 2; - equity = applyEquityPnl(equity, tradeSizePct, partialPct, partReturn - commission, - simGrossProfit, simGrossLoss); - + double effExit = applySlippage(exitPrice, cfg, false); + equity = applyExit(ledger, useLedger, equity, tradeSizePct, entryPrice, effExit, + partialPct, direction, cfg, simGrossProfit, simGrossLoss); signals.add(Signal.builder() .time(time).type("PARTIAL_SELL").price(effExit).barIndex(i).build()); partialDone = true; @@ -244,16 +237,10 @@ public class BacktestingService { } if (exitRule.isSatisfied(i, record)) { - 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 effExit = applySlippage(exitPrice, cfg, false); double size = partialDone ? (1.0 - partialPct) : 1.0; - equity = applyEquityPnl(equity, tradeSizePct, size, netReturn, - simGrossProfit, simGrossLoss); + equity = applyExit(ledger, useLedger, equity, tradeSizePct, entryPrice, effExit, + size, direction, cfg, simGrossProfit, simGrossLoss); Num numExitPrice = series.numFactory().numOf(effExit); 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 전체 계산 ─────────────────────────────────────── - BacktestAnalysisDto analysis = calcAnalysis(series, record, cfg, initCap, equity, - simGrossProfit[0], simGrossLoss[0]); + BacktestAnalysisDto analysis = calcAnalysis(series, record, cfg, initCap, finalEquity, + ledger, lastMarkPrice, simGrossProfit[0], simGrossLoss[0]); // ── Stats (하위 호환) ───────────────────────────────────────────────── Stats stats = toStats(analysis, signals); @@ -292,15 +285,28 @@ public class BacktestingService { private BacktestAnalysisDto calcAnalysis(BarSeries series, TradingRecord record, BacktestSettingsDto cfg, double initCap, double finalEquity, + PortfolioLedger ledger, double lastMarkPrice, 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() .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 grossProfit = simGrossProfit; - double grossLoss = simGrossLoss; double avgReturnPct = 0.0; double profitLossRatio = 0.0; @@ -372,10 +378,66 @@ public class BacktestingService { // ── 개별 Criterion 계산 헬퍼 ───────────────────────────────────────────── - /** - * equity 시뮬레이션에 체결 손익을 반영하고, 총 이익/총 손실 누적값을 갱신한다. - * @return 갱신된 equity - */ + private double enterPosition(PortfolioLedger ledger, boolean useLedger, + double equity, double tradeSizePct, double effEntry, double markPrice, + 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, double[] grossProfitAcc, double[] grossLossAcc) { double eqBefore = equity; diff --git a/backend/src/main/java/com/goldenchart/service/PortfolioLedger.java b/backend/src/main/java/com/goldenchart/service/PortfolioLedger.java new file mode 100644 index 0000000..060ebcf --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/PortfolioLedger.java @@ -0,0 +1,125 @@ +package com.goldenchart.service; + +import com.goldenchart.dto.BacktestSettingsDto; + +/** + * 표준 주식 프로그램 방식의 포트폴리오 회계. + * + */ +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; + } +} diff --git a/backend/src/main/resources/db/migration/V63__backtest_analysis_method.sql b/backend/src/main/resources/db/migration/V63__backtest_analysis_method.sql new file mode 100644 index 0000000..4d45f55 --- /dev/null +++ b/backend/src/main/resources/db/migration/V63__backtest_analysis_method.sql @@ -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)'; diff --git a/frontend/src/components/BacktestResultModal.tsx b/frontend/src/components/BacktestResultModal.tsx index ec6eb94..f331497 100644 --- a/frontend/src/components/BacktestResultModal.tsx +++ b/frontend/src/components/BacktestResultModal.tsx @@ -20,6 +20,7 @@ import { WinRateCircle, CompareBar, } from './shared/analysisDashboardUi'; +import { analysisMethodLabel } from '../utils/analysisMethodLabels'; const fmtDate = (ts: number) => { const d = new Date(ts * 1000); @@ -104,6 +105,11 @@ export function BacktestDashboard({ {timeframe} {periodStr} {createdAt && 실행 {fmtDateStr(createdAt)}} + {a.analysisMethod && ( + + {analysisMethodLabel(a.analysisMethod)} + + )}
@@ -141,6 +147,18 @@ export function BacktestDashboard({
+ {a.realizedPnl != null && ( + + )} + {a.unrealizedPnl != null && a.unrealizedPnl !== 0 && ( + + )} + {a.cashBalance != null && a.cashBalance > 0 && ( + + )} + {a.holdingsValue != null && a.holdingsValue > 0 && ( + + )} diff --git a/frontend/src/components/BacktestSettingsModal.tsx b/frontend/src/components/BacktestSettingsModal.tsx index 4262bff..b39f795 100644 --- a/frontend/src/components/BacktestSettingsModal.tsx +++ b/frontend/src/components/BacktestSettingsModal.tsx @@ -15,6 +15,11 @@ interface FieldMeta { } const FIELD_META: Partial> = { + analysisMethod: { + label: '투자분석 방식', + description: + '수익률·손익 산정 방식을 선택합니다.\n• 평가손익 포함(표준): 예수금 + 보유주식 시가평가. HTS 평가금액과 동일합니다.\n• 실현손익만: 청산 완료 거래의 실현손익만 반영합니다.', + }, initialCapital: { label: '초기 자본', description: @@ -129,6 +134,18 @@ interface SettingSection { } const SECTIONS: SettingSection[] = [ + { + title: '📊 투자분석 방식', + fields: [ + { + key: 'analysisMethod', type: 'select', + opts: [ + { value: 'MARK_TO_MARKET', label: '평가손익 포함 (표준)' }, + { value: 'REALIZED_ONLY', label: '실현손익만' }, + ], + }, + ], + }, { title: '💰 자본 설정', fields: [ diff --git a/frontend/src/components/BacktestSettingsPanel.tsx b/frontend/src/components/BacktestSettingsPanel.tsx index 9167ceb..1a7d821 100644 --- a/frontend/src/components/BacktestSettingsPanel.tsx +++ b/frontend/src/components/BacktestSettingsPanel.tsx @@ -8,6 +8,7 @@ import { loadBacktestSettings, saveBacktestSettings, } from '../utils/backendApi'; +import { ANALYSIS_METHOD_OPTIONS } from '../utils/analysisMethodLabels'; // ── 백테스팅 전용 UI 컴포넌트 ─────────────────────────────────────────────── @@ -106,6 +107,26 @@ export const BacktestSettingsPanel: React.FC = ({ + + + + + + + + = ({ } const strategyName = strategy.name || item.strategyName || '전략'; + const btSettings = await loadBacktestSettings(); const res = await runBacktest({ strategyId: item.strategyId, bars: bars.map(b => ({ @@ -284,7 +285,7 @@ export const TradeNotificationListPage: React.FC = ({ timeframe, symbol: market, strategyName, - settings: DEFAULT_BACKTEST_SETTINGS, + settings: btSettings, indicatorParams: Object.fromEntries( REPORT_INDICATOR_TYPES.map(t => [t, getParams(t) as Record]), ), diff --git a/frontend/src/components/paper/PaperAnalysisChart.tsx b/frontend/src/components/paper/PaperAnalysisChart.tsx index 21f1289..113e201 100644 --- a/frontend/src/components/paper/PaperAnalysisChart.tsx +++ b/frontend/src/components/paper/PaperAnalysisChart.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import BacktestAnalysisChart from '../backtest/BacktestAnalysisChart'; import { - DEFAULT_BACKTEST_SETTINGS, + loadBacktestSettings, loadStrategy, runBacktest, type BacktestSignal, @@ -82,6 +82,7 @@ const PaperAnalysisChart: React.FC = ({ market, strategyId, theme = 'dark setRunning(true); setError(null); try { + const btSettings = await loadBacktestSettings(); const res = await runBacktest({ strategyId: sid, bars: bars.map(b => ({ @@ -95,7 +96,7 @@ const PaperAnalysisChart: React.FC = ({ market, strategyId, theme = 'dark timeframe: tf, symbol: sym, strategyName: strat.name, - settings: DEFAULT_BACKTEST_SETTINGS, + settings: btSettings, indicatorParams: Object.fromEntries( ['RSI', 'MACD', 'CCI', 'SMA', 'EMA', 'IchimokuCloud', 'Stochastic', 'ADX', 'MFI', 'NewPsychological'].map(t => [ t, diff --git a/frontend/src/utils/analysisMethodLabels.ts b/frontend/src/utils/analysisMethodLabels.ts new file mode 100644 index 0000000..b0e6704 --- /dev/null +++ b/frontend/src/utils/analysisMethodLabels.ts @@ -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 ?? '평가손익 포함 (표준)'; +} diff --git a/frontend/src/utils/backendApi.ts b/frontend/src/utils/backendApi.ts index 8e53e73..04a30dc 100644 --- a/frontend/src/utils/backendApi.ts +++ b/frontend/src/utils/backendApi.ts @@ -1151,6 +1151,11 @@ export interface BacktestStats { export interface BacktestAnalysis { initialCapital: number; finalEquity: number; + analysisMethod?: 'MARK_TO_MARKET' | 'REALIZED_ONLY'; + cashBalance?: number; + holdingsValue?: number; + realizedPnl?: number; + unrealizedPnl?: number; totalReturnPct: number; totalProfitLoss: number; grossProfit: number; @@ -1230,6 +1235,8 @@ export interface BacktestSettingsDto { partialExitPct: number; /** "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 = { @@ -1253,6 +1260,7 @@ export const DEFAULT_BACKTEST_SETTINGS: BacktestSettingsDto = { partialExitEnabled: false, partialExitPct: 50, positionMode: 'LONG_ONLY', + analysisMethod: 'MARK_TO_MARKET', }; /** 백테스팅 설정 로드 */