Files
goldenChart/backend/src/main/java/com/goldenchart/service/StrategyDslTimeframeNormalizer.java
T
2026-05-28 01:45:53 +09:00

358 lines
15 KiB
Java

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(""))) {
// START 다중 분봉(candleTypes) — stale 1m 제거 후 candleType 동기화
if (hasCandleTypesArray(root)) {
return syncTimeframePrimary(reconcileStartCandleTypes(root));
}
String current = LiveStrategyTimeframeService.normalize(root.path("candleType").asText("1m"));
// 편집기에서 명시한 단일 분봉(3m, 5m 등)은 조건 노드 기본 1m 추론으로 덮어쓰지 않음
if (!"1m".equals(current)) {
return root;
}
String expected = inferPrimaryCandleType(root, strategyName);
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;
}
}
/**
* 매도 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) {
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<String> collectExplicitCandleTypes(JsonNode node) {
Set<String> out = new LinkedHashSet<>();
collectExplicitCandleTypes(node, out);
return out;
}
/** 조건 기본값 1m 은 제외하고 의미 있는 분봉만 */
public static Set<String> collectMeaningfulCandleTypes(JsonNode node) {
Set<String> all = collectExplicitCandleTypes(node);
Set<String> meaningful = new LinkedHashSet<>();
for (String ct : all) {
if (!"1m".equals(ct)) meaningful.add(ct);
}
return meaningful;
}
private static void collectExplicitCandleTypes(JsonNode node, Set<String> 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<String> meaningful = collectMeaningfulCandleTypes(node);
if (!meaningful.isEmpty()) return meaningful.iterator().next();
String fromName = inferFromStrategyName(strategyName);
if (fromName != null) return fromName;
Set<String> 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 candleTypes 에 남은 레거시 1m 제거.
* <ul>
* <li>[1m, 5m] + 조건 leftCandleType 5m → [5m] (편집기 5m만 선택·1m 기본값 잔존)</li>
* <li>[1m, 3m, 5m] 다중 OR 선택은 유지</li>
* <li>조건에 1m 명시 시 유지</li>
* </ul>
*/
public static Set<String> resolveStartCandleTypes(JsonNode timeframeNode) {
ObjectNode reconciled = reconcileStartCandleTypesStatic(timeframeNode);
Set<String> 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<String> explicit = collectExplicitCandleTypes(subtree);
if (explicit.contains("1m")) return copy;
List<String> 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();
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 copyTimeframeWithCandleType(JsonNode tf, String candleType) {
ObjectNode copy = tf.deepCopy();
copy.put("candleType", LiveStrategyTimeframeService.normalize(candleType));
return copy;
}
}