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; /** * 전략 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(""))) { return writeResolvedTimeframe(root, strategyName); } 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 candleTypes = objectMapper.createArrayNode(); candleTypes.add(primary); wrapped.set("candleTypes", candleTypes); 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; } } /** * 매도 DSL START TIMEFRAME 을 매수와 동일 분봉으로 맞춤. * (편집기 저장 전 매도만 1m 이 남은 레거시 전략 보정) */ public String alignSellStartTimeframeToBuy(String buyJson, String sellJson) { if (buyJson == null || buyJson.isBlank() || sellJson == null || sellJson.isBlank()) { return sellJson; } try { JsonNode buyRoot = objectMapper.readTree(buyJson); JsonNode sellRoot = objectMapper.readTree(sellJson); JsonNode buyTf = primaryStartTimeframeNode(buyRoot); if (buyTf == null || !hasExplicitStartTimeframe(buyTf)) return sellJson; ObjectNode sellCopy = (ObjectNode) sellRoot.deepCopy(); JsonNode sellTf = primaryStartTimeframeNode(sellCopy); if (sellTf == null || !sellTf.isObject()) return sellJson; copyIntoStartTimeframe(buyTf, (ObjectNode) sellTf); return objectMapper.writeValueAsString(sellCopy); } catch (Exception e) { return sellJson; } } private static boolean hasExplicitStartTimeframe(JsonNode buyTf) { Set resolved = resolveStartCandleTypes(buyTf); if (resolved.size() > 1) return true; if (resolved.size() == 1 && !resolved.contains("1m")) return true; if (hasCandleTypesArray(buyTf)) return true; String ct = LiveStrategyTimeframeService.normalize(buyTf.path("candleType").asText("1m")); return !"1m".equals(ct); } private static JsonNode primaryStartTimeframeNode(JsonNode root) { if (root == null || root.isNull()) return null; String type = root.path("type").asText(""); if ("TIMEFRAME".equals(type)) return root; if ("AND".equals(type) || "OR".equals(type)) { JsonNode children = root.path("children"); if (children.isArray() && !children.isEmpty() && allTimeframeChildren(children)) { return children.get(0); } } return null; } private void copyIntoStartTimeframe(JsonNode from, ObjectNode to) { if (hasCandleTypesArray(from)) { ArrayNode arr = objectMapper.createArrayNode(); for (JsonNode t : from.path("candleTypes")) { arr.add(LiveStrategyTimeframeService.normalize(t.asText(""))); } to.set("candleTypes", arr); to.put("candleType", LiveStrategyTimeframeService.normalize(arr.get(0).asText("1m"))); } else { to.remove("candleTypes"); to.put("candleType", LiveStrategyTimeframeService.normalize(from.path("candleType").asText("1m"))); } } private static boolean allTimeframeChildren(JsonNode children) { for (JsonNode c : children) { if (!"TIMEFRAME".equals(c.path("type").asText(""))) return false; } return true; } /** 전략 이름에서 평가 분봉 추론 (예: 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; } /** 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; 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 hasCandleTypesArray(JsonNode timeframeNode) { JsonNode arr = timeframeNode.path("candleTypes"); return arr.isArray() && !arr.isEmpty(); } /** * START 평가 분봉 해석 — 조건 left/rightCandleType · START candleTypes/candleType 통합. * 1m·3m·5m·15m·1h 등 모든 분봉에 동일 규칙 적용. */ public static Set resolveStartCandleTypes(JsonNode timeframeNode) { ObjectNode reconciled = reconcileStartCandleTypesStatic(timeframeNode); Set fromStart = readStartCandleTypesFromNode(reconciled); JsonNode subtree = firstTimeframeChild(timeframeNode); Set fromConditions = collectExplicitCandleTypes(subtree); if (fromConditions.isEmpty()) { return fromStart; } if (fromConditions.size() == 1) { String required = fromConditions.iterator().next(); // START에 5m 등 명시 분봉이 있으면 조건 노드 기본값 1m 보다 우선 (편집기 START 변경 직후) if ("1m".equals(required) && hasExplicitNon1mStart(fromStart)) { if (fromStart.size() == 1) { return fromStart; } return dropStale1mFromStart(fromStart, fromConditions); } // 레거시 [1m, X] — 조건이 X 단일 분봉이면 X만 if (fromStart.size() == 2 && fromStart.contains("1m") && fromStart.contains(required)) { return Set.of(required); } // START 1m 기본값만 있고 조건은 다른 분봉 if (isLegacyDefaultStartOnly(fromStart) && !"1m".equals(required)) { return Set.of(required); } // 의도적 다중 OR [1m,3m,5m] 또는 [3m,5m] — START 배열 유지 if (fromStart.size() >= 2 && fromStart.contains(required)) { return fromStart; } if (!fromStart.equals(Set.of(required))) { return Set.of(required); } return fromStart; } // 조건에 복수 분봉 명시 — START가 상위집합이면 START 유지 (다중 OR) if (fromStart.containsAll(fromConditions) && fromStart.size() >= fromConditions.size()) { return dropStale1mFromStart(fromStart, fromConditions); } return fromConditions; } /** * START 에만 남은 stale 1m 제거. * 조건 노드 기본값 leftCandleType=1m 만 있을 때는 1m 을 평가 분봉으로 보지 않음. * 의도적 다중 OR(조건에 1m·3m 등 복수 선언)은 유지. */ private static Set dropStale1mFromStart(Set fromStart, Set fromConditions) { if (!fromStart.contains("1m") || !hasExplicitNon1mStart(fromStart)) { return fromStart; } 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) { Set resolved = resolveStartCandleTypes(root); if (resolved.isEmpty()) { resolved = Set.of(inferPrimaryCandleType(root, strategyName)); } return copyTimeframeWithCandleTypes(root, resolved); } private static Set readStartCandleTypesFromNode(JsonNode tf) { Set out = new LinkedHashSet<>(); JsonNode arr = tf.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(tf.path("candleType").asText("1m"))); return out; } private static boolean isLegacyDefaultStartOnly(Set fromStart) { return fromStart.size() == 1 && fromStart.contains("1m"); } /** START TIMEFRAME에 1m 이 아닌 분봉이 명시되어 있는지 */ private static boolean hasExplicitNon1mStart(Set fromStart) { for (String ct : fromStart) { if (!"1m".equals(ct)) return true; } return false; } 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 conditionDeclared = collectConditionDeclaredCandleTypes(subtree); if (conditionDeclared.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; 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(); 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 copyTimeframeWithCandleTypes(JsonNode tf, Set types) { ObjectNode copy = tf.deepCopy(); ArrayNode arr = JsonNodeFactory.instance.arrayNode(); List ordered = new ArrayList<>(types); for (String ct : ordered) { arr.add(LiveStrategyTimeframeService.normalize(ct)); } if (arr.isEmpty()) { arr.add("1m"); } copy.set("candleTypes", arr); copy.put("candleType", arr.get(0).asText()); return copy; } /** @deprecated 단일 분봉 — {@link #copyTimeframeWithCandleTypes} 사용 */ private ObjectNode copyTimeframeWithCandleType(JsonNode tf, String candleType) { return copyTimeframeWithCandleTypes(tf, Set.of(candleType)); } }