전략알림 수정

This commit is contained in:
Macbook
2026-05-27 16:51:57 +09:00
parent d7ceb8cbfd
commit aca895e9fd
13 changed files with 701 additions and 123 deletions
@@ -57,6 +57,14 @@ public class StrategyController {
return ResponseEntity.ok(conditionTimeframes.collectForStrategyList(id));
}
/** TIMEFRAME 래핑 누락 DSL 보정 (3분봉 전략이 1분봉으로 평가되는 경우) */
@PostMapping("/{id}/repair-timeframes")
public ResponseEntity<StrategyDto> repairTimeframes(@PathVariable Long id) {
return service.repairTimeframeWrappers(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
/** 전략 삭제 */
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
@@ -72,7 +72,13 @@ public class StrategyConditionTimeframeService {
private void collectFromNode(JsonNode node, Set<String> out) {
if (node == null || node.isNull()) return;
if (collectTimeframesFromNode(node, out)) return;
// TIMEFRAME 래핑 없는 레거시 트리 — 기본 1m
Set<String> explicit = StrategyDslTimeframeNormalizer.collectExplicitCandleTypes(node);
if (!explicit.isEmpty()) {
out.addAll(explicit);
return;
}
// TIMEFRAME·명시 분봉 없음 — 기본 1m
out.add("1m");
}
@@ -0,0 +1,137 @@
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;
/**
* 전략 DSL에 TIMEFRAME 래핑이 없으면 평가 분봉을 추론해 래핑한다.
* (프론트 encodeConditionForSave 와 동일 목적)
*/
@Component
@RequiredArgsConstructor
public class StrategyDslTimeframeNormalizer {
private final ObjectMapper objectMapper;
public JsonNode normalize(JsonNode root) {
if (root == null || root.isNull()) return root;
if (hasTimeframeScope(root)) return root;
String primary = inferPrimaryCandleType(root);
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) {
if (json == null || json.isBlank()) return json;
try {
JsonNode root = objectMapper.readTree(json);
JsonNode normalized = normalize(root);
return objectMapper.writeValueAsString(normalized);
} catch (Exception e) {
return json;
}
}
/** 루트 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;
}
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)) {
out.add(LiveStrategyTimeframeService.normalize(node.path("candleType").asText("1m")));
}
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) {
JsonNode nested = findNestedTimeframe(node);
if (nested != null) {
return LiveStrategyTimeframeService.normalize(nested.path("candleType").asText("1m"));
}
Set<String> explicit = collectExplicitCandleTypes(node);
if (explicit.isEmpty()) return "1m";
Set<String> non1m = new LinkedHashSet<>();
for (String ct : explicit) {
if (!"1m".equals(ct)) non1m.add(ct);
}
if (non1m.size() == 1) return non1m.iterator().next();
if (non1m.size() > 1) return non1m.iterator().next();
if (explicit.size() == 1) return explicit.iterator().next();
return explicit.iterator().next();
}
}
@@ -22,6 +22,7 @@ public class StrategyService {
private final ObjectMapper objectMapper;
private final StrategyConditionTimeframeService conditionTimeframes;
private final LiveStrategyEvaluator liveStrategyEvaluator;
private final StrategyDslTimeframeNormalizer dslTimeframeNormalizer;
// ── 조회 ──────────────────────────────────────────────────────────────────
@@ -56,9 +57,13 @@ public class StrategyService {
try {
entity.setBuyConditionJson(
dto.getBuyCondition() != null ? objectMapper.writeValueAsString(dto.getBuyCondition()) : null);
dto.getBuyCondition() != null
? dslTimeframeNormalizer.normalizeJson(objectMapper.writeValueAsString(dto.getBuyCondition()))
: null);
entity.setSellConditionJson(
dto.getSellCondition() != null ? objectMapper.writeValueAsString(dto.getSellCondition()) : null);
dto.getSellCondition() != null
? dslTimeframeNormalizer.normalizeJson(objectMapper.writeValueAsString(dto.getSellCondition()))
: null);
} catch (Exception e) {
log.warn("전략 DSL 직렬화 실패: {}", e.getMessage());
}
@@ -69,6 +74,37 @@ public class StrategyService {
return toDto(saved);
}
/**
* DB에 저장된 매수/매도 DSL에 TIMEFRAME 래핑이 없으면 보정 후 저장.
* (편집기 재저장 없이 기존 3분봉 전략 등을 즉시 수정할 때)
*/
@Transactional
public Optional<StrategyDto> repairTimeframeWrappers(Long id) {
return repository.findById(id).map(entity -> {
boolean changed = false;
if (entity.getBuyConditionJson() != null && !entity.getBuyConditionJson().isBlank()) {
String fixed = dslTimeframeNormalizer.normalizeJson(entity.getBuyConditionJson());
if (!fixed.equals(entity.getBuyConditionJson())) {
entity.setBuyConditionJson(fixed);
changed = true;
}
}
if (entity.getSellConditionJson() != null && !entity.getSellConditionJson().isBlank()) {
String fixed = dslTimeframeNormalizer.normalizeJson(entity.getSellConditionJson());
if (!fixed.equals(entity.getSellConditionJson())) {
entity.setSellConditionJson(fixed);
changed = true;
}
}
if (!changed) return toDto(entity);
GcStrategy saved = repository.save(entity);
conditionTimeframes.invalidate(saved.getId());
liveStrategyEvaluator.invalidateStrategy(saved.getId());
log.info("[Strategy] TIMEFRAME 래핑 보정 strategyId={}", id);
return toDto(saved);
});
}
// ── 삭제 ──────────────────────────────────────────────────────────────────
@Transactional
@@ -107,7 +107,8 @@ public class StrategyTriggerBranchEvaluator {
return new BranchScope("SINGLE", List.of(new BranchDef(ct, sub != null && !sub.isNull() ? sub : node)));
}
return new BranchScope("SINGLE", List.of(new BranchDef("1m", node)));
String inferred = StrategyDslTimeframeNormalizer.inferPrimaryCandleType(node);
return new BranchScope("SINGLE", List.of(new BranchDef(inferred, node)));
}
/** 레거시 DSL — 루트가 TIMEFRAME이 아닐 때 하위 TIMEFRAME 래퍼 탐색 */