diff --git a/backend/src/main/java/com/goldenchart/service/StrategyDslTimeframeNormalizer.java b/backend/src/main/java/com/goldenchart/service/StrategyDslTimeframeNormalizer.java index 28a965e..8ec988e 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyDslTimeframeNormalizer.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyDslTimeframeNormalizer.java @@ -39,12 +39,16 @@ public class StrategyDslTimeframeNormalizer { if (root == null || root.isNull()) return root; if ("TIMEFRAME".equals(root.path("type").asText(""))) { - // START 다중 분봉(candleTypes) — 편집기 저장값 유지 - if (hasMultipleCandleTypes(root)) { + // START 다중 분봉(candleTypes) — 편집기 저장값 유지, candleType 은 배열 첫 값과 동기화 + if (hasCandleTypesArray(root)) { + return syncTimeframePrimary(root); + } + String current = LiveStrategyTimeframeService.normalize(root.path("candleType").asText("1m")); + // 편집기에서 명시한 단일 분봉(3m, 5m 등)은 조건 노드 기본 1m 추론으로 덮어쓰지 않음 + if (!"1m".equals(current)) { return root; } String expected = inferPrimaryCandleType(root, strategyName); - String current = LiveStrategyTimeframeService.normalize(root.path("candleType").asText("1m")); if (!expected.equals(current)) { return copyTimeframeWithCandleType(root, expected); } @@ -197,9 +201,20 @@ public class StrategyDslTimeframeNormalizer { return "1m"; } - private static boolean hasMultipleCandleTypes(JsonNode timeframeNode) { + private static boolean hasCandleTypesArray(JsonNode timeframeNode) { JsonNode arr = timeframeNode.path("candleTypes"); - return arr.isArray() && arr.size() > 1; + return arr.isArray() && !arr.isEmpty(); + } + + /** candleTypes[] 가 있으면 candleType 을 첫 항목과 맞춤 (stale 1m 방지) */ + private ObjectNode syncTimeframePrimary(JsonNode tf) { + ObjectNode copy = tf.deepCopy(); + JsonNode arr = tf.path("candleTypes"); + if (arr.isArray() && !arr.isEmpty()) { + String primary = LiveStrategyTimeframeService.normalize(arr.get(0).asText("1m")); + copy.put("candleType", primary); + } + return copy; } private ObjectNode copyTimeframeWithCandleType(JsonNode tf, String candleType) { diff --git a/backend/src/test/java/com/goldenchart/service/StrategyConditionTimeframeServiceTest.java b/backend/src/test/java/com/goldenchart/service/StrategyConditionTimeframeServiceTest.java index 259a22b..cce3521 100644 --- a/backend/src/test/java/com/goldenchart/service/StrategyConditionTimeframeServiceTest.java +++ b/backend/src/test/java/com/goldenchart/service/StrategyConditionTimeframeServiceTest.java @@ -55,6 +55,30 @@ class StrategyConditionTimeframeServiceTest { assertFalse(service.usesTimeframe(1L, "1m")); } + @Test + void collectForStrategy_reads3mAnd5mWithout1m() { + GcStrategy onlyUpper = new GcStrategy(); + onlyUpper.setId(4L); + onlyUpper.setBuyConditionJson(""" + { + "type": "TIMEFRAME", + "candleType": "3m", + "candleTypes": ["3m", "5m"], + "children": [{ + "type": "CONDITION", + "condition": { "indicatorType": "RSI", "period": 14 } + }] + } + """); + when(strategyRepo.findById(4L)).thenReturn(Optional.of(onlyUpper)); + + Set tfs = service.collectForStrategy(4L); + assertEquals(Set.of("3m", "5m"), tfs); + assertFalse(service.usesTimeframe(4L, "1m")); + assertTrue(service.usesTimeframe(4L, "3m")); + assertTrue(service.usesTimeframe(4L, "5m")); + } + @Test void collectForStrategy_readsMultipleCandleTypesFromTimeframeNode() { GcStrategy multi = new GcStrategy(); diff --git a/backend/src/test/java/com/goldenchart/service/StrategyDslTimeframeNormalizerTest.java b/backend/src/test/java/com/goldenchart/service/StrategyDslTimeframeNormalizerTest.java index 2c6e90b..1e21bce 100644 --- a/backend/src/test/java/com/goldenchart/service/StrategyDslTimeframeNormalizerTest.java +++ b/backend/src/test/java/com/goldenchart/service/StrategyDslTimeframeNormalizerTest.java @@ -11,6 +11,44 @@ class StrategyDslTimeframeNormalizerTest { private final ObjectMapper objectMapper = new ObjectMapper(); private final StrategyDslTimeframeNormalizer normalizer = new StrategyDslTimeframeNormalizer(objectMapper); + @Test + void normalize_preserves3mAnd5mWithout1m() throws Exception { + String json = """ + { + "type": "TIMEFRAME", + "candleType": "1m", + "candleTypes": ["3m", "5m"], + "children": [{ + "type": "CONDITION", + "condition": { "indicatorType": "RSI", "period": 14 } + }] + } + """; + String normalized = normalizer.normalizeJson(json, "테스트"); + JsonNode root = objectMapper.readTree(normalized); + assertEquals(2, root.path("candleTypes").size()); + assertEquals("3m", root.path("candleTypes").get(0).asText()); + assertEquals("5m", root.path("candleTypes").get(1).asText()); + assertEquals("3m", root.path("candleType").asText()); + } + + @Test + void normalize_preservesSingleExplicit3m() throws Exception { + String json = """ + { + "type": "TIMEFRAME", + "candleType": "3m", + "children": [{ + "type": "CONDITION", + "condition": { "indicatorType": "RSI", "period": 14 } + }] + } + """; + String normalized = normalizer.normalizeJson(json, "테스트"); + JsonNode root = objectMapper.readTree(normalized); + assertEquals("3m", root.path("candleType").asText()); + } + @Test void normalize_preservesMultiCandleTypesOnSave() throws Exception { String json = """ diff --git a/frontend/src/App.css b/frontend/src/App.css index c7e7d58..d01eecd 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -2871,6 +2871,23 @@ html.theme-blue { border-radius: 8px; box-shadow: var(--popup-shadow); } +.ism-color-popover-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin: -2px 0 10px; + padding-bottom: 8px; + border-bottom: 1px solid var(--sep); +} +.ism-color-popover-title { + font-size: 12px; + font-weight: 600; + color: var(--text); +} +.ism-color-popover-close { + flex-shrink: 0; +} .ism-color-popover-native { margin-bottom: 10px; padding-bottom: 10px; diff --git a/frontend/src/components/ColorInput.tsx b/frontend/src/components/ColorInput.tsx index c574f1e..066be14 100644 --- a/frontend/src/components/ColorInput.tsx +++ b/frontend/src/components/ColorInput.tsx @@ -52,18 +52,25 @@ const ColorInput: React.FC = ({ value, onChange, disabled = fal useEffect(() => { if (!open) return; - const onDoc = (e: MouseEvent) => { + const onDocPointer = (e: Event) => { const t = e.target as Node; if (anchorRef.current?.contains(t) || popoverRef.current?.contains(t)) return; setOpen(false); }; - document.addEventListener('mousedown', onDoc); - return () => document.removeEventListener('mousedown', onDoc); + // 설정 모달 등이 bubble 단계에서 stopPropagation 하므로 capture 사용 + document.addEventListener('mousedown', onDocPointer, true); + document.addEventListener('pointerdown', onDocPointer, true); + return () => { + document.removeEventListener('mousedown', onDocPointer, true); + document.removeEventListener('pointerdown', onDocPointer, true); + }; }, [open]); const patchHex = (h: string) => onChange(formatPlotColor(h, alpha)); const patchAlpha = (a: number) => onChange(formatPlotColor(hex6, a)); + const closePopover = useCallback(() => setOpen(false), []); + const popover = open && typeof document !== 'undefined' ? (
= ({ value, onChange, disabled = fal role="dialog" aria-label="색상 선택" onMouseDown={e => e.stopPropagation()} + onPointerDown={e => e.stopPropagation()} > +
+ 색상 선택 + +