package com.goldenchart.service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.goldenchart.entity.GcStrategy; import com.goldenchart.repository.GcStrategyRepository; import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import java.util.LinkedHashSet; import java.util.List; import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; /** * 전략 DSL(buy/sell LogicNode)에 포함된 조건 판별 시간봉 수집. * START / TIMEFRAME / AND|OR+TIMEFRAME 분기와 동일 규칙. */ @Service @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); } public List collectForStrategyList(long strategyId) { return List.copyOf(collectForStrategy(strategyId)); } public boolean usesTimeframe(long strategyId, String candleType) { String ct = LiveStrategyTimeframeService.normalize(candleType); return collectForStrategy(strategyId).contains(ct); } public void invalidate(long strategyId) { cache.remove(strategyId); } public void invalidateAll() { cache.clear(); } private Set loadFromDb(long strategyId) { Optional opt = strategyRepo.findById(strategyId); if (opt.isEmpty()) return Set.of("1m"); GcStrategy strategy = opt.get(); ensureDslRepaired(strategy); Set buyOut = new LinkedHashSet<>(); Set sellOut = new LinkedHashSet<>(); boolean buyScoped = collectStartScopeFromJson(strategy.getBuyConditionJson(), buyOut); boolean sellScoped = collectStartScopeFromJson(strategy.getSellConditionJson(), sellOut); // 평가 분봉은 buy/sell DSL(TIMEFRAME·START)이 단일 원천 — flow_layout 은 편집기 UI 보조 if (buyScoped && !buyOut.isEmpty()) { return sanitizeEvaluationTimeframes(buyOut, strategy); } if (sellScoped && !sellOut.isEmpty()) { return sanitizeEvaluationTimeframes(sellOut, strategy); } Set out = new LinkedHashSet<>(); // 레거시: START(TIMEFRAME) 래핑 없는 DSL if (!buyScoped) { collectLegacyFromJson(strategy.getBuyConditionJson(), out, strategy.getName()); } if (!sellScoped) { collectLegacyFromJson(strategy.getSellConditionJson(), out, strategy.getName()); } if (out.isEmpty()) { Set fromFlowLayout = collectFromFlowLayoutJson(strategy.getFlowLayoutJson()); if (!fromFlowLayout.isEmpty()) { return sanitizeEvaluationTimeframes(fromFlowLayout, strategy); } String fromName = StrategyDslTimeframeNormalizer.inferFromStrategyName(strategy.getName()); return Set.of(fromName != null ? fromName : "1m"); } return sanitizeEvaluationTimeframes(out, strategy); } /** * START/flow 에만 남은 stale 1m 제거 — CONDITION 에 선언된 분봉 기준(기본값 1m 제외). */ private Set sanitizeEvaluationTimeframes(Set scope, GcStrategy strategy) { if (!scope.contains("1m") || scope.size() <= 1) return scope; Set conditionDeclared = collectConditionDeclaredFromStrategy(strategy); if (conditionDeclared.contains("1m") && conditionDeclared.size() > 1) { return scope; } if (!conditionDeclared.contains("1m")) { Set filtered = new LinkedHashSet<>(scope); filtered.remove("1m"); return filtered.isEmpty() ? scope : filtered; } if (conditionDeclared.equals(Set.of("1m"))) { Set filtered = new LinkedHashSet<>(scope); filtered.remove("1m"); return filtered.isEmpty() ? scope : filtered; } return scope; } private Set collectConditionDeclaredFromStrategy(GcStrategy strategy) { Set out = new LinkedHashSet<>(); collectConditionDeclaredFromJson(strategy.getBuyConditionJson(), out); collectConditionDeclaredFromJson(strategy.getSellConditionJson(), out); return out; } private void collectConditionDeclaredFromJson(String json, Set out) { if (json == null || json.isBlank()) return; try { out.addAll(StrategyDslTimeframeNormalizer.collectConditionDeclaredCandleTypes( objectMapper.readTree(json))); } catch (Exception e) { log.warn("[StrategyTimeframes] condition-declared JSON 파싱 실패: {}", e.getMessage()); } } /** TIMEFRAME 누락·잘못된 1m 래핑을 전략명·DSL 기준으로 DB 보정 */ private void ensureDslRepaired(GcStrategy strategy) { boolean changed = false; String name = strategy.getName(); String buyJson = strategy.getBuyConditionJson(); String sellJson = strategy.getSellConditionJson(); if (buyJson != null && !buyJson.isBlank() && sellJson != null && !sellJson.isBlank()) { String alignedSell = dslNormalizer.alignSellStartTimeframeToBuy(buyJson, sellJson); if (!alignedSell.equals(sellJson)) { strategy.setSellConditionJson(alignedSell); sellJson = alignedSell; changed = true; } } 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 (sellJson != null && !sellJson.isBlank()) { String fixed = dslNormalizer.normalizeJson(sellJson, 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); } } /** * START 루트 TIMEFRAME(candleTypes)만 수집 — 조건 노드 leftCandleType·내부 1m 기본값은 제외. * * @return JSON 에 START 스코프가 있으면 true (빈 조건이어도) */ private boolean collectStartScopeFromJson(String json, Set out) { if (json == null || json.isBlank()) return false; try { return collectStartScopeFromNode(objectMapper.readTree(json), out); } catch (Exception e) { log.warn("[StrategyTimeframes] JSON 파싱 실패: {}", e.getMessage()); return false; } } private boolean collectStartScopeFromNode(JsonNode node, Set out) { if (node == null || node.isNull()) return false; return collectTimeframesFromNode(node, out); } /** 레거시 DSL — START 래핑 없을 때만 조건 트리·전략명에서 추론 */ private void collectLegacyFromJson(String json, Set out, String strategyName) { if (json == null || json.isBlank()) return; try { collectLegacyFromNode(objectMapper.readTree(json), out, strategyName); } catch (Exception e) { log.warn("[StrategyTimeframes] legacy JSON 파싱 실패: {}", e.getMessage()); addFallbackTimeframe(out, strategyName); } } private void collectLegacyFromNode(JsonNode node, Set out, String strategyName) { if (node == null || node.isNull()) return; Set meaningful = StrategyDslTimeframeNormalizer.collectMeaningfulCandleTypes(node); if (!meaningful.isEmpty()) { out.addAll(meaningful); return; } 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"); } /** * 트리에서 TIMEFRAME 노드를 수집. 발견 시 true. */ private boolean collectTimeframesFromNode(JsonNode node, Set out) { if (node == null || node.isNull()) return false; String type = node.path("type").asText(""); if ("TIMEFRAME".equals(type)) { addTimeframeCandleTypes(node, out); return true; } if ("AND".equals(type) || "OR".equals(type)) { JsonNode children = node.path("children"); if (children.isArray() && !children.isEmpty()) { if (allTimeframeChildren(children)) { for (JsonNode tf : children) { addTimeframeCandleTypes(tf, out); } return true; } boolean found = false; for (JsonNode child : children) { found |= collectTimeframesFromNode(child, out); } return found; } } JsonNode child = node.path("child"); if (!child.isMissingNode() && !child.isNull()) { return collectTimeframesFromNode(child, out); } return false; } private static boolean allTimeframeChildren(JsonNode children) { for (JsonNode c : children) { if (!"TIMEFRAME".equals(c.path("type").asText(""))) return false; } return true; } /** TIMEFRAME 노드 — candleTypes 배열 또는 단일 candleType (stale 1m 제거 반영) */ static void addTimeframeCandleTypes(JsonNode timeframeNode, Set out) { for (String ct : StrategyDslTimeframeNormalizer.resolveStartCandleTypes(timeframeNode)) { out.add(ct); } } /** flow_layout_json.startMeta — 편집기 Logic Expression·START 분봉 (DSL과 동기 저장) */ private Set collectFromFlowLayoutJson(String flowLayoutJson) { Set out = new LinkedHashSet<>(); if (flowLayoutJson == null || flowLayoutJson.isBlank()) return out; try { JsonNode root = objectMapper.readTree(flowLayoutJson); for (String side : new String[] { "buy", "sell" }) { JsonNode startMeta = root.path(side).path("startMeta"); if (!startMeta.isObject()) continue; startMeta.fields().forEachRemaining(entry -> { JsonNode meta = entry.getValue(); JsonNode types = meta.path("candleTypes"); if (types.isArray() && !types.isEmpty()) { for (JsonNode t : types) { String ct = LiveStrategyTimeframeService.normalize(t.asText("")); if (!ct.isBlank()) out.add(ct); } } else { String ct = LiveStrategyTimeframeService.normalize(meta.path("candleType").asText("")); if (!ct.isBlank()) out.add(ct); } }); } } catch (Exception e) { log.warn("[StrategyTimeframes] flow_layout_json 파싱 실패: {}", e.getMessage()); } return out; } }