From 133e3d04130b962def484795a70ff0602c71d4d9 Mon Sep 17 00:00:00 2001 From: Macbook Date: Tue, 16 Jun 2026 00:30:25 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A0=84=EB=9E=B5=ED=8F=89=EA=B0=80=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/BacktestingController.java | 32 ++++---- .../com/goldenchart/dto/BacktestRequest.java | 3 + .../service/BacktestingService.java | 28 ++++--- .../service/StrategySignalDeterminer.java | 17 ++--- frontend/src/App.css | 57 ++++++++++++++ .../components/IndicatorCustomTabEditor.tsx | 9 ++- .../src/components/IndicatorDropdownPanel.tsx | 74 +++++++++++++++++-- .../StrategyEvaluationChart.tsx | 10 +-- 8 files changed, 176 insertions(+), 54 deletions(-) diff --git a/backend/src/main/java/com/goldenchart/controller/BacktestingController.java b/backend/src/main/java/com/goldenchart/controller/BacktestingController.java index e347019..214efc6 100644 --- a/backend/src/main/java/com/goldenchart/controller/BacktestingController.java +++ b/backend/src/main/java/com/goldenchart/controller/BacktestingController.java @@ -49,21 +49,27 @@ public class BacktestingController { } long userId = TradingControllerSupport.requireRegisteredUser(headers); + String deviceId = headers.get("x-device-id"); + if (deviceId == null || deviceId.isBlank()) { + deviceId = req.getDeviceId(); + } + req.setUserId(userId); + req.setDeviceId(deviceId); java.util.Map> dbParams = - indicatorSettingsService.getAll(userId, null); - if (!dbParams.isEmpty()) { - java.util.Map> merged = - new java.util.HashMap<>(dbParams); - if (req.getIndicatorParams() != null) { - req.getIndicatorParams().forEach((type, params) -> - merged.merge(type, params, (dbP, reqP) -> { - java.util.Map m = new java.util.HashMap<>(dbP); - m.putAll(reqP); - return m; - }) - ); - } + indicatorSettingsService.getAll(userId, deviceId); + java.util.Map> merged = + new java.util.HashMap<>(dbParams); + if (req.getIndicatorParams() != null) { + req.getIndicatorParams().forEach((type, params) -> + merged.merge(type, params, (dbP, reqP) -> { + java.util.Map m = new java.util.HashMap<>(dbP); + m.putAll(reqP); + return m; + }) + ); + } + if (!merged.isEmpty()) { req.setIndicatorParams(merged); } diff --git a/backend/src/main/java/com/goldenchart/dto/BacktestRequest.java b/backend/src/main/java/com/goldenchart/dto/BacktestRequest.java index 1ea9510..ee80824 100644 --- a/backend/src/main/java/com/goldenchart/dto/BacktestRequest.java +++ b/backend/src/main/java/com/goldenchart/dto/BacktestRequest.java @@ -63,4 +63,7 @@ public class BacktestRequest { /** 디바이스 ID (결과 저장용) */ private String deviceId; + + /** 인증 사용자 ID — 컨트롤러에서 설정, 지표 설정 조회용 */ + private Long userId; } diff --git a/backend/src/main/java/com/goldenchart/service/BacktestingService.java b/backend/src/main/java/com/goldenchart/service/BacktestingService.java index 87f26a1..4685191 100644 --- a/backend/src/main/java/com/goldenchart/service/BacktestingService.java +++ b/backend/src/main/java/com/goldenchart/service/BacktestingService.java @@ -77,13 +77,16 @@ public class BacktestingService { } } - // DB 저장 인디케이터 설정을 기본으로 로드 (디바이스별 저장값 → 하드코딩 기본값 방지) + // DB 저장 인디케이터 설정 — live-conditions/evaluate 와 동일 (userId + deviceId) Map> dbParams = Map.of(); + Map> visual = Map.of(); if (req.getDeviceId() != null && !req.getDeviceId().isBlank()) { try { - dbParams = indicatorSettingsService.getAll(null, req.getDeviceId()); + dbParams = indicatorSettingsService.getAll(req.getUserId(), req.getDeviceId()); + visual = indicatorSettingsService.getAllVisual(req.getUserId(), req.getDeviceId()); } catch (Exception e) { - log.warn("[Backtest] 인디케이터 설정 DB 로드 실패 (deviceId={}): {}", req.getDeviceId(), e.getMessage()); + log.warn("[Backtest] 인디케이터 설정 DB 로드 실패 (userId={}, deviceId={}): {}", + req.getUserId(), req.getDeviceId(), e.getMessage()); } } // 요청에 포함된 파라미터가 있으면 DB 값 위에 덮어씀 (요청값 우선) @@ -111,9 +114,12 @@ public class BacktestingService { Map seriesOverrides = buildSeriesOverrides( series, primaryTf, buyDsl, sellDsl); - Rule entryRule = adapter.toRule(buyDsl, series, params, seriesOverrides); + StrategyDslToTa4jAdapter.RuleBuildContext ruleCtx = + new StrategyDslToTa4jAdapter.RuleBuildContext( + series, params, visual, null, null, false, seriesOverrides); + Rule entryRule = adapter.toRule(buyDsl, ruleCtx); Rule baseExitRule = (sellDsl != null && !sellDsl.isNull()) - ? adapter.toRule(sellDsl, series, params, seriesOverrides) + ? adapter.toRule(sellDsl, ruleCtx) : new BooleanRule(false); Rule exitRule = buildExitRule(baseExitRule, series, cfg); @@ -199,23 +205,21 @@ public class BacktestingService { double exitPrice = getPrice(series, req.getBars(), i, cfg.getExitPriceType()); long time = barStartEpoch(series, i); - // ── SIGNAL_ONLY: 조건 충족 '시작' 봉만 시그널 (GTE 유지형 연속 중복 방지) ── + // ── SIGNAL_ONLY: 조건 충족 봉마다 시그널 (live-conditions 충족과 동일 기준) ── if (signalOnly) { boolean entrySatisfied = entryRule.isSatisfied(i); boolean exitSatisfied = exitRule.isSatisfied(i); - boolean entryEdge = entrySatisfied && (i <= loopStart || !entryRule.isSatisfied(i - 1)); - boolean exitEdge = exitSatisfied && (i <= loopStart || !exitRule.isSatisfied(i - 1)); - if (entryEdge) { + if (entrySatisfied) { double effEntry = applySlippage(closePrice, cfg, true); String buyType = "SHORT".equals(direction) ? "SHORT_ENTRY" : "BUY"; signals.add(buildFillSignal(time, buyType, effEntry, i, 1.0)); - log.debug("[Backtest:SIGNAL_ONLY] BUY(edge) @ bar={} price={}", i, effEntry); + log.debug("[Backtest:SIGNAL_ONLY] BUY @ bar={} price={}", i, effEntry); } - if (exitEdge) { + if (exitSatisfied) { double effExit = applySlippage(exitPrice, cfg, false); String sellType = "SHORT".equals(direction) ? "SHORT_EXIT" : "SELL"; signals.add(buildFillSignal(time, sellType, effExit, i, 1.0)); - log.debug("[Backtest:SIGNAL_ONLY] SELL(edge) @ bar={} price={}", i, effExit); + log.debug("[Backtest:SIGNAL_ONLY] SELL @ bar={} price={}", i, effExit); } continue; } diff --git a/backend/src/main/java/com/goldenchart/service/StrategySignalDeterminer.java b/backend/src/main/java/com/goldenchart/service/StrategySignalDeterminer.java index 488f6b8..964dfab 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategySignalDeterminer.java +++ b/backend/src/main/java/com/goldenchart/service/StrategySignalDeterminer.java @@ -57,8 +57,8 @@ public class StrategySignalDeterminer { int index, String positionMode) { try { if ("SIGNAL_ONLY".equals(positionMode)) { - boolean enterOk = entryRule != null && isRisingEdge(entryRule, index); - boolean exitOk = exitRule != null && isRisingEdge(exitRule, index); + boolean enterOk = entryRule != null && entryRule.isSatisfied(index); + boolean exitOk = exitRule != null && exitRule.isSatisfied(index); if (enterOk) return "BUY"; if (exitOk) return "SELL"; return "NONE"; @@ -84,15 +84,10 @@ public class StrategySignalDeterminer { } private String determineSignalOnly(Strategy strategy, int index) { - if (isRisingEdge(strategy.getEntryRule(), index)) return "BUY"; - if (isRisingEdge(strategy.getExitRule(), index)) return "SELL"; + Rule entry = strategy.getEntryRule(); + Rule exit = strategy.getExitRule(); + if (entry != null && entry.isSatisfied(index)) return "BUY"; + if (exit != null && exit.isSatisfied(index)) return "SELL"; return "NONE"; } - - /** GTE 등 유지형 조건 — 충족 시작 봉만 true (연속 시그널 방지) */ - private boolean isRisingEdge(Rule rule, int index) { - if (rule == null || !rule.isSatisfied(index)) return false; - if (index <= 0) return true; - return !rule.isSatisfied(index - 1); - } } diff --git a/frontend/src/App.css b/frontend/src/App.css index 9ec8e4d..bd6d151 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1919,12 +1919,69 @@ html.desktop-client .tmb-logo-version { } .ind-tab-editor-footer { display: flex; + align-items: center; justify-content: flex-end; gap: 8px; padding: 10px 12px; border-top: 1px solid var(--border); flex-shrink: 0; } +.ind-tab-editor-error { + margin-right: auto; + font-size: 12px; + color: #ff6b6b; +} + +/* 인앱 확인 모달 (데스크톱 웹뷰에서 window.confirm 미동작 대체) */ +.gc-confirm-overlay { + position: fixed; + inset: 0; + z-index: 2200; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + background: rgba(0, 0, 0, 0.55); +} +.gc-confirm-box { + width: min(360px, calc(100vw - 24px)); + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45); + padding: 18px 18px 14px; +} +.gc-confirm-msg { + margin: 0 0 16px; + font-size: 13px; + line-height: 1.55; + color: var(--text); + white-space: pre-line; +} +.gc-confirm-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} +.gc-confirm-btn { + padding: 7px 16px; + border-radius: 4px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + border: 1px solid var(--border); +} +.gc-confirm-btn.cancel { + background: var(--bg3); + color: var(--text2); +} +.gc-confirm-btn.cancel:hover { color: var(--text); } +.gc-confirm-btn.ok { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} +.gc-confirm-btn.ok:hover { filter: brightness(1.08); } .ind-tab-editor-btn { padding: 7px 16px; border-radius: 4px; diff --git a/frontend/src/components/IndicatorCustomTabEditor.tsx b/frontend/src/components/IndicatorCustomTabEditor.tsx index 147a0a8..21a7247 100644 --- a/frontend/src/components/IndicatorCustomTabEditor.tsx +++ b/frontend/src/components/IndicatorCustomTabEditor.tsx @@ -26,6 +26,7 @@ const IndicatorCustomTabEditor: React.FC = ({ const [name, setName] = useState(initialName); const [selected, setSelected] = useState>(() => new Set(initialTypes)); const [search, setSearch] = useState(''); + const [error, setError] = useState(''); useEffect(() => { const onKey = (e: KeyboardEvent) => { @@ -64,13 +65,14 @@ const IndicatorCustomTabEditor: React.FC = ({ const handleSave = () => { const trimmed = name.trim(); if (!trimmed) { - window.alert('탭 이름을 입력해 주세요.'); + setError('탭 이름을 입력해 주세요.'); return; } if (selected.size === 0) { - window.alert('탭에 표시할 지표를 하나 이상 선택해 주세요.'); + setError('탭에 표시할 지표를 하나 이상 선택해 주세요.'); return; } + setError(''); const ordered = INDICATOR_REGISTRY .filter(d => selected.has(d.type)) .map(d => d.type); @@ -96,7 +98,7 @@ const IndicatorCustomTabEditor: React.FC = ({ setName(e.target.value)} + onChange={e => { setName(e.target.value); if (error) setError(''); }} placeholder="예: 내 지표" autoFocus /> @@ -156,6 +158,7 @@ const IndicatorCustomTabEditor: React.FC = ({
+ {error && {error}} diff --git a/frontend/src/components/IndicatorDropdownPanel.tsx b/frontend/src/components/IndicatorDropdownPanel.tsx index 687ffe7..43dfe1e 100644 --- a/frontend/src/components/IndicatorDropdownPanel.tsx +++ b/frontend/src/components/IndicatorDropdownPanel.tsx @@ -2,6 +2,7 @@ * 실시간 차트 툴바와 동일한 보조지표 추가·제거 패널 (ind-panel) */ import React, { useRef, useEffect, useState, useCallback } from 'react'; +import ReactDOM from 'react-dom'; import { useDraggablePanel } from '../hooks/useDraggablePanel'; import { INDICATOR_REGISTRY, type IndicatorDef } from '../utils/indicatorRegistry'; import { @@ -12,7 +13,12 @@ import { parseCustomTabKey, type IndicatorTabKey, } from '../utils/indicatorMainTab'; -import { confirmAndApplyIndicatorTab } from '../utils/indicatorTabApply'; +import { + applyIndicatorTab, + resolveIndicatorTabApplyTypes, + resolveIndicatorTabApplyLabel, + type IndicatorTabApplySource, +} from '../utils/indicatorTabApply'; import { getOrderedMainIndicatorTypes, saveMainTabOrder, @@ -85,6 +91,7 @@ const IndicatorDropdownPanel: React.FC = ({ const [dragOverType, setDragOverType] = useState(null); const [editorOpen, setEditorOpen] = useState(false); const [editorMode, setEditorMode] = useState<'create' | 'edit'>('create'); + const [confirmState, setConfirmState] = useState<{ message: string; onConfirm: () => void } | null>(null); const searchRef = useRef(null); const searchFocusedRef = useRef(false); @@ -117,10 +124,15 @@ const IndicatorDropdownPanel: React.FC = ({ const handleDeleteTab = () => { if (!selectedCustomTab) return; - if (!window.confirm(`"${selectedCustomTab.name}" 탭을 삭제할까요?`)) return; - deleteCustomTab(selectedCustomTab.id); - refreshCustomTabs(); - setCategory('All'); + const tab = selectedCustomTab; + setConfirmState({ + message: `"${tab.name}" 탭을 삭제할까요?`, + onConfirm: () => { + deleteCustomTab(tab.id); + refreshCustomTabs(); + setCategory('All'); + }, + }); }; const handleEditorSave = (name: string, indicatorTypes: string[]) => { @@ -205,14 +217,24 @@ const IndicatorDropdownPanel: React.FC = ({ const addAllDisabled = category === 'All' || !addAllContext || addAllContext.types.length === 0; + const requestApplyTab = (source: IndicatorTabApplySource) => { + const validTypes = resolveIndicatorTabApplyTypes(source); + if (validTypes.length === 0) return; + const label = resolveIndicatorTabApplyLabel(source); + setConfirmState({ + message: `기존 보조지표를 모두 제거하고 ${label} 탭의 지표 ${validTypes.length}개로 교체하시겠습니까?`, + onConfirm: () => applyIndicatorTab(source, onAddMany, onAdd), + }); + }; + const handleAddAllInTab = () => { if (!addAllContext || addAllContext.types.length === 0) return; if (category === 'Main') { - confirmAndApplyIndicatorTab({ kind: 'main' }, onAddMany, onAdd); + requestApplyTab({ kind: 'main' }); return; } if (isCustomTabKey(category) && selectedCustomTab) { - confirmAndApplyIndicatorTab({ kind: 'custom', tab: selectedCustomTab }, onAddMany, onAdd); + requestApplyTab({ kind: 'custom', tab: selectedCustomTab }); } }; @@ -229,6 +251,10 @@ const IndicatorDropdownPanel: React.FC = ({ useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key !== 'Escape') return; + if (confirmState) { + setConfirmState(null); + return; + } if (editorOpen) { setEditorOpen(false); return; @@ -237,7 +263,7 @@ const IndicatorDropdownPanel: React.FC = ({ }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [onClose, editorOpen]); + }, [onClose, editorOpen, confirmState]); const isActive = (type: string) => activeIndicators.some(a => a.type === type); @@ -535,6 +561,38 @@ const IndicatorDropdownPanel: React.FC = ({ /> )}
+ + {confirmState && ReactDOM.createPortal( +
{ if (e.target === e.currentTarget) setConfirmState(null); }} + > +
e.stopPropagation()}> +

{confirmState.message}

+
+ + +
+
+
, + document.body, + )} ); }; diff --git a/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx b/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx index 50c0619..3375f14 100644 --- a/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx +++ b/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx @@ -139,14 +139,10 @@ const StrategyEvaluationChart: React.FC = ({ return { candle: flex.candle, aux: flex.aux }; }, [auxPaneCount]); - /** 저장된 오버라이드가 있을 때만 백테스트에 전달 — 미설정 시 live-conditions 와 같이 서버 DB 사용 */ + /** 전략 DSL 지표 파라미터 — live-conditions/evaluate·차트와 동일 소스 */ const indicatorParams = useMemo( - () => ( - strategy && appliedIndicatorParams - ? buildEvalParamsFromStrategy(strategy, getParams) - : undefined - ), - [strategy, getParams, appliedIndicatorParams], + () => (strategy ? buildEvalParamsFromStrategy(strategy, getParams) : undefined), + [strategy, getParams], ); const resolveInitialDisplayCount = useCallback(