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.ObjectNode; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import java.util.LinkedHashSet; import java.util.Set; import java.util.UUID; import java.util.regex.Pattern; /** * 전략 DSL에 TIMEFRAME 래핑이 없으면 평가 분봉을 추론해 래핑한다. * (프론트 encodeConditionForSave 와 동일 목적) */ @Component @RequiredArgsConstructor public class StrategyDslTimeframeNormalizer { private static final Pattern NAME_3M = Pattern.compile("3\\s*분|3m", Pattern.CASE_INSENSITIVE); private static final Pattern NAME_5M = Pattern.compile("5\\s*분|5m", Pattern.CASE_INSENSITIVE); private static final Pattern NAME_10M = Pattern.compile("10\\s*분|10m", Pattern.CASE_INSENSITIVE); private static final Pattern NAME_15M = Pattern.compile("15\\s*분|15m", Pattern.CASE_INSENSITIVE); private static final Pattern NAME_30M = Pattern.compile("30\\s*분|30m", Pattern.CASE_INSENSITIVE); private static final Pattern NAME_1H = Pattern.compile("1\\s*시간|1h", Pattern.CASE_INSENSITIVE); private static final Pattern NAME_4H = Pattern.compile("4\\s*시간|4h", Pattern.CASE_INSENSITIVE); private static final Pattern NAME_1D = Pattern.compile("일봉|1\\s*일|1d", Pattern.CASE_INSENSITIVE); private final ObjectMapper objectMapper; public JsonNode normalize(JsonNode root) { return normalize(root, null); } public JsonNode normalize(JsonNode root, String strategyName) { if (root == null || root.isNull()) return root; if ("TIMEFRAME".equals(root.path("type").asText(""))) { // START 다중 분봉(candleTypes) — 편집기 저장값 유지 if (hasMultipleCandleTypes(root)) { return root; } String expected = inferPrimaryCandleType(root, strategyName); String current = LiveStrategyTimeframeService.normalize(root.path("candleType").asText("1m")); if (!expected.equals(current)) { return copyTimeframeWithCandleType(root, expected); } return root; } if (hasTimeframeScope(root)) return root; String primary = inferPrimaryCandleType(root, strategyName); ObjectNode wrapped = objectMapper.createObjectNode(); wrapped.put("id", UUID.randomUUID().toString()); wrapped.put("type", "TIMEFRAME"); wrapped.put("candleType", primary); ArrayNode children = objectMapper.createArrayNode(); children.add(root); wrapped.set("children", children); return wrapped; } public String normalizeJson(String json) { return normalizeJson(json, null); } public String normalizeJson(String json, String strategyName) { if (json == null || json.isBlank()) return json; try { JsonNode root = objectMapper.readTree(json); JsonNode normalized = normalize(root, strategyName); return objectMapper.writeValueAsString(normalized); } catch (Exception e) { return json; } } /** 전략 이름에서 평가 분봉 추론 (예: cci_3분봉 테스트 → 3m) */ public static String inferFromStrategyName(String name) { if (name == null || name.isBlank()) return null; if (NAME_3M.matcher(name).find()) return "3m"; if (NAME_5M.matcher(name).find()) return "5m"; if (NAME_10M.matcher(name).find()) return "10m"; if (NAME_15M.matcher(name).find()) return "15m"; if (NAME_30M.matcher(name).find()) return "30m"; if (NAME_4H.matcher(name).find()) return "4h"; if (NAME_1H.matcher(name).find()) return "1h"; if (NAME_1D.matcher(name).find()) return "1d"; return null; } /** 루트 TIMEFRAME 또는 AND|OR + 전부 TIMEFRAME 자식 */ public static boolean hasTimeframeScope(JsonNode node) { if (node == null || node.isNull()) return false; String type = node.path("type").asText(""); if ("TIMEFRAME".equals(type)) return true; if ("AND".equals(type) || "OR".equals(type)) { JsonNode children = node.path("children"); if (children.isArray() && !children.isEmpty()) { for (JsonNode c : children) { if (!"TIMEFRAME".equals(c.path("type").asText(""))) return false; } return true; } } return findNestedTimeframe(node) != null; } private static JsonNode findNestedTimeframe(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 c : children) { JsonNode found = findNestedTimeframe(c); if (found != null) return found; } } JsonNode child = node.path("child"); if (!child.isMissingNode() && !child.isNull()) return findNestedTimeframe(child); return null; } /** * 트리에서 명시적 분봉 수집 — CONDITION 의 left/rightCandleType, TIMEFRAME 노드. */ public static Set collectExplicitCandleTypes(JsonNode node) { Set out = new LinkedHashSet<>(); collectExplicitCandleTypes(node, out); return out; } /** 조건 기본값 1m 은 제외하고 의미 있는 분봉만 */ public static Set collectMeaningfulCandleTypes(JsonNode node) { Set all = collectExplicitCandleTypes(node); Set meaningful = new LinkedHashSet<>(); for (String ct : all) { if (!"1m".equals(ct)) meaningful.add(ct); } return meaningful; } private static void collectExplicitCandleTypes(JsonNode node, Set out) { if (node == null || node.isNull()) return; String type = node.path("type").asText(""); if ("TIMEFRAME".equals(type)) { StrategyConditionTimeframeService.addTimeframeCandleTypes(node, out); } if ("CONDITION".equals(type)) { 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) collectExplicitCandleTypes(c, out); } JsonNode child = node.path("child"); if (!child.isMissingNode() && !child.isNull()) collectExplicitCandleTypes(child, out); } public static String inferPrimaryCandleType(JsonNode node) { return inferPrimaryCandleType(node, null); } public static String inferPrimaryCandleType(JsonNode node, String strategyName) { JsonNode nested = findNestedTimeframe(node); if (nested != null) { String ct = LiveStrategyTimeframeService.normalize(nested.path("candleType").asText("1m")); if (!"1m".equals(ct)) return ct; String fromName = inferFromStrategyName(strategyName); if (fromName != null) return fromName; return ct; } Set meaningful = collectMeaningfulCandleTypes(node); if (!meaningful.isEmpty()) return meaningful.iterator().next(); String fromName = inferFromStrategyName(strategyName); if (fromName != null) return fromName; Set explicit = collectExplicitCandleTypes(node); if (explicit.size() == 1) return explicit.iterator().next(); return "1m"; } private static boolean hasMultipleCandleTypes(JsonNode timeframeNode) { JsonNode arr = timeframeNode.path("candleTypes"); return arr.isArray() && arr.size() > 1; } private ObjectNode copyTimeframeWithCandleType(JsonNode tf, String candleType) { ObjectNode copy = tf.deepCopy(); copy.put("candleType", LiveStrategyTimeframeService.normalize(candleType)); return copy; } }