From 01218b99c99183d3e55a44b85bf63c14c4edd0a8 Mon Sep 17 00:00:00 2001 From: Macbook Date: Thu, 28 May 2026 01:45:53 +0900 Subject: [PATCH] =?UTF-8?q?=EC=95=8C=EB=A6=BC=EB=AC=B8=EC=A0=9C=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 | 12 +--- .../StrategyDslTimeframeNormalizer.java | 72 ++++++++++++++++++- .../goldenchart/service/StrategyService.java | 33 ++++++--- .../StrategyTriggerBranchEvaluator.java | 12 +--- ...StrategyConditionTimeframeServiceTest.java | 36 ++++++++++ .../StrategyDslTimeframeNormalizerTest.java | 46 ++++++++++++ 6 files changed, 180 insertions(+), 31 deletions(-) diff --git a/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java b/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java index de232c5..abfc56d 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java @@ -235,16 +235,10 @@ public class StrategyConditionTimeframeService { return true; } - /** TIMEFRAME 노드 — candleTypes 배열 또는 단일 candleType */ + /** TIMEFRAME 노드 — candleTypes 배열 또는 단일 candleType (stale 1m 제거 반영) */ static void addTimeframeCandleTypes(JsonNode timeframeNode, Set out) { - JsonNode arr = timeframeNode.path("candleTypes"); - if (arr.isArray() && !arr.isEmpty()) { - for (JsonNode t : arr) { - String ct = LiveStrategyTimeframeService.normalize(t.asText("")); - if (!ct.isBlank()) out.add(ct); - } - return; + for (String ct : StrategyDslTimeframeNormalizer.resolveStartCandleTypes(timeframeNode)) { + out.add(ct); } - out.add(LiveStrategyTimeframeService.normalize(timeframeNode.path("candleType").asText("1m"))); } } diff --git a/backend/src/main/java/com/goldenchart/service/StrategyDslTimeframeNormalizer.java b/backend/src/main/java/com/goldenchart/service/StrategyDslTimeframeNormalizer.java index 6e34946..25e88ce 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyDslTimeframeNormalizer.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyDslTimeframeNormalizer.java @@ -3,11 +3,14 @@ package com.goldenchart.service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import java.util.ArrayList; import java.util.LinkedHashSet; +import java.util.List; import java.util.Set; import java.util.UUID; import java.util.regex.Pattern; @@ -39,9 +42,9 @@ public class StrategyDslTimeframeNormalizer { if (root == null || root.isNull()) return root; if ("TIMEFRAME".equals(root.path("type").asText(""))) { - // START 다중 분봉(candleTypes) — 편집기 저장값 유지, candleType 은 배열 첫 값과 동기화 + // START 다중 분봉(candleTypes) — stale 1m 제거 후 candleType 동기화 if (hasCandleTypesArray(root)) { - return syncTimeframePrimary(root); + return syncTimeframePrimary(reconcileStartCandleTypes(root)); } String current = LiveStrategyTimeframeService.normalize(root.path("candleType").asText("1m")); // 편집기에서 명시한 단일 분봉(3m, 5m 등)은 조건 노드 기본 1m 추론으로 덮어쓰지 않음 @@ -270,6 +273,71 @@ public class StrategyDslTimeframeNormalizer { return arr.isArray() && !arr.isEmpty(); } + /** + * START candleTypes 에 남은 레거시 1m 제거. + * + */ + public static Set resolveStartCandleTypes(JsonNode timeframeNode) { + ObjectNode reconciled = reconcileStartCandleTypesStatic(timeframeNode); + Set out = new LinkedHashSet<>(); + JsonNode arr = reconciled.path("candleTypes"); + if (arr.isArray() && !arr.isEmpty()) { + for (JsonNode t : arr) { + String ct = LiveStrategyTimeframeService.normalize(t.asText("")); + if (!ct.isBlank()) out.add(ct); + } + return out; + } + out.add(LiveStrategyTimeframeService.normalize( + reconciled.path("candleType").asText("1m"))); + return out; + } + + private ObjectNode reconcileStartCandleTypes(JsonNode tf) { + return reconcileStartCandleTypesStatic(tf); + } + + private static ObjectNode reconcileStartCandleTypesStatic(JsonNode tf) { + ObjectNode copy = tf.deepCopy(); + JsonNode arr = tf.path("candleTypes"); + if (!arr.isArray() || arr.size() < 2) return copy; + + JsonNode subtree = firstTimeframeChild(tf); + Set explicit = collectExplicitCandleTypes(subtree); + if (explicit.contains("1m")) return copy; + + List normalized = new ArrayList<>(); + for (JsonNode t : arr) { + String ct = LiveStrategyTimeframeService.normalize(t.asText("")); + if (!ct.isBlank() && !normalized.contains(ct)) normalized.add(ct); + } + if (!normalized.contains("1m")) return copy; + + long non1mCount = normalized.stream().filter(ct -> !"1m".equals(ct)).count(); + if (non1mCount == 0) return copy; + // [1m, 3m, 5m] 등 1m+복수 상위봉 OR — 1m 의도적 선택으로 간주 + if (non1mCount >= 2) return copy; + + ArrayNode newArr = JsonNodeFactory.instance.arrayNode(); + for (String ct : normalized) { + if (!"1m".equals(ct)) newArr.add(ct); + } + if (newArr.isEmpty()) return copy; + copy.set("candleTypes", newArr); + copy.put("candleType", LiveStrategyTimeframeService.normalize(newArr.get(0).asText("1m"))); + return copy; + } + + private static JsonNode firstTimeframeChild(JsonNode timeframeNode) { + JsonNode children = timeframeNode.path("children"); + if (children.isArray() && !children.isEmpty()) return children.get(0); + return timeframeNode.path("child"); + } + /** candleTypes[] 가 있으면 candleType 을 첫 항목과 맞춤 (stale 1m 방지) */ private ObjectNode syncTimeframePrimary(JsonNode tf) { ObjectNode copy = tf.deepCopy(); diff --git a/backend/src/main/java/com/goldenchart/service/StrategyService.java b/backend/src/main/java/com/goldenchart/service/StrategyService.java index d2b0d4c..7a28758 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyService.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyService.java @@ -57,15 +57,20 @@ public class StrategyService { try { String strategyName = entity.getName() != null ? entity.getName() : dto.getName(); + String buyJson = dto.getBuyCondition() != null + ? objectMapper.writeValueAsString(dto.getBuyCondition()) : null; + String sellJson = dto.getSellCondition() != null + ? objectMapper.writeValueAsString(dto.getSellCondition()) : null; + if (buyJson != null && sellJson != null) { + sellJson = dslTimeframeNormalizer.alignSellStartTimeframeToBuy(buyJson, sellJson); + } entity.setBuyConditionJson( - dto.getBuyCondition() != null - ? dslTimeframeNormalizer.normalizeJson( - objectMapper.writeValueAsString(dto.getBuyCondition()), strategyName) + buyJson != null + ? dslTimeframeNormalizer.normalizeJson(buyJson, strategyName) : null); entity.setSellConditionJson( - dto.getSellCondition() != null - ? dslTimeframeNormalizer.normalizeJson( - objectMapper.writeValueAsString(dto.getSellCondition()), strategyName) + sellJson != null + ? dslTimeframeNormalizer.normalizeJson(sellJson, strategyName) : null); } catch (Exception e) { log.warn("전략 DSL 직렬화 실패: {}", e.getMessage()); @@ -93,9 +98,19 @@ public class StrategyService { changed = true; } } - if (entity.getSellConditionJson() != null && !entity.getSellConditionJson().isBlank()) { - String fixed = dslTimeframeNormalizer.normalizeJson( - entity.getSellConditionJson(), entity.getName()); + String sellJson = entity.getSellConditionJson(); + if (entity.getBuyConditionJson() != null && !entity.getBuyConditionJson().isBlank() + && sellJson != null && !sellJson.isBlank()) { + String aligned = dslTimeframeNormalizer.alignSellStartTimeframeToBuy( + entity.getBuyConditionJson(), sellJson); + if (!aligned.equals(sellJson)) { + entity.setSellConditionJson(aligned); + sellJson = aligned; + changed = true; + } + } + if (sellJson != null && !sellJson.isBlank()) { + String fixed = dslTimeframeNormalizer.normalizeJson(sellJson, entity.getName()); if (!fixed.equals(entity.getSellConditionJson())) { entity.setSellConditionJson(fixed); changed = true; diff --git a/backend/src/main/java/com/goldenchart/service/StrategyTriggerBranchEvaluator.java b/backend/src/main/java/com/goldenchart/service/StrategyTriggerBranchEvaluator.java index 26b961a..462f687 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyTriggerBranchEvaluator.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyTriggerBranchEvaluator.java @@ -163,17 +163,7 @@ public class StrategyTriggerBranchEvaluator { /** START 다중 분봉 — 각 마감봉마다 동일 조건 트리를 독립 평가 */ private static List readTimeframeCandleTypes(JsonNode timeframeNode) { - JsonNode arr = timeframeNode.path("candleTypes"); - if (arr.isArray() && !arr.isEmpty()) { - List list = new ArrayList<>(); - for (JsonNode t : arr) { - String ct = LiveStrategyTimeframeService.normalize(t.asText("")); - if (!ct.isBlank() && !list.contains(ct)) list.add(ct); - } - if (!list.isEmpty()) return list; - } - return List.of(LiveStrategyTimeframeService.normalize( - timeframeNode.path("candleType").asText("1m"))); + return new ArrayList<>(StrategyDslTimeframeNormalizer.resolveStartCandleTypes(timeframeNode)); } private final class TriggerBranchRule implements Rule { diff --git a/backend/src/test/java/com/goldenchart/service/StrategyConditionTimeframeServiceTest.java b/backend/src/test/java/com/goldenchart/service/StrategyConditionTimeframeServiceTest.java index e529de0..a41cefd 100644 --- a/backend/src/test/java/com/goldenchart/service/StrategyConditionTimeframeServiceTest.java +++ b/backend/src/test/java/com/goldenchart/service/StrategyConditionTimeframeServiceTest.java @@ -159,6 +159,42 @@ class StrategyConditionTimeframeServiceTest { assertFalse(service.usesTimeframe(5L, "1m")); } + @Test + void collectForStrategy_stale1m5mArrayUses5mOnlyWhenConditionsSay5m() { + GcStrategy strategy = new GcStrategy(); + strategy.setId(6L); + strategy.setName("알트레이어 매수 알림"); + strategy.setBuyConditionJson(""" + { + "type": "TIMEFRAME", + "candleType": "1m", + "candleTypes": ["1m", "5m"], + "children": [{ + "type": "CONDITION", + "condition": { + "indicatorType": "CCI", + "leftCandleType": "5m", + "rightCandleType": "5m" + } + }] + } + """); + strategy.setSellConditionJson(null); + when(strategyRepo.findById(6L)).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 -> { + String json = inv.getArgument(0); + String name = inv.getArgument(1); + return new StrategyDslTimeframeNormalizer(objectMapper).normalizeJson(json, name); + }); + + Set tfs = service.collectForStrategy(6L); + assertEquals(Set.of("5m"), tfs); + assertFalse(service.usesTimeframe(6L, "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 1e21bce..44b09bf 100644 --- a/backend/src/test/java/com/goldenchart/service/StrategyDslTimeframeNormalizerTest.java +++ b/backend/src/test/java/com/goldenchart/service/StrategyDslTimeframeNormalizerTest.java @@ -4,6 +4,8 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; +import java.util.Set; + import static org.junit.jupiter.api.Assertions.*; class StrategyDslTimeframeNormalizerTest { @@ -49,6 +51,50 @@ class StrategyDslTimeframeNormalizerTest { assertEquals("3m", root.path("candleType").asText()); } + @Test + void normalize_dropsStale1mWhenConditionsUse5mOnly() throws Exception { + String json = """ + { + "type": "TIMEFRAME", + "candleType": "1m", + "candleTypes": ["1m", "5m"], + "children": [{ + "type": "CONDITION", + "condition": { + "indicatorType": "CCI", + "leftCandleType": "5m", + "rightCandleType": "5m" + } + }] + } + """; + String normalized = normalizer.normalizeJson(json, "알트레이어 매수 알림"); + JsonNode root = objectMapper.readTree(normalized); + assertEquals(1, root.path("candleTypes").size()); + assertEquals("5m", root.path("candleTypes").get(0).asText()); + assertEquals("5m", root.path("candleType").asText()); + } + + @Test + void resolveStartCandleTypes_dropsStale1mPair() throws Exception { + String json = """ + { + "type": "TIMEFRAME", + "candleType": "1m", + "candleTypes": ["1m", "5m"], + "children": [{ + "type": "CONDITION", + "condition": { + "indicatorType": "CCI", + "leftCandleType": "5m" + } + }] + } + """; + JsonNode root = objectMapper.readTree(json); + assertEquals(Set.of("5m"), StrategyDslTimeframeNormalizer.resolveStartCandleTypes(root)); + } + @Test void normalize_preservesMultiCandleTypesOnSave() throws Exception { String json = """