diff --git a/backend/src/main/java/com/goldenchart/dto/BacktestSettingsDto.java b/backend/src/main/java/com/goldenchart/dto/BacktestSettingsDto.java index 0eac027..45d3eed 100644 --- a/backend/src/main/java/com/goldenchart/dto/BacktestSettingsDto.java +++ b/backend/src/main/java/com/goldenchart/dto/BacktestSettingsDto.java @@ -107,4 +107,11 @@ public class BacktestSettingsDto { */ @Builder.Default private String analysisMethod = "MARK_TO_MARKET"; + + /** + * SCAN_SIGNALS — 조건 스캔(봉 종가·DSL 충족, 차트 마커와 동일) + * BACKTEST_ENGINE — 슬리피지·손절/익절·진입/청산가 등 백테스트 설정 반영 (기본) + */ + @Builder.Default + private String tradeExecutionMode = "BACKTEST_ENGINE"; } diff --git a/backend/src/main/java/com/goldenchart/entity/GcBacktestSettings.java b/backend/src/main/java/com/goldenchart/entity/GcBacktestSettings.java index c51214e..abf2df3 100644 --- a/backend/src/main/java/com/goldenchart/entity/GcBacktestSettings.java +++ b/backend/src/main/java/com/goldenchart/entity/GcBacktestSettings.java @@ -124,6 +124,11 @@ public class GcBacktestSettings { @Builder.Default private String analysisMethod = "MARK_TO_MARKET"; + /** SCAN_SIGNALS | BACKTEST_ENGINE */ + @Column(name = "trade_execution_mode", nullable = false, length = 32) + @Builder.Default + private String tradeExecutionMode = "BACKTEST_ENGINE"; + @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 3f76c70..6bd2394 100644 --- a/backend/src/main/java/com/goldenchart/service/BacktestSettingsService.java +++ b/backend/src/main/java/com/goldenchart/service/BacktestSettingsService.java @@ -54,6 +54,10 @@ public class BacktestSettingsService { PortfolioLedger.REALIZED_ONLY.equals(dto.getAnalysisMethod()) ? PortfolioLedger.REALIZED_ONLY : PortfolioLedger.MARK_TO_MARKET); + entity.setTradeExecutionMode( + "SCAN_SIGNALS".equals(dto.getTradeExecutionMode()) + ? "SCAN_SIGNALS" + : "BACKTEST_ENGINE"); return toDto(repo.save(entity)); } @@ -84,6 +88,10 @@ public class BacktestSettingsService { .partialExitPct(e.getPartialExitPct()) .positionMode(e.getPositionMode() != null ? e.getPositionMode() : "LONG_ONLY") .analysisMethod(e.getAnalysisMethod() != null ? e.getAnalysisMethod() : PortfolioLedger.MARK_TO_MARKET) + .tradeExecutionMode( + "SCAN_SIGNALS".equals(e.getTradeExecutionMode()) + ? "SCAN_SIGNALS" + : "BACKTEST_ENGINE") .build(); } } diff --git a/backend/src/main/java/com/goldenchart/service/BacktestingService.java b/backend/src/main/java/com/goldenchart/service/BacktestingService.java index b6784d4..4476d2a 100644 --- a/backend/src/main/java/com/goldenchart/service/BacktestingService.java +++ b/backend/src/main/java/com/goldenchart/service/BacktestingService.java @@ -50,6 +50,15 @@ public class BacktestingService { private static final BacktestSettingsDto DEFAULT_SETTINGS = new BacktestSettingsDto(); + /** 조건 스캔 — 종가·DSL 충족 (슬리피지·손절/익절 Rule 미적용) */ + public static final String TRADE_EXEC_SCAN_SIGNALS = "SCAN_SIGNALS"; + /** 백테스트 엔진 — 슬리피지·리스크 Rule·진입/청산가 반영 */ + public static final String TRADE_EXEC_BACKTEST_ENGINE = "BACKTEST_ENGINE"; + + private static boolean isScanSignalsExecution(BacktestSettingsDto cfg) { + return cfg != null && TRADE_EXEC_SCAN_SIGNALS.equals(cfg.getTradeExecutionMode()); + } + // ── Public API ──────────────────────────────────────────────────────────── public BacktestResponse run(BacktestRequest req) { @@ -152,6 +161,9 @@ public class BacktestingService { // ── 청산 규칙 합성 ──────────────────────────────────────────────────────── private Rule buildExitRule(Rule baseExit, BarSeries series, BacktestSettingsDto cfg) { + if (isScanSignalsExecution(cfg)) { + return baseExit; + } Rule result = baseExit; ClosePriceIndicator close = new ClosePriceIndicator(series); @@ -196,7 +208,8 @@ public class BacktestingService { double initCap = cfg.getInitialCapital() != null ? cfg.getInitialCapital().doubleValue() : 10_000_000.0; double tradeSizePct = cfg.getTradeSizeValue() != null ? cfg.getTradeSizeValue().doubleValue() / 100.0 : 1.0; - boolean partialExit = Boolean.TRUE.equals(cfg.getPartialExitEnabled()); + final boolean scanExec = isScanSignalsExecution(cfg); + boolean partialExit = !scanExec && Boolean.TRUE.equals(cfg.getPartialExitEnabled()); double partialPct = cfg.getPartialExitPct() != null ? cfg.getPartialExitPct().doubleValue() / 100.0 : 0.5; boolean partialDone = false; @@ -208,10 +221,12 @@ public class BacktestingService { int barCount = series.getBarCount(); int loopStart = Math.max(0, Math.min(evalStartIndex, barCount)); + final String entryPriceType = scanExec ? "CLOSE" : cfg.getEntryPriceType(); + final String exitPriceType = scanExec ? "CLOSE" : cfg.getExitPriceType(); for (int i = loopStart; i < barCount; i++) { - double closePrice = getPrice(series, req.getBars(), i, cfg.getEntryPriceType()); - double exitPrice = getPrice(series, req.getBars(), i, cfg.getExitPriceType()); + double closePrice = getPrice(series, req.getBars(), i, entryPriceType); + double exitPrice = getPrice(series, req.getBars(), i, exitPriceType); long time = barStartEpoch(series, i); // ── SIGNAL_ONLY: 조건 충족 봉마다 시그널 (live-conditions 충족과 동일 기준) ── @@ -727,6 +742,9 @@ public class BacktestingService { } private double applySlippage(double price, BacktestSettingsDto cfg, boolean isBuy) { + if (isScanSignalsExecution(cfg)) { + return price; + } double slip = cfg.getSlippageRate() != null ? cfg.getSlippageRate().doubleValue() : 0.0005; return isBuy ? price * (1 + slip) : price * (1 - slip); } diff --git a/backend/src/main/resources/db/migration/V68__backtest_trade_execution_mode.sql b/backend/src/main/resources/db/migration/V68__backtest_trade_execution_mode.sql new file mode 100644 index 0000000..fbd9756 --- /dev/null +++ b/backend/src/main/resources/db/migration/V68__backtest_trade_execution_mode.sql @@ -0,0 +1,5 @@ +-- 매매 체결 방식: SCAN_SIGNALS(조건 스캔·종가) | BACKTEST_ENGINE(슬리피지·리스크 Rule) +ALTER TABLE gc_backtest_settings + ADD COLUMN trade_execution_mode VARCHAR(32) NOT NULL DEFAULT 'BACKTEST_ENGINE' + COMMENT 'SCAN_SIGNALS | BACKTEST_ENGINE' + AFTER analysis_method; diff --git a/frontend/src/components/BacktestSettingsModal.tsx b/frontend/src/components/BacktestSettingsModal.tsx index bcc00cd..4fd2072 100644 --- a/frontend/src/components/BacktestSettingsModal.tsx +++ b/frontend/src/components/BacktestSettingsModal.tsx @@ -15,6 +15,11 @@ interface FieldMeta { } const FIELD_META: Partial> = { + tradeExecutionMode: { + label: '매매 체결 방식', + description: + '시그널·체결가 산출 방식입니다.\n• 백테스트 엔진: 슬리피지, 손절/익절, 진입/청산가 반영. 실제 수익률 분석에 가깝습니다.\n• 조건 스캔: 봉 종가·DSL 충족 기준. 차트 마커·조건 패널과 동일합니다.', + }, positionMode: { label: '시그널 생성 방식', description: @@ -139,6 +144,18 @@ interface SettingSection { } const SECTIONS: SettingSection[] = [ + { + title: '⚡ 매매 체결 방식', + fields: [ + { + key: 'tradeExecutionMode', type: 'select', + opts: [ + { value: 'BACKTEST_ENGINE', label: '백테스트 엔진 (슬리피지·리스크 반영)' }, + { value: 'SCAN_SIGNALS', label: '조건 스캔 (봉 종가·DSL 충족)' }, + ], + }, + ], + }, { title: '🎯 시그널 생성 방식', fields: [ diff --git a/frontend/src/components/BacktestSettingsPanel.tsx b/frontend/src/components/BacktestSettingsPanel.tsx index 1a7d821..edf719b 100644 --- a/frontend/src/components/BacktestSettingsPanel.tsx +++ b/frontend/src/components/BacktestSettingsPanel.tsx @@ -9,6 +9,7 @@ import { saveBacktestSettings, } from '../utils/backendApi'; import { ANALYSIS_METHOD_OPTIONS } from '../utils/analysisMethodLabels'; +import { TRADE_EXECUTION_MODE_OPTIONS } from '../utils/tradeExecutionMode'; // ── 백테스팅 전용 UI 컴포넌트 ─────────────────────────────────────────────── @@ -107,6 +108,36 @@ export const BacktestSettingsPanel: React.FC = ({ + + + + + + +

+ {(cfg.tradeExecutionMode ?? 'BACKTEST_ENGINE') === 'SCAN_SIGNALS' + ? TRADE_EXECUTION_MODE_OPTIONS[1].desc + : TRADE_EXECUTION_MODE_OPTIONS[0].desc} +

+ {(cfg.tradeExecutionMode ?? 'BACKTEST_ENGINE') === 'SCAN_SIGNALS' && ( +

+ 조건 스캔 모드: 슬리피지·손절/익절·트레일링스탑·진입/청산가(NEXT_OPEN) 설정은 시그널 생성에 적용되지 않습니다. 수수료만 시뮬레이션에 반영됩니다. +

+ )} +
+ normalizeTradeExecutionMode(backtestSettings?.tradeExecutionMode), + [backtestSettings], + ); + const analysisSummary = useMemo(() => { if (!selectedStrategy) return null; return buildStrategyEvaluationToolbarSummary(backtestSignals, { @@ -749,6 +755,7 @@ function StrategyEvaluationPageInner({ theme = 'dark' }: Props) { firstBarClose: evalBars[0]?.close, lastBarClose: evalBars[evalBars.length - 1]?.close, commissionRate: evaluationCommissionRate, + tradeExecutionMode, }); if (gen !== reportGenRef.current) return; if (!model) { @@ -779,6 +786,7 @@ function StrategyEvaluationPageInner({ theme = 'dark' }: Props) { bars, evalWindowMeta.evaluationBarCount, evaluationCommissionRate, + tradeExecutionMode, ]); return ( @@ -819,7 +827,7 @@ function StrategyEvaluationPageInner({ theme = 'dark' }: Props) { {selectedStrategy && analysisSummary && !signalScanRunning && ( 매수:{padEvaluationSignalCount(analysisSummary.buyCount)}건 diff --git a/frontend/src/utils/backendApi.ts b/frontend/src/utils/backendApi.ts index 6adfb24..4e42e8e 100644 --- a/frontend/src/utils/backendApi.ts +++ b/frontend/src/utils/backendApi.ts @@ -1436,6 +1436,8 @@ export interface BacktestSettingsDto { positionMode?: 'LONG_ONLY' | 'SIGNAL_ONLY'; /** MARK_TO_MARKET | REALIZED_ONLY — 투자분석 방식 */ analysisMethod?: 'MARK_TO_MARKET' | 'REALIZED_ONLY'; + /** SCAN_SIGNALS | BACKTEST_ENGINE — 매매 체결·시그널 생성 방식 */ + tradeExecutionMode?: 'SCAN_SIGNALS' | 'BACKTEST_ENGINE'; } export const DEFAULT_BACKTEST_SETTINGS: BacktestSettingsDto = { @@ -1460,6 +1462,7 @@ export const DEFAULT_BACKTEST_SETTINGS: BacktestSettingsDto = { partialExitPct: 50, positionMode: 'LONG_ONLY', analysisMethod: 'MARK_TO_MARKET', + tradeExecutionMode: 'BACKTEST_ENGINE', }; /** 백테스팅 설정 로드 */ diff --git a/frontend/src/utils/strategyEvaluationReport.ts b/frontend/src/utils/strategyEvaluationReport.ts index d157828..ecb0ec3 100644 --- a/frontend/src/utils/strategyEvaluationReport.ts +++ b/frontend/src/utils/strategyEvaluationReport.ts @@ -7,6 +7,7 @@ import type { EquityPoint, TradeHistoryRow } from './backtestEquity'; import { repairUtf8Mojibake } from './textEncoding'; import { getKoreanName } from './marketNameCache'; import { toUpbitMarket } from './backtestUiUtils'; +import type { TradeExecutionMode } from './tradeExecutionMode'; export type BuyAllocationMode = 'full' | 'split'; @@ -357,6 +358,7 @@ export function buildStrategyEvaluationReportModel(opts: { firstBarClose?: number; lastBarClose?: number; commissionRate?: number; + tradeExecutionMode?: TradeExecutionMode; }): BacktestAnalysisReportModel | null { const filtered = filterSignalsForEvaluationReport(opts.signals); const stillHolding = isStillHolding(filtered); @@ -396,6 +398,8 @@ export function buildStrategyEvaluationReportModel(opts: { reportSignalFilterNote: '분석 기간 전체 캔들 기준으로, 최초 매수 이전 매도·최종 매도 이후 매수 시그널은 제외했습니다. ' + '매도는 보유 물량 전량 매도로 가정합니다. ' - + '체결가·청산 Rule은 백테스트 설정(슬리피지·손절/익절·진입/청산가)과 동일합니다.', + + (opts.tradeExecutionMode === 'SCAN_SIGNALS' + ? '체결가: 조건 스캔(봉 종가·DSL 충족).' + : '체결가: 백테스트 설정(슬리피지·손절/익절·진입/청산가) 반영.'), }; } diff --git a/frontend/src/utils/strategyEvaluationSignals.ts b/frontend/src/utils/strategyEvaluationSignals.ts index bf4faef..a601842 100644 --- a/frontend/src/utils/strategyEvaluationSignals.ts +++ b/frontend/src/utils/strategyEvaluationSignals.ts @@ -1,11 +1,9 @@ /** - * 전략평가 — 차트·상단 요약·분석 레포트·일괄평가 공통 시그널 소스 - * - * runBacktest(SIGNAL_ONLY) + 백테스트 설정(슬리피지·손절/익절·진입/청산가) 기준. - * scan-signals(종가·이상적 체결)와 달리 실제 백테스트 엔진과 동일한 Rule·체결가를 사용한다. + * 전략평가·일괄평가 — 백테스트 설정의 매매 체결 방식에 따라 시그널 조회 */ import type { OHLCVBar } from '../types'; import { + fetchLiveConditionScanSignals, loadBacktestSettings, runBacktest, type BacktestSettingsDto, @@ -13,12 +11,14 @@ import { type StrategyDto, } from './backendApi'; import { buildEvalParamsFromStrategy } from './strategyEvaluationParams'; +import { isScanSignalsExecutionMode, normalizeTradeExecutionMode } from './tradeExecutionMode'; export interface StrategyEvaluationSignalsResult { signals: BacktestSignal[]; initialCapital: number; settings: BacktestSettingsDto; commissionRate: number; + tradeExecutionMode: ReturnType; } export function resolveEvaluationCommissionRate(settings: BacktestSettingsDto): number { @@ -26,6 +26,17 @@ export function resolveEvaluationCommissionRate(settings: BacktestSettingsDto): return settings.commissionRate ?? 0.0015; } +function mapBarsForRequest(bars: OHLCVBar[]) { + return bars.map(b => ({ + time: b.time, + open: b.open, + high: b.high, + low: b.low, + close: b.close, + volume: b.volume, + })); +} + export async function fetchStrategyEvaluationSignals(opts: { strategyId: number; strategy: StrategyDto; @@ -37,19 +48,38 @@ export async function fetchStrategyEvaluationSignals(opts: { backtestSettings?: BacktestSettingsDto | null; }): Promise { const btSettings = opts.backtestSettings ?? await loadBacktestSettings(); - const settings: BacktestSettingsDto = { ...btSettings, positionMode: 'SIGNAL_ONLY' }; + const tradeExecutionMode = normalizeTradeExecutionMode(btSettings.tradeExecutionMode); const indicatorParams = buildEvalParamsFromStrategy(opts.strategy, opts.getParams); + const initialCapital = btSettings.initialCapital ?? 10_000_000; + const commissionRate = resolveEvaluationCommissionRate(btSettings); + + if (isScanSignalsExecutionMode(btSettings)) { + const signals = await fetchLiveConditionScanSignals( + opts.market, + opts.strategyId, + mapBarsForRequest(opts.bars), + opts.timeframe, + indicatorParams, + opts.evaluationBarCount, + ); + return { + signals, + initialCapital, + settings: { ...btSettings, positionMode: 'SIGNAL_ONLY', tradeExecutionMode }, + commissionRate, + tradeExecutionMode, + }; + } + + const settings: BacktestSettingsDto = { + ...btSettings, + positionMode: 'SIGNAL_ONLY', + tradeExecutionMode: 'BACKTEST_ENGINE', + }; const res = await runBacktest({ strategyId: opts.strategyId, - bars: opts.bars.map(b => ({ - time: b.time, - open: b.open, - high: b.high, - low: b.low, - close: b.close, - volume: b.volume, - })), + bars: mapBarsForRequest(opts.bars), timeframe: opts.timeframe, symbol: opts.market, strategyName: opts.strategy.name, @@ -62,11 +92,11 @@ export async function fetchStrategyEvaluationSignals(opts: { throw new Error('시그널 계산 실패'); } - const initialCapital = btSettings.initialCapital ?? res.analysis?.initialCapital ?? 10_000_000; return { signals: res.signals, - initialCapital, + initialCapital: btSettings.initialCapital ?? res.analysis?.initialCapital ?? initialCapital, settings, - commissionRate: resolveEvaluationCommissionRate(settings), + commissionRate, + tradeExecutionMode: 'BACKTEST_ENGINE', }; } diff --git a/frontend/src/utils/tradeExecutionMode.ts b/frontend/src/utils/tradeExecutionMode.ts new file mode 100644 index 0000000..ad0b782 --- /dev/null +++ b/frontend/src/utils/tradeExecutionMode.ts @@ -0,0 +1,38 @@ +import type { BacktestSettingsDto } from './backendApi'; + +export type TradeExecutionMode = 'SCAN_SIGNALS' | 'BACKTEST_ENGINE'; + +export const TRADE_EXECUTION_MODE_OPTIONS: { + value: TradeExecutionMode; + label: string; + desc: string; +}[] = [ + { + value: 'BACKTEST_ENGINE', + label: '백테스트 엔진 (슬리피지·리스크 반영)', + desc: '슬리피지, 손절/익절, 진입/청산가 기준. 실제 수익률 분석에 가깝습니다.', + }, + { + value: 'SCAN_SIGNALS', + label: '조건 스캔 (봉 종가·DSL 충족)', + desc: '차트 마커·조건 패널과 동일. 봉 종가 기준 이상적 체결.', + }, +]; + +export function normalizeTradeExecutionMode( + value?: string | null, +): TradeExecutionMode { + return value === 'SCAN_SIGNALS' ? 'SCAN_SIGNALS' : 'BACKTEST_ENGINE'; +} + +export function isScanSignalsExecutionMode( + settings?: Pick | null, +): boolean { + return normalizeTradeExecutionMode(settings?.tradeExecutionMode) === 'SCAN_SIGNALS'; +} + +export function tradeExecutionModeLabel(mode?: string | null): string { + const normalized = normalizeTradeExecutionMode(mode); + return TRADE_EXECUTION_MODE_OPTIONS.find(o => o.value === normalized)?.label + ?? TRADE_EXECUTION_MODE_OPTIONS[0].label; +}