diff --git a/backend/src/main/java/com/goldenchart/config/StrategyDslRepairRunner.java b/backend/src/main/java/com/goldenchart/config/StrategyDslRepairRunner.java new file mode 100644 index 0000000..3fd9f64 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/config/StrategyDslRepairRunner.java @@ -0,0 +1,36 @@ +package com.goldenchart.config; + +import com.goldenchart.entity.GcStrategy; +import com.goldenchart.repository.GcStrategyRepository; +import com.goldenchart.service.StrategyConditionTimeframeService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +/** + * 기동 시 전략 DSL TIMEFRAME·분봉 자동 보정 (레거시 3분봉 전략 등). + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class StrategyDslRepairRunner implements ApplicationRunner { + + private final GcStrategyRepository strategyRepo; + private final StrategyConditionTimeframeService conditionTimeframes; + + @Override + public void run(ApplicationArguments args) { + int count = 0; + for (GcStrategy strategy : strategyRepo.findAll()) { + if (strategy.getId() == null) continue; + conditionTimeframes.invalidate(strategy.getId()); + conditionTimeframes.collectForStrategy(strategy.getId()); + count++; + } + if (count > 0) { + log.info("[StrategyDslRepair] 전략 {}건 분봉 DSL 점검 완료", count); + } + } +} diff --git a/backend/src/main/java/com/goldenchart/service/LiveStrategySettingsService.java b/backend/src/main/java/com/goldenchart/service/LiveStrategySettingsService.java index b872535..b560cae 100644 --- a/backend/src/main/java/com/goldenchart/service/LiveStrategySettingsService.java +++ b/backend/src/main/java/com/goldenchart/service/LiveStrategySettingsService.java @@ -31,6 +31,7 @@ public class LiveStrategySettingsService { private final DynamicSubscriptionManager subscriptionManager; private final LiveStrategyEvaluator liveStrategyEvaluator; private final StrategyConditionTimeframeService conditionTimeframes; + private final StrategyService strategyService; // ── 조회 ────────────────────────────────────────────────────────────────── @@ -235,6 +236,8 @@ public class LiveStrategySettingsService { private void pinIfReady(String market, LiveStrategySettingsDto dto) { if (Boolean.TRUE.equals(dto.isLiveCheck()) && dto.getStrategyId() != null) { + strategyService.repairTimeframeWrappers(dto.getStrategyId()); + conditionTimeframes.invalidate(dto.getStrategyId()); pinAllStrategyTimeframes(market, dto.getStrategyId()); } } diff --git a/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java b/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java index e733093..5a1b57d 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java @@ -4,8 +4,8 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.goldenchart.entity.GcStrategy; import com.goldenchart.repository.GcStrategyRepository; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import java.util.LinkedHashSet; @@ -19,15 +19,27 @@ import java.util.concurrent.ConcurrentHashMap; * START / TIMEFRAME / AND|OR+TIMEFRAME 분기와 동일 규칙. */ @Service -@RequiredArgsConstructor @Slf4j public class StrategyConditionTimeframeService { private final GcStrategyRepository strategyRepo; private final ObjectMapper objectMapper; + private final StrategyDslTimeframeNormalizer dslNormalizer; + private final LiveStrategyEvaluator liveStrategyEvaluator; private final ConcurrentHashMap> cache = new ConcurrentHashMap<>(); + public StrategyConditionTimeframeService( + GcStrategyRepository strategyRepo, + ObjectMapper objectMapper, + StrategyDslTimeframeNormalizer dslNormalizer, + @Lazy LiveStrategyEvaluator liveStrategyEvaluator) { + this.strategyRepo = strategyRepo; + this.objectMapper = objectMapper; + this.dslNormalizer = dslNormalizer; + this.liveStrategyEvaluator = liveStrategyEvaluator; + } + public Set collectForStrategy(long strategyId) { return cache.computeIfAbsent(strategyId, this::loadFromDb); } @@ -53,33 +65,79 @@ public class StrategyConditionTimeframeService { Optional opt = strategyRepo.findById(strategyId); if (opt.isEmpty()) return Set.of("1m"); GcStrategy strategy = opt.get(); + ensureDslRepaired(strategy); + Set out = new LinkedHashSet<>(); - collectFromJson(strategy.getBuyConditionJson(), out); - collectFromJson(strategy.getSellConditionJson(), out); - return out.isEmpty() ? Set.of("1m") : out; + collectFromJson(strategy.getBuyConditionJson(), out, strategy.getName()); + collectFromJson(strategy.getSellConditionJson(), out, strategy.getName()); + + if (out.isEmpty()) { + String fromName = StrategyDslTimeframeNormalizer.inferFromStrategyName(strategy.getName()); + return Set.of(fromName != null ? fromName : "1m"); + } + return out; } - private void collectFromJson(String json, Set out) { - if (json == null || json.isBlank()) return; - try { - collectFromNode(objectMapper.readTree(json), out); - } catch (Exception e) { - log.warn("[StrategyTimeframes] JSON 파싱 실패: {}", e.getMessage()); - out.add("1m"); + /** TIMEFRAME 누락·잘못된 1m 래핑을 전략명·DSL 기준으로 DB 보정 */ + private void ensureDslRepaired(GcStrategy strategy) { + boolean changed = false; + String name = strategy.getName(); + + if (strategy.getBuyConditionJson() != null && !strategy.getBuyConditionJson().isBlank()) { + String fixed = dslNormalizer.normalizeJson(strategy.getBuyConditionJson(), name); + if (!fixed.equals(strategy.getBuyConditionJson())) { + strategy.setBuyConditionJson(fixed); + changed = true; + } + } + if (strategy.getSellConditionJson() != null && !strategy.getSellConditionJson().isBlank()) { + String fixed = dslNormalizer.normalizeJson(strategy.getSellConditionJson(), name); + if (!fixed.equals(strategy.getSellConditionJson())) { + strategy.setSellConditionJson(fixed); + changed = true; + } + } + + if (changed) { + strategyRepo.save(strategy); + liveStrategyEvaluator.invalidateStrategy(strategy.getId()); + log.info("[StrategyTimeframes] DSL 분봉 보정 strategyId={} name={}", + strategy.getId(), name); } } - private void collectFromNode(JsonNode node, Set out) { + private void collectFromJson(String json, Set out, String strategyName) { + if (json == null || json.isBlank()) return; + try { + collectFromNode(objectMapper.readTree(json), out, strategyName); + } catch (Exception e) { + log.warn("[StrategyTimeframes] JSON 파싱 실패: {}", e.getMessage()); + addFallbackTimeframe(out, strategyName); + } + } + + private void collectFromNode(JsonNode node, Set out, String strategyName) { if (node == null || node.isNull()) return; if (collectTimeframesFromNode(node, out)) return; - Set explicit = StrategyDslTimeframeNormalizer.collectExplicitCandleTypes(node); - if (!explicit.isEmpty()) { - out.addAll(explicit); + Set meaningful = StrategyDslTimeframeNormalizer.collectMeaningfulCandleTypes(node); + if (!meaningful.isEmpty()) { + out.addAll(meaningful); return; } - // TIMEFRAME·명시 분봉 없음 — 기본 1m - out.add("1m"); + + String fromName = StrategyDslTimeframeNormalizer.inferFromStrategyName(strategyName); + if (fromName != null) { + out.add(fromName); + return; + } + + addFallbackTimeframe(out, strategyName); + } + + private void addFallbackTimeframe(Set out, String strategyName) { + String fromName = StrategyDslTimeframeNormalizer.inferFromStrategyName(strategyName); + out.add(fromName != null ? fromName : "1m"); } /** diff --git a/backend/src/main/java/com/goldenchart/service/StrategyDslTimeframeNormalizer.java b/backend/src/main/java/com/goldenchart/service/StrategyDslTimeframeNormalizer.java index 5ea972e..9f85e2c 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyDslTimeframeNormalizer.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyDslTimeframeNormalizer.java @@ -10,6 +10,7 @@ import org.springframework.stereotype.Component; import java.util.LinkedHashSet; import java.util.Set; import java.util.UUID; +import java.util.regex.Pattern; /** * 전략 DSL에 TIMEFRAME 래핑이 없으면 평가 분봉을 추론해 래핑한다. @@ -19,13 +20,36 @@ import java.util.UUID; @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) { + 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(""))) { + 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); + String primary = inferPrimaryCandleType(root, strategyName); ObjectNode wrapped = objectMapper.createObjectNode(); wrapped.put("id", UUID.randomUUID().toString()); wrapped.put("type", "TIMEFRAME"); @@ -37,16 +61,34 @@ public class StrategyDslTimeframeNormalizer { } 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); + 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; @@ -88,6 +130,16 @@ public class StrategyDslTimeframeNormalizer { 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; @@ -117,21 +169,33 @@ public class StrategyDslTimeframeNormalizer { } 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) { - return LiveStrategyTimeframeService.normalize(nested.path("candleType").asText("1m")); + 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.isEmpty()) return "1m"; - - Set 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(); + return "1m"; + } + + private ObjectNode copyTimeframeWithCandleType(JsonNode tf, String candleType) { + ObjectNode copy = tf.deepCopy(); + copy.put("candleType", LiveStrategyTimeframeService.normalize(candleType)); + return copy; } } diff --git a/backend/src/main/java/com/goldenchart/service/StrategyService.java b/backend/src/main/java/com/goldenchart/service/StrategyService.java index e2bca1f..d2b0d4c 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyService.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyService.java @@ -56,13 +56,16 @@ public class StrategyService { else if (entity.getEnabled() == null) entity.setEnabled(true); try { + String strategyName = entity.getName() != null ? entity.getName() : dto.getName(); entity.setBuyConditionJson( dto.getBuyCondition() != null - ? dslTimeframeNormalizer.normalizeJson(objectMapper.writeValueAsString(dto.getBuyCondition())) + ? dslTimeframeNormalizer.normalizeJson( + objectMapper.writeValueAsString(dto.getBuyCondition()), strategyName) : null); entity.setSellConditionJson( dto.getSellCondition() != null - ? dslTimeframeNormalizer.normalizeJson(objectMapper.writeValueAsString(dto.getSellCondition())) + ? dslTimeframeNormalizer.normalizeJson( + objectMapper.writeValueAsString(dto.getSellCondition()), strategyName) : null); } catch (Exception e) { log.warn("전략 DSL 직렬화 실패: {}", e.getMessage()); @@ -83,14 +86,16 @@ public class StrategyService { return repository.findById(id).map(entity -> { boolean changed = false; if (entity.getBuyConditionJson() != null && !entity.getBuyConditionJson().isBlank()) { - String fixed = dslTimeframeNormalizer.normalizeJson(entity.getBuyConditionJson()); + String fixed = dslTimeframeNormalizer.normalizeJson( + entity.getBuyConditionJson(), entity.getName()); if (!fixed.equals(entity.getBuyConditionJson())) { entity.setBuyConditionJson(fixed); changed = true; } } if (entity.getSellConditionJson() != null && !entity.getSellConditionJson().isBlank()) { - String fixed = dslTimeframeNormalizer.normalizeJson(entity.getSellConditionJson()); + String fixed = dslTimeframeNormalizer.normalizeJson( + entity.getSellConditionJson(), entity.getName()); if (!fixed.equals(entity.getSellConditionJson())) { entity.setSellConditionJson(fixed); changed = true; diff --git a/frontend/src/components/virtual/VirtualGridUnifiedHeader.tsx b/frontend/src/components/virtual/VirtualGridUnifiedHeader.tsx index a1a82f8..bea1a3c 100644 --- a/frontend/src/components/virtual/VirtualGridUnifiedHeader.tsx +++ b/frontend/src/components/virtual/VirtualGridUnifiedHeader.tsx @@ -199,16 +199,6 @@ export const VirtualViewHeaderControls: React.FC = ({ - {viewMode === 'detail' && ( - <> -