From a5f0a7eefd1a86ae6ac32755a555bbfeb0e9e581 Mon Sep 17 00:00:00 2001 From: Macbook Date: Fri, 29 May 2026 15:56:43 +0900 Subject: [PATCH] =?UTF-8?q?1=EB=B6=84=EB=B4=89=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../StrategyConditionTimeframeService.java | 30 ++++++---- .../StrategyDslTimeframeNormalizer.java | 59 ++++++++++++++++--- ...StrategyConditionTimeframeServiceTest.java | 41 +++++++++++++ .../StrategyDslTimeframeNormalizerTest.java | 23 ++++++++ 4 files changed, 136 insertions(+), 17 deletions(-) diff --git a/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java b/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java index 22234dd..53cfb9c 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java @@ -103,12 +103,21 @@ public class StrategyConditionTimeframeService { } /** - * START/flow 에만 남은 stale 1m 제거 — DSL 조건에 명시된 분봉 기준. + * START/flow 에만 남은 stale 1m 제거 — CONDITION 에 선언된 분봉 기준(기본값 1m 제외). */ private Set sanitizeEvaluationTimeframes(Set scope, GcStrategy strategy) { - Set explicit = collectExplicitFromStrategy(strategy); - if (explicit.isEmpty()) return scope; - if (!explicit.contains("1m") && scope.contains("1m")) { + if (!scope.contains("1m") || scope.size() <= 1) return scope; + + Set conditionDeclared = collectConditionDeclaredFromStrategy(strategy); + if (conditionDeclared.contains("1m") && conditionDeclared.size() > 1) { + return scope; + } + if (!conditionDeclared.contains("1m")) { + Set filtered = new LinkedHashSet<>(scope); + filtered.remove("1m"); + return filtered.isEmpty() ? scope : filtered; + } + if (conditionDeclared.equals(Set.of("1m"))) { Set filtered = new LinkedHashSet<>(scope); filtered.remove("1m"); return filtered.isEmpty() ? scope : filtered; @@ -116,19 +125,20 @@ public class StrategyConditionTimeframeService { return scope; } - private Set collectExplicitFromStrategy(GcStrategy strategy) { + private Set collectConditionDeclaredFromStrategy(GcStrategy strategy) { Set out = new LinkedHashSet<>(); - collectExplicitFromJson(strategy.getBuyConditionJson(), out); - collectExplicitFromJson(strategy.getSellConditionJson(), out); + collectConditionDeclaredFromJson(strategy.getBuyConditionJson(), out); + collectConditionDeclaredFromJson(strategy.getSellConditionJson(), out); return out; } - private void collectExplicitFromJson(String json, Set out) { + private void collectConditionDeclaredFromJson(String json, Set out) { if (json == null || json.isBlank()) return; try { - out.addAll(StrategyDslTimeframeNormalizer.collectExplicitCandleTypes(objectMapper.readTree(json))); + out.addAll(StrategyDslTimeframeNormalizer.collectConditionDeclaredCandleTypes( + objectMapper.readTree(json))); } catch (Exception e) { - log.warn("[StrategyTimeframes] explicit JSON 파싱 실패: {}", e.getMessage()); + log.warn("[StrategyTimeframes] condition-declared JSON 파싱 실패: {}", e.getMessage()); } } diff --git a/backend/src/main/java/com/goldenchart/service/StrategyDslTimeframeNormalizer.java b/backend/src/main/java/com/goldenchart/service/StrategyDslTimeframeNormalizer.java index 5266a9c..b98ec20 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyDslTimeframeNormalizer.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyDslTimeframeNormalizer.java @@ -208,6 +208,36 @@ public class StrategyDslTimeframeNormalizer { return meaningful; } + /** CONDITION 노드에 선언된 left/rightCandleType 만 — TIMEFRAME·기본 1m 래핑 제외 */ + public static Set collectConditionDeclaredCandleTypes(JsonNode node) { + Set out = new LinkedHashSet<>(); + collectConditionDeclaredCandleTypes(node, out); + return out; + } + + private static void collectConditionDeclaredCandleTypes(JsonNode node, Set out) { + if (node == null || node.isNull()) return; + + if ("CONDITION".equals(node.path("type").asText(""))) { + JsonNode cond = node.path("condition"); + if (!cond.isMissingNode() && !cond.isNull()) { + if (cond.has("leftCandleType") && !cond.path("leftCandleType").asText("").isBlank()) { + out.add(LiveStrategyTimeframeService.normalize(cond.path("leftCandleType").asText())); + } + if (cond.has("rightCandleType") && !cond.path("rightCandleType").asText("").isBlank()) { + out.add(LiveStrategyTimeframeService.normalize(cond.path("rightCandleType").asText())); + } + } + } + + JsonNode children = node.path("children"); + if (children.isArray()) { + for (JsonNode c : children) collectConditionDeclaredCandleTypes(c, out); + } + JsonNode child = node.path("child"); + if (!child.isMissingNode() && !child.isNull()) collectConditionDeclaredCandleTypes(child, out); + } + private static void collectExplicitCandleTypes(JsonNode node, Set out) { if (node == null || node.isNull()) return; @@ -314,14 +344,29 @@ public class StrategyDslTimeframeNormalizer { return fromConditions; } - /** START 에만 남은 stale 1m 제거 — 조건에 1m 이 없으면 평가·알림 분봉에서 제외 */ + /** + * START 에만 남은 stale 1m 제거. + * 조건 노드 기본값 leftCandleType=1m 만 있을 때는 1m 을 평가 분봉으로 보지 않음. + * 의도적 다중 OR(조건에 1m·3m 등 복수 선언)은 유지. + */ private static Set dropStale1mFromStart(Set fromStart, Set fromConditions) { - if (!fromStart.contains("1m") || fromConditions.contains("1m")) { + if (!fromStart.contains("1m") || !hasExplicitNon1mStart(fromStart)) { return fromStart; } - Set filtered = new LinkedHashSet<>(fromStart); - filtered.remove("1m"); - return filtered.isEmpty() ? fromStart : filtered; + if (fromConditions.size() > 1 && fromConditions.contains("1m")) { + return fromStart; + } + if (fromConditions.size() == 1 && fromConditions.contains("1m")) { + Set filtered = new LinkedHashSet<>(fromStart); + filtered.remove("1m"); + return filtered.isEmpty() ? fromStart : filtered; + } + if (!fromConditions.contains("1m")) { + Set filtered = new LinkedHashSet<>(fromStart); + filtered.remove("1m"); + return filtered.isEmpty() ? fromStart : filtered; + } + return fromStart; } private ObjectNode writeResolvedTimeframe(JsonNode root, String strategyName) { @@ -368,8 +413,8 @@ public class StrategyDslTimeframeNormalizer { if (!arr.isArray() || arr.size() < 2) return copy; JsonNode subtree = firstTimeframeChild(tf); - Set explicit = collectExplicitCandleTypes(subtree); - if (explicit.contains("1m")) return copy; + Set conditionDeclared = collectConditionDeclaredCandleTypes(subtree); + if (conditionDeclared.contains("1m")) return copy; List normalized = new ArrayList<>(); for (JsonNode t : arr) { diff --git a/backend/src/test/java/com/goldenchart/service/StrategyConditionTimeframeServiceTest.java b/backend/src/test/java/com/goldenchart/service/StrategyConditionTimeframeServiceTest.java index a4b2dde..d4b3e4a 100644 --- a/backend/src/test/java/com/goldenchart/service/StrategyConditionTimeframeServiceTest.java +++ b/backend/src/test/java/com/goldenchart/service/StrategyConditionTimeframeServiceTest.java @@ -276,6 +276,47 @@ class StrategyConditionTimeframeServiceTest { assertTrue(service.usesTimeframe(7L, "3m")); } + @Test + void collectForStrategy_flowLayoutStale1mRemovedWhenConditionsAreUpperOnly() { + GcStrategy strategy = new GcStrategy(); + strategy.setId(8L); + strategy.setName("CCI 3분 5분 10분 15분 1시간"); + strategy.setFlowLayoutJson(""" + { + "buy": { + "startMeta": { + "start-0": { + "candleTypes": ["1m", "3m", "5m", "10m", "15m", "1h"] + } + } + } + } + """); + strategy.setBuyConditionJson(""" + { + "type": "TIMEFRAME", + "candleType": "3m", + "candleTypes": ["3m", "5m", "10m", "15m", "1h"], + "children": [{ + "type": "AND", + "children": [ + { "type": "CONDITION", "condition": { "indicatorType": "CCI", "leftCandleType": "3m" } }, + { "type": "CONDITION", "condition": { "indicatorType": "CCI", "leftCandleType": "5m" } } + ] + }] + } + """); + when(strategyRepo.findById(8L)).thenReturn(Optional.of(strategy)); + when(dslNormalizer.alignSellStartTimeframeToBuy(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any())) + .thenAnswer(inv -> inv.getArgument(1)); + when(dslNormalizer.normalizeJson(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any())) + .thenAnswer(inv -> inv.getArgument(0)); + + Set tfs = service.collectForStrategy(8L); + assertEquals(Set.of("3m", "5m", "10m", "15m", "1h"), tfs); + assertFalse(service.usesTimeframe(8L, "1m")); + } + @Test void collectForStrategy_legacyTreeDefaultsTo1m() { GcStrategy legacy = 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 016364a..ae52c5b 100644 --- a/backend/src/test/java/com/goldenchart/service/StrategyDslTimeframeNormalizerTest.java +++ b/backend/src/test/java/com/goldenchart/service/StrategyDslTimeframeNormalizerTest.java @@ -186,6 +186,29 @@ class StrategyDslTimeframeNormalizerTest { assertEquals("15m", root.path("candleType").asText()); } + @Test + void resolveStartCandleTypes_dropsStale1mWhenAllConditionsDefaultTo1m() throws Exception { + String json = """ + { + "type": "TIMEFRAME", + "candleType": "1m", + "candleTypes": ["1m", "3m", "5m", "10m", "15m", "1h"], + "children": [{ + "type": "AND", + "children": [ + { "type": "CONDITION", "condition": { "indicatorType": "CCI", "leftCandleType": "1m" } }, + { "type": "CONDITION", "condition": { "indicatorType": "CCI", "leftCandleType": "1m" } }, + { "type": "CONDITION", "condition": { "indicatorType": "CCI", "leftCandleType": "1m" } } + ] + }] + } + """; + JsonNode root = objectMapper.readTree(json); + assertEquals( + Set.of("3m", "5m", "10m", "15m", "1h"), + StrategyDslTimeframeNormalizer.resolveStartCandleTypes(root)); + } + @Test void resolveStartCandleTypes_dropsStale1mWhenConditionsUseMultipleUpperTfs() throws Exception { String json = """