From e86142dad5deaf40cc0abbb9ddb2b8ca53f3061d Mon Sep 17 00:00:00 2001 From: Macbook Date: Wed, 27 May 2026 15:49:56 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A0=84=EB=9E=B5=20=EC=8B=9C=EA=B0=84?= =?UTF-8?q?=EB=B4=89=20=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 --- .../StrategyConditionTimeframeService.java | 25 +++++-- .../StrategyTriggerBranchEvaluator.java | 26 +++++++ ...StrategyConditionTimeframeServiceTest.java | 75 +++++++++++++++++++ .../src/components/StrategyEditorPage.tsx | 6 +- frontend/src/utils/strategyConditionSerde.ts | 18 ++++- 5 files changed, 139 insertions(+), 11 deletions(-) create mode 100644 backend/src/test/java/com/goldenchart/service/StrategyConditionTimeframeServiceTest.java diff --git a/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java b/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java index 518f66f..76c7bab 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java @@ -71,11 +71,21 @@ public class StrategyConditionTimeframeService { private void collectFromNode(JsonNode node, Set out) { if (node == null || node.isNull()) return; + if (collectTimeframesFromNode(node, out)) return; + // TIMEFRAME 래핑 없는 레거시 트리 — 기본 1m + out.add("1m"); + } + + /** + * 트리에서 TIMEFRAME 노드를 수집. 발견 시 true. + */ + private boolean collectTimeframesFromNode(JsonNode node, Set out) { + if (node == null || node.isNull()) return false; String type = node.path("type").asText(""); if ("TIMEFRAME".equals(type)) { out.add(LiveStrategyTimeframeService.normalize(node.path("candleType").asText("1m"))); - return; + return true; } if ("AND".equals(type) || "OR".equals(type)) { @@ -85,23 +95,22 @@ public class StrategyConditionTimeframeService { for (JsonNode tf : children) { out.add(LiveStrategyTimeframeService.normalize(tf.path("candleType").asText("1m"))); } - return; + return true; } + boolean found = false; for (JsonNode child : children) { - collectFromNode(child, out); + found |= collectTimeframesFromNode(child, out); } - return; + return found; } } JsonNode child = node.path("child"); if (!child.isMissingNode() && !child.isNull()) { - collectFromNode(child, out); - return; + return collectTimeframesFromNode(child, out); } - // 단일 START(기본 1m) 또는 TIMEFRAME 래핑 없는 트리 - out.add("1m"); + return false; } private static boolean allTimeframeChildren(JsonNode children) { diff --git a/backend/src/main/java/com/goldenchart/service/StrategyTriggerBranchEvaluator.java b/backend/src/main/java/com/goldenchart/service/StrategyTriggerBranchEvaluator.java index cf013cb..67153af 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyTriggerBranchEvaluator.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyTriggerBranchEvaluator.java @@ -100,9 +100,35 @@ public class StrategyTriggerBranchEvaluator { } } + JsonNode wrapped = findTimeframeWrapper(node); + if (wrapped != null) { + String ct = LiveStrategyTimeframeService.normalize(wrapped.path("candleType").asText("1m")); + JsonNode sub = firstChild(wrapped); + return new BranchScope("SINGLE", List.of(new BranchDef(ct, sub != null && !sub.isNull() ? sub : node))); + } + return new BranchScope("SINGLE", List.of(new BranchDef("1m", node))); } + /** 레거시 DSL — 루트가 TIMEFRAME이 아닐 때 하위 TIMEFRAME 래퍼 탐색 */ + private static JsonNode findTimeframeWrapper(JsonNode node) { + if (node == null || node.isNull()) return null; + if ("TIMEFRAME".equals(node.path("type").asText(""))) return node; + + JsonNode children = node.path("children"); + if (children.isArray()) { + for (JsonNode child : children) { + JsonNode found = findTimeframeWrapper(child); + if (found != null) return found; + } + } + JsonNode child = node.path("child"); + if (!child.isMissingNode() && !child.isNull()) { + return findTimeframeWrapper(child); + } + return null; + } + private static JsonNode firstChild(JsonNode timeframeNode) { JsonNode children = timeframeNode.path("children"); if (children.isArray() && !children.isEmpty()) return children.get(0); diff --git a/backend/src/test/java/com/goldenchart/service/StrategyConditionTimeframeServiceTest.java b/backend/src/test/java/com/goldenchart/service/StrategyConditionTimeframeServiceTest.java new file mode 100644 index 0000000..84c305f --- /dev/null +++ b/backend/src/test/java/com/goldenchart/service/StrategyConditionTimeframeServiceTest.java @@ -0,0 +1,75 @@ +package com.goldenchart.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.goldenchart.entity.GcStrategy; +import com.goldenchart.repository.GcStrategyRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class StrategyConditionTimeframeServiceTest { + + @Mock + private GcStrategyRepository strategyRepo; + + @Spy + private ObjectMapper objectMapper = new ObjectMapper(); + + @InjectMocks + private StrategyConditionTimeframeService service; + + @BeforeEach + void stubStrategy() { + GcStrategy strategy = new GcStrategy(); + strategy.setId(1L); + strategy.setBuyConditionJson(""" + { + "type": "TIMEFRAME", + "candleType": "5m", + "children": [{ + "type": "CONDITION", + "condition": { "indicatorType": "RSI", "period": 14 } + }] + } + """); + strategy.setSellConditionJson(null); + when(strategyRepo.findById(1L)).thenReturn(Optional.of(strategy)); + } + + @Test + void collectForStrategy_readsTimeframeFromDsl() { + Set tfs = service.collectForStrategy(1L); + assertEquals(Set.of("5m"), tfs); + assertTrue(service.usesTimeframe(1L, "5m")); + assertFalse(service.usesTimeframe(1L, "1m")); + } + + @Test + void collectForStrategy_legacyTreeDefaultsTo1m() { + GcStrategy legacy = new GcStrategy(); + legacy.setId(2L); + legacy.setBuyConditionJson(""" + { + "type": "AND", + "children": [{ + "type": "CONDITION", + "condition": { "indicatorType": "RSI", "period": 14 } + }] + } + """); + when(strategyRepo.findById(2L)).thenReturn(Optional.of(legacy)); + + assertEquals(Set.of("1m"), service.collectForStrategy(2L)); + } +} diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx index 58f7867..d4d5be2 100644 --- a/frontend/src/components/StrategyEditorPage.tsx +++ b/frontend/src/components/StrategyEditorPage.tsx @@ -24,6 +24,7 @@ import { findLogicNode, getIndicatorPeriodLabel } from '../utils/strategyFlowLay import { decodeConditionForEditor, encodeConditionForSave, + mergeStartMetaForLoad, addExtraStartSection, hasMultipleStartSections, normalizeStartCombineOp, @@ -500,7 +501,7 @@ export default function StrategyEditorPage({ theme }: Props) { setBuyLayout({ positions: stored?.buy.positions ?? {}, edgeHandles: stored?.buy.edgeHandles ?? {}, - startMeta: stored?.buy.startMeta ?? buyDecoded.startMeta, + startMeta: mergeStartMetaForLoad(buyDecoded.startMeta, stored?.buy.startMeta), extraStartIds: stored?.buy.extraStartIds?.length ? stored.buy.extraStartIds : buyDecoded.extraStartIds, extraRoots: stored?.buy.extraRoots && Object.keys(stored.buy.extraRoots).length ? stored.buy.extraRoots @@ -510,7 +511,7 @@ export default function StrategyEditorPage({ theme }: Props) { setSellLayout({ positions: stored?.sell.positions ?? {}, edgeHandles: stored?.sell.edgeHandles ?? {}, - startMeta: stored?.sell.startMeta ?? sellDecoded.startMeta, + startMeta: mergeStartMetaForLoad(sellDecoded.startMeta, stored?.sell.startMeta), extraStartIds: stored?.sell.extraStartIds?.length ? stored.sell.extraStartIds : sellDecoded.extraStartIds, extraRoots: stored?.sell.extraRoots && Object.keys(stored.sell.extraRoots).length ? stored.sell.extraRoots @@ -538,6 +539,7 @@ export default function StrategyEditorPage({ theme }: Props) { return; } setSaveNameError(false); + if (editorMode === 'graph') layoutFlushRef.current?.(); const encodedBuy = encodeConditionForSave(buyEditorState); const encodedSell = encodeConditionForSave(sellEditorState); if (!encodedBuy && !encodedSell) { showSnack('조건을 최소 1개 추가하세요', false); return; } diff --git a/frontend/src/utils/strategyConditionSerde.ts b/frontend/src/utils/strategyConditionSerde.ts index af2356b..7f8e3ae 100644 --- a/frontend/src/utils/strategyConditionSerde.ts +++ b/frontend/src/utils/strategyConditionSerde.ts @@ -161,7 +161,6 @@ export function encodeConditionForSave(state: EditorConditionState): LogicNode | if (branches.length === 0) return null; if (branches.length === 1) { const { candleType, root } = branches[0]; - if (normalizeStartCandleType(candleType) === DEFAULT_START_CANDLE) return root; return wrapTimeframe(candleType, root); } @@ -257,3 +256,20 @@ export function collectEditorBranches(state: EditorConditionState): ConditionBra export function collectDslBranches(dsl: LogicNode | null): ConditionBranch[] { return collectEditorBranches(decodeConditionForEditor(dsl)); } + +/** + * 전략 로드 시 startMeta 병합 — DB DSL에서 복원한 시간봉이 localStorage보다 우선. + */ +export function mergeStartMetaForLoad( + decoded: Record, + stored?: Record | null, +): Record { + const merged: Record = { + ...defaultStartMeta(), + ...stored, + }; + for (const [startId, meta] of Object.entries(decoded)) { + merged[startId] = { candleType: normalizeStartCandleType(meta.candleType) }; + } + return merged; +}